Skip to content

Commit c8e4be5

Browse files
trangdoan982claudedevin-ai-integration[bot]
authored
[ENG-1125] Add Convert to Node Type button on canvas block shapes (#867)
* [ENG-1125] Add "Convert to Node Type" button on canvas block shapes When a blck-node shape contains a discourse node tag (e.g. #claim), show a convert button that opens ModifyNodeDialog to formalize the block into a proper discourse node shape. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * address PR comments * use pattern from nodetag for regex instead * format * follow node tag pattern * Update apps/roam/src/components/canvas/DiscourseNodeUtil.tsx Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * address PR comment --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent f053406 commit c8e4be5

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

apps/roam/src/components/canvas/DiscourseNodeUtil.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { Button, Icon } from "@blueprintjs/core";
2929
import { DiscourseNode } from "~/utils/getDiscourseNodes";
3030
import { isPageUid } from "./Tldraw";
3131
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
32+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
33+
import { getCleanTagText } from "~/components/settings/NodeConfig";
3234
import { discourseContext } from "./Tldraw";
3335
import getDiscourseContextResults from "~/utils/getDiscourseContextResults";
3436
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
@@ -44,6 +46,8 @@ import DiscourseContextOverlay from "~/components/DiscourseContextOverlay";
4446
import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
4547
import { render as renderToast } from "roamjs-components/components/Toast";
4648

49+
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50+
4751
// TODO REPLACE WITH TLDRAW DEFAULTS
4852
// https://github.com/tldraw/tldraw/pull/1580/files
4953
const TEXT_PROPS = {
@@ -448,6 +452,33 @@ export class BaseDiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape>
448452
const [overlayMounted, setOverlayMounted] = useState(false);
449453
// eslint-disable-next-line react-hooks/rules-of-hooks
450454
const dialogRenderedRef = useRef(false);
455+
456+
// Detect discourse node tags in block text for blck-node shapes
457+
// eslint-disable-next-line react-hooks/rules-of-hooks
458+
const matchedNodeForConversion = useMemo(() => {
459+
if (shape.type !== "blck-node") return null;
460+
if (!isLiveBlock(shape.props.uid)) return null;
461+
const blockText = getTextByBlockUid(shape.props.uid);
462+
if (!blockText) return null;
463+
const nodes = Object.values(discourseContext.nodes);
464+
const tagPattern = /#(?:\[\[([^\]]*)\]\]|([^\s#[\]]+))/g;
465+
for (const node of nodes) {
466+
const tag = node.tag;
467+
if (!tag) continue;
468+
const normalizedNodeTag = getCleanTagText(tag);
469+
let match;
470+
tagPattern.lastIndex = 0;
471+
while ((match = tagPattern.exec(blockText)) !== null) {
472+
const tagFromBlock = match[1] ?? match[2] ?? "";
473+
const normalizedBlockTag = getCleanTagText(tagFromBlock);
474+
if (normalizedBlockTag === normalizedNodeTag) {
475+
return { node, blockText };
476+
}
477+
}
478+
}
479+
return null;
480+
}, [shape.type, shape.props.uid]);
481+
451482
// eslint-disable-next-line react-hooks/rules-of-hooks
452483
useEffect(() => {
453484
if (
@@ -589,6 +620,93 @@ export class BaseDiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape>
589620
title="Open in sidebar (Shift+Click)"
590621
/>
591622

623+
{/* Convert to Node Type Button */}
624+
{matchedNodeForConversion && (
625+
<Button
626+
className="absolute left-7 top-1 z-10"
627+
minimal
628+
small
629+
icon={
630+
<Icon icon="plus" color={textColor} className="opacity-50" />
631+
}
632+
onClick={(e) => {
633+
e.stopPropagation();
634+
const { node, blockText } = matchedNodeForConversion;
635+
const tag = node.tag;
636+
if (!tag) return;
637+
const cleanTag = getCleanTagText(tag);
638+
const escapedCleanTag = escapeRegExp(cleanTag);
639+
// Strip the tag from block text (same pattern as detection above)
640+
const cleanedText = blockText
641+
.replace(
642+
new RegExp(`#\\[\\[${escapedCleanTag}\\]\\]`, "i"),
643+
"",
644+
)
645+
.replace(new RegExp(`#${escapedCleanTag}`, "i"), "")
646+
.trim();
647+
const { x, y } = shape;
648+
renderModifyNodeDialog({
649+
mode: "create",
650+
nodeType: node.type,
651+
initialValue: { text: cleanedText, uid: "" },
652+
extensionAPI,
653+
includeDefaultNodes: true,
654+
onSuccess: async ({ text, uid }) => {
655+
if (!extensionAPI) return;
656+
try {
657+
const {
658+
h,
659+
w,
660+
imageUrl: nodeImageUrl,
661+
} = await calcCanvasNodeSizeAndImg({
662+
nodeText: text,
663+
extensionAPI,
664+
nodeType: node.type,
665+
uid,
666+
});
667+
editor.createShapes([
668+
{
669+
type: node.type,
670+
id: createShapeId(),
671+
props: {
672+
uid,
673+
title: text,
674+
h,
675+
w,
676+
imageUrl: nodeImageUrl,
677+
fontFamily: "sans",
678+
size: "s",
679+
},
680+
x,
681+
y,
682+
},
683+
]);
684+
editor.deleteShapes([shape.id]);
685+
} catch (error) {
686+
renderToast({
687+
id: `discourse-node-convert-error-${Date.now()}`,
688+
intent: "danger",
689+
content: (
690+
<span>Error converting block: {String(error)}</span>
691+
),
692+
});
693+
}
694+
},
695+
onClose: () => {},
696+
});
697+
}}
698+
onPointerDown={(e) => e.stopPropagation()}
699+
title={`Convert to ${matchedNodeForConversion.node.text}`}
700+
>
701+
<span
702+
className="opacity-70"
703+
style={{ color: textColor, fontSize: "11px" }}
704+
>
705+
Convert to {matchedNodeForConversion.node.text}
706+
</span>
707+
</Button>
708+
)}
709+
592710
{shape.props.imageUrl && isKeyImage === "true" ? (
593711
<div className="mt-2 flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
594712
<img

0 commit comments

Comments
 (0)