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