diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx index 27d812560..4b6fa4a73 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { TypeWidgetProps } from "../type_widget"; -import { jsPlumbInstance } from "jsplumb"; +import { jsPlumbInstance, OnConnectionBindInfo } from "jsplumb"; import { useEditorSpacedUpdate, useTriliumEvent, useTriliumEvents } from "../../react/hooks"; import FNote from "../../../entities/fnote"; import { RefObject } from "preact"; @@ -14,8 +14,10 @@ import { CreateChildrenResponse, RelationMapPostResponse, RelationMapRelation } import RelationMapApi, { MapData, MapDataNoteEntry } from "./api"; import setupOverlays, { uniDirectionalOverlays } from "./overlays"; import { JsPlumb } from "./jsplumb"; -import { getMousePosition, getZoom, noteIdToId } from "./utils"; +import { getMousePosition, getZoom, idToNoteId, noteIdToId } from "./utils"; import { NoteBox } from "./NoteBox"; +import utils from "../../../services/utils"; +import attribute_autocomplete from "../../../services/attribute_autocomplete"; interface Clipboard { noteId: string; @@ -95,6 +97,8 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) { mapApiRef }); + const connectionCallback = useRelationCreation({ mapApiRef, jsPlumbApiRef: pbApiRef }); + usePanZoom({ ntxId, containerRef, @@ -129,6 +133,7 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) { HoverPaintStyle: { stroke: "#777", strokeWidth: 1 }, }} onInstanceCreated={setupOverlays} + onConnection={connectionCallback} > {data?.notes.map(note => ( @@ -304,3 +309,49 @@ function useNoteCreation({ ntxId, note, containerRef, mapApiRef }: { }, []); return onClickHandler; } + +function useRelationCreation({ mapApiRef, jsPlumbApiRef }: { mapApiRef: RefObject, jsPlumbApiRef: RefObject }) { + const connectionCallback = useCallback(async (info: OnConnectionBindInfo, originalEvent: Event) => { + // if there's no event, then this has been triggered programmatically + if (!originalEvent || !mapApiRef.current) return; + + const connection = info.connection; + let name = await dialog.prompt({ + message: t("relation_map.specify_new_relation_name"), + shown: ({ $answer }) => { + if (!$answer) { + return; + } + + $answer.on("keyup", () => { + // invalid characters are simply ignored (from user perspective they are not even entered) + const attrName = utils.filterAttributeName($answer.val() as string); + + $answer.val(attrName); + }); + + attribute_autocomplete.initAttributeNameAutocomplete({ + $el: $answer, + attributeType: "relation", + open: true + }); + } + }); + + // Delete the newly created connection if the dialog was dismissed. + if (!name || !name.trim()) { + jsPlumbApiRef.current?.deleteConnection(connection); + return; + } + + const targetNoteId = idToNoteId(connection.target.id); + const sourceNoteId = idToNoteId(connection.source.id); + const result = await mapApiRef.current.connect(name, sourceNoteId, targetNoteId); + if (!result) { + await dialog.info(t("relation_map.connection_exists", { name })); + jsPlumbApiRef.current?.deleteConnection(connection); + } + }, []); + + return connectionCallback; +} diff --git a/apps/client/src/widgets/type_widgets/relation_map/api.ts b/apps/client/src/widgets/type_widgets/relation_map/api.ts index 16b25a634..693aeecd2 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/api.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/api.ts @@ -79,4 +79,14 @@ export default class RelationMapApi { this.onDataChange(false); } + async connect(name: string, sourceNoteId: string, targetNoteId: string) { + name = utils.filterAttributeName(name); + const relationExists = this.relations?.some((rel) => rel.targetNoteId === targetNoteId && rel.sourceNoteId === sourceNoteId && rel.name === name); + + if (relationExists) return false; + await server.put(`notes/${sourceNoteId}/relations/${name}/to/${targetNoteId}`); + this.onDataChange(true); + return true; + } + } diff --git a/apps/client/src/widgets/type_widgets/relation_map/jsplumb.tsx b/apps/client/src/widgets/type_widgets/relation_map/jsplumb.tsx index dc8be39eb..ef531e911 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/jsplumb.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/jsplumb.tsx @@ -1,19 +1,21 @@ -import { jsPlumb, Defaults, jsPlumbInstance, DragOptions } from "jsplumb"; +import { jsPlumb, Defaults, jsPlumbInstance, DragOptions, OnConnectionBindInfo } from "jsplumb"; import { ComponentChildren, createContext, RefObject } from "preact"; import { HTMLProps } from "preact/compat"; import { useContext, useEffect, useRef } from "preact/hooks"; const JsPlumbInstance = createContext | undefined>(undefined); -export function JsPlumb({ className, props, children, containerRef: externalContainerRef, apiRef, onInstanceCreated }: { +export function JsPlumb({ className, props, children, containerRef: externalContainerRef, apiRef, onInstanceCreated, onConnection }: { className?: string; props: Omit; children: ComponentChildren; containerRef?: RefObject; apiRef?: RefObject; onInstanceCreated?: (jsPlumbInstance: jsPlumbInstance) => void; + onConnection?: (info: OnConnectionBindInfo, originalEvent: Event) => void; }) { const containerRef = useRef(null); + const jsPlumbRef = useRef(); useEffect(() => { if (!containerRef.current) return; @@ -28,6 +30,7 @@ export function JsPlumb({ className, props, children, containerRef: externalCont if (apiRef) { apiRef.current = jsPlumbInstance; } + jsPlumbRef.current = jsPlumbInstance; onInstanceCreated?.(jsPlumbInstance); return () => { @@ -36,6 +39,14 @@ export function JsPlumb({ className, props, children, containerRef: externalCont }; }, [ apiRef ]); + useEffect(() => { + const jsPlumbInstance = jsPlumbRef.current; + if (!jsPlumbInstance || !onConnection) return; + + jsPlumbInstance.bind("connection", onConnection); + return () => jsPlumbInstance.unbind("connection", onConnection); + }, [ onConnection ]); + return (
diff --git a/apps/client/src/widgets/type_widgets_old/relation_map.ts b/apps/client/src/widgets/type_widgets_old/relation_map.ts index 58b84ea70..467fd0f73 100644 --- a/apps/client/src/widgets/type_widgets_old/relation_map.ts +++ b/apps/client/src/widgets/type_widgets_old/relation_map.ts @@ -116,12 +116,9 @@ export default class RelationMapTypeWidget extends TypeWidget { if (!this.jsPlumbInstance) { return; } - - this.jsPlumbInstance.bind("connection", (info, originalEvent) => this.connectionCreatedHandler(info, originalEvent)); } async connectionCreatedHandler(info: ConnectionMadeEventInfo, originalEvent: Event) { - const connection = info.connection; connection.bind("contextmenu", (obj: unknown, event: MouseEvent) => { if (connection.getType().includes("link")) { @@ -156,58 +153,6 @@ export default class RelationMapTypeWidget extends TypeWidget { }); } }); - - // if there's no event, then this has been triggered programmatically - if (!originalEvent || !this.jsPlumbInstance) { - return; - } - - let name = await dialogService.prompt({ - message: t("relation_map.specify_new_relation_name"), - shown: ({ $answer }) => { - if (!$answer) { - return; - } - - $answer.on("keyup", () => { - // invalid characters are simply ignored (from user perspective they are not even entered) - const attrName = utils.filterAttributeName($answer.val() as string); - - $answer.val(attrName); - }); - - attributeAutocompleteService.initAttributeNameAutocomplete({ - $el: $answer, - attributeType: "relation", - open: true - }); - } - }); - - if (!name || !name.trim()) { - this.jsPlumbInstance.deleteConnection(connection); - - return; - } - - name = utils.filterAttributeName(name); - - const targetNoteId = this.idToNoteId(connection.target.id); - const sourceNoteId = this.idToNoteId(connection.source.id); - - const relationExists = this.relations?.some((rel) => rel.targetNoteId === targetNoteId && rel.sourceNoteId === sourceNoteId && rel.name === name); - - if (relationExists) { - await dialogService.info(t("relation_map.connection_exists", { name })); - - this.jsPlumbInstance.deleteConnection(connection); - - return; - } - - await server.put(`notes/${sourceNoteId}/relations/${name}/to/${targetNoteId}`); - - this.loadNotesAndRelations(); } saveData() {