Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions src/SmartblocksMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void> =>
getTextByBlockUid(uid) === text
? Promise.resolve()
Expand All @@ -40,8 +41,8 @@ const waitForBlock = (uid: string, text: string): Promise<void> =>
const SmartblocksMenu = ({
onClose,
textarea,
trigger,
triggerStart,
triggerRegex,
isCustomOnly,
extensionAPI,
}: { onClose: () => void } & Props) => {
Expand Down Expand Up @@ -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;
Expand All @@ -84,26 +91,32 @@ 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%>"),
});
}, 10);
});
},
[menuRef, blockUid, onClose, triggerStart, textarea]
[menuRef, blockUid, getCurrentTextarea, onClose, trigger, triggerStart]
);
const keydownListener = useCallback(
(e: KeyboardEvent) => {
Expand Down Expand Up @@ -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 || "";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (value) {
setFilter(value);
} else {
Expand All @@ -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")
Expand Down
26 changes: 15 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: () => {
Expand Down
17 changes: 13 additions & 4 deletions src/utils/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -2751,6 +2759,7 @@ export const sbBomb = async ({
srcUid: string;
target: {
uid: string;
originalText?: string;
start?: number;
end?: number;
isParent?: boolean;
Expand All @@ -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}`,
Expand Down
44 changes: 44 additions & 0 deletions src/utils/getTriggerSelection.ts
Original file line number Diff line number Diff line change
@@ -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;
77 changes: 77 additions & 0 deletions tests/getTriggerSelection.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading