feat(web-clipper): make the note about a page the card's third mode, "Bookmark"

"Link with a note" had its own view, reached from the toolbar and left
by a back arrow, that repeated the page card's title row above a text
box. The card already switched between what it saves, so the note is
now a third mode of it, which the UI calls a bookmark: Evernote's
clipper names the same thing so, and "Note" would not tell it apart,
since everything the clipper saves becomes a note. In the code the
mode is `note`, after the `clipType: "note"` the background posts.

- The switch offers only what the page has: Selection when text is
  selected, Page when there is an article, Bookmark always. The card
  opens on the selection, else the article.
- In Bookmark, a text box ("Add a note about this page") takes the
  preview's place and gets the focus, the website line shows the tab's
  address, and the button becomes "Save bookmark" with Ctrl+Enter,
  which saves from the title or the text and only in this mode. An
  empty title still falls back to the page's title, and the popup stays
  open when the save fails. The title is one field for all modes, so
  with an article the bookmark starts with the article's title rather
  than the tab's; the note's text survives a switch.
- A page with no article and no selection opens on Bookmark, with "This
  page has no article; save it as a bookmark.", in place of the "no
  article" empty state. Pages out of reach keep their empty state and
  get no bookmark.
- The toolbar drops Note and keeps Crop, Screenshot and Tabs, the
  actions that do not depend on the card, in three columns.

LinkWithNoteForm, its back arrow and isWritingNote are gone;
readActiveTab() also returns the tab's title and address. The
`save-link-with-note` message is unchanged, so older Trilium versions
keep working.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-10-11 00:15:58 +03:00
co-authored by Claude Opus 5.5
parent 8814767e7f
commit d7c473e903
5 changed files with 264 additions and 255 deletions
@@ -30,43 +30,56 @@
<h2>The popup</h2>
<p>Clicking the extension's button in the browser toolbar opens its popup:</p>
<ul>
<li>The popup reads the current page as an article and shows a preview of
what will be saved: the note's title, which can be edited before saving,
the website and the publication date (when the page states one), and the
article's text and images. <em>Save page to Trilium</em> saves it. When the
page has no article (Readability cannot find one), the popup says so in
place of the preview, and the other actions still work. Pages the extension
cannot access (the browser's own pages, the extension stores, and pages
that were open before the extension was installed until they are reloaded)
cannot be saved or captured, so only <em>Note</em> and <em>Tabs</em> remain
available there. The keyboard shortcut and the context menu still save
the page right away, without a preview.</li>
<li>If text is selected on the page, the preview shows the selection instead,
and <em>Save selection</em> saves it into the note of that page's
clippings, as the context menu does. When the page also has an article,
a switch above the preview chooses between <em>Selection</em> and <em>Whole page</em>.
A selection can be saved even on a page that has no article.</li>
<li>Below the preview, a row of buttons offers the other actions: <em>Crop</em> (a
screenshot of an area you select), <em>Screenshot</em> (of the visible part
of the page), <em>Note</em> (a link with a note) and <em>Tabs</em> (saves the
links of every tab in the current window as a list). Hovering over a button
shows its full name and, if it has one, its keyboard shortcut.</li>
<li><em>Note</em> opens a small editor in place of the actions, to save a link
to the page with a note about it. The note's title starts as the page's
title and can be edited (left empty, it stays the page's title); the text
below it becomes the note's content. Press <em>Save</em> or <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to
save it, or the back arrow to return to the actions.</li>
<li>If the current page was already clipped, a notice at the top offers to
open the note in Trilium.</li>
<li>If Trilium cannot be found, the popup explains why instead of showing
the actions, with buttons to look for Trilium again and to open the options.</li>
<li>The bottom of the popup shows whether the extension is connected to Trilium,
as a colored dot (green when connected, amber when the versions are not
compatible, red when Trilium was not found) next to a description of the
connection, followed by the buttons for the extension's options and for
this help page. A button to check the connection again appears next to
them when hovering over the bar, and stays visible while Trilium cannot
be reached or is not compatible.</li>
<li>
<p>The popup shows what will be saved from the current page: the note's title,
which can be edited before saving, the website, and below them a preview.
A switch above the preview chooses what to save, among what the page offers:</p>
<ul>
<li><em>Selection</em>, when text is selected on the page: <em>Save selection</em> saves
it into the note of that page's clippings, as the context menu does.</li>
<li><em>Page</em>, when the page has an article: <em>Save page to Trilium</em> saves
the article's text and images, and the publication date shows next to the
website when the page states one.</li>
<li><em>Bookmark</em>, always: a text box for a note about the page replaces
the preview, and <em>Save bookmark</em> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>)
saves a link to the page with that note. Left empty, the title stays the
page's title.</li>
</ul>
<p>The popup opens on the selection if there is one, otherwise on the article.
On a page with no article (Readability cannot find one) and no selection,
it opens on <em>Bookmark</em> and says the page has no article. The keyboard
shortcuts and the context menu still save right away, without a preview.</p>
</li>
<li>
<p>Pages the extension cannot access (the browser's own pages, the extension
stores, and pages that were open before the extension was installed until
they are reloaded) cannot be saved or captured, so the popup says so in
place of the preview, and only <em>Tabs</em> remains available there.</p>
</li>
<li>
<p>Below the preview, a row of buttons offers the other actions: <em>Crop</em> (a
screenshot of an area you select), <em>Screenshot</em> (of the visible part
of the page) and <em>Tabs</em> (saves the links of every tab in the current
window as a list). Hovering over a button shows its full name and, if it
has one, its keyboard shortcut.</p>
</li>
<li>
<p>If the current page was already clipped, a notice at the top offers to
open the note in Trilium.</p>
</li>
<li>
<p>If Trilium cannot be found, the popup explains why instead of showing
the actions, with buttons to look for Trilium again and to open the options.</p>
</li>
<li>
<p>The bottom of the popup shows whether the extension is connected to Trilium,
as a colored dot (green when connected, amber when the versions are not
compatible, red when Trilium was not found) next to a description of the
connection, followed by the buttons for the extension's options and for
this help page. A button to check the connection again appears next to
them when hovering over the bar, and stays visible while Trilium cannot
be reached or is not compatible.</p>
</li>
</ul>
<h2>Location of clippings</h2>
<p>Trilium will save these clippings as a new child note under a "clipper
+115 -136
View File
@@ -2,15 +2,13 @@ import "@/assets/theme.css";
import "./popup.css";
import type { IconData, IconDefinition } from "@boxicons/js";
import ArrowLeft from "@boxicons/js/icons/ArrowLeft";
import Article from "@boxicons/js/icons/Article";
import Bookmark from "@boxicons/js/icons/Bookmark";
import Cog from "@boxicons/js/icons/Cog";
import Crop from "@boxicons/js/icons/Crop";
import FileX from "@boxicons/js/icons/FileX";
import Globe from "@boxicons/js/icons/Globe";
import HelpCircle from "@boxicons/js/icons/HelpCircle";
import Highlight from "@boxicons/js/icons/Highlight";
import Link from "@boxicons/js/icons/Link";
import Lock from "@boxicons/js/icons/Lock";
import RefreshCw from "@boxicons/js/icons/RefreshCw";
import Screenshot from "@boxicons/js/icons/Screenshot";
@@ -59,8 +57,17 @@ interface TabContent {
page: PageState;
/** `null` when nothing is selected, or the page is out of reach. */
selection: SelectedContent | null;
/** The tab's own title and address, which a note clip saves. */
title?: string;
url?: string;
}
/**
* What the card saves: the selection, the readable page (`clipType: "page"`), or a note about the
* page with a link to it (`clipType: "note"`), which the popup calls a bookmark.
*/
type ClipMode = "selection" | "page" | "note";
const PREVIEW_STYLE = `
:root { color-scheme: light dark; }
body { margin: 8px; font: 12px/1.5 system-ui, sans-serif; overflow-wrap: anywhere; }
@@ -70,6 +77,15 @@ const PREVIEW_STYLE = `
a { color: light-dark(#0076af, #95c3d9); text-decoration: none; pointer-events: none; }
`;
const MODE_LABELS: Record<ClipMode, string> = { selection: "Selection", page: "Page", note: "Bookmark" };
/** The save button of each mode, with the command whose shortcut it shows, or its own shortcut. */
const SAVE_BUTTONS: Record<ClipMode, { icon: IconDefinition, label: string, command: string, shortcut?: string }> = {
selection: { icon: Highlight, label: "Save selection", command: "saveSelection" },
page: { icon: Article, label: "Save page to Trilium", command: "saveWholePage" },
note: { icon: Bookmark, label: "Save bookmark", command: "", shortcut: "Ctrl+Enter" }
};
const root = document.getElementById("root");
if (root) {
render(<Popup />, root);
@@ -78,7 +94,6 @@ if (root) {
export function Popup() {
const [ searchStatus, setSearchStatus ] = useState<TriliumSearchStatus>();
const [ clippedNoteId, setClippedNoteId ] = useState<string | null>(null);
const [ isWritingNote, setIsWritingNote ] = useState(false);
const [ shortcuts, setShortcuts ] = useState<Shortcuts>({});
useEffect(() => {
@@ -106,17 +121,11 @@ export function Popup() {
const isMismatch = searchStatus?.status === "version-mismatch";
let body: ComponentChildren;
if (isWritingNote) {
body = <LinkWithNoteForm onBack={() => setIsWritingNote(false)} />;
} else if (searchStatus?.status === "not-found") {
if (searchStatus?.status === "not-found") {
body = <TriliumNotFound />;
} else {
body = (
<CaptureActions
disabled={!!searchStatus && !isConnected(searchStatus)}
shortcuts={shortcuts}
onWriteNote={() => setIsWritingNote(true)}
/>
<CaptureActions disabled={!!searchStatus && !isConnected(searchStatus)} shortcuts={shortcuts} />
);
}
@@ -174,11 +183,7 @@ export function Popup() {
);
}
function CaptureActions({ disabled, shortcuts, onWriteNote }: {
disabled: boolean;
shortcuts: Shortcuts;
onWriteNote: () => void;
}) {
function CaptureActions({ disabled, shortcuts }: { disabled: boolean, shortcuts: Shortcuts }) {
/** `undefined` while the page is read. */
const [ tab, setTab ] = useState<TabContent>();
@@ -201,17 +206,8 @@ function CaptureActions({ disabled, shortcuts, onWriteNote }: {
{"The extension cannot access this page.\nIf it is a regular web page, reload it."}
</EmptyState>
);
} else if (tab?.page === "unreadable" && !tab.selection) {
main = <EmptyState icon={FileX}>This page has no article to save.</EmptyState>;
} else {
main = (
<PagePreview
article={typeof tab?.page === "object" ? tab.page : undefined}
selection={tab?.selection ?? undefined}
disabled={disabled}
shortcuts={shortcuts}
/>
);
main = <PagePreview tab={tab} disabled={disabled} shortcuts={shortcuts} />;
}
return (
@@ -229,10 +225,6 @@ function CaptureActions({ disabled, shortcuts, onWriteNote }: {
disabledReason={pageDisabledReason}
onClick={() => sendAndClose("save-whole-screenshot")}
/>
<ToolbarAction
icon={Link} label="Note" name="Link with a note"
disabledReason={disabledReason} onClick={onWriteNote}
/>
<ToolbarAction
icon={Tabs} label="Tabs" name="All tabs in window"
shortcut={shortcuts.saveTabs} disabledReason={disabledReason}
@@ -279,38 +271,73 @@ function EmptyState({ icon, className, children }: {
/**
* What the popup saves from the page, with its title editable, and the button that saves it: the
* selection when there is one, otherwise the readable page, with a switch between the two when the
* page has both. The page's HTML comes from an arbitrary website, so it is shown only in a sandboxed
* selection, the readable page, or a note about the page, with a switch between those the
* page offers. The page's HTML comes from an arbitrary website, so it is shown only in a sandboxed
* frame.
*/
function PagePreview({ article, selection, disabled, shortcuts }: {
/** `undefined` while the page is read, or when it has no article. */
article: ExtractedPage | undefined;
selection: SelectedContent | undefined;
function PagePreview({ tab, disabled, shortcuts }: {
/** `undefined` while the page is read. */
tab: TabContent | undefined;
disabled: boolean;
shortcuts: Shortcuts;
}) {
const [ mode, setMode ] = useState<"selection" | "page">("selection");
const [ chosenMode, setChosenMode ] = useState<ClipMode>();
/** `undefined` until the user edits the title. */
const [ title, setTitle ] = useState<string>();
const [ text, setText ] = useState("");
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const clip = selection && (mode === "selection" || !article) ? selection : article;
const isSelection = !!clip && clip === selection;
const shownTitle = title ?? (article ?? selection)?.title ?? "";
const article = typeof tab?.page === "object" ? tab.page : undefined;
const selection = tab?.selection ?? undefined;
const modes = tab ? availableModes(article, selection) : [];
const mode = chosenMode && modes.includes(chosenMode) ? chosenMode : modes[0];
const clip = mode === "selection" ? selection : mode === "page" ? article : undefined;
const shownTitle = title ?? (article ?? selection)?.title ?? tab?.title ?? "";
function save(content: SelectedContent | ExtractedPage) {
useEffect(() => {
if (mode === "note") textAreaRef.current?.focus();
}, [ mode ]);
/** Keeps the popup open when the note could not be saved, so its text is not lost. */
async function saveNote() {
const content = text.trim();
const result = await sendMessage({
name: "save-link-with-note",
title: shownTitle.trim(),
content: content ? textToHtml(content) : ""
});
if (result) window.close();
}
function saveClip(content: SelectedContent) {
const titled = { ...content, title: shownTitle.trim() || content.title };
void sendMessage(isSelection
void sendMessage(mode === "selection"
? { name: "save-selection", selection: titled }
: { name: "save-whole-page", page: titled });
window.close();
}
const published = clip && !isSelection ? article?.labels.publishedDate : undefined;
function saveNoteOnCtrlEnter(e: KeyboardEvent) {
if (mode === "note" && e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
void saveNote();
}
}
let onSave: (() => void) | undefined;
if (mode === "note") {
onSave = () => void saveNote();
} else if (clip) {
onSave = () => saveClip(clip);
}
const pageUrl = mode === "note" ? tab?.url : clip?.pageUrl;
const published = mode === "page" ? article?.labels.publishedDate : undefined;
const button = SAVE_BUTTONS[mode ?? "page"];
return (
<div className="page-preview">
{clip && (
{mode && (
<div className="page-heading">
<span className="page-icon"><Icon icon={Globe} /></span>
<div className="page-heading-text">
@@ -321,41 +348,69 @@ function PagePreview({ article, selection, disabled, shortcuts }: {
placeholder="Note title"
value={shownTitle}
onInput={(e) => setTitle(e.currentTarget.value)}
onKeyDown={saveNoteOnCtrlEnter}
/>
<div className="page-meta">
{new URL(clip.pageUrl).hostname}{published && ` · Published ${published}`}
</div>
{pageUrl && (
<div className="page-meta">
{new URL(pageUrl).hostname}{published && ` · Published ${published}`}
</div>
)}
</div>
</div>
)}
{article && selection && (
{modes.length > 1 && (
<div className="clip-mode" role="group" aria-label="What to save">
<button aria-pressed={mode === "selection"} onClick={() => setMode("selection")}>Selection</button>
<button aria-pressed={mode === "page"} onClick={() => setMode("page")}>Whole page</button>
{modes.map((option) => (
<button key={option} aria-pressed={option === mode} onClick={() => setChosenMode(option)}>
{MODE_LABELS[option]}
</button>
))}
</div>
)}
{mode === "note" && modes.length === 1 && (
<div className="clip-hint">This page has no article; save it as a bookmark.</div>
)}
<div className="page-body">
{clip
? <iframe className="page-content" title="Preview of the page" sandbox="" srcDoc={previewDocument(clip)} />
: <div className="page-preview-placeholder">Reading the page…</div>}
{mode === "note" && (
<textarea
ref={textAreaRef}
className="note-text"
value={text}
placeholder="Add a note about this page"
onInput={(e) => setText(e.currentTarget.value)}
onKeyDown={saveNoteOnCtrlEnter}
/>
)}
{clip && <iframe className="page-content" title="Preview of the page" sandbox="" srcDoc={previewDocument(clip)} />}
{!mode && <div className="page-preview-placeholder">Reading the page…</div>}
</div>
<button
className="btn btn-primary primary-action"
disabled={disabled || !clip}
disabled={disabled || !mode}
title={disabled ? DISCONNECTED_TITLE : undefined}
onClick={clip ? () => save(clip) : undefined}
onClick={onSave}
>
<Icon icon={isSelection ? Highlight : Article} />
<span className="action-label">{isSelection ? "Save selection" : "Save page to Trilium"}</span>
<Shortcut keys={isSelection ? shortcuts.saveSelection : shortcuts.saveWholePage} />
<Icon icon={button.icon} />
<span className="action-label">{button.label}</span>
<Shortcut keys={button.shortcut ?? shortcuts[button.command]} />
</button>
</div>
);
}
/** The modes the page offers, the default first: the selection, then the article, then a note. */
function availableModes(article: ExtractedPage | undefined, selection: SelectedContent | undefined): ClipMode[] {
const modes: ClipMode[] = [];
if (selection) modes.push("selection");
if (article) modes.push("page");
modes.push("note");
return modes;
}
/**
* A shortcut as the app draws it: a key cap per key, joined by "+". The browser formats the shortcut;
* on macOS, Chrome gives the glyphs without a "+" (`⌥⇧S`), which stay in one key cap there too.
@@ -390,82 +445,6 @@ function TriliumNotFound() {
);
}
function LinkWithNoteForm({ onBack }: { onBack: () => void }) {
const [ title, setTitle ] = useState("");
const [ pageUrl, setPageUrl ] = useState<string>();
const [ text, setText ] = useState("");
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textAreaRef.current?.focus();
void browser.tabs.query({ active: true, currentWindow: true }).then(([ tab ]) => {
setTitle(tab?.title ?? "");
setPageUrl(tab?.url);
});
}, []);
async function save() {
const content = text.trim();
const result = await sendMessage({
name: "save-link-with-note",
title: title.trim(),
content: content ? textToHtml(content) : ""
});
if (result) {
window.close();
}
}
function saveOnCtrlEnter(e: KeyboardEvent) {
if (e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
void save();
}
}
return (
<div className="save-link-with-note">
<div className="view-header">
<button className="icon-action" title="Back" aria-label="Back" onClick={onBack}>
<Icon icon={ArrowLeft} />
</button>
<h4>Link with a note</h4>
</div>
<div className="page-heading">
<span className="page-icon"><Icon icon={Globe} /></span>
<div className="page-heading-text">
<input
type="text"
className="page-title"
aria-label="Note title"
placeholder="Note title"
value={title}
onInput={(e) => setTitle(e.currentTarget.value)}
onKeyDown={saveOnCtrlEnter}
/>
{pageUrl && <div className="page-meta">{new URL(pageUrl).hostname}</div>}
</div>
</div>
<textarea
ref={textAreaRef}
rows={5}
value={text}
placeholder="Your note about this page"
onInput={(e) => setText(e.currentTarget.value)}
onKeyDown={saveOnCtrlEnter}
/>
<button type="submit" className="btn btn-primary primary-action" onClick={save}>
<span className="action-label">Save</span>
<Shortcut keys="Ctrl+Enter" />
</button>
</div>
);
}
/** The state of the connection to Trilium: a kind for its color, and its description. */
function describeStatus(status: TriliumSearchStatus | undefined) {
switch (status?.status) {
@@ -489,7 +468,7 @@ async function readActiveTab(): Promise<TabContent> {
if (tab?.id === undefined) return { page: "inaccessible", selection: null };
const [ page, selection ] = await Promise.all([ extractPage(tab.id), readSelection(tab.id) ]);
return { page, selection };
return { page, selection, title: tab.title, url: tab.url };
}
/**
+22 -24
View File
@@ -153,6 +153,27 @@ body {
background: var(--main-background-color);
}
.note-text {
display: block;
width: 100%;
height: 160px;
padding: 8px;
border: 0;
border-radius: 6px;
resize: none;
&:focus {
outline: 0;
box-shadow: inset 0 0 0 2px var(--input-focus-outline-color);
}
}
.clip-hint {
padding-inline: 6px;
color: var(--muted-text-color);
font-size: 0.85em;
}
.page-preview-placeholder {
display: flex;
align-items: center;
@@ -211,7 +232,7 @@ body {
.toolbar {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
@@ -275,29 +296,6 @@ body {
}
}
.save-link-with-note {
display: flex;
flex-direction: column;
gap: 8px;
textarea {
width: 100%;
resize: vertical;
}
}
.view-header {
display: flex;
align-items: center;
gap: 4px;
--icon-button-size: 28px;
h4 {
margin: 0;
font-weight: 500;
}
}
.connection {
display: flex;
align-items: center;
@@ -68,7 +68,7 @@ describe("popup", () => {
it("asks for the connection status and shows it in the footer", async () => {
expect(sendMessage).toHaveBeenCalledWith({ name: "send-trilium-search-status" });
expect(status()).toEqual({ dot: "status-dot status-dot-pending", text: "Looking for Trilium…" });
expect(captureButtons()).toHaveLength(5);
expect(captureButtons()).toHaveLength(4);
expect(container.querySelector(".popup-header")).toBeNull();
expect([ ...container.querySelectorAll(".connection button") ].map((b) => b.getAttribute("aria-label")))
.toEqual([ "Check the connection again", "Options", "Help" ]);
@@ -185,7 +185,6 @@ describe("popup", () => {
expect(toolbar()).toEqual([
{ label: "Crop", title: "Crop screenshot (Ctrl+Shift+E)" },
{ label: "Screenshot", title: "Visible area screenshot" },
{ label: "Note", title: "Link with a note" },
{ label: "Tabs", title: "All tabs in window" }
]);
expect(container.querySelectorAll(".toolbar-action kbd")).toHaveLength(0);
@@ -199,7 +198,8 @@ describe("popup", () => {
expect(tabsQuery).toHaveBeenCalledWith({ active: true, currentWindow: true });
expect(tabsSendMessage).toHaveBeenCalledWith(7, { name: "trilium-save-page" });
expect(cardParts()).toEqual([ "page-heading", "page-body", "btn btn-primary primary-action" ]);
expect(cardParts()).toEqual([ "page-heading", "clip-mode", "page-body", "btn btn-primary primary-action" ]);
expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
const heading = container.querySelector(".page-heading");
expect(heading).not.toBeNull();
expect(heading?.querySelector(".page-icon svg path")).not.toBeNull();
@@ -243,11 +243,15 @@ describe("popup", () => {
expect(container.querySelector(".page-body iframe")).not.toBeNull();
});
it("keeps every other action on a page with no article", async () => {
it("opens a page with no article on a bookmark, and keeps every other action", async () => {
tabsSendMessage.mockResolvedValueOnce(undefined);
await rerender();
expect(unavailable()).toBe("This page has no article to save.");
expect(container.querySelector(".page-preview")).toBeNull();
expect(container.querySelector(".no-items")).toBeNull();
expect(container.querySelector(".clip-mode")).toBeNull();
expect(container.querySelector(".clip-hint")?.textContent).toBe("This page has no article; save it as a bookmark.");
expect(document.activeElement).toBe(container.querySelector(".page-body textarea"));
expect(container.querySelector(".page-meta")).toBeNull();
expect(button("Save bookmark")?.disabled).toBe(false);
expect(captureButtons().map((action) => action.disabled)).toEqual([ false, false, false, false ]);
});
@@ -260,10 +264,9 @@ describe("popup", () => {
"The extension cannot access this page.\nIf it is a regular web page, reload it."
);
expect(container.querySelector(".page-preview")).toBeNull();
expect(captureButtons().map((action) => action.disabled)).toEqual([ true, true, false, false ]);
expect(captureButtons().map((action) => action.disabled)).toEqual([ true, true, false ]);
expect(button("Crop screenshot")?.title).toBe(UNREACHABLE);
expect(button("Visible area screenshot")?.title).toBe(UNREACHABLE);
expect(button("Link with a note")?.title).toBe("Link with a note");
tabsQuery.mockResolvedValueOnce([ {} ]);
await rerender();
@@ -275,13 +278,13 @@ describe("popup", () => {
selection = SELECTION;
await rerender();
expect(tabsSendMessage).toHaveBeenCalledWith(7, { name: "trilium-save-selection" });
expect(modes()).toEqual([ [ "Selection", "true" ], [ "Whole page", "false" ] ]);
expect(modes()).toEqual([ [ "Selection", "true" ], [ "Page", "false" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(SELECTION));
expect(shortcutOf("Save selection").keys).toEqual([ "Ctrl", "Shift", "S" ]);
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com");
await click("Whole page");
expect(modes()).toEqual([ [ "Selection", "false" ], [ "Whole page", "true" ] ]);
await click("Page");
expect(modes()).toEqual([ [ "Selection", "false" ], [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(PAGE));
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com · Published 2024-05-01");
await click("Save page to Trilium");
@@ -299,7 +302,7 @@ describe("popup", () => {
tabsSendMessage.mockResolvedValueOnce(undefined);
await rerender();
expect(container.querySelector(".no-items")).toBeNull();
expect(container.querySelector(".clip-mode")).toBeNull();
expect(modes()).toEqual([ [ "Selection", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(SELECTION));
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("Page title");
await click("Save selection");
@@ -307,12 +310,12 @@ describe("popup", () => {
selection = { ...SELECTION, content: " <p> </p>", images: [] };
await rerender();
expect(container.querySelector(".clip-mode")).toBeNull();
expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(PAGE));
selection = { ...SELECTION, content: `<img src="i2">` };
await rerender();
expect(modes()).toHaveLength(2);
expect(modes()).toHaveLength(3);
expect(frameDocument()).toBe(previewDocument(selection as typeof SELECTION));
});
@@ -326,42 +329,45 @@ describe("popup", () => {
expect(alertMock).toHaveBeenCalledWith("Calling browser runtime failed. Refreshing page might help.");
});
it("writes a link note in a view of its own, and goes back to the actions", async () => {
expect(container.querySelector("textarea")).toBeNull();
it("writes a bookmark in the card, keeping the note across modes", async () => {
tabsQuery.mockResolvedValueOnce([ { id: 7, title: "A page", url: "https://example.com/post" } ]);
await click("Link with a note");
await flush();
const textArea = container.querySelector("textarea");
await rerender();
expect(container.querySelector("textarea")).toBeNull();
await click("Bookmark");
expect(modes()).toEqual([ [ "Page", "false" ], [ "Bookmark", "true" ] ]);
const textArea = container.querySelector(".page-body textarea");
expect(textArea).not.toBeNull();
expect(document.activeElement).toBe(textArea);
expect(textArea?.placeholder).toBe("Your note about this page");
expect(captureButtons()).toHaveLength(0);
expect(container.querySelector(".view-header h4")?.textContent).toBe("Link with a note");
const heading = container.querySelector(".save-link-with-note .page-heading");
expect(heading).not.toBeNull();
expect(heading?.querySelector(".page-icon svg path")).not.toBeNull();
expect(heading?.querySelector<HTMLInputElement>(".page-title")?.value).toBe("A page");
expect(heading?.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(container.querySelector("input[type=checkbox]")).toBeNull();
expect(shortcutOf("Save")).toEqual({ text: "Ctrl+Enter", keys: [ "Ctrl", "Enter" ] });
expect(textArea?.getAttribute("placeholder")).toBe("Add a note about this page");
expect(container.querySelector(".page-body iframe")).toBeNull();
expect(container.querySelector<HTMLInputElement>(".page-title")?.value).toBe("An article");
expect(container.querySelector(".page-meta")?.textContent).toBe("example.com");
expect(shortcutOf("Save bookmark")).toEqual({ text: "Ctrl+Enter", keys: [ "Ctrl", "Enter" ] });
await click("Back");
await type(".page-body textarea", "Worth a read");
await click("Page");
expect(container.querySelector("textarea")).toBeNull();
expect(captureButtons()).toHaveLength(5);
await click("Bookmark");
expect(container.querySelector<HTMLTextAreaElement>(".page-body textarea")?.value).toBe("Worth a read");
expect(closeWindow).not.toHaveBeenCalled();
sendMessage.mockResolvedValueOnce(true);
await click("Save bookmark");
expect(sendMessage).toHaveBeenLastCalledWith({
name: "save-link-with-note",
title: "An article",
content: "<p>Worth a read</p>"
});
expect(closeWindow).toHaveBeenCalledOnce();
});
it("saves the title and the text as they are, on Ctrl+Enter from either field", async () => {
tabsQuery.mockResolvedValueOnce([ { id: 7, title: "A page", url: "https://example.com/post" } ]);
await click("Link with a note");
await flush();
await type(".save-link-with-note .page-title", " Read later. Soon ");
await type(".save-link-with-note textarea", " It has <b>tips</b>\nand more ");
await click("Bookmark");
await type(".page-title", " Read later. Soon ");
await type(".page-body textarea", " It has <b>tips</b>\nand more ");
sendMessage.mockResolvedValueOnce(true);
await act(async () => {
container.querySelector(".save-link-with-note textarea")
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }));
});
await ctrlEnter(".page-body textarea");
expect(sendMessage).toHaveBeenCalledWith({
name: "save-link-with-note",
title: "Read later. Soon",
@@ -370,26 +376,25 @@ describe("popup", () => {
expect(closeWindow).toHaveBeenCalledOnce();
});
it("keeps the note open when it could not be saved, and leaves an empty title to the page", async () => {
tabsQuery.mockResolvedValueOnce([ {} ]);
await click("Link with a note");
await flush();
const title = container.querySelector<HTMLInputElement>(".save-link-with-note .page-title");
expect(title?.value).toBe("");
expect(container.querySelector(".save-link-with-note .page-meta")).toBeNull();
it("keeps the bookmark open when it could not be saved, and leaves an empty title to the page", async () => {
await click("Bookmark");
await type(".page-title", "");
const title = container.querySelector(".page-title");
await act(async () => {
title?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ name: "save-link-with-note" }));
sendMessage.mockResolvedValueOnce(undefined);
await act(async () => {
title?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }));
});
await ctrlEnter(".page-title");
expect(sendMessage).toHaveBeenCalledWith({ name: "save-link-with-note", title: "", content: "" });
expect(container.querySelector(".save-link-with-note")).not.toBeNull();
expect(container.querySelector(".page-body textarea")).not.toBeNull();
expect(closeWindow).not.toHaveBeenCalled();
const calls = sendMessage.mock.calls.length;
await click("Page");
await ctrlEnter(".page-title");
expect(sendMessage).toHaveBeenCalledTimes(calls);
});
it("renders itself into the page's root element", async () => {
@@ -443,7 +448,7 @@ describe("icons", () => {
await act(() => render(<Popup />, container));
const icons = container.querySelectorAll("button > svg.icon");
expect(icons).toHaveLength(8);
expect(icons).toHaveLength(7);
for (const icon of icons) {
expect(icon.getAttribute("aria-hidden")).toBe("true");
expect(icon.getAttribute("viewBox")).toBe("0 0 24 24");
@@ -517,6 +522,14 @@ function unavailable() {
return empty?.textContent;
}
async function ctrlEnter(selector: string) {
const field = container.querySelector(selector);
expect(field, selector).not.toBeNull();
await act(async () => {
field?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }));
});
}
function placeholder() {
return container.querySelector(".page-body > .page-preview-placeholder")?.textContent;
}
@@ -563,7 +576,8 @@ function cardParts() {
}
function captureButtons() {
return [ ...container.querySelectorAll<HTMLButtonElement>(".capture-actions button") ];
return [ ...container.querySelectorAll<HTMLButtonElement>(".capture-actions button") ]
.filter((button) => !button.closest(".clip-mode"));
}
function status() {
@@ -29,10 +29,15 @@ The extension is available from the official browser web stores:
Clicking the extension's button in the browser toolbar opens its popup:
* The popup reads the current page as an article and shows a preview of what will be saved: the note's title, which can be edited before saving, the website and the publication date (when the page states one), and the article's text and images. _Save page to Trilium_ saves it. When the page has no article (Readability cannot find one), the popup says so in place of the preview, and the other actions still work. Pages the extension cannot access (the browser's own pages, the extension stores, and pages that were open before the extension was installed until they are reloaded) cannot be saved or captured, so only _Note_ and _Tabs_ remain available there. The keyboard shortcut and the context menu still save the page right away, without a preview.
* If text is selected on the page, the preview shows the selection instead, and _Save selection_ saves it into the note of that page's clippings, as the context menu does. When the page also has an article, a switch above the preview chooses between _Selection_ and _Whole page_. A selection can be saved even on a page that has no article.
* Below the preview, a row of buttons offers the other actions: _Crop_ (a screenshot of an area you select), _Screenshot_ (of the visible part of the page), _Note_ (a link with a note) and _Tabs_ (saves the links of every tab in the current window as a list). Hovering over a button shows its full name and, if it has one, its keyboard shortcut.
* _Note_ opens a small editor in place of the actions, to save a link to the page with a note about it. The note's title starts as the page's title and can be edited (left empty, it stays the page's title); the text below it becomes the note's content. Press _Save_ or <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to save it, or the back arrow to return to the actions.
* The popup shows what will be saved from the current page: the note's title, which can be edited before saving, the website, and below them a preview. A switch above the preview chooses what to save, among what the page offers:
* _Selection_, when text is selected on the page: _Save selection_ saves it into the note of that page's clippings, as the context menu does.
* _Page_, when the page has an article: _Save page to Trilium_ saves the article's text and images, and the publication date shows next to the website when the page states one.
* _Bookmark_, always: a text box for a note about the page replaces the preview, and _Save bookmark_ (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) saves a link to the page with that note. Left empty, the title stays the page's title.
The popup opens on the selection if there is one, otherwise on the article. On a page with no article (Readability cannot find one) and no selection, it opens on _Bookmark_ and says the page has no article. The keyboard shortcuts and the context menu still save right away, without a preview.
* Pages the extension cannot access (the browser's own pages, the extension stores, and pages that were open before the extension was installed until they are reloaded) cannot be saved or captured, so the popup says so in place of the preview, and only _Tabs_ remains available there.
* Below the preview, a row of buttons offers the other actions: _Crop_ (a screenshot of an area you select), _Screenshot_ (of the visible part of the page) and _Tabs_ (saves the links of every tab in the current window as a list). Hovering over a button shows its full name and, if it has one, its keyboard shortcut.
* If the current page was already clipped, a notice at the top offers to open the note in Trilium.
* If Trilium cannot be found, the popup explains why instead of showing the actions, with buttons to look for Trilium again and to open the options.
* The bottom of the popup shows whether the extension is connected to Trilium, as a colored dot (green when connected, amber when the versions are not compatible, red when Trilium was not found) next to a description of the connection, followed by the buttons for the extension's options and for this help page. A button to check the connection again appears next to them when hovering over the bar, and stays visible while Trilium cannot be reached or is not compatible.