diff --git a/src/index.ts b/src/index.ts index 113a35e..9a72013 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,12 @@ 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", tag: "BUTTON", @@ -815,20 +823,62 @@ 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 + 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 || "", text, el: b, 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) { + 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); + } + } + }; + buttonCleanupByElement.set(b, cleanup); + unloads.add(cleanup); } }, + removeCallback: (b) => { + buttonCleanupByElement.get(b)?.(); + }, }); const todoObserver = createHTMLObserver({ diff --git a/src/utils/parseSmartBlockButton.ts b/src/utils/parseSmartBlockButton.ts index c28ac6d..19d2014 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; @@ -11,13 +12,21 @@ 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:(.*?)}}/; - 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]; diff --git a/tests/buttonParsing.test.ts b/tests/buttonParsing.test.ts index 7336380..9a9e875 100644 --- a/tests/buttonParsing.test.ts +++ b/tests/buttonParsing.test.ts @@ -65,3 +65,62 @@ 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", + }); +}); + +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]?", + }); +});