From d8b9d14712337acf6b2d8b241cd4537fb267b61a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 29 Sep 2025 19:28:46 +0300 Subject: [PATCH] chore(type_widgets): introduce panzoom --- .../src/widgets/type_widgets/RelationMap.tsx | 93 +++++++++++++++++-- .../widgets/type_widgets_old/relation_map.ts | 50 ---------- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/RelationMap.tsx b/apps/client/src/widgets/type_widgets/RelationMap.tsx index 38f0ef2bf..7067f9a96 100644 --- a/apps/client/src/widgets/type_widgets/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/RelationMap.tsx @@ -1,13 +1,14 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { TypeWidgetProps } from "./type_widget"; -import { Defaults, jsPlumb, OverlaySpec } from "jsplumb"; -import { useNoteBlob } from "../react/hooks"; +import { Defaults, jsPlumb, jsPlumbInstance, OverlaySpec } from "jsplumb"; +import { useEditorSpacedUpdate, useNoteBlob } from "../react/hooks"; import FNote from "../../entities/fnote"; -import { ComponentChildren } from "preact"; +import { ComponentChildren, RefObject } from "preact"; import froca from "../../services/froca"; import NoteLink from "../react/NoteLink"; import "./RelationMap.css"; import { t } from "../../services/i18n"; +import panzoom, { PanZoomOptions } from "panzoom"; interface MapData { notes: { @@ -37,11 +38,38 @@ const uniDirectionalOverlays: OverlaySpec[] = [ export default function RelationMap({ note }: TypeWidgetProps) { const data = useData(note); + const containerRef = useRef(null); + const apiRef = useRef(null); + + const onTransform = useCallback(() => { + if (!containerRef.current || !apiRef.current) return; + const zoom = getZoom(containerRef.current); + apiRef.current.setZoom(zoom); + }, [ ]); + + usePanZoom({ + containerRef, + options: { + maxZoom: 2, + minZoom: 0.3, + smoothScroll: false, + //@ts-expect-error Upstream incorrectly mentions no arguments. + filterKey: function (e: KeyboardEvent) { + // if ALT is pressed, then panzoom should bubble the event up + // this is to preserve ALT-LEFT, ALT-RIGHT navigation working + return e.altKey; + } + }, + transformData: data.transform, + onTransform + }); return (
; + options: PanZoomOptions; + transformData: MapData["transform"] | undefined; + onTransform: () => void +}) { + const apiRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + const pzInstance = panzoom(containerRef.current, options); + apiRef.current = pzInstance; + + if (transformData) { + pzInstance.zoomTo(0, 0, transformData.scale); + pzInstance.moveTo(transformData.x, transformData.y); + } else { + // set to initial coordinates + pzInstance.moveTo(0, 0); + } + + if (onTransform) { + apiRef.current!.on("transform", onTransform); + } + + return () => pzInstance.dispose(); + }, [ containerRef, onTransform ]); +} + +function JsPlumb({ className, props, children, containerRef: externalContainerRef, apiRef }: { className?: string; props: Omit; children: ComponentChildren; + containerRef?: RefObject; + apiRef?: RefObject; }) { const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; + if (externalContainerRef) { + externalContainerRef.current = containerRef.current; + } const jsPlumbInstance = jsPlumb.getInstance({ Container: containerRef.current, ...props }); + if (apiRef) { + apiRef.current = jsPlumbInstance; + } return () => jsPlumbInstance.cleanupListeners(); - }, []); + }, [ apiRef ]); return (
@@ -142,3 +207,19 @@ function noteIdToId(noteId: string) { function idToNoteId(id: string) { return id.substr(13); } + +function getZoom(container: HTMLDivElement) { + const transform = window.getComputedStyle(container).transform; + if (transform === "none") { + return 1; + } + + const matrixRegex = /matrix\((-?\d*\.?\d+),\s*0,\s*0,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+\)/; + const matches = transform.match(matrixRegex); + + if (!matches) { + throw new Error(t("relation_map.cannot_match_transform", { transform })); + } + + return parseFloat(matches[1]); +} 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 f398156dd..ebd3119b3 100644 --- a/apps/client/src/widgets/type_widgets_old/relation_map.ts +++ b/apps/client/src/widgets/type_widgets_old/relation_map.ts @@ -339,43 +339,11 @@ export default class RelationMapTypeWidget extends TypeWidget { } async initPanZoom() { - if (this.pzInstance) { - return; - } - const panzoom = (await import("panzoom")).default; - this.pzInstance = panzoom(this.$relationMapContainer[0], { - maxZoom: 2, - minZoom: 0.3, - smoothScroll: false, - - //@ts-expect-error Upstream incorrectly mentions no arguments. - filterKey: function (e: KeyboardEvent) { - // if ALT is pressed, then panzoom should bubble the event up - // this is to preserve ALT-LEFT, ALT-RIGHT navigation working - return e.altKey; - } - }); - - if (!this.pzInstance) { - return; - } this.pzInstance.on("transform", () => { - // gets triggered on any transform change - this.jsPlumbInstance?.setZoom(this.getZoom()); - this.saveCurrentTransform(); }); - - if (this.mapData?.transform) { - this.pzInstance.zoomTo(0, 0, this.mapData.transform.scale); - - this.pzInstance.moveTo(this.mapData.transform.x, this.mapData.transform.y); - } else { - // set to initial coordinates - this.pzInstance.moveTo(0, 0); - } } saveCurrentTransform() { @@ -564,24 +532,6 @@ export default class RelationMapTypeWidget extends TypeWidget { }); } - getZoom() { - const matrixRegex = /matrix\((-?\d*\.?\d+),\s*0,\s*0,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+\)/; - - const transform = this.$relationMapContainer.css("transform"); - - if (transform === "none") { - return 1; - } - - const matches = transform.match(matrixRegex); - - if (!matches) { - throw new Error(t("relation_map.cannot_match_transform", { transform })); - } - - return parseFloat(matches[1]); - } - async dropNoteOntoRelationMapHandler(ev: JQuery.DropEvent) { ev.preventDefault();