diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 29d9f7c4..b00b03f1 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -164,6 +164,51 @@ function sourceOffsetForCell(lines: string[], lineIdx: number, colIdx: number, t return lineStart + contentFrom; } +/** + * `rel` 落在 `tableSource` 的第几行;越界返回 null。 + * + * 刻意写成单遍扫描:`toDOM` 每渲染一行都会调一次, + * 用 `slice(0, rel).split("\n")` 会把整张表复制 N 遍。 + */ +function lineIndexForOffset(tableSource: string, rel: number): number | null { + if (!Number.isFinite(rel) || rel < 0 || rel > tableSource.length) return null; + let line = 0; + for ( + let i = tableSource.indexOf("\n"); + i !== -1 && i < rel; + i = tableSource.indexOf("\n", i + 1) + ) { + line++; + } + return line; +} + +/** + * AST 第 `rowIdx` 行落在 `source` 的第几行。 + * + * 分隔行不在 mdast 里,所以行索引 ≠ 源码行号。每一行都自带绝对文档偏移 + * (`adaptTable` 为每个 `TableHeader` / `TableRow` 记了 `position(c.from, c.to)`), + * `tableFrom` 是表格节点自身的偏移,两者相减即可直接定位到 `source`。 + * 这比嗅探行内容可靠:内容分不出分隔行和写成 `| - | - |` 的数据行。 + * + * 位置缺失或越出快照时返回 undefined。调用方本就把 undefined 当 no-op, + * 而且这是安全的方向:算错行号会经 `dirtyRows` 覆盖掉一行真实源码, + * 算不出来只是放弃这次编辑。 + */ +function sourceLineForRow( + node: Table, + tableFrom: number, + source: string, + rowIdx: number +): number | undefined { + const row = (node.children ?? [])[rowIdx] as + | { position?: { start?: { offset?: number } } } + | undefined; + const offset = row?.position?.start?.offset; + if (typeof offset !== "number") return undefined; + return lineIndexForOffset(source, offset - tableFrom) ?? undefined; +} + function getNodeSourceOffsets( node: any, tableFrom: number, @@ -613,19 +658,48 @@ export class EditableTableWidget extends WidgetType { this.dispatch(newLines.join("\n")); } - private deleteRow(rowIdx: number): void { + /** AST 第 `rowIdx` 行在本 widget 快照里的源码行号。 */ + private rowSourceLine(rowIdx: number): number | undefined { + return sourceLineForRow(this.node, this.tableFrom, this.source, rowIdx); + } + + /** + * 分隔行所在的源码行;判断不出来时返回 undefined。 + * GFM 不把分隔行写进 mdast,所以它就是表格里唯一没有被任何 AST 行认领的那一行。 + */ + private delimiterLine(): number | undefined { const lines = this.source.split("\n"); - const dataLines: number[] = []; - for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dataLines.push(i); - const lineIdx = dataLines[rowIdx]; + const claimed = new Set(); + for (let rowIdx = 0; rowIdx < (this.node.children ?? []).length; rowIdx++) { + const line = this.rowSourceLine(rowIdx); + if (line === undefined) return undefined; + claimed.add(line); + } + const unclaimed: number[] = []; + for (let i = 0; i < lines.length; i++) if (!claimed.has(i)) unclaimed.push(i); + return unclaimed.length === 1 ? unclaimed[0] : undefined; + } + + private deleteRow(rowIdx: number): void { + const lineIdx = this.rowSourceLine(rowIdx); if (lineIdx === undefined) return; + const lines = this.source.split("\n"); lines.splice(lineIdx, 1); this.dispatch(lines.join("\n")); } private addColumn(): void { const lines = this.source.split("\n"); - const nl = lines.map((l) => SEPARATOR_RE.test(l) ? l.replace(/\|?\s*$/, " | --- |") : l.replace(/\|?\s*$/, " | |")); + // 只有分隔行补 `---`,内容恰好全是横线的数据行补空单元格。 + // 定位不到分隔行时退回内容判断:AST 退化也不该让「加列」直接不能编辑。 + const delim = this.delimiterLine(); + const isDelimiter = + delim !== undefined && SEPARATOR_RE.test(lines[delim] ?? "") + ? (i: number) => i === delim + : (i: number) => SEPARATOR_RE.test(lines[i]); + const nl = lines.map((l, i) => + isDelimiter(i) ? l.replace(/\|?\s*$/, " | --- |") : l.replace(/\|?\s*$/, " | |") + ); this.dispatch(nl.join("\n")); } @@ -650,11 +724,12 @@ export class EditableTableWidget extends WidgetType { } private moveRow(from: number, to: number, source = this.source): void { - const lines = source.split("\n"); - const dl: number[] = []; - for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dl.push(i); - const s = dl[from], d = dl[to]; + // `onDragEnd` 传入的是冲刷过待提交单元格的快照,好让未提交的文字跟着行一起走。 + // 行号仍按 `this.source` 解析:快照是整行原地重写,行号不变, + // 但行位置依赖的字节偏移在新快照里已失效。 + const s = this.rowSourceLine(from), d = this.rowSourceLine(to); if (s === undefined || d === undefined) return; + const lines = source.split("\n"); const [m] = lines.splice(s, 1); lines.splice(d, 0, m); this.dispatch(lines.join("\n")); @@ -674,8 +749,11 @@ export class EditableTableWidget extends WidgetType { if (len > colCount) colCount = len; } const sourceLines = this.source.split("\n"); - const dataLineIndices: number[] = []; - for (let i = 0; i < sourceLines.length; i++) if (!SEPARATOR_RE.test(sourceLines[i])) dataLineIndices.push(i); + // 按渲染顺序每个 AST 行一项,位置不可用的行留空。 + // 下面两个读取方本就把 undefined 当「跳过」。 + const dataLineIndices: (number | undefined)[] = rows.map((_, rowIdx) => + self.rowSourceLine(rowIdx) + ); const dirtyRows = new Map(); // State @@ -1375,10 +1453,7 @@ export class EditableTableWidget extends WidgetType { if (!range) return false; const inputRows = text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.split("\t")); const lines = self.source.split("\n"); - const dataLines: number[] = []; - for (let index = 0; index < lines.length; index++) { - if (!SEPARATOR_RE.test(lines[index])) dataLines.push(index); - } + const dataLines = (self.node.children ?? []).map((_, rowIdx) => self.rowSourceLine(rowIdx)); for (let row = range.r1; row <= range.r2; row++) { const lineIndex = dataLines[row]; if (lineIndex === undefined) continue; @@ -2382,8 +2457,7 @@ export class EditableTableWidget extends WidgetType { if (range) { e.preventDefault(); const lines = self.source.split("\n"); - const dl: number[] = []; - for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dl.push(i); + const dl = (self.node.children ?? []).map((_, rowIdx) => self.rowSourceLine(rowIdx)); for (let r = range.r1; r <= range.r2; r++) { const lineIdx = dl[r]; if (lineIdx === undefined) continue; diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index f0beda39..87a694c7 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -3,6 +3,10 @@ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { createGfmPreset } from "../../preset-gfm/src/index"; import { createEditor, type EditorAPI } from "../src/index"; +import { + EDITOR_INPUT_TARGET_PROVIDER, + type InputTargetProviderElement, +} from "../src/input-target-provider"; beforeAll(() => { if (!("getClientRects" in Range.prototype)) { @@ -56,6 +60,16 @@ async function nextAnimationFrame(): Promise { await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); } +/** + * 渲染后带内容单元格的表格行,按 DOM 顺序:`[0]` 表头,`[1..]` 数据行。 + * 过滤掉合成的握把行 —— 它只有握把,没有 `.nexus-cell`。 + */ +function tableCellRows(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll("tr")).filter( + (row) => row.querySelector(".nexus-cell") + ); +} + describe("live preview", () => { // ── Inline formatting ── // Note: inline markers are hidden when cursor is on a DIFFERENT line (line-level detection). @@ -561,6 +575,309 @@ describe("live preview", () => { // ── Tables ── + // 单元格全是横线的行(`| - | - |`)是数据行,不是分隔行。 + // 分隔行固定在表头下一行;区分二者只能靠结构 —— + // 嗅探行内容会把两者都当成 "separator"。 + describe("table row → source line mapping", () => { + const sourceWithDashRow = "| A | B |\n| --- | --- |\n| - | - |\n| x | y |"; + + it("maps each row to its own source line when a data row is all dashes", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + // 表头 → 第 0 行,`| - | - |` → 第 2 行,`| x | y |` → 第 3 行。 + // 第 1 行的分隔行不是渲染出来的行。 + expect(tableCellRows(container).map((row) => row.dataset.sourceLineIdx)).toEqual([ + "0", + "2", + "3" + ]); + editor.destroy(); + }); + + it("keeps the following row intact when an all-dash row is edited", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const dashCell = tableCellRows(container)[1].querySelectorAll(".nexus-cell")[0]; + activateTableCell(dashCell); + dashCell.textContent = "edited"; + dashCell.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText", data: "edited" }) + ); + await blurCellOutsideTable(dashCell); + + // 改动落在第 2 行;真正属于第 3 行的那一行不受影响。 + expect(editor.getDocument()).toContain("edited"); + expect(editor.getDocument()).toContain("| x | y |"); + editor.destroy(); + container.remove(); + }); + + it("commits edits made in the row that follows an all-dash row", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const lastCell = tableCellRows(container)[2].querySelectorAll(".nexus-cell")[0]; + activateTableCell(lastCell); + lastCell.textContent = "changed"; + lastCell.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText", data: "changed" }) + ); + await blurCellOutsideTable(lastCell); + + expect(editor.getDocument()).toContain("changed"); + editor.destroy(); + container.remove(); + }); + + it("maps a trailing all-dash row to its own source line", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| x | y |\n| --- |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + expect(tableCellRows(container).map((row) => row.dataset.sourceLineIdx)).toEqual([ + "0", + "2", + "3" + ]); + editor.destroy(); + }); + + it("maps a non-pipe line that the parser absorbs into the table", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| x | y |\ntext", + livePreview: true, + plugins: [createGfmPreset()] + }); + + // Lezer 会把末尾的普通行也并进表格,所以行数并非 `源码行数 - 1`; + // 映射只能来自节点位置。 + const rows = tableCellRows(container); + expect(rows.length).toBe(3); + expect(rows.map((row) => row.dataset.sourceLineIdx)).toEqual(["0", "2", "3"]); + editor.destroy(); + }); + + it("only gives the new delimiter cell to the delimiter row", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const addColumn = Array.from(container.querySelectorAll("button")).find( + (button) => button.title === "Add column" + ); + expect(addColumn).not.toBeUndefined(); + addColumn!.click(); + + // 第 1 行是分隔行,补 `---`;第 2 行只是长得像分隔行, + // 必须补空单元格。 + const lines = editor.getDocument().split("\n"); + expect(lines[1]).toContain("---"); + expect(lines[2]).not.toContain("---"); + editor.destroy(); + }); + + it("leaves the mapping unchanged for a table with no dash rows", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + expect(tableCellRows(container).map((row) => row.dataset.sourceLineIdx)).toEqual(["0", "2"]); + editor.destroy(); + }); + + // 下面四个调用点共用同一套「行 → 源码行」解析,之前都以同样的方式错位: + // 全横线行拿到了后一行的行号,而后者一行号都没剩下。 + + it("deletes the row that owns the grip, not the row after it", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + // 握把在全横线行上:AST 第 1 行、源码第 2 行。 + // 嗅探内容会把 AST 第 1 行映射到第 3 行,于是 Delete 删掉了 `| x | y |`。 + const rowGrip = tableCellRows(container)[1].querySelector(".nexus-row-grip"); + expect(rowGrip).not.toBeNull(); + rowGrip!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const wrapper = container.querySelector(".nexus-table-wrapper"); + expect(wrapper).not.toBeNull(); + const deleteRow = new KeyboardEvent("keydown", { + key: "Delete", + bubbles: true, + cancelable: true + }); + wrapper!.dispatchEvent(deleteRow); + + expect(deleteRow.defaultPrevented).toBe(true); + expect(editor.getDocument()).toBe("| A | B |\n| --- | --- |\n| x | y |"); + editor.destroy(); + container.remove(); + }); + + it("reorders rows by grip drag when a data row is all dashes", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const rows = tableCellRows(container); + rows[1].getBoundingClientRect = () => new DOMRect(0, 30, 100, 30); + rows[2].getBoundingClientRect = () => new DOMRect(0, 60, 100, 30); + + // 把全横线行(AST 第 1 行)拖到 `| x | y |`(AST 第 2 行)原本毫无反应: + // 内容扫描没给 AST 第 2 行行号,`moveRow` 还没开始搬运就返回了。 + const grip = rows[1].querySelector(".nexus-row-grip"); + expect(grip).not.toBeNull(); + grip!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 5, + clientY: 45 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 5, + clientY: 75 + })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 5, clientY: 75 })); + + expect(editor.getDocument()).toBe("| A | B |\n| --- | --- |\n| x | y |\n| - | - |"); + editor.destroy(); + container.remove(); + }); + + it("clears a selected range that spans an all-dash row", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const rows = tableCellRows(container); + const firstCell = rows[1].querySelectorAll(".nexus-cell")[0]; + const lastCell = rows[2].querySelectorAll(".nexus-cell")[0]; + firstCell.getBoundingClientRect = () => new DOMRect(0, 0, 50, 30); + lastCell.getBoundingClientRect = () => new DOMRect(0, 30, 50, 30); + + // 只选中 AST 第 1..2 行、第 0 列。 + firstCell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 25, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, button: 0, clientX: 25, clientY: 45 })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 25, clientY: 45 })); + + const wrapper = container.querySelector(".nexus-table-wrapper"); + expect(wrapper).not.toBeNull(); + const clearRange = new KeyboardEvent("keydown", { + key: "Delete", + bubbles: true, + cancelable: true + }); + wrapper!.dispatchEvent(clearRange); + + expect(clearRange.defaultPrevented).toBe(true); + // 两行的第 0 列都被清空。内容扫描只能碰到后者, + // 全横线行会原封不动。 + expect(editor.getDocument()).toBe("| A | B |\n| --- | --- |\n| | - |\n| | y |"); + editor.destroy(); + container.remove(); + }); + + it("pastes into a selected range that spans an all-dash row", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: sourceWithDashRow, + livePreview: true, + plugins: [createGfmPreset()] + }); + + const rows = tableCellRows(container); + const firstCell = rows[1].querySelectorAll(".nexus-cell")[0]; + const lastCell = rows[2].querySelectorAll(".nexus-cell")[1]; + firstCell.getBoundingClientRect = () => new DOMRect(0, 0, 50, 30); + lastCell.getBoundingClientRect = () => new DOMRect(50, 30, 50, 30); + + // 跨两列选中 AST 第 1..2 行。 + firstCell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 25, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, button: 0, clientX: 75, clientY: 45 })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 75, clientY: 45 })); + + // 粘贴经表格的 input target provider 进入。 + const wrapper = container.querySelector(".nexus-table-wrapper") as + | InputTargetProviderElement + | null; + const provider = wrapper?.[EDITOR_INPUT_TARGET_PROVIDER]; + expect(provider).not.toBeUndefined(); + const target = provider!.resolveInputTarget(firstCell); + expect(target).not.toBeNull(); + expect(target!.replaceSelection("P\tQ\nR\tS")).toBe(true); + + expect(editor.getDocument()).toBe("| A | B |\n| --- | --- |\n| P | Q |\n| R | S |"); + editor.destroy(); + container.remove(); + }); + }); + it("renders tables as editable widget with grip cells", () => { const container = document.createElement("div"); const editor = createEditor({