From cceb490842a40a4884e1ef20bfd699e61d6a315c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 10:58:41 -0800 Subject: [PATCH 1/4] fix(paste): improve table pasting --- packages/super-editor/src/core/InputRule.js | 65 ++++++++++++++ .../InputRule.paste-table.integration.test.js | 90 +++++++++++++++++++ .../src/core/commands/insertContent.test.js | 13 +++ .../importMarkdown.integration.test.js | 15 ++++ .../helpers/markdown/mdastToProseMirror.ts | 12 +++ .../google-docs-paste/google-docs-paste.js | 1 + .../google-docs-paste.test.js | 1 + 7 files changed, 197 insertions(+) create mode 100644 packages/super-editor/src/core/InputRule.paste-table.integration.test.js diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js index 1ceb742263..258f1f393d 100644 --- a/packages/super-editor/src/core/InputRule.js +++ b/packages/super-editor/src/core/InputRule.js @@ -264,6 +264,64 @@ function findParagraphAncestor($from) { return { node: null, depth: -1 }; } +/** + * @param {import('prosemirror-model').Node} tableRow + * @returns {string} + */ +function getTableRowSignature(tableRow) { + const parts = []; + tableRow.forEach((cell) => { + parts.push(`${cell.attrs?.colspan ?? 1}:${cell.attrs?.rowspan ?? 1}`); + }); + return parts.join('|'); +} + +/** + * Browser "highlight copy" can emit table-like HTML where each visual row + * becomes an independent table element. Merge adjacent compatible tables back + * into one table so table editing features (cell selection, resizing) work. + * + * @param {import('prosemirror-model').Node} doc + * @returns {import('prosemirror-model').Node} + */ +function mergeAdjacentTableFragments(doc) { + if (!doc?.childCount) return doc; + + /** @type {import('prosemirror-model').Node[]} */ + const mergedChildren = []; + + doc.forEach((child) => { + const previous = mergedChildren[mergedChildren.length - 1]; + + if (child.type.name !== 'table' || previous?.type.name !== 'table') { + mergedChildren.push(child); + return; + } + + const previousFirstRow = previous.firstChild; + const currentFirstRow = child.firstChild; + if (!previousFirstRow || !currentFirstRow) { + mergedChildren.push(child); + return; + } + + const previousColumnShape = getTableRowSignature(previousFirstRow); + const currentColumnShape = getTableRowSignature(currentFirstRow); + if (previousColumnShape !== currentColumnShape) { + mergedChildren.push(child); + return; + } + + const combinedRows = []; + previous.forEach((row) => combinedRows.push(row)); + child.forEach((row) => combinedRows.push(row)); + + mergedChildren[mergedChildren.length - 1] = previous.type.create(previous.attrs, combinedRows, previous.marks); + }); + + return doc.copy(Fragment.fromArray(mergedChildren)); +} + /** * Handle HTML paste events. * @@ -277,7 +335,14 @@ export function handleHtmlPaste(html, editor, source) { if (source === 'google-docs') cleanedHtml = handleGoogleDocsHtml(html, editor); else cleanedHtml = htmlHandler(html, editor); + // Mark pasted HTML as import content so table parseDOM rules can apply + // import defaults (e.g., default table width to 100%). + if (cleanedHtml?.dataset) { + cleanedHtml.dataset.superdocImport = 'true'; + } + let doc = PMDOMParser.fromSchema(editor.schema).parse(cleanedHtml); + doc = mergeAdjacentTableFragments(doc); doc = wrapTextsInRuns(doc); diff --git a/packages/super-editor/src/core/InputRule.paste-table.integration.test.js b/packages/super-editor/src/core/InputRule.paste-table.integration.test.js new file mode 100644 index 0000000000..64fcae6980 --- /dev/null +++ b/packages/super-editor/src/core/InputRule.paste-table.integration.test.js @@ -0,0 +1,90 @@ +import { beforeAll, afterEach, describe, expect, it } from 'vitest'; +import { handleClipboardPaste, handleHtmlPaste } from './InputRule.js'; +import { initTestEditor, loadTestDataForEditorTests } from '../tests/helpers/helpers.js'; + +let docData; +let editor; + +beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); +}); + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +describe('handleHtmlPaste table import defaults', () => { + it('defaults pasted HTML tables to 100% width', () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + mode: 'docx', + })); + + const handled = handleHtmlPaste( + '
QueryAssessment
AB
', + editor, + ); + + expect(handled).toBe(true); + + const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table'); + expect(tableNode).toBeTruthy(); + expect(tableNode?.attrs?.tableProperties?.tableWidth).toEqual({ + value: 5000, + type: 'pct', + }); + }); + + it('defaults Google Docs HTML tables to 100% width', () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + mode: 'docx', + })); + + const handled = handleClipboardPaste( + { editor, view: editor.view }, + '
QueryAssessment
AB
', + ); + + expect(handled).toBe(true); + + const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table'); + expect(tableNode).toBeTruthy(); + expect(tableNode?.attrs?.tableProperties?.tableWidth).toEqual({ + value: 5000, + type: 'pct', + }); + }); + + it('merges fragmented pasted HTML tables into a single editable table', () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + mode: 'docx', + })); + + const fragmentedHtml = ` +
NameRoleDepartmentStart Date
+
Alice KimManagerOperations2022-03-14
+
Brian LeeDeveloperEngineering2023-01-09
+
Carla GomezDesignerProduct2021-11-22
+
David ChenAnalystFinance2024-06-03
+ `; + + const handled = handleHtmlPaste(fragmentedHtml, editor); + expect(handled).toBe(true); + + const tables = (editor.getJSON().content || []).filter((node) => node.type === 'table'); + expect(tables).toHaveLength(1); + expect(tables[0]?.content).toHaveLength(5); + }); +}); diff --git a/packages/super-editor/src/core/commands/insertContent.test.js b/packages/super-editor/src/core/commands/insertContent.test.js index 38ca141e97..d9e5ef6458 100644 --- a/packages/super-editor/src/core/commands/insertContent.test.js +++ b/packages/super-editor/src/core/commands/insertContent.test.js @@ -295,6 +295,19 @@ describe('insertContent (integration) list export', () => { }); }); + it('defaults imported markdown tables to 100% width', async () => { + const editor = await setupEditor(); + editor.commands.insertContent('| Query | Assessment |\n| --- | --- |\n| A | B |', { contentType: 'markdown' }); + await Promise.resolve(); + + const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table'); + expect(tableNode).toBeTruthy(); + expect(tableNode.attrs?.tableProperties?.tableWidth).toEqual({ + value: 5000, + type: 'pct', + }); + }); + it('normalizes imported HTML table header borders for render and export parity', async () => { const editor = await setupEditor(); editor.commands.insertContent( diff --git a/packages/super-editor/src/core/helpers/importMarkdown.integration.test.js b/packages/super-editor/src/core/helpers/importMarkdown.integration.test.js index 54f7f0ce3c..49ef3bd841 100644 --- a/packages/super-editor/src/core/helpers/importMarkdown.integration.test.js +++ b/packages/super-editor/src/core/helpers/importMarkdown.integration.test.js @@ -126,4 +126,19 @@ More text here. const numberedParagraphs = paragraphs.filter(hasNumbering); expect(numberedParagraphs).toHaveLength(2); }); + + it('defaults markdown tables to 100% width', () => { + const markdown = `| Query | Assessment | +| --- | --- | +| A | B |`; + + const doc = createDocFromMarkdown(markdown, editor); + const firstTable = doc.content.content.find((node) => node.type.name === 'table'); + + expect(firstTable).toBeTruthy(); + expect(firstTable?.attrs?.tableProperties?.tableWidth).toEqual({ + value: 5000, + type: 'pct', + }); + }); }); diff --git a/packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts b/packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts index 1883c3624c..8d0e9aa744 100644 --- a/packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts +++ b/packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts @@ -64,6 +64,10 @@ interface JsonMark { attrs?: Record; } +// OOXML stores percentages in fiftieths of a percent. +// 5000 = 100% table width. +const FULL_WIDTH_TABLE_PCT = 5000; + // --------------------------------------------------------------------------- // Block-level converters // --------------------------------------------------------------------------- @@ -299,6 +303,14 @@ function convertTable(node: MdastTable, ctx: MdastConversionContext): JsonNode { return { type: 'table', + attrs: { + tableProperties: { + tableWidth: { + value: FULL_WIDTH_TABLE_PCT, + type: 'pct', + }, + }, + }, content: rows, }; } diff --git a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js index d323ca1f26..c05b5cbac3 100644 --- a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js +++ b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.js @@ -23,6 +23,7 @@ export const handleGoogleDocsHtml = (html, editor, view) => { const htmlWithMergedLists = mergeSeparateLists(tempDiv); const flattenHtml = flattenListsInHtml(htmlWithMergedLists, editor); + flattenHtml.dataset.superdocImport = 'true'; let doc = DOMParser.fromSchema(editor.schema).parse(flattenHtml); doc = wrapTextsInRuns(doc); diff --git a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.test.js b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.test.js index dc3ec8defb..a420a26e7c 100644 --- a/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.test.js +++ b/packages/super-editor/src/core/inputRules/google-docs-paste/google-docs-paste.test.js @@ -82,6 +82,7 @@ describe('handleGoogleDocsHtml', () => { expect(generateNewListDefinitionMock).toHaveBeenCalledTimes(2); const parsedNode = parseSpy.mock.calls[0][0]; + expect(parsedNode.dataset.superdocImport).toBe('true'); const paragraphs = Array.from(parsedNode.querySelectorAll('p[data-num-id]')); expect(paragraphs).toHaveLength(2); expect(paragraphs[0].getAttribute('data-num-id')).toBe('410'); From f9558f97a9ca4ff403a664d73c22b26bad6678b3 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 11:23:01 -0800 Subject: [PATCH 2/4] fix(tables): column dragging on pasted tables --- .../src/components/TableResizeOverlay.test.js | 28 +++++ .../src/components/TableResizeOverlay.vue | 25 +++- .../tests/tables/paste-html-resize.spec.ts | 118 ++++++++++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/behavior/tests/tables/paste-html-resize.spec.ts diff --git a/packages/super-editor/src/components/TableResizeOverlay.test.js b/packages/super-editor/src/components/TableResizeOverlay.test.js index f9f13096ce..c914f20f54 100644 --- a/packages/super-editor/src/components/TableResizeOverlay.test.js +++ b/packages/super-editor/src/components/TableResizeOverlay.test.js @@ -409,6 +409,34 @@ describe('TableResizeOverlay', () => { wrapper.unmount(); }); + + it('should normalize clamped min width while keeping min below width', async () => { + const metadata = { + columns: [ + { i: 0, x: 0, w: 10, min: 10, r: 1 }, + { i: 1, x: 10, w: 2, min: 2, r: 1 }, + ], + }; + + const tableElement = createMockTableElement(metadata); + const wrapper = mount(TableResizeOverlay, { + props: { + editor: createMockEditor(), + visible: true, + tableElement, + }, + }); + + await nextTick(); + + const [firstCol, secondCol] = wrapper.vm.tableMetadata.columns; + expect(firstCol.min).toBeLessThan(firstCol.w); + expect(secondCol.min).toBeLessThan(secondCol.w); + expect(firstCol.min).toBe(9); + expect(secondCol.min).toBe(1); + + wrapper.unmount(); + }); }); // ========================================================================== diff --git a/packages/super-editor/src/components/TableResizeOverlay.vue b/packages/super-editor/src/components/TableResizeOverlay.vue index 911a9880dd..6f55387957 100644 --- a/packages/super-editor/src/components/TableResizeOverlay.vue +++ b/packages/super-editor/src/components/TableResizeOverlay.vue @@ -84,6 +84,27 @@ const overlayRect = ref(null); */ const tableMetadata = ref(null); +/** + * Normalize metadata-provided minimum width so resize remains possible. + * Some imported tables report min == current width (e.g. 100/100), which + * clamps drag delta to zero and makes columns feel "stuck". + * + * @param {number} width - Current column width in layout pixels + * @param {number} rawMin - Raw minimum width from metadata + * @returns {number} + */ +function normalizeColumnMinWidth(width, rawMin) { + const safeWidth = Math.max(1, Number(width) || 1); + const safeMin = Math.max(1, Number(rawMin) || 1); + if (safeMin < safeWidth) return safeMin; + + // Keep at least a practical shrink budget while guaranteeing min < width. + if (safeWidth <= 2) return 1; + + const candidate = Math.max(1, Math.max(25, Math.floor(safeWidth * 0.5))); + return Math.min(safeWidth - 1, candidate); +} + /** * Get the editor's zoom level for coordinate transformations. * @@ -648,10 +669,10 @@ function parseTableMetadata() { ); }) .map((col) => ({ + w: Math.max(1, col.w), + min: normalizeColumnMinWidth(col.w, col.min), i: col.i, x: Math.max(0, col.x), - w: Math.max(1, col.w), - min: Math.max(1, col.min), r: col.r, })); diff --git a/tests/behavior/tests/tables/paste-html-resize.spec.ts b/tests/behavior/tests/tables/paste-html-resize.spec.ts new file mode 100644 index 0000000000..71d580dee7 --- /dev/null +++ b/tests/behavior/tests/tables/paste-html-resize.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import type { Locator, Page } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const SINGLE_HTML_TABLE = ` + + + + + + + + +
NameRoleDepartmentStart Date
Alice KimManagerOperations2022-03-14
Brian LeeDeveloperEngineering2023-01-09
Carla GomezDesignerProduct2021-11-22
David ChenAnalystFinance2024-06-03
+`; + +async function hoverColumnBoundary(page: Page, target: number | 'right-edge') { + const pos = await page.evaluate((t) => { + const frag = document.querySelector('.superdoc-table-fragment[data-table-boundaries]'); + if (!frag) throw new Error('No table fragment with boundaries found'); + const { columns } = JSON.parse(frag.getAttribute('data-table-boundaries')!); + const col = t === 'right-edge' ? columns[columns.length - 1] : columns[t]; + if (!col) throw new Error(`Column ${t} not found`); + const rect = frag.getBoundingClientRect(); + const offset = t === 'right-edge' ? -2 : 0; + return { x: rect.left + col.x + col.w + offset, y: rect.top + rect.height / 2 }; + }, target); + + await page.mouse.move(pos.x, pos.y); +} + +async function dragHandle(page: Page, handle: Locator, deltaX: number) { + const box = await handle.boundingBox(); + if (!box) throw new Error('Resize handle not visible'); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(x + (deltaX * i) / 10, y); + await page.waitForTimeout(20); + } + await page.mouse.up(); +} + +async function getTableGrid(page: Page) { + return page.evaluate(() => { + const doc = (window as any).editor.state.doc; + let grid: any = null; + doc.descendants((node: any) => { + if (grid === null && node.type.name === 'table') { + grid = node.attrs.grid; + } + }); + return grid; + }); +} + +test('pasted HTML table can be column-resized', async ({ superdoc }) => { + await superdoc.page.evaluate((html) => { + const editor = (window as any).editor; + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/html', html); + dataTransfer.setData('text/plain', ''); + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: dataTransfer, + }); + editor.view.dom.dispatchEvent(pasteEvent); + }, SINGLE_HTML_TABLE); + await superdoc.waitForStable(); + + const initialState = await superdoc.page.evaluate(() => { + const tableFragment = document.querySelector('.superdoc-table-fragment'); + const hasPmStartMarker = Boolean(tableFragment?.querySelector('[data-pm-start]')); + const boundariesAttr = tableFragment?.getAttribute('data-table-boundaries') ?? null; + const boundaries = boundariesAttr ? JSON.parse(boundariesAttr) : null; + + const doc = (window as any).editor.state.doc; + let tableCount = 0; + let grid = null as unknown; + doc.descendants((node: any) => { + if (node.type.name === 'table') { + tableCount += 1; + if (grid === null) grid = node.attrs.grid; + } + }); + + return { hasPmStartMarker, tableCount, grid, boundaries }; + }); + + expect(initialState.tableCount).toBe(1); + expect(initialState.hasPmStartMarker).toBe(true); + expect(initialState.boundaries?.columns?.length).toBe(4); + + // Retry once to reduce flake from hover/drag timing in headless browsers. + for (let attempt = 0; attempt < 2; attempt += 1) { + await hoverColumnBoundary(superdoc.page, 0); + await superdoc.waitForStable(); + + const handle = superdoc.page.locator('.resize-handle[data-boundary-type="inner"]').first(); + await expect(handle).toBeAttached({ timeout: 5000 }); + + await dragHandle(superdoc.page, handle, 120); + await superdoc.waitForStable(); + + const grid = await getTableGrid(superdoc.page); + if (Array.isArray(grid) && grid.length === 4) { + return; + } + } + + const grid = await getTableGrid(superdoc.page); + expect(grid).toHaveLength(4); +}); From a73ff2fee2d14543268ca690fe736a2ba6b21f27 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 12:39:15 -0800 Subject: [PATCH 3/4] test(behavior): add behavior tests for cell color --- .../context-menu/CellBackgroundPicker.vue | 43 ++++ .../src/components/context-menu/constants.js | 3 + .../src/components/context-menu/menuItems.js | 13 + .../tests/CellBackgroundPicker.test.js | 102 ++++++++ .../context-menu/tests/menuItems.test.js | 68 ++++++ .../context-menu/tests/testHelpers.js | 2 + .../context-menu/tests/utils.test.js | 117 +++++++++ .../src/components/context-menu/utils.js | 37 +++ .../toolbar/color-dropdown-helpers.js | 2 +- .../toolbar/color-dropdown-helpers.test.js | 47 ++++ .../tests/tables/cell-background.spec.ts | 230 ++++++++++++++++++ 11 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue create mode 100644 packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js create mode 100644 packages/super-editor/src/components/toolbar/color-dropdown-helpers.test.js create mode 100644 tests/behavior/tests/tables/cell-background.spec.ts diff --git a/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue b/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue new file mode 100644 index 0000000000..826cb1e28c --- /dev/null +++ b/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/super-editor/src/components/context-menu/constants.js b/packages/super-editor/src/components/context-menu/constants.js index c85cdf7b97..8a8f3fad52 100644 --- a/packages/super-editor/src/components/context-menu/constants.js +++ b/packages/super-editor/src/components/context-menu/constants.js @@ -12,6 +12,7 @@ import copyIconSvg from '@superdoc/common/icons/copy-solid.svg?raw'; import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw'; import checkIconSvg from '@superdoc/common/icons/check-solid.svg?raw'; import xMarkIconSvg from '@superdoc/common/icons/xmark-solid.svg?raw'; +import paintRollerIconSvg from '@superdoc/common/icons/paint-roller-solid.svg?raw'; export const ICONS = { addRowBefore: plusIconSvg, @@ -35,6 +36,7 @@ export const ICONS = { removeDocumentSection: trashIconSvg, trackChangesAccept: checkIconSvg, trackChangesReject: xMarkIconSvg, + cellBackground: paintRollerIconSvg, }; // Table actions constant @@ -62,6 +64,7 @@ export const TEXTS = { createDocumentSection: 'Create section', trackChangesAccept: 'Accept change', trackChangesReject: 'Reject change', + cellBackground: 'Cell background', }; export const tableActionsOptions = [ diff --git a/packages/super-editor/src/components/context-menu/menuItems.js b/packages/super-editor/src/components/context-menu/menuItems.js index 3e05803041..e935e9c0cf 100644 --- a/packages/super-editor/src/components/context-menu/menuItems.js +++ b/packages/super-editor/src/components/context-menu/menuItems.js @@ -2,6 +2,7 @@ import TableGrid from '../toolbar/TableGrid.vue'; import AIWriter from '../toolbar/AIWriter.vue'; import TableActions from '../toolbar/TableActions.vue'; import LinkInput from '../toolbar/LinkInput.vue'; +import CellBackgroundPicker from './CellBackgroundPicker.vue'; import { TEXTS, ICONS, TRIGGERS } from './constants.js'; import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js'; import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js'; @@ -107,6 +108,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true) isInTable: context.isInTable ?? false, isInSectionNode: context.isInSectionNode ?? false, isTrackedChange: context.isTrackedChange ?? false, + isCellSelection: context.isCellSelection ?? false, + tableSelectionKind: context.tableSelectionKind ?? null, clipboardContent: context.clipboardContent ?? { hasContent: false }, selectedText: context.selectedText ?? '', hasSelection: context.hasSelection ?? Boolean(context.selectedText), @@ -248,6 +251,16 @@ export function getItems(context, customItems = [], includeDefaultItems = true) return allowedTriggers.includes(trigger) && isInTable; }, }, + { + id: 'cell-background', + label: TEXTS.cellBackground, + icon: ICONS.cellBackground, + component: CellBackgroundPicker, + isDefault: true, + showWhen: (context) => { + return context.trigger === TRIGGERS.click && (context.isCellSelection || context.isInTable); + }, + }, ], }, { diff --git a/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js b/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js new file mode 100644 index 0000000000..65f17c404d --- /dev/null +++ b/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; + +vi.mock('@extensions/table/tableHelpers/isCellSelection.js', () => ({ + isCellSelection: vi.fn(() => false), +})); + +vi.mock('@extensions/table/tableHelpers/cellAround.js', () => ({ + cellAround: vi.fn(() => null), +})); + +vi.mock('../../toolbar/IconGrid.vue', () => ({ + default: { + props: ['icons', 'customIcons', 'activeColor', 'hasNoneIcon'], + emits: ['select'], + template: '
', + }, +})); + +vi.mock('../../toolbar/color-dropdown-helpers.js', () => ({ + icons: [[{ label: 'black', value: '#000000', icon: '', style: {} }]], +})); + +import CellBackgroundPicker from '../CellBackgroundPicker.vue'; +import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js'; +import { cellAround } from '@extensions/table/tableHelpers/cellAround.js'; + +describe('CellBackgroundPicker', () => { + let mockEditor; + let closePopover; + + beforeEach(() => { + vi.clearAllMocks(); + + closePopover = vi.fn(); + mockEditor = { + state: { + selection: { + $from: { depth: 3 }, + }, + }, + commands: { + setCellSelection: vi.fn(), + setCellBackground: vi.fn(), + }, + }; + }); + + function mountPicker() { + return mount(CellBackgroundPicker, { + props: { editor: mockEditor, closePopover }, + }); + } + + it('should call setCellBackground directly when selection is already a CellSelection', () => { + isCellSelection.mockReturnValue(true); + + const wrapper = mountPicker(); + wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#FF0000'); + + expect(mockEditor.commands.setCellSelection).not.toHaveBeenCalled(); + expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#FF0000'); + expect(closePopover).toHaveBeenCalled(); + }); + + it('should select the cell first when cursor is inside a cell without CellSelection', () => { + isCellSelection.mockReturnValue(false); + cellAround.mockReturnValue({ pos: 42 }); + + const wrapper = mountPicker(); + wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#00FF00'); + + expect(cellAround).toHaveBeenCalledWith(mockEditor.state.selection.$from); + expect(mockEditor.commands.setCellSelection).toHaveBeenCalledWith({ + anchorCell: 42, + headCell: 42, + }); + expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#00FF00'); + expect(closePopover).toHaveBeenCalled(); + }); + + it('should still attempt setCellBackground when cellAround returns null', () => { + isCellSelection.mockReturnValue(false); + cellAround.mockReturnValue(null); + + const wrapper = mountPicker(); + wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#0000FF'); + + expect(mockEditor.commands.setCellSelection).not.toHaveBeenCalled(); + expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#0000FF'); + expect(closePopover).toHaveBeenCalled(); + }); + + it('should map "none" to null for removing background', () => { + isCellSelection.mockReturnValue(true); + + const wrapper = mountPicker(); + wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', 'none'); + + expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js index 71944f971a..b7dd341ad6 100644 --- a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js +++ b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js @@ -30,6 +30,7 @@ vi.mock('../constants.js', () => ({ paste: 'Paste', trackChangesAccept: 'Accept Tracked Changes', trackChangesReject: 'Reject Tracked Changes', + cellBackground: 'Cell background', }, ICONS: { ai: 'ai-icon', @@ -40,6 +41,7 @@ vi.mock('../constants.js', () => ({ cut: 'cut-icon', copy: 'copy-icon', paste: 'paste-icon', + cellBackground: 'cell-background-icon', }, TRIGGERS: { slash: 'slash', @@ -51,6 +53,7 @@ vi.mock('../../toolbar/TableGrid.vue', () => ({ default: { template: '
Table vi.mock('../../toolbar/AIWriter.vue', () => ({ default: { template: '
AIWriter
' } })); vi.mock('../../toolbar/TableActions.vue', () => ({ default: { template: '
TableActions
' } })); vi.mock('../../toolbar/LinkInput.vue', () => ({ default: { template: '
LinkInput
' } })); +vi.mock('../CellBackgroundPicker.vue', () => ({ default: { template: '
CellBackgroundPicker
' } })); vi.mock('../../../core/utilities/clipboardUtils.js', () => ({ readClipboardRaw: clipboardMocks.readClipboardRaw, @@ -575,6 +578,71 @@ describe('menuItems.js', () => { }); }); + describe('getItems - cell selection context', () => { + it('should show cell-background when isCellSelection is true and trigger is click', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + isCellSelection: true, + tableSelectionKind: 'cells', + isInTable: true, + }); + + const sections = getItems(mockContext); + const generalSection = sections.find((s) => s.id === 'general'); + const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background'); + + expect(cellBgItem).toBeDefined(); + expect(cellBgItem.label).toBe('Cell background'); + }); + + it('should show cell-background when right-clicking in a table cell without CellSelection', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + isCellSelection: false, + isInTable: true, + }); + + const sections = getItems(mockContext); + const generalSection = sections.find((s) => s.id === 'general'); + const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background'); + + expect(cellBgItem).toBeDefined(); + }); + + it('should hide cell-background when not in a table at all', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + isCellSelection: false, + isInTable: false, + }); + + const sections = getItems(mockContext); + const generalSection = sections.find((s) => s.id === 'general'); + const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background'); + + expect(cellBgItem).toBeUndefined(); + }); + + it('should hide cell-background on slash trigger even with cell selection', () => { + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.slash, + isCellSelection: true, + tableSelectionKind: 'row', + isInTable: true, + }); + + const sections = getItems(mockContext); + const allItems = sections.flatMap((s) => s.items); + const cellBgItem = allItems.find((item) => item.id === 'cell-background'); + + expect(cellBgItem).toBeUndefined(); + }); + }); + describe('getItems - paste selection preservation (SD-1302)', () => { /** * Creates a mock editor with doc.content.size and selection.constructor.create diff --git a/packages/super-editor/src/components/context-menu/tests/testHelpers.js b/packages/super-editor/src/components/context-menu/tests/testHelpers.js index 1b646b9b92..ded839ef1b 100644 --- a/packages/super-editor/src/components/context-menu/tests/testHelpers.js +++ b/packages/super-editor/src/components/context-menu/tests/testHelpers.js @@ -175,6 +175,8 @@ export function createMockContext(options = {}) { activeMarks: [], isTrackedChange: false, trackedChanges: [], + isCellSelection: false, + tableSelectionKind: null, documentMode: 'editing', canUndo: false, canRedo: false, diff --git a/packages/super-editor/src/components/context-menu/tests/utils.test.js b/packages/super-editor/src/components/context-menu/tests/utils.test.js index 1894ed783b..119b5d260e 100644 --- a/packages/super-editor/src/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/components/context-menu/tests/utils.test.js @@ -33,17 +33,34 @@ vi.mock('@core/commands/list-helpers', () => ({ isList: vi.fn(() => false), })); +vi.mock('@extensions/table/tableHelpers/isCellSelection.js', () => ({ + isCellSelection: vi.fn(() => false), +})); + +vi.mock('prosemirror-tables', () => ({ + selectedRect: vi.fn(() => ({ + top: 0, + bottom: 2, + left: 0, + right: 3, + map: { height: 4, width: 3 }, + })), +})); + import { getEditorContext, getPropsByItemId, __getStructureFromResolvedPosForTest, __isCollaborationEnabledForTest, + __getCellSelectionInfoForTest, } from '../utils.js'; import { isList } from '@core/commands/list-helpers'; import { readFromClipboard } from '../../../core/utilities/clipboardUtils.js'; import { selectionHasNodeOrMark } from '../../cursor-helpers.js'; import { undoDepth, redoDepth } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; +import { isCellSelection as isCellSelectionMock } from '@extensions/table/tableHelpers/isCellSelection.js'; +import { selectedRect as selectedRectMock } from 'prosemirror-tables'; // Get the mocked functions const mockReadFromClipboard = vi.mocked(readFromClipboard); @@ -103,6 +120,8 @@ describe('utils.js', () => { isInTable: false, isInList: false, isInSectionNode: false, + isCellSelection: false, + tableSelectionKind: null, currentNodeType: 'paragraph', activeMarks: [], @@ -518,6 +537,104 @@ describe('utils.js', () => { }); }); + describe('cell selection detection', () => { + beforeEach(() => { + isCellSelectionMock.mockReturnValue(false); + selectedRectMock.mockReturnValue({ + top: 0, + bottom: 2, + left: 0, + right: 3, + map: { height: 4, width: 3 }, + }); + }); + + it('should return isCellSelection false for non-cell selection', () => { + isCellSelectionMock.mockReturnValue(false); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: false, tableSelectionKind: null }); + }); + + it('should detect cells kind for partial cell selection', () => { + isCellSelectionMock.mockReturnValue(true); + selectedRectMock.mockReturnValue({ + top: 0, + bottom: 1, + left: 0, + right: 2, + map: { height: 4, width: 3 }, + }); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: true, tableSelectionKind: 'cells' }); + }); + + it('should detect row kind when all columns selected', () => { + isCellSelectionMock.mockReturnValue(true); + selectedRectMock.mockReturnValue({ + top: 1, + bottom: 2, + left: 0, + right: 3, + map: { height: 4, width: 3 }, + }); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: true, tableSelectionKind: 'row' }); + }); + + it('should detect column kind when all rows selected', () => { + isCellSelectionMock.mockReturnValue(true); + selectedRectMock.mockReturnValue({ + top: 0, + bottom: 4, + left: 1, + right: 2, + map: { height: 4, width: 3 }, + }); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: true, tableSelectionKind: 'column' }); + }); + + it('should detect table kind when all rows and columns selected', () => { + isCellSelectionMock.mockReturnValue(true); + selectedRectMock.mockReturnValue({ + top: 0, + bottom: 4, + left: 0, + right: 3, + map: { height: 4, width: 3 }, + }); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: true, tableSelectionKind: 'table' }); + }); + + it('should fall back to cells when selectedRect throws', () => { + isCellSelectionMock.mockReturnValue(true); + selectedRectMock.mockImplementation(() => { + throw new Error('no cell selection'); + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = __getCellSelectionInfoForTest(mockEditor.state); + + expect(result).toEqual({ isCellSelection: true, tableSelectionKind: 'cells' }); + expect(warnSpy).toHaveBeenCalledWith( + '[ContextMenu] Unable to resolve cell selection rectangle:', + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + }); + describe('internal helpers', () => { it('should detect structure from resolved position', () => { const state = { diff --git a/packages/super-editor/src/components/context-menu/utils.js b/packages/super-editor/src/components/context-menu/utils.js index ae916cb873..cc03dfb5b4 100644 --- a/packages/super-editor/src/components/context-menu/utils.js +++ b/packages/super-editor/src/components/context-menu/utils.js @@ -8,6 +8,8 @@ import { collectTrackedChangesForContext, } from '@extensions/track-changes/permission-helpers.js'; import { isList } from '@core/commands/list-helpers'; +import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js'; +import { selectedRect } from 'prosemirror-tables'; /** * Get props by item id * @@ -122,6 +124,8 @@ export async function getEditorContext(editor, event) { selectionHasNodeOrMark(state, 'documentSection', { requireEnds: true }); const currentNodeType = node?.type?.name || null; + const cellSelectionInfo = getCellSelectionInfo(state); + const activeMarks = []; let trackedChangeId = null; @@ -185,6 +189,8 @@ export async function getEditorContext(editor, event) { isInTable, isInList, isInSectionNode, + isCellSelection: cellSelectionInfo.isCellSelection, + tableSelectionKind: cellSelectionInfo.tableSelectionKind, currentNodeType, activeMarks, isTrackedChange, @@ -294,6 +300,36 @@ function selectionIncludesListParagraph(state) { return found; } +function getCellSelectionInfo(state) { + if (!isCellSelection(state.selection)) { + return { isCellSelection: false, tableSelectionKind: null }; + } + + let tableSelectionKind = 'cells'; + try { + const rect = selectedRect(state); + const selectedRows = rect.bottom - rect.top; + const selectedCols = rect.right - rect.left; + const totalRows = rect.map.height; + const totalCols = rect.map.width; + + const allRows = selectedRows === totalRows; + const allCols = selectedCols === totalCols; + + if (allRows && allCols) { + tableSelectionKind = 'table'; + } else if (allCols) { + tableSelectionKind = 'row'; + } else if (allRows) { + tableSelectionKind = 'column'; + } + } catch (error) { + console.warn('[ContextMenu] Unable to resolve cell selection rectangle:', error); + } + + return { isCellSelection: true, tableSelectionKind }; +} + function getStructureFromResolvedPos(state, pos) { try { const $pos = state.doc.resolve(pos); @@ -336,4 +372,5 @@ function getStructureFromResolvedPos(state, pos) { export { getStructureFromResolvedPos as __getStructureFromResolvedPosForTest, isCollaborationEnabled as __isCollaborationEnabledForTest, + getCellSelectionInfo as __getCellSelectionInfoForTest, }; diff --git a/packages/super-editor/src/components/toolbar/color-dropdown-helpers.js b/packages/super-editor/src/components/toolbar/color-dropdown-helpers.js index 95c3e62075..262ed1d771 100644 --- a/packages/super-editor/src/components/toolbar/color-dropdown-helpers.js +++ b/packages/super-editor/src/components/toolbar/color-dropdown-helpers.js @@ -37,7 +37,7 @@ export const renderColorOptions = (superToolbar, button, customIcons = [], hasNo ]); }; -const icons = [ +export const icons = [ [ makeColorOption('#111111', 'black'), makeColorOption('#333333', 'dark gray'), diff --git a/packages/super-editor/src/components/toolbar/color-dropdown-helpers.test.js b/packages/super-editor/src/components/toolbar/color-dropdown-helpers.test.js new file mode 100644 index 0000000000..d74aea6a53 --- /dev/null +++ b/packages/super-editor/src/components/toolbar/color-dropdown-helpers.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeColorOption, icons, getAvailableColorOptions, renderColorOptions } from './color-dropdown-helpers.js'; + +describe('color-dropdown-helpers', () => { + it('exports color icons as a non-empty 2D collection', () => { + expect(Array.isArray(icons)).toBe(true); + expect(icons.length).toBeGreaterThan(0); + expect(Array.isArray(icons[0])).toBe(true); + expect(icons[0].length).toBeGreaterThan(0); + expect(icons[0][0]).toMatchObject({ + label: expect.any(String), + value: expect.any(String), + icon: expect.anything(), + style: expect.any(Object), + }); + }); + + it('flattens every icon value in getAvailableColorOptions', () => { + const expected = icons.flat().map((item) => item.value); + expect(getAvailableColorOptions()).toEqual(expected); + }); + + it('creates color options with expected shape', () => { + const option = makeColorOption('#ABCDEF', 'custom'); + expect(option).toMatchObject({ + label: 'custom', + value: '#ABCDEF', + style: { color: '#ABCDEF' }, + }); + }); + + it('emits the selected color and closes the dropdown', () => { + const emitCommand = vi.fn(); + const button = { + iconColor: { value: null }, + expand: { value: true }, + }; + + const vnode = renderColorOptions({ emitCommand }, button); + const onSelect = vnode.children[0].props.onSelect; + onSelect('#00FF00'); + + expect(button.iconColor.value).toBe('#00FF00'); + expect(button.expand.value).toBe(false); + expect(emitCommand).toHaveBeenCalledWith({ item: button, argument: '#00FF00' }); + }); +}); diff --git a/tests/behavior/tests/tables/cell-background.spec.ts b/tests/behavior/tests/tables/cell-background.spec.ts new file mode 100644 index 0000000000..f5bf49253c --- /dev/null +++ b/tests/behavior/tests/tables/cell-background.spec.ts @@ -0,0 +1,230 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +/** + * Helper: read the `background` attribute of every table cell in document order. + * Returns an array like [null, { color: 'FF0000' }, null, …]. + */ +async function getCellBackgrounds(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const editor = (window as any).editor; + const backgrounds: (Record | null)[] = []; + editor.state.doc.descendants((node: any) => { + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + backgrounds.push(node.attrs.background ?? null); + } + }); + return backgrounds; + }); +} + +/** + * Helper: create a CellSelection spanning `anchorCellIndex` → `headCellIndex` + * (0-based indices into the flat list of cells). + */ +async function selectCells(page: import('@playwright/test').Page, anchorCellIndex: number, headCellIndex: number) { + await page.evaluate( + ({ anchor, head }) => { + const editor = (window as any).editor; + const positions: number[] = []; + editor.state.doc.descendants((node: any, pos: number) => { + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + positions.push(pos); + } + }); + if (positions[anchor] === undefined || positions[head] === undefined) { + throw new Error(`Cell index out of range: anchor=${anchor}, head=${head}, total=${positions.length}`); + } + editor.commands.setCellSelection({ anchorCell: positions[anchor], headCell: positions[head] }); + }, + { anchor: anchorCellIndex, head: headCellIndex }, + ); +} + +/** + * Helper: right-click on a target to open the context menu, optionally restore + * a CellSelection (the right-click handler resets it to a TextSelection), then + * pick "Cell background" → color swatch. + * + * @param colorLabel - aria-label of the color option (e.g. "red", "black") + * @param restoreCellSelection - optional [anchor, head] cell indices to restore after opening + */ +async function applyCellBackgroundViaContextMenu( + superdoc: any, + clickTarget: import('@playwright/test').Locator, + colorLabel: string, + restoreCellSelection?: [number, number], +) { + const box = await clickTarget.boundingBox(); + if (!box) throw new Error('Click target not visible'); + + // Right-click to open the context menu (this resets CellSelection to TextSelection) + await superdoc.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + // Restore CellSelection if needed — the menu is already open with isInTable=true + if (restoreCellSelection) { + await selectCells(superdoc.page, restoreCellSelection[0], restoreCellSelection[1]); + } + + // Click "Cell background" menu item + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + const cellBgItem = menu.locator('.context-menu-item').filter({ hasText: 'Cell background' }); + await cellBgItem.click(); + await superdoc.waitForStable(); + + // Pick the color from the popover grid + const colorOption = superdoc.page.locator(`.options-grid-wrap [aria-label="${colorLabel}"]`); + await expect(colorOption).toBeVisible({ timeout: 3000 }); + await colorOption.click(); + await superdoc.waitForStable(); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test.describe('cell background via context menu', () => { + test('apply background to a multi-cell selection across rows', async ({ superdoc }) => { + // 3×3 table, label cells + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + const labels = ['A1', 'B1', 'C1', 'A2', 'B2', 'C2', 'A3', 'B3', 'C3']; + for (let i = 0; i < labels.length; i++) { + await superdoc.type(labels[i]); + if (i < labels.length - 1) await superdoc.press('Tab'); + } + await superdoc.waitForStable(); + + // Select 2×2 block: B1, C1, B2, C2 (anchor=B1 index 1, head=C2 index 5) + await selectCells(superdoc.page, 1, 5); + await superdoc.waitForStable(); + + // Open context menu on the selection and apply red background. + // Restore the CellSelection after right-click (the context menu handler resets it). + const targetLine = superdoc.page.locator('.superdoc-line').filter({ hasText: 'B1' }).first(); + await applyCellBackgroundViaContextMenu(superdoc, targetLine, 'red', [1, 5]); + + const backgrounds = await getCellBackgrounds(superdoc.page); + // cells: A1(0) B1(1) C1(2) A2(3) B2(4) C2(5) A3(6) B3(7) C3(8) + // Selected 2×2 block: B1(1), C1(2), B2(4), C2(5) + expect(backgrounds[0]).toBeNull(); // A1 — untouched + expect(backgrounds[1]).toEqual({ color: 'D2003F' }); // B1 + expect(backgrounds[2]).toEqual({ color: 'D2003F' }); // C1 + expect(backgrounds[3]).toBeNull(); // A2 — untouched + expect(backgrounds[4]).toEqual({ color: 'D2003F' }); // B2 + expect(backgrounds[5]).toEqual({ color: 'D2003F' }); // C2 + expect(backgrounds[6]).toBeNull(); // A3 + expect(backgrounds[7]).toBeNull(); // B3 + expect(backgrounds[8]).toBeNull(); // C3 + }); + + test('apply background to a full column selection', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + const labels = ['A1', 'B1', 'C1', 'A2', 'B2', 'C2', 'A3', 'B3', 'C3']; + for (let i = 0; i < labels.length; i++) { + await superdoc.type(labels[i]); + if (i < labels.length - 1) await superdoc.press('Tab'); + } + await superdoc.waitForStable(); + + // Select entire middle column (B1→B3 — anchor=1, head=7) + await selectCells(superdoc.page, 1, 7); + await superdoc.waitForStable(); + + // Restore column selection after right-click + const targetLine = superdoc.page.locator('.superdoc-line').filter({ hasText: 'B2' }).first(); + await applyCellBackgroundViaContextMenu(superdoc, targetLine, 'forest green', [1, 7]); + + const backgrounds = await getCellBackgrounds(superdoc.page); + // Column B = indices 1, 4, 7; all others should be null + for (let i = 0; i < 9; i++) { + if ([1, 4, 7].includes(i)) { + expect(backgrounds[i]).toEqual({ color: '055432' }); + } else { + expect(backgrounds[i]).toBeNull(); + } + } + }); + + test('apply background to a single cell via right-click (no CellSelection)', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type in cells so we can identify them + await superdoc.type('A1'); + await superdoc.press('Tab'); + await superdoc.type('B1'); + await superdoc.press('Tab'); + await superdoc.type('A2'); + await superdoc.press('Tab'); + await superdoc.type('B2'); + await superdoc.waitForStable(); + + // Click inside cell A2 (just place cursor, no CellSelection) + const a2Line = superdoc.page.locator('.superdoc-line').filter({ hasText: 'A2' }).first(); + const a2Box = await a2Line.boundingBox(); + if (!a2Box) throw new Error('A2 line not visible'); + await superdoc.page.mouse.click(a2Box.x + a2Box.width / 2, a2Box.y + a2Box.height / 2); + await superdoc.waitForStable(); + + // Right-click → Cell background → pick a color (no CellSelection restore needed) + await applyCellBackgroundViaContextMenu(superdoc, a2Line, 'navy blue'); + + const backgrounds = await getCellBackgrounds(superdoc.page); + // Only A2 (index 2) should be coloured + expect(backgrounds[0]).toBeNull(); // A1 + expect(backgrounds[1]).toBeNull(); // B1 + expect(backgrounds[2]).toEqual({ color: '063E7E' }); // A2 + expect(backgrounds[3]).toBeNull(); // B2 + }); + + test('remove background with "None" option', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('A1'); + await superdoc.press('Tab'); + await superdoc.type('B1'); + await superdoc.press('Tab'); + await superdoc.type('A2'); + await superdoc.press('Tab'); + await superdoc.type('B2'); + await superdoc.waitForStable(); + + // Select cell A1 and apply a colour + await selectCells(superdoc.page, 0, 0); + await superdoc.waitForStable(); + + const a1Line = superdoc.page.locator('.superdoc-line').filter({ hasText: 'A1' }).first(); + await applyCellBackgroundViaContextMenu(superdoc, a1Line, 'red', [0, 0]); + + let backgrounds = await getCellBackgrounds(superdoc.page); + expect(backgrounds[0]).toEqual({ color: 'D2003F' }); + + // Now remove it via "None" + const a1Box = await a1Line.boundingBox(); + if (!a1Box) throw new Error('A1 line not visible'); + await superdoc.page.mouse.click(a1Box.x + a1Box.width / 2, a1Box.y + a1Box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + // Restore single-cell CellSelection so setCellAttr works + await selectCells(superdoc.page, 0, 0); + + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + await menu.locator('.context-menu-item').filter({ hasText: 'Cell background' }).click(); + await superdoc.waitForStable(); + + const noneOption = superdoc.page.locator('.options-grid-wrap .none-option'); + await expect(noneOption).toBeVisible({ timeout: 3000 }); + await noneOption.click(); + await superdoc.waitForStable(); + + backgrounds = await getCellBackgrounds(superdoc.page); + expect(backgrounds[0]).toBeNull(); + }); +}); From 1b8e776d4502a72c09dcb11a347b02c2964e7d73 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 12:47:59 -0800 Subject: [PATCH 4/4] chore: fixes --- .../src/components/context-menu/CellBackgroundPicker.vue | 7 +++++-- .../context-menu/tests/CellBackgroundPicker.test.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue b/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue index 826cb1e28c..1d5a1facc2 100644 --- a/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue +++ b/packages/super-editor/src/components/context-menu/CellBackgroundPicker.vue @@ -31,9 +31,12 @@ const ensureCellSelection = () => { }; const handleSelect = (color) => { - const value = color === 'none' ? null : color; ensureCellSelection(); - props.editor.commands.setCellBackground(value); + if (color === 'none') { + props.editor.commands.setCellAttr('background', null); + } else { + props.editor.commands.setCellBackground(color); + } props.closePopover(); }; diff --git a/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js b/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js index 65f17c404d..aef8753427 100644 --- a/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js +++ b/packages/super-editor/src/components/context-menu/tests/CellBackgroundPicker.test.js @@ -42,6 +42,7 @@ describe('CellBackgroundPicker', () => { commands: { setCellSelection: vi.fn(), setCellBackground: vi.fn(), + setCellAttr: vi.fn(), }, }; }); @@ -91,12 +92,14 @@ describe('CellBackgroundPicker', () => { expect(closePopover).toHaveBeenCalled(); }); - it('should map "none" to null for removing background', () => { + it('should map "none" to setCellAttr(background, null) for removing background', () => { isCellSelection.mockReturnValue(true); const wrapper = mountPicker(); wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', 'none'); - expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith(null); + expect(mockEditor.commands.setCellBackground).not.toHaveBeenCalled(); + expect(mockEditor.commands.setCellAttr).toHaveBeenCalledWith('background', null); + expect(closePopover).toHaveBeenCalled(); }); });