From 026c6fc8246a1f6da91de63e7d19d3cd8b3d3d03 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 09:32:22 +0200 Subject: [PATCH 1/2] test(capture): reproduce repeated Live Preview table captures --- tests/e2e/table-cell-capture.test.ts | 259 +++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 tests/e2e/table-cell-capture.test.ts diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts new file mode 100644 index 00000000..fc8b6963 --- /dev/null +++ b/tests/e2e/table-cell-capture.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { + clickInEditor, + openInEditor, + runFromPalette, + showLibrary, + showPodNotes, +} from "./journey/actions"; +import { startFixtureServer, type FixtureFeed } from "./journey/fixtures"; +import { cdp, click, typeText } from "./journey/input"; +import { readVaultText, seedLibrary, waitForPlayback, writeVaultFile } from "./journey/library"; +import { ui } from "./journey/targets"; +import { eventually, notices } from "./journey/wait"; +import { jsLiteral } from "./runtime"; +import type { IPodNotesSettings } from "../../src/types/IPodNotesSettings"; + +const getContext = createPodNotesE2EHarness("table-cell-capture"); +const FEED: FixtureFeed = { + title: "Table capture podcast", + slug: "table-capture", + artwork: false, + episodes: [{ title: "Table episode", seconds: 60, guid: "table-episode" }], +}; +const TABLE = + "# Captures\n\n| Moment | Notes |\n| --- | --- |\n| Start | anchor |\n\nAfter table\n"; + +async function setUp({ content = TABLE, source = false } = {}): Promise { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await plugin.updateDataAndReload((data) => { + data.timestamp.template = "{{time}} | cut\n"; + }); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.episode("Table episode")); + await waitForPlayback(obsidian, (state) => (state.media?.seconds ?? 0) > 0, "episode loaded"); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (state) => !state.playing, "episode paused"); + await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.currentTime = 30"); + const path = sandbox.path("Table captures.md"); + await writeVaultFile(obsidian, path, content); + await openInEditor(obsidian, path); + if (source) { + await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}); + await leaf.setViewState({type:"markdown",state:{file:${jsLiteral(path)},mode:"source",source:true}}); + return true; + })()`); + expect(await clickInEditor(obsidian, path, 4, 16)).toEqual({ line: 4, ch: 16 }); + return path; + } + await click(obsidian, { + name: "Notes table cell", + elements: + '[...document.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "anchor")', + }); + await cdp(obsidian, "Input.dispatchKeyEvent", { + type: "keyDown", + key: "End", + windowsVirtualKeyCode: 35, + }); + await cdp(obsidian, "Input.dispatchKeyEvent", { + type: "keyUp", + key: "End", + windowsVirtualKeyCode: 35, + }); + expect( + await obsidian.dev.evalJson('Boolean(document.activeElement?.closest("td"))'), + "the cell editor has focus", + ).toBe(true); + return path; +} + +const lastUndo = { + name: "last capture Undo", + elements: '[...document.querySelectorAll(".notice button.pn-undo")].slice(-1)', +}; + +async function cellText(): Promise { + return await getContext().obsidian.dev.evalJson( + 'app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.editor?.editorComponent?.tableCell)?.view.editor.editorComponent.tableCell.editor.getValue() ?? ""', + ); +} + +function rows(text: string): string[][] { + return text + .split("\n") + .filter((line) => line.startsWith("|")) + .map((line) => + line + .split(/(? cell.trim()), + ); +} + +async function expectCell(path: string, cell: string): Promise { + const { obsidian } = getContext(); + const expected = ["Start", cell.trim()]; + expect( + await eventually( + obsidian, + async () => rows(await readVaultText(obsidian, path)).at(-1), + expected, + 10_000, + ), + "persisted table cells", + ).toEqual(expected); + const text = await readVaultText(obsidian, path); + expect(rows(text)).toHaveLength(3); + expect(rows(text)[0]).toEqual(["Moment", "Notes"]); + expect(text).toMatch(/^# Captures\n\n/); + expect(text).toMatch(/\n\nAfter table\n$/); + return text; +} + +describe("Live Preview table captures", () => { + test("#445 three captures persist in one cell and Undo removes only the last", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + await runFromPalette(obsidian, "Capture Timestamp"); + const first = "anchor00:00:30 \\| cut "; + await expectCell(path, first); + await obsidian.exec("command", { id: "podnotes:capture-segment-10s" }); + const second = `${first}00:00:20-00:00:30 \\| cut `; + const beforeLast = await expectCell(path, second); + await obsidian.exec("command", { id: "podnotes:capture-segment-20s" }); + await expectCell(path, `${second}00:00:10-00:00:30 \\| cut `); + expect(await cellText()).toBe( + "anchor00:00:30 | cut 00:00:20-00:00:30 | cut 00:00:10-00:00:30 | cut ", + ); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), beforeLast, 10_000), + "Undo restores the exact saved note", + ).toBe(beforeLast); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + expect( + await obsidian.dev.evalJson( + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.getValue()`, + ), + ).toBe(beforeLast); + }, 90_000); + + test("#445 player, mini player and headset capture into the last focused cell", async () => { + const { obsidian, plugin } = getContext(); + await obsidian.dev.evalJson(`(() => { + const session = navigator.mediaSession; + const original = session.setActionHandler; + const handlers = new Map(); + session.setActionHandler = function(action, handler) { + handlers.set(action, handler); + return original.call(this, action, handler); + }; + window.__note2Media = { session, original, handlers }; + return true; + })()`); + onTestFinished(async () => { + await obsidian.dev.evalJson(`(() => { + const recorder = window.__note2Media; + if (recorder) recorder.session.setActionHandler = recorder.original; + delete window.__note2Media; + return true; + })()`); + }); + const path = await setUp(); + await click(obsidian, ui.playerTimestamp); + const first = "anchor00:00:30 \\| cut "; + await expectCell(path, first); + await click(obsidian, ui.collapsePlayer); + await click(obsidian, ui.miniCapture); + const second = `${first}00:00:30 \\| cut `; + await expectCell(path, second); + expect(await plugin.isEnabled()).toBe(true); + await obsidian.dev.evalJson(`(() => { + const handler = window.__note2Media.handlers.get("previoustrack"); + if (!handler) throw new Error("The headset capture handler is not installed"); + handler({ action: "previoustrack" }); + return true; + })()`); + await expectCell(path, `${second}00:00:30 \\| cut `); + await click(obsidian, lastUndo); + await expectCell(path, second); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 90_000); + + test("#445 Undo refuses after a different table cell changes", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + await expectCell(path, "anchor00:00:30 \\| cut "); + await click(obsidian, { + name: "Moment table cell", + elements: + '[...document.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "Start")', + }); + await typeText(obsidian, "edited"); + const edited = await obsidian.dev.evalJson( + "app.workspace.activeEditor.editor.getValue()", + ); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), edited, 10_000), + ).toBe(edited); + expect(await notices(obsidian)).toContain( + "The note isn't as the capture left it, so the timestamp stays", + ); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 90_000); + + test("#445 Undo restores a capture inside a cell without changing the table width", async () => { + const { obsidian } = getContext(); + const content = TABLE.replace( + "\n\nAfter table", + `\n| Kept | ${"wide".repeat(50)} |\n\nAfter table`, + ); + const path = await setUp({ content }); + const before = await obsidian.dev.evalJson( + "app.workspace.activeEditor.editor.getValue()", + ); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + expect( + await eventually( + obsidian, + async () => rows(await readVaultText(obsidian, path))[2], + ["Start", "anchor00:00:30 \\| cut"], + 10_000, + ), + ).toEqual(["Start", "anchor00:00:30 \\| cut"]); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + "the entire note is restored including cell padding", + ).toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 90_000); + + test("#445 Source mode still escapes table captures and preserves Undo", async () => { + const { obsidian } = getContext(); + const path = await setUp({ source: true }); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + const first = "anchor00:00:30 \\| cut "; + await expectCell(path, first); + await obsidian.exec("command", { id: "podnotes:capture-segment-10s" }); + const beforeLast = await expectCell(path, `${first}00:00:20-00:00:30 \\| cut `); + await obsidian.exec("command", { id: "podnotes:capture-segment-20s" }); + await expectCell(path, `${first}00:00:20-00:00:30 \\| cut 00:00:10-00:00:30 \\| cut `); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), beforeLast, 10_000), + ).toBe(beforeLast); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 90_000); +}); From f10c311fb6743847d91d7195ecd03232e7aa0cf2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 10:19:32 +0200 Subject: [PATCH 2/2] fix(capture): persist captures in the active Live Preview cell --- src/commands.test.ts | 113 ++++++++++++------- src/services/CaptureService.ts | 38 ++++++- src/utility/prepareTimestampInsertion.ts | 14 +-- tests/e2e/table-cell-capture.test.ts | 136 ++++++++++++++++++++--- tests/mocks/captureEditor.ts | 78 +++++++++++++ 5 files changed, 303 insertions(+), 76 deletions(-) create mode 100644 tests/mocks/captureEditor.ts diff --git a/src/commands.test.ts b/src/commands.test.ts index 3df51c52..52b248c3 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import * as obsidian from "obsidian"; -import { MarkdownView, TFile, TFolder, type EditorPosition, type WorkspaceLeaf } from "obsidian"; +import { MarkdownView, TFile, TFolder, type WorkspaceLeaf } from "obsidian"; import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import PodNotes from "./main"; @@ -10,6 +10,7 @@ import { PickerModal } from "./ui/PickerModal"; import { fetchTextWithTimeout } from "./utility/networkRequest"; import { privateFeedPlaceholder } from "./utility/privateFeedUrl"; import { dataFileText } from "../tests/mocks/dataFileWriter"; +import { fakeEditor, editInTableCell } from "../tests/mocks/captureEditor"; vi.mock("./utility/networkRequest", async (importOriginal) => ({ ...(await importOriginal()), @@ -51,46 +52,6 @@ function vaultFile(path: string): TFile { }); } -/** Enough of CodeMirror for a capture and its Undo: text, a selection, offsets and ranges. */ -function fakeEditor(text: string, caret = text.length) { - let value = text; - let from = caret; - let to = caret; - const lines = () => value.split("\n"); - const pos = (offset: number) => { - if (offset < 0 || offset > value.length) - throw new RangeError(`Offset ${offset} is outside`); - const before = value.slice(0, offset).split("\n"); - return { line: before.length - 1, ch: before[before.length - 1].length }; - }; - const offset = ({ line, ch }: EditorPosition) => - lines() - .slice(0, line) - .reduce((sum, text) => sum + text.length + 1, ch); - return { - getValue: () => value, - getLine: (line: number) => lines()[line] ?? "", - lineCount: () => lines().length, - getCursor: (side?: "from" | "to") => pos(side === "from" ? from : to), - getSelection: () => value.slice(from, to), - setSelection(anchor: EditorPosition, head = anchor) { - from = offset(anchor); - to = offset(head); - }, - replaceSelection: (replacement: string) => { - value = value.slice(0, from) + replacement + value.slice(to); - from = to = from + replacement.length; - }, - posToOffset: offset, - offsetToPos: pos, - getRange: (start: EditorPosition, end: EditorPosition) => - value.slice(offset(start), offset(end)), - replaceRange(replacement: string, start: EditorPosition, end = start) { - value = value.slice(0, offset(start)) + replacement + value.slice(offset(end)); - }, - }; -} - /** * `notes` is what is on disk. Like Obsidian's, `cachedRead` answers from a cache that a * change on disk outside the vault does not update; `process` reads the disk. @@ -312,6 +273,76 @@ describe("capture", () => { expect(editor.getValue()).toBe("| Time | Note |\n| --- | --- |\n| 00:20:34 \\| seen "); }); + it("captures before Obsidian delivers the leaf-change event after a player click", async () => { + const { plugin, openNote, workspace, read } = await loadPlugin(playing); + const { editor } = openNote("Journal.md", "Notes\n"); + workspace.activeEditor = null; + + expect(await plugin.capture.timestamp()).toMatchObject({ + destination: "editor", + path: "Journal.md", + }); + expect(editor.getValue()).toBe("Notes\n- 00:20:34"); + expect(read(NOTE_PATH)).toBeUndefined(); + }); + + describe("in Live Preview", () => { + const TABLE = "| Time | Note |\n| --- | --- |\n| Start | anchor |\nAfter"; + + async function inCell() { + const session = await loadPlugin(playing); + const tab = session.openNote("Table.md", TABLE); + const cell = editInTableCell(tab.editor, "anchor"); + session.focusPodNotes(); + return { ...session, ...tab, cell }; + } + + it("lands in a table cell's editor, which shows the pipes the note escapes", async () => { + const { plugin, editor, cell } = await inCell(); + plugin.settings.timestamp.template = "{{time}} | a \\| b \\\\| c\n"; + + expect(await plugin.capture.timestamp()).toEqual({ + destination: "editor", + path: "Table.md", + text: "00:20:34 \\| a \\| b \\\\\\| c ", + created: false, + }); + expect(cell.getValue()).toBe("anchor00:20:34 | a | b \\\\| c "); + expect(editor.getValue()).toBe( + "| Time | Note |\n| --- | --- |\n| Start | anchor00:20:34 \\| a \\| b \\\\\\| c |\nAfter", + ); + }); + + it("keeps each capture in a table cell, and Undo takes out only the last", async () => { + const { run, editor, cell } = await inCell(); + 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"); + + await undo(); + + expect(first).toBe( + "| Time | Note |\n| --- | --- |\n| Start | anchor- 00:20:34 |\nAfter", + ); + expect(editor.getValue()).toBe(first); + expect(said()).toEqual(["Timestamp added to Table", "Timestamp added to Table"]); + }); + + it("writes outside a table through the note's editor", async () => { + const { run, openNote, focusPodNotes } = await loadPlugin(playing); + const { editor } = openNote("Journal.md", "Notes\n"); + Object.assign(editor, { editorComponent: { tableCell: null } }); + focusPodNotes(); + run("capture-timestamp"); + expect(editor.getValue()).toBe("Notes\n- 00:20:34"); + + await undo(); + + expect(editor.getValue()).toBe("Notes\n"); + }); + }); + it("goes to the episode note once the last editor's tab is closed", async () => { const { run, openNote, focusPodNotes, close, read } = await loadPlugin(playing); const { leaf, editor } = openNote("Journal.md", ""); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index a9d2cf24..80c766d5 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -17,7 +17,7 @@ export interface Capture { destination: "editor" | "note"; /** Vault path of the note the text went into. */ path: string; - /** The text as inserted. */ + /** The captured text as the note stores it. */ text: string; /** The episode note did not exist and was created for this capture. */ created: boolean; @@ -95,16 +95,15 @@ export class CaptureService { const target = this.editorTarget(); if (target?.editor) { const { editor, file } = target; - // replaceSelection lets the editor place the caret, which hand-computed - // positions get wrong in Live Preview table cells. Inside a table the text is - // escaped so pipes and newlines don't break the row (#165). const inserted = prepareTimestampForInsertion(text, { getLine: (line) => editor.getLine(line), lineCount: editor.lineCount(), cursorLine: editor.getCursor("from").line, }); const before = editor.getValue(); - editor.replaceSelection(inserted); + const cell = tableCellWriter(editor); + if (cell) cell(inserted); + else editor.replaceSelection(inserted); const snapshot = { before, after: editor.getValue() }; // Outside a note's own view, as in a canvas card, the editor's text need not be // its file's, so only that editor can take the capture back. @@ -197,7 +196,10 @@ export class CaptureService { const active = this.plugin.app.workspace.activeEditor; // A note in Reading view keeps its hidden editor, where written text is lost. const reading = active instanceof MarkdownView && active.getMode() === "preview"; - return active?.editor && !reading ? active : this.editingView(this.lastEditorLeaf); + // Obsidian clears activeEditor before its deferred active-leaf-change event. + return active?.editor && !reading + ? active + : (this.editingView(this.activeLeaf) ?? this.editingView(this.lastEditorLeaf)); } /** The leaf's note while it is open in Source or Live Preview mode. */ @@ -224,6 +226,12 @@ function revertIn(editor: Editor, { before, after }: Snapshot): boolean { ) { tail++; } + // Obsidian holds back an edit that falls inside the table cell being edited and + // hands it to the cell's editor later, remapped against its decoded text. + // Starting before the cell bypasses that remap without moving the edit's end. + if (tableCellWriter(editor)) { + from = after.slice(0, from).lastIndexOf("\n") + 1; + } editor.replaceRange( before.slice(from, before.length - tail), editor.offsetToPos(from), @@ -231,3 +239,21 @@ function revertIn(editor: Editor, { before, after }: Snapshot): boolean { ); return true; } + +/** + * Writes table cell text where Live Preview edits it: the cell has an editor of its own, + * outside Obsidian's API, that the note's editor reaches only late and remapped. The + * cell's editor shows a pipe unescaped and Obsidian escapes it again as it writes the + * cell to the note. + */ +function tableCellWriter(editor: Editor): ((escaped: string) => void) | null { + const cell = field(field(field(editor, "editorComponent"), "tableCell"), "editor"); + const replaceSelection = field(cell, "replaceSelection"); + return typeof replaceSelection === "function" + ? (escaped) => replaceSelection.call(cell, escaped.replace(/\\\|/g, "|")) + : null; +} + +function field(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; +} diff --git a/src/utility/prepareTimestampInsertion.ts b/src/utility/prepareTimestampInsertion.ts index 2428fbd5..15591ff2 100644 --- a/src/utility/prepareTimestampInsertion.ts +++ b/src/utility/prepareTimestampInsertion.ts @@ -1,15 +1,3 @@ -// Helpers for inserting a captured timestamp at the editor cursor without -// breaking the markdown around it. See issue #165: capturing a timestamp into a -// markdown table cell would land the text in the wrong cell, scatter the cursor, -// or break the row. The root causes were (1) deriving a position with -// `getCursor()` and feeding it to `replaceRange` + a hand-computed `setCursor`, -// which fights Obsidian's Live Preview table-editing widget, and (2) inserting -// raw `|`/newline characters that are structural inside a table. -// -// The command now inserts with `editor.replaceSelection` (which lets CodeMirror -// own cursor placement) and, when the cursor sits inside a table, runs the -// capture through `escapeForTableCell` so pipes and newlines stay textual. - /** * Strip a leading blockquote/callout marker chain (`>`, `> >`, ...) so a table * nested inside a callout or blockquote is detected the same as a top-level one. @@ -95,7 +83,7 @@ export function escapeForTableCell(text: string): string { /** * Resolve the exact string to insert for a captured timestamp: the raw capture * everywhere except inside a table cell, where it is escaped so the row stays - * intact. Callers insert the result with `editor.replaceSelection`. + * intact. */ export function prepareTimestampForInsertion( capture: string, diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts index fc8b6963..8b946be3 100644 --- a/tests/e2e/table-cell-capture.test.ts +++ b/tests/e2e/table-cell-capture.test.ts @@ -25,15 +25,25 @@ const FEED: FixtureFeed = { const TABLE = "# Captures\n\n| Moment | Notes |\n| --- | --- |\n| Start | anchor |\n\nAfter table\n"; -async function setUp({ content = TABLE, source = false } = {}): Promise { +const WIDE_TABLE = TABLE.replace( + "\n\nAfter table", + `\n| Kept | ${"wide ".repeat(40)} |\n\nAfter table`, +); + +async function setUp({ + content = TABLE, + source = false, + template = "{{time}} | cut\n", +} = {}): Promise { const context = getContext(); const { obsidian, plugin, sandbox } = context; const server = await startFixtureServer([FEED]); onTestFinished(() => server.stop()); await seedLibrary(context, server, { subscriptions: [FEED] }); await plugin.updateDataAndReload((data) => { - data.timestamp.template = "{{time}} | cut\n"; + data.timestamp.template = template; }); + await showPodNotes(obsidian); await showLibrary(obsidian); await click(obsidian, ui.podcastCard(FEED.title)); @@ -56,8 +66,7 @@ async function setUp({ content = TABLE, source = false } = {}): Promise } await click(obsidian, { name: "Notes table cell", - elements: - '[...document.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "anchor")', + elements: `[...app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.containerEl.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "anchor")`, }); await cdp(obsidian, "Input.dispatchKeyEvent", { type: "keyDown", @@ -81,9 +90,9 @@ const lastUndo = { elements: '[...document.querySelectorAll(".notice button.pn-undo")].slice(-1)', }; -async function cellText(): Promise { +async function cellText(path: string): Promise { return await getContext().obsidian.dev.evalJson( - 'app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.editor?.editorComponent?.tableCell)?.view.editor.editorComponent.tableCell.editor.getValue() ?? ""', + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)})?.view.editor.editorComponent.tableCell.editor.getValue() ?? ""`, ); } @@ -131,7 +140,7 @@ describe("Live Preview table captures", () => { const beforeLast = await expectCell(path, second); await obsidian.exec("command", { id: "podnotes:capture-segment-20s" }); await expectCell(path, `${second}00:00:10-00:00:30 \\| cut `); - expect(await cellText()).toBe( + expect(await cellText(path)).toBe( "anchor00:00:30 | cut 00:00:20-00:00:30 | cut 00:00:10-00:00:30 | cut ", ); await click(obsidian, lastUndo); @@ -145,10 +154,12 @@ describe("Live Preview table captures", () => { `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.getValue()`, ), ).toBe(beforeLast); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + await expectCell(path, `${second}00:00:30 \\| cut `); }, 90_000); test("#445 player, mini player and headset capture into the last focused cell", async () => { - const { obsidian, plugin } = getContext(); + const { obsidian } = getContext(); await obsidian.dev.evalJson(`(() => { const session = navigator.mediaSession; const original = session.setActionHandler; @@ -170,13 +181,23 @@ describe("Live Preview table captures", () => { }); const path = await setUp(); await click(obsidian, ui.playerTimestamp); + expect( + await eventually( + obsidian, + () => + obsidian.dev.evalJson( + `(${ui.playerTimestamp.elements})[0].textContent.trim()`, + ), + "Added", + 2000, + ), + ).toBe("Added"); const first = "anchor00:00:30 \\| cut "; await expectCell(path, first); await click(obsidian, ui.collapsePlayer); await click(obsidian, ui.miniCapture); const second = `${first}00:00:30 \\| cut `; await expectCell(path, second); - expect(await plugin.isEnabled()).toBe(true); await obsidian.dev.evalJson(`(() => { const handler = window.__note2Media.handlers.get("previoustrack"); if (!handler) throw new Error("The headset capture handler is not installed"); @@ -196,8 +217,7 @@ describe("Live Preview table captures", () => { await expectCell(path, "anchor00:00:30 \\| cut "); await click(obsidian, { name: "Moment table cell", - elements: - '[...document.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "Start")', + elements: `[...app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.containerEl.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "Start")`, }); await typeText(obsidian, "edited"); const edited = await obsidian.dev.evalJson( @@ -215,11 +235,7 @@ describe("Live Preview table captures", () => { test("#445 Undo restores a capture inside a cell without changing the table width", async () => { const { obsidian } = getContext(); - const content = TABLE.replace( - "\n\nAfter table", - `\n| Kept | ${"wide".repeat(50)} |\n\nAfter table`, - ); - const path = await setUp({ content }); + const path = await setUp({ content: WIDE_TABLE }); const before = await obsidian.dev.evalJson( "app.workspace.activeEditor.editor.getValue()", ); @@ -240,6 +256,94 @@ describe("Live Preview table captures", () => { expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); }, 90_000); + test("#445 table captures preserve existing pipe escape parity", async () => { + const { obsidian } = getContext(); + const path = await setUp({ template: "{{time}} | a \\| b \\\\| c\n" }); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + await expectCell(path, "anchor00:00:30 \\| a \\| b \\\\\\| c "); + expect(await cellText(path)).toBe("anchor00:00:30 | a | b \\\\| c "); + }); + + test("#445 leaving a cell preserves paragraph pipes, newlines and Undo", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + await clickInEditor(obsidian, path, 6, 11); + const before = await obsidian.dev.evalJson( + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.getValue()`, + ); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + const expected = before.replace("After table", "After table00:00:30 | cut\n"); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), expected, 10_000), + ).toBe(expected); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + }); + + test.each(["split", "closed"])( + "#445 Undo restores the exact note with its captured tab %s", + async (state) => { + const { obsidian } = getContext(); + const path = await setUp(); + const before = await obsidian.dev.evalJson( + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.getValue()`, + ); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + await expectCell(path, "anchor00:00:30 \\| cut "); + await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}); + if (${jsLiteral(state)} === "closed") leaf.detach(); + else await app.workspace.getLeaf("split").openFile(leaf.view.file); + return true; + })()`); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + }, + ); + + test("#445 Undo restores both editors when the captured cell is in the second pane", async () => { + const { obsidian } = getContext(); + const path = await setUp({ content: WIDE_TABLE }); + await clickInEditor(obsidian, path, 7, 11); + await obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getAbstractFileByPath(${jsLiteral(path)}); + await app.workspace.getLeaf("split").openFile(file); + return true; + })()`); + const views = `app.workspace.getLeavesOfType("markdown").filter((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).map((leaf) => leaf.view)`; + await click(obsidian, { + name: "second pane's Notes cell", + elements: `[...(${views}).at(-1).containerEl.querySelectorAll(".markdown-source-view td")].filter((el) => el.textContent === "anchor")`, + }); + expect( + await obsidian.dev.evalJson( + `${views}.map((view) => Boolean(view.editor.editorComponent.tableCell))`, + ), + ).toEqual([false, true]); + const before = await obsidian.dev.evalJson(`(${views})[0].editor.getValue()`); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + expect( + await eventually( + obsidian, + async () => + rows(await readVaultText(obsidian, path))[2][1].includes("00:00:30 \\| cut"), + true, + 10_000, + ), + ).toBe(true); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + expect( + await obsidian.dev.evalJson(`${views}.map((view) => view.editor.getValue())`), + ).toEqual([before, before]); + }); + 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 new file mode 100644 index 00000000..418770cf --- /dev/null +++ b/tests/mocks/captureEditor.ts @@ -0,0 +1,78 @@ +import type { EditorPosition } from "obsidian"; + +/** Enough of CodeMirror for a capture and its Undo: text, a selection, offsets and ranges. */ +export function fakeEditor(text: string, caret = text.length) { + let value = text; + let from = caret; + let to = caret; + const lines = () => value.split("\n"); + const pos = (offset: number) => { + if (offset < 0 || offset > value.length) + throw new RangeError(`Offset ${offset} is outside`); + const before = value.slice(0, offset).split("\n"); + return { line: before.length - 1, ch: before[before.length - 1].length }; + }; + const offset = ({ line, ch }: EditorPosition) => + lines() + .slice(0, line) + .reduce((sum, text) => sum + text.length + 1, ch); + return { + getValue: () => value, + getLine: (line: number) => lines()[line] ?? "", + lineCount: () => lines().length, + getCursor: (side?: "from" | "to") => pos(side === "from" ? from : to), + getSelection: () => value.slice(from, to), + setSelection(anchor: EditorPosition, head = anchor) { + from = offset(anchor); + to = offset(head); + }, + replaceSelection: (replacement: string) => { + value = value.slice(0, from) + replacement + value.slice(to); + from = to = from + replacement.length; + }, + posToOffset: offset, + offsetToPos: pos, + getRange: (start: EditorPosition, end: EditorPosition) => + value.slice(offset(start), offset(end)), + replaceRange(replacement: string, start: EditorPosition, end = start) { + value = value.slice(0, offset(start)) + replacement + value.slice(offset(end)); + }, + }; +} + +/** + * 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. + */ +export function editInTableCell(note: ReturnType, cell: string) { + const start = note.getValue().indexOf(cell); + note.setSelection(note.offsetToPos(start + cell.length)); + const escape = (text: string) => + text.replace(/(\\*)\|/g, (pipe, slashes: string) => + slashes.length % 2 ? pipe : `${slashes}\\|`, + ); + const write = note.replaceRange; + const cellEditor = { + text: cell, + getValue() { + return this.text; + }, + replaceSelection(text: string) { + const end = note.offsetToPos(start + escape(this.text).length); + this.text += text; + write(escape(this.text), note.offsetToPos(start), end); + }, + }; + const inCell = (from: EditorPosition, to: EditorPosition) => + note.posToOffset(from) >= start && + note.posToOffset(to) <= start + escape(cellEditor.text).length; + Object.assign(note, { + editorComponent: { tableCell: { editor: cellEditor } }, + replaceSelection: () => {}, + replaceRange(text: string, from: EditorPosition, to = from) { + if (!inCell(from, to)) write(text, from, to); + }, + }); + return cellEditor; +}