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
44 changes: 42 additions & 2 deletions src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand All @@ -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();

Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down
17 changes: 17 additions & 0 deletions src/services/CaptureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
115 changes: 115 additions & 0 deletions tests/e2e/table-cell-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(`${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<string>(`${view(path)}.editor.getValue()`);
await obsidian.exec("command", { id: "podnotes:capture-timestamp" });
const captured = await obsidian.dev.evalJson<string>(`${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<string>(`${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 });
Expand Down
19 changes: 14 additions & 5 deletions tests/mocks/captureEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fakeEditor>, cell: string) {
const start = note.getValue().indexOf(cell);
Expand All @@ -52,7 +53,7 @@ export function editInTableCell(note: ReturnType<typeof fakeEditor>, 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() {
Expand All @@ -67,12 +68,20 @@ export function editInTableCell(note: ReturnType<typeof fakeEditor>, 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;
}
Loading