From 2f1efaa8181318b79112acc06147667b434b8898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Tue, 7 Jul 2026 12:28:35 +0800 Subject: [PATCH 1/6] fix: stabilize markdown table search and selection --- packages/core/src/live-preview-table.ts | 13 +- packages/core/test/live-preview.test.ts | 83 ++++++++ packages/plugin-search/src/index.ts | 190 +++++++++++++++++- .../plugin-search/test/plugin-search.test.ts | 86 +++++++- 4 files changed, 367 insertions(+), 5 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index b5acf20b..02a1d895 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -1210,6 +1210,8 @@ export class EditableTableWidget extends WidgetType { } } td.dataset.source = rawSource; + td.dataset.sourceFrom = String(self.tableFrom + rawSourceStart); + td.dataset.sourceTo = String(self.tableFrom + rawSourceStart + rawSource.length); if (astCell && Array.isArray(astCell.children) && astCell.children.length > 0) { renderCellRich(td, astCell, self.tableFrom, rawSourceStart); } else { @@ -1288,6 +1290,7 @@ export class EditableTableWidget extends WidgetType { }; const activateCellEditing = (): void => { + acquireEditingLock("focus"); if (td.contentEditable !== "true") { td.contentEditable = "true"; } @@ -1325,15 +1328,21 @@ export class EditableTableWidget extends WidgetType { renderRangeSelection(); return; } - if (target && Math.hypot(me.clientX - startX, me.clientY - startY) > TEXT_SELECTION_DRAG_THRESHOLD_PX) { + if (Math.hypot(me.clientX - startX, me.clientY - startY) > TEXT_SELECTION_DRAG_THRESHOLD_PX) { cellTextSelectionDrag = true; } }; - const onCellMouseUp = (): void => { + const onCellMouseUp = (ue: MouseEvent): void => { document.removeEventListener("mousemove", onCellMouseMove); document.removeEventListener("mouseup", onCellMouseUp); cellMouseDown = false; isRangeSelecting = false; + if ( + !cellMouseMoved && + Math.hypot(ue.clientX - startX, ue.clientY - startY) > TEXT_SELECTION_DRAG_THRESHOLD_PX + ) { + cellTextSelectionDrag = true; + } const range = getNormalizedRange(); if (cellTextSelectionDrag || hasNativeTextSelectionInCell(td)) { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 741392f3..36151915 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -623,6 +623,47 @@ describe("live preview", () => { container.remove(); }); + it("keeps the focused table cell DOM stable while typing", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 222 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[1]; + expect(cell).not.toBeUndefined(); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 80, + clientY: 40 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 80, + clientY: 40 + })); + + expect(cell?.contentEditable).toBe("true"); + cell!.textContent = "223"; + cell!.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + + const currentCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[1]; + expect(cell?.isConnected).toBe(true); + expect(currentCell).toBe(cell); + expect(cell?.contentEditable).toBe("true"); + expect(editor.getDocument()).toContain("| 1 | 223 |"); + + editor.destroy(); + container.remove(); + }); + it("moves between table cells with up/down arrow keys", () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -866,6 +907,48 @@ describe("live preview", () => { container.remove(); }); + it("keeps same-cell drags from activating whole-cell editing when hit testing misses", () => { + document.getSelection()?.removeAllRanges(); + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| Floatboat | 2 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 12, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 72, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 72, + clientY: 15 + })); + + expect(cell?.contentEditable).not.toBe("true"); + expect(cell?.style.background).not.toContain("124, 108, 250"); + + editor.destroy(); + container.remove(); + }); + it("renders inline markdown links inside table cells as elements", () => { const container = document.createElement("div"); const editor = createEditor({ diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 705d1451..84d0ed90 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -13,7 +13,14 @@ import { selectMatches, setSearchQuery } from "@codemirror/search"; -import { keymap, runScopeHandlers, type EditorView, type Panel, type ViewUpdate } from "@codemirror/view"; +import { + keymap, + runScopeHandlers, + ViewPlugin, + type EditorView, + type Panel, + type ViewUpdate +} from "@codemirror/view"; import type { NexusPlugin } from "@floatboat/nexus-core"; @@ -95,9 +102,187 @@ const DEFAULT_LABELS: SearchPluginLabels = { const DEFAULT_SEARCH_HISTORY_KEY = "nexus.search.history"; const DEFAULT_SEARCH_HISTORY_MAX_ENTRIES = 20; +const TABLE_SEARCH_MATCH_HIGHLIGHT = "nexus-table-search-match"; +const TABLE_SEARCH_SELECTED_HIGHLIGHT = "nexus-table-search-selected"; +const TABLE_SEARCH_CELL_SELECTOR = ".nexus-table-wrapper .nexus-cell"; +const TABLE_SEARCH_STYLE_ID = "nexus-table-search-highlight-style"; type SearchHistoryDirection = "previous" | "next"; +type CssHighlightRegistry = { + set(name: string, highlight: unknown): void; + delete(name: string): boolean; +}; + +type HighlightConstructor = new (...ranges: Range[]) => unknown; + +function getCssHighlightSupport(doc: Document): + | { + registry: CssHighlightRegistry; + Highlight: HighlightConstructor; + } + | null { + const win = doc.defaultView as + | (Window & { + CSS?: { highlights?: CssHighlightRegistry }; + Highlight?: HighlightConstructor; + }) + | null; + const registry = win?.CSS?.highlights; + const HighlightCtor = win?.Highlight; + + if (!registry || typeof registry.set !== "function" || typeof registry.delete !== "function") { + return null; + } + if (typeof HighlightCtor !== "function") { + return null; + } + + return { registry, Highlight: HighlightCtor }; +} + +function ensureTableSearchHighlightStyles(doc: Document): void { + if (doc.getElementById(TABLE_SEARCH_STYLE_ID)) return; + + const style = doc.createElement("style"); + style.id = TABLE_SEARCH_STYLE_ID; + style.textContent = ` +::highlight(${TABLE_SEARCH_MATCH_HIGHLIGHT}) { + background-color: rgba(255, 214, 10, 0.38); + color: inherit; +} +::highlight(${TABLE_SEARCH_SELECTED_HIGHLIGHT}) { + background-color: rgba(255, 177, 0, 0.68); + color: inherit; +} +`; + doc.head.appendChild(style); +} + +function createTextRanges(root: Element, from: number, to: number): Range[] { + if (from >= to) return []; + + const doc = root.ownerDocument; + const ranges: Range[] = []; + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let offset = 0; + + while (walker.nextNode()) { + const node = walker.currentNode; + const text = node.textContent ?? ""; + const nextOffset = offset + text.length; + const startsBeforeMatchEnd = offset < to; + const endsAfterMatchStart = nextOffset > from; + + if (text.length > 0 && startsBeforeMatchEnd && endsAfterMatchStart) { + const range = doc.createRange(); + range.setStart(node, Math.max(0, from - offset)); + range.setEnd(node, Math.min(text.length, to - offset)); + ranges.push(range); + } + + offset = nextOffset; + if (offset >= to) break; + } + + return ranges; +} + +function readNumericDatasetValue(element: HTMLElement, key: string): number | null { + const value = element.dataset[key]; + if (!value) return null; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isSelectedSearchMatch(cell: HTMLElement, match: SearchMatch, selectionFrom: number, selectionTo: number): boolean { + const sourceFrom = readNumericDatasetValue(cell, "sourceFrom"); + if (sourceFrom === null) return false; + + const matchFrom = sourceFrom + match.from; + const matchTo = sourceFrom + match.to; + return matchFrom === selectionFrom && matchTo === selectionTo; +} + +function clearTableSearchHighlights(doc: Document): void { + const support = getCssHighlightSupport(doc); + support?.registry.delete(TABLE_SEARCH_MATCH_HIGHLIGHT); + support?.registry.delete(TABLE_SEARCH_SELECTED_HIGHLIGHT); +} + +function syncTableSearchHighlights(view: EditorView): void { + const doc = view.dom.ownerDocument; + const support = getCssHighlightSupport(doc); + if (!support) return; + + ensureTableSearchHighlightStyles(doc); + + const query = getSearchQuery(view.state); + if (!query.search) { + clearTableSearchHighlights(doc); + return; + } + + const selection = view.state.selection.main; + const selectionFrom = Math.min(selection.anchor, selection.head); + const selectionTo = Math.max(selection.anchor, selection.head); + const matchRanges: Range[] = []; + const selectedRanges: Range[] = []; + + view.dom.querySelectorAll(TABLE_SEARCH_CELL_SELECTOR).forEach((cell) => { + if (cell.isContentEditable) return; + + const matches = findSearchMatches(cell.textContent ?? "", query.search, { + caseSensitive: query.caseSensitive, + regexp: query.regexp, + wholeWord: query.wholeWord + }); + + for (const match of matches) { + const ranges = createTextRanges(cell, match.from, match.to); + if (isSelectedSearchMatch(cell, match, selectionFrom, selectionTo)) { + selectedRanges.push(...ranges); + } else { + matchRanges.push(...ranges); + } + } + }); + + if (matchRanges.length > 0) { + support.registry.set(TABLE_SEARCH_MATCH_HIGHLIGHT, new support.Highlight(...matchRanges)); + } else { + support.registry.delete(TABLE_SEARCH_MATCH_HIGHLIGHT); + } + + if (selectedRanges.length > 0) { + support.registry.set(TABLE_SEARCH_SELECTED_HIGHLIGHT, new support.Highlight(...selectedRanges)); + } else { + support.registry.delete(TABLE_SEARCH_SELECTED_HIGHLIGHT); + } +} + +const tableSearchHighlightPlugin = ViewPlugin.fromClass( + class { + constructor(private readonly view: EditorView) { + syncTableSearchHighlights(view); + } + + update(update: ViewUpdate): void { + const searchQueryChanged = update.transactions.some((transaction) => + transaction.effects.some((effect) => effect.is(setSearchQuery)) + ); + if (update.docChanged || update.selectionSet || update.viewportChanged || searchQueryChanged) { + syncTableSearchHighlights(update.view); + } + } + + destroy(): void { + clearTableSearchHighlights(this.view.dom.ownerDocument); + } + } +); + function resolveMaxEntries(maxEntries: number | undefined): number { if (maxEntries === undefined || !Number.isFinite(maxEntries)) { return DEFAULT_SEARCH_HISTORY_MAX_ENTRIES; @@ -758,7 +943,8 @@ export function createSearchPlugin(options: SearchPluginOptions = {}): NexusPlug literal: true, createPanel: (view) => new NexusSearchPanel(view, options.top ?? true, options.labels, history) }), - keymap.of(searchKeymap) + keymap.of(searchKeymap), + tableSearchHighlightPlugin ]; if (options.highlightSelectionMatches ?? true) { diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index 82892db0..a6b78829 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createEditor } from "@floatboat/nexus-core"; +import { createGfmPreset } from "../../preset-gfm/src/index"; import { createSearchPlugin, findSearchMatches, @@ -7,6 +8,56 @@ import { } from "../src/index"; const HISTORY_KEY = "nexus:test-search-history"; +const TABLE_SEARCH_MATCH_HIGHLIGHT = "nexus-table-search-match"; +const TABLE_SEARCH_SELECTED_HIGHLIGHT = "nexus-table-search-selected"; + +class FakeHighlight { + readonly ranges: Range[]; + + constructor(...ranges: Range[]) { + this.ranges = ranges; + } +} + +function installCssHighlightMock() { + const win = window as unknown as Window & { + CSS?: { highlights?: Map }; + Highlight?: typeof FakeHighlight; + }; + const previousCss = win.CSS; + const previousHighlight = win.Highlight; + const registry = new Map(); + + Object.defineProperty(win, "CSS", { + configurable: true, + value: { + ...(previousCss ?? {}), + highlights: registry + } + }); + Object.defineProperty(win, "Highlight", { + configurable: true, + value: FakeHighlight + }); + + return { + registry, + restore() { + Object.defineProperty(win, "CSS", { + configurable: true, + value: previousCss + }); + Object.defineProperty(win, "Highlight", { + configurable: true, + value: previousHighlight + }); + } + }; +} + +function readHighlightText(highlight: FakeHighlight | undefined): string { + return highlight?.ranges.map((range) => range.toString()).join("") ?? ""; +} function createEmptyRect(): DOMRect { return { @@ -245,7 +296,40 @@ describe("@floatboat/nexus-plugin-search", () => { const plugin = createSearchPlugin(); expect(plugin.name).toBe("plugin-search"); - expect(plugin.cmExtensions).toHaveLength(3); + expect(plugin.cmExtensions).toHaveLength(4); + }); + + it("highlights rendered table cell matches through CSS Highlight ranges", () => { + const highlightSupport = installCssHighlightMock(); + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: [ + "| Name | Status |", + "| --- | --- |", + "| SearchNeedle070 | Todo |", + "| Other | Done |" + ].join("\n"), + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + const input = openSearchPanel(container); + + submitSearch(input, "SearchNeedle070"); + + expect(readHighlightText(highlightSupport.registry.get(TABLE_SEARCH_SELECTED_HIGHLIGHT))).toBe( + "SearchNeedle070" + ); + expect(readHighlightText(highlightSupport.registry.get(TABLE_SEARCH_MATCH_HIGHLIGHT))).toBe(""); + expect(container.querySelector(".nexus-table-wrapper")?.textContent).toContain("SearchNeedle070"); + } finally { + editor.destroy(); + container.remove(); + highlightSupport.restore(); + } }); it("opens a data-test-id annotated search panel from the editor keymap", () => { From a5bd6d607059fb45265bb8332fb636b50ddc6c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Tue, 7 Jul 2026 16:53:27 +0800 Subject: [PATCH 2/6] fix: stabilize table cell ime editing --- packages/core/src/live-preview-table.ts | 250 +++++++++++++++++++++--- packages/core/test/live-preview.test.ts | 169 +++++++++++++++- 2 files changed, 393 insertions(+), 26 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 02a1d895..8f514b8d 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -51,8 +51,35 @@ const tableColumnWidths = new Map(); const ROW_GRIP_WIDTH = 16; const MIN_COLUMN_WIDTH = 48; +const TABLE_WRAPPER_VERTICAL_PADDING = 16; +const TABLE_BASE_ROW_HEIGHT = 32; +const TABLE_EXTRA_LINE_HEIGHT = 20; +const TABLE_ESTIMATE_UNITS_PER_LINE = 34; +const TABLE_MAX_ESTIMATED_ROW_HEIGHT = 240; const renderedSourceOffsets = new WeakMap(); +function lineStartOffset(lines: string[], lineIdx: number, tableFrom: number): number { + let offset = tableFrom; + for (let i = 0; i < lineIdx; i++) offset += lines[i].length + 1; + return offset; +} + +function sourceOffsetForCell(lines: string[], lineIdx: number, colIdx: number, tableFrom: number): number { + const line = lines[lineIdx] ?? ""; + const lineStart = lineStartOffset(lines, lineIdx, tableFrom); + let pipeCount = 0; + for (let i = 0; i < line.length; i++) { + if (line[i] !== "|") continue; + if (pipeCount === colIdx) { + let offset = i + 1; + while (offset < line.length && /\s/.test(line[offset]) && line[offset] !== "|") offset++; + return lineStart + offset; + } + pipeCount++; + } + return lineStart; +} + function getNodeSourceOffsets(node: any, tableFrom: number, rawSourceStart: number, inlineCode = false): { start: number; end: number } | null { const startOffset = node?.position?.start?.offset; const endOffset = node?.position?.end?.offset; @@ -144,6 +171,35 @@ function extractCellText(cell: any): string { .join(""); } +function visualLength(text: string): number { + let length = 0; + for (const char of text) { + length += /[\u2e80-\u9fff\uff00-\uffef]/u.test(char) ? 2 : 1; + } + return length; +} + +function estimateCellLineCount(cellSource: string): number { + const normalized = cellSource.replace(//gi, "\n"); + return Math.max( + 1, + ...normalized.split("\n").map((line) => Math.ceil(visualLength(line.trim()) / TABLE_ESTIMATE_UNITS_PER_LINE)) + ); +} + +function estimateTableHeight(source: string): number { + let height = TABLE_WRAPPER_VERTICAL_PADDING; + for (const line of source.split("\n")) { + if (SEPARATOR_RE.test(line)) continue; + const parts = line.split("|"); + const cells = parts.length > 2 ? parts.slice(1, -1) : parts; + const lineCount = Math.max(1, ...cells.map(estimateCellLineCount)); + const rowHeight = TABLE_BASE_ROW_HEIGHT + (lineCount - 1) * TABLE_EXTRA_LINE_HEIGHT; + height += Math.min(TABLE_MAX_ESTIMATED_ROW_HEIGHT, rowHeight); + } + return height; +} + /** * Render an inline mdast node into DOM. Supports the inline subset that * appears inside table cells: text, link, strong, emphasis, delete, @@ -326,9 +382,9 @@ export class EditableTableWidget extends WidgetType { } get estimatedHeight(): number { - const rows = this.node.children?.length ?? 1; - // rows × ~32px (cell padding + text) + 16px wrapper padding (8px top + 8px bottom) - return rows * 32 + 16; + // 长表格里大量中文/链接会换行,固定 32px/行会严重低估高度。 + // 底部单元格编辑后 CM6 可能按低估 heightmap 把 widget 判出 viewport,导致 TD 被卸载失焦。 + return estimateTableHeight(this.source); } private dispatch(newSource: string): void { @@ -411,6 +467,7 @@ export class EditableTableWidget extends WidgetType { 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); + const dirtyRows = new Map(); // State let selectedCol = -1; @@ -458,6 +515,107 @@ export class EditableTableWidget extends WidgetType { releaseEditingLock("drag"); }; + const rememberDirtyRow = (lineIdx: number | undefined, row: HTMLElement): void => { + if (lineIdx === undefined) return; + dirtyRows.set(lineIdx, row); + }; + + const findRenderedRowForSourceLine = (lineIdx: number): HTMLElement | null => { + const ownerDocument = wrapper.ownerDocument; + const selector = `.nexus-table-wrapper tr[data-source-line-idx="${lineIdx}"]`; + return Array.from(ownerDocument.querySelectorAll(selector)).find((row) => { + const rect = row.getBoundingClientRect(); + return row.isConnected && rect.width > 0 && rect.height > 0; + }) ?? null; + }; + + const restoreRowScrollPosition = (lineIdx: number, row: HTMLElement): void => { + const v = self.viewRef.current; + if (!v) return; + const scroller = v.scrollDOM; + const ownerWindow = wrapper.ownerDocument.defaultView ?? window; + const beforeTop = row.getBoundingClientRect().top; + const beforeScrollTop = scroller.scrollTop; + + const restore = (): void => { + if (!scroller.isConnected) return; + const nextRow = findRenderedRowForSourceLine(lineIdx); + if (!nextRow) { + scroller.scrollTop = beforeScrollTop; + return; + } + const nextTop = nextRow.getBoundingClientRect().top; + scroller.scrollTop += nextTop - beforeTop; + }; + + ownerWindow.requestAnimationFrame(() => { + restore(); + ownerWindow.requestAnimationFrame(restore); + }); + }; + + const syncEditorSelectionToCell = (lineIdx: number | undefined, colIdx: number): void => { + const v = self.viewRef.current; + if (!v || lineIdx === undefined) return; + const anchor = sourceOffsetForCell(sourceLines, lineIdx, colIdx, self.tableFrom); + const current = v.state.selection.main; + if (current.anchor === anchor && current.head === anchor) return; + try { + v.dispatch({ selection: { anchor, head: anchor } }); + } catch { + // View may be gone while the widget is being destroyed. + } + }; + + const buildSourceLineFromRow = (row: HTMLElement): string => { + const vals: string[] = []; + row.querySelectorAll(".nexus-cell").forEach((el) => { + // dataset.source 是单元格 Markdown 源文本的权威值;未触碰的富文本单元格 + // 仍可能显示链接/加粗 DOM,不能用 textContent 反推源码。 + vals.push(el.dataset.source ?? el.textContent ?? ""); + }); + return "| " + vals.join(" | ") + " |"; + }; + + const syncDirtyRowsToDocument = (): boolean => { + const v = self.viewRef.current; + if (!v || dirtyRows.size === 0) return false; + + const nextSourceLines = sourceLines.slice(); + let changed = false; + let firstChangedLineIdx: number | null = null; + let firstChangedRow: HTMLElement | null = null; + dirtyRows.forEach((row, lineIdx) => { + const newLine = buildSourceLineFromRow(row); + if (newLine === nextSourceLines[lineIdx]) return; + nextSourceLines[lineIdx] = newLine; + firstChangedLineIdx ??= lineIdx; + firstChangedRow ??= row; + changed = true; + }); + dirtyRows.clear(); + if (!changed) return false; + + const anchorLineIdx = firstChangedLineIdx ?? 0; + const anchor = lineStartOffset(sourceLines, anchorLineIdx, self.tableFrom); + if (firstChangedRow) restoreRowScrollPosition(anchorLineIdx, firstChangedRow); + v.dispatch({ + changes: { + from: self.tableFrom, + to: self.tableFrom + self.source.length, + insert: nextSourceLines.join("\n") + }, + selection: { anchor, head: anchor } + }); + v.requestMeasure(); + return true; + }; + + const hasActiveCellInWrapper = (): boolean => { + const active = wrapper.ownerDocument.activeElement; + return active instanceof HTMLElement && active !== wrapper && wrapper.contains(active) && active.classList.contains("nexus-cell"); + }; + function blurActiveCellForDrag(): void { const active = document.activeElement; if (!(active instanceof HTMLElement) || !wrapper.contains(active) || !active.classList.contains("nexus-cell")) return; @@ -1145,6 +1303,7 @@ export class EditableTableWidget extends WidgetType { const tr = document.createElement("tr"); const astCells = "children" in astRow && Array.isArray(astRow.children) ? astRow.children : []; const sourceLineIdx = dataLineIndices[rowIdx]; + if (sourceLineIdx !== undefined) tr.dataset.sourceLineIdx = String(sourceLineIdx); const curRowIdx = rowIdx; // Row grip @@ -1265,6 +1424,8 @@ export class EditableTableWidget extends WidgetType { const cellCol = colIdx; let cellMouseMoved = false; let cellTextSelectionDrag = false; + let cellComposing = false; + let compositionSourceQueued = false; const enterRawEditingMode = (): void => { // Pin THIS cell to its currently rendered width before swapping @@ -1291,6 +1452,7 @@ export class EditableTableWidget extends WidgetType { const activateCellEditing = (): void => { acquireEditingLock("focus"); + syncEditorSelectionToCell(sourceLineIdx, cellCol); if (td.contentEditable !== "true") { td.contentEditable = "true"; } @@ -1373,6 +1535,7 @@ export class EditableTableWidget extends WidgetType { td.addEventListener("focus", () => { acquireEditingLock("focus"); + syncEditorSelectionToCell(sourceLineIdx, cellCol); clearRangeSelection(); enterRawEditingMode(); }); @@ -1402,7 +1565,10 @@ export class EditableTableWidget extends WidgetType { // the StateField rebuild. If the next click lands inside // another swallowing widget, no transaction fires, and the // cell stays in raw-source mode. - if (astCell && Array.isArray(astCell.children) && astCell.children.length > 0) { + const cellChanged = (td.dataset.source ?? "") !== rawSource; + if (cellChanged) { + td.textContent = td.dataset.source ?? ""; + } else if (astCell && Array.isArray(astCell.children) && astCell.children.length > 0) { renderCellRich(td, astCell, self.tableFrom, rawSourceStart); } else { td.textContent = td.dataset.source ?? ""; @@ -1419,6 +1585,16 @@ export class EditableTableWidget extends WidgetType { tableNavDebug("blur-dispatch:skipped (navigating)"); return; } + // 鼠标从一个单元格切到另一个单元格时,旧单元格的 blur 微任务会晚于 + // 新单元格 focus 执行。此时再派发 CM selection 会把焦点抢回编辑器源码区。 + if (hasActiveCellInWrapper()) { + tableNavDebug("blur-dispatch:skipped (cell-active)", { active: describeActiveCell() }); + return; + } + if (syncDirtyRowsToDocument()) { + tableNavDebug("blur-dispatch:committed", { active: describeActiveCell() }); + return; + } const v = self.viewRef.current; if (!v) return; const sel = v.state.selection.main; @@ -1431,26 +1607,54 @@ export class EditableTableWidget extends WidgetType { }); }); - td.addEventListener("input", () => { - const v = self.viewRef.current; - if (!v || sourceLineIdx === undefined) return; - // The currently edited cell holds the user's in-progress text; sync - // its dataset.source so we read a coherent set of values below. + const rememberCellSourceEdit = (): void => { + if (sourceLineIdx === undefined) return; td.dataset.source = td.textContent ?? ""; - const vals: string[] = []; - tr.querySelectorAll(".nexus-cell").forEach((el) => { - // Use dataset.source as the authoritative source for every cell. - // Untouched cells still display rich DOM (links, bold) — reading - // their textContent would strip URLs and lose inline markdown. - vals.push(el.dataset.source ?? el.textContent ?? ""); - }); - const newLine = "| " + vals.join(" | ") + " |"; - let off = self.tableFrom; - for (let i = 0; i < sourceLineIdx; i++) off += sourceLines[i].length + 1; - const end = off + sourceLines[sourceLineIdx].length; - sourceLines[sourceLineIdx] = newLine; - v.dispatch({ changes: { from: off, to: end, insert: newLine } }); - }); + rememberDirtyRow(sourceLineIdx, tr); + }; + + const queueCompositionSourceSnapshot = (): void => { + if (compositionSourceQueued) return; + compositionSourceQueued = true; + window.setTimeout(() => { + compositionSourceQueued = false; + if (cellComposing) return; + rememberCellSourceEdit(); + }, 0); + }; + + td.addEventListener("beforeinput", (event) => { + event.stopPropagation(); + }, true); + + td.addEventListener("compositionstart", (event) => { + event.stopPropagation(); + cellComposing = true; + acquireEditingLock("focus"); + }, true); + + td.addEventListener("compositionupdate", (event) => { + event.stopPropagation(); + }, true); + + td.addEventListener("compositionend", (event) => { + event.stopPropagation(); + cellComposing = false; + // 浏览器会在 compositionend 前后把候选词提交进 contentEditable。 + // 延后一拍读取 TD,只更新待提交源码,避免输入阶段重绘长表格导致失焦。 + queueCompositionSourceSnapshot(); + }, true); + + td.addEventListener("input", (event) => { + event.stopPropagation(); + const inputEvent = event as InputEvent; + if (cellComposing || inputEvent.isComposing || inputEvent.inputType === "insertCompositionText") { + return; + } + // 不在每个字符输入时 dispatch 到 CM6。长表格重算 heightmap 会让当前 + // widget 被判出 viewport,表现为拼音只剩首字母、焦点掉到 BODY。 + rememberCellSourceEdit(); + }, true); td.addEventListener("keydown", (e) => { if (e.key === "Tab") { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 36151915..8c926a95 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -28,6 +28,13 @@ function requireEditorView(view: EditorView | null): EditorView { return view; } +async function blurCellOutsideTable(cell: HTMLElement): Promise { + const activeElementSpy = vi.spyOn(document, "activeElement", "get").mockReturnValue(document.body); + cell.dispatchEvent(new Event("blur")); + await Promise.resolve(); + activeElementSpy.mockRestore(); +} + describe("live preview", () => { // ── Inline formatting ── // Note: inline markers are hidden when cursor is on a DIFFERENT line (line-level detection). @@ -623,7 +630,52 @@ describe("live preview", () => { container.remove(); }); - it("keeps the focused table cell DOM stable while typing", () => { + it("moves the editor selection to the focused table cell source", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let capturedView: EditorView | null = null; + const captureView = ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + capturedView = view; + } + } + ); + const source = "| A | B |\n| --- | --- |\n| 1 | 222 |"; + const editor = createEditor({ + container, + initialValue: source, + livePreview: true, + plugins: [createGfmPreset(), { name: "capture-view", cmExtensions: [captureView] }] + }); + const view = requireEditorView(capturedView); + editor.setSelection(source.length); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[1]; + expect(cell).not.toBeUndefined(); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 80, + clientY: 40 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 80, + clientY: 40 + })); + + expect(cell?.contentEditable).toBe("true"); + expect(view.state.selection.main.anchor).toBe(source.indexOf("222")); + + editor.destroy(); + container.remove(); + }); + + it("keeps the focused table cell DOM stable while typing and commits on blur", async () => { const container = document.createElement("div"); document.body.appendChild(container); const editor = createEditor({ @@ -651,15 +703,126 @@ describe("live preview", () => { })); expect(cell?.contentEditable).toBe("true"); - cell!.textContent = "223"; + const documentInputSpy = vi.fn(); + document.addEventListener("input", documentInputSpy); + cell!.textContent = "2223"; cell!.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + document.removeEventListener("input", documentInputSpy); const currentCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[1]; expect(cell?.isConnected).toBe(true); expect(currentCell).toBe(cell); expect(cell?.contentEditable).toBe("true"); - expect(editor.getDocument()).toContain("| 1 | 223 |"); + expect(documentInputSpy).not.toHaveBeenCalled(); + expect(editor.getDocument()).toContain("| 1 | 222 |"); + expect(editor.getDocument()).not.toContain("| 1 | 2223 |"); + + await blurCellOutsideTable(cell!); + + expect(editor.getDocument()).toContain("| 1 | 2223 |"); + + editor.destroy(); + container.remove(); + }); + + it("commits table cell IME composition only after compositionend", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| | aa |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 80, + clientY: 40 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 80, + clientY: 40 + })); + + expect(cell?.contentEditable).toBe("true"); + cell!.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, cancelable: true, data: "" })); + cell!.textContent = "n"; + cell!.dispatchEvent(new InputEvent("input", { + bubbles: true, + cancelable: true, + data: "n", + inputType: "insertCompositionText", + isComposing: true + })); + + expect(editor.getDocument()).toContain("| | aa |"); + expect(editor.getDocument()).not.toContain("| n | aa |"); + + cell!.textContent = "你好"; + cell!.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, cancelable: true, data: "你好" })); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(cell?.isConnected).toBe(true); + expect(cell?.contentEditable).toBe("true"); + expect(editor.getDocument()).toContain("| | aa |"); + expect(editor.getDocument()).not.toContain("| 你好 | aa |"); + + await blurCellOutsideTable(cell!); + + expect(editor.getDocument()).toContain("| 你好 | aa |"); + + editor.destroy(); + container.remove(); + }); + + it("does not let a blurred table cell steal focus from the next active cell", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let capturedView: EditorView | null = null; + const captureView = ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + capturedView = view; + } + } + ); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |", + livePreview: true, + plugins: [createGfmPreset(), { name: "capture-view", cmExtensions: [captureView] }] + }); + + const rows = Array.from(container.querySelectorAll("tr")).filter((row) => + row.querySelector(".nexus-cell") + ); + const firstCell = rows[1]?.querySelectorAll(".nexus-cell")[0]; + const nextCell = rows[2]?.querySelectorAll(".nexus-cell")[0]; + expect(firstCell).not.toBeUndefined(); + expect(nextCell).not.toBeUndefined(); + + const view = requireEditorView(capturedView); + const dispatchSpy = vi.spyOn(view, "dispatch"); + const activeElementSpy = vi.spyOn(document, "activeElement", "get").mockReturnValue(nextCell!); + + firstCell!.textContent = "9"; + firstCell!.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + firstCell!.dispatchEvent(new Event("blur")); + await Promise.resolve(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(editor.getDocument()).not.toContain("| 9 | 2 |"); + activeElementSpy.mockRestore(); + dispatchSpy.mockRestore(); editor.destroy(); container.remove(); }); From bb8f83fa3e99658fc1a1d82708a954dbceea56d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Tue, 7 Jul 2026 18:31:23 +0800 Subject: [PATCH 3/6] fix: preserve table cell partial text selections --- packages/core/src/live-preview-table.ts | 117 ++++++++++++++++++++++-- packages/core/test/live-preview.test.ts | 62 +++++++++++++ 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 8f514b8d..833becce 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -159,6 +159,65 @@ function placeRawSourceCaret(td: HTMLElement, rawOffset: number): void { selection?.addRange(range); } +function collectTextNodes(root: Node): Text[] { + const nodes: Text[] = []; + const visit = (node: Node): void => { + node.childNodes.forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + nodes.push(child as Text); + } else { + visit(child); + } + }); + }; + visit(root); + return nodes; +} + +function textPointForRawSourceOffset(cell: HTMLElement, rawOffset: number): { node: Text; offset: number } | null { + const textNodes = collectTextNodes(cell); + let sawMappedNode = false; + for (const text of textNodes) { + const mapped = renderedSourceOffsets.get(text); + if (!mapped) continue; + sawMappedNode = true; + if (rawOffset >= mapped.start && rawOffset <= mapped.end) { + return { + node: text, + offset: Math.max(0, Math.min(rawOffset - mapped.start, text.textContent?.length ?? 0)), + }; + } + } + + if (sawMappedNode) return null; + + let remaining = Math.max(0, rawOffset); + for (const text of textNodes) { + const length = text.textContent?.length ?? 0; + if (remaining <= length) return { node: text, offset: remaining }; + remaining -= length; + } + const last = textNodes[textNodes.length - 1]; + return last ? { node: last, offset: last.textContent?.length ?? 0 } : null; +} + +function selectRawSourceRange(td: HTMLElement, from: number, to: number): boolean { + if (from === to) return false; + const start = Math.min(from, to); + const end = Math.max(from, to); + const startPoint = textPointForRawSourceOffset(td, start); + const endPoint = textPointForRawSourceOffset(td, end); + if (!startPoint || !endPoint) return false; + + const range = td.ownerDocument.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + const selection = td.ownerDocument.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return true; +} + function extractCellText(cell: any): string { if (!cell || !("children" in cell) || !Array.isArray(cell.children)) return ""; return cell.children @@ -1499,30 +1558,68 @@ export class EditableTableWidget extends WidgetType { document.removeEventListener("mouseup", onCellMouseUp); cellMouseDown = false; isRangeSelecting = false; + const dragDistance = Math.hypot(ue.clientX - startX, ue.clientY - startY); + const rawSelectionEndOffset = rawSourceOffsetFromPoint(td, ue); if ( !cellMouseMoved && - Math.hypot(ue.clientX - startX, ue.clientY - startY) > TEXT_SELECTION_DRAG_THRESHOLD_PX + dragDistance > TEXT_SELECTION_DRAG_THRESHOLD_PX ) { cellTextSelectionDrag = true; } + const preserveRawDragSelection = (): boolean => { + if ( + rawCaretOffset === null || + rawSelectionEndOffset === null || + rawCaretOffset === rawSelectionEndOffset + ) { + return false; + } + return selectRawSourceRange(td, rawCaretOffset, rawSelectionEndOffset); + }; + + const stabilizeNativeTextSelection = (): void => { + const ownerWindow = td.ownerDocument.defaultView ?? window; + ownerWindow.setTimeout(() => { + if (!td.isConnected) return; + preserveRawDragSelection(); + }, 0); + }; + + const activateAfterNativeSelectionSettles = (): void => { + const run = (): void => { + if (!td.isConnected) return; + if (hasNativeTextSelectionInCell(td)) { + return; + } + activateCellEditing(); + if (rawCaretOffset !== null) { + placeRawSourceCaret(td, rawCaretOffset); + window.setTimeout(() => { + if (td.contentEditable === "true") { + placeRawSourceCaret(td, rawCaretOffset); + } + }, 0); + } + }; + if (dragDistance > 0) { + const ownerWindow = td.ownerDocument.defaultView ?? window; + ownerWindow.setTimeout(run, 0); + return; + } + run(); + }; + const range = getNormalizedRange(); if (cellTextSelectionDrag || hasNativeTextSelectionInCell(td)) { clearRangeSelection(); + stabilizeNativeTextSelection(); return; } if (!cellMouseMoved || (range && range.r1 === range.r2 && range.c1 === range.c2)) { // Single cell click — activate editing clearRangeSelection(); - activateCellEditing(); - if (rawCaretOffset !== null) { - placeRawSourceCaret(td, rawCaretOffset); - window.setTimeout(() => { - if (td.contentEditable === "true") { - placeRawSourceCaret(td, rawCaretOffset); - } - }, 0); - } + activateAfterNativeSelectionSettles(); } else { // Multi-cell range selected — keep range visible, focus wrapper for key events rangeActive = true; diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 8c926a95..7bbde785 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -1070,6 +1070,68 @@ describe("live preview", () => { container.remove(); }); + it("preserves partial native text selection in a table cell after mouseup", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| Creator | Segment |\n| --- | --- |\n| Kevin Stratvert | P1 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + const text = cell!.firstChild; + expect(text?.textContent).toBe("Kevin Stratvert"); + + const originalCaretRangeFromPoint = document.caretRangeFromPoint; + Object.defineProperty(document, "caretRangeFromPoint", { + configurable: true, + value: (x: number) => { + const range = document.createRange(); + range.setStart(text!, x < 30 ? 0 : 5); + range.collapse(true); + return range; + }, + }); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 12, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 72, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 72, + clientY: 15 + })); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(cell?.contentEditable).not.toBe("true"); + expect(document.getSelection()?.toString()).toBe("Kevin"); + expect(document.getSelection()?.toString()).not.toBe("Kevin Stratvert"); + + Object.defineProperty(document, "caretRangeFromPoint", { + configurable: true, + value: originalCaretRangeFromPoint, + }); + document.getSelection()?.removeAllRanges(); + editor.destroy(); + container.remove(); + }); + it("keeps same-cell drags from activating whole-cell editing when hit testing misses", () => { document.getSelection()?.removeAllRanges(); const container = document.createElement("div"); From 69b00014c1c017f76b499632feb5fce2858dd436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Tue, 7 Jul 2026 21:05:01 +0800 Subject: [PATCH 4/6] fix: restore table cell partial selections after selectionchange --- packages/core/src/live-preview-table.ts | 171 +++++++++++++++++++++++- packages/core/test/live-preview.test.ts | 87 ++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 833becce..f798b48c 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -415,6 +415,15 @@ const SELECT_BG = "rgba(124, 108, 250, 0.12)"; const SELECT_BORDER = "var(--nexus-accent)"; const DRAG_HIGHLIGHT_BG = "rgba(124, 108, 250, 0.08)"; const TEXT_SELECTION_DRAG_THRESHOLD_PX = 3; +const NATIVE_TEXT_SELECTION_RESTORE_WINDOW_MS = 220; + +interface PendingNativeTextSelection { + cell: HTMLElement; + from: number; + to: number; + expectedText: string; + expiresAt: number; +} export class EditableTableWidget extends WidgetType { private editing = false; @@ -548,10 +557,15 @@ export class EditableTableWidget extends WidgetType { focus: false, range: false, drag: false, + nativeSelection: false, }; + let pendingNativeTextSelection: PendingNativeTextSelection | null = null; + let pendingNativeTextSelectionTimer: number | null = null; + let pendingNativeTextSelectionTimerWindow: Window | null = null; + let selectionChangeDocument: Document | null = null; function hasEditingLocks(): boolean { - return editingLocks.focus || editingLocks.range || editingLocks.drag; + return editingLocks.focus || editingLocks.range || editingLocks.drag || editingLocks.nativeSelection; } function acquireEditingLock(lock: keyof typeof editingLocks): void { @@ -572,6 +586,11 @@ export class EditableTableWidget extends WidgetType { releaseEditingLock("focus"); releaseEditingLock("range"); releaseEditingLock("drag"); + clearPendingNativeTextSelection(); + if (selectionChangeDocument) { + selectionChangeDocument.removeEventListener("selectionchange", onDocumentSelectionChange); + selectionChangeDocument = null; + } }; const rememberDirtyRow = (lineIdx: number | undefined, row: HTMLElement): void => { @@ -691,6 +710,8 @@ export class EditableTableWidget extends WidgetType { // untracked height per table → cumulative click-drift below every table. wrapper.style.cssText = "display:inline-block;position:relative;padding:8px 0;user-select:text;-webkit-user-select:text;"; + selectionChangeDocument = wrapper.ownerDocument; + selectionChangeDocument.addEventListener("selectionchange", onDocumentSelectionChange); // ── Table ── const table = document.createElement("table"); @@ -961,6 +982,117 @@ export class EditableTableWidget extends WidgetType { table.ownerDocument.getSelection()?.removeAllRanges(); } + function clearPendingNativeTextSelection(): void { + if (pendingNativeTextSelectionTimer !== null && pendingNativeTextSelectionTimerWindow) { + pendingNativeTextSelectionTimerWindow.clearTimeout(pendingNativeTextSelectionTimer); + } + pendingNativeTextSelectionTimer = null; + pendingNativeTextSelectionTimerWindow = null; + pendingNativeTextSelection = null; + releaseEditingLock("nativeSelection"); + } + + function selectionTouchesCell(range: Range, cell: HTMLElement): boolean { + return ( + range.startContainer === cell || + range.endContainer === cell || + cell.contains(range.startContainer) || + cell.contains(range.endContainer) || + cell.contains(range.commonAncestorContainer) + ); + } + + function selectionMatchesRawSourceRange(cell: HTMLElement, from: number, to: number): boolean { + const selection = cell.ownerDocument.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false; + const range = selection.getRangeAt(0); + if (!selectionTouchesCell(range, cell)) return false; + const actualFrom = rawSourceOffsetFromCaret(range.startContainer, range.startOffset); + const actualTo = rawSourceOffsetFromCaret(range.endContainer, range.endOffset); + if (actualFrom === null || actualTo === null) return false; + return Math.min(actualFrom, actualTo) === from && Math.max(actualFrom, actualTo) === to; + } + + function rawSourceRangeFromNativeSelection(cell: HTMLElement): { from: number; to: number } | null { + const selection = cell.ownerDocument.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; + const range = selection.getRangeAt(0); + if (!selectionTouchesCell(range, cell)) return null; + const from = rawSourceOffsetFromCaret(range.startContainer, range.startOffset); + const to = rawSourceOffsetFromCaret(range.endContainer, range.endOffset); + if (from === null || to === null || from === to) return null; + return { from: Math.min(from, to), to: Math.max(from, to) }; + } + + function restorePendingNativeTextSelection(): void { + const pending = pendingNativeTextSelection; + if (!pending) return; + if (!pending.cell.isConnected || Date.now() > pending.expiresAt) { + clearPendingNativeTextSelection(); + return; + } + if (selectionMatchesRawSourceRange(pending.cell, pending.from, pending.to)) return; + + const selection = pending.cell.ownerDocument.getSelection(); + let shouldRestore = !selection || selection.rangeCount === 0 || selection.isCollapsed; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (!selectionTouchesCell(range, pending.cell)) { + return; + } + const selectedText = selection.toString(); + const cellText = pending.cell.textContent ?? ""; + shouldRestore = + shouldRestore || + selectedText !== pending.expectedText || + (cellText !== "" && selectedText === cellText && pending.expectedText !== cellText); + } + + if (!shouldRestore) return; + selectRawSourceRange(pending.cell, pending.from, pending.to); + } + + function armPendingNativeTextSelection(cell: HTMLElement, from: number, to: number): boolean { + if (from === to) return false; + const start = Math.min(from, to); + const end = Math.max(from, to); + const sourceText = cell.dataset.source ?? cell.textContent ?? ""; + const ownerWindow = cell.ownerDocument.defaultView ?? window; + if (pendingNativeTextSelectionTimer !== null && pendingNativeTextSelectionTimerWindow) { + pendingNativeTextSelectionTimerWindow.clearTimeout(pendingNativeTextSelectionTimer); + } + pendingNativeTextSelection = { + cell, + from: start, + to: end, + expectedText: sourceText.slice(start, end), + expiresAt: Date.now() + NATIVE_TEXT_SELECTION_RESTORE_WINDOW_MS, + }; + acquireEditingLock("nativeSelection"); + pendingNativeTextSelectionTimerWindow = ownerWindow; + pendingNativeTextSelectionTimer = ownerWindow.setTimeout( + clearPendingNativeTextSelection, + NATIVE_TEXT_SELECTION_RESTORE_WINDOW_MS + ); + return true; + } + + function scheduleNativeTextSelectionRestoreChecks(cell: HTMLElement): void { + const ownerWindow = cell.ownerDocument.defaultView ?? window; + const restore = (): void => { + if (!cell.isConnected) return; + restorePendingNativeTextSelection(); + }; + ownerWindow.setTimeout(restore, 0); + ownerWindow.requestAnimationFrame?.(restore); + ownerWindow.setTimeout(restore, 32); + ownerWindow.setTimeout(restore, 96); + } + + function onDocumentSelectionChange(): void { + restorePendingNativeTextSelection(); + } + function serializeRangeSelection(range: { r1: number; c1: number; r2: number; c2: number }): string { const lines: string[] = []; for (let row = range.r1; row <= range.r2; row++) { @@ -1529,6 +1661,7 @@ export class EditableTableWidget extends WidgetType { const startY = e.clientY; cellMouseMoved = false; cellTextSelectionDrag = false; + clearPendingNativeTextSelection(); clearSelection(); // Prepare range selection but don't render until mouse moves to a different cell @@ -1579,7 +1712,42 @@ export class EditableTableWidget extends WidgetType { }; const stabilizeNativeTextSelection = (): void => { + const armRange = (range: { from: number; to: number }): void => { + armPendingNativeTextSelection(td, range.from, range.to); + scheduleNativeTextSelectionRestoreChecks(td); + }; + const hitTestRange = + rawCaretOffset !== null && + rawSelectionEndOffset !== null && + rawCaretOffset !== rawSelectionEndOffset + ? { + from: Math.min(rawCaretOffset, rawSelectionEndOffset), + to: Math.max(rawCaretOffset, rawSelectionEndOffset), + } + : null; + const currentNativeRange = rawSourceRangeFromNativeSelection(td) ?? hitTestRange; + if (currentNativeRange) { + armRange(currentNativeRange); + return; + } + const ownerWindow = td.ownerDocument.defaultView ?? window; + if ( + rawCaretOffset === null || + rawSelectionEndOffset === null || + rawCaretOffset === rawSelectionEndOffset + ) { + ownerWindow.setTimeout(() => { + if (!td.isConnected) return; + const deferredNativeRange = rawSourceRangeFromNativeSelection(td); + if (deferredNativeRange) { + armRange(deferredNativeRange); + return; + } + preserveRawDragSelection(); + }, 0); + return; + } ownerWindow.setTimeout(() => { if (!td.isConnected) return; preserveRawDragSelection(); @@ -1896,6 +2064,7 @@ export class EditableTableWidget extends WidgetType { const onDocMouseDown = (e: MouseEvent): void => { if (!wrapper.isConnected) { document.removeEventListener("mousedown", onDocMouseDown); return; } if (!wrapper.contains(e.target as Node)) { + clearPendingNativeTextSelection(); clearSelection(); clearRangeSelection(); } diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 7bbde785..63a9958f 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -1118,6 +1118,14 @@ describe("live preview", () => { })); await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(document.getSelection()?.toString()).toBe("Kevin"); + + const wholeCellRange = document.createRange(); + wholeCellRange.setStart(cell!, 0); + wholeCellRange.setEnd(cell!, cell!.childNodes.length); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(wholeCellRange); + document.dispatchEvent(new Event("selectionchange")); expect(cell?.contentEditable).not.toBe("true"); expect(document.getSelection()?.toString()).toBe("Kevin"); @@ -1132,6 +1140,85 @@ describe("live preview", () => { container.remove(); }); + it("restores partial table cell text selection when caret hit testing misses", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| Creator | Segment |\n| --- | --- |\n| Kevin Stratvert | P1 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + const text = cell!.firstChild; + expect(text?.textContent).toBe("Kevin Stratvert"); + + const originalCaretRangeFromPoint = document.caretRangeFromPoint; + Object.defineProperty(document, "caretRangeFromPoint", { + configurable: true, + value: () => null, + }); + + cell?.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 12, + clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 72, + clientY: 15 + })); + + const partialRange = document.createRange(); + partialRange.setStart(text!, 0); + partialRange.setEnd(text!, 5); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(partialRange); + + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: 72, + clientY: 15 + })); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + const outside = document.createElement("span"); + outside.textContent = "outside"; + document.body.appendChild(outside); + const outsideRange = document.createRange(); + outsideRange.setStart(outside.firstChild!, 0); + outsideRange.setEnd(outside.firstChild!, 3); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(outsideRange); + document.dispatchEvent(new Event("selectionchange")); + + const wholeCellRange = document.createRange(); + wholeCellRange.setStart(cell!, 0); + wholeCellRange.setEnd(cell!, cell!.childNodes.length); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(wholeCellRange); + document.dispatchEvent(new Event("selectionchange")); + + expect(document.getSelection()?.toString()).toBe("Kevin"); + + Object.defineProperty(document, "caretRangeFromPoint", { + configurable: true, + value: originalCaretRangeFromPoint, + }); + document.getSelection()?.removeAllRanges(); + editor.destroy(); + container.remove(); + outside.remove(); + }); + it("keeps same-cell drags from activating whole-cell editing when hit testing misses", () => { document.getSelection()?.removeAllRanges(); const container = document.createElement("div"); From f7cf2546dc27bce4df884b1a72ae89df3658f08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Mon, 13 Jul 2026 10:43:56 +0800 Subject: [PATCH 5/6] fix(core): preserve pending table edits safely --- packages/core/src/editor.ts | 60 ++++- packages/core/src/live-preview-table.ts | 331 ++++++++++++++++++++--- packages/core/src/live-preview.ts | 2 +- packages/core/test/live-preview.test.ts | 337 +++++++++++++++++++++++- 4 files changed, 678 insertions(+), 52 deletions(-) diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 07bf1f0c..3df0a751 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -16,6 +16,7 @@ import { unified } from "unified"; import { EventEmitter } from "./event-emitter"; import { createLivePreviewExtension } from "./live-preview"; +import { flushPendingTableEdits } from "./live-preview-table"; import { createMarkdownLanguageSupport } from "./lezer-markdown"; import { lezerStringToMdast, lezerTreeToMdast } from "./lezer-mdast-adapter"; import { markdownFoldService } from "./markdown-fold"; @@ -281,6 +282,7 @@ export function createEditor(config: EditorConfig): EditorAPI { const parseDelayMs = config.parseDelayMs ?? 0; const emitter = new EventEmitter(); let destroyed = false; + let destroying = false; let focused = false; let parseTimer: ReturnType | undefined; let compositionFlushTimer: ReturnType | undefined; @@ -358,6 +360,13 @@ export function createEditor(config: EditorConfig): EditorAPI { }, parseDelayMs); } + function flushScheduledChangeNow(): void { + if (!parseTimer) return; + clearTimeout(parseTimer); + parseTimer = undefined; + emitChange(view.state.doc.toString()); + } + function queueCompositionChange(markdown: string) { pendingCompositionMarkdown = markdown; if (parseTimer) { @@ -394,6 +403,12 @@ export function createEditor(config: EditorConfig): EditorAPI { // 整文档替换的实际执行体。setDocument(公开 API)在组合输入中会推迟调用本函数。 function performSetDocument(next: string, opts?: SetDocumentOptions) { + // Table cells intentionally keep their DOM edits local until blur so the + // live-preview widget stays stable while typing. Commit that pending text + // before an external whole-document replacement, then release the widget's + // editing lock so the replacement rebuilds decorations from the new doc. + const tableEditFlushed = flushPendingTableEdits(view, true); + if (tableEditFlushed) flushScheduledChangeNow(); const beforeSelection = view.state.selection.main; const silent = opts?.silent === true; const selection = resolveDocumentSelection( @@ -907,13 +922,33 @@ export function createEditor(config: EditorConfig): EditorAPI { return { characters, words, lines }; }, destroy() { - debugNexus("destroy", { - documentLength: view.state.doc.length, - selection: { - anchor: view.state.selection.main.anchor, - head: view.state.selection.main.head, - }, - }); + if (destroyed || destroying) return; + destroying = true; + // A focused table cell may not have blurred yet. Flush its local DOM + // value while the view and change pipeline are still alive. + let firstError: unknown; + let hasError = false; + try { + const tableEditFlushed = flushPendingTableEdits(view, true); + if (tableEditFlushed) flushScheduledChangeNow(); + } catch (error) { + firstError = error; + hasError = true; + } + try { + debugNexus("destroy", { + documentLength: view.state.doc.length, + selection: { + anchor: view.state.selection.main.anchor, + head: view.state.selection.main.head, + }, + }); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + } destroyed = true; focused = false; if (parseTimer) { @@ -928,7 +963,16 @@ export function createEditor(config: EditorConfig): EditorAPI { pendingDocumentLoad = null; composing = false; emitter.clear(); - view.destroy(); + try { + view.destroy(); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + } + destroying = false; + if (hasError) throw firstError; } }; diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index f798b48c..91f49061 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -3,10 +3,74 @@ import type { Table } from "mdast"; import type { LivePreviewLabels } from "./types"; -let tableEditingCount = 0; +const tableEditingCounts = new WeakMap(); -export function isTableEditing(): boolean { - return tableEditingCount > 0; +export function isTableEditing(view: EditorView | null | undefined): boolean { + return !!view && (tableEditingCounts.get(view) ?? 0) > 0; +} + +function incrementTableEditing(view: EditorView): void { + tableEditingCounts.set(view, (tableEditingCounts.get(view) ?? 0) + 1); +} + +function decrementTableEditing(view: EditorView): void { + const next = Math.max(0, (tableEditingCounts.get(view) ?? 0) - 1); + if (next === 0) tableEditingCounts.delete(view); + else tableEditingCounts.set(view, next); +} + +interface TableEditSession { + flush(): boolean; + finish(): void; +} + +const tableEditSessions = new WeakMap>(); + +function registerTableEditSession(view: EditorView, session: TableEditSession): void { + let sessions = tableEditSessions.get(view); + if (!sessions) { + sessions = new Set(); + tableEditSessions.set(view, sessions); + } + sessions.add(session); +} + +function unregisterTableEditSession(view: EditorView, session: TableEditSession): void { + const sessions = tableEditSessions.get(view); + if (!sessions) return; + sessions.delete(session); + if (sessions.size === 0) tableEditSessions.delete(view); +} + +export function flushPendingTableEdits(view: EditorView, finishEditing = false): boolean { + const sessions = Array.from(tableEditSessions.get(view) ?? []); + let changed = false; + let firstError: unknown; + let hasError = false; + for (const session of sessions) { + try { + changed = session.flush() || changed; + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + } + } + if (finishEditing) { + for (const session of sessions) { + try { + session.finish(); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + } + } + } + if (hasError) throw firstError; + return changed; } // 表格方向键导航的调试日志,仅在显式开启 floatboat:markdown-debug 标记后输出。 @@ -67,24 +131,54 @@ function lineStartOffset(lines: string[], lineIdx: number, tableFrom: number): n function sourceOffsetForCell(lines: string[], lineIdx: number, colIdx: number, tableFrom: number): number { const line = lines[lineIdx] ?? ""; const lineStart = lineStartOffset(lines, lineIdx, tableFrom); - let pipeCount = 0; + const separators: number[] = []; for (let i = 0; i < line.length; i++) { if (line[i] !== "|") continue; - if (pipeCount === colIdx) { - let offset = i + 1; - while (offset < line.length && /\s/.test(line[offset]) && line[offset] !== "|") offset++; - return lineStart + offset; - } - pipeCount++; + let backslashes = 0; + for (let j = i - 1; j >= 0 && line[j] === "\\"; j--) backslashes++; + if (backslashes % 2 === 0) separators.push(i); + } + + const cells: Array<{ from: number; to: number }> = []; + let from = 0; + let separatorIdx = 0; + if (separators.length > 0 && line.slice(0, separators[0]).trim() === "") { + from = separators[0] + 1; + separatorIdx = 1; } - return lineStart; + for (; separatorIdx < separators.length; separatorIdx++) { + const separator = separators[separatorIdx]; + cells.push({ from, to: separator }); + from = separator + 1; + } + const lastSeparator = separators[separators.length - 1]; + if (lastSeparator === undefined || line.slice(lastSeparator + 1).trim() !== "") { + cells.push({ from, to: line.length }); + } + + const cell = cells[colIdx]; + if (!cell) return lineStart; + let contentFrom = cell.from; + while (contentFrom < cell.to && /\s/.test(line[contentFrom])) contentFrom++; + return lineStart + contentFrom; } -function getNodeSourceOffsets(node: any, tableFrom: number, rawSourceStart: number, inlineCode = false): { start: number; end: number } | null { +function getNodeSourceOffsets( + node: any, + tableFrom: number, + rawSourceStart: number, + inlineCodeValue?: string +): { start: number; end: number } | null { const startOffset = node?.position?.start?.offset; const endOffset = node?.position?.end?.offset; if (typeof startOffset !== "number" || typeof endOffset !== "number") return null; - const markerOffset = inlineCode ? 1 : 0; + let markerOffset = 0; + if (inlineCodeValue !== undefined) { + const markerCharacters = endOffset - startOffset - inlineCodeValue.length; + if (markerCharacters >= 2 && markerCharacters % 2 === 0) { + markerOffset = markerCharacters / 2; + } + } return { start: startOffset - tableFrom - rawSourceStart + markerOffset, end: endOffset - tableFrom - rawSourceStart - markerOffset, @@ -174,6 +268,41 @@ function collectTextNodes(root: Node): Text[] { return nodes; } +interface RenderedSourceSegment { + renderedFrom: number; + renderedTo: number; + sourceFrom: number; + sourceTo: number; +} + +function writeRenderedSourceMap(cell: HTMLElement): void { + const segments: RenderedSourceSegment[] = []; + let renderedFrom = 0; + for (const text of collectTextNodes(cell)) { + const length = text.textContent?.length ?? 0; + const source = renderedSourceOffsets.get(text); + if (source && length > 0) { + segments.push({ + renderedFrom, + renderedTo: renderedFrom + length, + sourceFrom: source.start, + sourceTo: source.end, + }); + } + renderedFrom += length; + } + cell.dataset.renderedSourceMap = JSON.stringify(segments); +} + +function writePlainSourceMap(cell: HTMLElement, length: number): void { + cell.dataset.renderedSourceMap = JSON.stringify(length > 0 ? [{ + renderedFrom: 0, + renderedTo: length, + sourceFrom: 0, + sourceTo: length, + }] : []); +} + function textPointForRawSourceOffset(cell: HTMLElement, rawOffset: number): { node: Text; offset: number } | null { const textNodes = collectTextNodes(cell); let sawMappedNode = false; @@ -338,8 +467,9 @@ function renderInlineMdast(node: any, mediaOnly = false, tableFrom = 0, rawSourc } case "inlineCode": { const el = document.createElement("code"); - const text = document.createTextNode(typeof node.value === "string" ? node.value : ""); - const sourceOffsets = getNodeSourceOffsets(node, tableFrom, rawSourceStart, true); + const value = typeof node.value === "string" ? node.value : ""; + const text = document.createTextNode(value); + const sourceOffsets = getNodeSourceOffsets(node, tableFrom, rawSourceStart, value); if (sourceOffsets) renderedSourceOffsets.set(text, sourceOffsets); el.appendChild(text); el.style.cssText = @@ -404,9 +534,13 @@ function renderInlineMdast(node: any, mediaOnly = false, tableFrom = 0, rawSourc function renderCellRich(td: HTMLElement, astCell: any, tableFrom = 0, rawSourceStart = 0): void { td.textContent = ""; - if (!astCell || !Array.isArray(astCell.children)) return; + if (!astCell || !Array.isArray(astCell.children)) { + writeRenderedSourceMap(td); + return; + } const mediaOnly = isCellMediaOnly(astCell); for (const child of astCell.children) td.appendChild(renderInlineMdast(child, mediaOnly, tableFrom, rawSourceStart)); + writeRenderedSourceMap(td); } const GRIP_BG = "var(--nexus-bg-muted)"; @@ -427,6 +561,7 @@ interface PendingNativeTextSelection { export class EditableTableWidget extends WidgetType { private editing = false; + private reusable = true; private cleanupEditingLocks: (() => void) | null = null; constructor( @@ -439,7 +574,8 @@ export class EditableTableWidget extends WidgetType { eq(other: EditableTableWidget): boolean { if (this.editing) return true; - return this.source === other.source; + if (!this.reusable) return false; + return this.tableFrom === other.tableFrom && this.source === other.source; } ignoreEvent(): boolean { return true; } @@ -458,6 +594,10 @@ export class EditableTableWidget extends WidgetType { private dispatch(newSource: string): void { const v = this.viewRef.current; if (!v) return; + const tableEnd = this.tableFrom + this.source.length; + if (tableEnd > v.state.doc.length || v.state.doc.sliceString(this.tableFrom, tableEnd) !== this.source) { + return; + } v.dispatch({ changes: { from: this.tableFrom, to: this.tableFrom + this.source.length, insert: newSource } }); } @@ -496,8 +636,8 @@ export class EditableTableWidget extends WidgetType { v.dispatch({ changes: { from: this.tableFrom + this.source.length, insert: nr } }); } - private moveColumn(from: number, to: number): void { - const lines = this.source.split("\n"); + private moveColumn(from: number, to: number, source = this.source): void { + const lines = source.split("\n"); const nl = lines.map((line) => { const p = line.split("|"), cells = p.slice(1, -1); if (from >= cells.length || to >= cells.length) return line; @@ -508,8 +648,8 @@ export class EditableTableWidget extends WidgetType { this.dispatch(nl.join("\n")); } - private moveRow(from: number, to: number): void { - const lines = this.source.split("\n"); + 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]; @@ -559,44 +699,86 @@ export class EditableTableWidget extends WidgetType { drag: false, nativeSelection: false, }; + const editingLockViews: Partial> = {}; let pendingNativeTextSelection: PendingNativeTextSelection | null = null; let pendingNativeTextSelectionTimer: number | null = null; let pendingNativeTextSelectionTimerWindow: Window | null = null; let selectionChangeDocument: Document | null = null; + let registeredSessionView: EditorView | null = null; + let sessionClosed = false; + + const tableEditSession: TableEditSession = { + flush: () => syncDirtyRowsToDocument(), + finish: () => { + self.reusable = false; + self.cleanupEditingLocks?.(); + }, + }; function hasEditingLocks(): boolean { return editingLocks.focus || editingLocks.range || editingLocks.drag || editingLocks.nativeSelection; } + function updateSessionRegistration(): void { + const view = self.viewRef.current; + const shouldRegister = !sessionClosed && (dirtyRows.size > 0 || hasEditingLocks()); + if (!shouldRegister || !view) { + if (registeredSessionView) { + unregisterTableEditSession(registeredSessionView, tableEditSession); + registeredSessionView = null; + } + return; + } + if (registeredSessionView === view) return; + if (registeredSessionView) unregisterTableEditSession(registeredSessionView, tableEditSession); + registerTableEditSession(view, tableEditSession); + registeredSessionView = view; + } + function acquireEditingLock(lock: keyof typeof editingLocks): void { if (editingLocks[lock]) return; editingLocks[lock] = true; self.editing = true; - tableEditingCount++; + const view = self.viewRef.current; + if (view) { + editingLockViews[lock] = view; + incrementTableEditing(view); + } + updateSessionRegistration(); } function releaseEditingLock(lock: keyof typeof editingLocks): void { if (!editingLocks[lock]) return; editingLocks[lock] = false; - tableEditingCount = Math.max(0, tableEditingCount - 1); + const view = editingLockViews[lock]; + if (view) decrementTableEditing(view); + delete editingLockViews[lock]; self.editing = hasEditingLocks(); + updateSessionRegistration(); } this.cleanupEditingLocks = () => { + sessionClosed = true; + dirtyRows.clear(); releaseEditingLock("focus"); releaseEditingLock("range"); releaseEditingLock("drag"); clearPendingNativeTextSelection(); + if (registeredSessionView) { + unregisterTableEditSession(registeredSessionView, tableEditSession); + registeredSessionView = null; + } if (selectionChangeDocument) { selectionChangeDocument.removeEventListener("selectionchange", onDocumentSelectionChange); selectionChangeDocument = null; } }; - const rememberDirtyRow = (lineIdx: number | undefined, row: HTMLElement): void => { - if (lineIdx === undefined) return; + function rememberDirtyRow(lineIdx: number | undefined, row: HTMLElement): void { + if (sessionClosed || lineIdx === undefined) return; dirtyRows.set(lineIdx, row); - }; + updateSessionRegistration(); + } const findRenderedRowForSourceLine = (lineIdx: number): HTMLElement | null => { const ownerDocument = wrapper.ownerDocument; @@ -655,10 +837,12 @@ export class EditableTableWidget extends WidgetType { return "| " + vals.join(" | ") + " |"; }; - const syncDirtyRowsToDocument = (): boolean => { - const v = self.viewRef.current; - if (!v || dirtyRows.size === 0) return false; - + function dirtySourceSnapshot(): { + source: string; + changed: boolean; + firstChangedLineIdx: number | null; + firstChangedRow: HTMLElement | null; + } { const nextSourceLines = sourceLines.slice(); let changed = false; let firstChangedLineIdx: number | null = null; @@ -671,23 +855,65 @@ export class EditableTableWidget extends WidgetType { firstChangedRow ??= row; changed = true; }); + return { + source: nextSourceLines.join("\n"), + changed, + firstChangedLineIdx, + firstChangedRow, + }; + } + + function clearDirtyRows(): void { dirtyRows.clear(); - if (!changed) return false; + updateSessionRegistration(); + } - const anchorLineIdx = firstChangedLineIdx ?? 0; + function currentDocumentContainsOriginalTable(v: EditorView): boolean { + const tableEnd = self.tableFrom + self.source.length; + return tableEnd <= v.state.doc.length && + v.state.doc.sliceString(self.tableFrom, tableEnd) === self.source; + } + + function takeDirtySourceForStructuralEdit(): { source: string; changed: boolean; valid: boolean } { + const v = self.viewRef.current; + if (!v || !currentDocumentContainsOriginalTable(v)) { + clearDirtyRows(); + return { source: self.source, changed: false, valid: false }; + } + const snapshot = dirtySourceSnapshot(); + clearDirtyRows(); + return { source: snapshot.source, changed: snapshot.changed, valid: true }; + } + + function syncDirtyRowsToDocument(): boolean { + const v = self.viewRef.current; + if (!v || dirtyRows.size === 0) return false; + if (!currentDocumentContainsOriginalTable(v)) { + clearDirtyRows(); + return false; + } + + const snapshot = dirtySourceSnapshot(); + if (!snapshot.changed) { + clearDirtyRows(); + return false; + } + + const anchorLineIdx = snapshot.firstChangedLineIdx ?? 0; const anchor = lineStartOffset(sourceLines, anchorLineIdx, self.tableFrom); - if (firstChangedRow) restoreRowScrollPosition(anchorLineIdx, firstChangedRow); + if (snapshot.firstChangedRow) restoreRowScrollPosition(anchorLineIdx, snapshot.firstChangedRow); + clearDirtyRows(); v.dispatch({ changes: { from: self.tableFrom, to: self.tableFrom + self.source.length, - insert: nextSourceLines.join("\n") + insert: snapshot.source }, selection: { anchor, head: anchor } }); v.requestMeasure(); return true; - }; + } const hasActiveCellInWrapper = (): boolean => { const active = wrapper.ownerDocument.activeElement; @@ -1276,6 +1502,7 @@ export class EditableTableWidget extends WidgetType { const savedDropCol = dropTargetCol; const savedDragRow = draggingRow; const savedDropRow = dropTargetRow; + const pendingSource = takeDirtySourceForStructuralEdit(); // Clean up visual state FIRST clearDragHighlights(); @@ -1293,9 +1520,13 @@ export class EditableTableWidget extends WidgetType { // Release editing lock BEFORE dispatch so the resulting update rebuilds widget releaseEditingLock("drag"); - // Now dispatch the move — this triggers a full decoration rebuild with new source - if (movedCol) self.moveColumn(savedDragCol, savedDropCol); - if (movedRow) self.moveRow(savedDragRow, savedDropRow); + if (!pendingSource.valid) return; + + // Commit the pending cell text and the structural move together so a + // drag can never rebuild from the widget's stale constructor source. + if (movedCol) self.moveColumn(savedDragCol, savedDropCol, pendingSource.source); + else if (movedRow) self.moveRow(savedDragRow, savedDropRow, pendingSource.source); + else if (pendingSource.changed) self.dispatch(pendingSource.source); } function startColDrag(colIdx: number, startX: number): void { @@ -1566,6 +1797,7 @@ export class EditableTableWidget extends WidgetType { renderCellRich(td, astCell, self.tableFrom, rawSourceStart); } else { td.textContent = rawSource; + writePlainSourceMap(td, rawSource.length); } td.style.cssText = "position:relative;border-bottom:1px solid var(--nexus-border);border-right:1px solid var(--nexus-border);padding:8px 12px;" + @@ -1805,6 +2037,10 @@ export class EditableTableWidget extends WidgetType { enterRawEditingMode(); }); td.addEventListener("blur", () => { + // input/compositionend can be followed immediately by blur. Snapshot + // before swapping raw DOM back to rich rendering so the final IME + // candidate (or last ordinary keystroke) cannot be overwritten. + rememberCellSourceEdit(); releaseEditingLock("focus"); td.contentEditable = "false"; // Restore default text-flow + width rules — we set them on @@ -1833,10 +2069,12 @@ export class EditableTableWidget extends WidgetType { const cellChanged = (td.dataset.source ?? "") !== rawSource; if (cellChanged) { td.textContent = td.dataset.source ?? ""; + writePlainSourceMap(td, td.textContent?.length ?? 0); } else if (astCell && Array.isArray(astCell.children) && astCell.children.length > 0) { renderCellRich(td, astCell, self.tableFrom, rawSourceStart); } else { td.textContent = td.dataset.source ?? ""; + writePlainSourceMap(td, td.textContent?.length ?? 0); } // For the edited-then-blurred case, queue a no-op selection @@ -1850,6 +2088,13 @@ export class EditableTableWidget extends WidgetType { tableNavDebug("blur-dispatch:skipped (navigating)"); return; } + // Grip mousedown blurs the active cell before mouseup performs the + // reorder. Keep the dirty row pending so onDragEnd can combine the + // text edit and the move in one transaction. + if (draggingCol >= 0 || draggingRow >= 0) { + tableNavDebug("blur-dispatch:skipped (dragging)"); + return; + } // 鼠标从一个单元格切到另一个单元格时,旧单元格的 blur 微任务会晚于 // 新单元格 focus 执行。此时再派发 CM selection 会把焦点抢回编辑器源码区。 if (hasActiveCellInWrapper()) { @@ -1872,18 +2117,19 @@ export class EditableTableWidget extends WidgetType { }); }); - const rememberCellSourceEdit = (): void => { + function rememberCellSourceEdit(): void { if (sourceLineIdx === undefined) return; td.dataset.source = td.textContent ?? ""; rememberDirtyRow(sourceLineIdx, tr); - }; + } const queueCompositionSourceSnapshot = (): void => { if (compositionSourceQueued) return; compositionSourceQueued = true; - window.setTimeout(() => { + const ownerWindow = td.ownerDocument.defaultView ?? window; + ownerWindow.setTimeout(() => { compositionSourceQueued = false; - if (cellComposing) return; + if (cellComposing || !td.isConnected) return; rememberCellSourceEdit(); }, 0); }; @@ -1905,6 +2151,7 @@ export class EditableTableWidget extends WidgetType { td.addEventListener("compositionend", (event) => { event.stopPropagation(); cellComposing = false; + rememberCellSourceEdit(); // 浏览器会在 compositionend 前后把候选词提交进 contentEditable。 // 延后一拍读取 TD,只更新待提交源码,避免输入阶段重绘长表格导致失焦。 queueCompositionSourceSnapshot(); diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index bbca2d71..b9784043 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1348,7 +1348,7 @@ export function createLivePreviewExtension( return build(state, state.selection.ranges, false); }, update(decos: DecorationSet, tr: Transaction) { - if (isTableEditing()) { + if (isTableEditing(viewRef.current)) { return tr.docChanged ? decos.map(tr.changes) : decos; } if (tr.effects.some((effect) => effect.is(rebuildForCompositionStart))) { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 63a9958f..7c8dbd63 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -35,6 +35,23 @@ async function blurCellOutsideTable(cell: HTMLElement): Promise { activeElementSpy.mockRestore(); } +function activateTableCell(cell: HTMLElement, clientX = 80, clientY = 40): void { + document.getSelection()?.removeAllRanges(); + cell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY, + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX, + clientY, + })); +} + describe("live preview", () => { // ── Inline formatting ── // Note: inline markers are hidden when cursor is on a DIFFERENT line (line-level detection). @@ -675,6 +692,36 @@ describe("live preview", () => { container.remove(); }); + it("maps focused cells correctly without leading pipes and across escaped pipes", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let capturedView: EditorView | null = null; + const captureView = ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + capturedView = view; + } + } + ); + const source = "A \\| literal | B\n--- | ---\nvalue \\| one | target"; + const editor = createEditor({ + container, + initialValue: source, + livePreview: true, + plugins: [createGfmPreset(), { name: "capture-view", cmExtensions: [captureView] }] + }); + const view = requireEditorView(capturedView); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[1]; + expect(cell).not.toBeUndefined(); + + activateTableCell(cell!); + + expect(view.state.selection.main.anchor).toBe(source.indexOf("target")); + + editor.destroy(); + container.remove(); + }); + it("keeps the focused table cell DOM stable while typing and commits on blur", async () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -783,6 +830,293 @@ describe("live preview", () => { container.remove(); }); + it("keeps the final IME candidate when compositionend is immediately followed by blur", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| | aa |", + livePreview: true, + plugins: [createGfmPreset()] + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + activateTableCell(cell!); + + cell!.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" })); + cell!.textContent = "ni"; + cell!.dispatchEvent(new InputEvent("input", { + bubbles: true, + data: "ni", + inputType: "insertCompositionText", + isComposing: true, + })); + cell!.textContent = "你好"; + cell!.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "你好" })); + + await blurCellOutsideTable(cell!); + + expect(editor.getDocument()).toContain("| 你好 | aa |"); + expect(editor.getDocument()).not.toContain("| ni | aa |"); + + editor.destroy(); + container.remove(); + }); + + it("flushes a dirty table cell before an external document replacement", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const docs: string[] = []; + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| old | 2 |", + livePreview: true, + plugins: [createGfmPreset()], + parseDelayMs: 10_000, + onChange(doc) { + docs.push(doc); + }, + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + activateTableCell(cell!); + cell!.textContent = "edited"; + cell!.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "edited" })); + + expect(() => editor.setDocument("replacement", { silent: true })).not.toThrow(); + + expect(docs).toHaveLength(1); + expect(docs[0]).toContain("| edited | 2 |"); + expect(editor.getDocument()).toBe("replacement"); + + // A late blur from the detached widget must not dispatch its stale table range. + cell!.dispatchEvent(new Event("blur")); + await Promise.resolve(); + expect(editor.getDocument()).toBe("replacement"); + + editor.destroy(); + container.remove(); + }); + + it("rebuilds a finished table edit session when setDocument reloads the same source", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const source = "| A | B |\n| --- | --- |\n| old | 2 |"; + const editor = createEditor({ + container, + initialValue: source, + livePreview: true, + plugins: [createGfmPreset()] + }); + const firstCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(firstCell).not.toBeUndefined(); + activateTableCell(firstCell!); + + editor.setDocument(source, { silent: true }); + + const reloadedCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(reloadedCell).not.toBeUndefined(); + expect(reloadedCell).not.toBe(firstCell); + activateTableCell(reloadedCell!); + reloadedCell!.textContent = "edited-after-reload"; + reloadedCell!.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: "edited-after-reload" + })); + + await blurCellOutsideTable(reloadedCell!); + + expect(editor.getDocument()).toContain("| edited-after-reload | 2 |"); + + editor.destroy(); + container.remove(); + }); + + it("rebuilds a table widget when unchanged table source moves in the document", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const table = "| A | B |\n| --- | --- |\n| old | 2 |"; + const editor = createEditor({ + container, + initialValue: `x\n\n${table}`, + livePreview: true, + plugins: [createGfmPreset()] + }); + const firstCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(firstCell).not.toBeUndefined(); + + editor.setDocument(`a much longer prefix\n\n${table}`, { silent: true }); + + const movedCell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(movedCell).not.toBeUndefined(); + expect(movedCell).not.toBe(firstCell); + activateTableCell(movedCell!); + movedCell!.textContent = "edited"; + movedCell!.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: "edited" + })); + + await blurCellOutsideTable(movedCell!); + + expect(editor.getDocument()).toContain("| edited | 2 |"); + + editor.destroy(); + container.remove(); + }); + + it("keeps table editing locks isolated between editor instances", () => { + const firstContainer = document.createElement("div"); + const secondContainer = document.createElement("div"); + document.body.append(firstContainer, secondContainer); + const firstEditor = createEditor({ + container: firstContainer, + initialValue: "| A |\n| --- |\n| editing |", + livePreview: true, + plugins: [createGfmPreset()] + }); + const secondEditor = createEditor({ + container: secondContainer, + initialValue: "| A |\n| --- |\n| Old |", + livePreview: true, + plugins: [createGfmPreset()] + }); + const activeCell = firstContainer.querySelectorAll("tr")[2]?.querySelector(".nexus-cell"); + expect(activeCell).not.toBeUndefined(); + activateTableCell(activeCell!); + + secondEditor.setDocument("| A |\n| --- |\n| New |", { silent: true }); + + expect(secondEditor.getDocument()).toContain("| New |"); + expect(secondContainer.querySelector(".nexus-table-wrapper")?.textContent).toContain("New"); + expect(secondContainer.querySelector(".nexus-table-wrapper")?.textContent).not.toContain("Old"); + + firstEditor.destroy(); + secondEditor.destroy(); + firstContainer.remove(); + secondContainer.remove(); + }); + + it("flushes a dirty table cell before destroy", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const docs: string[] = []; + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| old | 2 |", + livePreview: true, + plugins: [createGfmPreset()], + parseDelayMs: 10_000, + onChange(doc) { + docs.push(doc); + }, + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + activateTableCell(cell!); + cell!.textContent = "saved-before-destroy"; + cell!.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "saved-before-destroy" })); + + editor.destroy(); + + expect(docs.at(-1)).toContain("| saved-before-destroy | 2 |"); + expect(container.querySelector(".cm-editor")).toBeNull(); + container.remove(); + }); + + it("still tears down the editor when a flushed table onChange handler throws", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| old | 2 |", + livePreview: true, + plugins: [createGfmPreset()], + parseDelayMs: 10_000, + onChange() { + throw new Error("host failure"); + }, + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + expect(cell).not.toBeUndefined(); + activateTableCell(cell!); + cell!.textContent = "edited"; + cell!.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: "edited" + })); + + expect(() => editor.destroy()).toThrow("host failure"); + + expect(container.querySelector(".cm-editor")).toBeNull(); + container.remove(); + }); + + it("keeps a dirty cell edit when its column is dragged", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + const rows = Array.from(container.querySelectorAll("tr")).filter((row) => row.querySelector(".nexus-cell")); + const headerCells = rows[0].querySelectorAll(".nexus-cell"); + headerCells[0].getBoundingClientRect = () => new DOMRect(0, 0, 50, 30); + headerCells[1].getBoundingClientRect = () => new DOMRect(50, 0, 50, 30); + const cell = rows[1].querySelectorAll(".nexus-cell")[0]; + activateTableCell(cell); + cell.textContent = "edited"; + cell.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "edited" })); + + const grip = container.querySelectorAll(".nexus-col-grip")[0]; + grip.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientX: 25, clientY: 5 })); + cell.dispatchEvent(new Event("blur")); + await Promise.resolve(); + document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true, button: 0, clientX: 75, clientY: 5 })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 75, clientY: 5 })); + + expect(editor.getDocument()).toBe("| B | A |\n| --- | --- |\n| 2 | edited |"); + + editor.destroy(); + container.remove(); + }); + + it("keeps a dirty cell edit when its row is dragged", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + const rows = Array.from(container.querySelectorAll("tr")).filter((row) => row.querySelector(".nexus-cell")); + rows[1].getBoundingClientRect = () => new DOMRect(0, 30, 100, 30); + rows[2].getBoundingClientRect = () => new DOMRect(0, 60, 100, 30); + const cell = rows[1].querySelectorAll(".nexus-cell")[0]; + activateTableCell(cell); + cell.textContent = "edited"; + cell.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "edited" })); + + 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 })); + cell.dispatchEvent(new Event("blur")); + await Promise.resolve(); + 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| 3 | 4 |\n| edited | 2 |"); + + editor.destroy(); + container.remove(); + }); + it("does not let a blurred table cell steal focus from the next active cell", async () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -1182,13 +1516,14 @@ describe("live preview", () => { document.getSelection()?.removeAllRanges(); document.getSelection()?.addRange(partialRange); + vi.useFakeTimers(); document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, clientX: 72, clientY: 15 })); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await vi.advanceTimersByTimeAsync(0); const outside = document.createElement("span"); outside.textContent = "outside"; From c0236da11bf0e1f973f3a5264c35e72c8920e4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8C=AF=E5=AE=87?= Date: Mon, 13 Jul 2026 10:44:07 +0800 Subject: [PATCH 6/6] fix(search): map rendered table highlights to source --- packages/plugin-search/src/index.ts | 316 +++++++++++++++--- .../plugin-search/test/plugin-search.test.ts | 195 ++++++++++- 2 files changed, 451 insertions(+), 60 deletions(-) diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 84d0ed90..8e928815 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -9,6 +9,7 @@ import { replaceNext, search, searchKeymap, + searchPanelOpen, SearchQuery, selectMatches, setSearchQuery @@ -102,10 +103,10 @@ const DEFAULT_LABELS: SearchPluginLabels = { const DEFAULT_SEARCH_HISTORY_KEY = "nexus.search.history"; const DEFAULT_SEARCH_HISTORY_MAX_ENTRIES = 20; -const TABLE_SEARCH_MATCH_HIGHLIGHT = "nexus-table-search-match"; -const TABLE_SEARCH_SELECTED_HIGHLIGHT = "nexus-table-search-selected"; const TABLE_SEARCH_CELL_SELECTOR = ".nexus-table-wrapper .nexus-cell"; -const TABLE_SEARCH_STYLE_ID = "nexus-table-search-highlight-style"; +const TABLE_SEARCH_HIGHLIGHT_PREFIX = "nexus-table-search"; + +let nextTableSearchHighlightId = 0; type SearchHistoryDirection = "previous" | "next"; @@ -116,6 +117,36 @@ type CssHighlightRegistry = { type HighlightConstructor = new (...ranges: Range[]) => unknown; +interface TableSearchHighlightNames { + match: string; + selected: string; + styleId: string; +} + +interface RenderedSearchMatch extends SearchMatch { + sourceFrom: number; + sourceTo: number; +} + +interface TableSearchCell { + element: HTMLElement; + rendered: string; + sourceFrom: number; + sourceTo: number; + segments: RenderedSourceSegment[]; +} + +interface RenderedTableSearchMatch extends RenderedSearchMatch { + cell: HTMLElement; +} + +interface RenderedSourceSegment { + renderedFrom: number; + renderedTo: number; + sourceFrom: number; + sourceTo: number; +} + function getCssHighlightSupport(doc: Document): | { registry: CssHighlightRegistry; @@ -141,22 +172,34 @@ function getCssHighlightSupport(doc: Document): return { registry, Highlight: HighlightCtor }; } -function ensureTableSearchHighlightStyles(doc: Document): void { - if (doc.getElementById(TABLE_SEARCH_STYLE_ID)) return; +function createTableSearchHighlightNames(): TableSearchHighlightNames { + const id = ++nextTableSearchHighlightId; + return { + match: `${TABLE_SEARCH_HIGHLIGHT_PREFIX}-${id}-match`, + selected: `${TABLE_SEARCH_HIGHLIGHT_PREFIX}-${id}-selected`, + styleId: `${TABLE_SEARCH_HIGHLIGHT_PREFIX}-${id}-style` + }; +} +function createTableSearchHighlightStyles( + doc: Document, + names: TableSearchHighlightNames +): HTMLStyleElement { const style = doc.createElement("style"); - style.id = TABLE_SEARCH_STYLE_ID; + style.id = names.styleId; + style.dataset.nexusTableSearchHighlight = "true"; style.textContent = ` -::highlight(${TABLE_SEARCH_MATCH_HIGHLIGHT}) { +::highlight(${names.match}) { background-color: rgba(255, 214, 10, 0.38); color: inherit; } -::highlight(${TABLE_SEARCH_SELECTED_HIGHLIGHT}) { +::highlight(${names.selected}) { background-color: rgba(255, 177, 0, 0.68); color: inherit; } `; doc.head.appendChild(style); + return style; } function createTextRanges(root: Element, from: number, to: number): Range[] { @@ -196,89 +239,252 @@ function readNumericDatasetValue(element: HTMLElement, key: string): number | nu return Number.isFinite(parsed) ? parsed : null; } -function isSelectedSearchMatch(cell: HTMLElement, match: SearchMatch, selectionFrom: number, selectionTo: number): boolean { +function readRenderedSourceSegments(cell: HTMLElement): RenderedSourceSegment[] { + const encoded = cell.dataset.renderedSourceMap; + if (!encoded) return []; + try { + const value = JSON.parse(encoded) as unknown; + if (!Array.isArray(value)) return []; + return value.filter((segment): segment is RenderedSourceSegment => { + if (!segment || typeof segment !== "object") return false; + const candidate = segment as Partial; + return ( + Number.isInteger(candidate.renderedFrom) && + Number.isInteger(candidate.renderedTo) && + Number.isInteger(candidate.sourceFrom) && + Number.isInteger(candidate.sourceTo) && + candidate.renderedFrom! >= 0 && + candidate.renderedTo! >= candidate.renderedFrom! && + candidate.sourceFrom! >= 0 && + candidate.sourceTo! >= candidate.sourceFrom! + ); + }); + } catch { + return []; + } +} + +function mapSourceRangeToRendered( + segments: RenderedSourceSegment[], + sourceFrom: number, + sourceTo: number +): { from: number; to: number } | null { + if (sourceTo <= sourceFrom) return null; + let sourceCursor = sourceFrom; + let renderedFrom: number | null = null; + let renderedCursor = -1; + + for (const segment of segments) { + if (segment.sourceTo <= sourceCursor) continue; + if (segment.sourceFrom > sourceCursor) return null; + const sourceLength = segment.sourceTo - segment.sourceFrom; + const renderedLength = segment.renderedTo - segment.renderedFrom; + if (sourceLength !== renderedLength) return null; + + const partSourceTo = Math.min(sourceTo, segment.sourceTo); + const partRenderedFrom = segment.renderedFrom + sourceCursor - segment.sourceFrom; + const partRenderedTo = segment.renderedFrom + partSourceTo - segment.sourceFrom; + if (renderedFrom === null) renderedFrom = partRenderedFrom; + else if (partRenderedFrom !== renderedCursor) return null; + + renderedCursor = partRenderedTo; + sourceCursor = partSourceTo; + if (sourceCursor === sourceTo) { + return { from: renderedFrom, to: renderedCursor }; + } + } + + return null; +} + +function readTableSearchCell(view: EditorView, cell: HTMLElement): TableSearchCell | null { + const source = cell.dataset.source; const sourceFrom = readNumericDatasetValue(cell, "sourceFrom"); - if (sourceFrom === null) return false; + const sourceTo = readNumericDatasetValue(cell, "sourceTo"); + if (source === undefined || sourceFrom === null || sourceTo === null) return null; + if (sourceTo - sourceFrom !== source.length) return null; + if (view.state.sliceDoc(sourceFrom, sourceTo) !== source) return null; + + const rendered = cell.textContent ?? ""; + if (!rendered) return null; + const segments = readRenderedSourceSegments(cell); + if (segments.length === 0) return null; - const matchFrom = sourceFrom + match.from; - const matchTo = sourceFrom + match.to; - return matchFrom === selectionFrom && matchTo === selectionTo; + return { element: cell, rendered, sourceFrom, sourceTo, segments }; } -function clearTableSearchHighlights(doc: Document): void { - const support = getCssHighlightSupport(doc); - support?.registry.delete(TABLE_SEARCH_MATCH_HIGHLIGHT); - support?.registry.delete(TABLE_SEARCH_SELECTED_HIGHLIGHT); +function findRenderedTableSearchMatches( + view: EditorView, + query: SearchQuery +): RenderedTableSearchMatch[] { + const cells = Array.from(view.dom.querySelectorAll(TABLE_SEARCH_CELL_SELECTOR)) + .filter((cell) => !cell.isContentEditable) + .map((cell) => readTableSearchCell(view, cell)) + .filter((cell): cell is TableSearchCell => cell !== null) + .sort((a, b) => a.sourceFrom - b.sourceFrom); + if (cells.length === 0) return []; + + const renderedMatches: RenderedTableSearchMatch[] = []; + const sourceCursor = query.getCursor(view.state); + let cellIndex = 0; + for (let result = sourceCursor.next(); !result.done; result = sourceCursor.next()) { + const absoluteFrom = result.value.from; + const absoluteTo = result.value.to; + if (absoluteTo <= absoluteFrom) continue; + while (cellIndex < cells.length && cells[cellIndex].sourceTo <= absoluteFrom) cellIndex++; + if (cellIndex >= cells.length) break; + + let cell: TableSearchCell | null = null; + for (let index = cellIndex; index < cells.length && cells[index].sourceFrom < absoluteTo; index++) { + const candidate = cells[index]; + if (absoluteFrom >= candidate.sourceFrom && absoluteTo <= candidate.sourceTo) { + cell = candidate; + break; + } + } + if (!cell) continue; + + const mapped = mapSourceRangeToRendered( + cell.segments, + absoluteFrom - cell.sourceFrom, + absoluteTo - cell.sourceFrom + ); + if (!mapped || mapped.to > cell.rendered.length) continue; + const sourceText = view.state.sliceDoc(absoluteFrom, absoluteTo); + if (cell.rendered.slice(mapped.from, mapped.to) !== sourceText) continue; + + renderedMatches.push({ + cell: cell.element, + from: mapped.from, + to: mapped.to, + text: cell.rendered.slice(mapped.from, mapped.to), + sourceFrom: absoluteFrom, + sourceTo: absoluteTo + }); + } + + return renderedMatches; } -function syncTableSearchHighlights(view: EditorView): void { - const doc = view.dom.ownerDocument; - const support = getCssHighlightSupport(doc); - if (!support) return; +function isSelectedSearchMatch( + match: RenderedSearchMatch, + selections: readonly { from: number; to: number }[] +): boolean { + return selections.some( + (selection) => match.sourceFrom === selection.from && match.sourceTo === selection.to + ); +} - ensureTableSearchHighlightStyles(doc); +class TableSearchHighlighter { + private readonly names = createTableSearchHighlightNames(); + private registry: CssHighlightRegistry | null = null; + private style: HTMLStyleElement | null = null; + private syncScheduled = false; + private destroyed = false; - const query = getSearchQuery(view.state); - if (!query.search) { - clearTableSearchHighlights(doc); - return; + constructor(private readonly view: EditorView) { + this.sync(); } - const selection = view.state.selection.main; - const selectionFrom = Math.min(selection.anchor, selection.head); - const selectionTo = Math.max(selection.anchor, selection.head); - const matchRanges: Range[] = []; - const selectedRanges: Range[] = []; + sync(): void { + if (this.destroyed) return; + const doc = this.view.dom.ownerDocument; + const support = getCssHighlightSupport(doc); + if (!support) { + this.clear(); + return; + } - view.dom.querySelectorAll(TABLE_SEARCH_CELL_SELECTOR).forEach((cell) => { - if (cell.isContentEditable) return; + if (this.registry && this.registry !== support.registry) { + this.registry.delete(this.names.match); + this.registry.delete(this.names.selected); + } + this.registry = support.registry; - const matches = findSearchMatches(cell.textContent ?? "", query.search, { - caseSensitive: query.caseSensitive, - regexp: query.regexp, - wholeWord: query.wholeWord - }); + const query = getSearchQuery(this.view.state); + if (!searchPanelOpen(this.view.state) || !query.valid) { + this.clear(); + return; + } - for (const match of matches) { - const ranges = createTextRanges(cell, match.from, match.to); - if (isSelectedSearchMatch(cell, match, selectionFrom, selectionTo)) { + if (!this.style?.isConnected) { + this.style = createTableSearchHighlightStyles(doc, this.names); + } + + const selections = this.view.state.selection.ranges.map((selection) => ({ + from: selection.from, + to: selection.to, + })); + const matchRanges: Range[] = []; + const selectedRanges: Range[] = []; + + for (const match of findRenderedTableSearchMatches(this.view, query)) { + const ranges = createTextRanges(match.cell, match.from, match.to); + if (isSelectedSearchMatch(match, selections)) { selectedRanges.push(...ranges); } else { matchRanges.push(...ranges); } } - }); - if (matchRanges.length > 0) { - support.registry.set(TABLE_SEARCH_MATCH_HIGHLIGHT, new support.Highlight(...matchRanges)); - } else { - support.registry.delete(TABLE_SEARCH_MATCH_HIGHLIGHT); + if (matchRanges.length > 0) { + support.registry.set(this.names.match, new support.Highlight(...matchRanges)); + } else { + support.registry.delete(this.names.match); + } + + if (selectedRanges.length > 0) { + support.registry.set(this.names.selected, new support.Highlight(...selectedRanges)); + } else { + support.registry.delete(this.names.selected); + } } - if (selectedRanges.length > 0) { - support.registry.set(TABLE_SEARCH_SELECTED_HIGHLIGHT, new support.Highlight(...selectedRanges)); - } else { - support.registry.delete(TABLE_SEARCH_SELECTED_HIGHLIGHT); + scheduleSync(): void { + if (this.destroyed || this.syncScheduled) return; + this.syncScheduled = true; + queueMicrotask(() => { + this.syncScheduled = false; + this.sync(); + }); + } + + clear(): void { + this.registry?.delete(this.names.match); + this.registry?.delete(this.names.selected); + this.style?.remove(); + this.style = null; + } + + destroy(): void { + this.destroyed = true; + this.clear(); + this.registry = null; } } const tableSearchHighlightPlugin = ViewPlugin.fromClass( class { - constructor(private readonly view: EditorView) { - syncTableSearchHighlights(view); + private readonly highlighter: TableSearchHighlighter; + + constructor(view: EditorView) { + this.highlighter = new TableSearchHighlighter(view); } update(update: ViewUpdate): void { const searchQueryChanged = update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(setSearchQuery)) ); - if (update.docChanged || update.selectionSet || update.viewportChanged || searchQueryChanged) { - syncTableSearchHighlights(update.view); + const searchPanelChanged = searchPanelOpen(update.startState) !== searchPanelOpen(update.state); + if (update.docChanged || update.viewportChanged) { + this.highlighter.scheduleSync(); + } else if (update.selectionSet || searchQueryChanged || searchPanelChanged) { + this.highlighter.sync(); } } destroy(): void { - clearTableSearchHighlights(this.view.dom.ownerDocument); + this.highlighter.destroy(); } } ); diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index a6b78829..b97128b9 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -8,8 +8,7 @@ import { } from "../src/index"; const HISTORY_KEY = "nexus:test-search-history"; -const TABLE_SEARCH_MATCH_HIGHLIGHT = "nexus-table-search-match"; -const TABLE_SEARCH_SELECTED_HIGHLIGHT = "nexus-table-search-selected"; +const TABLE_SEARCH_HIGHLIGHT_PREFIX = "nexus-table-search-"; class FakeHighlight { readonly ranges: Range[]; @@ -59,6 +58,18 @@ function readHighlightText(highlight: FakeHighlight | undefined): string { return highlight?.ranges.map((range) => range.toString()).join("") ?? ""; } +function readTableHighlightTexts( + registry: Map, + kind: "match" | "selected" +): string[] { + return Array.from(registry.entries()) + .filter( + ([name]) => + name.startsWith(TABLE_SEARCH_HIGHLIGHT_PREFIX) && name.endsWith(`-${kind}`) + ) + .map(([, highlight]) => readHighlightText(highlight)); +} + function createEmptyRect(): DOMRect { return { bottom: 0, @@ -320,10 +331,10 @@ describe("@floatboat/nexus-plugin-search", () => { submitSearch(input, "SearchNeedle070"); - expect(readHighlightText(highlightSupport.registry.get(TABLE_SEARCH_SELECTED_HIGHLIGHT))).toBe( + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([ "SearchNeedle070" - ); - expect(readHighlightText(highlightSupport.registry.get(TABLE_SEARCH_MATCH_HIGHLIGHT))).toBe(""); + ]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); expect(container.querySelector(".nexus-table-wrapper")?.textContent).toContain("SearchNeedle070"); } finally { editor.destroy(); @@ -332,6 +343,180 @@ describe("@floatboat/nexus-plugin-search", () => { } }); + it("maps rich table matches to their exact Markdown source ranges", () => { + const highlightSupport = installCssHighlightMock(); + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: [ + "| Strong | Split | Link | Hidden URL | Repeated label | Double code |", + "| --- | --- | --- | --- | --- | --- |", + "| **RichNeedle** | **ab**cd | [Label](https://x) | [Shown](https://x.test/HiddenNeedle) | [RepeatedLabel](https://x.test/RepeatedLabel) | ``CodeNeedle`` |" + ].join("\n"), + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + const input = openSearchPanel(container); + + submitSearch(input, "RichNeedle"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([ + "RichNeedle" + ]); + + submitSearch(input, "bc"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); + + submitSearch(input, "Label"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual(["Label"]); + + submitSearch(input, "HiddenNeedle"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); + + submitSearch(input, "RepeatedLabel"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([ + "RepeatedLabel" + ]); + + submitSearch(input, "CodeNeedle"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([ + "CodeNeedle" + ]); + } finally { + editor.destroy(); + container.remove(); + highlightSupport.restore(); + } + }); + + it("keeps table highlights when an unchanged table moves in the document", async () => { + const highlightSupport = installCssHighlightMock(); + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: "| Value |\n| --- |\n| Label |", + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + submitSearch(openSearchPanel(container), "Label"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual(["Label"]); + + editor.replaceRange(0, 0, "a longer prefix\n\n"); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect([ + ...readTableHighlightTexts(highlightSupport.registry, "selected"), + ...readTableHighlightTexts(highlightSupport.registry, "match"), + ]).toContain("Label"); + } finally { + editor.destroy(); + container.remove(); + highlightSupport.restore(); + } + }); + + it("uses whole-document regexp context for rendered table matches", () => { + const highlightSupport = installCssHighlightMock(); + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: "| Value |\n| --- |\n| Label |", + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + const input = openSearchPanel(container); + const regexpField = container.querySelector( + '[data-test-id="markdown-search-regexp-toggle"]' + ); + expect(regexpField).not.toBeNull(); + regexpField!.checked = true; + regexpField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + + submitSearch(input, "Label$"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); + + submitSearch(input, "Label(?= \\|)"); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual(["Label"]); + } finally { + editor.destroy(); + container.remove(); + highlightSupport.restore(); + } + }); + + it("isolates table highlights between editors and clears them on close and destroy", () => { + const highlightSupport = installCssHighlightMock(); + const firstContainer = document.createElement("div"); + const secondContainer = document.createElement("div"); + document.body.append(firstContainer, secondContainer); + const initialStyleCount = document.querySelectorAll( + "style[data-nexus-table-search-highlight]" + ).length; + const firstEditor = createEditor({ + container: firstContainer, + initialValue: "| Value |\n| --- |\n| FirstNeedle |", + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + const secondEditor = createEditor({ + container: secondContainer, + initialValue: "| Value |\n| --- |\n| SecondNeedle |", + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + submitSearch(openSearchPanel(firstContainer), "FirstNeedle"); + submitSearch(openSearchPanel(secondContainer), "SecondNeedle"); + + expect(readTableHighlightTexts(highlightSupport.registry, "selected").sort()).toEqual([ + "FirstNeedle", + "SecondNeedle" + ]); + expect( + document.querySelectorAll("style[data-nexus-table-search-highlight]").length + ).toBe(initialStyleCount + 2); + + firstEditor.destroy(); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([ + "SecondNeedle" + ]); + expect( + document.querySelectorAll("style[data-nexus-table-search-highlight]").length + ).toBe(initialStyleCount + 1); + + secondContainer + .querySelector('[data-test-id="markdown-search-close"]') + ?.click(); + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual([]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); + expect( + document.querySelectorAll("style[data-nexus-table-search-highlight]").length + ).toBe(initialStyleCount); + } finally { + firstEditor.destroy(); + secondEditor.destroy(); + firstContainer.remove(); + secondContainer.remove(); + highlightSupport.restore(); + } + + expect(document.querySelectorAll("style[data-nexus-table-search-highlight]").length).toBe( + initialStyleCount + ); + }); + it("opens a data-test-id annotated search panel from the editor keymap", () => { const container = document.createElement("div"); document.body.append(container);