Skip to content

Commit 75c8668

Browse files
trangdoan982claude
andcommitted
[ENG-1547] Add relation type editing via click on existing arrows
Allow users to click an existing persisted relation arrow to change its type via the same dropdown used during creation. Updates relations.json in-place preserving id, created, author, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ebe9ce commit 75c8668

2 files changed

Lines changed: 139 additions & 3 deletions

File tree

apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useState } from "react";
1+
import { useCallback, useEffect, useRef, useState } from "react";
22
import { TFile } from "obsidian";
33
import {
44
TLArrowBindingProps,
@@ -19,6 +19,7 @@ import {
1919
} from "~/components/canvas/utils/relationUtils";
2020
import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors";
2121
import { showToast } from "~/components/canvas/utils/toastUtils";
22+
import { updateRelationType } from "~/utils/relationsStore";
2223
import { RelationTypeDropdown } from "./RelationTypeDropdown";
2324

2425
type DragHandleOverlayProps = {
@@ -73,8 +74,11 @@ const getEdgeMidpoints = (bounds: {
7374
export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
7475
const editor = useEditor();
7576
const [pendingArrowId, setPendingArrowId] = useState<TLShapeId | null>(null);
77+
const [editingArrowId, setEditingArrowId] = useState<TLShapeId | null>(null);
7678
const [isDragging, setIsDragging] = useState(false);
7779
const sourceNodeRef = useRef<DiscourseNodeShape | null>(null);
80+
// Tracks the arrow id we just finished editing, so the useEffect doesn't re-open the dropdown
81+
const justEditedRef = useRef<TLShapeId | null>(null);
7882

7983
// Track the single selected discourse node — mirrors RelationsOverlay pattern
8084
const selectedNode = useValue<DiscourseNodeShape | null>(
@@ -89,6 +93,47 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
8993
[editor],
9094
);
9195

96+
// Track when user selects an existing persisted relation arrow
97+
const selectedRelationArrow = useValue<DiscourseRelationShape | null>(
98+
"selectedRelationArrow",
99+
() => {
100+
const shape = editor.getOnlySelectedShape();
101+
if (
102+
shape?.type === "discourse-relation" &&
103+
(shape.meta as Record<string, unknown>)?.relationInstanceId
104+
) {
105+
return shape as DiscourseRelationShape;
106+
}
107+
return null;
108+
},
109+
[editor],
110+
);
111+
112+
// Clear justEditedRef when the user selects a different shape (not just transient null)
113+
const currentSelectedId = useValue(
114+
"currentSelectedId",
115+
() => editor.getOnlySelectedShape()?.id ?? null,
116+
[editor],
117+
);
118+
useEffect(() => {
119+
if (
120+
justEditedRef.current &&
121+
currentSelectedId !== justEditedRef.current
122+
) {
123+
justEditedRef.current = null;
124+
}
125+
}, [currentSelectedId]);
126+
127+
// Open edit dropdown when a persisted relation arrow is selected
128+
useEffect(() => {
129+
if (selectedRelationArrow && !pendingArrowId && !isDragging) {
130+
if (justEditedRef.current === selectedRelationArrow.id) return;
131+
setEditingArrowId(selectedRelationArrow.id);
132+
} else if (!selectedRelationArrow) {
133+
setEditingArrowId(null);
134+
}
135+
}, [selectedRelationArrow, pendingArrowId, isDragging]);
136+
92137
const handlePositions = useValue<
93138
{ left: number; top: number; anchor: { x: number; y: number } }[] | null
94139
>(
@@ -374,6 +419,9 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
374419
}
375420
}
376421

422+
// Prevent the edit-flow useEffect from re-opening the dropdown
423+
// (reifyRelationInFrontmatter will set meta.relationInstanceId on this arrow)
424+
justEditedRef.current = pendingArrowId;
377425
setPendingArrowId(null);
378426
sourceNodeRef.current = null;
379427
},
@@ -392,7 +440,72 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
392440
sourceNodeRef.current = null;
393441
}, [editor, pendingArrowId, cleanupArrow]);
394442

395-
const showHandles = !!handlePositions && !pendingArrowId;
443+
const handleEditSelect = useCallback(
444+
(relationTypeId: string) => {
445+
if (!editingArrowId) return;
446+
447+
const shape = editor.getShape<DiscourseRelationShape>(editingArrowId);
448+
if (!shape) {
449+
setEditingArrowId(null);
450+
return;
451+
}
452+
453+
// Same type re-selected — just dismiss
454+
if (shape.props.relationTypeId === relationTypeId) {
455+
setEditingArrowId(null);
456+
return;
457+
}
458+
459+
const relationType = plugin.settings.relationTypes.find(
460+
(rt) => rt.id === relationTypeId,
461+
);
462+
if (!relationType) {
463+
setEditingArrowId(null);
464+
return;
465+
}
466+
467+
const relationInstanceId = (
468+
shape.meta as Record<string, unknown>
469+
)?.relationInstanceId;
470+
if (typeof relationInstanceId === "string") {
471+
void updateRelationType(plugin, relationInstanceId, relationTypeId);
472+
}
473+
474+
// Update arrow visual props
475+
editor.updateShapes([
476+
{
477+
id: editingArrowId,
478+
type: "discourse-relation",
479+
props: {
480+
relationTypeId,
481+
color: relationType.color,
482+
},
483+
},
484+
]);
485+
486+
// Update text label for direction
487+
const updatedShape =
488+
editor.getShape<DiscourseRelationShape>(editingArrowId);
489+
if (updatedShape) {
490+
const bindings = getArrowBindings(editor, updatedShape);
491+
const util = editor.getShapeUtil(updatedShape);
492+
if (util instanceof DiscourseRelationUtil) {
493+
util.updateRelationTextForDirection(updatedShape, bindings);
494+
}
495+
}
496+
497+
justEditedRef.current = editingArrowId;
498+
setEditingArrowId(null);
499+
},
500+
[editor, editingArrowId, plugin],
501+
);
502+
503+
const handleEditDismiss = useCallback(() => {
504+
justEditedRef.current = editingArrowId;
505+
setEditingArrowId(null);
506+
}, [editingArrowId]);
507+
508+
const showHandles = !!handlePositions && !pendingArrowId && !editingArrowId;
396509

397510
return (
398511
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
@@ -428,7 +541,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
428541
</div>
429542
))}
430543

431-
{/* Relation type dropdown */}
544+
{/* Relation type dropdown — new arrow */}
432545
{pendingArrowId && (
433546
<RelationTypeDropdown
434547
arrowId={pendingArrowId}
@@ -437,6 +550,16 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
437550
onDismiss={handleDropdownDismiss}
438551
/>
439552
)}
553+
554+
{/* Relation type dropdown — edit existing arrow */}
555+
{editingArrowId && !pendingArrowId && (
556+
<RelationTypeDropdown
557+
arrowId={editingArrowId}
558+
plugin={plugin}
559+
onSelect={handleEditSelect}
560+
onDismiss={handleEditDismiss}
561+
/>
562+
)}
440563
</div>
441564
);
442565
};

apps/obsidian/src/utils/relationsStore.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ export const addRelation = async (
161161
return { id, alreadyExisted: false };
162162
};
163163

164+
export const updateRelationType = async (
165+
plugin: DiscourseGraphPlugin,
166+
relationInstanceId: string,
167+
newType: string,
168+
): Promise<boolean> => {
169+
const data = await loadRelations(plugin);
170+
const relation = data.relations[relationInstanceId];
171+
if (!relation) return false;
172+
relation.type = newType;
173+
await saveRelations(plugin, data);
174+
return true;
175+
};
176+
164177
export const removeRelationById = async (
165178
plugin: DiscourseGraphPlugin,
166179
relationInstanceId: string,

0 commit comments

Comments
 (0)