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 1/4] 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 2/4] 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 3/4] 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 4/4] 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({