chore(type_widgets): introduce panzoom

This commit is contained in:
Elian Doran 2025-09-29 19:28:46 +03:00
parent 9d4127ba6d
commit d8b9d14712
No known key found for this signature in database
2 changed files with 87 additions and 56 deletions

View File

@ -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<HTMLDivElement>(null);
const apiRef = useRef<jsPlumbInstance>(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 (
<div className="note-detail-relation-map note-detail-printable">
<div className="relation-map-wrapper">
<JsPlumb
apiRef={apiRef}
containerRef={containerRef}
className="relation-map-container"
props={{
Endpoint: ["Dot", { radius: 2 }],
@ -89,23 +117,60 @@ function useData(note: FNote) {
return content;
}
function JsPlumb({ className, props, children }: {
function usePanZoom({ containerRef, options, transformData, onTransform }: {
containerRef: RefObject<HTMLElement>;
options: PanZoomOptions;
transformData: MapData["transform"] | undefined;
onTransform: () => void
}) {
const apiRef = useRef<PanZoom>(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<Defaults, "container">;
children: ComponentChildren;
containerRef?: RefObject<HTMLElement>;
apiRef?: RefObject<jsPlumbInstance>;
}) {
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={className}>
@ -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]);
}

View File

@ -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();