From 12a1bb68cf0cc68cb587ae62ace441c06c463075 Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 20:13:46 +0800 Subject: [PATCH 01/15] feat(table): clipboard semantics for cells and rows Cell/row copy, cut and paste between tables per the owner decisions (issue table-clipboard): - paste of a copied cell block onto a cell selection replaces the rectangle's content; target cell attributes (fill, widths, borders, spans) are preserved, the grid tiles Excel-style over the selection - paste at a caret inserts copied rows after the caret row, aligned to the target column count (extra source columns dropped, missing ones become empty cells); a single copied cell replaces the caret cell - Cmd+X on a full-row selection removes those rows (Excel semantics), on a partial rectangle it clears the contents; the block is written to the system clipboard first - a cell drag ends with the editor focused, so the clipboard shortcuts reach the editor at all (the circle mousedown is preventDefault-ed) - cells built from clipboard-parsed sources sanitize their attrs (dataColor may arrive as a non-string from the parseHTML quirk) The paste override runs before prosemirror-tables' handlePaste, whose pastedCells path lost the table structure of our clipboard round-trip and nested the fragment into a cell. --- .../table/helpers/cellClipboard.test.ts | 375 ++++++++++++++++++ .../extensions/table/helpers/cellClipboard.ts | 362 +++++++++++++++++ .../table/helpers/handleCellSelection.ts | 2 +- .../table/helpers/handleMouseDown.ts | 9 + src/texto/extensions/table/table.ts | 7 + 5 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 src/texto/extensions/table/helpers/cellClipboard.test.ts create mode 100644 src/texto/extensions/table/helpers/cellClipboard.ts diff --git a/src/texto/extensions/table/helpers/cellClipboard.test.ts b/src/texto/extensions/table/helpers/cellClipboard.test.ts new file mode 100644 index 0000000..5f1b140 --- /dev/null +++ b/src/texto/extensions/table/helpers/cellClipboard.test.ts @@ -0,0 +1,375 @@ +import type { Node as ProseMirrorNode } from "prosemirror-model"; +import { DOMSerializer, Fragment, Slice } from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { CellSelection } from "prosemirror-tables"; +import { describe, expect, it } from "vitest"; + +import { buildSchema } from "../../../../../tests/helpers/buildSchema"; + +import { cellClipboardPlugin, cutCellSelection, extractCellGrid } from "./cellClipboard"; + +const schema = buildSchema(); + +function cell(text: string, attrs: Record = {}): ProseMirrorNode { + return schema.nodes.tableCell.create(attrs, [ + schema.nodes.paragraph.create(null, text ? [schema.text(text)] : []), + ]); +} + +function row(cells: Array): ProseMirrorNode { + return schema.nodes.tableRow.create(null, cells); +} + +function table(rows: Array): ProseMirrorNode { + return schema.nodes.table.create( + { data: { rowsCount: rows.length, colsCount: rows[0]?.childCount ?? 0 } }, + rows, + ); +} + +function boldCell(text: string): ProseMirrorNode { + return schema.nodes.tableCell.create(null, [ + schema.nodes.paragraph.create(null, [schema.text(text, [schema.marks.bold.create()])]), + ]); +} + +/** A doc with two plain 2x2 body tables and a paragraph between them. */ +function twoTablesDoc(): ProseMirrorNode { + const cellA = (t: string) => cell(t); + return schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([ + row([cellA("A00"), cellA("A01")]), + row([cellA("A10"), cellA("A11")]), + ]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([ + row([cell("B00"), cell("B01")]), + row([cell("B10"), cell("B11")]), + ]), + ]); +} + +function cellPositions(doc: ProseMirrorNode, tableIndex = 0): number[] { + const positions: number[] = []; + let seen = -1; + doc.descendants((node, pos) => { + if (node.type.name === "table") { + seen += 1; + return seen === tableIndex; + } + if (seen === tableIndex && (node.type.name === "tableCell" || node.type.name === "tableHeader")) { + positions.push(pos); + } + return true; + }); + return positions; +} + +function cellTexts(doc: ProseMirrorNode, tableIndex = 0): string[] { + const texts: string[] = []; + let seen = -1; + doc.descendants((node, pos) => { + if (node.type.name === "table") { + seen += 1; + return seen === tableIndex; + } + if (seen === tableIndex && (node.type.name === "tableCell" || node.type.name === "tableHeader")) { + texts.push(node.textContent); + } + return true; + }); + return texts; +} + +function stateWithSelection(doc: ProseMirrorNode, selection: CellSelection | TextSelection): EditorState { + return EditorState.create({ + doc, + selection, + plugins: [cellClipboardPlugin()], + }); +} + +/** A fake view: handlePaste/cutCellSelection only need state, dispatch and + * serializeForClipboard (real layout is not involved in these paths). */ +function fakeView(state: EditorState, onDispatch: (tr: import("prosemirror-state").Transaction) => void): EditorView { + const serializer = DOMSerializer.fromSchema(state.schema); + return { + state, + dispatch: (tr: import("prosemirror-state").Transaction) => onDispatch(tr), + serializeForClipboard: (slice: Slice) => { + const dom = createDiv(); + dom.appendChild(serializer.serializeFragment(slice.content)); + return { dom, text: slice.content.textBetween(0, slice.content.size, "\n\n"), slice }; + }, + } as unknown as EditorView; +} + +function paste(state: EditorState, slice: Slice): EditorState { + let next = state; + const plugin = state.plugins.find((p) => p.spec.props?.handlePaste); + const handlePaste = plugin?.spec.props?.handlePaste as + | ((view: EditorView, event: unknown, slice: Slice) => boolean) + | undefined; + expect(handlePaste).toBeDefined(); + handlePaste?.(fakeView(state, (tr) => { next = state.apply(tr); }), {}, slice); + return next; +} + +function rectSelection(doc: ProseMirrorNode, tableIndex: number): CellSelection { + const [p1, , , p4] = cellPositions(doc, tableIndex); + return new CellSelection(doc.resolve(p1), doc.resolve(p4)); +} + +describe("extractCellGrid", () => { + it("reads bare rows", () => { + const grid = extractCellGrid(new Slice(Fragment.fromArray([row([cell("a"), cell("b")])]), 1, 1)); + expect(grid).toHaveLength(1); + expect(grid?.[0]).toHaveLength(2); + expect(grid?.[0][0].textContent).toBe("a"); + }); + + it("unwraps a whole table copy", () => { + const t = table([row([cell("a")]), row([cell("b")])]); + const grid = extractCellGrid(new Slice(Fragment.from(t), 1, 1)); + expect(grid).toHaveLength(2); + }); + + it("reads a single-cell slice (open depths)", () => { + const grid = extractCellGrid(new Slice(Fragment.from(cell("a")), 1, 1)); + expect(grid).toHaveLength(1); + expect(grid?.[0][0].textContent).toBe("a"); + }); + + it("rejects plain content", () => { + const para = schema.nodes.paragraph.create(null, [schema.text("x")]); + expect(extractCellGrid(new Slice(Fragment.from(para), 0, 0))).toBeNull(); + }); +}); + +describe("paste onto a cell selection", () => { + it("replaces the target rect content and preserves target cell attrs", () => { + // Source: S01 carries bold text; S10 carries a background color attr. + const sourceRows = [ + row([cell("S00"), boldCell("S01")]), + row([cell("S10", { backgroundColor: "#ff0000" }), cell("S11")]), + ]; + + // Target: B00 has its own background color and wider column — both must + // survive the paste (only content is replaced). + const withAttrs = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([row([cell("B00"), cell("B01")]), row([cell("B10"), cell("B11")])]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([ + row([cell("B00", { backgroundColor: "#00ff00", colwidth: [120] }), cell("B01")]), + row([cell("B10"), cell("B11")]), + ]), + ]); + const state = stateWithSelection(withAttrs, rectSelection(withAttrs, 1)); + const next = paste(state, new Slice(Fragment.fromArray(sourceRows), 1, 1)); + + expect(cellTexts(next.doc, 1)).toEqual(["S00", "S01", "S10", "S11"]); + // Target attrs preserved; source fill did not leak in. + const target = cellPositions(next.doc, 1); + const firstCell = next.doc.nodeAt(target[0]); + expect(firstCell?.attrs.backgroundColor).toBe("#00ff00"); + expect(firstCell?.attrs.colwidth).toEqual([120]); + // Inline formatting travels with the content. + const secondCell = next.doc.nodeAt(target[1]); + expect(secondCell?.firstChild?.firstChild?.marks).toHaveLength(1); + // The selection is back over the same rectangle. + expect(next.selection).toBeInstanceOf(CellSelection); + // The source table is untouched. + expect(cellTexts(next.doc, 0)).toEqual(["B00", "B01", "B10", "B11"]); + }); + + it("keeps the structure valid (trailing paragraphs in every cell)", () => { + const doc = twoTablesDoc(); + const state = stateWithSelection(doc, rectSelection(doc, 1)); + const next = paste(state, new Slice(Fragment.from(cell("X")), 1, 1)); + const texts = cellTexts(next.doc, 1); + expect(texts).toEqual(["X", "X", "X", "X"]); + let cells = 0; + next.doc.descendants((node) => { + if (node.type.name === "tableCell" || node.type.name === "tableHeader") { + cells += 1; + expect(node.lastChild?.type.name).toBe("paragraph"); + } + return true; + }); + expect(cells).toBe(8); + }); + + it("tiles a 1x1 copy over a larger rect and keeps spans", () => { + const doc = twoTablesDoc(); + const state = stateWithSelection(doc, rectSelection(doc, 1)); + const next = paste(state, new Slice(Fragment.from(cell("Z")), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual(["Z", "Z", "Z", "Z"]); + }); + + it("keeps cells outside the rectangle untouched", () => { + // Target: 3 columns; the selection covers columns 0-1 of both body rows. + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([ + row([cell("A0"), cell("A1")]), + row([cell("A2"), cell("A3")]), + ]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([ + row([cell("B00"), cell("B01"), cell("KEEP1")]), + row([cell("B10"), cell("B11"), cell("KEEP2")]), + ]), + ]); + const positions = cellPositions(doc, 1); + const state = stateWithSelection( + doc, + new CellSelection(doc.resolve(positions[0]), doc.resolve(positions[4])), + ); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("X0"), cell("X1")]), + row([cell("X2"), cell("X3")]), + ]), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual(["X0", "X1", "KEEP1", "X2", "X3", "KEEP2"]); + }); +}); + +describe("paste at a caret", () => { + it("single copied cell replaces the caret cell content", () => { + const doc = twoTablesDoc(); + const [b0] = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, b0 + 1)); + const next = paste(state, new Slice(Fragment.from(cell("Q")), 1, 1)); + expect(cellTexts(next.doc, 1)[0]).toBe("Q"); + expect(cellTexts(next.doc, 1)).toHaveLength(4); + }); + + it("copied rows are inserted after the caret row", () => { + const doc = twoTablesDoc(); + const [b0] = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, b0 + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("R0a"), cell("R0b")]), + row([cell("R1a"), cell("R1b")]), + ]), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual([ + "B00", "B01", + "R0a", "R0b", + "R1a", "R1b", + "B10", "B11", + ]); + expect(next.selection.from).toBeGreaterThan(b0); + }); + + it("aligns columns: extra source columns are dropped", () => { + const doc = twoTablesDoc(); // target is 2 columns + const [b0] = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, b0 + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("c0"), cell("c1"), cell("c2")]), // the 3rd column is dropped + ]), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual([ + "B00", "B01", + "c0", "c1", + "B10", "B11", + ]); + }); + + it("aligns columns: missing source columns become empty cells", () => { + // Target is 3 columns, the source row has 2 cells — the third one pads. + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([row([cell("A0"), cell("A1"), cell("A2")])]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([row([cell("B0"), cell("B1"), cell("B2")])]), + ]); + const [b0] = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, b0 + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("c0"), cell("c1")]), + ]), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual(["B0", "B1", "B2", "c0", "c1", ""]); + // The padded cell satisfies the trailing-paragraph invariant. + const positions = cellPositions(next.doc, 1); + const padded = next.doc.nodeAt(positions[5]); + expect(padded?.lastChild?.type.name).toBe("paragraph"); + }); +}); + +describe("cut (Excel semantics)", () => { + function cutWith(state: EditorState): { dispatched: Array; next: EditorState } { + const dispatched: Array = []; + const view = { + state, + dispatch: (tr: import("prosemirror-state").Transaction) => dispatched.push(tr), + serializeForClipboard: (slice: Slice) => ({ + dom: createDiv(), + text: "", + slice, + }), + } as unknown as EditorView; + expect(cutCellSelection(view)).toBe(true); + expect(dispatched).toHaveLength(1); + return { dispatched, next: state.apply(dispatched[0]) }; + } + + it("removes full rows (a body row out of several)", () => { + // 3 body rows in the second table; select the middle one. + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([row([cell("A0")]), row([cell("A1")]), row([cell("A2")])]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([row([cell("B0")]), row([cell("B1")]), row([cell("B2")])]), + ]); + const positions = cellPositions(doc, 1); + const state = stateWithSelection(doc, new CellSelection(doc.resolve(positions[1]))); + const { next } = cutWith(state); + expect(cellTexts(next.doc, 1)).toEqual(["B0", "B2"]); + }); + + it("cutting every row removes the table (an empty table is invalid)", () => { + const doc = twoTablesDoc(); + const state = stateWithSelection(doc, rectSelection(doc, 1)); + const { next } = cutWith(state); + let tables = 0; + next.doc.descendants((node) => { + if (node.type.name === "table") tables += 1; + return true; + }); + expect(tables).toBe(1); + }); + + it("clears contents of a partial rectangle", () => { + const doc = twoTablesDoc(); + const [p1] = cellPositions(doc, 1); + // Select only the first cell of the second table (1x1 "rect"). + const state = stateWithSelection(doc, new CellSelection(doc.resolve(p1))); + const dispatched: Array = []; + const view = { + state, + dispatch: (tr: import("prosemirror-state").Transaction) => dispatched.push(tr), + serializeForClipboard: (slice: Slice) => ({ + dom: createDiv(), + text: "", + slice, + }), + } as unknown as EditorView; + expect(cutCellSelection(view)).toBe(true); + expect(dispatched).toHaveLength(1); + const next = state.apply(dispatched[0]); + expect(cellTexts(next.doc, 1)).toEqual(["", "B01", "B10", "B11"]); + // Structure survived: still 4 cells. + expect(cellTexts(next.doc, 1)).toHaveLength(4); + }); + + it("does not touch a plain caret cut", () => { + const doc = twoTablesDoc(); + const state = stateWithSelection(doc, TextSelection.create(doc, 6)); + const view = fakeView(state, () => { + throw new Error("must not dispatch"); + }); + expect(cutCellSelection(view)).toBe(false); + }); +}); diff --git a/src/texto/extensions/table/helpers/cellClipboard.ts b/src/texto/extensions/table/helpers/cellClipboard.ts new file mode 100644 index 0000000..c9a9501 --- /dev/null +++ b/src/texto/extensions/table/helpers/cellClipboard.ts @@ -0,0 +1,362 @@ +import { Fragment, Slice, type Node as ProseMirrorNode } from "prosemirror-model"; +import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { + CellSelection, + deleteRow, + deleteTable, + isInTable, + selectionCell, + selectedRect, + TableMap, + tableNodeTypes, + type TableRect, +} from "prosemirror-tables"; + +import { TextoCellSelection } from "./TextoCellSelection"; + +/** + * Table clipboard semantics (owner decisions, issue table-clipboard): + * + * - Cmd+C — stock ProseMirror copy of the CellSelection content (works as is; + * this module only makes sure the editor is focused after a cell drag, see + * handleMouseDown.stop). + * - Cmd+V onto a cell selection — the copied cell block replaces the CONTENT + * of the selected rectangle; target cell attributes (fill, widths, borders, + * spans) are preserved, so the target table structure never changes. The + * source grid tiles over a larger target rectangle (Excel behavior). + * - Cmd+V with a bare caret in a cell — a single copied cell replaces the + * caret cell's content; several copied cells/rows are INSERTED as new rows + * after the caret row, aligned to the target column count: extra source + * columns are dropped, missing ones become empty cells. + * - Cmd+X — Excel semantics: a full-row selection removes those rows, a + * partial rectangle clears the cell contents. Either way the copied block + * goes to the system clipboard first. + * + * Without the paste override the table fragment raced past + * prosemirror-tables' pastedCells (its shape assumptions are stricter than + * what our clipboard round-trip produces) and ended up as a nested table + * inside a cell. + */ + +function isCellLike(node: ProseMirrorNode): boolean { + // .spec is untyped (any) in prosemirror-model — cast explicitly. + const role = node.type.spec.tableRole as string | undefined; + return role === "cell" || role === "header_cell"; +} + +/** Attrs for a cell created from a clipboard-parsed source node. Parsed + * attrs are untrusted: dataColor can be a non-string (the parseHTML quirk + * of the data-color dataset) and only a well-formed color may reach + * renderHTML's safeColorValue. */ +function insertedCellAttrs(source: ProseMirrorNode, span: number): Record { + const attrs = source.attrs as Record; + const dataColor = attrs.dataColor; + return { + ...attrs, + colspan: span, + rowspan: 1, + dataColor: typeof dataColor === "string" ? dataColor : null, + }; +} + +/** + * The rectangular cell grid carried by a clipboard slice: an array of rows, + * each an array of cell nodes. Accepts the shapes a table copy produces — + * bare rows, a whole table node, or a run of cells (a single-cell copy with + * open depths). Returns null for plain (non-table) content. + */ +export function extractCellGrid(slice: Slice): Array> | null { + let content = slice.content; + for (let i = 0; i < 4 && content.childCount === 1; i += 1) { + // .spec is untyped (any) in prosemirror-model — cast explicitly. + if ((content.child(0).type.spec.tableRole as string | undefined) !== "table") { + break; + } + content = content.child(0).content; + } + + const rows: Array> = []; + let cellRun: Array = []; + const flushRun = () => { + if (cellRun.length) { + rows.push(cellRun); + cellRun = []; + } + }; + + for (let i = 0; i < content.childCount; i += 1) { + const child = content.child(i); + const role = child.type.spec.tableRole as string | undefined; + if (role === "row") { + flushRun(); + const cells: Array = []; + for (let c = 0; c < child.childCount; c += 1) { + if (isCellLike(child.child(c))) { + cells.push(child.child(c)); + } + } + if (cells.length) { + rows.push(cells); + } + } else if (isCellLike(child)) { + cellRun.push(child); + } else { + return null; + } + } + flushRun(); + + return rows.length ? rows : null; +} + +/** Offset of the start of the row with the given index, in table content + * coordinates (TableMap.positionAt targets a cell column, not a row edge). */ +function rowStartOffset(table: ProseMirrorNode, rowIndex: number): number { + let offset = 0; + for (let i = 0; i < rowIndex; i += 1) { + offset += table.child(i).nodeSize; + } + return offset; +} + +/** Replace the content of the selected rectangle with the copied grid. + * Target cell nodes survive with their attributes; only the content is + * swapped, so spans, widths, fill and borders come from the target. Cells + * outside the rectangle (in the same rows) are kept unchanged. */ +function replaceRectWithGrid( + state: EditorState, + dispatch: (tr: Transaction) => void, + rect: TableRect, + grid: Array>, +): void { + const { map, table, tableStart } = rect; + const gridH = grid.length; + const gridW = Math.max(...grid.map((row) => row.length)); + + const newRows: Array = []; + for (let row = rect.top; row < rect.bottom; row += 1) { + const rowNode = table.child(row); + const rowStart = rowStartOffset(table, row); + const rowEnd = rowStart + rowNode.nodeSize; + const gridRow = grid[(row - rect.top) % gridH]; + const cells: Array = []; + const seenOffsets = new Set(); + let gridCol = 0; + for (let col = 0; col < map.width; col += 1) { + const offset = map.map[row * map.width + col]; + if (seenOffsets.has(offset)) { + continue; // colspan continuation — the cell is already built + } + seenOffsets.add(offset); + if (offset < rowStart || offset >= rowEnd) { + continue; // rowspan continuation — nothing in this row node + } + const target = table.nodeAt(offset); + if (!target) { + continue; + } + const span = target.attrs.colspan as number; + const insideRect = col >= rect.left && col + span <= rect.right; + const source = insideRect ? gridRow[gridCol % gridW] : null; + if (insideRect && source) { + cells.push(target.type.create(target.attrs, source.content)); + } else { + cells.push(target); + } + if (insideRect) { + gridCol += span; + } + } + newRows.push(rowNode.copy(Fragment.fromArray(cells))); + } + + let replacedSize = 0; + for (let row = rect.top; row < rect.bottom; row += 1) { + replacedSize += table.child(row).nodeSize; + } + const from = tableStart + rowStartOffset(table, rect.top); + + const tr = state.tr; + tr.replace(from, from + replacedSize, new Slice(Fragment.fromArray(newRows), 0, 0)); + + // Restore the rectangle selection in the new document. Content sizes + // changed, so old positions are useless here: the structure (rows, spans) + // did not change, so the same rect indexes address the same cells in the + // new table. cellsInRect skips span continuations — its ends are the + // visible corner cells. + const newTable = tr.doc.nodeAt(tableStart - 1); + if (!newTable) { + dispatch(tr); + return; + } + const rectCells = TableMap.get(newTable).cellsInRect(rect); + const selection = new TextoCellSelection( + tr.doc.resolve(tableStart + rectCells[0]), + tr.doc.resolve(tableStart + rectCells[rectCells.length - 1]), + ); + tr.setSelection(selection).scrollIntoView(); + dispatch(tr); +} + +/** Insert the copied rows as new rows after the caret row, aligned to the + * target column count (extra source columns dropped, missing ones become + * empty cells). The new rows mirror the caret row's cell type. */ +function insertRowsAfterCaret( + state: EditorState, + dispatch: (tr: Transaction) => void, + grid: Array>, +): boolean { + const $cell = selectionCell(state); + const table = $cell.node(-1); + const tableStart = $cell.start(-1); + const map = TableMap.get(table); + const targetWidth = map.width; + const cursorRect = map.findCell($cell.pos - tableStart); + const insertAfterRow = cursorRect.bottom; // the row below the caret row + const referenceRow = table.child(Math.min(insertAfterRow - 1, table.childCount - 1)); + const cellType = referenceRow.childCount + ? referenceRow.child(0).type + : tableNodeTypes(state.schema).cell; + + const newRows: Array = []; + for (const sourceRow of grid) { + const cells: Array = []; + let col = 0; + for (const sourceCell of sourceRow) { + if (col >= targetWidth) { + break; // extra source columns are ignored (owner decision) + } + const span = Math.min(sourceCell.attrs.colspan as number, targetWidth - col); + cells.push(cellType.create(insertedCellAttrs(sourceCell, span), sourceCell.content)); + col += span; + } + while (col < targetWidth) { + const empty = cellType.createAndFill(); + if (!empty) { + return false; + } + cells.push(empty); + col += 1; + } + newRows.push(state.schema.nodes.tableRow.createChecked(null, cells)); + } + + const tr = state.tr; + const pos = tableStart + rowStartOffset(table, insertAfterRow); + tr.insert(pos, Fragment.fromArray(newRows)); + tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(pos + 1)))); + tr.scrollIntoView(); + dispatch(tr); + return true; +} + +/** Paste handling for table fragments (owner semantics above). Returns false + * for non-table content so the default paste path takes over. */ +function handleCellPaste(view: EditorView, slice: Slice): boolean { + const { state } = view; + const grid = extractCellGrid(slice); + if (!grid) { + return false; + } + const dispatch = view.dispatch.bind(view); + const { selection } = state; + if (selection instanceof CellSelection) { + replaceRectWithGrid(state, dispatch, selectedRect(state), grid); + return true; + } + if (!isInTable(state)) { + return false; + } + // Excel: a single copied cell pasted at a caret replaces the caret cell's + // content (target attributes preserved), it does not insert a row. + if (grid.length === 1 && grid[0].length === 1) { + const $cell = selectionCell(state); + const table = $cell.node(-1); + const tableStart = $cell.start(-1); + const map = TableMap.get(table); + const cellOffset = $cell.pos - tableStart; + const cellPos = tableStart + map.map[map.findCell(cellOffset).top * map.width + map.colCount(cellOffset)]; + const target = state.doc.nodeAt(cellPos); + if (!target) { + return false; + } + const tr = state.tr; + tr.replace( + tr.mapping.map(cellPos + 1), + tr.mapping.map(cellPos + target.nodeSize - 1), + new Slice(grid[0][0].content, 0, 0), + ); + tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(cellPos + 1)))); + tr.scrollIntoView(); + dispatch(tr); + return true; + } + return insertRowsAfterCaret(state, dispatch, grid); +} + +/** + * Cmd+X for a cell selection: the copied block goes to the system clipboard, + * then Excel semantics — full rows are removed, a partial rectangle only + * clears cell contents. Returns false outside a cell selection (normal cut). + */ +export function cutCellSelection(view: EditorView): boolean { + const { state } = view; + const { selection } = state; + if (!(selection instanceof CellSelection) || selection.empty || !isInTable(state)) { + return false; + } + + const content = selection.content(); + const { dom, text } = view.serializeForClipboard(content); + void copyToSystemClipboard(dom.innerHTML, text); + + const rect = selectedRect(state); + const fullRows = rect.left === 0 && rect.right === rect.map.width; + if (fullRows) { + // deleteRow refuses to remove every row of a table; cutting all rows + // removes the table itself — an empty table is not a valid document + // node (mirrors deleteTableWhenAllCellsSelected for Backspace). + const wholeTable = rect.top === 0 && rect.bottom === rect.map.height; + (wholeTable ? deleteTable : deleteRow)(state, view.dispatch.bind(view)); + } else { + view.dispatch(state.tr.deleteSelection().scrollIntoView()); + } + return true; +} + +async function copyToSystemClipboard(html: string, text: string): Promise { + try { + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([html], { type: "text/html" }), + "text/plain": new Blob([text], { type: "text/plain" }), + }), + ]); + return; + } + } catch { + // fall through to the plain-text path + } + try { + await navigator.clipboard.writeText(text); + } catch { + // no clipboard access — nothing else to do + } +} + +export function cellClipboardPlugin(): Plugin { + return new Plugin({ + props: { + // Registered before tableEditing: returning false falls through to its + // handlePaste and then to the default paste handling. + handlePaste: (view, _event, slice) => { + if (!isInTable(view.state)) { + return false; + } + return handleCellPaste(view, slice); + }, + }, + }); +} diff --git a/src/texto/extensions/table/helpers/handleCellSelection.ts b/src/texto/extensions/table/helpers/handleCellSelection.ts index 61f1de1..62a8d07 100644 --- a/src/texto/extensions/table/helpers/handleCellSelection.ts +++ b/src/texto/extensions/table/helpers/handleCellSelection.ts @@ -152,7 +152,7 @@ export function handleCellSelection(isMobileView: boolean) { document.body.classList.add("texto-table__cell-dragging"); } }); - return handleMouseDown(view, event); + return handleMouseDown(view, event, isMobileView); }, // Swallow the synthetic compatibility mouse events (mouseup/click) // that Android dispatches after a touch cell drag: a click would diff --git a/src/texto/extensions/table/helpers/handleMouseDown.ts b/src/texto/extensions/table/helpers/handleMouseDown.ts index 28a6d7d..7a2286a 100644 --- a/src/texto/extensions/table/helpers/handleMouseDown.ts +++ b/src/texto/extensions/table/helpers/handleMouseDown.ts @@ -181,6 +181,7 @@ export function cellInTableAtPoint( export function handleMouseDown( view: EditorView, startEvent: MouseEvent, + isMobileView = false, ): boolean { // The synthetic compatibility mousedown that Android dispatches right after // a touch cell drag would collapse the freshly built TextoCellSelection @@ -261,6 +262,14 @@ export function handleMouseDown( if (tableEditingKey.getState(view.state) != null) { view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); } + // The circle-mousedown is preventDefault-ed, so the browser never moves + // focus into the editor: without an explicit focus the clipboard + // shortcuts (Cmd+C/X/V) die on the document body and copy nothing. + // Mobile keeps the focus where it is — focusing would pop the soft + // keyboard (mobile copy/paste runs through the bubble menu instead). + if (!isMobileView && view.state.selection instanceof TextoCellSelection) { + view.focus(); + } } function move(event: MouseEvent) { diff --git a/src/texto/extensions/table/table.ts b/src/texto/extensions/table/table.ts index 54b3b90..95a0fbf 100644 --- a/src/texto/extensions/table/table.ts +++ b/src/texto/extensions/table/table.ts @@ -11,6 +11,7 @@ import { NodeView } from "prosemirror-view"; import { addCommands } from "./commands"; import { deleteTableWhenAllCellsSelected } from "./helpers"; +import { cellClipboardPlugin, cutCellSelection } from "./helpers/cellClipboard"; import { cellTrailingParagraphPlugin } from "./helpers/cellTrailingParagraph"; import { createColGroup } from "./helpers/createColGroup"; import { handleCellSelection } from "./helpers/handleCellSelection"; @@ -132,6 +133,9 @@ export const Table = Node.create({ "Mod-Backspace": deleteTableWhenAllCellsSelected, Delete: deleteTableWhenAllCellsSelected, "Mod-Delete": deleteTableWhenAllCellsSelected, + // Excel cut semantics for cell selections (rows are removed, partial + // rectangles are cleared); the block is copied to the clipboard first. + "Mod-x": () => cutCellSelection(this.editor.view), }; }, @@ -145,6 +149,9 @@ export const Table = Node.create({ // whose last block is a cursor-hostile atom (cell-level twin of the // note trailing-paragraph invariant). cellTrailingParagraphPlugin(), + // Table clipboard semantics (cell-rect paste, row insert, Excel cut); + // before tableEditing, whose handlePaste it replaces for table content. + cellClipboardPlugin(), ...(isResizable ? [ columnResizing({ From b74c65682ad83e2efa293f92bf591c79cf81671e Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 20:30:46 +0800 Subject: [PATCH 02/15] test(debug): CDP verification scripts for the table clipboard task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop (trusted keyboard/mouse in the dev vault, CDP :9223) and Android emulator (:9224) probes for issue table-clipboard — the desktop one replays the full user flow: rect copy/paste between tables, row insert at the caret, Excel-semantics Cmd+X, undo, and the circle-drag focus fix. --- .../debug/verify-table-clipboard-android.mjs | 93 ++++++++ scripts/debug/verify-table-clipboard.mjs | 205 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 scripts/debug/verify-table-clipboard-android.mjs create mode 100644 scripts/debug/verify-table-clipboard.mjs diff --git a/scripts/debug/verify-table-clipboard-android.mjs b/scripts/debug/verify-table-clipboard-android.mjs new file mode 100644 index 0000000..cdf6c06 --- /dev/null +++ b/scripts/debug/verify-table-clipboard-android.mjs @@ -0,0 +1,93 @@ +// tmp-verify-android.mjs — mobile verification of the table clipboard task +// on the Android emulator (CDP :9224), obsidian-plugin vault only. +import { chromium } from 'playwright-core'; + +const browser = await chromium.connectOverCDP('http://127.0.0.1:9224', { timeout: 10000 }); +const page = browser.contexts().flatMap((c) => c.pages()).find((p) => p.url().startsWith('http://localhost/')); +if (page == null) throw new Error('Android Obsidian page not found'); + +const R = {}; +R.vault = await page.evaluate(() => window.app?.vault?.getName?.() ?? null); +R.plugin = await page.evaluate(() => !!window.app?.plugins?.getPlugin?.('inscriptum')); + +// write the test note (two 3-col tables) and open it +R.setup = await page.evaluate(async () => { + const mk = (name) => ({ + type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } }, + content: [ + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + 'h' + i }] }] })) }, + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '0' + i }] }] })) }, + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '1' + i }] }] })) }, + ], + }); + const doc = { + type: 'noteDoc', + content: [ + { type: 'noteTitle', content: [{ type: 'text', text: 'table-clipboard-verify' }] }, + mk('A'), + { type: 'paragraph', content: [{ type: 'text', text: 'between' }] }, + mk('B'), + ], + }; + await app.vault.adapter.write('local/table-clipboard-verify.note', JSON.stringify(doc)); + const file = app.vault.getAbstractFileByPath('local/table-clipboard-verify.note'); + const leaf = app.workspace.getLeaf('tab'); + await leaf.openFile(file); + app.workspace.revealLeaf(leaf); + await new Promise((r) => setTimeout(r, 800)); + const ed = leaf.view?.editor; + if (!ed) return { err: 'no editor' }; + window.__ed = ed; + window.__cellPos = (text) => { + let p = null; + ed.state.doc.descendants((node, pos) => { + if (!p && (node.type.name === 'tableCell' || node.type.name === 'tableHeader') && node.textContent === text) p = pos; + return !p; + }); + return p; + }; + window.__tableTexts = (idx) => { + const out = []; + let seen = -1; + ed.state.doc.descendants((node) => { + if (node.type.name === 'table') { seen += 1; return seen === idx; } + if (seen === idx && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) out.push(node.textContent); + return true; + }); + return out; + }; + return { ok: true, platform: { isMobile: /is-mobile/.test(document.body.className) || Boolean(window.Capacitor), ua: navigator.userAgent.slice(0, 60) } }; +}); + +// rect paste: CellSelection over B00..B11 + synthetic paste with a copied 2x2 +R.rectPaste = await page.evaluate(() => { + const ed = window.__ed; + ed.commands.setCellSelection({ anchorCell: window.__cellPos('B00'), headCell: window.__cellPos('B11') }); + const html = '

A00

A01

A10

A11

'; + const dt = new DataTransfer(); + dt.setData('text/html', html); + dt.setData('text/plain', 'A00\n\nA01\n\nA10\n\nA11'); + ed.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + return window.__tableTexts(1); +}); + +// undo +R.afterUndo = await page.evaluate(() => { + window.__ed.commands.undo(); + return window.__tableTexts(1); +}); + +// rows insert at caret +R.rowsAtCaret = await page.evaluate(() => { + const ed = window.__ed; + const pos = window.__cellPos('B00'); + ed.commands.focus(pos + 1); + const html = '

A00

A01

A10

A11

'; + const dt = new DataTransfer(); + dt.setData('text/html', html); + ed.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + return window.__tableTexts(1); +}); + +await browser.close().catch(() => {}); +console.log(JSON.stringify(R, null, 2)); diff --git a/scripts/debug/verify-table-clipboard.mjs b/scripts/debug/verify-table-clipboard.mjs new file mode 100644 index 0000000..e8915c2 --- /dev/null +++ b/scripts/debug/verify-table-clipboard.mjs @@ -0,0 +1,205 @@ +// tmp-verify-desktop.mjs — desktop verification of the table clipboard task +// (issue table-clipboard) in the live obsidian-plugin dev vault, CDP :9223. +// Trusted keyboard/mouse via playwright Input; state reads via evaluate. +import { chromium } from 'playwright-core'; + +const browser = await chromium.connectOverCDP('http://127.0.0.1:9223', { timeout: 8000 }); + +const R = {}; +let page = null; +try { + // 0. find the dev-vault window (rule-verify-both-platforms: only the + // obsidian-plugin vault may be touched) + const appPages = browser.contexts().flatMap((c) => c.pages()).filter((x) => x.url().startsWith('app://obsidian.md')); + for (const p of appPages) { + const vault = await p.evaluate(() => window.app?.vault?.getName?.() ?? null); + if (vault === 'obsidian-plugin') { + page = p; + break; + } + } + R.windows = await Promise.all(appPages.map((p) => p.evaluate(() => window.app?.vault?.getName?.() ?? null))); + if (page == null) throw new Error('obsidian-plugin vault window not found'); + + // The window must be OS-focused: trusted copy/cut keyboard commands are + // dropped by Chromium when the web contents have no focus. + R.windowFocus = await page.evaluate(() => { + const win = window.electron?.remote?.getCurrentWindow?.(); + if (!win) return 'no electron.remote'; + win.show(); + win.focus(); + return win.getTitle(); + }); + await page.waitForTimeout(400); + + // 1. the app was reloaded (app:reload) AFTER the build, so the fresh + // bundle is already live; just confirm the plugin is enabled + R.pluginEnabled = await page.evaluate(() => !!app.plugins.getPlugin('inscriptum')); + + // 2. write the test note (two 3-col tables: header + 2 body rows each) + await page.evaluate(async () => { + const mk = (name) => ({ + type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } }, + content: [ + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + 'h' + i }] }] })) }, + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '0' + i }] }] })) }, + { type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '1' + i }] }] })) }, + ], + }); + const doc = { + type: 'noteDoc', + content: [ + { type: 'noteTitle', content: [{ type: 'text', text: 'table-clipboard-verify' }] }, + mk('A'), + { type: 'paragraph', content: [{ type: 'text', text: 'between' }] }, + mk('B'), + ], + }; + await app.vault.adapter.write('local/table-clipboard-verify.note', JSON.stringify(doc)); + const file = app.vault.getAbstractFileByPath('local/table-clipboard-verify.note'); + // close the tabs my earlier runs left behind, keep exactly one leaf + const existing = app.workspace.getLeavesOfType('note-view') + .filter((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); + const leaf = app.workspace.getLeaf('tab'); + await leaf.openFile(file); + for (const l of existing) { + if (l !== leaf) { + try { l.detach(); } catch {} + } + } + app.workspace.revealLeaf(leaf); + }); + await page.waitForTimeout(1200); + R.editorUp = await page.evaluate(() => { + const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); + return !!(leaf?.view?.editor); + }); + + // helpers evaluated in-page + await page.evaluate(() => { + const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); + window.__ed = leaf.view.editor; + window.__cellPos = (text) => { + let p = null; + window.__ed.state.doc.descendants((node, pos) => { + if (!p && (node.type.name === 'tableCell' || node.type.name === 'tableHeader') && node.textContent === text) p = pos; + return !p; + }); + return p; + }; + window.__tableTexts = (idx) => { + const out = []; + let seen = -1; + window.__ed.state.doc.descendants((node) => { + if (node.type.name === 'table') { seen += 1; return seen === idx; } + if (seen === idx && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) out.push(node.textContent); + return true; + }); + return out; + }; + window.__selRect = (a, b) => { + window.__ed.commands.setCellSelection({ anchorCell: window.__cellPos(a), headCell: window.__cellPos(b) }); + window.__ed.view.focus(); + }; + window.__caret = (text) => { + const pos = window.__cellPos(text); + window.__ed.commands.focus(pos + 1); + window.__ed.view.focus(); + }; + }); + + // focus the web contents with a trusted click into the editor, then keep + // PM focus via the API (native copy/cut need OS-focused web contents) + const edRect = await page.evaluate(() => { + const r = window.__ed.view.dom.getBoundingClientRect(); + return { x: r.x + Math.min(80, r.width / 2), y: r.y + 8 }; + }); + await page.mouse.click(edRect.x, edRect.y); + await page.waitForTimeout(200); + R.focusAfterClick = await page.evaluate(() => window.__ed.view.hasFocus()); + + // 3. rect copy from A00..A11 -> rect paste onto B00..B11 + await page.evaluate(() => window.__selRect('A00', 'A11')); + await page.keyboard.press('Meta+c'); + await page.waitForTimeout(150); + await page.evaluate(() => window.__selRect('B00', 'B11')); + await page.keyboard.press('Meta+v'); + await page.waitForTimeout(250); + R.rectPaste = await page.evaluate(() => window.__tableTexts(1)); + + // 4. undo once — the paste must roll back in a single step + await page.keyboard.press('Meta+z'); + await page.waitForTimeout(200); + R.afterUndo = await page.evaluate(() => window.__tableTexts(1)); + + // 5. rows copy (full rows A0+A1) -> caret in B00 -> paste inserts rows + await page.evaluate(() => window.__selRect('A00', 'A12')); + await page.keyboard.press('Meta+c'); + await page.waitForTimeout(150); + await page.evaluate(() => window.__caret('B00')); + R.caretType = await page.evaluate(() => window.__ed.state.selection.constructor.name); + await page.keyboard.press('Meta+v'); + await page.waitForTimeout(250); + R.rowsAtCaret = await page.evaluate(() => window.__tableTexts(1)); + await page.keyboard.press('Meta+z'); + await page.waitForTimeout(200); + + // 6. Cmd+X on a full body row removes the row + await page.evaluate(() => window.__selRect('A00', 'A02')); + await page.keyboard.press('Meta+x'); + await page.waitForTimeout(250); + R.cutRow = await page.evaluate(() => window.__tableTexts(0)); + await page.keyboard.press('Meta+z'); + await page.waitForTimeout(200); + + // 7. Cmd+X on a partial rect clears contents + await page.evaluate(() => window.__selRect('A00', 'A11')); + await page.keyboard.press('Meta+x'); + await page.waitForTimeout(250); + R.cutPartial = await page.evaluate(() => window.__tableTexts(0)); + await page.keyboard.press('Meta+z'); + await page.waitForTimeout(200); + + // 8. focus after a real circle drag (trusted mouse): caret in A11 -> drag + // the selection circle to B11... wait, drag stays in one table: to B02. + await page.evaluate(() => window.__caret('A11')); + await page.waitForTimeout(300); + const circle = await page.evaluate(() => { + const el = window.__ed.view.dom.querySelector('.texto-table__overlay_overlay-circle'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2, cellPos: el.dataset.cellPos }; + }); + R.circle = circle; + if (circle) { + const target = await page.evaluate(() => { + const td = [...window.__ed.view.dom.querySelectorAll('td,th')].find((el) => el.textContent === 'A02'); + const r = td.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await page.mouse.move(circle.x, circle.y); + await page.mouse.down(); + await page.mouse.move(target.x, target.y, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(300); + R.afterDrag = await page.evaluate(() => { + const s = window.__ed.state.selection; + return { + type: s.constructor.name, + anchorCell: s.$anchorCell ? s.$anchorCell.pos : null, + headCell: s.$headCell ? s.$headCell.pos : null, + hasFocus: window.__ed.view.hasFocus(), + }; + }); + // 9. trusted Cmd+C right after the drag — the focus fix makes it work + await page.keyboard.press('Meta+c'); + await page.waitForTimeout(150); + await page.evaluate(() => window.__selRect('B00', 'B12')); + await page.keyboard.press('Meta+v'); + await page.waitForTimeout(250); + R.pasteAfterDragCopy = await page.evaluate(() => window.__tableTexts(1)); + } +} finally { + await browser.close().catch(() => {}); +} +console.log(JSON.stringify(R, null, 2)); From ad97b7368676ca1c26c9dfd1586bada2f9edc6aa Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 20:56:29 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(table):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20rowspan-aware=20row=20insert,=20tiling=20cursor,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - row insertion extends merges crossing the insertion point (addRow convention) and fills only the free columns; source cells never recycle — missing columns become empty cells - rect-paste tiling consumes the source grid in order (cursor advances by the placed cell's colspan), so a merged target cell no longer skips source cells - extractCellGrid unwraps nested single-table copies without the arbitrary depth cap - cut handles every cell selection including 1x1 (copy + clear) - tests: whole-table paste (the original bug shape), merged-target tiling, header cells, rowspan-crossed row insertion --- .../table/helpers/cellClipboard.test.ts | 104 ++++++++++++++++ .../extensions/table/helpers/cellClipboard.ts | 111 +++++++++++++----- 2 files changed, 188 insertions(+), 27 deletions(-) diff --git a/src/texto/extensions/table/helpers/cellClipboard.test.ts b/src/texto/extensions/table/helpers/cellClipboard.test.ts index 5f1b140..237af0f 100644 --- a/src/texto/extensions/table/helpers/cellClipboard.test.ts +++ b/src/texto/extensions/table/helpers/cellClipboard.test.ts @@ -34,6 +34,12 @@ function boldCell(text: string): ProseMirrorNode { ]); } +function headerCell(text: string): ProseMirrorNode { + return schema.nodes.tableHeader.create(null, [ + schema.nodes.paragraph.create(null, [schema.text(text)]), + ]); +} + /** A doc with two plain 2x2 body tables and a paragraph between them. */ function twoTablesDoc(): ProseMirrorNode { const cellA = (t: string) => cell(t); @@ -234,6 +240,72 @@ describe("paste onto a cell selection", () => { ]), 1, 1)); expect(cellTexts(next.doc, 1)).toEqual(["X0", "X1", "KEEP1", "X2", "X3", "KEEP2"]); }); + + it("pastes a whole-table copy onto a selection (the original bug shape)", () => { + // A full-table copy travels as Slice([table], 1, 1); pasting it used to + // nest the table into the selected cells. + const doc = twoTablesDoc(); + const sourceTable = table([ + row([cell("S0"), cell("S1")]), + row([cell("S2"), cell("S3")]), + ]); + const state = stateWithSelection(doc, rectSelection(doc, 1)); + const next = paste(state, new Slice(Fragment.from(sourceTable), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual(["S0", "S1", "S2", "S3"]); + // No nested table anywhere. + let nested = 0; + next.doc.descendants((node) => { + if (node.type.name === "tableCell") { + node.content.forEach((child) => { + if (child.type.name === "table") nested += 1; + }); + } + return true; + }); + expect(nested).toBe(0); + }); + + it("tiles source cells in order over a merged target cell", () => { + // Target: col 0 spans two columns (colspan 2), col 2 is separate. + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([row([cell("A0"), cell("A1")])]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([ + row([ + schema.nodes.tableCell.create({ colspan: 2 }, [ + schema.nodes.paragraph.create(null, [schema.text("WIDE")]), + ]), + cell("C"), + ]), + ]), + ]); + const positions = cellPositions(doc, 1); + const state = stateWithSelection( + doc, + new CellSelection(doc.resolve(positions[0]), doc.resolve(positions[1])), + ); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("X"), cell("Y")]), + ]), 1, 1)); + // The merged cell takes the first source cell, C takes the second one. + expect(cellTexts(next.doc, 1)).toEqual(["X", "Y"]); + const target = next.doc.nodeAt(positions[0]); + expect(target?.attrs.colspan).toBe(2); + }); + + it("pastes header cells onto a header selection", () => { + const doc = twoTablesDoc(); // both tables have header rows + const positions = cellPositions(doc, 1); + const state = stateWithSelection( + doc, + new CellSelection(doc.resolve(positions[0]), doc.resolve(positions[1])), + ); + const next = paste(state, new Slice(Fragment.fromArray([ + row([headerCell("H0"), headerCell("H1")]), + ]), 1, 1)); + expect(cellTexts(next.doc, 1)).toEqual(["H0", "H1", "B10", "B11"]); + }); }); describe("paste at a caret", () => { @@ -296,6 +368,38 @@ describe("paste at a caret", () => { const padded = next.doc.nodeAt(positions[5]); expect(padded?.lastChild?.type.name).toBe("paragraph"); }); + + it("inserts rows under a rowspan: merges extend, rows fill free columns", () => { + // 3-col table; col 0 is merged across rows 0-1 (rowspan 2). The caret + // sits in row 0, so the insertion point is crossed by the merge. + const merged = schema.nodes.tableCell.create({ rowspan: 2 }, [ + schema.nodes.paragraph.create(null, [schema.text("M")]), + ]); + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([ + row([merged, cell("c01"), cell("c02")]), + row([cell("c11"), cell("c12")]), + row([cell("c21"), cell("c22"), cell("c23")]), + ]), + ]); + const positions = cellPositions(doc, 0); + const state = stateWithSelection(doc, TextSelection.create(doc, positions[1] + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("X"), cell("Y")]), + ]), 1, 1)); + // The new row holds cells at the free columns (1-2) only; col 0 is the + // extended merge. + expect(cellTexts(next.doc, 0)).toEqual([ + "M", "c01", "c02", + "X", "Y", + "c11", "c12", + "c21", "c22", "c23", + ]); + // The merge crossing the insertion point extended by one row. + const mergedNode = next.doc.nodeAt(positions[0]); + expect(mergedNode?.attrs.rowspan).toBe(3); + }); }); describe("cut (Excel semantics)", () => { diff --git a/src/texto/extensions/table/helpers/cellClipboard.ts b/src/texto/extensions/table/helpers/cellClipboard.ts index c9a9501..65a3c78 100644 --- a/src/texto/extensions/table/helpers/cellClipboard.ts +++ b/src/texto/extensions/table/helpers/cellClipboard.ts @@ -64,11 +64,13 @@ function insertedCellAttrs(source: ProseMirrorNode, span: number): Record> | null { let content = slice.content; - for (let i = 0; i < 4 && content.childCount === 1; i += 1) { + while (content.childCount === 1) { // .spec is untyped (any) in prosemirror-model — cast explicitly. if ((content.child(0).type.spec.tableRole as string | undefined) !== "table") { break; @@ -142,7 +144,10 @@ function replaceRectWithGrid( const gridRow = grid[(row - rect.top) % gridH]; const cells: Array = []; const seenOffsets = new Set(); - let gridCol = 0; + // Source placements consume the SOURCE grid in order: the cursor + // advances by the placed cell's own colspan (1 after a clipboard + // round-trip), so a merged target cell no longer skips source cells. + let srcIdx = 0; for (let col = 0; col < map.width; col += 1) { const offset = map.map[row * map.width + col]; if (seenOffsets.has(offset)) { @@ -158,15 +163,13 @@ function replaceRectWithGrid( } const span = target.attrs.colspan as number; const insideRect = col >= rect.left && col + span <= rect.right; - const source = insideRect ? gridRow[gridCol % gridW] : null; + const source = insideRect ? gridRow[srcIdx % gridW] : null; if (insideRect && source) { cells.push(target.type.create(target.attrs, source.content)); + srcIdx += (source.attrs.colspan as number) || 1; } else { cells.push(target); } - if (insideRect) { - gridCol += span; - } } newRows.push(rowNode.copy(Fragment.fromArray(cells))); } @@ -201,7 +204,10 @@ function replaceRectWithGrid( /** Insert the copied rows as new rows after the caret row, aligned to the * target column count (extra source columns dropped, missing ones become - * empty cells). The new rows mirror the caret row's cell type. */ + * empty cells). Columns continuing a rowspan from above are no place for a + * new cell: the crossing merges extend by the number of inserted rows and + * the new rows carry cells at the free columns only — the same convention + * as prosemirror-tables' own addRow. Cell types mirror the caret row. */ function insertRowsAfterCaret( state: EditorState, dispatch: (tr: Transaction) => void, @@ -214,35 +220,82 @@ function insertRowsAfterCaret( const targetWidth = map.width; const cursorRect = map.findCell($cell.pos - tableStart); const insertAfterRow = cursorRect.bottom; // the row below the caret row - const referenceRow = table.child(Math.min(insertAfterRow - 1, table.childCount - 1)); - const cellType = referenceRow.childCount - ? referenceRow.child(0).type - : tableNodeTypes(state.schema).cell; + const fallbackType = tableNodeTypes(state.schema).cell; + // Column type reference: the caret row's cell at the same column. + const typeAt = (col: number): ProseMirrorNode["type"] => { + if (insertAfterRow > 0) { + const node = table.nodeAt(map.map[(insertAfterRow - 1) * targetWidth + col]); + if (node) { + return node.type; + } + } + return fallbackType; + }; + // Free columns of the insertion point vs columns continuing a rowspan. + const covered = new Set(); // cell offsets in table content coords + const freeCols: number[] = []; + if (insertAfterRow < map.height) { + for (let col = 0; col < targetWidth; col += 1) { + const index = insertAfterRow * targetWidth + col; + if (map.map[index] === map.map[index - targetWidth]) { + covered.add(map.map[index]); + } else { + freeCols.push(col); + } + } + } else { + for (let col = 0; col < targetWidth; col += 1) { + freeCols.push(col); + } + } const newRows: Array = []; for (const sourceRow of grid) { + if (!sourceRow.length) { + return false; + } const cells: Array = []; - let col = 0; - for (const sourceCell of sourceRow) { - if (col >= targetWidth) { - break; // extra source columns are ignored (owner decision) + let srcIdx = 0; + let i = 0; + while (i < freeCols.length) { + const col = freeCols[i]; + // A pasted cell cannot span across a column covered by a merge. + let run = 1; + while (i + run < freeCols.length && freeCols[i + run] === col + run) { + run += 1; } - const span = Math.min(sourceCell.attrs.colspan as number, targetWidth - col); - cells.push(cellType.create(insertedCellAttrs(sourceCell, span), sourceCell.content)); - col += span; - } - while (col < targetWidth) { - const empty = cellType.createAndFill(); - if (!empty) { - return false; + if (srcIdx < sourceRow.length) { + const sourceCell = sourceRow[srcIdx]; + const span = Math.min(sourceCell.attrs.colspan as number, run); + cells.push(typeAt(col).create(insertedCellAttrs(sourceCell, span), sourceCell.content)); + srcIdx += 1; + i += span; + } else { + // Source columns exhausted — the rest are empty cells (owner + // decision); the source grid never recycles here. + const empty = typeAt(col).createAndFill(); + if (!empty) { + return false; + } + cells.push(empty); + i += 1; } - cells.push(empty); - col += 1; } newRows.push(state.schema.nodes.tableRow.createChecked(null, cells)); } const tr = state.tr; + // Extend the vertical merges crossing the insertion point. + for (const offset of covered) { + const node = table.nodeAt(offset); + if (!node) { + continue; + } + tr.setNodeMarkup(tableStart + offset, null, { + ...node.attrs, + rowspan: ((node.attrs.rowspan as number) || 1) + grid.length, + }); + } const pos = tableStart + rowStartOffset(table, insertAfterRow); tr.insert(pos, Fragment.fromArray(newRows)); tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(pos + 1)))); @@ -303,7 +356,11 @@ function handleCellPaste(view: EditorView, slice: Slice): boolean { export function cutCellSelection(view: EditorView): boolean { const { state } = view; const { selection } = state; - if (!(selection instanceof CellSelection) || selection.empty || !isInTable(state)) { + // Every CellSelection gets the Excel treatment, including a 1×1 one + // (copy + clear). Do NOT use CellSelection.empty here: from/to bound the + // head cell's content range, so `empty` is true for any rect whose head + // cell happens to be empty. + if (!(selection instanceof CellSelection) || !isInTable(state)) { return false; } From 8d9c46301eb0daeca0556f5914a10c7e5cb3e244 Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 21:03:09 +0800 Subject: [PATCH 04/15] test(debug): harden the table clipboard verification scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unique note per run (a restored workspace tab may hold a stale doc for a reused path), explicit cleanup — the leaf is detached before the verify note is deleted (rule-open-file-safety). --- .../debug/verify-table-clipboard-android.mjs | 25 ++++++++-- scripts/debug/verify-table-clipboard.mjs | 47 +++++++++++++------ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/scripts/debug/verify-table-clipboard-android.mjs b/scripts/debug/verify-table-clipboard-android.mjs index cdf6c06..e09487f 100644 --- a/scripts/debug/verify-table-clipboard-android.mjs +++ b/scripts/debug/verify-table-clipboard-android.mjs @@ -10,8 +10,10 @@ const R = {}; R.vault = await page.evaluate(() => window.app?.vault?.getName?.() ?? null); R.plugin = await page.evaluate(() => !!window.app?.plugins?.getPlugin?.('inscriptum')); -// write the test note (two 3-col tables) and open it -R.setup = await page.evaluate(async () => { +// write the test note (two 3-col tables) and open it; unique name per run +const notePath = `local/table-clipboard-verify-${Date.now()}.note`; +R.notePath = notePath; +R.setup = await page.evaluate(async (notePath) => { const mk = (name) => ({ type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } }, content: [ @@ -29,8 +31,8 @@ R.setup = await page.evaluate(async () => { mk('B'), ], }; - await app.vault.adapter.write('local/table-clipboard-verify.note', JSON.stringify(doc)); - const file = app.vault.getAbstractFileByPath('local/table-clipboard-verify.note'); + await app.vault.adapter.write(notePath, JSON.stringify(doc)); + const file = app.vault.getAbstractFileByPath(notePath); const leaf = app.workspace.getLeaf('tab'); await leaf.openFile(file); app.workspace.revealLeaf(leaf); @@ -57,7 +59,7 @@ R.setup = await page.evaluate(async () => { return out; }; return { ok: true, platform: { isMobile: /is-mobile/.test(document.body.className) || Boolean(window.Capacitor), ua: navigator.userAgent.slice(0, 60) } }; -}); +}, notePath); // rect paste: CellSelection over B00..B11 + synthetic paste with a copied 2x2 R.rectPaste = await page.evaluate(() => { @@ -89,5 +91,18 @@ R.rowsAtCaret = await page.evaluate(() => { return window.__tableTexts(1); }); +// cleanup: close the leaf, delete this run's note (file closed first) +R.cleanedUp = await page.evaluate(async (notePath) => { + const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === notePath); + if (leaf) { + try { leaf.detach(); } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + const file = app.vault.getAbstractFileByPath(notePath); + if (file) { + try { await app.vault.delete(file); } catch { return false; } + } + return true; +}, notePath); await browser.close().catch(() => {}); console.log(JSON.stringify(R, null, 2)); diff --git a/scripts/debug/verify-table-clipboard.mjs b/scripts/debug/verify-table-clipboard.mjs index e8915c2..f13e480 100644 --- a/scripts/debug/verify-table-clipboard.mjs +++ b/scripts/debug/verify-table-clipboard.mjs @@ -36,8 +36,12 @@ try { // bundle is already live; just confirm the plugin is enabled R.pluginEnabled = await page.evaluate(() => !!app.plugins.getPlugin('inscriptum')); - // 2. write the test note (two 3-col tables: header + 2 body rows each) - await page.evaluate(async () => { + // 2. write the test note (two 3-col tables: header + 2 body rows each). + // Unique name per run: a restored workspace tab may hold a stale doc + // for a reused path (the view does not necessarily reload from disk). + const notePath = `local/table-clipboard-verify-${Date.now()}.note`; + R.notePath = notePath; + await page.evaluate(async (notePath) => { const mk = (name) => ({ type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } }, content: [ @@ -55,11 +59,11 @@ try { mk('B'), ], }; - await app.vault.adapter.write('local/table-clipboard-verify.note', JSON.stringify(doc)); - const file = app.vault.getAbstractFileByPath('local/table-clipboard-verify.note'); + await app.vault.adapter.write(notePath, JSON.stringify(doc)); + const file = app.vault.getAbstractFileByPath(notePath); // close the tabs my earlier runs left behind, keep exactly one leaf const existing = app.workspace.getLeavesOfType('note-view') - .filter((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); + .filter((l) => (l.view?.file?.path ?? '').startsWith('local/table-clipboard-verify')); const leaf = app.workspace.getLeaf('tab'); await leaf.openFile(file); for (const l of existing) { @@ -68,16 +72,15 @@ try { } } app.workspace.revealLeaf(leaf); - }); + window.__ed = leaf.view.editor ?? null; + }, notePath); await page.waitForTimeout(1200); - R.editorUp = await page.evaluate(() => { - const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); - return !!(leaf?.view?.editor); - }); + R.editorUp = await page.evaluate(() => !!window.__ed); - // helpers evaluated in-page - await page.evaluate(() => { - const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === 'local/table-clipboard-verify.note'); + // helpers evaluated in-page (re-resolve the editor: it is created + // asynchronously after the leaf mounts) + await page.evaluate((notePath) => { + const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === notePath); window.__ed = leaf.view.editor; window.__cellPos = (text) => { let p = null; @@ -106,7 +109,7 @@ try { window.__ed.commands.focus(pos + 1); window.__ed.view.focus(); }; - }); + }, notePath); // focus the web contents with a trusted click into the editor, then keep // PM focus via the API (native copy/cut need OS-focused web contents) @@ -114,6 +117,7 @@ try { const r = window.__ed.view.dom.getBoundingClientRect(); return { x: r.x + Math.min(80, r.width / 2), y: r.y + 8 }; }); + if (edRect.width === 0) throw new Error('editor has no layout'); await page.mouse.click(edRect.x, edRect.y); await page.waitForTimeout(200); R.focusAfterClick = await page.evaluate(() => window.__ed.view.hasFocus()); @@ -199,6 +203,21 @@ try { await page.waitForTimeout(250); R.pasteAfterDragCopy = await page.evaluate(() => window.__tableTexts(1)); } + // cleanup: close the leaf (file not open anymore), then delete this run's + // note and any leftovers of earlier runs (explicit user-style deletes — + // never while the file is open in a view, see rule-open-file-safety) + await page.evaluate(async (notePath) => { + const files = app.vault.getFiles().filter((f) => f.path.startsWith('local/table-clipboard-verify')); + for (const f of files) { + const leaves = app.workspace.getLeavesOfType('note-view').filter((l) => l.view?.file?.path === f.path); + for (const l of leaves) { + try { l.detach(); } catch {} + } + await new Promise((r) => setTimeout(r, 150)); + try { await app.vault.delete(f); } catch {} + } + }, notePath); + R.cleanedUp = true; } finally { await browser.close().catch(() => {}); } From 0dacc649ecd14edb4301116396d368c2bd73b948 Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 21:05:48 +0800 Subject: [PATCH 05/15] =?UTF-8?q?docs(changelog):=20unreleased=20=E2=80=94?= =?UTF-8?q?=20table=20cell=20and=20row=20clipboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d028a..fd35cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Ячейки и строки таблиц наконец переходят из таблицы в таблицу: вставка на выделение заменяет содержимое ячеек (заливка и ширины целевых ячеек остаются своими), скопированные строки вставляются новыми рядами, а «вырезать» убирает выделенные строки целиком — вместо вложенной таблицы и сломанной вёрстки. + ## [0.12.1] - 2026-10-22 ### Changed From 5fac1db79bf1b0bbf264f5607acf476086ce386c Mon Sep 17 00:00:00 2001 From: sumbad Date: Thu, 22 Oct 2026 22:39:54 +0800 Subject: [PATCH 06/15] fix(table): survive non-string cell color attrs from clipboard HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tableCell/tableHeader parseHTML emitted dataColor: [] for every cell parsed from clipboard HTML; cells inserted from a paste carried that array into saved documents and the color rendering (safeColorValue → .trim) crashed the editor on the next open — the note became unopenable (issue table-clipboard report, 💪 one arm 2.note). - safeColorValue rejects non-strings; renderHTML checks the type before reading the attr (tableCell and tableHeader) - parseHTML returns a string or null instead of an array - new read-time repair normalizeCellColorAttrsJSON (wired into parseNoteDoc and UmNotepad.prepareNote like the trailing-paragraph repair) strips the broken values from already-saved files on open --- src/storage/noteStorage.ts | 5 +- src/storage/um/umNotepad.ts | 13 ++++ .../table/elements/header/header.ts | 7 +- .../table/elements/tableCell/tableCell.ts | 7 +- .../table/helpers/cellColorAttrs.test.ts | 68 +++++++++++++++++++ .../table/helpers/cellColorAttrs.ts | 63 +++++++++++++++++ .../table/helpers/safeStyle.test.ts | 6 ++ .../extensions/table/helpers/safeStyle.ts | 7 +- 8 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/texto/extensions/table/helpers/cellColorAttrs.test.ts create mode 100644 src/texto/extensions/table/helpers/cellColorAttrs.ts diff --git a/src/storage/noteStorage.ts b/src/storage/noteStorage.ts index 0905502..5b18eda 100644 --- a/src/storage/noteStorage.ts +++ b/src/storage/noteStorage.ts @@ -2,6 +2,7 @@ import type { DataAdapter, TFile, Vault } from "obsidian"; import type { JSONContent } from "../texto/core/@types"; import { ensureTrailingParagraphJSON } from "../texto/extensions/note-doc/trailingParagraph"; import { ensureCellTrailingParagraphJSON } from "../texto/extensions/table/helpers/cellTrailingParagraph"; +import { normalizeCellColorAttrsJSON } from "../texto/extensions/table/helpers/cellColorAttrs"; export const EMPTY_DOC: JSONContent = { type: "noteDoc", @@ -45,11 +46,13 @@ export function parseNoteDoc(raw: string): JSONContent { // prosemirror's nodeFromJSON rejects them ("Empty text nodes are not // allowed") and the note would never open. Strip them on read; the next // normalized autosave rewrites the file without them. - return ensureCellTrailingParagraphJSON( + const doc = ensureCellTrailingParagraphJSON( ensureTrailingParagraphJSON( sanitizeNoteDoc(JSON.parse(raw) as JSONContent), ), ); + normalizeCellColorAttrsJSON(doc); + return doc; } /** Recursively drop empty text nodes ("text" missing or "") from a parsed diff --git a/src/storage/um/umNotepad.ts b/src/storage/um/umNotepad.ts index 35ccbf9..a669f5d 100644 --- a/src/storage/um/umNotepad.ts +++ b/src/storage/um/umNotepad.ts @@ -3,6 +3,10 @@ import { ensureCellTrailingParagraphJSON, hasCellTrailingImage, } from "../../texto/extensions/table/helpers/cellTrailingParagraph"; +import { + hasCellColorAttrQuirk, + normalizeCellColorAttrsJSON, +} from "../../texto/extensions/table/helpers/cellColorAttrs"; import { assetExtension, assetPathForId, @@ -114,6 +118,15 @@ export class UmNotepad { this.dirtyNotes.add(id); } + // Cell color attrs (table-clipboard task): clipboard-parsed cells used + // to carry dataColor as an array, which crashed the color rendering on + // the next open. Normalize on read, like the trailing repair above. + if (hasCellColorAttrQuirk(prepared)) { + normalizeCellColorAttrsJSON(prepared); + this.data.notes.set(id, prepared); + this.dirtyNotes.add(id); + } + if (state.family === UM_SCHEMA_TITLE) { const normalized = normalizeTitleDoc(prepared); if (normalized.changed) { diff --git a/src/texto/extensions/table/elements/header/header.ts b/src/texto/extensions/table/elements/header/header.ts index 095c961..4a72a08 100644 --- a/src/texto/extensions/table/elements/header/header.ts +++ b/src/texto/extensions/table/elements/header/header.ts @@ -58,7 +58,7 @@ export const TableHeader = Node.create({ dataColor: string | null; backgroundColor: string | null; }) => { - if (!attributes.dataColor) { + if (typeof attributes.dataColor !== "string") { return {}; } @@ -75,8 +75,11 @@ export const TableHeader = Node.create({ : { ["data-color"]: attributes.dataColor }; }, parseHTML: (element) => { + // A string or nothing — an array here (the old quirk) flowed into + // saved documents through clipboard-parsed cells and crashed the + // color rendering on every later open. const dataColor = element.dataset["color"]; - return dataColor ? [dataColor] : []; + return dataColor ? dataColor : null; }, }, backgroundColor: { diff --git a/src/texto/extensions/table/elements/tableCell/tableCell.ts b/src/texto/extensions/table/elements/tableCell/tableCell.ts index 96b40dc..98a6e12 100644 --- a/src/texto/extensions/table/elements/tableCell/tableCell.ts +++ b/src/texto/extensions/table/elements/tableCell/tableCell.ts @@ -58,7 +58,7 @@ export const TableCell = Node.create({ dataColor: string | null; backgroundColor: string | null; }) => { - if (!attributes.dataColor) { + if (typeof attributes.dataColor !== "string") { return {}; } @@ -75,8 +75,11 @@ export const TableCell = Node.create({ : { ["data-color"]: attributes.dataColor }; }, parseHTML: (element) => { + // A string or nothing — an array here (the old quirk) flowed into + // saved documents through clipboard-parsed cells and crashed the + // color rendering on every later open. const dataColor = element.dataset["color"]; - return dataColor ? [dataColor] : []; + return dataColor ? dataColor : null; }, }, backgroundColor: { diff --git a/src/texto/extensions/table/helpers/cellColorAttrs.test.ts b/src/texto/extensions/table/helpers/cellColorAttrs.test.ts new file mode 100644 index 0000000..4569c3c --- /dev/null +++ b/src/texto/extensions/table/helpers/cellColorAttrs.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + hasCellColorAttrQuirk, + normalizeCellColorAttrsJSON, +} from "./cellColorAttrs"; + +type Json = Record; + +const cell = (attrs: Json, text = "x"): Json => ({ + type: "tableCell", + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); + +const doc = (cells: Json[]): Json => ({ + type: "noteDoc", + content: [ + { + type: "table", + content: cells.map((c) => ({ type: "tableRow", content: [c] })), + }, + ], +}); + +describe("hasCellColorAttrQuirk", () => { + it("detects the clipboard-parse array in dataColor", () => { + expect(hasCellColorAttrQuirk(doc([cell({ dataColor: [] })]))).toBe(true); + }); + + it("detects a non-string backgroundColor", () => { + expect(hasCellColorAttrQuirk(doc([cell({ backgroundColor: 5 })]))).toBe(true); + }); + + it("accepts strings, null and absent attrs", () => { + expect( + hasCellColorAttrQuirk( + doc([cell({ dataColor: "#ff0000", backgroundColor: null })]), + ), + ).toBe(false); + expect(hasCellColorAttrQuirk(doc([cell({})]))).toBe(false); + }); +}); + +describe("normalizeCellColorAttrsJSON", () => { + it("nulls the broken attrs and keeps well-formed colors", () => { + const document = doc([ + cell({ dataColor: [], backgroundColor: "#00ff00", colwidth: [50] }), + cell({ dataColor: "#112233" }), + ]); + normalizeCellColorAttrsJSON(document as never); + // doc() gives every cell its own row + const rows = (((document.content as Json[])[0].content ?? []) as Json[]); + const cellAt = (row: number) => + (((rows[row].content ?? []) as Json[])[0].attrs ?? {}) as Json; + expect(cellAt(0).dataColor).toBeNull(); + expect(cellAt(0).backgroundColor).toBe("#00ff00"); + expect(cellAt(0).colwidth).toEqual([50]); + expect(cellAt(1).dataColor).toBe("#112233"); + }); + + it("leaves documents without the quirk untouched (in place, no throw)", () => { + const document = doc([cell({ dataColor: "#112233" })]); + expect(() => normalizeCellColorAttrsJSON(document as never)).not.toThrow(); + const table = (document.content as Json[])[0]; + expect(Array.isArray(table.content)).toBe(true); + }); +}); diff --git a/src/texto/extensions/table/helpers/cellColorAttrs.ts b/src/texto/extensions/table/helpers/cellColorAttrs.ts new file mode 100644 index 0000000..e666eff --- /dev/null +++ b/src/texto/extensions/table/helpers/cellColorAttrs.ts @@ -0,0 +1,63 @@ +/** + * Read-time repair for table cell color attributes (JSON level, same layer + * as ensureCellTrailingParagraphJSON). + * + * tableCell/tableHeader parseHTML used to emit `dataColor: []` (an array) + * for every cell parsed from clipboard HTML. Cells inserted from a paste + * carried that array into saved documents, and the color rendering + * (safeColorValue → .trim) crashed the editor on the next open of the note. + * The render side now guards on the type (safeColorValue, renderHTML), and + * this repair strips the broken values from already-saved files on read — + * the next autosave persists the normalized document. + */ + +type JsonNode = { type?: string; attrs?: Record; content?: JsonNode[] }; + +const CELL_TYPES = new Set(["tableCell", "tableHeader"]); + +function colorAttrIsBroken(value: unknown): boolean { + return value != null && typeof value !== "string"; +} + +/** True when any table cell carries a non-string color attr — the quirk + * `normalizeCellColorAttrsJSON` repairs. Pure check for callers that need + * to know whether the repair changed anything (the repair mutates in + * place), mirroring hasCellTrailingImage. */ +export function hasCellColorAttrQuirk(doc: JsonNode | null): boolean { + if (doc == null) return false; + const content = doc.content; + if (!Array.isArray(content)) return false; + if (CELL_TYPES.has(doc.type ?? "")) { + const attrs = doc.attrs; + if (attrs != null && (colorAttrIsBroken(attrs.dataColor) || colorAttrIsBroken(attrs.backgroundColor))) { + return true; + } + } + for (const child of content) { + if (child != null && hasCellColorAttrQuirk(child)) return true; + } + return false; +} + +/** Normalize cell color attrs in place: a non-string dataColor or + * backgroundColor becomes null. Well-formed strings are kept untouched — + * render-side sanitization (safeColorValue) stays the authority on what + * may reach an inline style. */ +export function normalizeCellColorAttrsJSON(doc: T): void { + const walk = (node: JsonNode): void => { + if (CELL_TYPES.has(node.type ?? "") && node.attrs != null) { + if (colorAttrIsBroken(node.attrs.dataColor)) { + node.attrs.dataColor = null; + } + if (colorAttrIsBroken(node.attrs.backgroundColor)) { + node.attrs.backgroundColor = null; + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) { + if (child != null) walk(child); + } + } + }; + if (doc != null) walk(doc); +} diff --git a/src/texto/extensions/table/helpers/safeStyle.test.ts b/src/texto/extensions/table/helpers/safeStyle.test.ts index e091658..701fa05 100644 --- a/src/texto/extensions/table/helpers/safeStyle.test.ts +++ b/src/texto/extensions/table/helpers/safeStyle.test.ts @@ -20,6 +20,12 @@ describe("safeColorValue (inline-style injection guard)", () => { expect(safeColorValue("#b3a3f7; color: red")).toBeNull(); expect(safeColorValue("")).toBeNull(); }); + + it("rejects non-strings (clipboard-parsed cells used to carry [])", () => { + expect(safeColorValue([] as never)).toBeNull(); + expect(safeColorValue(undefined as never)).toBeNull(); + expect(safeColorValue({} as never)).toBeNull(); + }); }); describe("bordersToStyle injection guard", () => { diff --git a/src/texto/extensions/table/helpers/safeStyle.ts b/src/texto/extensions/table/helpers/safeStyle.ts index 227e7cd..1fb3721 100644 --- a/src/texto/extensions/table/helpers/safeStyle.ts +++ b/src/texto/extensions/table/helpers/safeStyle.ts @@ -16,8 +16,13 @@ export const SAFE_HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; export const SAFE_RGB_COLOR = /^rgba?\(\s*[0-9.]+\s*,\s*[0-9.]+\s*,\s*[0-9.]+\s*(?:,\s*[0-9.]+\s*)?\)$/i; -/** A color value safe for inline-style interpolation, or null. */ +/** A color value safe for inline-style interpolation, or null. Stored attrs + * are untrusted: a non-string (e.g. the [] that tableCell.parseHTML used to + * emit for cells parsed from clipboard HTML) must not reach .trim(). */ export function safeColorValue(value: string): string | null { + if (typeof value !== "string") { + return null; + } const v = value.trim(); if (SAFE_HEX_COLOR.test(v) || SAFE_RGB_COLOR.test(v)) { return v; From 9be7d1b6733cd76c31f47496ea70d5dadce93f00 Mon Sep 17 00:00:00 2001 From: sumbad Date: Fri, 23 Oct 2026 01:43:32 +0800 Subject: [PATCH 07/15] fix(table): column paste at a caret, single-cell selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual-review round two (issue table-clipboard): - pasting a column-shaped copy (single-cell-wide, multi-row) at a caret inserts a new column right of the caret column and fills it with the copied cells row by row — it used to fall into the row-insert path and grow the table downward - a circle press released without any movement now selects the anchor cell (desktop) — a plain click places a caret and the drag needs a second cell, so a single cell could not be selected; the touch long- press path commits the same way on release without movement --- .../table/helpers/cellClipboard.test.ts | 52 +++++++++++++++ .../extensions/table/helpers/cellClipboard.ts | 64 +++++++++++++++++++ .../table/helpers/handleMouseDown.ts | 14 ++++ .../table/helpers/handleTouchStart.ts | 8 +++ 4 files changed, 138 insertions(+) diff --git a/src/texto/extensions/table/helpers/cellClipboard.test.ts b/src/texto/extensions/table/helpers/cellClipboard.test.ts index 237af0f..23737ed 100644 --- a/src/texto/extensions/table/helpers/cellClipboard.test.ts +++ b/src/texto/extensions/table/helpers/cellClipboard.test.ts @@ -400,6 +400,58 @@ describe("paste at a caret", () => { const mergedNode = next.doc.nodeAt(positions[0]); expect(mergedNode?.attrs.rowspan).toBe(3); }); + + it("pastes a copied column at a caret: a new column, not rows", () => { + // 3x2 target; the copy is a 2-cell vertical run (a column-shaped grid). + const doc = twoTablesDoc(); + const [b0] = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, b0 + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([cell("K0")]), + row([cell("K1")]), + ]), 1, 1)); + // A column is inserted right of the caret column, filled top down. + expect(cellTexts(next.doc, 1)).toEqual([ + "B00", "K0", "B01", + "B10", "K1", "B11", + ]); + // Still 2 rows — rows must not appear. + let rowCount = 0; + let seen = -1; + next.doc.descendants((node) => { + if (node.type.name === "table") { seen += 1; return seen === 1; } + if (seen === 1 && node.type.name === "tableRow") rowCount += 1; + return true; + }); + expect(rowCount).toBe(2); + }); + + it("column paste fills only the available rows and keeps types", () => { + // Target: header row + 1 body row; the copy is a 2-row single-cell grid + // (a column) — both rows fill, the copy's second cell is dropped. + const doc = schema.nodes.noteDoc.create(null, [ + schema.nodes.noteTitle.create(null, [schema.text("T")]), + table([row([cell("A0"), cell("A1")])]), + schema.nodes.paragraph.create(null, [schema.text("between")]), + table([ + row([headerCell("Bh"), cell("B0")]), + row([cell("B1"), cell("B2")]), + ]), + ]); + const positions = cellPositions(doc, 1); + const state = stateWithSelection(doc, TextSelection.create(doc, positions[1] + 1)); + const next = paste(state, new Slice(Fragment.fromArray([ + row([headerCell("K")]), + row([cell("K2")]), + row([cell("K3")]), + ]), 1, 1)); + // The column is inserted right of the caret's column (B0 is the second + // column); the third copied row is dropped (the target has only two). + expect(cellTexts(next.doc, 1)).toEqual(["Bh", "B0", "K", "B1", "B2", "K2"]); + // The filled cell of the header row is a header cell. + const newTop = next.doc.nodeAt(cellPositions(next.doc, 1)[2]); + expect(newTop?.type.name).toBe("tableHeader"); + }); }); describe("cut (Excel semantics)", () => { diff --git a/src/texto/extensions/table/helpers/cellClipboard.ts b/src/texto/extensions/table/helpers/cellClipboard.ts index 65a3c78..ff78d6a 100644 --- a/src/texto/extensions/table/helpers/cellClipboard.ts +++ b/src/texto/extensions/table/helpers/cellClipboard.ts @@ -2,6 +2,7 @@ import { Fragment, Slice, type Node as ProseMirrorNode } from "prosemirror-model import { Plugin, TextSelection, type EditorState, type Transaction } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { + addColumn, CellSelection, deleteRow, deleteTable, @@ -304,6 +305,64 @@ function insertRowsAfterCaret( return true; } +/** Paste a copied column at a caret: a new column is inserted right of the + * caret's column (the addColumn conventions — merges crossing it extend) + * and filled with the copied cells row by row, table top down. Rows below + * the copied grid stay empty; extra copied rows are dropped. */ +function insertColumnAfterCaret( + state: EditorState, + dispatch: (tr: Transaction) => void, + grid: Array>, +): boolean { + const $cell = selectionCell(state); + const table = $cell.node(-1); + const tableStart = $cell.start(-1); + const map = TableMap.get(table); + const cursorRect = map.findCell($cell.pos - tableStart); + const insertAt = cursorRect.right; // 0-based index of the new column + + const tr = state.tr; + // addColumn's JS implementation only destructures {map, tableStart, table} + // — the TableRect type demands the rect fields the code never reads. + addColumn(tr, { map, tableStart, table } as unknown as TableRect, insertAt); + + // Fill the new column's cells with the copied contents. Every position is + // computed against the post-addColumn document; the fills run bottom-up so + // an earlier (upper) position stays valid after a lower edit. addColumn + // creates plain body cells at the right edge — the header row gets a + // header cell instead (the row's own type wins). + const filled = tr.doc.nodeAt(tableStart - 1); + if (!filled) { + return false; + } + const filledMap = TableMap.get(filled); + const rowsToFill = Math.min(filledMap.height, grid.length); + for (let row = rowsToFill - 1; row >= 0; row -= 1) { + const cellPos = tableStart + filledMap.map[row * filledMap.width + insertAt]; + const target = tr.doc.nodeAt(cellPos); + const rowType = filled.child(row).child(0)?.type ?? target?.type; + const source = grid[row][0]; + if (!target || !rowType || !source) { + continue; + } + const newCell = rowType.create(target.attrs, source.content); + tr.replace(cellPos, cellPos + target.nodeSize, new Slice(Fragment.from(newCell), 0, 0)); + } + + // Caret into the new column at the caret row (recomputed — the fills + // shifted the lower rows). + const finalTable = tr.doc.nodeAt(tableStart - 1); + if (finalTable) { + const finalMap = TableMap.get(finalTable); + const caretRow = Math.min(cursorRect.top, finalMap.height - 1); + const selOffset = finalMap.map[caretRow * finalMap.width + insertAt]; + tr.setSelection(TextSelection.near(tr.doc.resolve(tableStart + selOffset + 1))); + } + tr.scrollIntoView(); + dispatch(tr); + return true; +} + /** Paste handling for table fragments (owner semantics above). Returns false * for non-table content so the default paste path takes over. */ function handleCellPaste(view: EditorView, slice: Slice): boolean { @@ -321,6 +380,11 @@ function handleCellPaste(view: EditorView, slice: Slice): boolean { if (!isInTable(state)) { return false; } + // A single-cell-wide, multi-row copy is a column (Excel semantics): + // pasting it at a caret inserts a new column, it does not insert rows. + if (grid.length > 1 && grid.every((sourceRow) => sourceRow.length === 1)) { + return insertColumnAfterCaret(state, dispatch, grid); + } // Excel: a single copied cell pasted at a caret replaces the caret cell's // content (target attributes preserved), it does not insert a row. if (grid.length === 1 && grid[0].length === 1) { diff --git a/src/texto/extensions/table/helpers/handleMouseDown.ts b/src/texto/extensions/table/helpers/handleMouseDown.ts index 7a2286a..07f02a8 100644 --- a/src/texto/extensions/table/helpers/handleMouseDown.ts +++ b/src/texto/extensions/table/helpers/handleMouseDown.ts @@ -220,6 +220,7 @@ export function handleMouseDown( // degrade on mobile (workspace-drawer-backdrop). let topLeftAnchor = Number(target.dataset.cellPos) || null; let isFirstMove = true; + let movedSelection = false; function setCellSelection($anchor: ResolvedPos | null, event: MouseEvent) { if (!$anchor) return; @@ -249,6 +250,7 @@ export function handleMouseDown( } const selection = new TextoCellSelection($anchor, $head); + movedSelection = true; view.dispatch( view.state.tr.setSelection(selection).setMeta(tableEditingKey, selection), ); @@ -262,6 +264,18 @@ export function handleMouseDown( if (tableEditingKey.getState(view.state) != null) { view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); } + // A circle press without any movement selects the anchor cell — the + // only way to get a single-cell selection on desktop (a plain click in + // a cell places the caret, the drag needs a second cell). + if (!movedSelection && topLeftAnchor != null) { + const selection = new TextoCellSelection( + view.state.doc.resolve(topLeftAnchor), + ); + view.dispatch( + view.state.tr.setSelection(selection).setMeta(tableEditingKey, selection), + ); + scheduleOverlayRefresh(view); + } // The circle-mousedown is preventDefault-ed, so the browser never moves // focus into the editor: without an explicit focus the clipboard // shortcuts (Cmd+C/X/V) die on the document body and copy nothing. diff --git a/src/texto/extensions/table/helpers/handleTouchStart.ts b/src/texto/extensions/table/helpers/handleTouchStart.ts index 897aa92..ff3809d 100644 --- a/src/texto/extensions/table/helpers/handleTouchStart.ts +++ b/src/texto/extensions/table/helpers/handleTouchStart.ts @@ -239,6 +239,7 @@ function runCellDrag( ): void { let topLeftAnchor = anchorPos; let isFirstMove = true; + let movedSelection = false; function point(event: TouchEvent) { return event.touches[0] ?? event.changedTouches[0]; @@ -253,6 +254,12 @@ function runCellDrag( // Suppress the synthetic click: it would collapse the freshly built // cell selection back to a text cursor. event.preventDefault(); + // A press released without any movement selects the anchor cell — the + // long-press twin of the desktop circle-click single-cell selection. + if (!movedSelection && anchorPos != null) { + const $anchor = view.state.doc.resolve(anchorPos); + dispatchCellSelection(view, $anchor, $anchor); + } if (tableEditingKey.getState(view.state) != null) { view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); } @@ -281,6 +288,7 @@ function runCellDrag( isFirstMove = false; if (!drag) return; + movedSelection = true; topLeftAnchor = drag.topLeft; dispatchCellSelection(view, drag.anchor, drag.head); } From a38e0da1a56a30a7e4ed6a603031d489dc2720bd Mon Sep 17 00:00:00 2001 From: sumbad Date: Fri, 23 Oct 2026 10:15:53 +0800 Subject: [PATCH 08/15] feat(table): Copy/Cut/Paste buttons in the table bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue table-clipboard-buttons. The structural table bar (docked in the toolbar on BOTH platforms) gains clipboard buttons — on mobile there is no Cmd+C and the cells-format bubble is desktop-only: - copyTableSelection/cutTableSelection accept a cell selection or the caret cell (1×1 — the only single-cell affordance on desktop once the circle is gone); cut keeps the Excel semantics - pasteFromSystemClipboard reads the system clipboard inside the click gesture (rich with a plain-text fallback) and feeds it through the editor's own paste pipeline via a synthetic paste event; the Paste button is hidden when no clipboard read API exists - icons: copy (from the toolbar set), cut, paste --- .../table-bubble-menu-bar.element.tsx | 48 ++++++++ src/components/icons/iconSprite.ts | 8 ++ .../extensions/table/helpers/cellClipboard.ts | 112 ++++++++++++++++++ 3 files changed, 168 insertions(+) diff --git a/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx b/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx index fff0e31..c313676 100644 --- a/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx +++ b/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx @@ -20,6 +20,11 @@ import type { BorderPen, BorderSideName, } from "../../texto/extensions/table/helpers/borders"; +import { + copyTableSelection, + cutTableSelection, + pasteFromSystemClipboard, +} from "../../texto/extensions/table/helpers/cellClipboard"; import type { BubbleIconName } from "../icons/iconSprite"; import { bubbleIconNodes } from "./icons.svgnode"; @@ -268,6 +273,22 @@ export const TableBubbleMenuElement = litView.element({ .run(); }; + /* ── Clipboard actions (issue table-clipboard-buttons): a cell selection + or the caret cell (1×1). Paste reads the system clipboard inside the + click gesture and feeds it through the editor's paste pipeline. ── */ + const copyCells = () => { + copyTableSelection(props.editor.view); + }; + const cutCells = () => { + cutTableSelection(props.editor.view); + }; + const pasteCells = () => { + void pasteFromSystemClipboard(props.editor.view); + }; + const canReadClipboard = + typeof navigator !== "undefined" && + !!(navigator.clipboard?.read || navigator.clipboard?.readText); + const syncCaret = () => { const editor = props.editor; if (editor.isDestroyed || !editor.view) return; @@ -426,6 +447,33 @@ export const TableBubbleMenuElement = litView.element({ {bubbleIconNodes.colRight()} + + + {canReadClipboard && ( + + )} + + +