diff --git a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx index bc557cbe1..eeaf3a03c 100644 --- a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx @@ -11,6 +11,10 @@ import { import type { TFile } from "obsidian"; import { usePlugin } from "~/components/PluginContext"; import { convertToDiscourseNode } from "./utils/convertToDiscourseNode"; +import { + convertArrowToDiscourseRelation, + getValidRelationTypesForArrow, +} from "./utils/convertArrowToDiscourseRelation"; type CustomContextMenuProps = { canvasFile: TFile; @@ -34,9 +38,54 @@ export const CustomContextMenu = ({ selectedShape && (selectedShape.type === "text" || selectedShape.type === "image"); + const isReadonly = useValue( + "isReadonly", + () => editor.getInstanceState().isReadonly, + [editor], + ); + + const validRelationTypes = useValue( + "validRelationTypes", + () => { + if (!selectedShape || selectedShape.type !== "arrow") return []; + return getValidRelationTypesForArrow({ + editor, + plugin, + arrowId: selectedShape.id, + }); + }, + [editor, plugin, selectedShape?.id, selectedShape?.type], + ); + + const shouldShowRelationMenu = + selectedShape?.type === "arrow" && validRelationTypes.length > 0; + return ( + {shouldShowRelationMenu && ( + + + {validRelationTypes.map((relationType) => ( + { + void convertArrowToDiscourseRelation({ + editor, + plugin, + canvasFile, + arrowId: selectedShape.id, + relationTypeId: relationType.id, + }); + }} + /> + ))} + + + )} {shouldShowConvertTo && ( @@ -63,4 +112,3 @@ export const CustomContextMenu = ({ ); }; - diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx index 738a207a5..43b1379c6 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationBinding.tsx @@ -139,6 +139,18 @@ export class BaseRelationBindingUtil extends BindingUtil { } } + static isRelationReified(shapeId: TLShapeId): boolean { + return BaseRelationBindingUtil.reifiedArrows.has(shapeId); + } + + static markRelationReified(shapeId: TLShapeId): void { + BaseRelationBindingUtil.reifiedArrows.add(shapeId); + } + + static unmarkRelationReified(shapeId: TLShapeId): void { + BaseRelationBindingUtil.reifiedArrows.delete(shapeId); + } + /** * Check selected relation shapes for completed bindings * Called from mouseup event handler @@ -150,19 +162,20 @@ export class BaseRelationBindingUtil extends BindingUtil { ) as DiscourseRelationShape[]; relationShapes.forEach((arrow) => { + if (arrow.meta.relationInstanceId) return; + const bindings = getArrowBindings(editor, arrow); if ( bindings.start && bindings.end && - !BaseRelationBindingUtil.reifiedArrows.has(arrow.id) + !BaseRelationBindingUtil.isRelationReified(arrow.id) ) { - BaseRelationBindingUtil.reifiedArrows.add(arrow.id); const util = editor.getShapeUtil(arrow); if (util instanceof DiscourseRelationUtil) { + BaseRelationBindingUtil.markRelationReified(arrow.id); util.reifyRelation(arrow, bindings).catch((error) => { console.error("Failed to reify relation:", error); - // Remove from reified set on error so it can be retried - BaseRelationBindingUtil.reifiedArrows.delete(arrow.id); + BaseRelationBindingUtil.unmarkRelationReified(arrow.id); }); } } diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx index f5bfb95a7..91653fcc4 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx @@ -58,12 +58,12 @@ import { updateArrowTerminal, } from "~/components/canvas/utils/relationUtils"; import { RelationBindings } from "./DiscourseRelationBinding"; -import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape"; -import { addRelationToRelationsJson } from "~/components/canvas/utils/relationJsonUtils"; +import { DiscourseNodeShape } from "./DiscourseNodeShape"; +import { persistRelationBetweenNodeShapes } from "~/components/canvas/utils/relationJsonUtils"; import { getDiscourseNodeAtPoint, getDiscourseNodeTypeId, - getRelationDirection, + getRelationLabelForDirection, isValidRelationConnection, } from "~/components/canvas/utils/relationTypeUtils"; import { getNodeTypeById, getRelationTypeById } from "~/utils/typeUtils"; @@ -1084,20 +1084,13 @@ export class DiscourseRelationUtil extends ShapeUtil { if (!relationType) return; - const { direct, reverse } = getRelationDirection({ + const newText = getRelationLabelForDirection({ discourseRelations: plugin.settings.discourseRelations, - relationTypeId, + relationType, sourceNodeTypeId: startNodeTypeId, targetNodeTypeId: endNodeTypeId, }); - let newText = relationType.label; // Default to main label - - if (reverse && !direct) { - // This is purely a reverse connection, use complement - newText = relationType.complement; - } - // Update the shape text if it's different if (shape.props.text !== newText) { this.editor.updateShapes([ @@ -1122,77 +1115,53 @@ export class DiscourseRelationUtil extends ShapeUtil { return; } - try { - const startNode = this.editor.getShape(bindings.start.toId); - const endNode = this.editor.getShape(bindings.end.toId); - - if ( - !startNode || - !endNode || - startNode.type !== "discourse-node" || - endNode.type !== "discourse-node" - ) { - return; - } - - const startNodeUtil = this.editor.getShapeUtil(startNode); - const endNodeUtil = this.editor.getShapeUtil(endNode); + const startNode = this.editor.getShape(bindings.start.toId); + const endNode = this.editor.getShape(bindings.end.toId); - // Get the files associated with both nodes - const sourceFile = await (startNodeUtil as DiscourseNodeUtil).getFile( - startNode as DiscourseNodeShape, - { - app: this.options.app, - canvasFile: this.options.canvasFile, - }, - ); - const targetFile = await (endNodeUtil as DiscourseNodeUtil).getFile( - endNode as DiscourseNodeShape, - { - app: this.options.app, - canvasFile: this.options.canvasFile, - }, - ); + if ( + !startNode || + !endNode || + startNode.type !== "discourse-node" || + endNode.type !== "discourse-node" + ) { + return; + } - if (!sourceFile || !targetFile) { - console.warn("Could not resolve files for relation nodes"); - return; - } + const persistResult = await persistRelationBetweenNodeShapes({ + plugin: this.options.plugin, + canvasFile: this.options.canvasFile, + editor: this.editor, + startNode: startNode as DiscourseNodeShape, + endNode: endNode as DiscourseNodeShape, + relationTypeId: shape.props.relationTypeId, + }); - const { alreadyExisted, relationInstanceId } = - await addRelationToRelationsJson({ - plugin: this.options.plugin, - sourceFile, - targetFile, - relationTypeId: shape.props.relationTypeId, - }); + if (!persistResult.ok) { + return; + } - if (relationInstanceId) { - this.editor.updateShape({ - id: shape.id, - type: shape.type, - meta: { ...shape.meta, relationInstanceId }, - }); - } + if (persistResult.relationInstanceId) { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + meta: { + ...shape.meta, + relationInstanceId: persistResult.relationInstanceId, + }, + }); + } - const relationType = getRelationTypeById( - this.options.plugin, - shape.props.relationTypeId, - ); + const relationType = getRelationTypeById( + this.options.plugin, + shape.props.relationTypeId, + ); - if (relationType && !alreadyExisted) { - showToast({ - severity: "success", - title: "Relation Created", - description: `Added ${relationType.label} relation between ${sourceFile.basename} and ${targetFile.basename}`, - }); - } - } catch (error) { - console.error("Failed to reify relation:", error); + if (relationType && !persistResult.alreadyExisted) { showToast({ - severity: "error", - title: "Failed to Save Relation", - description: "Could not save relation to files", + severity: "success", + title: "Relation Created", + description: `Added ${relationType.label} relation between ${persistResult.sourceFile.basename} and ${persistResult.targetFile.basename}`, + targetCanvasId: this.options.canvasFile.path, }); } } diff --git a/apps/obsidian/src/components/canvas/utils/convertArrowToDiscourseRelation.ts b/apps/obsidian/src/components/canvas/utils/convertArrowToDiscourseRelation.ts new file mode 100644 index 000000000..29aa69779 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/convertArrowToDiscourseRelation.ts @@ -0,0 +1,292 @@ +import type { TFile } from "obsidian"; +import { + createShapeId, + Editor, + TLArrowShape, + TLShape, + TLShapeId, +} from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; +import { + BaseRelationBindingUtil, + RelationBinding, +} from "~/components/canvas/shapes/DiscourseRelationBinding"; +import { createOrUpdateArrowBinding } from "~/components/canvas/utils/relationUtils"; +import { persistRelationBetweenNodeShapes } from "~/components/canvas/utils/relationJsonUtils"; +import { removeRelationById } from "~/utils/relationsStore"; +import { + getDiscourseNodeTypeId, + getRelationLabelForDirection, + getValidRelationTypesForNodePair, +} from "~/components/canvas/utils/relationTypeUtils"; +import { getRelationTypeById } from "~/utils/typeUtils"; +import { toTldrawColor } from "~/utils/tldrawColors"; +import { showToast } from "./toastUtils"; + +type ResolvedNativeArrowPair = { + arrow: TLArrowShape; + startBinding: RelationBinding; + endBinding: RelationBinding; + startNode: DiscourseNodeShape; + endNode: DiscourseNodeShape; + startNodeTypeId: string; + endNodeTypeId: string; +}; + +type ResolveNativeArrowFailureReason = + | "not-arrow" + | "unbound" + | "same-node" + | "not-discourse-node" + | "missing-type-id"; + +const isDiscourseNodeShape = (shape: TLShape): shape is DiscourseNodeShape => + shape.type === "discourse-node"; + +const getNativeArrowBindings = ( + editor: Editor, + arrowId: TLShapeId, +): { start?: RelationBinding; end?: RelationBinding } => { + const bindings = editor.getBindingsFromShape( + arrowId, + "arrow", + ); + return { + start: bindings.find((b) => b.props.terminal === "start"), + end: bindings.find((b) => b.props.terminal === "end"), + }; +}; + +const resolveNativeArrowDiscoursePair = ( + editor: Editor, + arrowId: TLShapeId, +): + | { ok: true; value: ResolvedNativeArrowPair } + | { ok: false; reason: ResolveNativeArrowFailureReason } => { + const shape = editor.getShape(arrowId); + if (!shape || shape.type !== "arrow") { + return { ok: false, reason: "not-arrow" }; + } + + const { start, end } = getNativeArrowBindings(editor, arrowId); + if (!start || !end) { + return { ok: false, reason: "unbound" }; + } + + if (start.toId === end.toId) { + return { ok: false, reason: "same-node" }; + } + + const startNode = editor.getShape(start.toId); + const endNode = editor.getShape(end.toId); + + if ( + !startNode || + !endNode || + !isDiscourseNodeShape(startNode) || + !isDiscourseNodeShape(endNode) + ) { + return { ok: false, reason: "not-discourse-node" }; + } + + const startNodeTypeId = getDiscourseNodeTypeId(startNode); + const endNodeTypeId = getDiscourseNodeTypeId(endNode); + + if (!startNodeTypeId || !endNodeTypeId) { + return { ok: false, reason: "missing-type-id" }; + } + + return { + ok: true, + value: { + arrow: shape as TLArrowShape, + startBinding: start, + endBinding: end, + startNode, + endNode, + startNodeTypeId, + endNodeTypeId, + }, + }; +}; + +const getResolveFailureMessage = ( + reason: ResolveNativeArrowFailureReason, +): string => { + switch (reason) { + case "same-node": + return "Target must be a different discourse node"; + case "unbound": + case "not-discourse-node": + return "Arrow must connect two discourse nodes"; + default: + return "Could not convert this arrow to a relation"; + } +}; + +export const getValidRelationTypesForArrow = ({ + editor, + plugin, + arrowId, +}: { + editor: Editor; + plugin: DiscourseGraphPlugin; + arrowId: TLShapeId; +}): { id: string; label: string; color: string }[] => { + const resolved = resolveNativeArrowDiscoursePair(editor, arrowId); + if (!resolved.ok) return []; + + return getValidRelationTypesForNodePair({ + settings: plugin.settings, + sourceNodeTypeId: resolved.value.startNodeTypeId, + targetNodeTypeId: resolved.value.endNodeTypeId, + }); +}; + +type ConvertArrowToDiscourseRelationArgs = { + editor: Editor; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + arrowId: TLShapeId; + relationTypeId: string; +}; + +export const convertArrowToDiscourseRelation = async ({ + editor, + plugin, + canvasFile, + arrowId, + relationTypeId, +}: ConvertArrowToDiscourseRelationArgs): Promise => { + if (editor.getInstanceState().isReadonly) return; + + const resolved = resolveNativeArrowDiscoursePair(editor, arrowId); + if (!resolved.ok) { + if (resolved.reason !== "not-arrow") { + showToast({ + severity: "warning", + title: "Relation", + description: getResolveFailureMessage(resolved.reason), + targetCanvasId: canvasFile.path, + }); + } + return; + } + + const { + arrow, + startBinding, + endBinding, + startNode, + endNode, + startNodeTypeId, + endNodeTypeId, + } = resolved.value; + + const relationType = getRelationTypeById(plugin, relationTypeId); + if (!relationType) return; + + const persistResult = await persistRelationBetweenNodeShapes({ + plugin, + canvasFile, + editor, + startNode, + endNode, + relationTypeId, + }); + + if (!persistResult.ok) { + return; + } + + const relationLabel = getRelationLabelForDirection({ + discourseRelations: plugin.settings.discourseRelations, + relationType, + sourceNodeTypeId: startNodeTypeId, + targetNodeTypeId: endNodeTypeId, + }); + + const color = toTldrawColor(relationType.color); + const newShapeId = createShapeId(); + + editor.run(() => { + editor.createShape({ + id: newShapeId, + type: "discourse-relation", + x: arrow.x, + y: arrow.y, + rotation: arrow.rotation, + parentId: arrow.parentId, + index: arrow.index, + opacity: arrow.opacity, + meta: { + ...arrow.meta, + relationInstanceId: persistResult.relationInstanceId, + }, + props: { + ...arrow.props, + relationTypeId, + color, + labelColor: color, + text: relationLabel, + }, + }); + + const createdShape = editor.getShape(newShapeId); + if (!createdShape) return; + + createOrUpdateArrowBinding( + editor, + createdShape, + startBinding.toId, + startBinding.props, + ); + createOrUpdateArrowBinding( + editor, + createdShape, + endBinding.toId, + endBinding.props, + ); + + editor.deleteShape(arrowId); + editor.setSelectedShapes([newShapeId]); + }); + + const convertedShape = editor.getShape(newShapeId); + const arrowStillExists = editor.getShape(arrowId); + + if (!convertedShape || arrowStillExists) { + if (convertedShape) { + editor.deleteShape(newShapeId); + } + + if (!persistResult.alreadyExisted) { + await removeRelationById(plugin, persistResult.relationInstanceId); + } + + showToast({ + severity: "error", + title: "Relation", + description: + "Could not convert the arrow on canvas. The relation was not saved.", + targetCanvasId: canvasFile.path, + }); + return; + } + + BaseRelationBindingUtil.markRelationReified(newShapeId); + editor.markHistoryStoppingPoint("convert arrow to discourse relation"); + + if (!persistResult.alreadyExisted) { + showToast({ + severity: "success", + title: "Relation Created", + description: `Added ${relationLabel} relation between ${persistResult.sourceFile.basename} and ${persistResult.targetFile.basename}`, + targetCanvasId: canvasFile.path, + }); + } + + return newShapeId; +}; diff --git a/apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts b/apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts index 2adeb5164..909f64f84 100644 --- a/apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts @@ -1,5 +1,11 @@ import { Notice, type TFile } from "obsidian"; +import type { Editor } from "tldraw"; import type DiscourseGraphPlugin from "~/index"; +import { + DiscourseNodeShape, + DiscourseNodeUtil, +} from "~/components/canvas/shapes/DiscourseNodeShape"; +import { showToast } from "~/components/canvas/utils/toastUtils"; import { addRelation, getNodeInstanceIdForFile, @@ -23,15 +29,17 @@ export const addRelationToRelationsJson = async ({ targetFile: TFile; relationTypeId: string; }): Promise<{ alreadyExisted: boolean; relationInstanceId?: string }> => { - const sourceId = await getNodeInstanceIdForFile(plugin, sourceFile); - const destId = await getNodeInstanceIdForFile(plugin, targetFile); + const [sourceId, destId] = await Promise.all([ + getNodeInstanceIdForFile(plugin, sourceFile), + getNodeInstanceIdForFile(plugin, targetFile), + ]); if (!sourceId || !destId) { const missing: string[] = []; if (!sourceId) missing.push(`source (${sourceFile.basename})`); if (!destId) missing.push(`target (${targetFile.basename})`); new Notice( - "Could not create relation: one or both files are not discourse nodes or metadata is not ready.", + `Could not create relation: ${missing.join(" and ")} could not be resolved as discourse nodes.`, 3000, ); return { alreadyExisted: false }; @@ -45,6 +53,89 @@ export const addRelationToRelationsJson = async ({ return { alreadyExisted, relationInstanceId: id }; }; +type PersistRelationBetweenNodesResult = + | { + ok: true; + relationInstanceId: string; + sourceFile: TFile; + targetFile: TFile; + alreadyExisted: boolean; + } + | { ok: false }; + +export const persistRelationBetweenNodeShapes = async ({ + plugin, + canvasFile, + editor, + startNode, + endNode, + relationTypeId, +}: { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + editor: Editor; + startNode: DiscourseNodeShape; + endNode: DiscourseNodeShape; + relationTypeId: string; +}): Promise => { + const nodeCtx = { app: plugin.app, canvasFile }; + const startNodeUtil = editor.getShapeUtil(startNode); + const endNodeUtil = editor.getShapeUtil(endNode); + + if ( + !(startNodeUtil instanceof DiscourseNodeUtil) || + !(endNodeUtil instanceof DiscourseNodeUtil) + ) { + return { ok: false }; + } + + const [sourceFile, targetFile] = await Promise.all([ + startNodeUtil.getFile(startNode, nodeCtx), + endNodeUtil.getFile(endNode, nodeCtx), + ]); + + if (!sourceFile || !targetFile) { + showToast({ + severity: "warning", + title: "Failed to Save Relation", + description: "Could not resolve files for the connected nodes", + targetCanvasId: canvasFile.path, + }); + return { ok: false }; + } + + try { + const { alreadyExisted, relationInstanceId } = + await addRelationToRelationsJson({ + plugin, + sourceFile, + targetFile, + relationTypeId, + }); + + if (!relationInstanceId) { + return { ok: false }; + } + + return { + ok: true, + relationInstanceId, + sourceFile, + targetFile, + alreadyExisted, + }; + } catch (error) { + console.error("Failed to persist relation:", error); + showToast({ + severity: "error", + title: "Failed to Save Relation", + description: "Could not save relation to files", + targetCanvasId: canvasFile.path, + }); + return { ok: false }; + } +}; + type RelationParams = { /** DiscourseRelation.id; when set, a relation is created between the two files. */ relationshipId?: string; diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts index bbd39b4bb..903c01e37 100644 --- a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts @@ -75,6 +75,31 @@ export const getRelationDirection = ({ return { direct, reverse }; }; +export const getRelationLabelForDirection = ({ + discourseRelations, + relationType, + sourceNodeTypeId, + targetNodeTypeId, +}: { + discourseRelations: DiscourseRelation[]; + relationType: DiscourseRelationType; + sourceNodeTypeId: string; + targetNodeTypeId: string; +}): string => { + const { direct, reverse } = getRelationDirection({ + discourseRelations, + relationTypeId: relationType.id, + sourceNodeTypeId, + targetNodeTypeId, + }); + + if (reverse && !direct) { + return relationType.complement; + } + + return relationType.label; +}; + /** * Returns the list of valid relation types for a given pair of node types, * checking both directions of the discourse relations.