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
25 changes: 25 additions & 0 deletions src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,31 @@ describe("capture", () => {
expect(said()).toEqual(["Timestamp added to Saved Episode", REFUSED]);
});

it("leaves the timestamp once another open editor of the note holds other text, and says so", async () => {
const { editor, openNote, read } = await intoEditor("Notes\n");
const other = openNote("Journal.md", "Notes\nUnsaved in the other pane");

await undo();

expect(editor.getValue()).toBe("Notes\n- 00:20:34");
expect(other.editor.getValue()).toBe("Notes\nUnsaved in the other pane");
expect(read("Journal.md")).toBe("Notes\n");
expect(said()).toEqual(["Timestamp added to Journal", REFUSED]);
});

it("takes the timestamp out when every open editor of the note holds it as captured", async () => {
const { editor, openNote } = await intoEditor("Notes\n");
const other = openNote("Journal.md", editor.getValue());

await undo();

expect([editor.getValue(), other.editor.getValue()]).toEqual([
"Notes\n",
"Notes\n- 00:20:34",
]);
expect(said()).toEqual(["Timestamp added to Journal"]);
});

it("takes the timestamp out through the episode note's open editor", async () => {
const { plugin, notes, openNote, read } = await loadPlugin(playing);
notes.set(NOTE_PATH, { file: vaultFile(NOTE_PATH), content: "# Mine\n" });
Expand Down
17 changes: 12 additions & 5 deletions src/services/CaptureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,18 @@ export class CaptureService {
// An open editor's buffer is the note: a vault write behind it could lose what it
// has not saved yet. Obsidian saves a note as it leaves editing, so once that save
// lands, a closed tab or one in Reading view leaves the file as the note.
const editing = this.plugin.app.workspace
.getLeavesOfType("markdown")
.map((leaf) => this.editingView(leaf))
.find((view) => view?.file === file);
if (editing) return revertIn(editing.editor, snapshot);
// Every open editor of the note has to hold the text as the capture left it.
const editing = this.plugin.app.workspace.getLeavesOfType("markdown").flatMap((leaf) => {
const view = this.editingView(leaf);
return view?.file === file ? [view] : [];
});
const [first] = editing;
if (first) {
return (
editing.every((view) => view.editor.getValue() === snapshot.after) &&
revertIn(first.editor, snapshot)
);
}
try {
await this.plugin.app.vault.process(file, (content) => {
if (content !== snapshot.after) throw new Error("The note changed");
Expand Down
61 changes: 15 additions & 46 deletions src/ui/PodcastView/Player.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script lang="ts">
import { Scope } from "obsidian";
import { tick } from "svelte";
import type { Chapter } from "src/types/Chapter";
import type { Episode } from "src/types/Episode";
Expand All @@ -22,7 +21,7 @@

let { viewRoot, layout }: { viewRoot: HTMLElement; layout: Layout } = $props();
const services = useServices();
const { app, api, player, navigation, settings, library, rowHasNote, captureTimestamp } =
const { api, player, navigation, settings, library, rowHasNote, captureTimestamp, scope } =
services;
const playerState = player.state;
const { savedFeeds } = library;
Expand Down Expand Up @@ -90,65 +89,35 @@
if (failed) volumeOpen = false;
});

// Obsidian's keymap gives a key to the newest scope, so a menu or modal opened
// over the player takes Escape first. It keeps one stack per window and pushes
// onto the active window's, so the scope goes onto the stack of the view's
// window once that window is active, and moves when the view moves to another
// window. It is pushed once: pushing it again on a later focus would lift it over
// a modal opened since. Keys typed elsewhere, as in an editor, stay there.
// The view's own scope gets the keys typed while the view's leaf is active, which focus
// or a press in the view makes it. A menu or modal over the player takes Escape first.
$effect(() => {
const scope = new Scope(app.scope);
const ours = (evt: KeyboardEvent) => {
const target = evt.target as Element | null;
return target === viewRoot.ownerDocument.body || viewRoot.contains(target);
};
scope.register(null, "Escape", (evt) => {
if (!ours(evt)) return true;
const escape = scope.register(null, "Escape", () => {
if (volumeOpen) {
volumeOpen = false;
volumeButton?.focus();
} else void collapse();
return false;
});
scope.register(null, " ", (evt) => {
const target = evt.target as Element | null;
const space = scope.register(null, " ", (evt) => {
const control = "input, textarea, select, button, a, [contenteditable], [role=slider]";
if (!ours(evt) || target?.closest?.(control)) return true;
if ((evt.target as Element | null)?.closest?.(control)) return true;
player.toggle();
return false;
});
let win = viewRoot.win;
let pushed = false;
// Obsidian's own focus listener runs first and makes the window active.
const push = () => {
if (pushed || activeWindow !== win) return;
app.keymap.pushScope(scope);
pushed = true;
};
const pop = () => {
app.keymap.popScope(scope);
pushed = false;
};
const follow = (next: Window) => {
win = next;
// The video plays in the main window, so a popout shows the artwork.
inMainWindow = next === window;
win.addEventListener("focus", push);
push();
};
follow(win);
const stopFollowing = viewRoot.onWindowMigrated((next) => {
win.removeEventListener("focus", push);
pop();
follow(next);
});
return () => {
stopFollowing();
win.removeEventListener("focus", push);
pop();
scope.unregister(escape);
scope.unregister(space);
};
});

$effect(() => {
// The video plays in the main window, so a popout shows the artwork.
const follow = (win: Window) => (inMainWindow = win === window);
follow(viewRoot.win);
return viewRoot.onWindowMigrated(follow);
});

/** A keyboard user who collapses the player lands on the mini player that opens it again. */
async function collapse() {
const hadFocus = !!section?.contains(viewRoot.ownerDocument.activeElement);
Expand Down
1 change: 1 addition & 0 deletions src/ui/PodcastView/PodcastView.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function renderView() {
player,
feeds,
navigation,
scope: new obsidian.Scope(app.scope),
library: pluginLibrary,
actions: createLibraryActions({
app,
Expand Down
87 changes: 52 additions & 35 deletions src/ui/PodcastView/PodcastView.shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type FakeResizeObservers,
} from "../../../tests/mocks/resizeObserver";
import {
activateLeaf,
createTestServices,
pluginLibrary,
type TestLibrary,
Expand Down Expand Up @@ -667,64 +668,80 @@ describe("full player", () => {
expect(await escape()).toBe(true);
});

test("leaves a modal above the player when its window regains focus", async () => {
renderView();
playing();
await tick();
await fireEvent.click(button(`Open player: ${episode.title}`));
const modal = new obsidian.Scope(services.app.scope);
modal.register(null, "Escape", () => false);
services.app.keymap.pushScope(modal);

window.dispatchEvent(new Event("focus"));
await escape();

expect(services.navigation.current.expanded).toBe(true);
services.app.keymap.popScope(modal);
});
/** A note's tab whose own keys, as its find bar's, record what reaches them. */
function noteLeaf() {
const scope = new obsidian.Scope(services.app.scope);
const keys: string[] = [];
for (const key of ["Enter", "Escape"]) {
scope.register(null, key, () => {
keys.push(key);
return false;
});
}
return { scope, keys };
}

test("typed outside the view stays there", async () => {
test("typed in a note reach the note, and the player stays open (#467)", async () => {
const note = document.body.appendChild(document.createElement("textarea"));
renderView();
playing();
await tick();
await fireEvent.click(button(`Open player: ${episode.title}`));
const tab = noteLeaf();
activateLeaf(services.app, tab.scope);
note.focus();

services.navigation.expand();
await tick();
expect(document.activeElement).toBe(note);
await fireEvent.keyDown(note, { key: "Enter" });
await escape(note);

expect(await escape()).toBe(true);
expect(tab.keys).toEqual(["Enter", "Escape"]);
expect(services.navigation.current.expanded).toBe(true);
note.remove();
});

test("from outside the view passes through, even with focus left in the player", async () => {
const note = document.body.appendChild(document.createElement("textarea"));
renderView();
playing();
await tick();
await fireEvent.click(button(`Open player: ${episode.title}`));
expect(document.activeElement).toBe(button("Collapse player"));

expect(await escape(note)).toBe(true);
expect(services.navigation.current.expanded).toBe(true);
note.remove();
});
test.each([
["a note's editor", "textarea", true],
["an empty tab", "div", false],
] as const)(
"typed in %s, which has no use for Escape or Space, leave the player open and playing (#467)",
async (_, tag, viewHasScope) => {
const at = document.body.appendChild(document.createElement(tag));
renderView();
playing();
await tick();
await fireEvent.click(button(`Open player: ${episode.title}`));
const pause = vi.spyOn(doc.media, "pause");
activateLeaf(
services.app,
viewHasScope ? new obsidian.Scope(services.app.scope) : null,
);

await fireEvent.keyDown(at, { key: " " });
expect(get(services.player.state).status).toBe("playing");
expect(pause).not.toHaveBeenCalled();

await escape(at);
expect(services.navigation.current.expanded).toBe(true);
at.remove();
},
);

test("follows the player to a popout window, and Escape in the main window passes it by", async () => {
test("reach the player in a popout through that window's base scope, and a note's Escape passes it by", async () => {
const popout = openPopout();
popout.win.document.addEventListener("keydown", dispatch);
const note = document.body.appendChild(document.createElement("textarea"));
const { container } = renderView();
playing();
await tick();
await fireEvent.click(button(`Open player: ${episode.title}`));

popout.moveIn(container);
expect(await escape(note)).toBe(true);
const tab = noteLeaf();
activateLeaf(services.app, tab.scope);
await escape(note);
expect(tab.keys).toEqual(["Escape"]);
expect(services.navigation.current.expanded).toBe(true);

activateLeaf(services.app, services.scope);
popout.focus();
const collapseButton = within(container).getByRole("button", {
name: "Collapse player",
Expand Down
2 changes: 1 addition & 1 deletion src/ui/PodcastView/QueueList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
if (cancelled.pointerId === pointerId) end();
};
// Obsidian's keymap gives Escape to the newest scope and stops it there, so a
// window listener would not hear an Escape the full player's scope takes.
// window listener would not hear an Escape the view's scope takes.
const escape = new Scope(app.scope);
escape.register(null, "Escape", () => {
end();
Expand Down
4 changes: 3 additions & 1 deletion src/ui/PodcastView/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { App } from "obsidian";
import type { App, Scope } from "obsidian";
import { createContext } from "svelte";
import type { Readable, Writable } from "svelte/store";
import type { IAPI } from "src/API/IAPI";
Expand Down Expand Up @@ -35,6 +35,8 @@ export interface ShellServices {
readonly player: PlayerController;
readonly feeds: FeedRepository;
readonly navigation: Navigation;
/** The view's own keys. Obsidian hands them every key typed while the view's leaf is active. */
readonly scope: Scope;
readonly library: Library;
readonly actions: LibraryActions;
readonly search: PodcastSearch;
Expand Down
9 changes: 9 additions & 0 deletions src/ui/PodcastView/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ describe("MainView mobile startup mounting", () => {
});
});

it("gives its components the view's own key scope, the one Obsidian hands keys to", async () => {
const { view } = createView({ shouldMount: true });

await openView(view);

expect(view.scope).not.toBeNull();
expect(mountedServices().scope).toBe(view.scope);
});

it("mountPodcastView wakes a dormant restored view exactly once", () => {
const { contentEl, plugin, view } = createView({ shouldMount: false });

Expand Down
5 changes: 4 additions & 1 deletion src/ui/PodcastView/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { debounce, ItemView } from "obsidian";
import { debounce, ItemView, Scope } from "obsidian";
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import type { IPodNotes } from "../../types/IPodNotes";
import { VIEW_TYPE } from "../../constants";
Expand Down Expand Up @@ -110,12 +110,15 @@ export class MainView extends ItemView {
notesChangedSoon();
}),
);
const scope = new Scope(app.scope);
this.scope = scope;
return {
app,
api: plugin.api,
player: plugin.player,
feeds: plugin.feeds,
navigation: plugin.navigation,
scope,
library: {
savedFeeds,
queue,
Expand Down
Loading
Loading