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 && (
+
+
+
+ )}
{shouldShowConvertTo && (
);
};
-
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.