From a2a9bf931bb310bf78e35c5af5830fda0d8f304f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 24 Dec 2025 05:04:14 -0800 Subject: [PATCH 01/16] fix: support multiple SmartBlock buttons with same label in one block Add occurrence tracking to correctly identify which button was clicked when multiple buttons share the same label. Uses matchAll() to find all button patterns and returns the Nth match based on position. Fixes #136 --- src/index.ts | 32 +++++++++++++++++++++++++++--- src/utils/parseSmartBlockButton.ts | 15 ++++++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 113a35e..f7ee059 100644 --- a/src/index.ts +++ b/src/index.ts @@ -598,16 +598,18 @@ export default runExtension(async ({ extensionAPI }) => { el, parentUid, hideIcon, + occurrenceIndex = 0, }: { textContent: string; text: string; el: HTMLElement; parentUid: string; hideIcon?: boolean; + occurrenceIndex?: number; }) => { // We include textcontent here bc there could be multiple smartblocks in a block const label = textContent.trim(); - const parsed = parseSmartBlockButton(label, text); + const parsed = parseSmartBlockButton(label, text, occurrenceIndex); if (parsed) { const { index, full, buttonContent, workflowName, variables } = parsed; const clickListener = () => { @@ -806,6 +808,9 @@ export default runExtension(async ({ extensionAPI }) => { }; const unloads = new Set<() => void>(); + // Track button occurrences per block: blockUid -> label -> count + const buttonOccurrences = new Map>(); + const buttonLogoObserver = createHTMLObserver({ className: "bp3-button bp3-small dont-focus-block", tag: "BUTTON", @@ -815,17 +820,38 @@ export default runExtension(async ({ extensionAPI }) => { const text = getTextByBlockUid(parentUid); b.setAttribute("data-roamjs-smartblock-button", "true"); - // We include textcontent here bc there could be multiple smartblocks in a block - // TODO: if multiple smartblocks have the same textContent, we need to distinguish them + // Track occurrence index for buttons with the same label in the same block + const label = (b.textContent || "").trim(); + if (!buttonOccurrences.has(parentUid)) { + buttonOccurrences.set(parentUid, new Map()); + } + const blockOccurrences = buttonOccurrences.get(parentUid)!; + const occurrenceIndex = blockOccurrences.get(label) || 0; + blockOccurrences.set(label, occurrenceIndex + 1); + const unload = registerElAsSmartBlockTrigger({ textContent: b.textContent || "", text, el: b, parentUid, + occurrenceIndex, }); unloads.add(() => { b.removeAttribute("data-roamjs-smartblock-button"); unload(); + // Clean up occurrence tracking when button is removed + const blockOccurrences = buttonOccurrences.get(parentUid); + if (blockOccurrences) { + const currentCount = blockOccurrences.get(label) || 0; + if (currentCount <= 1) { + blockOccurrences.delete(label); + if (blockOccurrences.size === 0) { + buttonOccurrences.delete(parentUid); + } + } else { + blockOccurrences.set(label, currentCount - 1); + } + } }); } }, diff --git a/src/utils/parseSmartBlockButton.ts b/src/utils/parseSmartBlockButton.ts index c28ac6d..d6e2ce0 100644 --- a/src/utils/parseSmartBlockButton.ts +++ b/src/utils/parseSmartBlockButton.ts @@ -1,6 +1,7 @@ export const parseSmartBlockButton = ( label: string, - text: string + text: string, + occurrenceIndex: number = 0 ): | { index: number; @@ -14,10 +15,16 @@ export const parseSmartBlockButton = ( const trimmedLabel = label.trim(); const buttonRegex = trimmedLabel ? new RegExp( - `{{(${trimmedLabel.replace(/\\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}` + `{{(${trimmedLabel.replace(/\\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`, + "g" ) - : /{{\s*:(?:42)?SmartBlock:(.*?)}}/; - const match = buttonRegex.exec(text); + : /{{\s*:(?:42)?SmartBlock:(.*?)}/g; + + // Find all matches + const matches = Array.from(text.matchAll(buttonRegex)); + if (matches.length === 0 || occurrenceIndex >= matches.length) return null; + + const match = matches[occurrenceIndex]; if (!match) return null; const index = match.index; const full = match[0]; From 2c9f199012fafd90a069538c85c2f3be8bb5285e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:29:53 -0800 Subject: [PATCH 02/16] Fix SmartBlock button regex and add occurrence tests --- src/utils/parseSmartBlockButton.ts | 4 +-- tests/buttonParsing.test.ts | 50 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/utils/parseSmartBlockButton.ts b/src/utils/parseSmartBlockButton.ts index d6e2ce0..cfd80a2 100644 --- a/src/utils/parseSmartBlockButton.ts +++ b/src/utils/parseSmartBlockButton.ts @@ -15,10 +15,10 @@ export const parseSmartBlockButton = ( const trimmedLabel = label.trim(); const buttonRegex = trimmedLabel ? new RegExp( - `{{(${trimmedLabel.replace(/\\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`, + `{{(${trimmedLabel.replace(/\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`, "g" ) - : /{{\s*:(?:42)?SmartBlock:(.*?)}/g; + : /{{\s*:(?:42)?SmartBlock:(.*?)}}/g; // Find all matches const matches = Array.from(text.matchAll(buttonRegex)); diff --git a/tests/buttonParsing.test.ts b/tests/buttonParsing.test.ts index 7336380..494132a 100644 --- a/tests/buttonParsing.test.ts +++ b/tests/buttonParsing.test.ts @@ -65,3 +65,53 @@ test("parses SmartBlock button for today's entry", () => { ButtonContent: "Create Today's Entry", }); }); + +test("parses multiple labeled SmartBlock buttons in the same block", () => { + const text = + "{{Run It:SmartBlock:WorkflowOne:RemoveButton=false}} and {{Run It:SmartBlock:WorkflowTwo:Icon=locate,Order=last}}"; + const first = parseSmartBlockButton("Run It", text, 0); + const second = parseSmartBlockButton("Run It", text, 1); + expect(first?.workflowName).toBe("WorkflowOne"); + expect(first?.variables).toMatchObject({ + RemoveButton: "false", + ButtonContent: "Run It", + }); + expect(second?.workflowName).toBe("WorkflowTwo"); + expect(second?.variables).toMatchObject({ + Icon: "locate", + Order: "last", + ButtonContent: "Run It", + }); +}); + +test("parses multiple unlabeled SmartBlock buttons in the same block", () => { + const text = + "{{:SmartBlock:first:Icon=locate}} and {{:SmartBlock:second:RemoveButton=false}}"; + const first = parseSmartBlockButton("", text, 0); + const second = parseSmartBlockButton("", text, 1); + expect(first?.buttonText).toBe("first:Icon=locate"); + expect(first?.variables).toMatchObject({ + Icon: "locate", + ButtonContent: "", + }); + expect(second?.buttonText).toBe("second:RemoveButton=false"); + expect(second?.variables).toMatchObject({ + RemoveButton: "false", + ButtonContent: "", + }); +}); + +test("returns null when occurrence index is out of bounds", () => { + const text = "{{Only One:SmartBlock:Workflow}}"; + const result = parseSmartBlockButton("Only One", text, 2); + expect(result).toBeNull(); +}); + +test("parses SmartBlock button label containing plus signs", () => { + const text = "{{Add+More:SmartBlock:PlusWorkflow}}"; + const result = parseSmartBlockButton("Add+More", text); + expect(result?.workflowName).toBe("PlusWorkflow"); + expect(result?.variables).toMatchObject({ + ButtonContent: "Add+More", + }); +}); From a02a65919135a334e82fa7bc64a8761ce0734427 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:38:20 -0800 Subject: [PATCH 03/16] Escape regex chars in SmartBlock labels --- src/utils/parseSmartBlockButton.ts | 4 +++- tests/buttonParsing.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/utils/parseSmartBlockButton.ts b/src/utils/parseSmartBlockButton.ts index cfd80a2..19d2014 100644 --- a/src/utils/parseSmartBlockButton.ts +++ b/src/utils/parseSmartBlockButton.ts @@ -12,10 +12,12 @@ export const parseSmartBlockButton = ( variables: Record; } | null => { + const escapeRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const trimmedLabel = label.trim(); const buttonRegex = trimmedLabel ? new RegExp( - `{{(${trimmedLabel.replace(/\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`, + `{{(${escapeRegex(trimmedLabel)}):(?:42)?SmartBlock:(.*?)}}`, "g" ) : /{{\s*:(?:42)?SmartBlock:(.*?)}}/g; diff --git a/tests/buttonParsing.test.ts b/tests/buttonParsing.test.ts index 494132a..9a9e875 100644 --- a/tests/buttonParsing.test.ts +++ b/tests/buttonParsing.test.ts @@ -115,3 +115,12 @@ test("parses SmartBlock button label containing plus signs", () => { ButtonContent: "Add+More", }); }); + +test("parses SmartBlock button label with regex special characters", () => { + const text = "{{Add+(Test)[One]?:SmartBlock:WeirdWorkflow}}"; + const result = parseSmartBlockButton("Add+(Test)[One]?", text); + expect(result?.workflowName).toBe("WeirdWorkflow"); + expect(result?.variables).toMatchObject({ + ButtonContent: "Add+(Test)[One]?", + }); +}); From a7bb3bd2fce8692fc85fa68da38aae96532b8fa0 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:46:46 -0800 Subject: [PATCH 04/16] Fix smartblock buttons after re-render --- src/index.ts | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index f7ee059..9a72013 100644 --- a/src/index.ts +++ b/src/index.ts @@ -810,6 +810,9 @@ export default runExtension(async ({ extensionAPI }) => { const unloads = new Set<() => void>(); // Track button occurrences per block: blockUid -> label -> count const buttonOccurrences = new Map>(); + const buttonTextByBlockUid = new Map(); + const buttonGenerationByBlockUid = new Map(); + const buttonCleanupByElement = new Map void>(); const buttonLogoObserver = createHTMLObserver({ className: "bp3-button bp3-small dont-focus-block", @@ -820,14 +823,24 @@ export default runExtension(async ({ extensionAPI }) => { const text = getTextByBlockUid(parentUid); b.setAttribute("data-roamjs-smartblock-button", "true"); - // Track occurrence index for buttons with the same label in the same block - const label = (b.textContent || "").trim(); - if (!buttonOccurrences.has(parentUid)) { + const cachedText = buttonTextByBlockUid.get(parentUid); + if (cachedText !== text) { + buttonTextByBlockUid.set(parentUid, text); + buttonGenerationByBlockUid.set( + parentUid, + (buttonGenerationByBlockUid.get(parentUid) || 0) + 1 + ); + buttonOccurrences.set(parentUid, new Map()); + } else if (!buttonOccurrences.has(parentUid)) { buttonOccurrences.set(parentUid, new Map()); } + + // Track occurrence index for buttons with the same label in the same block + const label = (b.textContent || "").trim(); const blockOccurrences = buttonOccurrences.get(parentUid)!; const occurrenceIndex = blockOccurrences.get(label) || 0; blockOccurrences.set(label, occurrenceIndex + 1); + const generation = buttonGenerationByBlockUid.get(parentUid) || 0; const unload = registerElAsSmartBlockTrigger({ textContent: b.textContent || "", @@ -836,9 +849,15 @@ export default runExtension(async ({ extensionAPI }) => { parentUid, occurrenceIndex, }); - unloads.add(() => { + const cleanup = () => { + if (!buttonCleanupByElement.has(b)) return; + buttonCleanupByElement.delete(b); + unloads.delete(cleanup); b.removeAttribute("data-roamjs-smartblock-button"); unload(); + if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) { + return; + } // Clean up occurrence tracking when button is removed const blockOccurrences = buttonOccurrences.get(parentUid); if (blockOccurrences) { @@ -852,9 +871,14 @@ export default runExtension(async ({ extensionAPI }) => { blockOccurrences.set(label, currentCount - 1); } } - }); + }; + buttonCleanupByElement.set(b, cleanup); + unloads.add(cleanup); } }, + removeCallback: (b) => { + buttonCleanupByElement.get(b)?.(); + }, }); const todoObserver = createHTMLObserver({ From 0f21eca27906d652c91bda746045f25dd5d64d0d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:49:01 -0800 Subject: [PATCH 05/16] refactor: move escapeRegex to module scope in parseSmartBlockButton Co-Authored-By: Claude Opus 4.6 --- src/utils/parseSmartBlockButton.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/utils/parseSmartBlockButton.ts b/src/utils/parseSmartBlockButton.ts index 19d2014..669ba1a 100644 --- a/src/utils/parseSmartBlockButton.ts +++ b/src/utils/parseSmartBlockButton.ts @@ -1,3 +1,6 @@ +const escapeRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + export const parseSmartBlockButton = ( label: string, text: string, @@ -12,8 +15,6 @@ export const parseSmartBlockButton = ( variables: Record; } | null => { - const escapeRegex = (value: string) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const trimmedLabel = label.trim(); const buttonRegex = trimmedLabel ? new RegExp( @@ -24,10 +25,15 @@ export const parseSmartBlockButton = ( // Find all matches const matches = Array.from(text.matchAll(buttonRegex)); - if (matches.length === 0 || occurrenceIndex >= matches.length) return null; + if ( + matches.length === 0 || + occurrenceIndex < 0 || + occurrenceIndex >= matches.length + ) { + return null; + } const match = matches[occurrenceIndex]; - if (!match) return null; const index = match.index; const full = match[0]; const buttonContent = trimmedLabel ? match[1] || "" : ""; From 0e6936821b0f5db94b4ac0b1d57df8724f552d2b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:49:04 -0800 Subject: [PATCH 06/16] Harden SmartBlock button re-render occurrence tracking --- src/index.ts | 30 ++++++++++++++++++++++++++++++ tests/buttonParsing.test.ts | 6 ++++++ 2 files changed, 36 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9a72013..bb9d3e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -813,6 +813,7 @@ export default runExtension(async ({ extensionAPI }) => { const buttonTextByBlockUid = new Map(); const buttonGenerationByBlockUid = new Map(); const buttonCleanupByElement = new Map void>(); + const buttonElementsByBlockUid = new Map>(); const buttonLogoObserver = createHTMLObserver({ className: "bp3-button bp3-small dont-focus-block", @@ -820,6 +821,17 @@ export default runExtension(async ({ extensionAPI }) => { callback: (b) => { const parentUid = getBlockUidFromTarget(b); if (parentUid && !b.hasAttribute("data-roamjs-smartblock-button")) { + // Clean up disconnected elements first so stale counts don't break + // occurrence tracking during re-renders. + const trackedButtons = buttonElementsByBlockUid.get(parentUid); + if (trackedButtons) { + Array.from(trackedButtons).forEach((el) => { + if (!el.isConnected) { + buttonCleanupByElement.get(el)?.(); + } + }); + } + const text = getTextByBlockUid(parentUid); b.setAttribute("data-roamjs-smartblock-button", "true"); @@ -855,6 +867,16 @@ export default runExtension(async ({ extensionAPI }) => { unloads.delete(cleanup); b.removeAttribute("data-roamjs-smartblock-button"); unload(); + const blockButtons = buttonElementsByBlockUid.get(parentUid); + if (blockButtons) { + blockButtons.delete(b); + if (blockButtons.size === 0) { + buttonElementsByBlockUid.delete(parentUid); + buttonOccurrences.delete(parentUid); + buttonTextByBlockUid.delete(parentUid); + buttonGenerationByBlockUid.delete(parentUid); + } + } if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) { return; } @@ -866,12 +888,20 @@ export default runExtension(async ({ extensionAPI }) => { blockOccurrences.delete(label); if (blockOccurrences.size === 0) { buttonOccurrences.delete(parentUid); + if (!buttonElementsByBlockUid.get(parentUid)?.size) { + buttonTextByBlockUid.delete(parentUid); + buttonGenerationByBlockUid.delete(parentUid); + } } } else { blockOccurrences.set(label, currentCount - 1); } } }; + const blockButtons = + buttonElementsByBlockUid.get(parentUid) || new Set(); + blockButtons.add(b); + buttonElementsByBlockUid.set(parentUid, blockButtons); buttonCleanupByElement.set(b, cleanup); unloads.add(cleanup); } diff --git a/tests/buttonParsing.test.ts b/tests/buttonParsing.test.ts index 9a9e875..13aa125 100644 --- a/tests/buttonParsing.test.ts +++ b/tests/buttonParsing.test.ts @@ -107,6 +107,12 @@ test("returns null when occurrence index is out of bounds", () => { expect(result).toBeNull(); }); +test("returns null when occurrence index is negative", () => { + const text = "{{Only One:SmartBlock:Workflow}}"; + const result = parseSmartBlockButton("Only One", text, -1); + expect(result).toBeNull(); +}); + test("parses SmartBlock button label containing plus signs", () => { const text = "{{Add+More:SmartBlock:PlusWorkflow}}"; const result = parseSmartBlockButton("Add+More", text); From a44cb44aedb0c0d9989f7810a98da85769702223 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:51:21 -0800 Subject: [PATCH 07/16] fix: clear button tracking Maps on extension unload Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index bb9d3e9..c80e0bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1039,6 +1039,10 @@ export default runExtension(async ({ extensionAPI }) => { unloads.forEach((u) => u()); window.clearTimeout(getDailyConfig()["next-run-timeout"]); saveDailyConfig({ "next-run-timeout": 0 }); + buttonOccurrences.clear(); + buttonTextByBlockUid.clear(); + buttonGenerationByBlockUid.clear(); + buttonCleanupByElement.clear(); }, }; }); From f53b15ae127836c9685e0621889697b3f5ee27ab Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:53:53 -0800 Subject: [PATCH 08/16] test: add edge-case tests for parseSmartBlockButton Co-Authored-By: Claude Opus 4.6 --- tests/buttonParsing.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/buttonParsing.test.ts b/tests/buttonParsing.test.ts index 13aa125..c8b199f 100644 --- a/tests/buttonParsing.test.ts +++ b/tests/buttonParsing.test.ts @@ -130,3 +130,28 @@ test("parses SmartBlock button label with regex special characters", () => { ButtonContent: "Add+(Test)[One]?", }); }); + +test("returns null for empty text", () => { + const result = parseSmartBlockButton("Run", ""); + expect(result).toBeNull(); +}); + +test("returns null for empty label with no SmartBlock buttons in text", () => { + const result = parseSmartBlockButton("", "just some regular text"); + expect(result).toBeNull(); +}); + +test("parses 42SmartBlock variant", () => { + const text = "{{Run:42SmartBlock:LegacyWorkflow}}"; + const result = parseSmartBlockButton("Run", text); + expect(result?.workflowName).toBe("LegacyWorkflow"); +}); + +test("handles label with leading/trailing whitespace", () => { + // The function trims the label, but the regex requires an exact match + // against the text content after `{{`. Since the text has spaces around + // "Run" that don't appear in the trimmed regex pattern, there is no match. + const text = "{{ Run :SmartBlock:TrimWorkflow}}"; + const result = parseSmartBlockButton(" Run ", text); + expect(result).toBeNull(); +}); From 20eba488a96d0717888124f9f23b9ae17df1b688 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:56:21 -0800 Subject: [PATCH 09/16] fix: restore #146 vim keybindings and #147 hotkey overflow from rebase Rebase silently dropped these changes. Restores: - popoverProps with overflow CSS for hotkey dropdown (#147) - Ctrl+n/j/p/k vim-style navigation in SmartBlocks menu (#146) Co-Authored-By: Claude Opus 4.6 --- src/HotKeyPanel.tsx | 1 + src/SmartblocksMenu.tsx | 10 ++++++++-- src/index.ts | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/HotKeyPanel.tsx b/src/HotKeyPanel.tsx index 3d69f10..fea02f6 100644 --- a/src/HotKeyPanel.tsx +++ b/src/HotKeyPanel.tsx @@ -81,6 +81,7 @@ const HotKeyEntry = ({ }} transformItem={(e) => workflowNamesByUid[e]} className={"w-full"} + popoverProps={{ portalClassName: "roamjs-hotkey-dropdown" }} />