Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 72 additions & 41 deletions src/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof import("./utility/networkRequest")>()),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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", "");
Expand Down
38 changes: 32 additions & 6 deletions src/services/CaptureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand All @@ -224,10 +226,34 @@ 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),
editor.offsetToPos(after.length - tail),
);
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;
}
14 changes: 1 addition & 13 deletions src/utility/prepareTimestampInsertion.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading