diff --git a/src/commands.test.ts b/src/commands.test.ts index 2ec9a78e..60fad145 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -308,7 +308,7 @@ describe("capture", () => { text: "00:20:34 \\| a \\| b \\\\\\| c ", created: false, }); - expect(cell.getValue()).toBe("anchor00:20:34 | a | b \\\\| c "); + expect(cell.editor.getValue()).toBe("anchor00:20:34 | a | b \\\\| c "); expect(editor.getValue()).toBe( "| Time | Note |\n| --- | --- |\n| Start | anchor00:20:34 \\| a \\| b \\\\\\| c |\nAfter", ); @@ -319,7 +319,7 @@ describe("capture", () => { run("capture-timestamp"); const first = editor.getValue(); run("capture-segment-10s"); - expect(cell.getValue()).toBe("anchor- 00:20:34- 00:20:24-00:20:34"); + expect(cell.editor.getValue()).toBe("anchor- 00:20:34- 00:20:24-00:20:34"); await undo(); @@ -343,6 +343,36 @@ describe("capture", () => { expect(editor.getValue()).toBe("Notes\n"); }); + it("ends a cell no longer in the document, and writes at the note's caret with an exact Undo", async () => { + const { plugin, editor, cell } = await inCell(); + plugin.settings.timestamp.template = "{{time}} | cut\n"; + cell.containerEl.isConnected = false; + editor.replaceRange("Synced\n", editor.offsetToPos(0)); + editor.setSelection(editor.offsetToPos(editor.getValue().indexOf("anchor") + 6)); + const before = editor.getValue(); + + expect(await plugin.capture.timestamp()).toMatchObject({ text: "00:20:34 \\| cut " }); + + expect(editor.getValue()).toBe( + "Synced\n| Time | Note |\n| --- | --- |\n| Start | anchor00:20:34 \\| cut |\nAfter", + ); + expect(cell.editor.getValue()).toBe("anchor"); + await undo(); + expect(editor.getValue()).toBe(before); + }); + + it("ends a detached cell whose old range covers the capture before Undo puts the note back", async () => { + const { run, openNote, focusPodNotes } = await loadPlugin(playing); + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + run("capture-timestamp"); + editInTableCell(editor, "Notes\n- 00:20:34").containerEl.isConnected = false; + + await undo(); + + expect(editor.getValue()).toBe("Notes\n"); + }); + it("falls back to the note editor when the cell's replaceSelection is not a function", async () => { const { plugin, openNote, focusPodNotes } = await loadPlugin(playing); const { editor } = openNote("Table.md", TABLE, TABLE.indexOf("anchor") + 6); @@ -361,6 +391,16 @@ describe("capture", () => { "| Time | Note |\n| --- | --- |\n| Start | anchor- 00:20:34 |\nAfter", ); }); + + it("still writes through a cell no longer in the document when Obsidian has no destroyTableCell", async () => { + const { run, editor, cell } = await inCell(); + cell.containerEl.isConnected = false; + Reflect.deleteProperty(Reflect.get(editor, "editorComponent"), "destroyTableCell"); + + run("capture-timestamp"); + + expect(cell.editor.getValue()).toBe("anchor- 00:20:34"); + }); }); it("goes to the episode note once the last editor's tab is closed", async () => { diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 80c766d5..accae5e7 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -101,6 +101,7 @@ export class CaptureService { cursorLine: editor.getCursor("from").line, }); const before = editor.getValue(); + endDetachedTableCell(editor); const cell = tableCellWriter(editor); if (cell) cell(inserted); else editor.replaceSelection(inserted); @@ -217,6 +218,7 @@ export class CaptureService { */ function revertIn(editor: Editor, { before, after }: Snapshot): boolean { if (editor.getValue() !== after) return false; + endDetachedTableCell(editor); let from = 0; while (from < before.length && before[from] === after[from]) from++; let tail = 0; @@ -254,6 +256,21 @@ function tableCellWriter(editor: Editor): ((escaped: string) => void) | null { : null; } +/** + * Obsidian keeps a table cell's editor once its table leaves the note's view, as in Source + * mode or after the tab showed another note. That cell rewrites the table from its old copy + * at its old place, and takes over the note editor's edits there, so it is ended as Obsidian + * ends it when the note's editor takes focus. + */ +function endDetachedTableCell(editor: Editor): void { + const component = field(editor, "editorComponent"); + const cell = field(component, "tableCell"); + const end = field(component, "destroyTableCell"); + if (cell && !field(field(cell, "containerEl"), "isConnected") && typeof end === "function") { + end.call(component); + } +} + function field(value: unknown, key: string): unknown { return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; } diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts index 58377afa..3eee3b8f 100644 --- a/tests/e2e/table-cell-capture.test.ts +++ b/tests/e2e/table-cell-capture.test.ts @@ -368,6 +368,121 @@ describe("Live Preview table captures", () => { ).toEqual([before, before]); }); + test.each(["Source mode", "the tab showed another note"])( + "#473 a capture after %s and a change made elsewhere keeps the table and its Undo", + async (away) => { + const { obsidian, sandbox } = getContext(); + const table = + "# Captures\n\n| Moment | Notes |\n| ------ | ------ |\n| Start | anchor |\n| Later | other |\n\nAfter table\n"; + const path = await setUp({ content: table }); + const view = `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view`; + const changeElsewhere = `app.vault.process(app.vault.getAbstractFileByPath(${jsLiteral(path)}), (text) => text.replace("| other |", "| other, edited elsewhere |"))`; + if (away === "Source mode") { + await obsidian.exec("command", { id: "editor:toggle-source" }); + await obsidian.dev.evalJsonAsync(`${changeElsewhere}.then(() => true)`); + } else { + const elsewhere = sandbox.path("Elsewhere.md"); + await writeVaultFile(obsidian, elsewhere, "Elsewhere\n"); + await obsidian.dev.evalJsonAsync(`(async () => { + await ${view}.leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(elsewhere)})); + await ${changeElsewhere}; + return true; + })()`); + await obsidian.exec("command", { id: "app:go-back" }); + } + const before = table.replace("| other |", "| other, edited elsewhere |"); + expect( + await eventually( + obsidian, + () => obsidian.dev.evalJson(`${view}.editor.getValue()`), + before, + 10_000, + ), + ).toBe(before); + expect( + await obsidian.dev.evalJson( + `${view}.editor.editorComponent.tableCell?.containerEl.isConnected ?? null`, + ), + "Obsidian still holds a cell no longer in the document", + ).toBe(false); + + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + + const captured = before.replace("anchor", "anchor00:00:30 \\| cut "); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), captured, 10_000), + ).toBe(captured); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, + 90_000, + ); + + test("#473 a capture into a cell of a note in a background tab keeps that cell open", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + await obsidian.exec("command", { id: "workspace:new-tab" }); + expect( + await obsidian.dev.evalJson(`(() => { + const cell = app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.editorComponent.tableCell; + return { attached: cell.containerEl.isConnected, shown: cell.containerEl.isShown() }; + })()`), + "the cell is in the document but not on screen", + ).toEqual({ attached: true, shown: false }); + + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + + await expectCell(path, "anchor00:00:30 \\| cut "); + expect(await cellText(path)).toBe("anchor00:00:30 | cut "); + }); + + test("#473 Undo puts the note back after a cell of another note in its tab went stale", async () => { + const { obsidian, sandbox } = getContext(); + const wide = sandbox.path("Wide header.md"); + const header = Array(80).fill("wide").join(" "); + await writeVaultFile(obsidian, wide, `| ${header} |\n| --- |\n| x |\n`); + const path = await setUp(); + const view = (file: string) => + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(file)})?.view`; + const before = await obsidian.dev.evalJson(`${view(path)}.editor.getValue()`); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + const captured = await obsidian.dev.evalJson(`${view(path)}.editor.getValue()`); + await obsidian.dev.evalJsonAsync(`(async () => { + await ${view(path)}.leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(wide)})); + return true; + })()`); + await click(obsidian, { + name: "wide header cell", + elements: `[...${view(wide)}.containerEl.querySelectorAll(".markdown-source-view th")]`, + }); + await obsidian.exec("command", { id: "app:go-back" }); + expect( + await eventually( + obsidian, + () => obsidian.dev.evalJson(`${view(path)}.editor.getValue()`), + captured, + 5000, + ), + ).toBe(captured); + expect( + await obsidian.dev.evalJson(`(() => { + const cell = ${view(path)}.editor.editorComponent.tableCell; + return { attached: cell.containerEl.isConnected, text: cell.editor.getValue() }; + })()`), + "the tab still holds the other note's header cell", + ).toEqual({ attached: false, text: header }); + + await click(obsidian, lastUndo); + + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }); + test("#445 Source mode still escapes table captures and preserves Undo", async () => { const { obsidian } = getContext(); const path = await setUp({ source: true }); diff --git a/tests/mocks/captureEditor.ts b/tests/mocks/captureEditor.ts index 418770cf..63346b9e 100644 --- a/tests/mocks/captureEditor.ts +++ b/tests/mocks/captureEditor.ts @@ -42,8 +42,9 @@ export function fakeEditor(text: string, caret = text.length) { /** * A Live Preview cell has an editor of its own that holds its pipes unescaped, and Obsidian escapes - * them as it writes the cell to the note. An edit made through the note's editor inside - * the cell reaches the cell only later, so here it never lands. + * them as it writes the cell to the note at the offsets the cell had when it opened. An edit + * made through the note's editor inside the cell reaches the cell only later, so here it + * is dropped until Obsidian ends the cell. */ export function editInTableCell(note: ReturnType, cell: string) { const start = note.getValue().indexOf(cell); @@ -52,7 +53,7 @@ export function editInTableCell(note: ReturnType, cell: strin text.replace(/(\\*)\|/g, (pipe, slashes: string) => slashes.length % 2 ? pipe : `${slashes}\\|`, ); - const write = note.replaceRange; + const { replaceSelection, replaceRange: write } = note; const cellEditor = { text: cell, getValue() { @@ -67,12 +68,20 @@ export function editInTableCell(note: ReturnType, cell: strin const inCell = (from: EditorPosition, to: EditorPosition) => note.posToOffset(from) >= start && note.posToOffset(to) <= start + escape(cellEditor.text).length; + const tableCell = { editor: cellEditor, containerEl: { isConnected: true } }; + const editorComponent = { + tableCell: tableCell as typeof tableCell | null, + destroyTableCell() { + editorComponent.tableCell = null; + Object.assign(note, { replaceSelection, replaceRange: write }); + }, + }; Object.assign(note, { - editorComponent: { tableCell: { editor: cellEditor } }, + editorComponent, replaceSelection: () => {}, replaceRange(text: string, from: EditorPosition, to = from) { if (!inCell(from, to)) write(text, from, to); }, }); - return cellEditor; + return tableCell; }