diff --git a/src/SmartblocksMenu.tsx b/src/SmartblocksMenu.tsx index ec496c6..fdeb4c3 100644 --- a/src/SmartblocksMenu.tsx +++ b/src/SmartblocksMenu.tsx @@ -13,6 +13,7 @@ import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTit import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import type { OnloadArgs } from "roamjs-components/types/native"; import { getCoords } from "./utils/dom"; +import getTriggerSelection from "./utils/getTriggerSelection"; import { getVisibleCustomWorkflows, predefinedWorkflows, @@ -23,13 +24,13 @@ import fuzzy from "fuzzy"; type Props = { textarea: HTMLTextAreaElement; + trigger: string; triggerStart: number; - triggerRegex: RegExp; isCustomOnly: boolean; extensionAPI: OnloadArgs["extensionAPI"]; }; -// The block doesn't always have the trigger saved, causing weird race condition errors +// Roam may not have persisted the latest textarea input when a workflow is selected. const waitForBlock = (uid: string, text: string): Promise => getTextByBlockUid(uid) === text ? Promise.resolve() @@ -40,8 +41,8 @@ const waitForBlock = (uid: string, text: string): Promise => const SmartblocksMenu = ({ onClose, textarea, + trigger, triggerStart, - triggerRegex, isCustomOnly, extensionAPI, }: { onClose: () => void } & Props) => { @@ -75,6 +76,12 @@ const SmartblocksMenu = ({ [filter, initialWorkflows] ); const [activeIndex, setActiveIndex] = useState(0); + const getCurrentTextarea = useCallback( + () => + (document.getElementById(textarea.id) as HTMLTextAreaElement | null) || + textarea, + [textarea] + ); const onSelect = useCallback( (index) => { if (!menuRef.current) return; @@ -84,18 +91,24 @@ const SmartblocksMenu = ({ const srcName = item.getAttribute("data-name"); const srcUid = item.getAttribute("data-uid"); if (!srcName || !srcUid) return; - const currentTextarea = document.getElementById( - textarea.id - ) as HTMLTextAreaElement; - waitForBlock(blockUid, textarea.value).then(() => { + const currentTextarea = getCurrentTextarea(); + const selection = getTriggerSelection({ + text: currentTextarea.value, + selectionStart: currentTextarea.selectionStart, + trigger, + triggerStart, + }); + if (!selection) return onClose(); + waitForBlock(blockUid, selection.text).then(() => { onClose(); setTimeout(() => { sbBomb({ srcUid, target: { uid: blockUid, - start: triggerStart, - end: currentTextarea.selectionStart, + originalText: selection.text, + start: selection.start, + end: selection.end, windowId, }, mutableCursor: !srcName.includes("<%NOCURSOR%>"), @@ -103,7 +116,7 @@ const SmartblocksMenu = ({ }, 10); }); }, - [menuRef, blockUid, onClose, triggerStart, textarea] + [menuRef, blockUid, getCurrentTextarea, onClose, trigger, triggerStart] ); const keydownListener = useCallback( (e: KeyboardEvent) => { @@ -137,10 +150,14 @@ const SmartblocksMenu = ({ } else if (e.key === "Escape") { onClose(); } else { + const currentTextarea = getCurrentTextarea(); const value = - triggerRegex.exec( - textarea.value.substring(0, textarea.selectionStart) - )?.[1] || ""; + getTriggerSelection({ + text: currentTextarea.value, + selectionStart: currentTextarea.selectionStart, + trigger, + triggerStart, + })?.filter || ""; if (value) { setFilter(value); } else { @@ -149,7 +166,15 @@ const SmartblocksMenu = ({ } } }, - [menuRef, setActiveIndex, setFilter, onClose, triggerRegex, textarea] + [ + menuRef, + setActiveIndex, + setFilter, + onClose, + getCurrentTextarea, + trigger, + triggerStart, + ] ); useEffect(() => { const listeningEl = !!textarea.closest(".rm-reference-item") diff --git a/src/index.ts b/src/index.ts index fd50bac..d9c583c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,9 @@ import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParen import { zCommandOutput } from "./utils/zodTypes"; import { IconNames } from "@blueprintjs/icons"; import parseSmartBlockButton from "./utils/parseSmartBlockButton"; +import getTriggerSelection, { + resolveTrigger, +} from "./utils/getTriggerSelection"; const getLegacy42Setting = (name: string) => { const settings = Object.fromEntries( @@ -194,14 +197,11 @@ export default runExtension(async ({ extensionAPI }) => { }; let trigger = "jj"; - let triggerRegex = /$^/; const refreshTrigger = (value: string) => { - trigger = (getLegacy42Setting("SmartBlockTrigger") || value || "jj") - .replace(/"/g, "") - .replace(/\\/g, "\\\\") - .replace(/\+/g, "\\+") - .trim(); - triggerRegex = new RegExp(`${trigger}(.*)$`); + trigger = resolveTrigger({ + legacyTrigger: getLegacy42Setting("SmartBlockTrigger"), + value, + }); }; let isCustomOnly = extensionAPI.settings.get("custom-only") as boolean; @@ -405,13 +405,17 @@ export default runExtension(async ({ extensionAPI }) => { 0, textarea.selectionStart ); - const match = triggerRegex.exec(valueToCursor); - if (match) { + const selection = getTriggerSelection({ + text: textarea.value, + selectionStart: textarea.selectionStart, + trigger, + }); + if (selection) { menuLoaded = true; render({ textarea, - triggerRegex, - triggerStart: match.index, + trigger, + triggerStart: selection.start, isCustomOnly, extensionAPI, onClose: () => { diff --git a/src/utils/core.ts b/src/utils/core.ts index e399e5d..c24cc2d 100644 --- a/src/utils/core.ts +++ b/src/utils/core.ts @@ -2742,7 +2742,15 @@ const count = (t: InputTextNode[] = []): number => export const sbBomb = async ({ srcUid, - target: { uid, start = 0, end = start, isParent = false, order, windowId }, + target: { + uid, + originalText, + start = 0, + end = start, + isParent = false, + order, + windowId, + }, variables = {}, mutableCursor, triggerUid = uid, @@ -2751,6 +2759,7 @@ export const sbBomb = async ({ srcUid: string; target: { uid: string; + originalText?: string; start?: number; end?: number; isParent?: boolean; @@ -2770,9 +2779,9 @@ export const sbBomb = async ({ const props: { introUid?: string; introContent?: string; suffix?: string } = {}; if (!isParent) { - const originalText = getTextByBlockUid(uid); - const prefix = originalText.substring(0, start); - const suffix = originalText.substring(end); + const text = originalText ?? getTextByBlockUid(uid); + const prefix = text.substring(0, start); + const suffix = text.substring(end); await updateBlock({ uid, text: `${prefix}${suffix}`, diff --git a/src/utils/getTriggerSelection.ts b/src/utils/getTriggerSelection.ts new file mode 100644 index 0000000..bc31131 --- /dev/null +++ b/src/utils/getTriggerSelection.ts @@ -0,0 +1,44 @@ +export type TriggerSelection = { + text: string; + start: number; + end: number; + filter: string; +}; + +export const resolveTrigger = ({ + legacyTrigger, + value, +}: { + legacyTrigger: string; + value: unknown; +}): string => + (legacyTrigger || (typeof value === "string" ? value : "jj")) + .replace(/"/g, "") + .trim(); + +const getTriggerSelection = ({ + text, + selectionStart, + trigger, + triggerStart, +}: { + text: string; + selectionStart: number; + trigger: string; + triggerStart?: number; +}): TriggerSelection | undefined => { + if (!trigger) return undefined; + const valueToCursor = text.substring(0, selectionStart); + const start = triggerStart ?? valueToCursor.lastIndexOf(trigger); + return start < 0 || + valueToCursor.substring(start, start + trigger.length) !== trigger + ? undefined + : { + text, + start, + end: selectionStart, + filter: valueToCursor.substring(start + trigger.length), + }; +}; + +export default getTriggerSelection; diff --git a/tests/getTriggerSelection.test.ts b/tests/getTriggerSelection.test.ts new file mode 100644 index 0000000..0e28ffa --- /dev/null +++ b/tests/getTriggerSelection.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test"; +import getTriggerSelection, { + resolveTrigger, +} from "../src/utils/getTriggerSelection"; + +test("uses the trigger occurrence nearest the cursor", () => { + expect( + getTriggerSelection({ + text: "beforejjmiddlejjworkflow after", + selectionStart: 24, + trigger: "jj", + }) + ).toEqual({ + text: "beforejjmiddlejjworkflow after", + start: 14, + end: 24, + filter: "workflow", + }); +}); + +test("treats regular expression characters in a trigger literally", () => { + expect( + getTriggerSelection({ + text: "keep.*first.*second suffix", + selectionStart: 19, + trigger: ".*", + }) + ).toEqual({ + text: "keep.*first.*second suffix", + start: 11, + end: 19, + filter: "second", + }); +}); + +test("keeps the trigger occurrence that opened the menu", () => { + expect( + getTriggerSelection({ + text: "/daily/plan suffix", + selectionStart: 11, + trigger: "/", + triggerStart: 0, + }) + ).toEqual({ + text: "/daily/plan suffix", + start: 0, + end: 11, + filter: "daily/plan", + }); +}); + +test("returns a single text snapshot for the splice offsets", () => { + const selection = getTriggerSelection({ + text: "keep this jjwrap suffix", + selectionStart: 16, + trigger: "jj", + }); + expect(selection).toBeTruthy(); + expect( + `${selection?.text.substring(0, selection.start)}${selection?.text.substring( + selection.end + )}` + ).toBe("keep this suffix"); +}); + +test("an empty trigger is disabled", () => { + const trigger = resolveTrigger({ legacyTrigger: "", value: "" }); + expect(trigger).toBe(""); + expect( + getTriggerSelection({ text: "unchanged", selectionStart: 9, trigger }) + ).toBeUndefined(); +}); + +test("defaults an unset trigger while preserving a legacy trigger", () => { + expect(resolveTrigger({ legacyTrigger: "", value: undefined })).toBe("jj"); + expect(resolveTrigger({ legacyTrigger: " zz ", value: "new" })).toBe("zz"); +});