refactor(type_widgets): use API architecture for relation map

This commit is contained in:
Elian Doran
2025-09-29 21:05:29 +03:00
parent 614fc66890
commit 1c1243912b
4 changed files with 100 additions and 61 deletions

View File

@@ -115,11 +115,17 @@ declare global {
filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
});
interface PanZoomTransform {
x: number;
y: number;
scale: number;
}
interface PanZoom {
zoomTo(x: number, y: number, scale: number);
moveTo(x: number, y: number);
on(event: string, callback: () => void);
getTransform(): unknown;
getTransform(): PanZoomTransform;
dispose(): void;
}
}

View File

@@ -15,19 +15,7 @@ import toast from "../../../services/toast";
import { CreateChildrenResponse } from "@triliumnext/commons";
import contextMenu from "../../../menus/context_menu";
import appContext from "../../../components/app_context";
interface MapData {
notes: {
noteId: string;
x: number;
y: number;
}[];
transform: {
x: number,
y: number,
scale: number
}
}
import RelationMapApi, { MapData, MapDataNoteEntry } from "./api";
interface Clipboard {
noteId: string;
@@ -50,7 +38,9 @@ const uniDirectionalOverlays: OverlaySpec[] = [
export default function RelationMap({ note, ntxId }: TypeWidgetProps) {
const [ data, setData ] = useState<MapData>();
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<jsPlumbInstance>(null);
const mapApiRef = useRef<RelationMapApi>(null);
const pbApiRef = useRef<jsPlumbInstance>(null);
const spacedUpdate = useEditorSpacedUpdate({
note,
getData() {
@@ -61,7 +51,14 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) {
onContentChange(content) {
if (content) {
try {
setData(JSON.parse(content));
const data = JSON.parse(content);
setData(data);
mapApiRef.current = new RelationMapApi(note, data, (newData, refreshUi) => {
if (refreshUi) {
setData(newData);
}
spacedUpdate.scheduleUpdate();
});
return;
} catch (e) {
console.log("Could not parse content: ", e);
@@ -87,24 +84,17 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) {
});
const onTransform = useCallback((pzInstance: PanZoom) => {
if (!containerRef.current || !apiRef.current || !data) return;
if (!containerRef.current || !mapApiRef.current || !pbApiRef.current || !data) return;
const zoom = getZoom(containerRef.current);
apiRef.current.setZoom(zoom);
data.transform = JSON.parse(JSON.stringify(pzInstance.getTransform()));
spacedUpdate.scheduleUpdate();
mapApiRef.current.setTransform(pzInstance.getTransform());
pbApiRef.current.setZoom(zoom);
}, [ data ]);
const onNewItem = useCallback((newNote: MapData["notes"][number]) => {
if (!data) return;
data.notes.push(newNote);
setData({ ...data });
spacedUpdate.scheduleUpdate();
}, [ data, spacedUpdate ]);
const clickCallback = useNoteCreation({
containerRef,
note,
ntxId,
onCreate: onNewItem
mapApiRef
});
usePanZoom({
@@ -129,7 +119,7 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) {
<div className="note-detail-relation-map note-detail-printable">
<div className="relation-map-wrapper" onClick={clickCallback}>
<JsPlumb
apiRef={apiRef}
apiRef={pbApiRef}
containerRef={containerRef}
className="relation-map-container"
props={{
@@ -140,7 +130,7 @@ export default function RelationMap({ note, ntxId }: TypeWidgetProps) {
}}
>
{data?.notes.map(note => (
<NoteBox {...note} />
<NoteBox {...note} mapApiRef={mapApiRef} />
))}
</JsPlumb>
</div>
@@ -193,11 +183,11 @@ function usePanZoom({ ntxId, containerRef, options, transformData, onTransform }
});
}
function useNoteCreation({ ntxId, note, containerRef, onCreate }: {
function useNoteCreation({ ntxId, note, containerRef, mapApiRef }: {
ntxId: string | null | undefined;
note: FNote;
containerRef: RefObject<HTMLDivElement>;
onCreate: (newNote: MapData["notes"][number]) => void;
mapApiRef: RefObject<RelationMapApi>;
}) {
const clipboardRef = useRef<Clipboard>(null);
useTriliumEvent("relationMapCreateChildNote", async ({ ntxId: eventNtxId }) => {
@@ -227,10 +217,10 @@ function useNoteCreation({ ntxId, note, containerRef, onCreate }: {
x -= 80;
y -= 15;
onCreate({ noteId: clipboard.noteId, x, y });
mapApiRef.current?.createItem({ noteId: clipboard.noteId, x, y });
clipboardRef.current = null;
}
}, [ onCreate ]);
}, []);
return onClickHandler;
}
@@ -267,7 +257,7 @@ function JsPlumb({ className, props, children, containerRef: externalContainerRe
)
}
function NoteBox({ noteId, x, y }: MapData["notes"][number]) {
function NoteBox({ noteId, x, y, mapApiRef }: MapDataNoteEntry & { mapApiRef: RefObject<RelationMapApi> }) {
const [ note, setNote ] = useState<FNote | null>();
useEffect(() => {
froca.getNote(noteId).then(setNote);
@@ -286,12 +276,19 @@ function NoteBox({ noteId, x, y }: MapData["notes"][number]) {
},
{
title: t("relation_map.remove_note"),
uiIcon: "bx bx-trash"
uiIcon: "bx bx-trash",
handler: async () => {
if (!note) return;
const result = await dialog.confirmDeleteNoteBoxWithNote(note.title);
if (typeof result !== "object" || !result.confirmed) return;
mapApiRef.current?.removeItem(noteId, result.isDeleteNoteChecked);
}
}
],
selectMenuItemHandler() {}
})
}, [ noteId ]);
}, [ note ]);
return note && (
<div

View File

@@ -0,0 +1,61 @@
import FNote from "../../../entities/fnote";
import server from "../../../services/server";
import utils from "../../../services/utils";
export interface MapDataNoteEntry {
noteId: string;
x: number;
y: number;
}
export interface MapData {
notes: MapDataNoteEntry[];
transform: PanZoomTransform;
}
const DELTA = 0.0001;
export default class RelationMapApi {
private data: MapData;
private relations: any[];
private onDataChange: (refreshUi: boolean) => void;
constructor(note: FNote, initialMapData: MapData, onDataChange: (newData: MapData, refreshUi: boolean) => void) {
this.data = initialMapData;
this.onDataChange = (refreshUi) => onDataChange({ ...this.data }, refreshUi);
}
createItem(newNote: MapDataNoteEntry) {
this.data.notes.push(newNote);
this.onDataChange(true);
}
async removeItem(noteId: string, deleteNoteToo: boolean) {
console.log("Remove ", noteId, deleteNoteToo);
if (deleteNoteToo) {
const taskId = utils.randomString(10);
await server.remove(`notes/${noteId}?taskId=${taskId}&last=true`);
}
if (this.data) {
this.data.notes = this.data.notes.filter((note) => note.noteId !== noteId);
}
if (this.relations) {
this.relations = this.relations.filter((relation) => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId);
}
this.onDataChange(true);
}
setTransform(transform: PanZoomTransform) {
if (this.data.transform.scale - transform.scale > DELTA
|| this.data.transform.x - transform.x > DELTA
|| this.data.transform.y - transform.y > DELTA) {
this.data.transform = { ...transform };
this.onDataChange(false);
}
}
}

View File

@@ -173,31 +173,6 @@ export default class RelationMapTypeWidget extends TypeWidget {
const $title = $noteBox.find(".title a");
const noteId = this.idToNoteId($noteBox.prop("id"));
if (command === "openInNewTab") {
} else if (command === "remove") {
const result = await dialogService.confirmDeleteNoteBoxWithNote($title.text());
if (typeof result !== "object" || !result.confirmed) {
return;
}
this.jsPlumbInstance?.remove(this.noteIdToId(noteId));
if (result.isDeleteNoteChecked) {
const taskId = utils.randomString(10);
await server.remove(`notes/${noteId}?taskId=${taskId}&last=true`);
}
if (this.mapData) {
this.mapData.notes = this.mapData.notes.filter((note) => note.noteId !== noteId);
}
if (this.relations) {
this.relations = this.relations.filter((relation) => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId);
}
this.saveData();
} else if (command === "editTitle") {
const title = await dialogService.prompt({
title: t("relation_map.rename_note"),