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 Ctrl+Enter to
- save it, or the back arrow to return to the actions.
-
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.
+
+
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 Ctrl+Enter)
+ 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.
+
Location of clippings
Trilium will save these clippings as a new child note under a "clipper
diff --git a/apps/web-clipper/entrypoints/popup/main.tsx b/apps/web-clipper/entrypoints/popup/main.tsx
index 1448a29845..a74980cdde 100644
--- a/apps/web-clipper/entrypoints/popup/main.tsx
+++ b/apps/web-clipper/entrypoints/popup/main.tsx
@@ -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 = { 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 = {
+ 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(, root);
@@ -78,7 +94,6 @@ if (root) {
export function Popup() {
const [ searchStatus, setSearchStatus ] = useState();
const [ clippedNoteId, setClippedNoteId ] = useState(null);
- const [ isWritingNote, setIsWritingNote ] = useState(false);
const [ shortcuts, setShortcuts ] = useState({});
useEffect(() => {
@@ -106,17 +121,11 @@ export function Popup() {
const isMismatch = searchStatus?.status === "version-mismatch";
let body: ComponentChildren;
- if (isWritingNote) {
- body = setIsWritingNote(false)} />;
- } else if (searchStatus?.status === "not-found") {
+ if (searchStatus?.status === "not-found") {
body = ;
} else {
body = (
- setIsWritingNote(true)}
- />
+
);
}
@@ -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();
@@ -201,17 +206,8 @@ function CaptureActions({ disabled, shortcuts, onWriteNote }: {
{"The extension cannot access this page.\nIf it is a regular web page, reload it."}
);
- } else if (tab?.page === "unreadable" && !tab.selection) {
- main = This page has no article to save.;
} else {
- main = (
-
- );
+ main = ;
}
return (
@@ -229,10 +225,6 @@ function CaptureActions({ disabled, shortcuts, onWriteNote }: {
disabledReason={pageDisabledReason}
onClick={() => sendAndClose("save-whole-screenshot")}
/>
- ("selection");
+ const [ chosenMode, setChosenMode ] = useState();
/** `undefined` until the user edits the title. */
const [ title, setTitle ] = useState();
+ const [ text, setText ] = useState("");
+ const textAreaRef = useRef(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 (
", images: [] };
await rerender();
- expect(container.querySelector(".clip-mode")).toBeNull();
+ expect(modes()).toEqual([ [ "Page", "true" ], [ "Bookmark", "false" ] ]);
expect(frameDocument()).toBe(previewDocument(PAGE));
selection = { ...SELECTION, content: `` };
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(".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(".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(".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: "
Worth a read
"
+ });
+ 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 tips\nand more ");
+ await click("Bookmark");
+ await type(".page-title", " Read later. Soon ");
+ await type(".page-body textarea", " It has tips\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(".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(, 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(".capture-actions button") ];
+ return [ ...container.querySelectorAll(".capture-actions button") ]
+ .filter((button) => !button.closest(".clip-mode"));
}
function status() {
diff --git a/docs/User Guide/User Guide/Installation & Setup/Web Clipper.md b/docs/User Guide/User Guide/Installation & Setup/Web Clipper.md
index 21576144c1..341e23ec13 100644
--- a/docs/User Guide/User Guide/Installation & Setup/Web Clipper.md
+++ b/docs/User Guide/User Guide/Installation & Setup/Web Clipper.md
@@ -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 Ctrl+Enter 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 Ctrl+Enter) 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.