diff --git a/.github/workflows/web-clipper.yml b/.github/workflows/web-clipper.yml
new file mode 100644
index 000000000..ef360a5a9
--- /dev/null
+++ b/.github/workflows/web-clipper.yml
@@ -0,0 +1,47 @@
+name: Deploy web clipper extension
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "apps/web-clipper/**"
+
+ pull_request:
+ paths:
+ - "apps/web-clipper/**"
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Build web clipper extension
+
+ permissions:
+ contents: read
+ deployments: write
+
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v4
+ - name: Set up node & dependencies
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install --filter web-clipper --frozen-lockfile --ignore-scripts
+
+ - name: Build the web clipper extension
+ run: |
+ pnpm --filter web-clipper zip
+ pnpm --filter web-clipper zip:firefox
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: web-clipper-extension
+ path: apps/web-clipper/.output/*.zip
+ include-hidden-files: true
+ if-no-files-found: error
+ compression-level: 0
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Web Clipper.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Web Clipper.html
index b406c7b3c..effb6e395 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Web Clipper.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Web Clipper.html
@@ -4,17 +4,34 @@
Trilium Web Clipper is a web browser extension which allows user to clip
text, screenshots, whole pages and short notes and save them directly to
Trilium Notes.
-Project is hosted here .
-Firefox and Chrome are supported browsers, but the chrome build should
- work on other chromium based browsers as well.
+Supported browsers
+Trilium Web Clipper officially supports the following web browsers:
+
+
+ Mozilla Firefox, using Manifest v2.
+
+
+ Google Chrome, using Manifest v3. Theoretically the extension should work
+ on other Chromium-based browsers as well, but they are not officially supported.
+
+
+Obtaining the extension
+
+ The extension is currently under development. A preview with unsigned
+ extensions is available on GitHub Actions .
+ We have already submitted the extension to both Chrome and Firefox web
+ stores, but they are pending validation.
+
Functionality
select text and clip it with the right-click context menu
click on an image or link and save it through context menu
save whole page from the popup or context menu
save screenshot (with crop tool) from either popup or context menu
- create short text note from popup
+ create short text note from popup
+Location of clippings
Trilium will save these clippings as a new child note under a "clipper
inbox" note.
By default, that's the day note but you
@@ -23,21 +40,33 @@
spellcheck="false">clipperInbox, on any other note.
If there's multiple clippings from the same page (and on the same day),
then they will be added to the same note.
-Extension is available from:
-
+Keyboard shortcuts
+Keyboard shortcuts are available for most functions:
- Project release page -
- .xpi for Firefox and .zip for Chromium based browsers.
- Chrome Web Store
+ Save selected text: Ctrl +Shift +S (Mac: ⌘ +⇧ +S )
+ Save whole page: Alt +Shift +S (Mac: ⌥ +⇧ +S )
+ Save screenshot: Ctrl +Shift +E (Mac: ⌘ +⇧ +E )
+
+To set custom shortcuts, follow the directions for your browser.
+
+ Firefox : about:addons →
+ Gear icon ⚙️ → Manage extension shortcuts
+ Chrome : chrome://extensions/shortcuts
+
+ On Firefox, the default shortcuts interfere with some browser features.
+ As such, the keyboard combinations will not trigger the Web Clipper action.
+ To fix this, simply change the keyboard shortcut to something that works.
+ The defaults will be adjusted in future versions.
+
Configuration
The extension needs to connect to a running Trilium instance. By default,
it scans a port range on the local computer to find a desktop Trilium instance.
It's also possible to configure the server address
if you don't run the desktop application, or want it to work without the
desktop application running.
-Username
-Older versions of Trilium (before 0.50) required username & password
- to authenticate, but this is no longer the case. You may enter anything
- in that field, it will not have any effect.
\ No newline at end of file
+Credits
+Some parts of the code are based on the Joplin Notes browser extension .
\ No newline at end of file
diff --git a/apps/web-clipper/.gitignore b/apps/web-clipper/.gitignore
index 77738287f..3e99175bf 100644
--- a/apps/web-clipper/.gitignore
+++ b/apps/web-clipper/.gitignore
@@ -1 +1,2 @@
-dist/
\ No newline at end of file
+.output
+.wxt
\ No newline at end of file
diff --git a/apps/web-clipper/README.md b/apps/web-clipper/README.md
deleted file mode 100644
index a37d0e181..000000000
--- a/apps/web-clipper/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Trilium Web Clipper
-
-## This repo is dead
-
-**Trilium is in maintenance mode and Web Clipper is not likely to get new releases.**
-
-Trilium Web Clipper is a web browser extension which allows user to clip text, screenshots, whole pages and short notes and save them directly to [Trilium Notes](https://github.com/zadam/trilium).
-
-For more details, see the [wiki page](https://github.com/zadam/trilium/wiki/Web-clipper).
-
-## Keyboard shortcuts
-Keyboard shortcuts are available for most functions:
-* Save selected text: `Ctrl+Shift+S` (Mac: `Cmd+Shift+S`)
-* Save whole page: `Alt+Shift+S` (Mac: `Opt+Shift+S`)
-* Save screenshot: `Ctrl+Shift+E` (Mac: `Cmd+Shift+E`)
-
-To set custom shortcuts, follow the directions for your browser.
-
-**Firefox**: `about:addons` > Gear icon ⚙️ > Manage extension shortcuts
-
-**Chrome**: `chrome://extensions/shortcuts`
-
-## Credits
-Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).
diff --git a/apps/web-clipper/assets/icon.png b/apps/web-clipper/assets/icon.png
new file mode 100644
index 000000000..2d4da35c4
Binary files /dev/null and b/apps/web-clipper/assets/icon.png differ
diff --git a/apps/web-clipper/background.js b/apps/web-clipper/background.js
deleted file mode 100644
index 4074987ab..000000000
--- a/apps/web-clipper/background.js
+++ /dev/null
@@ -1,451 +0,0 @@
-// Keyboard shortcuts
-chrome.commands.onCommand.addListener(async function (command) {
- if (command == "saveSelection") {
- await saveSelection();
- } else if (command == "saveWholePage") {
- await saveWholePage();
- } else if (command == "saveTabs") {
- await saveTabs();
- } else if (command == "saveCroppedScreenshot") {
- const activeTab = await getActiveTab();
-
- await saveCroppedScreenshot(activeTab.url);
- } else {
- console.log("Unrecognized command", command);
- }
-});
-
-function cropImage(newArea, dataUrl) {
- return new Promise((resolve, reject) => {
- const img = new Image();
-
- img.onload = function () {
- const canvas = document.createElement('canvas');
- canvas.width = newArea.width;
- canvas.height = newArea.height;
-
- const ctx = canvas.getContext('2d');
-
- ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
-
- resolve(canvas.toDataURL());
- };
-
- img.src = dataUrl;
- });
-}
-
-async function takeCroppedScreenshot(cropRect) {
- const activeTab = await getActiveTab();
- const zoom = await browser.tabs.getZoom(activeTab.id) * window.devicePixelRatio;
-
- const newArea = Object.assign({}, cropRect);
- newArea.x *= zoom;
- newArea.y *= zoom;
- newArea.width *= zoom;
- newArea.height *= zoom;
-
- const dataUrl = await browser.tabs.captureVisibleTab(null, { format: 'png' });
-
- return await cropImage(newArea, dataUrl);
-}
-
-async function takeWholeScreenshot() {
- // this saves only visible portion of the page
- // workaround to save the whole page is to scroll & stitch
- // example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
- // see page.js and popup.js
- return await browser.tabs.captureVisibleTab(null, { format: 'png' });
-}
-
-browser.runtime.onInstalled.addListener(() => {
- if (isDevEnv()) {
- browser.browserAction.setIcon({
- path: 'icons/32-dev.png',
- });
- }
-});
-
-browser.contextMenus.create({
- id: "trilium-save-selection",
- title: "Save selection to Trilium",
- contexts: ["selection"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-cropped-screenshot",
- title: "Clip screenshot to Trilium",
- contexts: ["page"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-cropped-screenshot",
- title: "Crop screen shot to Trilium",
- contexts: ["page"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-whole-screenshot",
- title: "Save whole screen shot to Trilium",
- contexts: ["page"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-page",
- title: "Save whole page to Trilium",
- contexts: ["page"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-link",
- title: "Save link to Trilium",
- contexts: ["link"]
-});
-
-browser.contextMenus.create({
- id: "trilium-save-image",
- title: "Save image to Trilium",
- contexts: ["image"]
-});
-
-async function getActiveTab() {
- const tabs = await browser.tabs.query({
- active: true,
- currentWindow: true
- });
-
- return tabs[0];
-}
-
-async function getWindowTabs() {
- const tabs = await browser.tabs.query({
- currentWindow: true
- });
-
- return tabs;
-}
-
-async function sendMessageToActiveTab(message) {
- const activeTab = await getActiveTab();
-
- if (!activeTab) {
- throw new Error("No active tab.");
- }
-
- try {
- return await browser.tabs.sendMessage(activeTab.id, message);
- }
- catch (e) {
- throw e;
- }
-}
-
-function toast(message, noteId = null, tabIds = null) {
- sendMessageToActiveTab({
- name: 'toast',
- message: message,
- noteId: noteId,
- tabIds: tabIds
- });
-}
-
-function blob2base64(blob) {
- return new Promise(resolve => {
- const reader = new FileReader();
- reader.onloadend = function() {
- resolve(reader.result);
- };
- reader.readAsDataURL(blob);
- });
-}
-
-async function fetchImage(url) {
- const resp = await fetch(url);
- const blob = await resp.blob();
-
- return await blob2base64(blob);
-}
-
-async function postProcessImage(image) {
- if (image.src.startsWith("data:image/")) {
- image.dataUrl = image.src;
- image.src = "inline." + image.src.substr(11, 3); // this should extract file type - png/jpg
- }
- else {
- try {
- image.dataUrl = await fetchImage(image.src, image);
- }
- catch (e) {
- console.log(`Cannot fetch image from ${image.src}`);
- }
- }
-}
-
-async function postProcessImages(resp) {
- if (resp.images) {
- for (const image of resp.images) {
- await postProcessImage(image);
- }
- }
-}
-
-async function saveSelection() {
- const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
-
- await postProcessImages(payload);
-
- const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
-
- if (!resp) {
- return;
- }
-
- toast("Selection has been saved to Trilium.", resp.noteId);
-}
-
-async function getImagePayloadFromSrc(src, pageUrl) {
- const image = {
- imageId: randomString(20),
- src: src
- };
-
- await postProcessImage(image);
-
- const activeTab = await getActiveTab();
-
- return {
- title: activeTab.title,
- content: ` `,
- images: [image],
- pageUrl: pageUrl
- };
-}
-
-async function saveCroppedScreenshot(pageUrl) {
- const cropRect = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
-
- const src = await takeCroppedScreenshot(cropRect);
-
- const payload = await getImagePayloadFromSrc(src, pageUrl);
-
- const resp = await triliumServerFacade.callService("POST", "clippings", payload);
-
- if (!resp) {
- return;
- }
-
- toast("Screenshot has been saved to Trilium.", resp.noteId);
-}
-
-async function saveWholeScreenshot(pageUrl) {
- const src = await takeWholeScreenshot();
-
- const payload = await getImagePayloadFromSrc(src, pageUrl);
-
- const resp = await triliumServerFacade.callService("POST", "clippings", payload);
-
- if (!resp) {
- return;
- }
-
- toast("Screenshot has been saved to Trilium.", resp.noteId);
-}
-
-async function saveImage(srcUrl, pageUrl) {
- const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
-
- const resp = await triliumServerFacade.callService("POST", "clippings", payload);
-
- if (!resp) {
- return;
- }
-
- toast("Image has been saved to Trilium.", resp.noteId);
-}
-
-async function saveWholePage() {
- const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
-
- await postProcessImages(payload);
-
- const resp = await triliumServerFacade.callService('POST', 'notes', payload);
-
- if (!resp) {
- return;
- }
-
- toast("Page has been saved to Trilium.", resp.noteId);
-}
-
-async function saveLinkWithNote(title, content) {
- const activeTab = await getActiveTab();
-
- if (!title.trim()) {
- title = activeTab.title;
- }
-
- const resp = await triliumServerFacade.callService('POST', 'notes', {
- title: title,
- content: content,
- clipType: 'note',
- pageUrl: activeTab.url
- });
-
- if (!resp) {
- return false;
- }
-
- toast("Link with note has been saved to Trilium.", resp.noteId);
-
- return true;
-}
-
-async function getTabsPayload(tabs) {
- let content = '';
- tabs.forEach(tab => {
- content += `${tab.title} `
- });
- content += ' ';
-
- const domainsCount = tabs.map(tab => tab.url)
- .reduce((acc, url) => {
- const hostname = new URL(url).hostname
- return acc.set(hostname, (acc.get(hostname) || 0) + 1)
- }, new Map());
-
- let topDomains = [...domainsCount]
- .sort((a, b) => {return b[1]-a[1]})
- .slice(0,3)
- .map(domain=>domain[0])
- .join(', ')
-
- if (tabs.length > 3) { topDomains += '...' }
-
- return {
- title: `${tabs.length} browser tabs: ${topDomains}`,
- content: content,
- clipType: 'tabs'
- };
-}
-
-async function saveTabs() {
- const tabs = await getWindowTabs();
-
- const payload = await getTabsPayload(tabs);
-
- const resp = await triliumServerFacade.callService('POST', 'notes', payload);
-
- if (!resp) {
- return;
- }
-
- const tabIds = tabs.map(tab=>{return tab.id});
-
- toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
-}
-
-browser.contextMenus.onClicked.addListener(async function(info, tab) {
- if (info.menuItemId === 'trilium-save-selection') {
- await saveSelection();
- }
- else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
- await saveCroppedScreenshot(info.pageUrl);
- }
- else if (info.menuItemId === 'trilium-save-whole-screenshot') {
- await saveWholeScreenshot(info.pageUrl);
- }
- else if (info.menuItemId === 'trilium-save-image') {
- await saveImage(info.srcUrl, info.pageUrl);
- }
- else if (info.menuItemId === 'trilium-save-link') {
- const link = document.createElement("a");
- link.href = info.linkUrl;
- // linkText might be available only in firefox
- link.appendChild(document.createTextNode(info.linkText || info.linkUrl));
-
- const activeTab = await getActiveTab();
-
- const resp = await triliumServerFacade.callService('POST', 'clippings', {
- title: activeTab.title,
- content: link.outerHTML,
- pageUrl: info.pageUrl
- });
-
- if (!resp) {
- return;
- }
-
- toast("Link has been saved to Trilium.", resp.noteId);
- }
- else if (info.menuItemId === 'trilium-save-page') {
- await saveWholePage();
- }
- else {
- console.log("Unrecognized menuItemId", info.menuItemId);
- }
-});
-
-browser.runtime.onMessage.addListener(async request => {
- console.log("Received", request);
-
- if (request.name === 'openNoteInTrilium') {
- const resp = await triliumServerFacade.callService('POST', 'open/' + request.noteId);
-
- if (!resp) {
- return;
- }
-
- // desktop app is not available so we need to open in browser
- if (resp.result === 'open-in-browser') {
- const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
-
- if (triliumServerUrl) {
- const noteUrl = triliumServerUrl + '/#' + request.noteId;
-
- console.log("Opening new tab in browser", noteUrl);
-
- browser.tabs.create({
- url: noteUrl
- });
- }
- else {
- console.error("triliumServerUrl not found in local storage.");
- }
- }
- }
- else if (request.name === 'closeTabs') {
- return await browser.tabs.remove(request.tabIds)
- }
- else if (request.name === 'load-script') {
- return await browser.tabs.executeScript({file: request.file});
- }
- else if (request.name === 'save-cropped-screenshot') {
- const activeTab = await getActiveTab();
-
- return await saveCroppedScreenshot(activeTab.url);
- }
- else if (request.name === 'save-whole-screenshot') {
- const activeTab = await getActiveTab();
-
- return await saveWholeScreenshot(activeTab.url);
- }
- else if (request.name === 'save-whole-page') {
- return await saveWholePage();
- }
- else if (request.name === 'save-link-with-note') {
- return await saveLinkWithNote(request.title, request.content);
- }
- else if (request.name === 'save-tabs') {
- return await saveTabs();
- }
- else if (request.name === 'trigger-trilium-search') {
- triliumServerFacade.triggerSearchForTrilium();
- }
- else if (request.name === 'send-trilium-search-status') {
- triliumServerFacade.sendTriliumSearchStatusToPopup();
- }
- else if (request.name === 'trigger-trilium-search-note-url') {
- const activeTab = await getActiveTab();
- triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
- }
-});
diff --git a/apps/web-clipper/build.js b/apps/web-clipper/build.js
deleted file mode 100644
index 3826b2524..000000000
--- a/apps/web-clipper/build.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
diff --git a/apps/web-clipper/build.ts b/apps/web-clipper/build.ts
new file mode 100644
index 000000000..2f5056d47
--- /dev/null
+++ b/apps/web-clipper/build.ts
@@ -0,0 +1 @@
+export default { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
diff --git a/apps/web-clipper/content.js b/apps/web-clipper/content.js
deleted file mode 100644
index faacfa546..000000000
--- a/apps/web-clipper/content.js
+++ /dev/null
@@ -1,351 +0,0 @@
-function absoluteUrl(url) {
- if (!url) {
- return url;
- }
-
- const protocol = url.toLowerCase().split(':')[0];
- if (['http', 'https', 'file'].indexOf(protocol) >= 0) {
- return url;
- }
-
- if (url.indexOf('//') === 0) {
- return location.protocol + url;
- } else if (url[0] === '/') {
- return location.protocol + '//' + location.host + url;
- } else {
- return getBaseUrl() + '/' + url;
- }
-}
-
-function pageTitle() {
- const titleElements = document.getElementsByTagName("title");
-
- return titleElements.length ? titleElements[0].text.trim() : document.title.trim();
-}
-
-function getReadableDocument() {
- // Readability directly change the passed document, so clone to preserve the original web page.
- const documentCopy = document.cloneNode(true);
- const readability = new Readability(documentCopy, {
- serializer: el => el // so that .content is returned as DOM element instead of HTML
- });
-
- const article = readability.parse();
-
- if (!article) {
- throw new Error('Could not parse HTML document with Readability');
- }
-
- return {
- title: article.title,
- body: article.content,
- }
-}
-
-function getDocumentDates() {
- var dates = {
- publishedDate: null,
- modifiedDate: null,
- };
-
- const articlePublishedTime = document.querySelector("meta[property='article:published_time']");
- if (articlePublishedTime && articlePublishedTime.getAttribute('content')) {
- dates.publishedDate = new Date(articlePublishedTime.getAttribute('content'));
- }
-
- const articleModifiedTime = document.querySelector("meta[property='article:modified_time']");
- if (articleModifiedTime && articleModifiedTime.getAttribute('content')) {
- dates.modifiedDate = new Date(articleModifiedTime.getAttribute('content'));
- }
-
- // TODO: if we didn't get dates from meta, then try to get them from JSON-LD
-
- return dates;
-}
-
-function getRectangleArea() {
- return new Promise((resolve, reject) => {
- const overlay = document.createElement('div');
- overlay.style.opacity = '0.6';
- overlay.style.background = 'black';
- overlay.style.width = '100%';
- overlay.style.height = '100%';
- overlay.style.zIndex = 99999999;
- overlay.style.top = 0;
- overlay.style.left = 0;
- overlay.style.position = 'fixed';
-
- document.body.appendChild(overlay);
-
- const messageComp = document.createElement('div');
-
- const messageCompWidth = 300;
- messageComp.setAttribute("tabindex", "0"); // so that it can be focused
- messageComp.style.position = 'fixed';
- messageComp.style.opacity = '0.95';
- messageComp.style.fontSize = '14px';
- messageComp.style.width = messageCompWidth + 'px';
- messageComp.style.maxWidth = messageCompWidth + 'px';
- messageComp.style.border = '1px solid black';
- messageComp.style.background = 'white';
- messageComp.style.color = 'black';
- messageComp.style.top = '10px';
- messageComp.style.textAlign = 'center';
- messageComp.style.padding = '10px';
- messageComp.style.left = Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) + 'px';
- messageComp.style.zIndex = overlay.style.zIndex + 1;
-
- messageComp.textContent = 'Drag and release to capture a screenshot';
-
- document.body.appendChild(messageComp);
-
- const selection = document.createElement('div');
- selection.style.opacity = '0.5';
- selection.style.border = '1px solid red';
- selection.style.background = 'white';
- selection.style.border = '2px solid black';
- selection.style.zIndex = overlay.style.zIndex - 1;
- selection.style.top = 0;
- selection.style.left = 0;
- selection.style.position = 'fixed';
-
- document.body.appendChild(selection);
-
- messageComp.focus(); // we listen on keypresses on this element to cancel on escape
-
- let isDragging = false;
- let draggingStartPos = null;
- let selectionArea = {};
-
- function updateSelection() {
- selection.style.left = selectionArea.x + 'px';
- selection.style.top = selectionArea.y + 'px';
- selection.style.width = selectionArea.width + 'px';
- selection.style.height = selectionArea.height + 'px';
- }
-
- function setSelectionSizeFromMouse(event) {
- if (event.clientX < draggingStartPos.x) {
- selectionArea.x = event.clientX;
- }
-
- if (event.clientY < draggingStartPos.y) {
- selectionArea.y = event.clientY;
- }
-
- selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x));
- selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y));
- updateSelection();
- }
-
- function selection_mouseDown(event) {
- selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0};
- draggingStartPos = {x: event.clientX, y: event.clientY};
- isDragging = true;
- updateSelection();
- }
-
- function selection_mouseMove(event) {
- if (!isDragging) return;
- setSelectionSizeFromMouse(event);
- }
-
- function removeOverlay() {
- isDragging = false;
-
- overlay.removeEventListener('mousedown', selection_mouseDown);
- overlay.removeEventListener('mousemove', selection_mouseMove);
- overlay.removeEventListener('mouseup', selection_mouseUp);
-
- document.body.removeChild(overlay);
- document.body.removeChild(selection);
- document.body.removeChild(messageComp);
- }
-
- function selection_mouseUp(event) {
- setSelectionSizeFromMouse(event);
-
- removeOverlay();
-
- console.info('selectionArea:', selectionArea);
-
- if (!selectionArea || !selectionArea.width || !selectionArea.height) {
- return;
- }
-
- // Need to wait a bit before taking the screenshot to make sure
- // the overlays have been removed and don't appear in the
- // screenshot. 10ms is not enough.
- setTimeout(() => resolve(selectionArea), 100);
- }
-
- function cancel(event) {
- if (event.key === "Escape") {
- removeOverlay();
- }
- }
-
- overlay.addEventListener('mousedown', selection_mouseDown);
- overlay.addEventListener('mousemove', selection_mouseMove);
- overlay.addEventListener('mouseup', selection_mouseUp);
- overlay.addEventListener('mouseup', selection_mouseUp);
- messageComp.addEventListener('keydown', cancel);
- });
-}
-
-function makeLinksAbsolute(container) {
- for (const link of container.getElementsByTagName('a')) {
- if (link.href) {
- link.href = absoluteUrl(link.href);
- }
- }
-}
-
-function getImages(container) {
- const images = [];
-
- for (const img of container.getElementsByTagName('img')) {
- if (!img.src) {
- continue;
- }
-
- const existingImage = images.find(image => image.src === img.src);
-
- if (existingImage) {
- img.src = existingImage.imageId;
- }
- else {
- const imageId = randomString(20);
-
- images.push({
- imageId: imageId,
- src: img.src
- });
-
- img.src = imageId;
- }
- }
-
- return images;
-}
-
-function createLink(clickAction, text, color = "lightskyblue") {
- const link = document.createElement('a');
- link.href = "javascript:";
- link.style.color = color;
- link.appendChild(document.createTextNode(text));
- link.addEventListener("click", () => {
- browser.runtime.sendMessage(null, clickAction)
- });
-
- return link
-}
-
-async function prepareMessageResponse(message) {
- console.info('Message: ' + message.name);
-
- if (message.name === "toast") {
- let messageText;
-
- if (message.noteId) {
- messageText = document.createElement('p');
- messageText.setAttribute("style", "padding: 0; margin: 0; font-size: larger;")
- messageText.appendChild(document.createTextNode(message.message + " "));
- messageText.appendChild(createLink(
- {name: 'openNoteInTrilium', noteId: message.noteId},
- "Open in Trilium."
- ));
-
- // only after saving tabs
- if (message.tabIds) {
- messageText.appendChild(document.createElement("br"));
- messageText.appendChild(createLink(
- {name: 'closeTabs', tabIds: message.tabIds},
- "Close saved tabs.",
- "tomato"
- ));
- }
- }
- else {
- messageText = message.message;
- }
-
- await requireLib('/lib/toast.js');
-
- showToast(messageText, {
- settings: {
- duration: 7000
- }
- });
- }
- else if (message.name === "trilium-save-selection") {
- const container = document.createElement('div');
-
- const selection = window.getSelection();
-
- for (let i = 0; i < selection.rangeCount; i++) {
- const range = selection.getRangeAt(i);
-
- container.appendChild(range.cloneContents());
- }
-
- makeLinksAbsolute(container);
-
- const images = getImages(container);
-
- return {
- title: pageTitle(),
- content: container.innerHTML,
- images: images,
- pageUrl: getPageLocationOrigin() + location.pathname + location.search + location.hash
- };
-
- }
- else if (message.name === 'trilium-get-rectangle-for-screenshot') {
- return getRectangleArea();
- }
- else if (message.name === "trilium-save-page") {
- await requireLib("/lib/JSDOMParser.js");
- await requireLib("/lib/Readability.js");
- await requireLib("/lib/Readability-readerable.js");
-
- const {title, body} = getReadableDocument();
-
- makeLinksAbsolute(body);
-
- const images = getImages(body);
-
- var labels = {};
- const dates = getDocumentDates();
- if (dates.publishedDate) {
- labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10);
- }
- if (dates.modifiedDate) {
- labels['modifiedDate'] = dates.publishedDate.toISOString().substring(0, 10);
- }
-
- return {
- title: title,
- content: body.innerHTML,
- images: images,
- pageUrl: getPageLocationOrigin() + location.pathname + location.search,
- clipType: 'page',
- labels: labels
- };
- }
- else {
- throw new Error('Unknown command: ' + JSON.stringify(message));
- }
-}
-
-browser.runtime.onMessage.addListener(prepareMessageResponse);
-
-const loadedLibs = [];
-
-async function requireLib(libPath) {
- if (!loadedLibs.includes(libPath)) {
- loadedLibs.push(libPath);
-
- await browser.runtime.sendMessage({name: 'load-script', file: libPath});
- }
-}
diff --git a/apps/web-clipper/entrypoints/background/index.ts b/apps/web-clipper/entrypoints/background/index.ts
new file mode 100644
index 000000000..8657ee511
--- /dev/null
+++ b/apps/web-clipper/entrypoints/background/index.ts
@@ -0,0 +1,483 @@
+import { randomString, Rect } from "@/utils";
+
+import TriliumServerFacade from "./trilium_server_facade";
+
+type BackgroundMessage = {
+ name: "toast";
+ message: string;
+ noteId: string | null;
+ tabIds: number[] | null;
+} | {
+ name: "trilium-save-selection";
+} | {
+ name: "trilium-get-rectangle-for-screenshot";
+} | {
+ name: "trilium-save-page";
+};
+
+export default defineBackground(() => {
+ const triliumServerFacade = new TriliumServerFacade();
+
+ // Keyboard shortcuts
+ browser.commands.onCommand.addListener(async (command) => {
+ switch (command) {
+ case "saveSelection":
+ await saveSelection();
+ break;
+ case "saveWholePage":
+ await saveWholePage();
+ break;
+ case "saveTabs":
+ await saveTabs();
+ break;
+ case "saveCroppedScreenshot": {
+ const activeTab = await getActiveTab();
+ await saveCroppedScreenshot(activeTab.url);
+ break;
+ }
+ default:
+ console.log("Unrecognized command", command);
+ }
+ });
+
+ function cropImageManifestV2(newArea: Rect, dataUrl: string) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+
+ img.onload = function () {
+ const canvas = document.createElement('canvas');
+ canvas.width = newArea.width;
+ canvas.height = newArea.height;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ reject();
+ return;
+ }
+ ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
+ resolve(canvas.toDataURL());
+ };
+ img.onerror = reject;
+
+ img.src = dataUrl;
+ });
+ }
+
+ async function cropImageManifestV3(newArea: Rect, dataUrl: string) {
+ // Create offscreen document if it doesn't exist
+ await ensureOffscreenDocument();
+
+ // Send cropping task to offscreen document
+ return await browser.runtime.sendMessage({
+ type: 'CROP_IMAGE',
+ dataUrl,
+ cropRect: newArea
+ });
+ }
+
+ async function takeCroppedScreenshot(cropRect: Rect, devicePixelRatio: number = 1) {
+ const activeTab = await getActiveTab();
+ const zoom = await browser.tabs.getZoom(activeTab.id) * devicePixelRatio;
+
+ const newArea: Rect = {
+ x: cropRect.x * zoom,
+ y: cropRect.y * zoom,
+ width: cropRect.width * zoom,
+ height: cropRect.height * zoom
+ };
+
+ const dataUrl = await browser.tabs.captureVisibleTab({ format: 'png' });
+ const cropImage = (import.meta.env.MANIFEST_VERSION === 3 ? cropImageManifestV3 : cropImageManifestV2);
+ return await cropImage(newArea, dataUrl);
+ }
+
+ async function ensureOffscreenDocument() {
+ const existingContexts = await browser.runtime.getContexts({
+ contextTypes: ['OFFSCREEN_DOCUMENT']
+ });
+
+ if (existingContexts.length > 0) {
+ return; // Already exists
+ }
+
+ await browser.offscreen.createDocument({
+ url: browser.runtime.getURL('/offscreen.html'),
+ reasons: ['DOM_SCRAPING'], // or 'DISPLAY_MEDIA' depending on browser support
+ justification: 'Image cropping requires canvas API'
+ });
+ }
+
+ async function takeWholeScreenshot() {
+ // this saves only visible portion of the page
+ // workaround to save the whole page is to scroll & stitch
+ // example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
+ // see page.js and popup.js
+ return await browser.tabs.captureVisibleTab({ format: 'png' });
+ }
+
+ browser.contextMenus.create({
+ id: "trilium-save-selection",
+ title: "Save selection to Trilium",
+ contexts: ["selection"]
+ });
+
+ browser.contextMenus.create({
+ id: "trilium-save-cropped-screenshot",
+ title: "Crop screen shot to Trilium",
+ contexts: ["page"]
+ });
+
+ browser.contextMenus.create({
+ id: "trilium-save-whole-screenshot",
+ title: "Save whole screen shot to Trilium",
+ contexts: ["page"]
+ });
+
+ browser.contextMenus.create({
+ id: "trilium-save-page",
+ title: "Save whole page to Trilium",
+ contexts: ["page"]
+ });
+
+ browser.contextMenus.create({
+ id: "trilium-save-link",
+ title: "Save link to Trilium",
+ contexts: ["link"]
+ });
+
+ browser.contextMenus.create({
+ id: "trilium-save-image",
+ title: "Save image to Trilium",
+ contexts: ["image"]
+ });
+
+ async function getActiveTab() {
+ const tabs = await browser.tabs.query({
+ active: true,
+ currentWindow: true
+ });
+
+ return tabs[0];
+ }
+
+ async function getWindowTabs() {
+ const tabs = await browser.tabs.query({
+ currentWindow: true
+ });
+
+ return tabs;
+ }
+
+ async function sendMessageToActiveTab(message: BackgroundMessage) {
+ const activeTab = await getActiveTab();
+
+ if (!activeTab?.id) {
+ throw new Error("No active tab.");
+ }
+
+ return await browser.tabs.sendMessage(activeTab.id, message);
+ }
+
+ function toast(message: string, noteId: string | null = null, tabIds: number[] | null = null) {
+ sendMessageToActiveTab({
+ name: 'toast',
+ message,
+ noteId,
+ tabIds
+ });
+ }
+
+ function blob2base64(blob: Blob) {
+ return new Promise(resolve => {
+ const reader = new FileReader();
+ reader.onloadend = function() {
+ resolve(reader.result as string | null);
+ };
+ reader.readAsDataURL(blob);
+ });
+ }
+
+ async function fetchImage(url: string) {
+ const resp = await fetch(url);
+ const blob = await resp.blob();
+
+ return await blob2base64(blob);
+ }
+
+ async function postProcessImage(image: { src: string, dataUrl?: string | null }) {
+ if (image.src.startsWith("data:image/")) {
+ image.dataUrl = image.src;
+ const mimeSubtype = image.src.match(/data:image\/(.*?);/)?.[1];
+ if (!mimeSubtype) return;
+ image.src = `inline.${mimeSubtype}`; // this should extract file type - png/jpg
+ }
+ else {
+ try {
+ image.dataUrl = await fetchImage(image.src);
+ } catch (e) {
+ console.error(`Cannot fetch image from ${image.src}`, e);
+ }
+ }
+ }
+
+ async function postProcessImages(resp: { images?: { src: string, dataUrl?: string }[] }) {
+ if (resp.images) {
+ for (const image of resp.images) {
+ await postProcessImage(image);
+ }
+ }
+ }
+
+ async function saveSelection() {
+ const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
+
+ await postProcessImages(payload);
+
+ const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
+
+ if (!resp) {
+ return;
+ }
+
+ toast("Selection has been saved to Trilium.", resp.noteId);
+ }
+
+ async function getImagePayloadFromSrc(src: string, pageUrl: string | null | undefined) {
+ const image = {
+ imageId: randomString(20),
+ src
+ };
+
+ await postProcessImage(image);
+
+ const activeTab = await getActiveTab();
+
+ return {
+ title: activeTab.title,
+ content: ` `,
+ images: [image],
+ pageUrl
+ };
+ }
+
+ async function saveCroppedScreenshot(pageUrl: string | null | undefined) {
+ const { rect, devicePixelRatio } = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
+
+ const src = await takeCroppedScreenshot(rect, devicePixelRatio);
+
+ const payload = await getImagePayloadFromSrc(src, pageUrl);
+
+ const resp = await triliumServerFacade.callService("POST", "clippings", payload);
+
+ if (!resp) {
+ return;
+ }
+
+ toast("Screenshot has been saved to Trilium.", resp.noteId);
+ }
+
+ async function saveWholeScreenshot(pageUrl: string | null | undefined) {
+ const src = await takeWholeScreenshot();
+
+ const payload = await getImagePayloadFromSrc(src, pageUrl);
+
+ const resp = await triliumServerFacade.callService("POST", "clippings", payload);
+
+ if (!resp) {
+ return;
+ }
+
+ toast("Screenshot has been saved to Trilium.", resp.noteId);
+ }
+
+ async function saveImage(srcUrl: string, pageUrl: string | null | undefined) {
+ const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
+
+ const resp = await triliumServerFacade.callService("POST", "clippings", payload);
+
+ if (!resp) {
+ return;
+ }
+
+ toast("Image has been saved to Trilium.", resp.noteId);
+ }
+
+ async function saveWholePage() {
+ const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
+
+ await postProcessImages(payload);
+
+ const resp = await triliumServerFacade.callService('POST', 'notes', payload);
+
+ if (!resp) {
+ return;
+ }
+
+ toast("Page has been saved to Trilium.", resp.noteId);
+ }
+
+ async function saveLinkWithNote(title: string, content: string) {
+ const activeTab = await getActiveTab();
+
+ if (!title.trim()) {
+ title = activeTab.title ?? "";
+ }
+
+ const resp = await triliumServerFacade.callService('POST', 'notes', {
+ title,
+ content,
+ clipType: 'note',
+ pageUrl: activeTab.url
+ });
+
+ if (!resp) {
+ return false;
+ }
+
+ toast("Link with note has been saved to Trilium.", resp.noteId);
+
+ return true;
+ }
+
+ async function getTabsPayload(tabs: Browser.tabs.Tab[]) {
+ let content = '';
+ tabs.forEach(tab => {
+ content += `${tab.title} `;
+ });
+ content += ' ';
+
+ const domainsCount = tabs.map(tab => tab.url)
+ .reduce((acc, url) => {
+ const hostname = new URL(url ?? "").hostname;
+ return acc.set(hostname, (acc.get(hostname) || 0) + 1);
+ }, new Map());
+
+ let topDomains = [...domainsCount]
+ .sort((a, b) => {return b[1]-a[1];})
+ .slice(0,3)
+ .map(domain=>domain[0])
+ .join(', ');
+
+ if (tabs.length > 3) { topDomains += '...'; }
+
+ return {
+ title: `${tabs.length} browser tabs: ${topDomains}`,
+ content,
+ clipType: 'tabs'
+ };
+ }
+
+ async function saveTabs() {
+ const tabs = await getWindowTabs();
+
+ const payload = await getTabsPayload(tabs);
+
+ const resp = await triliumServerFacade.callService('POST', 'notes', payload);
+ if (!resp) return;
+
+ const tabIds = tabs.map(tab => tab.id).filter(id => id !== undefined) as number[];
+ toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
+ }
+
+ browser.contextMenus.onClicked.addListener(async (info: globalThis.Browser.contextMenus.OnClickData & { linkText?: string; }) => {
+ if (info.menuItemId === 'trilium-save-selection') {
+ await saveSelection();
+ }
+ else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
+ await saveCroppedScreenshot(info.pageUrl);
+ }
+ else if (info.menuItemId === 'trilium-save-whole-screenshot') {
+ await saveWholeScreenshot(info.pageUrl);
+ }
+ else if (info.menuItemId === 'trilium-save-image') {
+ if (!info.srcUrl) return;
+ await saveImage(info.srcUrl, info.pageUrl);
+ }
+ else if (info.menuItemId === 'trilium-save-link') {
+ if (!info.linkUrl) return;
+ // Link text is only available on Firefox.
+ const linkText = info.linkText || info.linkUrl;
+ const content = `${linkText} `;
+ const activeTab = await getActiveTab();
+
+ const resp = await triliumServerFacade.callService('POST', 'clippings', {
+ title: activeTab.title,
+ content,
+ pageUrl: info.pageUrl
+ });
+
+ if (!resp) return;
+ toast("Link has been saved to Trilium.", resp.noteId);
+ }
+ else if (info.menuItemId === 'trilium-save-page') {
+ await saveWholePage();
+ }
+ else {
+ console.log("Unrecognized menuItemId", info.menuItemId);
+ }
+ });
+
+ browser.runtime.onMessage.addListener(async request => {
+ console.log("Received", request);
+
+ if (request.name === 'openNoteInTrilium') {
+ const resp = await triliumServerFacade.callService('POST', `open/${request.noteId}`);
+
+ if (!resp) {
+ return;
+ }
+
+ // desktop app is not available so we need to open in browser
+ if (resp.result === 'open-in-browser') {
+ const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
+
+ if (triliumServerUrl) {
+ const noteUrl = `${triliumServerUrl }/#${ request.noteId}`;
+
+ console.log("Opening new tab in browser", noteUrl);
+
+ browser.tabs.create({
+ url: noteUrl
+ });
+ }
+ else {
+ console.error("triliumServerUrl not found in local storage.");
+ }
+ }
+ }
+ else if (request.name === 'closeTabs') {
+ return await browser.tabs.remove(request.tabIds);
+ }
+ else if (request.name === 'save-cropped-screenshot') {
+ const activeTab = await getActiveTab();
+
+ return await saveCroppedScreenshot(activeTab.url);
+ }
+ else if (request.name === 'save-whole-screenshot') {
+ const activeTab = await getActiveTab();
+
+ return await saveWholeScreenshot(activeTab.url);
+ }
+ else if (request.name === 'save-whole-page') {
+ return await saveWholePage();
+ }
+ else if (request.name === 'save-link-with-note') {
+ return await saveLinkWithNote(request.title, request.content);
+ }
+ else if (request.name === 'save-tabs') {
+ return await saveTabs();
+ }
+ else if (request.name === 'trigger-trilium-search') {
+ triliumServerFacade.triggerSearchForTrilium();
+ }
+ else if (request.name === 'send-trilium-search-status') {
+ triliumServerFacade.sendTriliumSearchStatusToPopup();
+ }
+ else if (request.name === 'trigger-trilium-search-note-url') {
+ const activeTab = await getActiveTab();
+ if (activeTab.url) {
+ triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
+ }
+ }
+ });
+});
diff --git a/apps/web-clipper/entrypoints/background/trilium_server_facade.ts b/apps/web-clipper/entrypoints/background/trilium_server_facade.ts
new file mode 100644
index 000000000..da553cec2
--- /dev/null
+++ b/apps/web-clipper/entrypoints/background/trilium_server_facade.ts
@@ -0,0 +1,245 @@
+const PROTOCOL_VERSION_MAJOR = 1;
+
+type TriliumSearchStatus = {
+ status: "searching";
+} | {
+ status: "not-found"
+} | {
+ status: "found-desktop",
+ port: number;
+ url: string;
+} | {
+ status: "found-server",
+ url: string;
+ token: string;
+} | {
+ status: "version-mismatch";
+ extensionMajor: number;
+ triliumMajor: number;
+};
+
+type TriliumSearchNoteStatus = {
+ status: "not-found",
+ noteId: null
+} | {
+ status: "found",
+ noteId: string
+};
+
+export default class TriliumServerFacade {
+ private triliumSearch?: TriliumSearchStatus;
+ private triliumSearchNote?: TriliumSearchNoteStatus;
+
+ constructor() {
+ this.triggerSearchForTrilium();
+
+ // continually scan for changes (if e.g. desktop app is started after browser)
+ setInterval(() => this.triggerSearchForTrilium(), 60 * 1000);
+ }
+
+ async sendTriliumSearchStatusToPopup() {
+ try {
+ await browser.runtime.sendMessage({
+ name: "trilium-search-status",
+ triliumSearch: this.triliumSearch
+ });
+ }
+ catch (e) {} // nothing might be listening
+ }
+ async sendTriliumSearchNoteToPopup(){
+ try{
+ await browser.runtime.sendMessage({
+ name: "trilium-previously-visited",
+ searchNote: this.triliumSearchNote
+ });
+
+ }
+ catch (e) {} // nothing might be listening
+ }
+
+ setTriliumSearchNote(st: TriliumSearchNoteStatus){
+ this.triliumSearchNote = st;
+ this.sendTriliumSearchNoteToPopup();
+ }
+
+ setTriliumSearch(ts: TriliumSearchStatus) {
+ this.triliumSearch = ts;
+
+ this.sendTriliumSearchStatusToPopup();
+ }
+
+ setTriliumSearchWithVersionCheck(json: { protocolVersion: string }, resp: TriliumSearchStatus) {
+ const [ major ] = json.protocolVersion
+ .split(".")
+ .map(chunk => parseInt(chunk, 10));
+
+ // minor version is intended to be used to dynamically limit features provided by extension
+ // if some specific Trilium API is not supported. So far not needed.
+
+ if (major !== PROTOCOL_VERSION_MAJOR) {
+ this.setTriliumSearch({
+ status: 'version-mismatch',
+ extensionMajor: PROTOCOL_VERSION_MAJOR,
+ triliumMajor: major
+ });
+ }
+ else {
+ this.setTriliumSearch(resp);
+ }
+ }
+
+ async triggerSearchForTrilium() {
+ this.setTriliumSearch({ status: 'searching' });
+
+ try {
+ const port = await this.getPort();
+
+ console.debug(`Trying port ${port}`);
+
+ const resp = await fetch(`http://127.0.0.1:${port}/api/clipper/handshake`);
+
+ const text = await resp.text();
+
+ console.log("Received response:", text);
+
+ const json = JSON.parse(text);
+
+ if (json.appName === 'trilium') {
+ this.setTriliumSearchWithVersionCheck(json, {
+ status: 'found-desktop',
+ port,
+ url: `http://127.0.0.1:${port}`
+ });
+
+ return;
+ }
+ }
+ catch (error) {
+ // continue
+ }
+
+ const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
+ const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
+
+ if (triliumServerUrl && authToken) {
+ try {
+ const resp = await fetch(`${triliumServerUrl }/api/clipper/handshake`, {
+ headers: {
+ Authorization: authToken
+ }
+ });
+
+ const text = await resp.text();
+
+ console.log("Received response:", text);
+
+ const json = JSON.parse(text);
+
+ if (json.appName === 'trilium') {
+ this.setTriliumSearchWithVersionCheck(json, {
+ status: 'found-server',
+ url: triliumServerUrl,
+ token: authToken
+ });
+
+ return;
+ }
+ }
+ catch (e) {
+ console.log("Request to the configured server instance failed with:", e);
+ }
+ }
+
+ // if all above fails it's not found
+ this.setTriliumSearch({ status: 'not-found' });
+ }
+
+ async triggerSearchNoteByUrl(noteUrl: string) {
+ const resp = await this.callService('GET', `notes-by-url/${encodeURIComponent(noteUrl)}`);
+ let newStatus: TriliumSearchNoteStatus;
+ if (resp && resp.noteId) {
+ newStatus = {
+ status: 'found',
+ noteId: resp.noteId,
+ };
+ } else {
+ newStatus = {
+ status: 'not-found',
+ noteId: null
+ };
+ }
+ this.setTriliumSearchNote(newStatus);
+ }
+ async waitForTriliumSearch() {
+ return new Promise((res, rej) => {
+ const checkStatus = () => {
+ if (this.triliumSearch?.status === "searching") {
+ setTimeout(checkStatus, 500);
+ } else if (this.triliumSearch?.status === 'not-found') {
+ rej(new Error("Trilium instance has not been found."));
+ } else {
+ res();
+ }
+ };
+
+ checkStatus();
+ });
+ }
+
+ async getPort() {
+ const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
+
+ if (triliumDesktopPort) {
+ return parseInt(triliumDesktopPort, 10);
+ }
+
+ return import.meta.env.DEV ? 37742 : 37840;
+ }
+
+ async callService(method: string, path: string, body?: string | object) {
+ await this.waitForTriliumSearch();
+ if (!this.triliumSearch || (this.triliumSearch.status !== 'found-desktop' && this.triliumSearch.status !== 'found-server')) return;
+
+ try {
+ const fetchOptions: RequestInit = {
+ method,
+ headers: {
+ Authorization: "token" in this.triliumSearch ? this.triliumSearch.token ?? "" : "",
+ 'Content-Type': 'application/json',
+ 'trilium-local-now-datetime': this.localNowDateTime()
+ },
+ };
+
+ if (body) {
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
+ }
+
+ const url = `${this.triliumSearch.url}/api/clipper/${path}`;
+
+ console.log(`Sending ${method} request to ${url}`);
+
+ const response = await fetch(url, fetchOptions);
+
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+
+ return await response.json();
+ }
+ catch (e) {
+ console.log("Sending request to trilium failed", e);
+
+ return null;
+ }
+ }
+
+ localNowDateTime() {
+ const date = new Date();
+ const off = date.getTimezoneOffset();
+ const absoff = Math.abs(off);
+ return (`${new Date(date.getTime() - off * 60 * 1000).toISOString().substr(0,23).replace("T", " ") +
+ (off > 0 ? '-' : '+') +
+ (absoff / 60).toFixed(0).padStart(2,'0') }:${
+ (absoff % 60).toString().padStart(2,'0')}`);
+ }
+}
diff --git a/apps/web-clipper/entrypoints/content/index.ts b/apps/web-clipper/entrypoints/content/index.ts
new file mode 100644
index 000000000..d35f3d897
--- /dev/null
+++ b/apps/web-clipper/entrypoints/content/index.ts
@@ -0,0 +1,349 @@
+import Readability from "@/lib/Readability.js";
+import { createLink, getBaseUrl, getPageLocationOrigin, randomString, Rect } from "@/utils.js";
+
+export default defineContentScript({
+ matches: [
+ ""
+ ],
+ main: () => {
+ function absoluteUrl(url: string | undefined) {
+ if (!url) {
+ return url;
+ }
+
+ const protocol = url.toLowerCase().split(':')[0];
+ if (['http', 'https', 'file'].indexOf(protocol) >= 0) {
+ return url;
+ }
+
+ if (url.indexOf('//') === 0) {
+ return location.protocol + url;
+ } else if (url[0] === '/') {
+ return `${location.protocol}//${location.host}${url}`;
+ }
+ return `${getBaseUrl()}/${url}`;
+
+ }
+
+ function pageTitle() {
+ const titleElements = document.getElementsByTagName("title");
+
+ return titleElements.length ? titleElements[0].text.trim() : document.title.trim();
+ }
+
+ function getReadableDocument() {
+ // Readability directly change the passed document, so clone to preserve the original web page.
+ const documentCopy = document.cloneNode(true);
+ const readability = new Readability(documentCopy, {
+ serializer: el => el // so that .content is returned as DOM element instead of HTML
+ });
+
+ const article = readability.parse();
+
+ if (!article) {
+ throw new Error('Could not parse HTML document with Readability');
+ }
+
+ return {
+ title: article.title,
+ body: article.content,
+ };
+ }
+
+ function getDocumentDates() {
+ let publishedDate: Date | null = null;
+ let modifiedDate: Date | null = null;
+
+ const articlePublishedTime = document.querySelector("meta[property='article:published_time']")?.getAttribute('content');
+ if (articlePublishedTime) {
+ publishedDate = new Date(articlePublishedTime);
+ }
+
+ const articleModifiedTime = document.querySelector("meta[property='article:modified_time']")?.getAttribute('content');
+ if (articleModifiedTime) {
+ modifiedDate = new Date(articleModifiedTime);
+ }
+
+ // TODO: if we didn't get dates from meta, then try to get them from JSON-LD
+ return { publishedDate, modifiedDate };
+ }
+
+ function getRectangleArea() {
+ return new Promise((resolve) => {
+ const overlay = document.createElement('div');
+ overlay.style.opacity = '0.6';
+ overlay.style.background = 'black';
+ overlay.style.width = '100%';
+ overlay.style.height = '100%';
+ overlay.style.zIndex = "99999999";
+ overlay.style.top = "0";
+ overlay.style.left = "0";
+ overlay.style.position = 'fixed';
+
+ document.body.appendChild(overlay);
+
+ const messageComp = document.createElement('div');
+
+ const messageCompWidth = 300;
+ messageComp.setAttribute("tabindex", "0"); // so that it can be focused
+ messageComp.style.position = 'fixed';
+ messageComp.style.opacity = '0.95';
+ messageComp.style.fontSize = '14px';
+ messageComp.style.width = `${messageCompWidth}px`;
+ messageComp.style.maxWidth = `${messageCompWidth}px`;
+ messageComp.style.border = '1px solid black';
+ messageComp.style.background = 'white';
+ messageComp.style.color = 'black';
+ messageComp.style.top = '10px';
+ messageComp.style.textAlign = 'center';
+ messageComp.style.padding = '10px';
+ messageComp.style.left = `${Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) }px`;
+ messageComp.style.zIndex = overlay.style.zIndex + 1;
+
+ messageComp.textContent = 'Drag and release to capture a screenshot';
+
+ document.body.appendChild(messageComp);
+
+ const selection = document.createElement('div');
+ selection.style.opacity = '0.5';
+ selection.style.border = '1px solid red';
+ selection.style.background = 'white';
+ selection.style.border = '2px solid black';
+ selection.style.zIndex = String(parseInt(overlay.style.zIndex, 10) - 1);
+ selection.style.top = "0";
+ selection.style.left = "0";
+ selection.style.position = 'fixed';
+
+ document.body.appendChild(selection);
+
+ messageComp.focus(); // we listen on keypresses on this element to cancel on escape
+
+ let isDragging = false;
+ let draggingStartPos: {x: number, y: number} | null = null;
+ let selectionArea: Rect;
+
+ function updateSelection() {
+ selection.style.left = `${selectionArea.x}px`;
+ selection.style.top = `${selectionArea.y}px`;
+ selection.style.width = `${selectionArea.width}px`;
+ selection.style.height = `${selectionArea.height}px`;
+ }
+
+ function setSelectionSizeFromMouse(event: MouseEvent) {
+ if (!draggingStartPos) return;
+
+ if (event.clientX < draggingStartPos.x) {
+ selectionArea.x = event.clientX;
+ }
+
+ if (event.clientY < draggingStartPos.y) {
+ selectionArea.y = event.clientY;
+ }
+
+ selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x));
+ selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y));
+ updateSelection();
+ }
+
+ function selection_mouseDown(event: MouseEvent) {
+ selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0};
+ draggingStartPos = {x: event.clientX, y: event.clientY};
+ isDragging = true;
+ updateSelection();
+ }
+
+ function selection_mouseMove(event: MouseEvent) {
+ if (!isDragging) return;
+ setSelectionSizeFromMouse(event);
+ }
+
+ function removeOverlay() {
+ isDragging = false;
+
+ overlay.removeEventListener('mousedown', selection_mouseDown);
+ overlay.removeEventListener('mousemove', selection_mouseMove);
+ overlay.removeEventListener('mouseup', selection_mouseUp);
+
+ document.body.removeChild(overlay);
+ document.body.removeChild(selection);
+ document.body.removeChild(messageComp);
+ }
+
+ function selection_mouseUp(event: MouseEvent) {
+ setSelectionSizeFromMouse(event);
+
+ removeOverlay();
+
+ console.info('selectionArea:', selectionArea);
+
+ if (!selectionArea || !selectionArea.width || !selectionArea.height) {
+ return;
+ }
+
+ // Need to wait a bit before taking the screenshot to make sure
+ // the overlays have been removed and don't appear in the
+ // screenshot. 10ms is not enough.
+ setTimeout(() => resolve(selectionArea), 100);
+ }
+
+ function cancel(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ removeOverlay();
+ }
+ }
+
+ overlay.addEventListener('mousedown', selection_mouseDown);
+ overlay.addEventListener('mousemove', selection_mouseMove);
+ overlay.addEventListener('mouseup', selection_mouseUp);
+ messageComp.addEventListener('keydown', cancel);
+ });
+ }
+
+ function makeLinksAbsolute(container: HTMLElement) {
+ for (const link of container.getElementsByTagName('a')) {
+ if (link.href) {
+ const newUrl = absoluteUrl(link.href);
+ if (!newUrl) continue;
+ link.href = newUrl;
+ }
+ }
+ }
+
+ function getImages(container: HTMLElement) {
+ const images: {imageId: string, src: string}[] = [];
+
+ for (const img of container.getElementsByTagName('img')) {
+ if (!img.src) {
+ continue;
+ }
+
+ const existingImage = images.find(image => image.src === img.src);
+
+ if (existingImage) {
+ img.src = existingImage.imageId;
+ }
+ else {
+ const imageId = randomString(20);
+
+ images.push({
+ imageId,
+ src: img.src
+ });
+
+ img.src = imageId;
+ }
+ }
+
+ return images;
+ }
+
+ async function prepareMessageResponse(message: {name: string, noteId?: string, message?: string, tabIds?: string[]}) {
+ console.info(`Message: ${ message.name}`);
+
+ if (message.name === "toast") {
+ let messageText;
+
+ if (message.noteId) {
+ messageText = document.createElement('p');
+ messageText.setAttribute("style", "padding: 0; margin: 0; font-size: larger;");
+ messageText.appendChild(document.createTextNode(`${message.message } `));
+ messageText.appendChild(createLink(
+ {name: 'openNoteInTrilium', noteId: message.noteId},
+ "Open in Trilium."
+ ));
+
+ // only after saving tabs
+ if (message.tabIds) {
+ messageText.appendChild(document.createElement("br"));
+ messageText.appendChild(createLink(
+ {name: 'closeTabs', tabIds: message.tabIds},
+ "Close saved tabs.",
+ "tomato"
+ ));
+ }
+ }
+ else {
+ messageText = message.message;
+ }
+
+ await import("@/lib/toast");
+
+ window.showToast(messageText, {
+ settings: {
+ duration: 7000
+ }
+ });
+ }
+ else if (message.name === "trilium-save-selection") {
+ const container = document.createElement('div');
+
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ throw new Error('No selection available to clip');
+ }
+
+ for (let i = 0; i < selection.rangeCount; i++) {
+ const range = selection.getRangeAt(i);
+
+ container.appendChild(range.cloneContents());
+ }
+
+ makeLinksAbsolute(container);
+
+ const images = getImages(container);
+
+ return {
+ title: pageTitle(),
+ content: container.innerHTML,
+ images,
+ pageUrl: getPageLocationOrigin() + location.pathname + location.search + location.hash
+ };
+
+ }
+ else if (message.name === 'trilium-get-rectangle-for-screenshot') {
+ return {
+ rect: await getRectangleArea(),
+ devicePixelRatio: window.devicePixelRatio
+ };
+ }
+ else if (message.name === "trilium-save-page") {
+ const {title, body} = getReadableDocument();
+
+ makeLinksAbsolute(body);
+
+ const images = getImages(body);
+
+ const labels = {};
+ const dates = getDocumentDates();
+ if (dates.publishedDate) {
+ labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10);
+ }
+ if (dates.modifiedDate) {
+ labels['modifiedDate'] = dates.modifiedDate.toISOString().substring(0, 10);
+ }
+
+ return {
+ title,
+ content: body.innerHTML,
+ images,
+ pageUrl: getPageLocationOrigin() + location.pathname + location.search,
+ clipType: 'page',
+ labels
+ };
+ }
+ else {
+ throw new Error(`Unknown command: ${ JSON.stringify(message)}`);
+ }
+ }
+
+ browser.runtime.onMessage.addListener(async (message) => {
+ try {
+ const response = await prepareMessageResponse(message);
+ return response;
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+ });
+ }
+});
diff --git a/apps/web-clipper/entrypoints/offscreen/index.html b/apps/web-clipper/entrypoints/offscreen/index.html
new file mode 100644
index 000000000..232bb6390
--- /dev/null
+++ b/apps/web-clipper/entrypoints/offscreen/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/web-clipper/entrypoints/offscreen/index.ts b/apps/web-clipper/entrypoints/offscreen/index.ts
new file mode 100644
index 000000000..6c63f5f4c
--- /dev/null
+++ b/apps/web-clipper/entrypoints/offscreen/index.ts
@@ -0,0 +1,24 @@
+browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (message.type === 'CROP_IMAGE') {
+ cropImage(message.cropRect, message.dataUrl).then(sendResponse);
+ return true; // Keep channel open for async response
+ }
+});
+
+function cropImage(newArea: { x: number, y: number, width: number, height: number }, dataUrl: string) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = newArea.width;
+ canvas.height = newArea.height;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height,
+ 0, 0, newArea.width, newArea.height);
+ }
+ resolve(canvas.toDataURL());
+ };
+ img.src = dataUrl;
+ });
+}
diff --git a/apps/web-clipper/options/options.html b/apps/web-clipper/entrypoints/options/index.html
similarity index 94%
rename from apps/web-clipper/options/options.html
rename to apps/web-clipper/entrypoints/options/index.html
index 2363567a5..331a36a02 100644
--- a/apps/web-clipper/options/options.html
+++ b/apps/web-clipper/entrypoints/options/index.html
@@ -54,9 +54,8 @@
Note that the entered password is not stored anywhere, it will be only used to retrieve an authorization token from the server instance which will be then used to send the clipped notes.
-
-
-
+
+