@@ -29,6 +29,8 @@ import { Button, Icon } from "@blueprintjs/core";
2929import { DiscourseNode } from "~/utils/getDiscourseNodes" ;
3030import { isPageUid } from "./Tldraw" ;
3131import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog" ;
32+ import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid" ;
33+ import { getCleanTagText } from "~/components/settings/NodeConfig" ;
3234import { discourseContext } from "./Tldraw" ;
3335import getDiscourseContextResults from "~/utils/getDiscourseContextResults" ;
3436import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg" ;
@@ -44,6 +46,8 @@ import DiscourseContextOverlay from "~/components/DiscourseContextOverlay";
4446import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors" ;
4547import { 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
4953const 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