From 93329500c884930dabc4231b7309e91c020f203a Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 02:06:46 -0600 Subject: [PATCH 01/13] Add sticky note command and UI --- src/index.ts | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 351 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6ffa421..6d7a4c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,230 @@ +import addStyle from "roamjs-components/dom/addStyle"; import runExtension from "roamjs-components/util/runExtension"; +import { createBlock, createPage } from "roamjs-components/writes"; + +type StickyNoteLayout = { + x: number; + y: number; + width: number; + height: number; + minimized: boolean; +}; + +type StickyNoteLayouts = Record; + +const PAGE_TITLE = "Roam/js/sticky-note"; +const STORAGE_KEY = "roam-sticky-note-layouts"; +const COMMAND_LABEL = "Create Sticky Note"; +const NOTE_CLASS = "roam-sticky-note"; +const NOTE_MINIMIZED_CLASS = "roam-sticky-note--minimized"; + +const getLayouts = (): StickyNoteLayouts => { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw) as StickyNoteLayouts; + return parsed; + } catch { + return {}; + } +}; + +const setLayouts = (layouts: StickyNoteLayouts): void => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts)); +}; + +const getPageUid = (): string | null => { + const result = window.roamAlphaAPI.q( + `[:find ?uid :where [?p :node/title "${PAGE_TITLE}"] [?p :block/uid ?uid]]` + ) as [string][]; + return result.length ? result[0][0] : null; +}; + +const ensurePageUid = async (): Promise => { + const existing = getPageUid(); + if (existing) { + return existing; + } + return createPage({ title: PAGE_TITLE }); +}; + +const fetchStickyNoteUids = (): string[] => { + const result = window.roamAlphaAPI.q( + `[:find ?uid ?order :where [?p :node/title "${PAGE_TITLE}"] [?c :block/parents ?p] [?c :block/uid ?uid] [?c :block/order ?order]]` + ) as [string, number][]; + return result + .sort((a, b) => a[1] - b[1]) + .map((entry) => entry[0]); +}; + +const defaultLayout = ( + index: number, + viewportWidth: number, + viewportHeight: number +): StickyNoteLayout => { + const width = 240; + const height = 220; + const offset = 30 * index; + const x = Math.min(100 + offset, Math.max(20, viewportWidth - width - 20)); + const y = Math.min(120 + offset, Math.max(20, viewportHeight - height - 20)); + return { + x, + y, + width, + height, + minimized: false, + }; +}; + +const applyLayout = ( + note: HTMLElement, + layout: StickyNoteLayout +): void => { + note.style.left = `${layout.x}px`; + note.style.top = `${layout.y}px`; + note.style.width = `${layout.width}px`; + note.style.height = `${layout.height}px`; + note.classList.toggle(NOTE_MINIMIZED_CLASS, layout.minimized); +}; + +const updateLayout = ( + layouts: StickyNoteLayouts, + uid: string, + next: Partial +): void => { + const current = layouts[uid]; + layouts[uid] = { + ...(current || defaultLayout(0, window.innerWidth, window.innerHeight)), + ...next, + }; + setLayouts(layouts); +}; + +const createStickyNoteElement = ({ + uid, + layout, + layouts, +}: { + uid: string; + layout: StickyNoteLayout; + layouts: StickyNoteLayouts; +}): HTMLDivElement => { + const note = document.createElement("div"); + note.className = NOTE_CLASS; + note.dataset.uid = uid; + applyLayout(note, layout); + + const header = document.createElement("div"); + header.className = "roam-sticky-note__header"; + + const title = document.createElement("div"); + title.className = "roam-sticky-note__title"; + title.textContent = "Sticky Note"; + + const actions = document.createElement("div"); + actions.className = "roam-sticky-note__actions"; + + const minimizeButton = document.createElement("button"); + minimizeButton.type = "button"; + minimizeButton.className = "bp3-button bp3-minimal roam-sticky-note__button"; + minimizeButton.setAttribute( + "aria-label", + layout.minimized ? "Expand sticky note" : "Minimize sticky note" + ); + minimizeButton.textContent = layout.minimized ? "▢" : "–"; + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "bp3-button bp3-minimal roam-sticky-note__button"; + deleteButton.setAttribute("aria-label", "Delete sticky note"); + deleteButton.textContent = "✕"; + + actions.append(minimizeButton, deleteButton); + header.append(title, actions); + + const content = document.createElement("div"); + content.className = "roam-sticky-note__content"; + + note.append(header, content); + + window.roamAlphaAPI.ui.components.renderBlock({ uid, el: content }); + + let dragOffsetX = 0; + let dragOffsetY = 0; + let isDragging = false; + + const onPointerMove = (event: PointerEvent): void => { + if (!isDragging) { + return; + } + const x = event.clientX - dragOffsetX; + const y = event.clientY - dragOffsetY; + note.style.left = `${x}px`; + note.style.top = `${y}px`; + updateLayout(layouts, uid, { x, y }); + }; + + const onPointerUp = (): void => { + if (!isDragging) { + return; + } + isDragging = false; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + }; + + const onPointerDown = (event: PointerEvent): void => { + if ((event.target as HTMLElement).closest("button")) { + return; + } + isDragging = true; + dragOffsetX = event.clientX - note.offsetLeft; + dragOffsetY = event.clientY - note.offsetTop; + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }; + + header.addEventListener("pointerdown", onPointerDown); + + minimizeButton.addEventListener("click", () => { + const nextMinimized = !note.classList.contains(NOTE_MINIMIZED_CLASS); + note.classList.toggle(NOTE_MINIMIZED_CLASS, nextMinimized); + minimizeButton.textContent = nextMinimized ? "▢" : "–"; + minimizeButton.setAttribute( + "aria-label", + nextMinimized ? "Expand sticky note" : "Minimize sticky note" + ); + updateLayout(layouts, uid, { minimized: nextMinimized }); + }); + + deleteButton.addEventListener("click", () => { + note.remove(); + delete layouts[uid]; + setLayouts(layouts); + window.roamAlphaAPI.deleteBlock({ block: { uid } }); + }); + + const resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + if (!(entry.target instanceof HTMLElement)) { + return; + } + if (entry.target.classList.contains(NOTE_MINIMIZED_CLASS)) { + return; + } + updateLayout(layouts, uid, { + width: Math.round(entry.target.offsetWidth), + height: Math.round(entry.target.offsetHeight), + }); + }); + }); + + resizeObserver.observe(note); + + return note; +}; export default runExtension(async ({ extensionAPI }) => { extensionAPI.settings.panel.create({ @@ -16,12 +242,134 @@ export default runExtension(async ({ extensionAPI }) => { const enabled = extensionAPI.settings.get("enabled") as boolean | undefined; if (enabled === false) return; - // Add your extension logic here. - // Use roamjs-components: dom/*, queries/*, writes/*, util/*, components/* + const style = addStyle( + ` + .${NOTE_CLASS} { + position: absolute; + background: #f8e88b; + border: 1px solid #e5d671; + border-radius: 10px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + resize: both; + overflow: hidden; + min-width: 180px; + min-height: 160px; + pointer-events: auto; + z-index: 1000; + } + + .${NOTE_CLASS}::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 10px; + pointer-events: none; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); + } + + .${NOTE_CLASS} .roam-sticky-note__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + cursor: grab; + user-select: none; + font-weight: 600; + color: #5a4b1d; + background: rgba(255, 255, 255, 0.4); + } + + .${NOTE_CLASS} .roam-sticky-note__header:active { + cursor: grabbing; + } + + .${NOTE_CLASS} .roam-sticky-note__title { + font-size: 13px; + } + + .${NOTE_CLASS} .roam-sticky-note__actions { + display: flex; + gap: 4px; + } + + .${NOTE_CLASS} .roam-sticky-note__button { + min-width: 24px; + height: 24px; + padding: 0; + color: #5a4b1d; + } + + .${NOTE_CLASS} .roam-sticky-note__content { + padding: 6px 10px 12px; + flex: 1; + overflow: auto; + } + + .${NOTE_MINIMIZED_CLASS} { + height: auto !important; + resize: none; + } + + .${NOTE_MINIMIZED_CLASS} .roam-sticky-note__content { + display: none; + } + `, + "roam-sticky-note-style" + ); + + const container = document.createElement("div"); + container.id = "roam-sticky-note-container"; + container.style.position = "fixed"; + container.style.left = "0"; + container.style.top = "0"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.pointerEvents = "none"; + document.body.append(container); + + const layouts = getLayouts(); + const pageUid = await ensurePageUid(); + const existingUids = fetchStickyNoteUids(); + + existingUids.forEach((uid, index) => { + const layout = + layouts[uid] || + defaultLayout(index, window.innerWidth, window.innerHeight); + layouts[uid] = layout; + const note = createStickyNoteElement({ uid, layout, layouts }); + container.append(note); + }); + setLayouts(layouts); + + const createStickyNote = async (): Promise => { + const uid = await createBlock({ + parentUid: pageUid, + order: "last", + node: { text: " " }, + }); + const layout = defaultLayout( + Object.keys(layouts).length, + window.innerWidth, + window.innerHeight + ); + layouts[uid] = layout; + setLayouts(layouts); + const note = createStickyNoteElement({ uid, layout, layouts }); + container.append(note); + }; + + await extensionAPI.ui.commandPalette.addCommand({ + label: COMMAND_LABEL, + callback: createStickyNote, + }); return { + elements: [style], + commands: [COMMAND_LABEL], unload: () => { - // Clean up observers, listeners, command palette, etc. + container.remove(); }, }; }); From e4b75ca714756b0e5aba423e27554f7abcbe97ee Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 02:19:01 -0600 Subject: [PATCH 02/13] Fix sticky note parsing, editable titles, and observer cleanup --- src/index.ts | 157 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6d7a4c3..8e57f2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,16 @@ type StickyNoteLayout = { }; type StickyNoteLayouts = Record; +type BlockChild = { + uid: string; + text: string; +}; + +type StickyNoteMeta = { + titleUid: string; + titleText: string; + contentUids: string[]; +}; const PAGE_TITLE = "Roam/js/sticky-note"; const STORAGE_KEY = "roam-sticky-note-layouts"; @@ -52,13 +62,54 @@ const ensurePageUid = async (): Promise => { const fetchStickyNoteUids = (): string[] => { const result = window.roamAlphaAPI.q( - `[:find ?uid ?order :where [?p :node/title "${PAGE_TITLE}"] [?c :block/parents ?p] [?c :block/uid ?uid] [?c :block/order ?order]]` + `[:find ?uid ?order + :where + [?p :node/title "${PAGE_TITLE}"] + [?p :block/children ?c] + [?c :block/uid ?uid] + [?c :block/order ?order]]` ) as [string, number][]; return result .sort((a, b) => a[1] - b[1]) .map((entry) => entry[0]); }; +const fetchBlockChildren = (uid: string): BlockChild[] => { + const result = window.roamAlphaAPI.q( + `[:find ?childUid ?text ?order + :in $ ?uid + :where + [?b :block/uid ?uid] + [?b :block/children ?c] + [?c :block/uid ?childUid] + [(get-else $ ?c :block/string "") ?text] + [?c :block/order ?order]]`, + uid + ) as [string, string, number][]; + return result + .sort((a, b) => a[2] - b[2]) + .map(([childUid, text]) => ({ uid: childUid, text })); +}; + +const ensureStickyNoteMeta = async (noteUid: string): Promise => { + const children = fetchBlockChildren(noteUid); + const titleChild = children[0]; + if (!titleChild) { + const titleUid = await createBlock({ + parentUid: noteUid, + order: 0, + node: { text: "Sticky Note" }, + }); + return { titleUid, titleText: "Sticky Note", contentUids: [] }; + } + + return { + titleUid: titleChild.uid, + titleText: titleChild.text, + contentUids: children.slice(1).map((c) => c.uid), + }; +}; + const defaultLayout = ( index: number, viewportWidth: number, @@ -106,10 +157,14 @@ const createStickyNoteElement = ({ uid, layout, layouts, + meta, + resizeObservers, }: { uid: string; layout: StickyNoteLayout; layouts: StickyNoteLayouts; + meta: StickyNoteMeta; + resizeObservers: Set; }): HTMLDivElement => { const note = document.createElement("div"); note.className = NOTE_CLASS; @@ -119,9 +174,11 @@ const createStickyNoteElement = ({ const header = document.createElement("div"); header.className = "roam-sticky-note__header"; - const title = document.createElement("div"); + const title = document.createElement("input"); title.className = "roam-sticky-note__title"; - title.textContent = "Sticky Note"; + title.type = "text"; + title.value = meta.titleText || "Sticky Note"; + title.setAttribute("aria-label", "Sticky note title"); const actions = document.createElement("div"); actions.className = "roam-sticky-note__actions"; @@ -149,7 +206,32 @@ const createStickyNoteElement = ({ note.append(header, content); - window.roamAlphaAPI.ui.components.renderBlock({ uid, el: content }); + meta.contentUids.forEach((contentUid) => { + const blockContainer = document.createElement("div"); + content.append(blockContainer); + window.roamAlphaAPI.ui.components.renderBlock({ + uid: contentUid, + el: blockContainer, + }); + }); + + const commitTitle = (): void => { + const nextTitle = title.value.trim() || "Sticky Note"; + if (title.value !== nextTitle) { + title.value = nextTitle; + } + window.roamAlphaAPI.updateBlock({ + block: { uid: meta.titleUid, string: nextTitle }, + }); + }; + + title.addEventListener("blur", commitTitle); + title.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + title.blur(); + } + }); let dragOffsetX = 0; let dragOffsetY = 0; @@ -199,13 +281,6 @@ const createStickyNoteElement = ({ updateLayout(layouts, uid, { minimized: nextMinimized }); }); - deleteButton.addEventListener("click", () => { - note.remove(); - delete layouts[uid]; - setLayouts(layouts); - window.roamAlphaAPI.deleteBlock({ block: { uid } }); - }); - const resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { if (!(entry.target instanceof HTMLElement)) { @@ -220,9 +295,18 @@ const createStickyNoteElement = ({ }); }); }); - + resizeObservers.add(resizeObserver); resizeObserver.observe(note); + deleteButton.addEventListener("click", () => { + resizeObserver.disconnect(); + resizeObservers.delete(resizeObserver); + note.remove(); + delete layouts[uid]; + setLayouts(layouts); + window.roamAlphaAPI.deleteBlock({ block: { uid } }); + }); + return note; }; @@ -287,6 +371,21 @@ export default runExtension(async ({ extensionAPI }) => { .${NOTE_CLASS} .roam-sticky-note__title { font-size: 13px; + font-weight: 600; + color: #5a4b1d; + background: transparent; + border: none; + outline: none; + width: 100%; + min-width: 0; + margin-right: 8px; + padding: 0; + } + + .${NOTE_CLASS} .roam-sticky-note__title:focus { + background: rgba(255, 255, 255, 0.55); + border-radius: 4px; + padding: 0 4px; } .${NOTE_CLASS} .roam-sticky-note__actions { @@ -330,17 +429,25 @@ export default runExtension(async ({ extensionAPI }) => { document.body.append(container); const layouts = getLayouts(); + const resizeObservers = new Set(); const pageUid = await ensurePageUid(); const existingUids = fetchStickyNoteUids(); - existingUids.forEach((uid, index) => { + for (const [index, uid] of existingUids.entries()) { + const meta = await ensureStickyNoteMeta(uid); const layout = layouts[uid] || defaultLayout(index, window.innerWidth, window.innerHeight); layouts[uid] = layout; - const note = createStickyNoteElement({ uid, layout, layouts }); + const note = createStickyNoteElement({ + uid, + layout, + layouts, + meta, + resizeObservers, + }); container.append(note); - }); + } setLayouts(layouts); const createStickyNote = async (): Promise => { @@ -349,6 +456,16 @@ export default runExtension(async ({ extensionAPI }) => { order: "last", node: { text: " " }, }); + const titleUid = await createBlock({ + parentUid: uid, + order: 0, + node: { text: "Sticky Note" }, + }); + const contentUid = await createBlock({ + parentUid: uid, + order: 1, + node: { text: " " }, + }); const layout = defaultLayout( Object.keys(layouts).length, window.innerWidth, @@ -356,7 +473,13 @@ export default runExtension(async ({ extensionAPI }) => { ); layouts[uid] = layout; setLayouts(layouts); - const note = createStickyNoteElement({ uid, layout, layouts }); + const note = createStickyNoteElement({ + uid, + layout, + layouts, + meta: { titleUid, titleText: "Sticky Note", contentUids: [contentUid] }, + resizeObservers, + }); container.append(note); }; @@ -369,6 +492,8 @@ export default runExtension(async ({ extensionAPI }) => { elements: [style], commands: [COMMAND_LABEL], unload: () => { + resizeObservers.forEach((observer) => observer.disconnect()); + resizeObservers.clear(); container.remove(); }, }; From 3282bdc7e2d7a1f2e4ddc64cdc5b8456db032998 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 02:23:50 -0600 Subject: [PATCH 03/13] Use top-level sticky note block as title with child content --- src/index.ts | 55 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8e57f2b..d8504e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,22 +91,46 @@ const fetchBlockChildren = (uid: string): BlockChild[] => { .map(([childUid, text]) => ({ uid: childUid, text })); }; +const fetchBlockText = (uid: string): string => { + const result = window.roamAlphaAPI.q( + `[:find ?text + :in $ ?uid + :where + [?b :block/uid ?uid] + [(get-else $ ?b :block/string "") ?text]]`, + uid + ) as [string][]; + return result.length ? result[0][0] : ""; +}; + const ensureStickyNoteMeta = async (noteUid: string): Promise => { + const noteText = fetchBlockText(noteUid).trim(); const children = fetchBlockChildren(noteUid); - const titleChild = children[0]; - if (!titleChild) { - const titleUid = await createBlock({ - parentUid: noteUid, - order: 0, - node: { text: "Sticky Note" }, - }); - return { titleUid, titleText: "Sticky Note", contentUids: [] }; + + if (noteText) { + return { + titleUid: noteUid, + titleText: noteText, + contentUids: children.map((c) => c.uid), + }; } + const legacyTitleChild = children[0]; + if (legacyTitleChild) { + return { + titleUid: legacyTitleChild.uid, + titleText: legacyTitleChild.text || "Sticky Note", + contentUids: children.slice(1).map((c) => c.uid), + }; + } + + window.roamAlphaAPI.updateBlock({ + block: { uid: noteUid, string: "Sticky Note" }, + }); return { - titleUid: titleChild.uid, - titleText: titleChild.text, - contentUids: children.slice(1).map((c) => c.uid), + titleUid: noteUid, + titleText: "Sticky Note", + contentUids: [], }; }; @@ -454,16 +478,11 @@ export default runExtension(async ({ extensionAPI }) => { const uid = await createBlock({ parentUid: pageUid, order: "last", - node: { text: " " }, - }); - const titleUid = await createBlock({ - parentUid: uid, - order: 0, node: { text: "Sticky Note" }, }); const contentUid = await createBlock({ parentUid: uid, - order: 1, + order: 0, node: { text: " " }, }); const layout = defaultLayout( @@ -477,7 +496,7 @@ export default runExtension(async ({ extensionAPI }) => { uid, layout, layouts, - meta: { titleUid, titleText: "Sticky Note", contentUids: [contentUid] }, + meta: { titleUid: uid, titleText: "Sticky Note", contentUids: [contentUid] }, resizeObservers, }); container.append(note); From bbfc83d507e0f23d7db4ff7a048a46aafae11ead Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 03:44:28 -0600 Subject: [PATCH 04/13] Refine sticky note rendering and interaction UX --- src/index.ts | 258 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 189 insertions(+), 69 deletions(-) diff --git a/src/index.ts b/src/index.ts index d8504e9..0139f1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,25 +8,48 @@ type StickyNoteLayout = { width: number; height: number; minimized: boolean; + rotation: number; }; type StickyNoteLayouts = Record; -type BlockChild = { - uid: string; - text: string; -}; type StickyNoteMeta = { titleUid: string; titleText: string; - contentUids: string[]; }; -const PAGE_TITLE = "Roam/js/sticky-note"; +type ReactLike = { + createElement: (component: unknown, props: Record) => unknown; +}; + +type ReactDomLike = { + render: (element: unknown, container: Element) => void; + unmountComponentAtNode: (container: Element) => boolean; +}; + +type RoamReactApi = { + Block: (props: { + uid: string; + open?: boolean; + zoomPath?: boolean; + zoomStartAfterUid?: string; + }) => unknown; +}; + +const PAGE_TITLE = "roam/js/sticky-note"; const STORAGE_KEY = "roam-sticky-note-layouts"; const COMMAND_LABEL = "Create Sticky Note"; -const NOTE_CLASS = "roam-sticky-note"; -const NOTE_MINIMIZED_CLASS = "roam-sticky-note--minimized"; +const NOTE_CLASS = "roamjs-sticky-note"; +const NOTE_MINIMIZED_CLASS = "roamjs-sticky-note--minimized"; +const NOTE_DRAGGING_CLASS = "roamjs-sticky-note--dragging"; + +const randomRotation = (): number => + Math.round((Math.random() * 3 - 1.5) * 10) / 10; + +const normalizeLayout = (layout: StickyNoteLayout): StickyNoteLayout => ({ + ...layout, + rotation: Number.isFinite(layout.rotation) ? layout.rotation : randomRotation(), +}); const getLayouts = (): StickyNoteLayouts => { const raw = window.localStorage.getItem(STORAGE_KEY); @@ -35,7 +58,9 @@ const getLayouts = (): StickyNoteLayouts => { } try { const parsed = JSON.parse(raw) as StickyNoteLayouts; - return parsed; + return Object.fromEntries( + Object.entries(parsed).map(([uid, layout]) => [uid, normalizeLayout(layout)]) + ); } catch { return {}; } @@ -74,23 +99,6 @@ const fetchStickyNoteUids = (): string[] => { .map((entry) => entry[0]); }; -const fetchBlockChildren = (uid: string): BlockChild[] => { - const result = window.roamAlphaAPI.q( - `[:find ?childUid ?text ?order - :in $ ?uid - :where - [?b :block/uid ?uid] - [?b :block/children ?c] - [?c :block/uid ?childUid] - [(get-else $ ?c :block/string "") ?text] - [?c :block/order ?order]]`, - uid - ) as [string, string, number][]; - return result - .sort((a, b) => a[2] - b[2]) - .map(([childUid, text]) => ({ uid: childUid, text })); -}; - const fetchBlockText = (uid: string): string => { const result = window.roamAlphaAPI.q( `[:find ?text @@ -105,22 +113,11 @@ const fetchBlockText = (uid: string): string => { const ensureStickyNoteMeta = async (noteUid: string): Promise => { const noteText = fetchBlockText(noteUid).trim(); - const children = fetchBlockChildren(noteUid); if (noteText) { return { titleUid: noteUid, titleText: noteText, - contentUids: children.map((c) => c.uid), - }; - } - - const legacyTitleChild = children[0]; - if (legacyTitleChild) { - return { - titleUid: legacyTitleChild.uid, - titleText: legacyTitleChild.text || "Sticky Note", - contentUids: children.slice(1).map((c) => c.uid), }; } @@ -130,7 +127,33 @@ const ensureStickyNoteMeta = async (noteUid: string): Promise => return { titleUid: noteUid, titleText: "Sticky Note", - contentUids: [], + }; +}; + +const mountRoamBlock = ({ + uid, + el, + open, +}: { + uid: string; + el: HTMLElement; + open?: boolean; +}): (() => void) => { + const globalWindow = window as unknown as { + React?: ReactLike; + ReactDOM?: ReactDomLike; + roamAlphaAPI?: { ui?: { react?: RoamReactApi } }; + }; + const React = globalWindow.React; + const ReactDOM = globalWindow.ReactDOM; + const Block = globalWindow.roamAlphaAPI?.ui?.react?.Block; + if (!React || !ReactDOM || !Block) { + return () => undefined; + } + + ReactDOM.render(React.createElement(Block, { uid, open }), el); + return () => { + ReactDOM.unmountComponentAtNode(el); }; }; @@ -150,6 +173,7 @@ const defaultLayout = ( width, height, minimized: false, + rotation: randomRotation(), }; }; @@ -161,6 +185,7 @@ const applyLayout = ( note.style.top = `${layout.y}px`; note.style.width = `${layout.width}px`; note.style.height = `${layout.height}px`; + note.style.transform = `rotate(${layout.rotation}deg)`; note.classList.toggle(NOTE_MINIMIZED_CLASS, layout.minimized); }; @@ -183,12 +208,14 @@ const createStickyNoteElement = ({ layouts, meta, resizeObservers, + blockUnmounts, }: { uid: string; layout: StickyNoteLayout; layouts: StickyNoteLayouts; meta: StickyNoteMeta; resizeObservers: Set; + blockUnmounts: Set<() => void>; }): HTMLDivElement => { const note = document.createElement("div"); note.className = NOTE_CLASS; @@ -196,20 +223,35 @@ const createStickyNoteElement = ({ applyLayout(note, layout); const header = document.createElement("div"); - header.className = "roam-sticky-note__header"; + header.className = "roamjs-sticky-note__header"; const title = document.createElement("input"); - title.className = "roam-sticky-note__title"; + title.className = "roamjs-sticky-note__title"; title.type = "text"; title.value = meta.titleText || "Sticky Note"; title.setAttribute("aria-label", "Sticky note title"); + const measureTitleWidth = (value: string): number => { + const text = value.trim() || "Sticky Note"; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return Math.max(36, text.length * 8); + } + const computed = window.getComputedStyle(title); + ctx.font = `${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`; + return Math.max(36, Math.ceil(ctx.measureText(text).width) + 10); + }; + const syncTitleWidth = (): void => { + title.style.width = `${measureTitleWidth(title.value)}px`; + }; + syncTitleWidth(); const actions = document.createElement("div"); - actions.className = "roam-sticky-note__actions"; + actions.className = "roamjs-sticky-note__actions"; const minimizeButton = document.createElement("button"); minimizeButton.type = "button"; - minimizeButton.className = "bp3-button bp3-minimal roam-sticky-note__button"; + minimizeButton.className = "bp3-button bp3-minimal roamjs-sticky-note__button"; minimizeButton.setAttribute( "aria-label", layout.minimized ? "Expand sticky note" : "Minimize sticky note" @@ -218,7 +260,7 @@ const createStickyNoteElement = ({ const deleteButton = document.createElement("button"); deleteButton.type = "button"; - deleteButton.className = "bp3-button bp3-minimal roam-sticky-note__button"; + deleteButton.className = "bp3-button bp3-minimal roamjs-sticky-note__button"; deleteButton.setAttribute("aria-label", "Delete sticky note"); deleteButton.textContent = "✕"; @@ -226,29 +268,46 @@ const createStickyNoteElement = ({ header.append(title, actions); const content = document.createElement("div"); - content.className = "roam-sticky-note__content"; + content.className = "roamjs-sticky-note__content"; note.append(header, content); - - meta.contentUids.forEach((contentUid) => { - const blockContainer = document.createElement("div"); - content.append(blockContainer); - window.roamAlphaAPI.ui.components.renderBlock({ - uid: contentUid, - el: blockContainer, - }); - }); + syncTitleWidth(); + window.requestAnimationFrame(syncTitleWidth); + window.setTimeout(syncTitleWidth, 80); + + const blockContainer = document.createElement("div"); + blockContainer.className = "roamjs-sticky-note__embedded-root"; + content.append(blockContainer); + const unmountBlock = mountRoamBlock({ uid, el: blockContainer, open: true }); + const hideEmbeddedRootTitle = (): void => { + const rootMain = blockContainer.querySelector( + ".rm-level-0 > .rm-block-main, .rm-block-main" + ) as HTMLElement | null; + if (rootMain) { + rootMain.style.display = "none"; + } + }; + hideEmbeddedRootTitle(); + const embedObserver = new MutationObserver(() => hideEmbeddedRootTitle()); + embedObserver.observe(blockContainer, { childList: true, subtree: true }); + const cleanupEmbeddedBlock = (): void => { + embedObserver.disconnect(); + unmountBlock(); + }; + blockUnmounts.add(cleanupEmbeddedBlock); const commitTitle = (): void => { const nextTitle = title.value.trim() || "Sticky Note"; if (title.value !== nextTitle) { title.value = nextTitle; } + syncTitleWidth(); window.roamAlphaAPI.updateBlock({ block: { uid: meta.titleUid, string: nextTitle }, }); }; + title.addEventListener("input", syncTitleWidth); title.addEventListener("blur", commitTitle); title.addEventListener("keydown", (event) => { if (event.key === "Enter") { @@ -260,6 +319,7 @@ const createStickyNoteElement = ({ let dragOffsetX = 0; let dragOffsetY = 0; let isDragging = false; + let previousBodyUserSelect = ""; const onPointerMove = (event: PointerEvent): void => { if (!isDragging) { @@ -277,17 +337,23 @@ const createStickyNoteElement = ({ return; } isDragging = false; + note.classList.remove(NOTE_DRAGGING_CLASS); + document.body.style.userSelect = previousBodyUserSelect; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); }; const onPointerDown = (event: PointerEvent): void => { - if ((event.target as HTMLElement).closest("button")) { + const target = event.target as HTMLElement; + if (target.closest("button, input, textarea, [contenteditable='true']")) { return; } isDragging = true; dragOffsetX = event.clientX - note.offsetLeft; dragOffsetY = event.clientY - note.offsetTop; + note.classList.add(NOTE_DRAGGING_CLASS); + previousBodyUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }; @@ -303,6 +369,10 @@ const createStickyNoteElement = ({ nextMinimized ? "Expand sticky note" : "Minimize sticky note" ); updateLayout(layouts, uid, { minimized: nextMinimized }); + window.requestAnimationFrame(() => { + const activeElement = document.activeElement as HTMLElement | null; + activeElement?.blur(); + }); }); const resizeObserver = new ResizeObserver((entries) => { @@ -325,6 +395,8 @@ const createStickyNoteElement = ({ deleteButton.addEventListener("click", () => { resizeObserver.disconnect(); resizeObservers.delete(resizeObserver); + cleanupEmbeddedBlock(); + blockUnmounts.delete(cleanupEmbeddedBlock); note.remove(); delete layouts[uid]; setLayouts(layouts); @@ -366,6 +438,12 @@ export default runExtension(async ({ extensionAPI }) => { min-height: 160px; pointer-events: auto; z-index: 1000; + transition: box-shadow 120ms ease; + transform-origin: center center; + } + + .${NOTE_DRAGGING_CLASS} { + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.32); } .${NOTE_CLASS}::after { @@ -377,7 +455,7 @@ export default runExtension(async ({ extensionAPI }) => { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); } - .${NOTE_CLASS} .roam-sticky-note__header { + .${NOTE_CLASS} .roamjs-sticky-note__header { display: flex; align-items: center; justify-content: space-between; @@ -389,61 +467,98 @@ export default runExtension(async ({ extensionAPI }) => { background: rgba(255, 255, 255, 0.4); } - .${NOTE_CLASS} .roam-sticky-note__header:active { + .${NOTE_CLASS} .roamjs-sticky-note__header:active { cursor: grabbing; } - .${NOTE_CLASS} .roam-sticky-note__title { + .${NOTE_CLASS} .roamjs-sticky-note__title { font-size: 13px; font-weight: 600; color: #5a4b1d; background: transparent; border: none; outline: none; - width: 100%; + flex: 0 0 auto; min-width: 0; + max-width: calc(100% - 60px); margin-right: 8px; - padding: 0; + padding: 0 1px; + box-sizing: border-box; } - .${NOTE_CLASS} .roam-sticky-note__title:focus { + .${NOTE_CLASS} .roamjs-sticky-note__title:focus { background: rgba(255, 255, 255, 0.55); border-radius: 4px; - padding: 0 4px; } - .${NOTE_CLASS} .roam-sticky-note__actions { + .${NOTE_CLASS} .roamjs-sticky-note__actions { display: flex; gap: 4px; } - .${NOTE_CLASS} .roam-sticky-note__button { + .${NOTE_CLASS} .roamjs-sticky-note__button { min-width: 24px; height: 24px; padding: 0; color: #5a4b1d; } - .${NOTE_CLASS} .roam-sticky-note__content { + .${NOTE_CLASS} .roamjs-sticky-note__content { padding: 6px 10px 12px; flex: 1; overflow: auto; } + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root { + width: 100%; + min-width: 0; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root > .roam-block-container { + width: 100%; + min-width: 0; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root > .roam-block-container > .rm-block-main { + display: none; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .roam-block-container > .rm-block-children.rm-level-1 { + margin-left: 0 !important; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .roam-block-container, + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .rm-level-0, + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .rm-block-main, + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .roam-block { + width: 100%; + min-width: 0; + max-width: 100%; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .rm-block-separator { + display: none; + } + + .${NOTE_CLASS} .roamjs-sticky-note__embedded-root .rm-multibar { + display: none; + } + .${NOTE_MINIMIZED_CLASS} { height: auto !important; + min-height: 0 !important; resize: none; } - .${NOTE_MINIMIZED_CLASS} .roam-sticky-note__content { + .${NOTE_MINIMIZED_CLASS} .roamjs-sticky-note__content { display: none; } `, - "roam-sticky-note-style" + "roamjs-sticky-note-style" ); const container = document.createElement("div"); - container.id = "roam-sticky-note-container"; + container.id = "roamjs-sticky-note-container"; container.style.position = "fixed"; container.style.left = "0"; container.style.top = "0"; @@ -454,6 +569,7 @@ export default runExtension(async ({ extensionAPI }) => { const layouts = getLayouts(); const resizeObservers = new Set(); + const blockUnmounts = new Set<() => void>(); const pageUid = await ensurePageUid(); const existingUids = fetchStickyNoteUids(); @@ -469,6 +585,7 @@ export default runExtension(async ({ extensionAPI }) => { layouts, meta, resizeObservers, + blockUnmounts, }); container.append(note); } @@ -480,7 +597,7 @@ export default runExtension(async ({ extensionAPI }) => { order: "last", node: { text: "Sticky Note" }, }); - const contentUid = await createBlock({ + await createBlock({ parentUid: uid, order: 0, node: { text: " " }, @@ -496,8 +613,9 @@ export default runExtension(async ({ extensionAPI }) => { uid, layout, layouts, - meta: { titleUid: uid, titleText: "Sticky Note", contentUids: [contentUid] }, + meta: { titleUid: uid, titleText: "Sticky Note" }, resizeObservers, + blockUnmounts, }); container.append(note); }; @@ -513,6 +631,8 @@ export default runExtension(async ({ extensionAPI }) => { unload: () => { resizeObservers.forEach((observer) => observer.disconnect()); resizeObservers.clear(); + blockUnmounts.forEach((unmount) => unmount()); + blockUnmounts.clear(); container.remove(); }, }; From 29c8dccacb2c1f963890d5a9118182584a110426 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 05:41:47 -0600 Subject: [PATCH 05/13] Enhance sticky note functionality with new focus methods and debug utilities --- src/index.ts | 109 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0139f1a..6cb2cde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ type RoamReactApi = { const PAGE_TITLE = "roam/js/sticky-note"; const STORAGE_KEY = "roam-sticky-note-layouts"; -const COMMAND_LABEL = "Create Sticky Note"; +const COMMAND_LABEL = "Sticky Notes: Create Sticky Note"; const NOTE_CLASS = "roamjs-sticky-note"; const NOTE_MINIMIZED_CLASS = "roamjs-sticky-note--minimized"; const NOTE_DRAGGING_CLASS = "roamjs-sticky-note--dragging"; @@ -202,6 +202,81 @@ const updateLayout = ( setLayouts(layouts); }; +const getStickyRenderedIdFromUid = ({ + uid, + root = document, +}: { + uid: string; + root?: ParentNode; +}): string | null => { + const el = root.querySelector( + `.roamjs-sticky-note__embedded-root [id^="block-input-"][id$="-${uid}"]` + ) as HTMLElement | null; + return el?.id || null; +}; + +const getStickyWindowIdFromUid = ({ + uid, + root = document, +}: { + uid: string; + root?: ParentNode; +}): string | null => { + const id = getStickyRenderedIdFromUid({ uid, root }); + if (!id) { + return null; + } + const match = id.match(/^block-input-(.+)-([A-Za-z0-9_-]{9})$/); + return match ? match[1] : null; +}; + +const focusStickyRenderedUid = ({ + uid, + root = document, +}: { + uid: string; + root?: ParentNode; +}): boolean => { + const windowId = getStickyWindowIdFromUid({ uid, root }); + if (!windowId) { + return false; + } + window.roamAlphaAPI.ui.setBlockFocusAndSelection({ + location: { + "block-uid": uid, + "window-id": windowId, + }, + }); + return true; +}; + +const focusStickyRenderedUidWithRetries = ({ + uid, + root, +}: { + uid: string; + root: ParentNode; +}): void => { + const timerIds: number[] = []; + let focused = false; + [60, 140, 280, 520, 900].forEach((delay) => { + const timerId = window.setTimeout(() => { + if (focused) { + return; + } + focused = focusStickyRenderedUid({ uid, root }); + if (focused) { + timerIds.forEach((id) => { + if (id !== timerId) { + window.clearTimeout(id); + } + }); + } + }, delay); + timerIds.push(timerId); + }); +}; + const createStickyNoteElement = ({ uid, layout, @@ -407,6 +482,21 @@ const createStickyNoteElement = ({ }; export default runExtension(async ({ extensionAPI }) => { + const stickyNoteDebug = window as unknown as { + roamjsStickyNoteDebug?: { + getStickyRenderedIdFromUid: typeof getStickyRenderedIdFromUid; + getStickyWindowIdFromUid: typeof getStickyWindowIdFromUid; + focusStickyRenderedUid: typeof focusStickyRenderedUid; + focusStickyRenderedUidWithRetries: typeof focusStickyRenderedUidWithRetries; + }; + }; + stickyNoteDebug.roamjsStickyNoteDebug = { + getStickyRenderedIdFromUid, + getStickyWindowIdFromUid, + focusStickyRenderedUid, + focusStickyRenderedUidWithRetries, + }; + extensionAPI.settings.panel.create({ tabTitle: "Extension", settings: [ @@ -503,6 +593,14 @@ export default runExtension(async ({ extensionAPI }) => { color: #5a4b1d; } + .${NOTE_CLASS} .roamjs-sticky-note__button:focus, + .${NOTE_CLASS} .roamjs-sticky-note__button:focus-visible, + .${NOTE_CLASS} .roamjs-sticky-note__button.bp3-active, + .${NOTE_CLASS} .roamjs-sticky-note__button.bp3-active:focus { + outline: none !important; + box-shadow: none !important; + } + .${NOTE_CLASS} .roamjs-sticky-note__content { padding: 6px 10px 12px; flex: 1; @@ -570,7 +668,7 @@ export default runExtension(async ({ extensionAPI }) => { const layouts = getLayouts(); const resizeObservers = new Set(); const blockUnmounts = new Set<() => void>(); - const pageUid = await ensurePageUid(); + await ensurePageUid(); const existingUids = fetchStickyNoteUids(); for (const [index, uid] of existingUids.entries()) { @@ -592,15 +690,16 @@ export default runExtension(async ({ extensionAPI }) => { setLayouts(layouts); const createStickyNote = async (): Promise => { + const pageUid = await ensurePageUid(); const uid = await createBlock({ parentUid: pageUid, order: "last", node: { text: "Sticky Note" }, }); - await createBlock({ + const firstContentUid = await createBlock({ parentUid: uid, order: 0, - node: { text: " " }, + node: { text: "" }, }); const layout = defaultLayout( Object.keys(layouts).length, @@ -618,6 +717,7 @@ export default runExtension(async ({ extensionAPI }) => { blockUnmounts, }); container.append(note); + focusStickyRenderedUidWithRetries({ uid: firstContentUid, root: note }); }; await extensionAPI.ui.commandPalette.addCommand({ @@ -629,6 +729,7 @@ export default runExtension(async ({ extensionAPI }) => { elements: [style], commands: [COMMAND_LABEL], unload: () => { + delete stickyNoteDebug.roamjsStickyNoteDebug; resizeObservers.forEach((observer) => observer.disconnect()); resizeObservers.clear(); blockUnmounts.forEach((unmount) => unmount()); From 0f5b6c0dae393e4f6fa49a20657f27b4d9f6bbcb Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:31:12 -0600 Subject: [PATCH 06/13] Update package.json to rename project to "sticky-notes" and enhance description and keywords for better clarity --- package.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b2c6884..26ee491 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "extension-base", + "name": "sticky-notes", "version": "1.0.0", - "description": "Stock RoamJS Roam Research Extension base — fork this repo to start new extensions.", + "description": "Sticky Notes Extension for Roam Research", "main": "index.js", "scripts": { "postinstall": "patch-package", @@ -10,7 +10,13 @@ "build:roam": "samepage build --dry", "test": "samepage test" }, - "keywords": ["roam", "roam-research", "roamjs"], + "keywords": [ + "roam", + "roam-research", + "roamjs", + "sticky-notes", + "extension" + ], "license": "MIT", "dependencies": { "roamjs-components": "^0.86.4" From 2b3b94601c8342b14af9d894f04facdacd3778d3 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:31:55 -0600 Subject: [PATCH 07/13] Add error handling for block updates and deletions in sticky notes --- src/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6cb2cde..2c30acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,18 @@ const NOTE_CLASS = "roamjs-sticky-note"; const NOTE_MINIMIZED_CLASS = "roamjs-sticky-note--minimized"; const NOTE_DRAGGING_CLASS = "roamjs-sticky-note--dragging"; +const logRoamMutationError = ({ + operation, + uid, + error, +}: { + operation: "updateBlock" | "deleteBlock"; + uid: string; + error: unknown; +}): void => { + console.error(`[sticky-note] Failed to ${operation} for block ${uid}`, error); +}; + const randomRotation = (): number => Math.round((Math.random() * 3 - 1.5) * 10) / 10; @@ -121,9 +133,13 @@ const ensureStickyNoteMeta = async (noteUid: string): Promise => }; } - window.roamAlphaAPI.updateBlock({ - block: { uid: noteUid, string: "Sticky Note" }, - }); + try { + await window.roamAlphaAPI.updateBlock({ + block: { uid: noteUid, string: "Sticky Note" }, + }); + } catch (error) { + logRoamMutationError({ operation: "updateBlock", uid: noteUid, error }); + } return { titleUid: noteUid, titleText: "Sticky Note", @@ -377,9 +393,17 @@ const createStickyNoteElement = ({ title.value = nextTitle; } syncTitleWidth(); - window.roamAlphaAPI.updateBlock({ - block: { uid: meta.titleUid, string: nextTitle }, - }); + void window.roamAlphaAPI + .updateBlock({ + block: { uid: meta.titleUid, string: nextTitle }, + }) + .catch((error) => { + logRoamMutationError({ + operation: "updateBlock", + uid: meta.titleUid, + error, + }); + }); }; title.addEventListener("input", syncTitleWidth); @@ -467,7 +491,16 @@ const createStickyNoteElement = ({ resizeObservers.add(resizeObserver); resizeObserver.observe(note); - deleteButton.addEventListener("click", () => { + deleteButton.addEventListener("click", async () => { + deleteButton.disabled = true; + try { + await window.roamAlphaAPI.deleteBlock({ block: { uid } }); + } catch (error) { + deleteButton.disabled = false; + logRoamMutationError({ operation: "deleteBlock", uid, error }); + return; + } + resizeObserver.disconnect(); resizeObservers.delete(resizeObserver); cleanupEmbeddedBlock(); @@ -475,7 +508,6 @@ const createStickyNoteElement = ({ note.remove(); delete layouts[uid]; setLayouts(layouts); - window.roamAlphaAPI.deleteBlock({ block: { uid } }); }); return note; From 2bc85b0ca95fa986b58e00dcbd41026fde8a2e91 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:36:26 -0600 Subject: [PATCH 08/13] Refactor layout update function and implement layout persistence for sticky notes --- src/index.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2c30acf..079f442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -205,7 +205,7 @@ const applyLayout = ( note.classList.toggle(NOTE_MINIMIZED_CLASS, layout.minimized); }; -const updateLayout = ( +const mutateLayout = ( layouts: StickyNoteLayouts, uid: string, next: Partial @@ -215,7 +215,6 @@ const updateLayout = ( ...(current || defaultLayout(0, window.innerWidth, window.innerHeight)), ...next, }; - setLayouts(layouts); }; const getStickyRenderedIdFromUid = ({ @@ -419,6 +418,17 @@ const createStickyNoteElement = ({ let dragOffsetY = 0; let isDragging = false; let previousBodyUserSelect = ""; + let resizePersistTimeout: number | null = null; + + const scheduleLayoutPersistence = (): void => { + if (resizePersistTimeout) { + window.clearTimeout(resizePersistTimeout); + } + resizePersistTimeout = window.setTimeout(() => { + resizePersistTimeout = null; + setLayouts(layouts); + }, 250); + }; const onPointerMove = (event: PointerEvent): void => { if (!isDragging) { @@ -428,7 +438,7 @@ const createStickyNoteElement = ({ const y = event.clientY - dragOffsetY; note.style.left = `${x}px`; note.style.top = `${y}px`; - updateLayout(layouts, uid, { x, y }); + mutateLayout(layouts, uid, { x, y }); }; const onPointerUp = (): void => { @@ -440,6 +450,7 @@ const createStickyNoteElement = ({ document.body.style.userSelect = previousBodyUserSelect; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); + setLayouts(layouts); }; const onPointerDown = (event: PointerEvent): void => { @@ -467,7 +478,8 @@ const createStickyNoteElement = ({ "aria-label", nextMinimized ? "Expand sticky note" : "Minimize sticky note" ); - updateLayout(layouts, uid, { minimized: nextMinimized }); + mutateLayout(layouts, uid, { minimized: nextMinimized }); + setLayouts(layouts); window.requestAnimationFrame(() => { const activeElement = document.activeElement as HTMLElement | null; activeElement?.blur(); @@ -482,10 +494,11 @@ const createStickyNoteElement = ({ if (entry.target.classList.contains(NOTE_MINIMIZED_CLASS)) { return; } - updateLayout(layouts, uid, { + mutateLayout(layouts, uid, { width: Math.round(entry.target.offsetWidth), height: Math.round(entry.target.offsetHeight), }); + scheduleLayoutPersistence(); }); }); resizeObservers.add(resizeObserver); @@ -501,6 +514,10 @@ const createStickyNoteElement = ({ return; } + if (resizePersistTimeout) { + window.clearTimeout(resizePersistTimeout); + resizePersistTimeout = null; + } resizeObserver.disconnect(); resizeObservers.delete(resizeObserver); cleanupEmbeddedBlock(); From cf365c0119c9cb86293d53d06c033729113ec449 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:36:31 -0600 Subject: [PATCH 09/13] Update README.md to introduce Sticky Notes feature, detailing usage, functionality, and design principles --- README.md | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2fd1f03..e7585c2 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,30 @@ -# RoamJS Extension Base + + RoamJS Logo + -Stock base for [RoamJS](https://roamjs.com) Roam Research extensions. **Fork this repo** to start a new extension. +# Sticky Notes -## What's included +**Quick, ephemeral notes that float on top of your Roam Research graph.** Jot something down, drag it wherever you want, and when you're done—remove it. No clutter, no permanent page unless you want one. -- **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/sticky-notes) -## After forking +## What are Sticky Notes? -1. **Rename the repo** and update `package.json`: - - `name`: your extension slug (e.g. `my-extension`) - - `description`: one line describing the extension +Sticky Notes are lightweight notes that sit in a layer above your Roam graph. They're meant for **quick, temporary capture**: ideas you're playing with, reminders for the current session, or scratch space while you work. When you delete a sticky note, it's gone—the block is removed from your graph. No archive, no cleanup later. -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 +## Features -3. **Optional**: Add React components under `src/components/` (see [autocomplete](https://github.com/RoamJS/autocomplete), [giphy](https://github.com/RoamJS/giphy) for examples). +- **Full Roam inside every note** — Each sticky is a real Roam block. Use **tags** `#like-this`, **images**, **embeds**, **links**, and everything else you normally do in Roam. +- **Drag anywhere** — Grab the note by the header bar and drag it anywhere on your screen. Position and size are remembered for your session. +- **Resize** — Drag the corner or edge of a note to make it bigger or smaller. +- **Minimize** — Collapse a note to just its title bar when you want it out of the way but still visible. +- **Ephemeral by design** — Delete a note with the ✕ button and the block is removed from Roam. Perfect for throwaway thoughts and temporary scratch space. -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) +## How to use -## Scripts +1. **Create a sticky note** — Open the command palette (`Ctrl/Cmd + Shift + P`), run **"Sticky Notes: Create Sticky Note"**, and a new note appears. +2. **Move it** — Click and drag the colored header bar to place the note wherever you like. +3. **Edit** — Type in the note as you would in any Roam block. Use `#tags`, `/commands`, images, and links. +4. **Remove it** — Click the **✕** on the note when you're done. The note and its content are deleted from your graph. -- `npm start` — samepage dev (local development) -- `npm run build:roam` — build for Roam (dry run; CI runs `npx samepage build`) - -## License - -MIT +Sticky notes are stored under a single Roam page (`roam/js/sticky-note`) so they stay in your graph while they exist, but the extension is built so you can treat them as disposable: create, use, delete. From 3475c0c355f6db2866dc21bf202a0579735173ae Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:39:25 -0600 Subject: [PATCH 10/13] Adjust z-index values for sticky notes to improve layering and interaction. Set container z-index to a higher value for better visibility. --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 079f442..2166c95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -576,7 +576,7 @@ export default runExtension(async ({ extensionAPI }) => { min-width: 180px; min-height: 160px; pointer-events: auto; - z-index: 1000; + z-index: 1; transition: box-shadow 120ms ease; transform-origin: center center; } @@ -712,6 +712,7 @@ export default runExtension(async ({ extensionAPI }) => { container.style.width = "100%"; container.style.height = "100%"; container.style.pointerEvents = "none"; + container.style.zIndex = "2147483000"; document.body.append(container); const layouts = getLayouts(); From 9633523908f12d12896a6b156ead493d4a0addc1 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:42:11 -0600 Subject: [PATCH 11/13] Remove unused layouts from sticky notes based on existing UIDs to optimize performance and maintain clean state. --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2166c95..15a315d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -737,6 +737,11 @@ export default runExtension(async ({ extensionAPI }) => { }); container.append(note); } + for (const key of Object.keys(layouts)) { + if (!existingUids.includes(key)) { + delete layouts[key]; + } + } setLayouts(layouts); const createStickyNote = async (): Promise => { From 4a3fc9eb121764d0fece452863067ffd1db65801 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:58:01 -0600 Subject: [PATCH 12/13] Implement error handling in sticky note creation process to ensure proper rollback on failure and improve stability. --- src/index.ts | 77 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/src/index.ts b/src/index.ts index 15a315d..3afb7e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -712,7 +712,7 @@ export default runExtension(async ({ extensionAPI }) => { container.style.width = "100%"; container.style.height = "100%"; container.style.pointerEvents = "none"; - container.style.zIndex = "2147483000"; + container.style.zIndex = "19"; document.body.append(container); const layouts = getLayouts(); @@ -746,33 +746,54 @@ export default runExtension(async ({ extensionAPI }) => { const createStickyNote = async (): Promise => { const pageUid = await ensurePageUid(); - const uid = await createBlock({ - parentUid: pageUid, - order: "last", - node: { text: "Sticky Note" }, - }); - const firstContentUid = await createBlock({ - parentUid: uid, - order: 0, - node: { text: "" }, - }); - const layout = defaultLayout( - Object.keys(layouts).length, - window.innerWidth, - window.innerHeight - ); - layouts[uid] = layout; - setLayouts(layouts); - const note = createStickyNoteElement({ - uid, - layout, - layouts, - meta: { titleUid: uid, titleText: "Sticky Note" }, - resizeObservers, - blockUnmounts, - }); - container.append(note); - focusStickyRenderedUidWithRetries({ uid: firstContentUid, root: note }); + let uid: string | null = null; + let firstContentUid: string | null = null; + try { + uid = await createBlock({ + parentUid: pageUid, + order: "last", + node: { text: "Sticky Note" }, + }); + firstContentUid = await createBlock({ + parentUid: uid, + order: 0, + node: { text: "" }, + }); + + const layout = defaultLayout( + Object.keys(layouts).length, + window.innerWidth, + window.innerHeight + ); + layouts[uid] = layout; + setLayouts(layouts); + const note = createStickyNoteElement({ + uid, + layout, + layouts, + meta: { titleUid: uid, titleText: "Sticky Note" }, + resizeObservers, + blockUnmounts, + }); + container.append(note); + focusStickyRenderedUidWithRetries({ uid: firstContentUid, root: note }); + } catch (error) { + if (uid) { + try { + await window.roamAlphaAPI.deleteBlock({ block: { uid } }); + } catch (rollbackError) { + logRoamMutationError({ + operation: "deleteBlock", + uid, + error: rollbackError, + }); + } + delete layouts[uid]; + setLayouts(layouts); + } + console.error("[sticky-note] Failed to create sticky note", error); + throw error; + } }; await extensionAPI.ui.commandPalette.addCommand({ From 7bd3b58cd930df42670212d0ce1eb9720fb172d8 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 15:59:48 -0600 Subject: [PATCH 13/13] Add video demonstration to README.md for Sticky Notes feature --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e7585c2..a1ecc26 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RoamJS/sticky-notes) + + ## What are Sticky Notes? Sticky Notes are lightweight notes that sit in a layer above your Roam graph. They're meant for **quick, temporary capture**: ideas you're playing with, reminders for the current session, or scratch space while you work. When you delete a sticky note, it's gone—the block is removed from your graph. No archive, no cleanup later.