diff --git a/README.md b/README.md index 2fd1f03..aab4086 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,23 @@ -# RoamJS Extension Base +# Breadcrumbs -Stock base for [RoamJS](https://roamjs.com) Roam Research extensions. **Fork this repo** to start a new extension. + + RoamJS Logo + -## What's included +**Never lose your place in Roam again. Breadcrumbs adds a clickable trail of your recent pages and blocks in the top bar, so +you can jump back instantly and navigate your graph with confidence.** -- **roamjs-components** — shared utilities, DOM helpers, queries, writes, and UI components -- **Samepage build** — `samepage build` produces the Roam Depot–ready bundle -- **Settings panel** — example `extensionAPI.settings.panel.create` with an Enable switch -- **TypeScript** — tsconfig extending `@samepage/scripts` -- **CI** — GitHub Actions to build on push/PR (uses RoamJS secrets for publish) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RoamJS/breadcrumbs) -## After forking +## Features -1. **Rename the repo** and update `package.json`: - - `name`: your extension slug (e.g. `my-extension`) - - `description`: one line describing the extension +- Tracks recently visited pages and block locations +- Renders oldest-to-current breadcrumb trail in the top bar +- Click any non-current breadcrumb to navigate back +- Distinguishes pages vs blocks with different styles +- Supports Blueprint dark mode (`.bp3-dark`) -2. **Implement in `src/index.ts`**: - - Keep or replace the settings panel - - Add your logic using `roamjs-components` (e.g. `createHTMLObserver`, `createBlock`, `renderToast`) - - Return `{ unload }` to clean up on unload +## Settings -3. **Optional**: Add React components under `src/components/` (see [autocomplete](https://github.com/RoamJS/autocomplete), [giphy](https://github.com/RoamJS/giphy) for examples). - -4. **Secrets (for publish)** — in the forked repo, configure: - - `ROAMJS_RELEASE_TOKEN`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - - `AWS_REGION`, `ROAMJS_PROXY` (vars) - -## Scripts - -- `npm start` — samepage dev (local development) -- `npm run build:roam` — build for Roam (dry run; CI runs `npx samepage build`) - -## License - -MIT +- `Max breadcrumbs`: max number of prior locations to keep +- `Truncate length`: max label length before truncation diff --git a/package-lock.json b/package-lock.json index 5f72716..8e8fca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "roamjs-components": "^0.86.4" + }, + "devDependencies": { + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.6.11" } }, "node_modules/@alloc/quick-lru": { @@ -6991,6 +6995,109 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index 3ee5f29..68e064e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", - "description": "Stock RoamJS Roam Research Extension base — fork this repo to start new extensions.", + "description": "Navigation breadcrumbs for Roam Research.", "main": "index.js", "scripts": { "postinstall": "patch-package", @@ -10,7 +10,7 @@ "build:roam": "samepage build --dry", "test": "samepage test" }, - "keywords": ["roam", "roam-research", "roamjs"], + "keywords": ["roam", "roam-research", "roamjs", "breadcrumbs", "navigation"], "license": "MIT", "dependencies": { "roamjs-components": "^0.86.4" diff --git a/src/index.ts b/src/index.ts index 6ffa421..5ed69b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,472 @@ +import { OnloadArgs } from "roamjs-components/types/native"; import runExtension from "roamjs-components/util/runExtension"; +type LocationType = "page" | "block"; + +type Location = { + type: LocationType; + uid: string; +}; + +type BreadcrumbItem = { + uid: string; + type: LocationType; + title: string; +}; + +type ExtensionSettings = { + maxBreadcrumbs: number; + truncateLength: number; +}; + +const DEFAULT_SETTINGS: ExtensionSettings = { + maxBreadcrumbs: 8, + truncateLength: 25, +}; + +const UI_IDS = { + panel: "roam-breadcrumbs-panel", + styles: "roam-breadcrumbs-styles", +} as const; + +const UI_SELECTORS = { + topbar: ".rm-topbar", + content: ".breadcrumbs-content", +} as const; + +let breadcrumbHistory: BreadcrumbItem[] = []; +let currentLocation: Location | null = null; +let hashChangeListener: (() => void) | null = null; +let navigationSeq = 0; + +const parsePositiveInteger = ({ + value, + fallback, +}: { + value: unknown; + fallback: number; +}): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; + +const readSettings = ({ + extensionAPI, +}: { + extensionAPI: OnloadArgs["extensionAPI"]; +}): ExtensionSettings => ({ + maxBreadcrumbs: parsePositiveInteger({ + value: extensionAPI.settings.get("maxBreadcrumbs"), + fallback: DEFAULT_SETTINGS.maxBreadcrumbs, + }), + truncateLength: parsePositiveInteger({ + value: extensionAPI.settings.get("truncateLength"), + fallback: DEFAULT_SETTINGS.truncateLength, + }), +}); + +const getLocationFromHash = (): Location | null => { + const hash = window.location.hash; + + const blockMatch = hash.match(/\/page\/[^?]+\?.*block=([a-zA-Z0-9_-]+)/); + if (blockMatch?.[1]) { + return { type: "block", uid: blockMatch[1] }; + } + + const pageMatch = hash.match(/\/page\/([a-zA-Z0-9_-]+)/); + if (pageMatch?.[1]) { + return { type: "page", uid: pageMatch[1] }; + } + + return null; +}; + +const extractFirstString = ({ result }: { result: unknown }): string | null => { + if (!Array.isArray(result) || !Array.isArray(result[0])) return null; + const firstValue = result[0][0]; + return typeof firstValue === "string" ? firstValue : null; +}; + +const queryPageTitle = async ({ + uid, +}: { + uid: string; +}): Promise => { + const pageResult = await window.roamAlphaAPI.q(` + [:find ?title + :where [?e :block/uid "${uid}"] + [?e :node/title ?title]] + `); + + return extractFirstString({ result: pageResult }); +}; + +const queryBlockString = async ({ + uid, +}: { + uid: string; +}): Promise => { + const blockResult = await window.roamAlphaAPI.q(` + [:find ?string + :where [?e :block/uid "${uid}"] + [?e :block/string ?string]] + `); + + const blockString = extractFirstString({ result: blockResult }); + if (blockString === null) return null; + return blockString || "(empty block)"; +}; + +const getBreadcrumbItemByUid = async ({ + uid, +}: { + uid: string; +}): Promise => { + const pageTitle = await queryPageTitle({ uid }); + if (pageTitle) { + return { + uid, + type: "page", + title: pageTitle, + }; + } + + const blockString = await queryBlockString({ uid }); + if (!blockString) return null; + + return { + uid, + type: "block", + title: blockString, + }; +}; + +const stripMarkdown = ({ text }: { text: string }): string => + text + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/\(\(([^)]+)\)\)/g, "->") + .replace(/```[^`]*```/g, "[code]") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?\]\(.*?\)/g, "[img]") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/{{.*?}}/g, "") + .replace(/#\[\[([^\]]+)\]\]/g, "#$1") + .trim(); + +const truncateText = ({ + text, + maxLength, +}: { + text: string; + maxLength: number; +}): string => { + const cleaned = stripMarkdown({ text }); + if (cleaned.length <= maxLength) return cleaned; + return `${cleaned.slice(0, maxLength - 1)}...`; +}; + +const navigateTo = ({ + uid, + type, +}: { + uid: string; + type: LocationType; +}): void => { + if (type === "page") { + window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + + window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +const createSeparatorElement = (): HTMLSpanElement => { + const separator = document.createElement("span"); + separator.className = "breadcrumb-separator"; + separator.textContent = ">"; + return separator; +}; + +const createBreadcrumbElement = ({ + item, + isCurrent, + truncateLength, +}: { + item: BreadcrumbItem; + isCurrent: boolean; + truncateLength: number; +}): HTMLSpanElement => { + const element = document.createElement("span"); + const typeClass = + item.type === "page" ? "breadcrumb-page" : "breadcrumb-block"; + + element.className = + `breadcrumb-item ${typeClass} ${isCurrent ? "breadcrumb-current" : ""}`.trim(); + element.textContent = truncateText({ + text: item.title, + maxLength: truncateLength, + }); + element.title = stripMarkdown({ text: item.title }); + element.style.maxWidth = `${Math.max(truncateLength, 20)}ch`; + + if (!isCurrent) { + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + navigateTo({ uid: item.uid, type: item.type }); + }); + } + + return element; +}; + +const getOrCreatePanel = (): HTMLElement | null => { + const existingPanel = document.getElementById(UI_IDS.panel); + if (existingPanel instanceof HTMLElement) return existingPanel; + + const topbar = document.querySelector(UI_SELECTORS.topbar); + if (!(topbar instanceof HTMLElement)) return null; + + const panel = document.createElement("div"); + panel.id = UI_IDS.panel; + + const content = document.createElement("div"); + content.className = "breadcrumbs-content"; + panel.appendChild(content); + + const firstChild = topbar.firstChild; + if (firstChild?.nextSibling) { + topbar.insertBefore(panel, firstChild.nextSibling); + return panel; + } + + topbar.appendChild(panel); + return panel; +}; + +const getContentContainer = (): HTMLElement | null => { + const panel = getOrCreatePanel(); + if (!panel) return null; + + const content = panel.querySelector(UI_SELECTORS.content); + return content instanceof HTMLElement ? content : null; +}; + +const renderBreadcrumbs = ({ + truncateLength, +}: { + truncateLength: number; +}): void => { + const content = getContentContainer(); + if (!content) return; + + content.innerHTML = ""; + + if (!breadcrumbHistory.length) { + content.textContent = "No history yet"; + return; + } + + const displayOrder = [...breadcrumbHistory].reverse(); + + displayOrder.forEach((item, index) => { + const isCurrent = index === displayOrder.length - 1; + + if (index > 0) { + content.appendChild(createSeparatorElement()); + } + + content.appendChild( + createBreadcrumbElement({ item, isCurrent, truncateLength }), + ); + }); +}; + +const updateBreadcrumbHistory = ({ + item, + maxBreadcrumbs, +}: { + item: BreadcrumbItem; + maxBreadcrumbs: number; +}): void => { + breadcrumbHistory = breadcrumbHistory.filter( + (historyItem) => historyItem.uid !== item.uid, + ); + breadcrumbHistory.unshift(item); + + const maxEntries = maxBreadcrumbs + 1; + if (breadcrumbHistory.length > maxEntries) { + breadcrumbHistory = breadcrumbHistory.slice(0, maxEntries); + } +}; + +const handleNavigation = async ({ + maxBreadcrumbs, + truncateLength, +}: ExtensionSettings): Promise => { + const mySeq = ++navigationSeq; + const location = getLocationFromHash(); + if (!location) return; + + if (currentLocation?.uid === location.uid) return; + + const breadcrumbItem = await getBreadcrumbItemByUid({ uid: location.uid }); + if (mySeq !== navigationSeq) return; + if (!breadcrumbItem) return; + + updateBreadcrumbHistory({ item: breadcrumbItem, maxBreadcrumbs }); + currentLocation = location; + + renderBreadcrumbs({ truncateLength }); +}; + +const injectStyles = (): void => { + if (document.getElementById(UI_IDS.styles)) return; + + const styles = document.createElement("style"); + styles.id = UI_IDS.styles; + styles.textContent = ` + #${UI_IDS.panel} { + display: flex; + align-items: center; + padding: 0 12px; + flex-grow: 1; + min-width: 0; + overflow: hidden; + } + + .breadcrumbs-content { + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; + white-space: nowrap; + font-size: 13px; + } + + .breadcrumb-item { + padding: 3px 8px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 200px; + } + + .breadcrumb-item:hover:not(.breadcrumb-current) { + background-color: rgba(0, 0, 0, 0.08); + } + + .breadcrumb-page { + color: #137cbd; + font-weight: 500; + } + + .breadcrumb-block { + color: #5c7080; + font-style: italic; + } + + .breadcrumb-block::before { + content: "*"; + margin-right: 4px; + opacity: 0.5; + } + + .breadcrumb-current { + background-color: rgba(19, 124, 189, 0.1); + cursor: default; + } + + .breadcrumb-separator { + color: #8a9ba8; + margin: 0 2px; + user-select: none; + } + + .bp3-dark #${UI_IDS.panel} .breadcrumb-item:hover:not(.breadcrumb-current) { + background-color: rgba(255, 255, 255, 0.1); + } + + .bp3-dark #${UI_IDS.panel} .breadcrumb-page { + color: #48aff0; + } + + .bp3-dark #${UI_IDS.panel} .breadcrumb-block { + color: #a7b6c2; + } + + .bp3-dark #${UI_IDS.panel} .breadcrumb-current { + background-color: rgba(72, 175, 240, 0.15); + } + + .bp3-dark #${UI_IDS.panel} .breadcrumb-separator { + color: #5c7080; + } + `; + + document.head.appendChild(styles); +}; + +const cleanup = (): void => { + if (hashChangeListener) { + window.removeEventListener("hashchange", hashChangeListener); + hashChangeListener = null; + } + + document.getElementById(UI_IDS.panel)?.remove(); + document.getElementById(UI_IDS.styles)?.remove(); + + breadcrumbHistory = []; + currentLocation = null; + navigationSeq = 0; +}; + export default runExtension(async ({ extensionAPI }) => { extensionAPI.settings.panel.create({ - tabTitle: "Extension", + tabTitle: "Breadcrumbs", settings: [ { - id: "enabled", - name: "Enable", - description: "Turn the extension on or off", - action: { type: "switch" }, + id: "maxBreadcrumbs", + name: "Max breadcrumbs", + description: "Maximum number of previous locations to keep", + action: { + type: "input", + placeholder: `${DEFAULT_SETTINGS.maxBreadcrumbs}`, + }, + }, + { + id: "truncateLength", + name: "Truncate length", + description: "Maximum breadcrumb label length before truncation", + action: { + type: "input", + placeholder: `${DEFAULT_SETTINGS.truncateLength}`, + }, }, ], }); - const enabled = extensionAPI.settings.get("enabled") as boolean | undefined; - if (enabled === false) return; + injectStyles(); + getOrCreatePanel(); + + hashChangeListener = () => { + const settings = readSettings({ extensionAPI }); + void handleNavigation(settings); + }; + window.addEventListener("hashchange", hashChangeListener); - // Add your extension logic here. - // Use roamjs-components: dom/*, queries/*, writes/*, util/*, components/* + const settings = readSettings({ extensionAPI }); + await handleNavigation(settings); return { - unload: () => { - // Clean up observers, listeners, command palette, etc. - }, + unload: cleanup, }; });