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 0ef1ba32b..7b10133a1 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx @@ -12,7 +12,7 @@ import panzoom, { PanZoomOptions } from "panzoom"; import dialog from "../../../services/dialog"; import server from "../../../services/server"; import toast from "../../../services/toast"; -import { CreateChildrenResponse } from "@triliumnext/commons"; +import { CreateChildrenResponse, RelationMapPostResponse, RelationMapRelation } from "@triliumnext/commons"; import contextMenu from "../../../menus/context_menu"; import appContext from "../../../components/app_context"; import RelationMapApi, { MapData, MapDataNoteEntry } from "./api"; @@ -23,6 +23,13 @@ interface Clipboard { title: string; } +type RelationType = "uniDirectional" | "biDirectional" | "inverse"; + +interface ClientRelation extends RelationMapRelation { + type: RelationType; + render: boolean; +} + export default function RelationMap({ note, ntxId }: TypeWidgetProps) { const [ data, setData ] = useState(); const containerRef = useRef(null); @@ -103,6 +110,8 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) { onTransform }); + useRelationData(note.noteId, data, mapApiRef, pbApiRef); + return (
@@ -172,6 +181,84 @@ function usePanZoom({ ntxId, containerRef, options, transformData, onTransform } }); } +async function useRelationData(noteId: string, mapData: MapData | undefined, mapApiRef: RefObject, jsPlumbRef: RefObject) { + const noteIds = mapData?.notes.map((note) => note.noteId); + const [ relations, setRelations ] = useState(); + const [ inverseRelations, setInverseRelations ] = useState(); + + async function refresh() { + const data = await server.post("relation-map", { noteIds, relationMapNoteId: noteId }); + const relations: ClientRelation[] = []; + + for (const _relation of data.relations) { + const relation = _relation as ClientRelation; // we inject a few variables. + const match = relations.find( + (rel) => + rel.name === data.inverseRelations[relation.name] && + ((rel.sourceNoteId === relation.sourceNoteId && rel.targetNoteId === relation.targetNoteId) || + (rel.sourceNoteId === relation.targetNoteId && rel.targetNoteId === relation.sourceNoteId)) + ); + + if (match) { + match.type = relation.type = relation.name === data.inverseRelations[relation.name] ? "biDirectional" : "inverse"; + relation.render = false; // don't render second relation + } else { + relation.type = "uniDirectional"; + relation.render = true; + } + + relations.push(relation); + setInverseRelations(data.inverseRelations); + } + + setRelations(relations); + mapApiRef.current?.cleanupOtherNotes(Object.keys(data.noteTitles)); + } + + useEffect(() => { + refresh(); + }, [ noteId, mapData, jsPlumbInstance ]); + + // Refresh on the canvas. + useEffect(() => { + const jsPlumbInstance = jsPlumbRef.current; + if (!jsPlumbInstance) return; + + jsPlumbInstance.batch(async () => { + if (!mapData || !relations) { + return; + } + + jsPlumbInstance.deleteEveryEndpoint(); + + for (const relation of relations) { + if (!relation.render) { + continue; + } + + const connection = jsPlumbInstance.connect({ + source: noteIdToId(relation.sourceNoteId), + target: noteIdToId(relation.targetNoteId), + type: relation.type + }); + + // TODO: Does this actually do anything. + //@ts-expect-error + connection.id = relation.attributeId; + + if (relation.type === "inverse") { + connection.getOverlay("label-source").setLabel(relation.name); + connection.getOverlay("label-target").setLabel(inverseRelations?.[relation.name] ?? ""); + } else { + connection.getOverlay("label").setLabel(relation.name); + } + + connection.canvas.setAttribute("data-connection-id", connection.id); + } + }); + }, [ relations, mapData ]); +} + function useNoteCreation({ ntxId, note, containerRef, mapApiRef }: { ntxId: string | null | undefined; note: FNote; @@ -238,7 +325,10 @@ function JsPlumb({ className, props, children, containerRef: externalContainerRe } onInstanceCreated?.(jsPlumbInstance); - return () => jsPlumbInstance.cleanupListeners(); + return () => { + jsPlumbInstance.deleteEveryEndpoint(); + jsPlumbInstance.cleanupListeners() + }; }, [ apiRef ]); return ( 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 848515118..e80fcab0a 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/api.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/api.ts @@ -49,6 +49,13 @@ export default class RelationMapApi { this.onDataChange(true); } + cleanupOtherNotes(noteIds: string[]) { + const filteredNotes = this.data.notes.filter((note) => noteIds.includes(note.noteId)); + if (filteredNotes.length === this.data.notes.length) return; + this.data.notes = filteredNotes; + this.onDataChange(true); + } + setTransform(transform: PanZoomTransform) { if (this.data.transform.scale - transform.scale > DELTA || this.data.transform.x - transform.x > DELTA 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 fab06ed8c..82a272961 100644 --- a/apps/client/src/widgets/type_widgets_old/relation_map.ts +++ b/apps/client/src/widgets/type_widgets_old/relation_map.ts @@ -31,24 +31,6 @@ declare module "jsplumb" { let containerCounter = 1; -export type RelationType = "uniDirectional" | "biDirectional" | "inverse"; - -interface Relation { - name: string; - attributeId: string; - sourceNoteId: string; - targetNoteId: string; - type: RelationType; - render: boolean; -} - -// TODO: Deduplicate. -interface RelationMapPostResponse { - relations: Relation[]; - inverseRelations: Record; - noteTitles: Record; -} - type MenuCommands = "openInNewTab" | "remove" | "editTitle"; export default class RelationMapTypeWidget extends TypeWidget { @@ -113,77 +95,6 @@ export default class RelationMapTypeWidget extends TypeWidget { this.$relationMapContainer.empty(); } - async loadNotesAndRelations() { - if (!this.mapData || !this.jsPlumbInstance) { - return; - } - - const noteIds = this.mapData.notes.map((note) => note.noteId); - const data = await server.post("relation-map", { noteIds, relationMapNoteId: this.noteId }); - - this.relations = []; - - for (const relation of data.relations) { - const match = this.relations.find( - (rel) => - rel.name === data.inverseRelations[relation.name] && - ((rel.sourceNoteId === relation.sourceNoteId && rel.targetNoteId === relation.targetNoteId) || - (rel.sourceNoteId === relation.targetNoteId && rel.targetNoteId === relation.sourceNoteId)) - ); - - if (match) { - match.type = relation.type = relation.name === data.inverseRelations[relation.name] ? "biDirectional" : "inverse"; - relation.render = false; // don't render second relation - } else { - relation.type = "uniDirectional"; - relation.render = true; - } - - this.relations.push(relation); - } - - this.mapData.notes = this.mapData.notes.filter((note) => note.noteId in data.noteTitles); - - this.jsPlumbInstance.batch(async () => { - if (!this.jsPlumbInstance || !this.mapData || !this.relations) { - return; - } - - this.clearMap(); - - for (const note of this.mapData.notes) { - const title = data.noteTitles[note.noteId]; - - await this.createNoteBox(note.noteId, title, note.x, note.y); - } - - for (const relation of this.relations) { - if (!relation.render) { - continue; - } - - const connection = this.jsPlumbInstance.connect({ - source: this.noteIdToId(relation.sourceNoteId), - target: this.noteIdToId(relation.targetNoteId), - type: relation.type - }); - - // TODO: Does this actually do anything. - //@ts-expect-error - connection.id = relation.attributeId; - - if (relation.type === "inverse") { - connection.getOverlay("label-source").setLabel(relation.name); - connection.getOverlay("label-target").setLabel(data.inverseRelations[relation.name]); - } else { - connection.getOverlay("label").setLabel(relation.name); - } - - connection.canvas.setAttribute("data-connection-id", connection.id); - } - }); - } - cleanup() { if (this.jsPlumbInstance) { this.clearMap(); diff --git a/apps/server/src/routes/api/relation-map.ts b/apps/server/src/routes/api/relation-map.ts index 0f0df6149..bc1c877aa 100644 --- a/apps/server/src/routes/api/relation-map.ts +++ b/apps/server/src/routes/api/relation-map.ts @@ -1,22 +1,12 @@ import type { Request } from "express"; import becca from "../../becca/becca.js"; import sql from "../../services/sql.js"; - -interface ResponseData { - noteTitles: Record; - relations: { - attributeId: string; - sourceNoteId: string; - targetNoteId: string; - name: string; - }[]; - inverseRelations: Record; -} +import { RelationMapPostResponse } from "@triliumnext/commons"; function getRelationMap(req: Request) { const { relationMapNoteId, noteIds } = req.body; - const resp: ResponseData = { + const resp: RelationMapPostResponse = { // noteId => title noteTitles: {}, relations: [], diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 2dd658f57..bfa809499 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -247,3 +247,16 @@ export interface SchemaResponse { type: string; }[]; } + +export interface RelationMapRelation { + name: string; + attributeId: string; + sourceNoteId: string; + targetNoteId: string; +} + +export interface RelationMapPostResponse { + noteTitles: Record; + relations: RelationMapRelation[]; + inverseRelations: Record; +}