Skip to content

Commit 10ba459

Browse files
committed
ENG-605: Add new relation flow
1 parent 82a3329 commit 10ba459

File tree

7 files changed

+582
-21
lines changed

7 files changed

+582
-21
lines changed
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import { StateNode, TLEventHandlers, TLStateNodeConstructor } from "@tldraw/editor";
2+
import { createShapeId } from "tldraw";
3+
import type { TFile } from "obsidian";
4+
import DiscourseGraphPlugin from "~/index";
5+
import { getRelationTypeById } from "./utils/relationUtils";
6+
import { DiscourseRelationShape } from "./shapes/DiscourseRelationShape";
7+
import { getNodeTypeById } from "~/utils/utils";
8+
import { Notice } from "obsidian";
9+
10+
type RelationToolContext = {
11+
plugin: DiscourseGraphPlugin;
12+
canvasFile: TFile;
13+
relationTypeId: string;
14+
} | null;
15+
16+
let relationToolContext: RelationToolContext = null;
17+
18+
export const setDiscourseRelationToolContext = (
19+
args: RelationToolContext,
20+
): void => {
21+
relationToolContext = args;
22+
};
23+
24+
export class DiscourseRelationTool extends StateNode {
25+
static override id = "discourse-relation";
26+
static override initial = "idle";
27+
static override children = (): TLStateNodeConstructor[] => [Idle, Pointing];
28+
29+
override onEnter = () => {
30+
this.editor.setCursor({ type: "cross" });
31+
};
32+
}
33+
34+
class Idle extends StateNode {
35+
static override id = "idle";
36+
37+
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
38+
this.parent.transition("pointing", info);
39+
};
40+
41+
override onEnter = () => {
42+
this.editor.setCursor({ type: "cross", rotation: 0 });
43+
};
44+
45+
override onCancel = () => {
46+
this.editor.setCurrentTool("select");
47+
};
48+
49+
override onKeyUp: TLEventHandlers["onKeyUp"] = (info) => {
50+
if (info.key === "Enter") {
51+
if (this.editor.getInstanceState().isReadonly) return null;
52+
const onlySelectedShape = this.editor.getOnlySelectedShape();
53+
// If the only selected shape is editable, start editing it
54+
if (
55+
onlySelectedShape &&
56+
this.editor.getShapeUtil(onlySelectedShape).canEdit(onlySelectedShape)
57+
) {
58+
this.editor.setCurrentTool("select");
59+
this.editor.setEditingShape(onlySelectedShape.id);
60+
this.editor.root.getCurrent()?.transition("editing_shape", {
61+
...info,
62+
target: "shape",
63+
shape: onlySelectedShape,
64+
});
65+
}
66+
}
67+
};
68+
}
69+
70+
class Pointing extends StateNode {
71+
static override id = "pointing";
72+
shape?: DiscourseRelationShape;
73+
markId = "";
74+
75+
private showWarning = (message: string) => {
76+
new Notice(message, 3000);
77+
this.cancel();
78+
};
79+
80+
private getCompatibleNodeTypes = (
81+
plugin: DiscourseGraphPlugin,
82+
relationTypeId: string,
83+
sourceNodeTypeId: string,
84+
): string[] => {
85+
const compatibleTypes: string[] = [];
86+
87+
// Find all discourse relations that match the relation type and source
88+
const relations = plugin.settings.discourseRelations.filter(
89+
(relation) =>
90+
relation.relationshipTypeId === relationTypeId &&
91+
relation.sourceId === sourceNodeTypeId,
92+
);
93+
94+
relations.forEach((relation) => {
95+
compatibleTypes.push(relation.destinationId);
96+
});
97+
98+
// Also check reverse relations (where current node is destination)
99+
const reverseRelations = plugin.settings.discourseRelations.filter(
100+
(relation) =>
101+
relation.relationshipTypeId === relationTypeId &&
102+
relation.destinationId === sourceNodeTypeId,
103+
);
104+
105+
reverseRelations.forEach((relation) => {
106+
compatibleTypes.push(relation.sourceId);
107+
});
108+
109+
return [...new Set(compatibleTypes)]; // Remove duplicates
110+
};
111+
112+
override onEnter = () => {
113+
this.didTimeout = false;
114+
115+
const target = this.editor.getShapeAtPoint(
116+
this.editor.inputs.currentPagePoint,
117+
);
118+
119+
if (!relationToolContext) {
120+
this.showWarning("No relation type selected");
121+
return;
122+
}
123+
124+
const plugin = relationToolContext.plugin;
125+
const relationTypeId = relationToolContext.relationTypeId;
126+
127+
// Validate source node
128+
if (!target || target.type !== "discourse-node") {
129+
this.showWarning("Must start on a discourse node");
130+
return;
131+
}
132+
133+
const sourceNodeTypeId = (target as { props?: { nodeTypeId?: string } }).props?.nodeTypeId;
134+
if (!sourceNodeTypeId) {
135+
this.showWarning("Source node must have a valid node type");
136+
return;
137+
}
138+
139+
// Check if this source node type can create relations of this type
140+
if (sourceNodeTypeId) {
141+
const compatibleTargetTypes = this.getCompatibleNodeTypes(
142+
plugin,
143+
relationTypeId,
144+
sourceNodeTypeId,
145+
);
146+
147+
if (compatibleTargetTypes.length === 0) {
148+
const sourceNodeType = getNodeTypeById(plugin, sourceNodeTypeId);
149+
const relationType = getRelationTypeById(plugin, relationTypeId);
150+
this.showWarning(
151+
`Node type "${sourceNodeType?.name}" cannot create "${relationType?.label}" relations`,
152+
);
153+
return;
154+
}
155+
}
156+
157+
if (!target) {
158+
this.createArrowShape();
159+
} else {
160+
this.editor.setHintingShapes([target.id]);
161+
}
162+
163+
this.startPreciseTimeout();
164+
};
165+
166+
override onExit = () => {
167+
this.shape = undefined;
168+
this.editor.setHintingShapes([]);
169+
this.clearPreciseTimeout();
170+
};
171+
172+
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
173+
if (this.editor.inputs.isDragging) {
174+
if (!this.shape) {
175+
this.createArrowShape();
176+
}
177+
178+
if (!this.shape) throw Error(`expected shape`);
179+
180+
this.updateArrowShapeEndHandle();
181+
182+
this.editor.setCurrentTool("select.dragging_handle", {
183+
shape: this.shape,
184+
handle: { id: "end", type: "vertex", index: "a3", x: 0, y: 0 },
185+
isCreating: true,
186+
onInteractionEnd: "select",
187+
});
188+
}
189+
};
190+
191+
override onPointerUp: TLEventHandlers["onPointerUp"] = () => {
192+
this.cancel();
193+
};
194+
195+
override onCancel: TLEventHandlers["onCancel"] = () => {
196+
this.cancel();
197+
};
198+
199+
override onComplete: TLEventHandlers["onComplete"] = () => {
200+
this.cancel();
201+
};
202+
203+
override onInterrupt: TLEventHandlers["onInterrupt"] = () => {
204+
this.cancel();
205+
};
206+
207+
cancel() {
208+
if (this.shape) {
209+
// the arrow might not have been created yet!
210+
this.editor.bailToMark(this.markId);
211+
}
212+
this.editor.setHintingShapes([]);
213+
relationToolContext = null;
214+
this.parent.transition("idle");
215+
}
216+
217+
createArrowShape() {
218+
const { originPagePoint } = this.editor.inputs;
219+
220+
const id = createShapeId();
221+
222+
this.markId = `creating:${id}`;
223+
this.editor.mark(this.markId);
224+
225+
if (!relationToolContext) {
226+
this.showWarning("Must start on a node");
227+
return;
228+
}
229+
230+
const relationType = getRelationTypeById(
231+
relationToolContext.plugin,
232+
relationToolContext.relationTypeId,
233+
);
234+
235+
this.editor.createShape<DiscourseRelationShape>({
236+
id,
237+
type: "discourse-relation",
238+
x: originPagePoint.x,
239+
y: originPagePoint.y,
240+
props: {
241+
relationTypeId: relationToolContext.relationTypeId,
242+
text: relationType?.label ?? "",
243+
scale: this.editor.user.getIsDynamicResizeMode()
244+
? 1 / this.editor.getZoomLevel()
245+
: 1,
246+
},
247+
});
248+
249+
const shape = this.editor.getShape<DiscourseRelationShape>(id);
250+
if (!shape) throw Error(`expected shape`);
251+
252+
const handles = this.editor.getShapeHandles(shape);
253+
if (!handles) throw Error(`expected handles for arrow`);
254+
255+
const util = this.editor.getShapeUtil<DiscourseRelationShape>("discourse-relation");
256+
const initial = this.shape;
257+
const startHandle = handles.find((h) => h.id === "start")!;
258+
const change = util.onHandleDrag?.(shape, {
259+
handle: { ...startHandle, x: 0, y: 0 },
260+
isPrecise: true,
261+
initial: initial,
262+
});
263+
264+
if (change) {
265+
this.editor.updateShapes([change]);
266+
}
267+
268+
// Cache the current shape after those changes
269+
this.shape = this.editor.getShape(id);
270+
this.editor.select(id);
271+
}
272+
273+
updateArrowShapeEndHandle() {
274+
const shape = this.shape;
275+
276+
if (!shape) throw Error(`expected shape`);
277+
278+
const handles = this.editor.getShapeHandles(shape);
279+
if (!handles) throw Error(`expected handles for arrow`);
280+
281+
// start update
282+
{
283+
const util = this.editor.getShapeUtil<DiscourseRelationShape>("discourse-relation");
284+
const initial = this.shape;
285+
const startHandle = handles.find((h) => h.id === "start")!;
286+
const change = util.onHandleDrag?.(shape, {
287+
handle: { ...startHandle, x: 0, y: 0 },
288+
isPrecise: this.didTimeout,
289+
initial: initial,
290+
});
291+
292+
if (change) {
293+
this.editor.updateShapes([change]);
294+
}
295+
}
296+
297+
// end update
298+
{
299+
const util = this.editor.getShapeUtil<DiscourseRelationShape>("discourse-relation");
300+
const initial = this.shape;
301+
const point = this.editor.getPointInShapeSpace(
302+
shape,
303+
this.editor.inputs.currentPagePoint,
304+
);
305+
const endHandle = handles.find((h) => h.id === "end")!;
306+
const change = util.onHandleDrag?.(this.editor.getShape(shape)!, {
307+
handle: { ...endHandle, x: point.x, y: point.y },
308+
isPrecise: false,
309+
initial: initial,
310+
});
311+
312+
if (change) {
313+
this.editor.updateShapes([change]);
314+
}
315+
}
316+
317+
// Cache the current shape after those changes
318+
this.shape = this.editor.getShape(shape.id);
319+
}
320+
321+
public preciseTimeout = -1;
322+
public didTimeout = false;
323+
public startPreciseTimeout() {
324+
this.preciseTimeout = this.editor.timers.setTimeout(() => {
325+
if (!this.getIsActive()) return;
326+
this.didTimeout = true;
327+
}, 320);
328+
}
329+
public clearPreciseTimeout() {
330+
clearTimeout(this.preciseTimeout);
331+
}
332+
}

0 commit comments

Comments
 (0)