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
13 changes: 13 additions & 0 deletions src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,19 @@ describe("capture", () => {
await undo();

expect(editor.getValue()).toBe("Heard: draft line");
expect(editor.getSelection()).toBe("draft line");
});

it("takes the timestamp out of the note's other tab once the tab it went into closed, leaving that tab's caret", async () => {
const { leaf, editor, close, openNote } = await intoEditor("Notes\nLater", 5);
const other = openNote("Journal.md", editor.getValue(), 0);
close(leaf);

await undo();

expect(other.editor.getValue()).toBe("Notes\nLater");
expect(other.editor.getCursor()).toEqual({ line: 0, ch: 0 });
expect(said()).toEqual(["Timestamp added to Journal"]);
});

it.each([
Expand Down
1 change: 1 addition & 0 deletions src/main.activateView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,7 @@ describe("PodNotes onload wiring (#55)", () => {
getLine: () => "",
lineCount: () => 1,
getValue: () => "",
listSelections: () => [],
replaceSelection: vi.fn(),
};
Object.assign(plugin.app.workspace, {
Expand Down
17 changes: 10 additions & 7 deletions src/services/CaptureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
MarkdownView,
Notice,
type Editor,
type EditorSelection,
type MarkdownFileInfo,
type TFile,
type WorkspaceLeaf,
Expand All @@ -23,10 +24,11 @@ export interface Capture {
created: boolean;
}

/** A note's text as a capture found it and as it left it. */
/** A note's text as a capture found it and as it left it, and the caret it found in its editor. */
interface Snapshot {
readonly before: string;
readonly after: string;
readonly caret?: { readonly editor: Editor; readonly selections: EditorSelection[] };
}

/** Puts a capture where the user writes: the editor they used last, else the episode note. */
Expand Down Expand Up @@ -101,11 +103,12 @@ export class CaptureService {
cursorLine: editor.getCursor("from").line,
});
const before = editor.getValue();
const caret = { editor, selections: editor.listSelections() };
endDetachedTableCell(editor);
const cell = tableCellWriter(editor);
if (cell) cell(inserted);
else editor.replaceSelection(inserted);
const snapshot = { before, after: editor.getValue() };
const snapshot = { before, after: editor.getValue(), caret };
// 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.
const undo =
Expand Down Expand Up @@ -212,11 +215,8 @@ export class CaptureService {
}
}

/**
* Puts back only the stretch the capture changed, so the caret and the editor's own
* undo history stay.
*/
function revertIn(editor: Editor, { before, after }: Snapshot): boolean {
/** Puts back only the stretch the capture changed, so the editor's own undo history stays. */
function revertIn(editor: Editor, { before, after, caret }: Snapshot): boolean {
if (editor.getValue() !== after) return false;
endDetachedTableCell(editor);
let from = 0;
Expand All @@ -239,6 +239,9 @@ function revertIn(editor: Editor, { before, after }: Snapshot): boolean {
editor.offsetToPos(from),
editor.offsetToPos(after.length - tail),
);
// When Obsidian padded the table around the cell, the stretch holds the caret, and the
// edit moves it to the stretch's start, before the table.
if (caret?.editor === editor) editor.setSelections(caret.selections);
return true;
}

Expand Down
34 changes: 33 additions & 1 deletion src/ui/undoNotice.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 { afterEach, expect, test, vi } from "vitest";
import { afterEach, expect, onTestFinished, test, vi } from "vitest";
import { dismissUndoNotices, noticeWithUndo } from "./undoNotice";

afterEach(() => {
Expand Down Expand Up @@ -31,6 +31,38 @@ test("says what happened, with an Undo button, for seven seconds", () => {
expect(read().button).toHaveTextContent("Undo");
});

test("builds its message in the main window, as Obsidian's Notice needs also with a popout focused", () => {
const frame = document.body.appendChild(document.createElement("iframe"));
const popout = frame.contentWindow as Window & typeof globalThis;
// Obsidian gives a popout's nodes its DOM helpers too.
const { createEl, createSpan } = DocumentFragment.prototype;
Object.assign(popout.DocumentFragment.prototype, { createEl, createSpan });
vi.stubGlobal("activeDocument", popout.document);
onTestFinished(() => {
vi.unstubAllGlobals();
frame.remove();
});
const notice = vi.spyOn(obsidian, "Notice");

noticeWithUndo("Timestamp added to Draft", () => {});

// Obsidian shows any other message as its String().
expect(notice.mock.calls[0][0]).toBeInstanceOf(DocumentFragment);
});

test("stops checking on a notice left in the document of a popout that closed", () => {
vi.useFakeTimers();
const notice = vi.spyOn(obsidian, "Notice");
noticeWithUndo("Timestamp added to Draft", () => {});
// A closed popout's document has no window, and still holds the notice.
const closed = document.implementation.createHTMLDocument();
closed.body.append(notice.mock.calls[0][0] as DocumentFragment);

vi.advanceTimersByTime(9000);

expect(vi.getTimerCount()).toBe(0);
});

test("Undo runs once and hides the notice", () => {
const read = shown();
const undo = vi.fn();
Expand Down
8 changes: 5 additions & 3 deletions src/ui/undoNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const showing = new Set<() => void>();

/** A Notice that ends in an Undo button. Undo runs at most once, and hides the notice. */
export function noticeWithUndo(message: string, undo: () => void): void {
const fragment = activeDocument.createDocumentFragment();
// Obsidian's Notice takes only a main window fragment, also where it shows in a popout.
const fragment = createFragment();
fragment.createSpan({ cls: "pn-undo-message", text: message });
const button = fragment.createEl("button", { cls: "pn-undo", text: "Undo" });
const notice = new Notice(fragment, 7000);
Expand All @@ -20,9 +21,10 @@ export function noticeWithUndo(message: string, undo: () => void): void {
};
button.addEventListener("click", run);
showing.add(dismiss);
// Obsidian keeps a notice up past its time while the pointer rests on it.
// Obsidian keeps a notice up past its time while the pointer rests on it, and a popout
// that closes keeps it in its document.
const forget = () => {
if (button.isConnected) window.setTimeout(forget, 1000);
if (button.isConnected && button.doc.defaultView) window.setTimeout(forget, 1000);
else showing.delete(dismiss);
};
window.setTimeout(forget, 7000);
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/journey-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, onTestFinished, test } from "vitest";
import { createPodNotesE2EHarness } from "./harness";
import {
clickInEditor,
clickLastInNoteWindow,
closePopoutTab,
focusNoteWindow,
noticesInNoteWindow,
openInEditor,
openInReadingView,
reloadAfterPopout,
Expand Down Expand Up @@ -996,4 +999,46 @@ describe("episode note and capture journeys", () => {
await reloadAfterPopout(obsidian);
}
});

test("J-TS-21 Capture Timestamp with a popout focused says so there, and its Undo takes the capture back", async () => {
const { obsidian, sandbox } = getContext();
const journey = await setUp("Journey Popout Focused");
const draft = sandbox.path("Focused draft.md");
await writeVaultFile(obsidian, draft, "Popout notes\n");
await detachMarkdownTabs();
await playFromList(journey.show, ORIGINAL.title);
const paused = await pauseInView();
try {
await obsidian.dev.evalJsonAsync(`(async () => {
const leaf = app.workspace.openPopoutLeaf();
await leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(draft)}), { state: { mode: "source" } });
return true;
})()`);
expect(await clickInEditor(obsidian, draft, 0, 12)).toEqual({ line: 0, ch: 12 });
await focusNoteWindow(obsidian, draft);

await obsidian.exec("command", { id: "podnotes:capture-timestamp" });

const captured = `Popout notes${capture(journey, ORIGINAL, paused.seconds)}\n`;
await waitForText(
draft,
(text) => text === captured,
"the capture in the popout draft",
);
expect(await noticesInNoteWindow(obsidian, draft)).toEqual([
{ message: "Timestamp added to Focused draft", button: "Undo" },
]);
await clickLastInNoteWindow(obsidian, draft, ".notice button.pn-undo");
await waitForText(draft, (text) => text === "Popout notes\n", "the draft as it was");
} finally {
if (await obsidian.dev.evalJson<number>("app.workspace.floatingSplit.children.length"))
await closePopoutTab(obsidian, draft);
await obsidian.dev.evalJsonAsync(`(async () => {
app.workspace.requestSaveLayout.cancel();
await app.workspace.saveLayout();
return true;
})()`);
await reloadAfterPopout(obsidian);
}
});
});
44 changes: 44 additions & 0 deletions tests/e2e/journey/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,50 @@ export async function clickInEditor(
})()`);
}

/** Gives the window holding the open editor of `notePath` the OS focus, which input sent to it does not. */
export async function focusNoteWindow(obsidian: ObsidianClient, notePath: string): Promise<void> {
const focused = await obsidian.dev.evalJsonAsync<boolean>(`(async () => {
${inNoteWindow(notePath)}
win.focus();
for (let i = 0; i < 50 && activeDocument !== leaf.view.containerEl.ownerDocument; i++)
await new Promise((resolve) => setTimeout(resolve, 100));
return activeDocument === leaf.view.containerEl.ownerDocument;
})()`);
if (!focused) throw new Error(`The window showing ${notePath} did not take focus.`);
}

export async function clickLastInNoteWindow(
obsidian: ObsidianClient,
notePath: string,
selector: string,
text?: string,
): Promise<void> {
const clicked = await obsidian.dev.evalJson<boolean>(`(() => {
${inNoteWindow(notePath)}
const target = [...leaf.view.containerEl.ownerDocument.querySelectorAll(${jsLiteral(selector)})]
.filter((each) => ${jsLiteral(text ?? null)} === null || each.textContent === ${jsLiteral(text ?? null)})
.at(-1);
if (!target) return false;
const box = target.getBoundingClientRect();
press(box.left + box.width / 2, box.top + box.height / 2);
return true;
})()`);
if (!clicked) throw new Error(`Nothing matches ${selector} in the window of ${notePath}.`);
}

export async function noticesInNoteWindow(
obsidian: ObsidianClient,
notePath: string,
): Promise<Array<{ message: string; button: string | null }>> {
return await obsidian.dev.evalJson(`(() => {
${inNoteWindow(notePath)}
return [...leaf.view.containerEl.ownerDocument.querySelectorAll(".notice")].map((notice) => ({
message: (notice.querySelector(".pn-undo-message") ?? notice).textContent.trim(),
button: notice.querySelector("button.pn-undo")?.textContent ?? null,
}));
})()`);
}

/** Clicks the close button of the popout tab showing `notePath`, which closes its window. */
export async function closePopoutTab(obsidian: ObsidianClient, notePath: string): Promise<void> {
const closed = await obsidian.dev.evalJsonAsync<boolean>(`(async () => {
Expand Down
Loading
Loading