Recently Written · git

sbm-extension

sbm bookmarks for Firefox and Chrome: fuzzy search, add the page, sync with bm and the sbm app

git clone https://github.com/equwal/sbm-extension

Log | Files | Refs


commit c915c68005c860f647bd2f96c4b36ea53dd4838a
equwal <truex@equwal.com>
2026-09-22 19:36:27 -0700

Popup: a live preview of the highlighted page

The popup shows the page of the highlighted bookmark in a frame beside
the list. The arrow keys and the mouse move the highlight, and Enter
opens the highlighted bookmark. The frame loads a page 300 ms after the
highlight stops on it, so fast typing loads one page only. An http page
loads with https, because a secure page cannot show an insecure frame.

The frame is sandboxed (scripts, but no top navigation, popups or forms)
and sends no referrer. A page in the frame cannot take the focus from
the search. The options can turn the preview off. On Android the
preview goes under the search.

 README.md            |  13 +++++--
 lib/preview.js       |  31 +++++++++++++++
 options.html         |   7 ++++
 options.js           |   6 +++
 popup.html           |   6 +++
 popup.js             | 105 ++++++++++++++++++++++++++++++++++++++++++++++-----
 store/listing.md     |   3 +-
 style.css            |  16 ++++++++
 test/preview.test.js |  56 +++++++++++++++++++++++++++
 9 files changed, 230 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 4ad4d18..0477313 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,12 @@ Your [sbm](https://github.com/equwal/sbm) bookmarks in the browser.
 ([MP4](https://github.com/equwal/sbm-sync/raw/master/demo/sbm-demo.mp4), 80 seconds.)
 
 - **Search as you type** in the popup (Alt+Shift+M), with the fuzzy match of
-  fzf. Enter opens the first match. Text that is no bookmark opens as an
-  address, or as a web search, as in bm.
+  fzf. Enter opens the highlighted match, first the top one. Text that is no
+  bookmark opens as an address, or as a web search, as in bm.
+- **Live preview:** the page of the highlighted bookmark shows beside the
+  list while you search. The arrow keys and the mouse move the highlight.
+  An http page shows with https. Some sites do not let other pages show
+  them, so their preview stays empty. Turn the preview off in the options.
 - **Address bar:** type `bm`, a space, then words.
 - **Add this page** with a description and tags.
 - **Sync** with bm on your computers, the
@@ -50,13 +54,16 @@ your email address and your password to the server. The
 [privacy policy](https://sbmsync.com/privacy) tells what the server
 keeps.
 
+The live preview needs no permission. It loads the page from its site, as
+a visit does, without a referrer. It sends nothing to the sync server.
+
 ## Develop
 
 The add-on needs no build: load this directory as an unpacked extension
 (Chrome: chrome://extensions, developer mode; Firefox: about:debugging).
 
     npm install
-    npm test          # property tests of the file format and the search
+    npm test          # property tests of the file format, the search and the preview
     npm run lint      # the checks of addons.mozilla.org, on the Firefox package
     npm run build     # the zip for the Chrome Web Store, in web-ext-artifacts/
     npm run build:firefox  # the zip for addons.mozilla.org
diff --git a/lib/preview.js b/lib/preview.js
new file mode 100644
index 0000000..68241dc
--- /dev/null
+++ b/lib/preview.js
@@ -0,0 +1,31 @@
+// The live preview of the popup: the page of the highlighted bookmark shows
+// in a frame beside the list. This file has no browser code, so the tests
+// run in node.
+"use strict";
+
+const Preview = {
+  /**
+   * The address that the frame loads for the URL of a bookmark, or null when
+   * the page cannot show in a frame. Only web pages can show. An http:// page
+   * loads as https://, because the popup is a secure page, and a secure page
+   * cannot show an insecure frame.
+   */
+  address(url) {
+    let u;
+    try {
+      u = new URL(url);
+    } catch (e) {
+      return null;
+    }
+    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
+    u.protocol = "https:";
+    return u.href;
+  },
+
+  /** The row that is highlighted after a move of step rows from row i, in a list of n rows. */
+  move(i, step, n) {
+    return Math.max(0, Math.min(n - 1, i + step));
+  },
+};
+
+if (typeof module !== "undefined") module.exports = Preview;
diff --git a/options.html b/options.html
index 64ce1cb..8723c2f 100644
--- a/options.html
+++ b/options.html
@@ -37,6 +37,13 @@ other browsers.</p>
   <p class="note"><a id="account" href="https://sbmsync.com/account" target="_blank" rel="noopener">Your account</a></p>
 </section>
 
+<section>
+  <h2>Preview</h2>
+  <label class="check"><input id="preview" type="checkbox"> Show the page of the highlighted bookmark in the popup</label>
+  <p class="note">The preview loads the page from its site, as a visit does. Some
+  sites do not let other pages show them, so their preview stays empty.</p>
+</section>
+
 <p id="message" role="status"></p>
 
 <footer>
diff --git a/options.js b/options.js
index 0220df8..d8e5d3f 100644
--- a/options.js
+++ b/options.js
@@ -150,4 +150,10 @@ $("sign-out").addEventListener("click", async () => {
   show();
 });
 
+// The preview in the popup is on until the user turns it off.
+api.storage.local.get("preview").then((s) => {
+  $("preview").checked = s.preview !== false;
+});
+$("preview").addEventListener("change", () => api.storage.local.set({ preview: $("preview").checked }));
+
 show();
diff --git a/popup.html b/popup.html
index 1c570a7..bc83da7 100644
--- a/popup.html
+++ b/popup.html
@@ -23,8 +23,14 @@
   <span id="status" class="note"></span>
   <nav><a href="#" id="options">Options</a> ยท <a href="https://ko-fi.com/truex" target="_blank" rel="noopener">Support sbm on Ko-fi</a></nav>
 </footer>
+
+<section id="preview" hidden>
+  <p id="caption" class="note">Type a search, or point at a bookmark, to see its page here.</p>
+  <iframe id="frame" title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
+</section>
 <script src="lib/tsv.js"></script>
 <script src="lib/fuzzy.js"></script>
+<script src="lib/preview.js"></script>
 <script src="popup.js"></script>
 </body>
 </html>
diff --git a/popup.js b/popup.js
index de33da9..1ddf98b 100644
--- a/popup.js
+++ b/popup.js
@@ -1,11 +1,21 @@
 // The popup: search as you type, Enter or a click opens, "Add this page" adds.
+// The page of the highlighted bookmark shows live in a preview beside the list.
 "use strict";
 
 const api = globalThis.browser ?? globalThis.chrome;
 const $ = (id) => document.getElementById(id);
 const MAX_ROWS = 50;
+// The preview loads a page only after the highlight stays on it this long
+// (ms), so that fast typing does not load a page for each letter.
+const PREVIEW_WAIT = 300;
 let all = []; // newest first, as bm adds at the end
 let shown = [];
+let sel = 0; // the highlighted row: Enter opens it
+let preview = true; // the options can turn the preview off
+let started = false; // the preview starts when the user types or points at a bookmark
+let framed = null; // the address in the frame
+let timer;
+let pointer = ""; // where the mouse was last
 
 function send(msg) {
   return api.runtime.sendMessage(msg).then((r) => {
@@ -29,11 +39,12 @@ function showStatus(s) {
   $("status").textContent = text;
 }
 
-function render() {
+/** Show the bookmarks that match the search. The highlight goes to the row of keep, or else to the first row. */
+function render(keep) {
   shown = Fuzzy.filter(all, $("search").value, (b) => b.desc + " " + b.tags.join(" ") + " " + b.url);
   const list = $("list");
   list.replaceChildren();
-  for (const b of shown.slice(0, MAX_ROWS)) {
+  shown.slice(0, MAX_ROWS).forEach((b, i) => {
     const li = document.createElement("li");
     const a = document.createElement("a");
     a.href = b.url;
@@ -45,19 +56,76 @@ function render() {
       e.preventDefault();
       open(b.url, e.ctrlKey || e.metaKey || e.button === 1);
     });
+    a.addEventListener("mousemove", (e) => {
+      // The browser also sends mousemove when the list moves under a mouse
+      // that stays still. Only a move of the mouse moves the highlight.
+      const at = e.screenX + "," + e.screenY;
+      if (at === pointer) return;
+      pointer = at;
+      point(i);
+    });
+    a.addEventListener("focus", () => point(i));
     li.append(a);
     list.append(li);
-  }
+  });
   const empty = $("empty");
   empty.hidden = shown.length > 0;
   empty.textContent = all.length === 0 ? "No bookmarks yet. Sign in to sync in the options, or add this page."
     : "No bookmark matches. Press Enter to search the web.";
+  const again = keep ? shown.findIndex((b) => b.url === keep.url) : -1;
+  highlight(again >= 0 && again < MAX_ROWS ? again : 0);
+}
+
+/** The user points at row i: with the mouse, the Tab key or the arrow keys. */
+function point(i) {
+  started = true;
+  highlight(i);
+}
+
+/** Highlight row i, and show its page in the preview. */
+function highlight(i) {
+  const rows = $("list").children;
+  sel = Preview.move(i, 0, rows.length);
+  for (let j = 0; j < rows.length; j++) rows[j].classList.toggle("sel", j === sel);
+  if (started) showPage(rows.length > 0 ? shown[sel] : undefined);
+}
+
+/** Show the page of bookmark b in the preview, when the highlight stays on it. */
+function showPage(b) {
+  clearTimeout(timer);
+  if (!preview) return;
+  const address = b ? Preview.address(b.url) : null;
+  $("caption").textContent = !b ? "No bookmark to preview."
+    : address ? address : "No preview: " + b.url + " is not a web page.";
+  if (address) timer = setTimeout(() => frame(address), PREVIEW_WAIT);
+  else frame(null);
+}
+
+/** Load address in the frame, or an empty page for null. */
+function frame(address) {
+  if (address === framed) return;
+  framed = address;
+  $("frame").src = address || "about:blank";
+}
+
+/**
+ * Show or hide the preview. On a computer it goes beside the list. On
+ * Android the popup fills the narrow screen, so it goes under the search.
+ */
+function showPreview(on) {
+  const android = /Android/.test(navigator.userAgent);
+  $("preview").hidden = !on;
+  document.body.classList.toggle("wide", on && !android);
+  document.body.classList.toggle("stack", on && android);
+  if (!on) frame(null);
 }
 
 async function load() {
-  const s = await api.storage.local.get(["text", "token", "synced", "error"]);
+  const s = await api.storage.local.get(["text", "token", "synced", "error", "preview"]);
   all = Tsv.parse(s.text || "").reverse();
-  render();
+  preview = s.preview !== false;
+  showPreview(preview && $("add").hidden);
+  render(shown[sel]);
   showStatus(s);
   return s;
 }
@@ -69,24 +137,43 @@ function open(url, newTab) {
   window.close();
 }
 
-$("search").addEventListener("input", render);
+$("search").addEventListener("input", () => {
+  started = true;
+  render();
+});
 $("search").addEventListener("keydown", (e) => {
+  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+    e.preventDefault();
+    point(sel + (e.key === "ArrowDown" ? 1 : -1));
+    const row = $("list").children[sel];
+    if (row) row.scrollIntoView({ block: "nearest" });
+    return;
+  }
   if (e.key !== "Enter") return;
   const text = $("search").value;
-  if (shown.length > 0) open(shown[0].url, e.ctrlKey || e.metaKey);
+  if (shown.length > 0) open(shown[sel].url, e.ctrlKey || e.metaKey);
   else if (text.trim() !== "") open(Tsv.target(text), e.ctrlKey || e.metaKey);
 });
 
+// A page in the preview can take the focus with a script, and then the
+// typing goes into that page. The preview is only to look at, so the focus
+// goes back to the search at once. The mouse wheel and clicks on links still
+// work in the preview.
+window.addEventListener("blur", () => setTimeout(() => {
+  if (document.activeElement === $("frame") && !$("search").hidden) $("search").focus();
+}));
+
 $("options").addEventListener("click", (e) => {
   e.preventDefault();
   api.runtime.openOptionsPage();
   window.close();
 });
 
-/** The form takes the place of the list while it is open. */
+/** The form takes the place of the list and the preview while it is open. */
 function showForm(on) {
   for (const id of ["search", "list", "add-page"]) $(id).hidden = on;
   $("add").hidden = !on;
+  showPreview(preview && !on);
   if (on) $("empty").hidden = true;
   else render();
 }
@@ -122,7 +209,7 @@ $("add").addEventListener("submit", async (e) => {
 });
 
 api.storage.onChanged.addListener((changes, area) => {
-  if (area === "local" && (changes.text || changes.synced || changes.error)) load();
+  if (area === "local" && (changes.text || changes.synced || changes.error || changes.preview)) load();
 });
 
 load().then((s) => {
diff --git a/store/listing.md b/store/listing.md
index a4a8e0c..21899af 100644
--- a/store/listing.md
+++ b/store/listing.md
@@ -21,7 +21,8 @@ Your sbm bookmarks in the browser: search as you type, add the page, sync with b
 
 sbm keeps your bookmarks in one plain text file: one bookmark per line, with the address, a description and tags. This add-on brings that file to your browser.
 
-- Search as you type: press Alt+Shift+M or click the icon. The search is fuzzy, as in fzf. Enter opens the first match. Text that is no bookmark opens as an address, or as a web search.
+- Search as you type: press Alt+Shift+M or click the icon. The search is fuzzy, as in fzf. Enter opens the highlighted match. Text that is no bookmark opens as an address, or as a web search.
+- Live preview: the page of the highlighted bookmark shows beside the list while you search. Move the highlight with the arrow keys or the mouse. Some sites do not let other pages show them. Turn the preview off in the options.
 - In the address bar, type bm, a space, then words.
 - Add the page that you look at, with a description and tags.
 - Sync with bm on your computers, the sbm app for Android and your other browsers, through an sbm-sync server. The server is free software: use the default one, sbmsync.com, or run your own. Sync on sbmsync.com is free.
diff --git a/style.css b/style.css
index 50adfe8..ff06b2f 100644
--- a/style.css
+++ b/style.css
@@ -7,15 +7,31 @@ a { color: var(--accent); }
 input { font: inherit; box-sizing: border-box; width: 100%; padding: 6px; margin: 2px 0 8px; }
 button { font: inherit; padding: 4px 12px; }
 label { display: block; }
+label.check input { width: auto; margin: 0 6px 0 0; }
 
 .popup { width: 380px; padding: 8px; }
 .popup #list { list-style: none; margin: 0; padding: 0; max-height: 380px; overflow-y: auto; }
 .popup #list a { display: block; padding: 6px 4px; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit; }
 .popup #list a:hover, .popup #list a:focus { background: color-mix(in srgb, var(--accent) 15%, transparent); }
 .popup #list small { color: var(--muted); }
+.popup #list .sel a { background: color-mix(in srgb, var(--accent) 15%, transparent); }
 .popup footer { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
 .popup nav { font-size: 12px; }
 
+/* The preview of the highlighted page: beside the list on a computer, under
+   the search on Android. The frame is white, as a browser shows a page. */
+.popup #preview[hidden] { display: none; }
+.popup #caption { margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.popup #frame { flex: 1; width: 100%; box-sizing: border-box; border: 1px solid var(--line); background: #fff; }
+.popup.wide { width: 780px; display: grid; grid-template-columns: 380px minmax(0, 1fr); column-gap: 12px; align-items: start; }
+.popup.wide > * { grid-column: 1; }
+.popup.wide #preview { grid-column: 2; grid-row: 1 / span 5; align-self: stretch; display: flex; flex-direction: column; }
+.popup.wide #frame { min-height: 460px; }
+.popup.stack { width: auto; display: flex; flex-direction: column; }
+.popup.stack #search { order: -2; }
+.popup.stack #preview { order: -1; margin-bottom: 8px; }
+.popup.stack #frame { height: 50vh; }
+
 .options { max-width: 36em; margin: 2em auto; padding: 0 1em; font-size: 15px; }
 .options h1 { color: var(--accent); }
 .options section { margin: 1.5em 0; }
diff --git a/test/preview.test.js b/test/preview.test.js
new file mode 100644
index 0000000..a469c9a
--- /dev/null
+++ b/test/preview.test.js
@@ -0,0 +1,56 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fc = require("fast-check");
+const Preview = require("../lib/preview.js");
+
+const https = fc.webUrl({ validSchemes: ["https"], withQueryParameters: true, withFragments: true });
+const http = fc.webUrl({ validSchemes: ["http"], withQueryParameters: true, withFragments: true });
+
+test("an https page shows at its own address", () => {
+  fc.assert(fc.property(https, (u) => {
+    assert.equal(Preview.address(u), new URL(u).href);
+  }), { numRuns: 1000 });
+});
+
+test("an http page shows at the same address with https", () => {
+  fc.assert(fc.property(http, (u) => {
+    const shown = new URL(Preview.address(u));
+    const asked = new URL(u);
+    assert.equal(shown.protocol, "https:");
+    assert.equal(shown.host, asked.host);
+    assert.equal(shown.pathname + shown.search + shown.hash, asked.pathname + asked.search + asked.hash);
+  }), { numRuns: 1000 });
+});
+
+test("the frame only ever loads https addresses", () => {
+  fc.assert(fc.property(fc.oneof(https, http, fc.string(), fc.webUrl({ validSchemes: ["ftp", "file", "data"] })), (u) => {
+    const a = Preview.address(u);
+    if (a !== null) {
+      assert.equal(new URL(a).protocol, "https:");
+      assert.equal(Preview.address(a), a);
+    }
+  }), { numRuns: 2000 });
+});
+
+test("addresses that are no web page have no preview", () => {
+  for (const u of ["javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,<b>x</b>", "file:///etc/passwd",
+    "ftp://example.org/", "about:blank", "chrome://settings", "example.org", "", "https://", "http://"]) {
+    assert.equal(Preview.address(u), null, u);
+  }
+});
+
+test("the highlight stays on a row of the list", () => {
+  fc.assert(fc.property(fc.integer({ min: 1, max: 60 }), fc.integer({ min: -5, max: 70 }), fc.integer({ min: -100, max: 100 }),
+    (n, i, step) => {
+      const j = Preview.move(i, step, n);
+      assert.ok(j >= 0 && j < n, `${i} + ${step} in ${n} rows gave ${j}`);
+    }), { numRuns: 1000 });
+});
+
+test("a move inside the list goes exactly that many rows", () => {
+  fc.assert(fc.property(fc.integer({ min: 1, max: 60 }).chain((n) => fc.tuple(fc.constant(n), fc.nat(n - 1), fc.nat(n - 1))),
+    ([n, i, j]) => {
+      assert.equal(Preview.move(i, j - i, n), j);
+    }), { numRuns: 1000 });
+});