From 69c915786d4b1dce61c9bdaeecd9da79eaa57ff9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 17 Dec 2025 23:17:50 -0500 Subject: [PATCH 1/7] allow reverse label creation --- .../DiscourseRelationTool.tsx | 11 +- .../DiscourseRelationUtil.tsx | 146 ++++++++++++++++-- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index 3e809406b..8c068c2bc 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -363,14 +363,17 @@ export const createAllRelationShapeTools = ( ); const relation = discourseContext.relations[name].find( - (r) => r.source === target?.type, + (r) => r.source === target?.type || r.destination === target?.type, ); if (relation) { this.shapeType = relation.id; } else { - const acceptableTypes = discourseContext.relations[name].map( - (r) => discourseContext.nodes[r.source].text, - ); + const acceptableTypes = discourseContext.relations[name] + .flatMap((r) => [ + discourseContext.nodes[r.source]?.text, + discourseContext.nodes[r.destination]?.text, + ]) + .filter(Boolean); const uniqueTypes = [...new Set(acceptableTypes)]; this.cancelAndWarn( `Starting node must be one of ${uniqueTypes.join(", ")}`, diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 5e40a22ad..eee8dd710 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -624,14 +624,30 @@ export const createAllRelationShapeUtils = ( const relations = Object.values(discourseContext.relations).flat(); const relation = relations.find((r) => r.id === arrow.type); if (!relation) return; - const possibleTargets = discourseContext.relations[relation.label] - .filter((r) => r.source === relation.source) - .map((r) => r.destination); - if (!possibleTargets.includes(target.type)) { - const uniqueTargets = [...new Set(possibleTargets)]; - const uniqueTargetTexts = uniqueTargets.map( - (t) => discourseContext.nodes[t].text, + const sourceNodeType = source.type; + const targetNodeType = target.type; + + const { isDirect, isReverse } = this.checkConnectionType( + relation, + sourceNodeType, + targetNodeType, + ); + + if (!isDirect && !isReverse) { + const possibleTargets = discourseContext.relations[relation.label] + .filter((r) => r.source === relation.source) + .map((r) => r.destination); + const possibleReverseTargets = discourseContext.relations[ + relation.label + ] + .filter((r) => r.destination === relation.source) + .map((r) => r.source); + const allPossibleTargets = [ + ...new Set([...possibleTargets, ...possibleReverseTargets]), + ]; + const uniqueTargetTexts = allPossibleTargets.map( + (t) => discourseContext.nodes[t]?.text || t, ); return deleteAndWarn( `Target node must be of type ${uniqueTargetTexts.join(", ")}`, @@ -657,8 +673,9 @@ export const createAllRelationShapeUtils = ( }); } } else { - const { triples, label: relationLabel } = relation; - const isOriginal = arrow.props.text === relationLabel; + const { triples } = relation; + const isOriginal = isDirect && !isReverse; + const newTriples = triples .map((t) => { if (/is a/i.test(t[1])) { @@ -845,6 +862,52 @@ export const createAllRelationShapeUtils = ( return update; } + // Validate target node type compatibility before creating binding + if ( + target.type !== "arrow" && + otherBinding && + target.id !== otherBinding.toId && + (!currentBinding || target.id !== currentBinding.toId) + ) { + const sourceNodeId = otherBinding.toId; + const sourceNode = this.editor.getShape(sourceNodeId); + const targetNodeType = target.type; + const sourceNodeType = sourceNode?.type; + + if (sourceNodeType && targetNodeType && shape.type) { + const isValidConnection = this.isValidNodeConnection( + sourceNodeType, + targetNodeType, + shape.type, + ); + + if (!isValidConnection) { + const sourceNodeTypeText = + discourseContext.nodes[sourceNodeType]?.text || sourceNodeType; + const targetNodeTypeText = + discourseContext.nodes[targetNodeType]?.text || targetNodeType; + const relations = Object.values( + discourseContext.relations, + ).flat(); + const relation = relations.find((r) => r.id === shape.type); + const relationLabel = relation?.label || shape.type; + + const errorMessage = `Cannot connect "${sourceNodeTypeText}" to "${targetNodeTypeText}" with "${relationLabel}" relation`; + dispatchToastEvent({ + id: `tldraw-invalid-connection-${shape.id}`, + title: "Invalid Connection", + description: errorMessage, + severity: "error", + }); + + removeArrowBinding(this.editor, shape, handleId); + update.props![handleId] = { x: handle.x, y: handle.y }; + this.editor.deleteShapes([shape.id]); + return update; + } + } + } + // we've got a target! the handle is being dragged over a shape, bind to it const targetGeometry = this.editor.getShapeGeometry(target); @@ -921,6 +984,37 @@ export const createAllRelationShapeUtils = ( this.editor.setHintingShapes([target.id]); const newBindings = getArrowBindings(this.editor, shape); + + // Check if both ends are bound and determine the correct text based on direction + if (newBindings.start && newBindings.end) { + const relations = Object.values(discourseContext.relations).flat(); + const relation = relations.find((r) => r.id === shape.type); + + if (relation) { + const startNode = this.editor.getShape(newBindings.start.toId); + const endNode = this.editor.getShape(newBindings.end.toId); + + if (startNode && endNode) { + const startNodeType = startNode.type; + const endNodeType = endNode.type; + + const { isDirect, isReverse } = this.checkConnectionType( + relation, + startNodeType, + endNodeType, + ); + + const newText = + isReverse && !isDirect ? relation.complement : relation.label; + + if (shape.props.text !== newText) { + update.props = update.props || {}; + update.props.text = newText; + } + } + } + } + if ( newBindings.start && newBindings.end && @@ -1601,6 +1695,40 @@ export class BaseDiscourseRelationUtil extends ShapeUtil ]; } + checkConnectionType( + relation: { source: string; destination: string }, + sourceNodeType: string, + targetNodeType: string, + ): { isDirect: boolean; isReverse: boolean } { + const isDirect = + sourceNodeType === relation.source && + targetNodeType === relation.destination; + + const isReverse = + sourceNodeType === relation.destination && + targetNodeType === relation.source; + + return { isDirect, isReverse }; + } + + isValidNodeConnection( + sourceNodeType: string, + targetNodeType: string, + relationId: string, + ): boolean { + const relations = Object.values(discourseContext.relations).flat(); + const relation = relations.find((r) => r.id === relationId); + if (!relation) return false; + + const { isDirect, isReverse } = this.checkConnectionType( + relation, + sourceNodeType, + targetNodeType, + ); + + return isDirect || isReverse; + } + component(shape: DiscourseRelationShape) { // eslint-disable-next-line react-hooks/rules-of-hooks // const theme = useDefaultColorTheme(); From 42cc630a60f7a717ac01b19db855df4ecb6761a6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 22 Jan 2026 10:24:06 -0500 Subject: [PATCH 2/7] cur progress --- .../DiscourseRelationUtil.tsx | 71 ++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index eee8dd710..d339ddefd 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -628,12 +628,35 @@ export const createAllRelationShapeUtils = ( const sourceNodeType = source.type; const targetNodeType = target.type; - const { isDirect, isReverse } = this.checkConnectionType( + // First check if the current relation matches + let { isDirect, isReverse } = this.checkConnectionType( relation, sourceNodeType, targetNodeType, ); + // If current relation doesn't match, check all relations with the same label + let matchingRelation = relation; + if (!isDirect && !isReverse) { + const relationsWithSameLabel = + discourseContext.relations[relation.label]; + if (relationsWithSameLabel) { + for (const rel of relationsWithSameLabel) { + const connectionCheck = this.checkConnectionType( + rel, + sourceNodeType, + targetNodeType, + ); + if (connectionCheck.isDirect || connectionCheck.isReverse) { + matchingRelation = rel; + isDirect = connectionCheck.isDirect; + isReverse = connectionCheck.isReverse; + break; + } + } + } + } + if (!isDirect && !isReverse) { const possibleTargets = discourseContext.relations[relation.label] .filter((r) => r.source === relation.source) @@ -653,8 +676,23 @@ export const createAllRelationShapeUtils = ( `Target node must be of type ${uniqueTargetTexts.join(", ")}`, ); } - if (arrow.type !== target.type) { - editor.updateShapes([{ id: arrow.id, type: target.type }]); + + // If we found a matching relation with a different ID, switch to it + if (matchingRelation.id !== arrow.type) { + // Get bindings before updating the shape type + const existingBindings = editor.getBindingsFromShape( + arrow, + arrow.type, + ); + // Update the shape type + editor.updateShapes([{ id: arrow.id, type: matchingRelation.id }]); + // Update bindings to use the new relation type + for (const binding of existingBindings) { + editor.updateBinding({ + ...binding, + type: matchingRelation.id, + }); + } } if (getStoredRelationsEnabled()) { const sourceAsDNS = asDiscourseNodeShape(source, editor); @@ -664,7 +702,7 @@ export const createAllRelationShapeUtils = ( await createReifiedRelation({ sourceUid: sourceAsDNS.props.uid, destinationUid: targetAsDNS.props.uid, - relationBlockUid: relation.id, + relationBlockUid: matchingRelation.id, }); else { void internalError({ @@ -673,7 +711,7 @@ export const createAllRelationShapeUtils = ( }); } } else { - const { triples } = relation; + const { triples } = matchingRelation; const isOriginal = isDirect && !isReverse; const newTriples = triples @@ -1720,13 +1758,24 @@ export class BaseDiscourseRelationUtil extends ShapeUtil const relation = relations.find((r) => r.id === relationId); if (!relation) return false; - const { isDirect, isReverse } = this.checkConnectionType( - relation, - sourceNodeType, - targetNodeType, - ); + // Get all relations with the same label as this relation + const relationsWithSameLabel = discourseContext.relations[relation.label]; + if (!relationsWithSameLabel) return false; + + // Check if any relation with the same label matches the source-target connection + for (const rel of relationsWithSameLabel) { + const { isDirect, isReverse } = this.checkConnectionType( + rel, + sourceNodeType, + targetNodeType, + ); - return isDirect || isReverse; + if (isDirect || isReverse) { + return true; + } + } + + return false; } component(shape: DiscourseRelationShape) { From 35f04e765ed3cdef1b685f215a8fe2ade9502786 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Mar 2026 17:15:27 -0400 Subject: [PATCH 3/7] allow working for multiple same-label relation --- .../DiscourseRelationUtil.tsx | 120 +++++++++--------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index d339ddefd..8c66fbe0a 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -65,6 +65,7 @@ import { } from "./helpers"; import { createReifiedRelation } from "~/utils/createReifiedBlock"; import { getStoredRelationsEnabled } from "~/utils/storedRelations"; +import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; import { discourseContext, isPageUid } from "~/components/canvas/Tldraw"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; @@ -628,48 +629,24 @@ export const createAllRelationShapeUtils = ( const sourceNodeType = source.type; const targetNodeType = target.type; - // First check if the current relation matches - let { isDirect, isReverse } = this.checkConnectionType( - relation, + // Check all relations with the same label for a match + const { + isDirect, + isReverse, + matchingRelation: foundRelation, + } = this.checkConnectionTypeAcrossLabel( + relation.label, sourceNodeType, targetNodeType, ); + const matchingRelation = foundRelation ?? relation; - // If current relation doesn't match, check all relations with the same label - let matchingRelation = relation; if (!isDirect && !isReverse) { - const relationsWithSameLabel = - discourseContext.relations[relation.label]; - if (relationsWithSameLabel) { - for (const rel of relationsWithSameLabel) { - const connectionCheck = this.checkConnectionType( - rel, - sourceNodeType, - targetNodeType, - ); - if (connectionCheck.isDirect || connectionCheck.isReverse) { - matchingRelation = rel; - isDirect = connectionCheck.isDirect; - isReverse = connectionCheck.isReverse; - break; - } - } - } - } - - if (!isDirect && !isReverse) { - const possibleTargets = discourseContext.relations[relation.label] - .filter((r) => r.source === relation.source) - .map((r) => r.destination); - const possibleReverseTargets = discourseContext.relations[ - relation.label - ] - .filter((r) => r.destination === relation.source) - .map((r) => r.source); - const allPossibleTargets = [ - ...new Set([...possibleTargets, ...possibleReverseTargets]), - ]; - const uniqueTargetTexts = allPossibleTargets.map( + const validTargets = this.getValidTargetTypes( + relation.label, + sourceNodeType, + ); + const uniqueTargetTexts = validTargets.map( (t) => discourseContext.nodes[t]?.text || t, ); return deleteAndWarn( @@ -1036,11 +1013,12 @@ export const createAllRelationShapeUtils = ( const startNodeType = startNode.type; const endNodeType = endNode.type; - const { isDirect, isReverse } = this.checkConnectionType( - relation, - startNodeType, - endNodeType, - ); + const { isDirect, isReverse } = + this.checkConnectionTypeAcrossLabel( + relation.label, + startNodeType, + endNodeType, + ); const newText = isReverse && !isDirect ? relation.complement : relation.label; @@ -1749,33 +1727,61 @@ export class BaseDiscourseRelationUtil extends ShapeUtil return { isDirect, isReverse }; } - isValidNodeConnection( + checkConnectionTypeAcrossLabel( + label: string, sourceNodeType: string, targetNodeType: string, - relationId: string, - ): boolean { - const relations = Object.values(discourseContext.relations).flat(); - const relation = relations.find((r) => r.id === relationId); - if (!relation) return false; - - // Get all relations with the same label as this relation - const relationsWithSameLabel = discourseContext.relations[relation.label]; - if (!relationsWithSameLabel) return false; + ): { + isDirect: boolean; + isReverse: boolean; + matchingRelation: DiscourseRelation | null; + } { + const relationsWithLabel = discourseContext.relations[label]; + if (!relationsWithLabel) { + return { isDirect: false, isReverse: false, matchingRelation: null }; + } - // Check if any relation with the same label matches the source-target connection - for (const rel of relationsWithSameLabel) { + for (const rel of relationsWithLabel) { const { isDirect, isReverse } = this.checkConnectionType( rel, sourceNodeType, targetNodeType, ); - if (isDirect || isReverse) { - return true; + return { isDirect, isReverse, matchingRelation: rel }; } } - return false; + return { isDirect: false, isReverse: false, matchingRelation: null }; + } + + getValidTargetTypes(label: string, sourceNodeType: string): string[] { + const relationsWithLabel = discourseContext.relations[label]; + if (!relationsWithLabel) return []; + + const targets = new Set(); + for (const rel of relationsWithLabel) { + if (rel.source === sourceNodeType) targets.add(rel.destination); + if (rel.destination === sourceNodeType) targets.add(rel.source); + } + return [...targets]; + } + + isValidNodeConnection( + sourceNodeType: string, + targetNodeType: string, + relationId: string, + ): boolean { + const relations = Object.values(discourseContext.relations).flat(); + const relation = relations.find((r) => r.id === relationId); + if (!relation) return false; + + const { isDirect, isReverse } = this.checkConnectionTypeAcrossLabel( + relation.label, + sourceNodeType, + targetNodeType, + ); + return isDirect || isReverse; } component(shape: DiscourseRelationShape) { From 97b6ad4ed965b8bd685b3cd8acd72b496b53ea94 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Mar 2026 17:36:53 -0400 Subject: [PATCH 4/7] address PR comments --- .../DiscourseRelationUtil.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 8c66fbe0a..0da47894c 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -675,12 +675,18 @@ export const createAllRelationShapeUtils = ( const sourceAsDNS = asDiscourseNodeShape(source, editor); const targetAsDNS = asDiscourseNodeShape(target, editor); - if (sourceAsDNS && targetAsDNS) + if (sourceAsDNS && targetAsDNS) { + const isOriginal = isDirect; await createReifiedRelation({ - sourceUid: sourceAsDNS.props.uid, - destinationUid: targetAsDNS.props.uid, + sourceUid: isOriginal + ? sourceAsDNS.props.uid + : targetAsDNS.props.uid, + destinationUid: isOriginal + ? targetAsDNS.props.uid + : sourceAsDNS.props.uid, relationBlockUid: matchingRelation.id, }); + } else { void internalError({ error: "attempt to create a relation between non discourse nodes", @@ -689,7 +695,7 @@ export const createAllRelationShapeUtils = ( } } else { const { triples } = matchingRelation; - const isOriginal = isDirect && !isReverse; + const isOriginal = isDirect; const newTriples = triples .map((t) => { From ce2faee458f22128104fcd99a843494749658411 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 25 Mar 2026 17:53:34 -0400 Subject: [PATCH 5/7] address PR comment --- .../DiscourseRelationUtil.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 0da47894c..9254b19be 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -903,27 +903,8 @@ export const createAllRelationShapeUtils = ( ); if (!isValidConnection) { - const sourceNodeTypeText = - discourseContext.nodes[sourceNodeType]?.text || sourceNodeType; - const targetNodeTypeText = - discourseContext.nodes[targetNodeType]?.text || targetNodeType; - const relations = Object.values( - discourseContext.relations, - ).flat(); - const relation = relations.find((r) => r.id === shape.type); - const relationLabel = relation?.label || shape.type; - - const errorMessage = `Cannot connect "${sourceNodeTypeText}" to "${targetNodeTypeText}" with "${relationLabel}" relation`; - dispatchToastEvent({ - id: `tldraw-invalid-connection-${shape.id}`, - title: "Invalid Connection", - description: errorMessage, - severity: "error", - }); - removeArrowBinding(this.editor, shape, handleId); update.props![handleId] = { x: handle.x, y: handle.y }; - this.editor.deleteShapes([shape.id]); return update; } } From 58781b303897f0aaffe68de4a4237b88b64e757d Mon Sep 17 00:00:00 2001 From: Trang Doan <44855874+trangdoan982@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:31:55 -0700 Subject: [PATCH 6/7] Update apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 9254b19be..5bd4defba 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -1008,7 +1008,7 @@ export const createAllRelationShapeUtils = ( ); const newText = - isReverse && !isDirect ? relation.complement : relation.label; + isReverse && !isDirect && relation.complement ? relation.complement : relation.label; if (shape.props.text !== newText) { update.props = update.props || {}; From 77b4078378a1b53fde962e98a56d8684950f103d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 26 Mar 2026 10:07:15 -0400 Subject: [PATCH 7/7] format and address PR comment --- .../DiscourseRelationShape/DiscourseRelationUtil.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 5bd4defba..46fd38643 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -686,8 +686,7 @@ export const createAllRelationShapeUtils = ( : sourceAsDNS.props.uid, relationBlockUid: matchingRelation.id, }); - } - else { + } else { void internalError({ error: "attempt to create a relation between non discourse nodes", type: "Canvas create relation", @@ -1000,15 +999,19 @@ export const createAllRelationShapeUtils = ( const startNodeType = startNode.type; const endNodeType = endNode.type; - const { isDirect, isReverse } = + const { isReverse, matchingRelation } = this.checkConnectionTypeAcrossLabel( relation.label, startNodeType, endNodeType, ); + const effectiveRelation = matchingRelation ?? relation; + const newText = - isReverse && !isDirect && relation.complement ? relation.complement : relation.label; + isReverse && effectiveRelation.complement + ? effectiveRelation.complement + : effectiveRelation.label; if (shape.props.text !== newText) { update.props = update.props || {};