From 0ebe9ce75a7c99f9b450f6e20d60b9043a5cba11 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 23 Mar 2026 17:13:51 -0400 Subject: [PATCH 1/2] drag handle works --- .../components/canvas/TldrawViewComponent.tsx | 7 +- .../canvas/overlays/DragHandleOverlay.tsx | 442 ++++++++++++++++++ .../canvas/overlays/RelationTypeDropdown.tsx | 230 +++++++++ 3 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx create mode 100644 apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index d196a04b6..81911608b 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -46,6 +46,8 @@ import { } from "~/components/canvas/shapes/DiscourseRelationBinding"; import ToastListener from "./ToastListener"; import { RelationsOverlay } from "./overlays/RelationOverlay"; +import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; +import { showToast } from "./utils/toastUtils"; import { WHITE_LOGO_SVG } from "~/icons"; import { CustomContextMenu } from "./CustomContextMenu"; import { @@ -474,7 +476,10 @@ export const TldrawPreviewComponent = ({ ); }, InFrontOfTheCanvas: () => ( - + <> + + + ), }} /> diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx new file mode 100644 index 000000000..ee8b2158d --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -0,0 +1,442 @@ +import { useCallback, useRef, useState } from "react"; +import { TFile } from "obsidian"; +import { + TLArrowBindingProps, + TLShapeId, + createShapeId, + useEditor, + useValue, +} from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { + DiscourseRelationShape, + DiscourseRelationUtil, +} from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + createOrUpdateArrowBinding, + getArrowBindings, +} from "~/components/canvas/utils/relationUtils"; +import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors"; +import { showToast } from "~/components/canvas/utils/toastUtils"; +import { RelationTypeDropdown } from "./RelationTypeDropdown"; + +type DragHandleOverlayProps = { + plugin: DiscourseGraphPlugin; + file: TFile; +}; + +type HandlePosition = { + x: number; + y: number; + anchor: { x: number; y: number }; +}; + +const HANDLE_RADIUS = 5; +const HANDLE_HIT_AREA = 12; +const HANDLE_PADDING = 8; // px offset outward from the node edge + +const getEdgeMidpoints = (bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; +}): HandlePosition[] => { + return [ + // Top + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.minY - HANDLE_PADDING, + anchor: { x: 0.5, y: 0 }, + }, + // Right + { + x: bounds.maxX + HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 1, y: 0.5 }, + }, + // Bottom + { + x: (bounds.minX + bounds.maxX) / 2, + y: bounds.maxY + HANDLE_PADDING, + anchor: { x: 0.5, y: 1 }, + }, + // Left + { + x: bounds.minX - HANDLE_PADDING, + y: (bounds.minY + bounds.maxY) / 2, + anchor: { x: 0, y: 0.5 }, + }, + ]; +}; + +export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { + const editor = useEditor(); + const [pendingArrowId, setPendingArrowId] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const sourceNodeRef = useRef(null); + + // Track the single selected discourse node — mirrors RelationsOverlay pattern + const selectedNode = useValue( + "dragHandleSelectedNode", + () => { + const shape = editor.getOnlySelectedShape(); + if (shape && shape.type === "discourse-node") { + return shape as DiscourseNodeShape; + } + return null; + }, + [editor], + ); + + const handlePositions = useValue< + { left: number; top: number; anchor: { x: number; y: number } }[] | null + >( + "dragHandlePositions", + () => { + if (!selectedNode || pendingArrowId || isDragging) return null; + const bounds = editor.getShapePageBounds(selectedNode.id); + if (!bounds) return null; + const midpoints = getEdgeMidpoints(bounds); + return midpoints.map((mp) => { + const vp = editor.pageToViewport({ x: mp.x, y: mp.y }); + return { left: vp.x, top: vp.y, anchor: mp.anchor }; + }); + }, + [editor, selectedNode?.id, pendingArrowId, isDragging], + ); + + const cleanupArrow = useCallback( + (arrowId: TLShapeId) => { + if (editor.getShape(arrowId)) { + editor.deleteShapes([arrowId]); + } + }, + [editor], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent, anchor: { x: number; y: number }) => { + if (!selectedNode) return; + e.preventDefault(); + e.stopPropagation(); + + setIsDragging(true); + sourceNodeRef.current = selectedNode; + + const arrowId = createShapeId(); + + // Get the source node's page bounds for start position + const sourceBounds = editor.getShapePageBounds(selectedNode.id); + if (!sourceBounds) return; + + const startX = sourceBounds.minX + anchor.x * sourceBounds.width; + const startY = sourceBounds.minY + anchor.y * sourceBounds.height; + + // Create the arrow shape at the source node's position + editor.createShape({ + id: arrowId, + type: "discourse-relation", + x: startX, + y: startY, + props: { + color: DEFAULT_TLDRAW_COLOR, + relationTypeId: "", + text: "", + dash: "draw", + size: "m", + fill: "none", + labelColor: "black", + bend: 0, + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + arrowheadStart: "none", + arrowheadEnd: "arrow", + labelPosition: 0.5, + font: "draw", + scale: 1, + kind: "arc", + elbowMidPoint: 0, + }, + }); + + const createdShape = editor.getShape(arrowId); + if (!createdShape) return; + + // Bind the start handle to the source node + createOrUpdateArrowBinding(editor, createdShape, selectedNode.id, { + terminal: "start", + normalizedAnchor: anchor, + isPrecise: false, + isExact: false, + snap: "none", + }); + + // Select the arrow and start dragging the end handle + editor.select(arrowId); + + // Use tldraw's built-in handle dragging by setting the tool state + // We need to track the pointer to update the end handle + const containerEl = editor.getContainer(); + const onPointerMove = (moveEvent: PointerEvent) => { + const point = editor.screenToPage({ + x: moveEvent.clientX, + y: moveEvent.clientY, + }); + + // Update the arrow's end position + const currentShape = editor.getShape(arrowId); + if (!currentShape) return; + + const dx = point.x - currentShape.x; + const dy = point.y - currentShape.y; + + // Check for a target shape under the cursor + const target = editor.getShapeAtPoint(point, { + hitInside: true, + hitFrameInside: true, + margin: 0, + filter: (targetShape) => { + return ( + targetShape.type === "discourse-node" && + targetShape.id !== selectedNode.id && + !targetShape.isLocked + ); + }, + }); + + if (target) { + // Bind end to target + createOrUpdateArrowBinding(editor, currentShape, target.id, { + terminal: "end", + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + isExact: false, + snap: "none", + }); + editor.setHintingShapes([target.id]); + } else { + // Update free end position + // Remove any existing end binding + const bindings = getArrowBindings(editor, currentShape); + if (bindings.end) { + editor.deleteBindings( + editor + .getBindingsFromShape(currentShape.id, "discourse-relation") + .filter( + (b) => (b.props as TLArrowBindingProps).terminal === "end", + ), + ); + } + editor.updateShapes([ + { + id: arrowId, + type: "discourse-relation", + props: { end: { x: dx, y: dy } }, + }, + ]); + editor.setHintingShapes([]); + } + }; + + const onPointerUp = () => { + containerEl.removeEventListener("pointermove", onPointerMove); + containerEl.removeEventListener("pointerup", onPointerUp); + editor.setHintingShapes([]); + setIsDragging(false); + + const finalShape = editor.getShape(arrowId); + if (!finalShape) return; + + const bindings = getArrowBindings(editor, finalShape); + + // Validate: both ends bound to different discourse nodes + if ( + bindings.start && + bindings.end && + bindings.start.toId !== bindings.end.toId + ) { + const endTarget = editor.getShape(bindings.end.toId); + if (endTarget && endTarget.type === "discourse-node") { + // Check if any relation types are valid for this node pair + const startNodeTypeId = ( + editor.getShape(bindings.start.toId) as { + props?: { nodeTypeId?: string }; + } + )?.props?.nodeTypeId; + const endNodeTypeId = ( + endTarget as { props?: { nodeTypeId?: string } } + )?.props?.nodeTypeId; + + const hasValidRelationType = + startNodeTypeId && + endNodeTypeId && + plugin.settings.discourseRelations.some( + (r) => + plugin.settings.relationTypes.some( + (rt) => rt.id === r.relationshipTypeId, + ) && + ((r.sourceId === startNodeTypeId && + r.destinationId === endNodeTypeId) || + (r.sourceId === endNodeTypeId && + r.destinationId === startNodeTypeId)), + ); + + if (!hasValidRelationType) { + cleanupArrow(arrowId); + showToast({ + severity: "warning", + title: "Relation", + description: + "No relation types are defined between these node types", + targetCanvasId: file.path, + }); + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + return; + } + + // Success - show dropdown to pick relation type + setPendingArrowId(arrowId); + editor.select(arrowId); + return; + } + } + + // Failure - clean up the arrow and show notice + cleanupArrow(arrowId); + showToast({ + severity: "warning", + title: "Relation", + description: !bindings.end + ? "Drop on a discourse node to create a relation" + : "Target must be a different discourse node", + targetCanvasId: file.path, + }); + // Re-select the source node + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + }; + + containerEl.addEventListener("pointermove", onPointerMove); + containerEl.addEventListener("pointerup", onPointerUp); + }, + [selectedNode, editor, cleanupArrow, file.path], + ); + + const handleDropdownSelect = useCallback( + (relationTypeId: string) => { + if (!pendingArrowId) return; + + const shape = editor.getShape(pendingArrowId); + if (!shape) { + setPendingArrowId(null); + return; + } + + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relationTypeId, + ); + if (!relationType) { + cleanupArrow(pendingArrowId); + setPendingArrowId(null); + return; + } + + // Update arrow props with relation type info + editor.updateShapes([ + { + id: pendingArrowId, + type: "discourse-relation", + props: { + relationTypeId, + color: relationType.color, + }, + }, + ]); + + // Get updated shape and bindings for text direction + const updatedShape = + editor.getShape(pendingArrowId); + if (updatedShape) { + const bindings = getArrowBindings(editor, updatedShape); + + // Update text based on direction + const util = editor.getShapeUtil(updatedShape); + if (util instanceof DiscourseRelationUtil) { + util.updateRelationTextForDirection(updatedShape, bindings); + // Persist to frontmatter + void util.reifyRelationInFrontmatter(updatedShape, bindings); + } + } + + setPendingArrowId(null); + sourceNodeRef.current = null; + }, + [editor, pendingArrowId, plugin, cleanupArrow], + ); + + const handleDropdownDismiss = useCallback(() => { + if (pendingArrowId) { + cleanupArrow(pendingArrowId); + setPendingArrowId(null); + } + // Re-select source node + if (sourceNodeRef.current) { + editor.select(sourceNodeRef.current.id); + } + sourceNodeRef.current = null; + }, [editor, pendingArrowId, cleanupArrow]); + + const showHandles = !!handlePositions && !pendingArrowId; + + return ( +
+ {/* Drag handle dots */} + {showHandles && + handlePositions.map((pos, i) => ( +
handlePointerDown(e, pos.anchor)} + style={{ + position: "absolute", + left: `${pos.left}px`, + top: `${pos.top}px`, + transform: "translate(-50%, -50%)", + width: `${HANDLE_HIT_AREA * 2}px`, + height: `${HANDLE_HIT_AREA * 2}px`, + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "crosshair", + pointerEvents: "all", + zIndex: 20, + }} + > +
+
+ ))} + + {/* Relation type dropdown */} + {pendingArrowId && ( + + )} +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx new file mode 100644 index 000000000..e319d3e6c --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { TLShapeId, useEditor, useValue } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + getArrowBindings, + getArrowInfo, +} from "~/components/canvas/utils/relationUtils"; +import { COLOR_PALETTE } from "~/utils/tldrawColors"; + +type RelationTypeDropdownProps = { + arrowId: TLShapeId; + plugin: DiscourseGraphPlugin; + onSelect: (relationTypeId: string) => void; + onDismiss: () => void; +}; + +export const RelationTypeDropdown = ({ + arrowId, + plugin, + onSelect, + onDismiss, +}: RelationTypeDropdownProps) => { + const editor = useEditor(); + const dropdownRef = useRef(null); + + const arrow = useValue( + "dropdownArrow", + () => editor.getShape(arrowId) ?? null, + [editor, arrowId], + ); + + // Auto-dismiss if arrow is deleted + useEffect(() => { + if (!arrow) { + onDismiss(); + } + }, [arrow, onDismiss]); + + // Get valid relation types based on source/target node types + const validRelationTypes = useMemo(() => { + if (!arrow) return []; + + const bindings = getArrowBindings(editor, arrow); + if (!bindings.start || !bindings.end) return []; + + const startNode = editor.getShape(bindings.start.toId); + const endNode = editor.getShape(bindings.end.toId); + + if (!startNode || !endNode) return []; + + const startNodeTypeId = (startNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + const endNodeTypeId = (endNode as { props?: { nodeTypeId?: string } }) + ?.props?.nodeTypeId; + + if (!startNodeTypeId || !endNodeTypeId) return []; + + // Find relation types that are valid for this node type pair + const validTypes: { + id: string; + label: string; + color: string; + }[] = []; + + for (const relationType of plugin.settings.relationTypes) { + // Check if there's a discourse relation that matches this pair + const isValid = plugin.settings.discourseRelations.some( + (relation) => + relation.relationshipTypeId === relationType.id && + ((relation.sourceId === startNodeTypeId && + relation.destinationId === endNodeTypeId) || + (relation.sourceId === endNodeTypeId && + relation.destinationId === startNodeTypeId)), + ); + + if (isValid) { + validTypes.push({ + id: relationType.id, + label: relationType.label, + color: COLOR_PALETTE[relationType.color] ?? COLOR_PALETTE["black"]!, + }); + } + } + + return validTypes; + }, [arrow, editor, plugin]); + + // Position dropdown at arrow midpoint + const dropdownPosition = useValue<{ left: number; top: number } | null>( + "dropdownPosition", + () => { + if (!arrow) return null; + + const info = getArrowInfo(editor, arrow); + if (!info) return null; + + // Get the midpoint in page space + const pageTransform = editor.getShapePageTransform(arrow.id); + const midInPage = pageTransform.applyToPoint(info.middle); + + const vp = editor.pageToViewport(midInPage); + return { left: vp.x, top: vp.y }; + }, + [editor, arrow?.id], + ); + + // Handle click outside + useEffect(() => { + const handlePointerDown = (e: PointerEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + onDismiss(); + } + }; + + // Delay to avoid immediately triggering from the pointer up that opened this + const timer = setTimeout(() => { + window.addEventListener("pointerdown", handlePointerDown, true); + }, 100); + + return () => { + clearTimeout(timer); + window.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, [onDismiss]); + + // Handle Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onDismiss(); + } + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [onDismiss]); + + const handleSelect = useCallback( + (relationTypeId: string) => { + onSelect(relationTypeId); + }, + [onSelect], + ); + + if (!dropdownPosition || !arrow) return null; + + return ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
+
+ Relation Type +
+ {validRelationTypes.map((rt) => ( + + ))} +
+
+ ); +}; From 75c8668257bb3af71c8a0fcd8eac72909532c934 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 23 Mar 2026 17:52:59 -0400 Subject: [PATCH 2/2] [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 --- .../canvas/overlays/DragHandleOverlay.tsx | 129 +++++++++++++++++- apps/obsidian/src/utils/relationsStore.ts | 13 ++ 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index ee8b2158d..c1a471d77 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TFile } from "obsidian"; import { TLArrowBindingProps, @@ -19,6 +19,7 @@ import { } from "~/components/canvas/utils/relationUtils"; import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors"; import { showToast } from "~/components/canvas/utils/toastUtils"; +import { updateRelationType } from "~/utils/relationsStore"; import { RelationTypeDropdown } from "./RelationTypeDropdown"; type DragHandleOverlayProps = { @@ -73,8 +74,11 @@ const getEdgeMidpoints = (bounds: { export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { const editor = useEditor(); const [pendingArrowId, setPendingArrowId] = useState(null); + const [editingArrowId, setEditingArrowId] = useState(null); const [isDragging, setIsDragging] = useState(false); const sourceNodeRef = useRef(null); + // Tracks the arrow id we just finished editing, so the useEffect doesn't re-open the dropdown + const justEditedRef = useRef(null); // Track the single selected discourse node — mirrors RelationsOverlay pattern const selectedNode = useValue( @@ -89,6 +93,47 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { [editor], ); + // Track when user selects an existing persisted relation arrow + const selectedRelationArrow = useValue( + "selectedRelationArrow", + () => { + const shape = editor.getOnlySelectedShape(); + if ( + shape?.type === "discourse-relation" && + (shape.meta as Record)?.relationInstanceId + ) { + return shape as DiscourseRelationShape; + } + return null; + }, + [editor], + ); + + // Clear justEditedRef when the user selects a different shape (not just transient null) + const currentSelectedId = useValue( + "currentSelectedId", + () => editor.getOnlySelectedShape()?.id ?? null, + [editor], + ); + useEffect(() => { + if ( + justEditedRef.current && + currentSelectedId !== justEditedRef.current + ) { + justEditedRef.current = null; + } + }, [currentSelectedId]); + + // Open edit dropdown when a persisted relation arrow is selected + useEffect(() => { + if (selectedRelationArrow && !pendingArrowId && !isDragging) { + if (justEditedRef.current === selectedRelationArrow.id) return; + setEditingArrowId(selectedRelationArrow.id); + } else if (!selectedRelationArrow) { + setEditingArrowId(null); + } + }, [selectedRelationArrow, pendingArrowId, isDragging]); + const handlePositions = useValue< { left: number; top: number; anchor: { x: number; y: number } }[] | null >( @@ -374,6 +419,9 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { } } + // Prevent the edit-flow useEffect from re-opening the dropdown + // (reifyRelationInFrontmatter will set meta.relationInstanceId on this arrow) + justEditedRef.current = pendingArrowId; setPendingArrowId(null); sourceNodeRef.current = null; }, @@ -392,7 +440,72 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { sourceNodeRef.current = null; }, [editor, pendingArrowId, cleanupArrow]); - const showHandles = !!handlePositions && !pendingArrowId; + const handleEditSelect = useCallback( + (relationTypeId: string) => { + if (!editingArrowId) return; + + const shape = editor.getShape(editingArrowId); + if (!shape) { + setEditingArrowId(null); + return; + } + + // Same type re-selected — just dismiss + if (shape.props.relationTypeId === relationTypeId) { + setEditingArrowId(null); + return; + } + + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === relationTypeId, + ); + if (!relationType) { + setEditingArrowId(null); + return; + } + + const relationInstanceId = ( + shape.meta as Record + )?.relationInstanceId; + if (typeof relationInstanceId === "string") { + void updateRelationType(plugin, relationInstanceId, relationTypeId); + } + + // Update arrow visual props + editor.updateShapes([ + { + id: editingArrowId, + type: "discourse-relation", + props: { + relationTypeId, + color: relationType.color, + }, + }, + ]); + + // Update text label for direction + const updatedShape = + editor.getShape(editingArrowId); + if (updatedShape) { + const bindings = getArrowBindings(editor, updatedShape); + const util = editor.getShapeUtil(updatedShape); + if (util instanceof DiscourseRelationUtil) { + util.updateRelationTextForDirection(updatedShape, bindings); + } + } + + justEditedRef.current = editingArrowId; + setEditingArrowId(null); + }, + [editor, editingArrowId, plugin], + ); + + const handleEditDismiss = useCallback(() => { + justEditedRef.current = editingArrowId; + setEditingArrowId(null); + }, [editingArrowId]); + + const showHandles = !!handlePositions && !pendingArrowId && !editingArrowId; return (
@@ -428,7 +541,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => {
))} - {/* Relation type dropdown */} + {/* Relation type dropdown — new arrow */} {pendingArrowId && ( { onDismiss={handleDropdownDismiss} /> )} + + {/* Relation type dropdown — edit existing arrow */} + {editingArrowId && !pendingArrowId && ( + + )}
); }; diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 659b57650..41f4951b7 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -161,6 +161,19 @@ export const addRelation = async ( return { id, alreadyExisted: false }; }; +export const updateRelationType = async ( + plugin: DiscourseGraphPlugin, + relationInstanceId: string, + newType: string, +): Promise => { + const data = await loadRelations(plugin); + const relation = data.relations[relationInstanceId]; + if (!relation) return false; + relation.type = newType; + await saveRelations(plugin, data); + return true; +}; + export const removeRelationById = async ( plugin: DiscourseGraphPlugin, relationInstanceId: string,