Skip to content

Commit 8c4fa43

Browse files
ENG-1831: Convert native tldraw arrows to discourse relations (Obsidian) (#1118)
* ENG-1831: Convert native tldraw arrows to discourse relations on Obsidian canvas. Persist to relations.json before swapping the shape so failed saves leave the native arrow unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * ENG-1831: Roll back persisted relation when canvas conversion fails. Also pass targetCanvasId on reifyRelation success toasts for consistency. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback for arrow-to-relation conversion. Normalize reverse-only schema edges before persisting, share isDiscourseNodeShape via relationTypeUtils, and remove redundant console.error alongside toast. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix arrow shape narrowing for TypeScript check-types. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9e41900 commit 8c4fa43

6 files changed

Lines changed: 539 additions & 84 deletions

File tree

apps/obsidian/src/components/canvas/CustomContextMenu.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
import type { TFile } from "obsidian";
1212
import { usePlugin } from "~/components/PluginContext";
1313
import { convertToDiscourseNode } from "./utils/convertToDiscourseNode";
14+
import {
15+
convertArrowToDiscourseRelation,
16+
getValidRelationTypesForArrow,
17+
} from "./utils/convertArrowToDiscourseRelation";
1418

1519
type CustomContextMenuProps = {
1620
canvasFile: TFile;
@@ -34,9 +38,54 @@ export const CustomContextMenu = ({
3438
selectedShape &&
3539
(selectedShape.type === "text" || selectedShape.type === "image");
3640

41+
const isReadonly = useValue(
42+
"isReadonly",
43+
() => editor.getInstanceState().isReadonly,
44+
[editor],
45+
);
46+
47+
const validRelationTypes = useValue(
48+
"validRelationTypes",
49+
() => {
50+
if (!selectedShape || selectedShape.type !== "arrow") return [];
51+
return getValidRelationTypesForArrow({
52+
editor,
53+
plugin,
54+
arrowId: selectedShape.id,
55+
});
56+
},
57+
[editor, plugin, selectedShape?.id, selectedShape?.type],
58+
);
59+
60+
const shouldShowRelationMenu =
61+
selectedShape?.type === "arrow" && validRelationTypes.length > 0;
62+
3763
return (
3864
<DefaultContextMenu {...props}>
3965
<DefaultContextMenuContent />
66+
{shouldShowRelationMenu && (
67+
<TldrawUiMenuGroup id="relation">
68+
<TldrawUiMenuSubmenu id="relation-submenu" label="Relation">
69+
{validRelationTypes.map((relationType) => (
70+
<TldrawUiMenuItem
71+
key={relationType.id}
72+
id={`relation-${relationType.id}`}
73+
label={relationType.label}
74+
disabled={isReadonly}
75+
onSelect={() => {
76+
void convertArrowToDiscourseRelation({
77+
editor,
78+
plugin,
79+
canvasFile,
80+
arrowId: selectedShape.id,
81+
relationTypeId: relationType.id,
82+
});
83+
}}
84+
/>
85+
))}
86+
</TldrawUiMenuSubmenu>
87+
</TldrawUiMenuGroup>
88+
)}
4089
{shouldShowConvertTo && (
4190
<TldrawUiMenuGroup id="convert-to">
4291
<TldrawUiMenuSubmenu id="convert-to-submenu" label="Convert To">
@@ -63,4 +112,3 @@ export const CustomContextMenu = ({
63112
</DefaultContextMenu>
64113
);
65114
};
66-

apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ export class BaseRelationBindingUtil extends BindingUtil<RelationBinding> {
139139
}
140140
}
141141

142+
static isRelationReified(shapeId: TLShapeId): boolean {
143+
return BaseRelationBindingUtil.reifiedArrows.has(shapeId);
144+
}
145+
146+
static markRelationReified(shapeId: TLShapeId): void {
147+
BaseRelationBindingUtil.reifiedArrows.add(shapeId);
148+
}
149+
150+
static unmarkRelationReified(shapeId: TLShapeId): void {
151+
BaseRelationBindingUtil.reifiedArrows.delete(shapeId);
152+
}
153+
142154
/**
143155
* Check selected relation shapes for completed bindings
144156
* Called from mouseup event handler
@@ -150,19 +162,20 @@ export class BaseRelationBindingUtil extends BindingUtil<RelationBinding> {
150162
) as DiscourseRelationShape[];
151163

152164
relationShapes.forEach((arrow) => {
165+
if (arrow.meta.relationInstanceId) return;
166+
153167
const bindings = getArrowBindings(editor, arrow);
154168
if (
155169
bindings.start &&
156170
bindings.end &&
157-
!BaseRelationBindingUtil.reifiedArrows.has(arrow.id)
171+
!BaseRelationBindingUtil.isRelationReified(arrow.id)
158172
) {
159-
BaseRelationBindingUtil.reifiedArrows.add(arrow.id);
160173
const util = editor.getShapeUtil(arrow);
161174
if (util instanceof DiscourseRelationUtil) {
175+
BaseRelationBindingUtil.markRelationReified(arrow.id);
162176
util.reifyRelation(arrow, bindings).catch((error) => {
163177
console.error("Failed to reify relation:", error);
164-
// Remove from reified set on error so it can be retried
165-
BaseRelationBindingUtil.reifiedArrows.delete(arrow.id);
178+
BaseRelationBindingUtil.unmarkRelationReified(arrow.id);
166179
});
167180
}
168181
}

apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx

Lines changed: 45 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ import {
5858
updateArrowTerminal,
5959
} from "~/components/canvas/utils/relationUtils";
6060
import { RelationBindings } from "./DiscourseRelationBinding";
61-
import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape";
62-
import { addRelationToRelationsJson } from "~/components/canvas/utils/relationJsonUtils";
61+
import { DiscourseNodeShape } from "./DiscourseNodeShape";
62+
import { persistRelationBetweenNodeShapes } from "~/components/canvas/utils/relationJsonUtils";
6363
import {
6464
getDiscourseNodeAtPoint,
6565
getDiscourseNodeTypeId,
66-
getRelationDirection,
66+
getRelationLabelForDirection,
6767
isValidRelationConnection,
6868
} from "~/components/canvas/utils/relationTypeUtils";
6969
import { getNodeTypeById, getRelationTypeById } from "~/utils/typeUtils";
@@ -1084,20 +1084,13 @@ export class DiscourseRelationUtil extends ShapeUtil<DiscourseRelationShape> {
10841084

10851085
if (!relationType) return;
10861086

1087-
const { direct, reverse } = getRelationDirection({
1087+
const newText = getRelationLabelForDirection({
10881088
discourseRelations: plugin.settings.discourseRelations,
1089-
relationTypeId,
1089+
relationType,
10901090
sourceNodeTypeId: startNodeTypeId,
10911091
targetNodeTypeId: endNodeTypeId,
10921092
});
10931093

1094-
let newText = relationType.label; // Default to main label
1095-
1096-
if (reverse && !direct) {
1097-
// This is purely a reverse connection, use complement
1098-
newText = relationType.complement;
1099-
}
1100-
11011094
// Update the shape text if it's different
11021095
if (shape.props.text !== newText) {
11031096
this.editor.updateShapes([
@@ -1122,77 +1115,53 @@ export class DiscourseRelationUtil extends ShapeUtil<DiscourseRelationShape> {
11221115
return;
11231116
}
11241117

1125-
try {
1126-
const startNode = this.editor.getShape(bindings.start.toId);
1127-
const endNode = this.editor.getShape(bindings.end.toId);
1128-
1129-
if (
1130-
!startNode ||
1131-
!endNode ||
1132-
startNode.type !== "discourse-node" ||
1133-
endNode.type !== "discourse-node"
1134-
) {
1135-
return;
1136-
}
1137-
1138-
const startNodeUtil = this.editor.getShapeUtil(startNode);
1139-
const endNodeUtil = this.editor.getShapeUtil(endNode);
1118+
const startNode = this.editor.getShape(bindings.start.toId);
1119+
const endNode = this.editor.getShape(bindings.end.toId);
11401120

1141-
// Get the files associated with both nodes
1142-
const sourceFile = await (startNodeUtil as DiscourseNodeUtil).getFile(
1143-
startNode as DiscourseNodeShape,
1144-
{
1145-
app: this.options.app,
1146-
canvasFile: this.options.canvasFile,
1147-
},
1148-
);
1149-
const targetFile = await (endNodeUtil as DiscourseNodeUtil).getFile(
1150-
endNode as DiscourseNodeShape,
1151-
{
1152-
app: this.options.app,
1153-
canvasFile: this.options.canvasFile,
1154-
},
1155-
);
1121+
if (
1122+
!startNode ||
1123+
!endNode ||
1124+
startNode.type !== "discourse-node" ||
1125+
endNode.type !== "discourse-node"
1126+
) {
1127+
return;
1128+
}
11561129

1157-
if (!sourceFile || !targetFile) {
1158-
console.warn("Could not resolve files for relation nodes");
1159-
return;
1160-
}
1130+
const persistResult = await persistRelationBetweenNodeShapes({
1131+
plugin: this.options.plugin,
1132+
canvasFile: this.options.canvasFile,
1133+
editor: this.editor,
1134+
startNode: startNode as DiscourseNodeShape,
1135+
endNode: endNode as DiscourseNodeShape,
1136+
relationTypeId: shape.props.relationTypeId,
1137+
});
11611138

1162-
const { alreadyExisted, relationInstanceId } =
1163-
await addRelationToRelationsJson({
1164-
plugin: this.options.plugin,
1165-
sourceFile,
1166-
targetFile,
1167-
relationTypeId: shape.props.relationTypeId,
1168-
});
1139+
if (!persistResult.ok) {
1140+
return;
1141+
}
11691142

1170-
if (relationInstanceId) {
1171-
this.editor.updateShape({
1172-
id: shape.id,
1173-
type: shape.type,
1174-
meta: { ...shape.meta, relationInstanceId },
1175-
});
1176-
}
1143+
if (persistResult.relationInstanceId) {
1144+
this.editor.updateShape({
1145+
id: shape.id,
1146+
type: shape.type,
1147+
meta: {
1148+
...shape.meta,
1149+
relationInstanceId: persistResult.relationInstanceId,
1150+
},
1151+
});
1152+
}
11771153

1178-
const relationType = getRelationTypeById(
1179-
this.options.plugin,
1180-
shape.props.relationTypeId,
1181-
);
1154+
const relationType = getRelationTypeById(
1155+
this.options.plugin,
1156+
shape.props.relationTypeId,
1157+
);
11821158

1183-
if (relationType && !alreadyExisted) {
1184-
showToast({
1185-
severity: "success",
1186-
title: "Relation Created",
1187-
description: `Added ${relationType.label} relation between ${sourceFile.basename} and ${targetFile.basename}`,
1188-
});
1189-
}
1190-
} catch (error) {
1191-
console.error("Failed to reify relation:", error);
1159+
if (relationType && !persistResult.alreadyExisted) {
11921160
showToast({
1193-
severity: "error",
1194-
title: "Failed to Save Relation",
1195-
description: "Could not save relation to files",
1161+
severity: "success",
1162+
title: "Relation Created",
1163+
description: `Added ${relationType.label} relation between ${persistResult.sourceFile.basename} and ${persistResult.targetFile.basename}`,
1164+
targetCanvasId: this.options.canvasFile.path,
11961165
});
11971166
}
11981167
}

0 commit comments

Comments
 (0)