diff --git a/apps/client/package.json b/apps/client/package.json index c5957e1b54..ef874c4781 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -59,7 +59,6 @@ "marked": "18.0.14", "mermaid": "12.1.0", "mind-elixir": "5.15.1", - "panzoom": "9.4.4", "preact": "11.0.0", "qrcode-generator": "2.0.4", "react-i18next": "17.0.15", diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 1449496bc2..9aae2dcd83 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1665,7 +1665,6 @@ "connection_exists": "Connection '{{name}}' between these notes already exists.", "start_dragging_relations": "Start dragging relations from here and drop them on another note.", "note_not_found": "Note {{noteId}} not found!", - "cannot_match_transform": "Cannot match transform: {{transform}}", "note_already_in_diagram": "Note \"{{title}}\" is already in the diagram.", "default_new_note_title": "new note", "add_note_toast_title": "New note", diff --git a/apps/client/src/types.d.ts b/apps/client/src/types.d.ts index 2a8a8a5184..fa9b1282e8 100644 --- a/apps/client/src/types.d.ts +++ b/apps/client/src/types.d.ts @@ -91,29 +91,4 @@ declare global { var glob: CustomGlobals; //@ts-ignore var require: RequireMethod; - - /* - * Panzoom - */ - - function panzoom(el: HTMLElement, opts: { - maxZoom: number, - minZoom: number, - smoothScroll: false, - 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(): PanZoomTransform; - dispose(): void; - } } diff --git a/apps/client/src/widgets/react/zoom_pan.spec.ts b/apps/client/src/widgets/react/zoom_pan.spec.ts index a7342d2f4a..108a48e2cb 100644 --- a/apps/client/src/widgets/react/zoom_pan.spec.ts +++ b/apps/client/src/widgets/react/zoom_pan.spec.ts @@ -4,7 +4,7 @@ import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch"; import { describe, expect, it, vi } from "vitest"; import { - clampPan, useZoomPanWheel, wheelTargetScale, zoomStep, zoomToPointPosition + clampPan, panBounds, useZoomPanWheel, wheelTargetScale, zoomStep, zoomToPointPosition } from "./zoom_pan"; describe("zoomStep", () => { @@ -97,6 +97,16 @@ describe("clampPan", () => { }); }); +describe("panBounds", () => { + it("keeps a bounded instance to its bounds, and an unbounded one to none", () => { + const bounds = { minPositionX: -10, maxPositionX: 0, minPositionY: -10, maxPositionY: 0 }; + const api = (limitToBounds: boolean) => ({ instance: { setup: { limitToBounds }, bounds } }) as unknown as ReactZoomPanPinchRef; + + expect(panBounds(api(true))).toBe(bounds); + expect(panBounds(api(false))).toBeNull(); + }); +}); + describe("zoomToPointPosition", () => { it("leaves the position unchanged when the cursor sits on the content origin", () => { // Cursor at (posX0, posY0) → content point 0, so scaling moves nothing. diff --git a/apps/client/src/widgets/react/zoom_pan.ts b/apps/client/src/widgets/react/zoom_pan.ts index e8eeaf9f41..7e69ab274a 100644 --- a/apps/client/src/widgets/react/zoom_pan.ts +++ b/apps/client/src/widgets/react/zoom_pan.ts @@ -93,7 +93,7 @@ export function useZoomPanWheel( scale, positionX, positionY, zoomed, event.clientX - rect.left, event.clientY - rect.top ); - const bounds = api.instance.bounds; + const bounds = panBounds(api); const { x, y } = bounds ? clampPan(anchored.x, anchored.y, bounds) : anchored; api.setTransform(x, y, zoomed, 0); }; @@ -163,6 +163,14 @@ export function wheelTargetScale(scale: number, deltaY: number, deltaMode = 0) { interface PanBounds { minPositionX: number; maxPositionX: number; minPositionY: number; maxPositionY: number; } +/** + * The bounds a pan is clamped to, or `null` when the instance has none to keep to. The library + * computes `bounds` even with `limitToBounds={false}`, as on the relation map's unbounded canvas. + */ +export function panBounds(api: ReactZoomPanPinchRef): PanBounds | null { + return api.instance.setup.limitToBounds ? api.instance.bounds : null; +} + /** Clamps a candidate content position to the library's computed pan bounds. */ export function clampPan(x: number, y: number, bounds: PanBounds): { x: number; y: number } { return { diff --git a/apps/client/src/widgets/react/zoom_pan_keyboard.ts b/apps/client/src/widgets/react/zoom_pan_keyboard.ts index 65839fcd21..720393f267 100644 --- a/apps/client/src/widgets/react/zoom_pan_keyboard.ts +++ b/apps/client/src/widgets/react/zoom_pan_keyboard.ts @@ -5,7 +5,7 @@ import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch"; import type { ShortcutHintDefinition } from "../../services/shortcut_hints"; import { isAppShortcutChord } from "../../services/shortcuts"; -import { clampPan, zoomToPointPosition } from "./zoom_pan"; +import { clampPan, panBounds, zoomToPointPosition } from "./zoom_pan"; export type ZoomPanControl = | "zoomIn" | "zoomOut" | "reset" @@ -152,9 +152,12 @@ export function useZoomPanKeyboard( const { dx, dy } = getPanDelta(controls, shift, dt); if (dx || dy) { const { scale, positionX, positionY } = api.instance.state; - const bounds = api.instance.bounds; - if (bounds) { - const next = clampPan(positionX + dx, positionY + dy, bounds); + const bounds = panBounds(api); + // A bounded instance has no bounds until its first layout. + if (bounds || !api.instance.setup.limitToBounds) { + const next = bounds + ? clampPan(positionX + dx, positionY + dy, bounds) + : { x: positionX + dx, y: positionY + dy }; api.setTransform(next.x, next.y, scale, 0); // Keyboard panning shifts the image under a held mouse cursor; re-anchor the // drag so the new point under the cursor becomes its grab point. diff --git a/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.spec.tsx b/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.spec.tsx index 63b70ff1a4..4bfab23592 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.spec.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.spec.tsx @@ -2,7 +2,6 @@ * The two groups standing over a relation map (see MapToolbar.tsx): the camera at one foot corner, * and the editing actions at the other. */ -import { render } from "preact"; import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; @@ -12,54 +11,33 @@ import MapToolbar, { EditToolbar, type MapCommand } from "./MapToolbar"; vi.mock("../../../services/i18n", () => ({ t: (key: string) => key })); describe("relation map MapToolbar", () => { - it("says the scale the map is drawn at, following it as the map is moved", () => { - const { map, readout } = renderToolbar(); - + it("says the scale the map is drawn at", () => { + const { readout, rerender } = renderToolbar(); expect(readout()?.textContent).toBe("100%"); - act(() => map.moveTo(2.5)); - expect(readout()?.textContent).toBe("250%"); - - // A pan leaves the scale where it was, so the readout says the same thing. - act(() => map.moveTo(2.5)); + rerender({ scale: 2.5 }); expect(readout()?.textContent).toBe("250%"); }); it("leaves a step with no room left to it disabled", () => { - const { map, zoomIn, zoomOut } = renderToolbar(); - + const { zoomIn, zoomOut, rerender } = renderToolbar(); expect([ zoomOut()?.disabled, zoomIn()?.disabled ]).toEqual([ false, false ]); - act(() => map.moveTo(MIN_ZOOM)); + rerender({ canZoomOut: false }); expect([ zoomOut()?.disabled, zoomIn()?.disabled ]).toEqual([ true, false ]); - act(() => map.moveTo(MAX_ZOOM)); + rerender({ canZoomIn: false }); expect([ zoomOut()?.disabled, zoomIn()?.disabled ]).toEqual([ false, true ]); }); it("asks for what each button stands for rather than moving the map itself", () => { - const { map, commands, readout, zoomIn, zoomOut } = renderToolbar(); + const { commands, readout, zoomIn, zoomOut } = renderToolbar(); act(() => zoomOut()?.click()); act(() => readout()?.click()); act(() => zoomIn()?.click()); expect(commands).toEqual([ "relationMapResetZoomOut", "relationMapResetPanZoom", "relationMapResetZoomIn" ]); - expect(map.transformListeners()).toBe(1); - }); - - it("stands aside while there is no map to read", () => { - const { buttons } = renderToolbar({ withMap: false }); - - expect(buttons()).toHaveLength(0); - }); - - it("stops listening to a map that is torn down", () => { - const { map, unmount } = renderToolbar(); - expect(map.transformListeners()).toBe(1); - - unmount(); - expect(map.transformListeners()).toBe(0); }); }); @@ -91,33 +69,28 @@ describe("relation map EditToolbar", () => { }); }); -const MIN_ZOOM = 0.3; -const MAX_ZOOM = 2; - -/** Builds the camera group over a map, holding on to what the buttons ask of it. */ -function renderToolbar({ withMap = true } = {}) { - const map = fakeMap(); +/** Builds the camera group, holding on to what the buttons ask for. */ +function renderToolbar() { const commands: MapCommand[] = []; let container: HTMLElement | undefined; - act(() => { + const rerender = (zoom: Partial[0]["zoom"]> = {}) => act(() => { container = renderInto( [0]["panZoom"] : undefined} + zoom={{ scale: 1, canZoomIn: true, canZoomOut: true, ...zoom }} onCommand={(command) => commands.push(command)} /> ); }); + rerender(); if (!container) throw new Error("the toolbar was not rendered"); const all = () => [ ...container?.querySelectorAll(".relation-map-toolbar button") ?? [] ]; return { - map, commands, - buttons: all, + rerender, zoomOut: () => all()[0], readout: () => all()[1], - zoomIn: () => all()[2], - unmount: () => act(() => render(null, container as HTMLElement)) + zoomIn: () => all()[2] }; } @@ -136,22 +109,3 @@ function renderEditToolbar({ isReadOnly = false, placing = false } = {}) { }; } -/** A stand-in for panzoom: a scale that can be moved, told to whoever asked to be told. */ -function fakeMap() { - let scale = 1; - const listeners = new Set<() => void>(); - - return { - getTransform: () => ({ x: 0, y: 0, scale }), - getMinZoom: () => MIN_ZOOM, - getMaxZoom: () => MAX_ZOOM, - on: (_event: string, handler: () => void) => listeners.add(handler), - off: (_event: string, handler: () => void) => listeners.delete(handler), - /** Reports a move, as the library does for a pan as well as for a zoom. */ - moveTo: (newScale: number) => { - scale = newScale; - for (const listener of listeners) listener(); - }, - transformListeners: () => listeners.size - }; -} diff --git a/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.tsx b/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.tsx index 9f07f911ed..fb3346e7cc 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/MapToolbar.tsx @@ -1,17 +1,14 @@ import "./MapToolbar.css"; -import type { PanZoom } from "panzoom"; -import { useEffect, useState } from "preact/hooks"; - import { t } from "../../../services/i18n"; import OverlayControlGroup, { OverlayControlButton, ZoomControls } from "../../react/OverlayControlGroup"; -/** What the buttons ask for, which is what the map itself answers (see `usePanZoom` in RelationMap.tsx). */ +/** What the buttons ask for, which is what the map itself answers (see `useMapZoom` in RelationMap.tsx). */ export type MapCommand = "relationMapResetZoomIn" | "relationMapResetZoomOut" | "relationMapResetPanZoom"; interface MapToolbarProps { - /** The map the scale is read from, or `undefined` while there is none yet. */ - panZoom: PanZoom | undefined; + /** The scale the map is drawn at, and whether each zoom step has room left. */ + zoom: { scale: number; canZoomIn: boolean; canZoomOut: boolean }; onCommand: (command: MapCommand) => void; } @@ -28,20 +25,15 @@ interface MapToolbarProps { * What the three do is asked for as commands rather than done here — the same commands a script can * trigger (`api.triggerCommand`), which is why they outlive the floating buttons that were the other * caller. The map itself is only read from: for the scale to show, and for the ends of its range, - * which is what leaves a step with nothing left to give disabled. Absent until there is a map to - * read. + * which is what leaves a step with nothing left to give disabled. */ -export default function MapToolbar({ panZoom, onCommand }: MapToolbarProps) { - const scale = useMapScale(panZoom); - - if (!panZoom) return null; - +export default function MapToolbar({ zoom, onCommand }: MapToolbarProps) { return ( panZoom.getMinZoom()} + percent={zoom.scale * 100} + canZoomIn={zoom.canZoomIn} + canZoomOut={zoom.canZoomOut} onZoomIn={() => onCommand("relationMapResetZoomIn")} onZoomOut={() => onCommand("relationMapResetZoomOut")} onReset={() => onCommand("relationMapResetPanZoom")} @@ -98,26 +90,3 @@ export function EditToolbar({ isReadOnly, placing, onTogglePlacement }: EditTool ); } -/** - * The scale the map is drawn at, followed as it changes — by these buttons, by the wheel, or by the - * saved view being restored. - * - * Read off the map's own transform, which it reports as it is panned as well as zoomed: a pan leaves - * the scale where it was, and a state set to the number it already holds costs nothing. - */ -function useMapScale(panZoom: PanZoom | undefined) { - const [ scale, setScale ] = useState(1); - - useEffect(() => { - if (!panZoom) return; - - const report = () => setScale(panZoom.getTransform().scale); - // The map may have been moved between being built and being listened to. - report(); - - panZoom.on("transform", report); - return () => panZoom.off("transform", report); - }, [ panZoom ]); - - return scale; -} diff --git a/apps/client/src/widgets/type_widgets/relation_map/NoteBox.tsx b/apps/client/src/widgets/type_widgets/relation_map/NoteBox.tsx index 6c2b365d57..ac2899bc4a 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/NoteBox.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/NoteBox.tsx @@ -65,9 +65,6 @@ export function NoteBox({ noteId, x, y, mapApiRef, selected, highlighted, dropTa style={{ left: `${x}px`, top: `${y}px` }} onContextMenu={contextMenuHandler} onPointerDown={onPointerDown} - // Keeps `panzoom` from panning the map while the box is dragged. - onMouseDown={stopPropagation} - onTouchStart={stopPropagation} > {title} @@ -88,7 +85,3 @@ export function GhostNoteBox({ elementRef }: { elementRef: RefObject ); } - -function stopPropagation(e: Event) { - e.stopPropagation(); -} diff --git a/apps/client/src/widgets/type_widgets/relation_map/NotePane.css b/apps/client/src/widgets/type_widgets/relation_map/NotePane.css index e6dceebaa4..6541366f62 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/NotePane.css +++ b/apps/client/src/widgets/type_widgets/relation_map/NotePane.css @@ -4,10 +4,6 @@ --relation-map-pane-width: 380px; } -.relation-map-note-pane-host { - display: contents; -} - .relation-map-wrapper .relation-map-note-pane { position: absolute; top: var(--relation-map-inset); diff --git a/apps/client/src/widgets/type_widgets/relation_map/NotePane.spec.tsx b/apps/client/src/widgets/type_widgets/relation_map/NotePane.spec.tsx index b5ce920297..49da948ea8 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/NotePane.spec.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/NotePane.spec.tsx @@ -186,18 +186,4 @@ describe("relation map NotePane", () => { expect(pane()?.querySelector(".tn-embedded-note-remove")).toBeNull(); expect(pane()?.querySelector(".tn-embedded-note-color")).toBeNull(); }); - - it("keeps wheel and double clicks inside the pane from reaching the map", async () => { - const reached = vi.fn(); - container?.addEventListener("wheel", reached); - container?.addEventListener("dblclick", reached); - await mount(); - - const body = pane()?.querySelector(".relation-map-note-pane-body"); - expect(body).toBeTruthy(); - body?.dispatchEvent(new WheelEvent("wheel", { bubbles: true })); - body?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); - - expect(reached).not.toHaveBeenCalled(); - }); }); diff --git a/apps/client/src/widgets/type_widgets/relation_map/NotePane.tsx b/apps/client/src/widgets/type_widgets/relation_map/NotePane.tsx index 28e40a49f6..62800cbc5f 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/NotePane.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/NotePane.tsx @@ -85,53 +85,40 @@ export default function NotePane({ paneRef, noteIdsOnMap, mapApiRef, isReadOnly, if (!note || !isOnMap) return null; return ( - // Stops wheel, double-click and touch events in the pane from reaching `panzoom`, which - // listens on the whole map. -
- - - - - {!isReadOnly && <> - - void confirmRemoveFromMap(note, mapApiRef)} - /> - } - - - - - {selection?.isNew && } - -
+ + + + + {!isReadOnly && <> + + void confirmRemoveFromMap(note, mapApiRef)} + /> + } + + + + + {selection?.isNew && } + ); } const PANE_NTX_ID_PREFIX = "_relation-map-note-pane"; - -function stopPropagation(e: Event) { - e.stopPropagation(); -} diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.css b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.css index 7c929c79a4..4de86c3790 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.css +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.css @@ -14,7 +14,12 @@ .relation-map-wrapper { position: relative; height: 100%; - outline: none; /* remove dotted outline on click */ +} + +.relation-map-viewport, +.relation-map-viewport .relation-map-transform.react-transform-wrapper { + width: 100%; + height: 100%; } .note-detail-relation-map .relation-map-connections { diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.tsx b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.tsx index ef9e89e954..7a790a2d7c 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.tsx @@ -1,10 +1,38 @@ -import type { PanZoom } from "panzoom"; -import { render } from "preact"; -import { useRef } from "preact/hooks"; +import { type ComponentChildren, render } from "preact"; +import { useRef, useState } from "preact/hooks"; +import { forwardRef } from "preact/compat"; +import { useImperativeHandle } from "preact/hooks"; import { act } from "preact/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useCanvasClicks, useRevealSelectedBox } from "./RelationMap"; +// A stand-in for the zoom library, which needs real layout: it applies a transform at once and +// reports it, as the library does once an animation ends. +const { wrapperProps } = vi.hoisted(() => ({ wrapperProps: { current: null as Record | null } })); +vi.mock("react-zoom-pan-pinch", () => ({ + TransformWrapper: forwardRef((props: { children?: ComponentChildren; onTransform?(ref: unknown, state: object): void }, ref) => { + wrapperProps.current = props; + useImperativeHandle(ref, () => { + const api = { + instance: { state: { positionX: 0, positionY: 0, scale: 1 } }, + setTransform(positionX: number, positionY: number, scale: number) { + api.instance.state = { positionX, positionY, scale }; + props.onTransform?.(api, api.instance.state); + }, + zoomIn: (step: number) => api.setTransform(0, 0, api.instance.state.scale + step), + zoomOut: (step: number) => api.setTransform(0, 0, api.instance.state.scale - step) + }; + return api; + }, []); + return <>{props.children}; + }), + TransformComponent: (props: { children?: ComponentChildren }) =>
{props.children}
+})); + +import Component from "../../../components/component"; +import { ParentComponent } from "../../react/react_utils"; +import type RelationMapApi from "./api"; +import type { MapTransform } from "./api"; +import { MapViewport, useCanvasClicks, useMapZoom, useRevealSelectedBox } from "./RelationMap"; import { noteIdToId } from "./utils"; describe("relation map canvas clicks", () => { @@ -141,7 +169,7 @@ describe("relation map revealing the selected box", () => { function Harness({ noteId }: { noteId: string | undefined }) { const wrapperRef = useRef(null); const canvasRef = useRef(null); - useRevealSelectedBox({ wrapperRef, containerRef: canvasRef, panZoom: { moveBy } as unknown as PanZoom, noteId }); + useRevealSelectedBox({ wrapperRef, containerRef: canvasRef, moveBy, noteId }); return (
@@ -171,9 +199,9 @@ describe("relation map revealing the selected box", () => { it("pans a box out from under the pane, and leaves one alone that stands clear", () => { addBoxBeforeMount("under", 1000); expect(moveBy).toHaveBeenCalledTimes(1); - const [ dx, dy, smooth ] = moveBy.mock.calls[0]; + const [ dx, dy ] = moveBy.mock.calls[0]; expect(dx).toBeLessThan(0); - expect([ dy, smooth ]).toEqual([ 0, true ]); + expect(dy).toBe(0); moveBy.mockClear(); addBoxBeforeMount("clear", 100); @@ -197,3 +225,70 @@ describe("relation map revealing the selected box", () => { mount(noteId); } }); + +describe("relation map zoom", () => { + let container: HTMLElement | undefined; + const setTransform = vi.fn(); + let zoom: ReturnType | undefined; + const component = new Component(); + + afterEach(() => { + if (container) { + render(null, container); + container.remove(); + container = undefined; + } + setTransform.mockClear(); + }); + + function Harness({ loadedTransform }: { loadedTransform: MapTransform }) { + const [ viewport, setViewport ] = useState(null); + const mapApiRef = useRef({ setTransform } as unknown as RelationMapApi); + zoom = useMapZoom({ ntxId: "map", viewport, loadedTransform, mapApiRef }); + return
; + } + + function mount(loadedTransform: MapTransform) { + container = document.createElement("div"); + document.body.appendChild(container); + act(() => render( + , + container as HTMLElement)); + } + + const state = () => zoom?.ref.current?.instance.state; + const trigger = (name: "relationMapResetPanZoom" | "relationMapResetZoomIn" | "relationMapResetZoomOut", ntxId: string) => + act(() => { component.handleEvent(name, { ntxId }); }); + + it("pans an unbounded canvas, and leaves boxes, labels and relations to be dragged and clicked", () => { + mount({ x: 0, y: 0, scale: 1 }); + + expect(wrapperProps.current).toMatchObject({ + limitToBounds: false, + panning: { excluded: [ "note-box", "connection-label", "relation-map-connection-hit" ] } + }); + }); + + it("restores the saved view, saves every change and goes back to the origin on reset", () => { + mount({ x: -800, y: 600, scale: 1.5 }); + expect(state()).toEqual({ positionX: -800, positionY: 600, scale: 1.5 }); + expect(zoom?.scale).toBe(1.5); + + trigger("relationMapResetPanZoom", "map"); + expect(state()).toEqual({ positionX: 0, positionY: 0, scale: 1 }); + expect(setTransform).toHaveBeenLastCalledWith({ x: 0, y: 0, scale: 1 }); + }); + + it("steps the zoom on the commands of its own map only", () => { + mount({ x: 0, y: 0, scale: 1 }); + + trigger("relationMapResetZoomIn", "other"); + expect(state()?.scale).toBe(1); + + trigger("relationMapResetZoomIn", "map"); + expect(state()?.scale).toBeCloseTo(1.2); + + trigger("relationMapResetZoomOut", "map"); + expect(state()?.scale).toBeCloseTo(1); + }); +}); 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 9dab6d4f1f..fe5f5364e1 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx @@ -2,12 +2,9 @@ import "./RelationMap.css"; import { RelationMapPostResponse } from "@triliumnext/commons"; import clsx from "clsx"; -// The library's own types rather than the hand-written `PanZoom` in types.d.ts, which stops at the -// handful of calls the map made when it was written and knows nothing of the rest — the ends of the -// zoom range, or unsubscribing from a report. -import panzoom, { PanZoom, PanZoomOptions } from "panzoom"; -import { HTMLAttributes, RefObject } from "preact"; +import { ComponentChildren, HTMLAttributes, RefObject } from "preact"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { type ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; @@ -19,8 +16,10 @@ import server from "../../../services/server"; import toast from "../../../services/toast"; import { isMobile } from "../../../services/utils"; import { useEditorSpacedUpdate, useNoteLabelBoolean, useTriliumEvent, useTriliumEvents } from "../../react/hooks"; +import { useZoomPanPinch, useZoomPanWheel } from "../../react/zoom_pan"; +import { useZoomPanKeyboard, ZOOM_PAN_VIEWPORT_CLASS } from "../../react/zoom_pan_keyboard"; import { TypeWidgetProps } from "../type_widget"; -import RelationMapApi, { ClientRelation, MapData, MapDataNoteEntry } from "./api"; +import RelationMapApi, { ClientRelation, MapData, MapDataNoteEntry, MapTransform } from "./api"; import Connections from "./Connections"; import { showRelationContextMenu } from "./context_menu"; import { useBoxDragging, useRelationDrawing } from "./drags"; @@ -29,7 +28,7 @@ import MapToolbar, { EditToolbar } from "./MapToolbar"; import { GhostNoteBox, NoteBox } from "./NoteBox"; import NotePane, { type NotePaneHandle, type PaneSelection } from "./NotePane"; import RelationNamePopover, { useRelationNamePrompt } from "./RelationNamePopover"; -import { CLICK_TOLERANCE, getMousePosition, getZoom, idToNoteId, noteIdToId, revealOffset } from "./utils"; +import { CLICK_TOLERANCE, getMousePosition, idToNoteId, noteIdToId, revealOffset } from "./utils"; export default function RelationMap({ note, noteContext, ntxId, parentComponent }: TypeWidgetProps) { const [ data, setData ] = useState(); @@ -38,6 +37,8 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent const wrapperRef = useRef(null); const containerRef = useRef(null); const mapApiRef = useRef(null); + const [ viewport, setViewport ] = useState(null); + const [ loadedTransform, setLoadedTransform ] = useState(); const spacedUpdate = useEditorSpacedUpdate({ note, @@ -75,6 +76,7 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent } setData(newData as MapData); + setLoadedTransform({ ...(newData as MapData).transform }); mapApiRef.current = new RelationMapApi(note, newData as MapData, (newData, refreshUi) => { if (refreshUi) { setData(newData); @@ -87,16 +89,14 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent } }); - const onTransform = useCallback((pzInstance: PanZoom) => { - if (!mapApiRef.current || !data) return; - mapApiRef.current.setTransform(pzInstance.getTransform()); - }, [ data ]); - + const mapZoom = useMapZoom({ ntxId, viewport, loadedTransform, mapApiRef }); + const { getScale } = mapZoom; const [ selection, setSelection ] = useState(null); const noteIdsOnMap = useMemo(() => data?.notes.map((entry) => entry.noteId) ?? [], [ data ]); const paneRef = useRef(null); const placement = useNotePlacement({ containerRef, + getScale, note, ntxId, mapApiRef, @@ -112,32 +112,14 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent onClickEmpty: () => paneRef.current?.close(), onOpenNote: openNoteFromBox }); - const dragProps = useNoteDragging({ containerRef, mapApiRef }); + const dragProps = useNoteDragging({ containerRef, mapApiRef, getScale }); const relationNamePrompt = useRelationNamePrompt(); - const { dragged, startDrag } = useBoxDragging({ containerRef, mapApiRef }); - const { pending, startDrawing } = useRelationDrawing({ containerRef, mapApiRef, askRelationName: relationNamePrompt.ask }); + const { dragged, startDrag } = useBoxDragging({ containerRef, mapApiRef, getScale }); + const { pending, startDrawing } = useRelationDrawing({ containerRef, mapApiRef, getScale, askRelationName: relationNamePrompt.ask }); const { boxes, onBoxResize } = useBoxes(data?.notes, dragged); - const panZoom = usePanZoom({ - ntxId, - containerRef, - options: { - maxZoom: 2, - minZoom: 0.3, - smoothScroll: false, - //@ts-expect-error Upstream incorrectly mentions no arguments. - filterKey (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 - }); - - useRevealSelectedBox({ wrapperRef, containerRef, panZoom, noteId: selection?.noteId }); + useRevealSelectedBox({ wrapperRef, containerRef, moveBy: mapZoom.moveBy, noteId: selection?.noteId }); const hoveredNoteId = useHoveredBox(containerRef); const [ hoveredRelationId, setHoveredRelationId ] = useState(null); const { relations, inverseRelations } = useRelationData(note.noteId, data, mapApiRef); @@ -158,41 +140,43 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent {...clickProps} {...dragProps} > -
- - {data?.notes.map((entry) => { - const position = dragged?.noteId === entry.noteId ? dragged : entry; - return ( - { - if (e.target instanceof Element && e.target.closest(".endpoint")) { - startDrawing(e, entry.noteId); - } else { - startDrag(e, position); - } - }} - onResize={onBoxResize} - /> - ); - })} - {placement.placing && } -
+ +
+ + {data?.notes.map((entry) => { + const position = dragged?.noteId === entry.noteId ? dragged : entry; + return ( + { + if (e.target instanceof Element && e.target.closest(".endpoint")) { + startDrawing(e, entry.noteId); + } else { + startDrag(e, position); + } + }} + onResize={onBoxResize} + /> + ); + })} + {placement.placing && } +
+
{/* Both groups stand on the map whatever layout the note is read in: what is done to a canvas belongs on the canvas, and the bar of buttons above the note is not where the @@ -204,7 +188,7 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent /> parentComponent?.triggerEvent(command, { ntxId })} /> @@ -230,62 +214,95 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent } /** - * Sets the map up to be panned and zoomed, answers the commands that drive it, and hands the - * instance back so that the controls over the map can read it — as state rather than as the ref the - * commands are answered from, so that they are drawn afresh when the map is rebuilt under a new one. + * Pans and zooms the map with react-zoom-pan-pinch, as the image viewer does, on a canvas without + * bounds. Restores `loadedTransform` whenever the note's content loads, saves every change to the + * map's data, and answers the zoom commands, which scripts can trigger too. The wheel zooms without + * the map being focused, since the map fills its pane and has no page to scroll. */ -function usePanZoom({ ntxId, containerRef, options, transformData, onTransform }: { +export function useMapZoom({ ntxId, viewport, loadedTransform, mapApiRef }: { ntxId: string | null | undefined; - containerRef: RefObject; - options: PanZoomOptions; - transformData: MapData["transform"] | undefined; - onTransform: (pzInstance: PanZoom) => void + /** The focusable element the keyboard and the wheel act on. */ + viewport: HTMLDivElement | null; + loadedTransform: MapTransform | undefined; + mapApiRef: RefObject; }) { - const apiRef = useRef(null); - const [ panZoom, setPanZoom ] = useState(); + const zoom = useZoomPanPinch({ minScale: MIN_SCALE, maxScale: MAX_SCALE }); + const { ref } = zoom; + useZoomPanKeyboard(ref, viewport); + useZoomPanWheel(ref, viewport); useEffect(() => { - if (!containerRef.current) return; - const pzInstance = panzoom(containerRef.current, options); - apiRef.current = pzInstance; - setPanZoom(pzInstance); + if (!loadedTransform) return; + ref.current?.setTransform(loadedTransform.x, loadedTransform.y, loadedTransform.scale, 0); + }, [ ref, loadedTransform ]); - if (transformData) { - pzInstance.zoomTo(0, 0, transformData.scale); - pzInstance.moveTo(transformData.x, transformData.y); - } else { - // set to initial coordinates - pzInstance.moveTo(0, 0); - } + const onTransform = useCallback((api: ReactZoomPanPinchRef, state: { scale: number; positionX: number; positionY: number }) => { + zoom.onTransform(api, state); + mapApiRef.current?.setTransform({ x: state.positionX, y: state.positionY, scale: state.scale }); + }, [ zoom.onTransform, mapApiRef ]); - if (onTransform) { - pzInstance.on("transform", () => onTransform(pzInstance)); - } + const getScale = useCallback(() => ref.current?.instance.state.scale ?? 1, [ ref ]); - return () => { - setPanZoom(undefined); - pzInstance.dispose(); - }; - }, [ containerRef, onTransform ]); + const moveBy = useCallback((dx: number, dy: number) => { + const api = ref.current; + if (!api) return; + const { positionX, positionY, scale } = api.instance.state; + api.setTransform(positionX + dx, positionY + dy, scale, REVEAL_ANIMATION_MS); + }, [ ref ]); useTriliumEvents([ "relationMapResetPanZoom", "relationMapResetZoomIn", "relationMapResetZoomOut" ], ({ ntxId: eventNtxId }, eventName) => { - const pzInstance = apiRef.current; - if (eventNtxId !== ntxId || !pzInstance) return; + if (eventNtxId !== ntxId) return; - if (eventName === "relationMapResetPanZoom" && containerRef.current) { - const zoom = getZoom(containerRef.current); - pzInstance.zoomTo(0, 0, 1 / zoom); - pzInstance.moveTo(0, 0); + if (eventName === "relationMapResetPanZoom") { + ref.current?.setTransform(0, 0, 1); } else if (eventName === "relationMapResetZoomIn") { - pzInstance.zoomTo(0, 0, 1.2); - } else if (eventName === "relationMapResetZoomOut") { - pzInstance.zoomTo(0, 0, 0.8); + zoom.zoomIn(); + } else { + zoom.zoomOut(); } }); - return panZoom; + return { ...zoom, onTransform, getScale, moveBy }; } +/** + * The focusable area of the map that pans and zooms its `children`. A press on a box, a label or a + * relation starts no pan, since those are dragged or clicked themselves. + */ +export function MapViewport({ zoom, viewportRef, children }: { + zoom: ReturnType; + viewportRef(element: HTMLDivElement | null): void; + children: ComponentChildren; +}) { + return ( +
+ + + {children} + + +
+ ); +} + +/** The zoom range of the map. */ +const MIN_SCALE = 0.3; +const MAX_SCALE = 2; + +const PAN_EXCLUDED = [ "note-box", "connection-label", "relation-map-connection-hit" ]; + +/** How long panning a selected box into view takes. */ +const REVEAL_ANIMATION_MS = 300; + function useRelationData(noteId: string, mapData: MapData | undefined, mapApiRef: RefObject) { const [ relations, setRelations ] = useState(); const [ inverseRelations, setInverseRelations ] = useState(); @@ -379,10 +396,11 @@ function useBoxes(notes: MapDataNoteEntry[] | undefined, dragged: MapDataNoteEnt * the default title (or the map's `#titleTemplate`), and the pane opens on it with the title * selected. */ -function useNotePlacement({ ntxId, note, containerRef, mapApiRef, onArm, onCreated }: { +function useNotePlacement({ ntxId, note, containerRef, getScale, mapApiRef, onArm, onCreated }: { ntxId: string | null | undefined; note: FNote; containerRef: RefObject; + getScale(): number; mapApiRef: RefObject; onArm(): void; onCreated(noteId: string): void; @@ -425,12 +443,12 @@ function useNotePlacement({ ntxId, note, containerRef, mapApiRef, onArm, onCreat const container = containerRef.current; if (!ghost || !container) return; - const { x, y } = boxPositionAt(e, container); + const { x, y } = boxPositionAt(e, container, getScale()); ghost.style.left = `${x}px`; ghost.style.top = `${y}px`; // Hides the ghost over the toolbars, where a click does not place a note. ghost.classList.toggle("visible", isOnCanvas(e, container)); - }, [ containerRef ]); + }, [ containerRef, getScale ]); const hideGhost = useCallback(() => ghostRef.current?.classList.remove("visible"), []); @@ -440,7 +458,7 @@ function useNotePlacement({ ntxId, note, containerRef, mapApiRef, onArm, onCreat // Leaves placement mode first, so the map does not stay in it if creating the note fails. setPlacing(false); - const position = boxPositionAt(e, container); + const position = boxPositionAt(e, container, getScale()); const { note: created } = await note_create.createNote(note.noteId, { content: "", @@ -452,7 +470,7 @@ function useNotePlacement({ ntxId, note, containerRef, mapApiRef, onArm, onCreat mapApiRef.current.createItem({ noteId: created.noteId, ...position }); onCreated(created.noteId); - }, [ note, containerRef, mapApiRef, onCreated ]); + }, [ note, containerRef, getScale, mapApiRef, onCreated ]); return { placing, ghostRef, followPointer, hideGhost, placeAt }; } @@ -530,23 +548,24 @@ export function useCanvasClicks({ containerRef, placing, onPlace, onSelectNote, * The box of a just-placed note is not in the DOM yet, because `NoteBox` renders only after the * note loads, so the hook waits for it with a `MutationObserver`. */ -export function useRevealSelectedBox({ wrapperRef, containerRef, panZoom, noteId }: { +export function useRevealSelectedBox({ wrapperRef, containerRef, moveBy, noteId }: { wrapperRef: RefObject; containerRef: RefObject; - panZoom: PanZoom | undefined; + /** Pans the map by a distance in page pixels, animated. */ + moveBy(dx: number, dy: number): void; noteId: string | undefined; }) { useEffect(() => { const wrapper = wrapperRef.current; const container = containerRef.current; - if (!noteId || !wrapper || !container || !panZoom || isMobile()) return; + if (!noteId || !wrapper || !container || isMobile()) return; const id = noteIdToId(noteId); const findBox = () => [ ...container.children ].find((child) => child.id === id); const reveal = (box: Element) => { const offset = revealOffset(box.getBoundingClientRect(), wrapper.getBoundingClientRect(), glob.isRtl); if (offset) { - panZoom.moveBy(offset.dx, offset.dy, true); + moveBy(offset.dx, offset.dy); } }; @@ -564,7 +583,7 @@ export function useRevealSelectedBox({ wrapperRef, containerRef, panZoom, noteId }); observer.observe(container, { childList: true }); return () => observer.disconnect(); - }, [ wrapperRef, containerRef, panZoom, noteId ]); + }, [ wrapperRef, containerRef, moveBy, noteId ]); } /** The note of the box under the pointer, or `null` while the pointer is over no box. */ @@ -608,8 +627,8 @@ function openNoteFromBox(noteId: string, e: MouseEvent) { } /** The map position, in unzoomed map pixels, of a box placed under the pointer. */ -function boxPositionAt(e: MouseEvent, container: HTMLDivElement) { - const { x, y } = getMousePosition(e, container, getZoom(container)); +function boxPositionAt(e: MouseEvent, container: HTMLDivElement, scale: number) { + const { x, y } = getMousePosition(e, container, scale); return { x: x - PLACEMENT_OFFSET.x, y: y - PLACEMENT_OFFSET.y }; } @@ -618,8 +637,9 @@ function isOnCanvas(e: MouseEvent, container: HTMLDivElement) { return e.target === e.currentTarget || (e.target instanceof Node && container.contains(e.target)); } -function useNoteDragging({ containerRef, mapApiRef }: { +function useNoteDragging({ containerRef, mapApiRef, getScale }: { containerRef: RefObject; + getScale(): number; mapApiRef: RefObject; }): Pick, "onDrop" | "onDragOver"> { const dragProps = useMemo(() => ({ @@ -631,7 +651,7 @@ function useNoteDragging({ containerRef, mapApiRef }: { if (!dragData) return; const notes = JSON.parse(dragData); - let { x, y } = getMousePosition(ev, container, getZoom(container)); + let { x, y } = getMousePosition(ev, container, getScale()); const entries: (MapDataNoteEntry & { title: string })[] = []; for (const note of notes) { @@ -653,7 +673,7 @@ function useNoteDragging({ containerRef, mapApiRef }: { onDragOver(ev) { ev.preventDefault(); } - }), [ containerRef, mapApiRef ]); + }), [ containerRef, mapApiRef, getScale ]); return dragProps; } 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 681c1c0b6d..55a28f7419 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/api.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/api.ts @@ -13,9 +13,16 @@ export interface MapDataNoteEntry { y: number; } +/** The map's pan and zoom: the content is translated by `x`, `y` and scaled from its top-left corner. */ +export interface MapTransform { + x: number; + y: number; + scale: number; +} + export interface MapData { notes: MapDataNoteEntry[]; - transform: PanZoomTransform; + transform: MapTransform; } export type RelationType = "uniDirectional" | "biDirectional" | "inverse"; @@ -127,10 +134,10 @@ export default class RelationMapApi { 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) { + setTransform(transform: MapTransform) { + if (Math.abs(this.data.transform.scale - transform.scale) > DELTA + || Math.abs(this.data.transform.x - transform.x) > DELTA + || Math.abs(this.data.transform.y - transform.y) > DELTA) { this.data.transform = { ...transform }; this.onDataChange(false); } diff --git a/apps/client/src/widgets/type_widgets/relation_map/drags.spec.tsx b/apps/client/src/widgets/type_widgets/relation_map/drags.spec.tsx index f4e2f3eacb..1f5777b45d 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/drags.spec.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/drags.spec.tsx @@ -37,20 +37,22 @@ describe("relation map pointer drags", () => { function Harness() { const canvasRef = useRef(null); const mapApiRef = useRef({ moveNote, connect } as unknown as RelationMapApi); - const dragging = useBoxDragging({ containerRef: canvasRef, mapApiRef }); - const drawing = useRelationDrawing({ containerRef: canvasRef, mapApiRef, askRelationName }); + const dragging = useBoxDragging({ containerRef: canvasRef, mapApiRef, getScale }); + const drawing = useRelationDrawing({ containerRef: canvasRef, mapApiRef, getScale, askRelationName }); state = { dragged: dragging.dragged, pending: drawing.pending }; startDrag = dragging.startDrag; startDrawing = drawing.startDrawing; - // Zoomed to 2x, as `panzoom` would transform it. return ( -
+
); } + /** The map is zoomed to 2x. */ + const getScale = () => 2; + function pointer(type: string, clientX: number, clientY: number) { return new PointerEvent(type, { clientX, clientY, button: 0, isPrimary: true }); } diff --git a/apps/client/src/widgets/type_widgets/relation_map/drags.ts b/apps/client/src/widgets/type_widgets/relation_map/drags.ts index 12b75da2b5..1bd8d52529 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/drags.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/drags.ts @@ -6,16 +6,18 @@ import toast from "../../../services/toast"; import RelationMapApi, { MapDataNoteEntry } from "./api"; import { type PendingRelation, PENDING_CONNECTION_ID } from "./Connections"; import type { AskRelationName } from "./RelationNamePopover"; -import { CLICK_TOLERANCE, getMousePosition, getZoom, idToNoteId } from "./utils"; +import { CLICK_TOLERANCE, getMousePosition, idToNoteId } from "./utils"; /** * Moves a box with the pointer. The box follows the pointer once it has moved more than * `CLICK_TOLERANCE`, so a click still selects it, and the map saves the position on release. * `dragged` is the box's position while it is dragged, which overrides the saved one. */ -export function useBoxDragging({ containerRef, mapApiRef }: { +export function useBoxDragging({ containerRef, mapApiRef, getScale }: { containerRef: RefObject; mapApiRef: RefObject; + /** The scale the map is zoomed to. */ + getScale(): number; }) { const [ dragged, setDragged ] = useState(null); const track = usePointerTracking(); @@ -24,7 +26,7 @@ export function useBoxDragging({ containerRef, mapApiRef }: { const container = containerRef.current; if (!container || e.button !== 0 || !e.isPrimary) return; - const zoom = getZoom(container); + const zoom = getScale(); let position: MapDataNoteEntry | null = null; track({ onMove(moveEvent) { @@ -42,7 +44,7 @@ export function useBoxDragging({ containerRef, mapApiRef }: { setDragged(null); } }); - }, [ containerRef, mapApiRef, track ]); + }, [ containerRef, mapApiRef, getScale, track ]); return { dragged, startDrag }; } @@ -53,9 +55,11 @@ export function useBoxDragging({ containerRef, mapApiRef }: { * the relation is created with that name. A release within `CLICK_TOLERANCE` of the press creates * nothing, so a click on the endpoint does not start a relation from the box to itself. */ -export function useRelationDrawing({ containerRef, mapApiRef, askRelationName }: { +export function useRelationDrawing({ containerRef, mapApiRef, getScale, askRelationName }: { containerRef: RefObject; mapApiRef: RefObject; + /** The scale the map is zoomed to. */ + getScale(): number; askRelationName: AskRelationName; }) { const [ pending, setPending ] = useState(null); @@ -65,7 +69,7 @@ export function useRelationDrawing({ containerRef, mapApiRef, askRelationName }: const container = containerRef.current; if (!container || e.button !== 0 || !e.isPrimary) return; - const zoom = getZoom(container); + const zoom = getScale(); const pendingAt = (event: PointerEvent): PendingRelation => ({ sourceNoteId, targetNoteId: boxNoteIdAt(event, container), @@ -94,7 +98,7 @@ export function useRelationDrawing({ containerRef, mapApiRef, askRelationName }: setPending(null); } }); - }, [ containerRef, mapApiRef, askRelationName, track ]); + }, [ containerRef, mapApiRef, getScale, askRelationName, track ]); return { pending, startDrawing }; } diff --git a/apps/client/src/widgets/type_widgets/relation_map/utils.ts b/apps/client/src/widgets/type_widgets/relation_map/utils.ts index a3eea9dc31..b8bf3c5a1c 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/utils.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/utils.ts @@ -1,6 +1,3 @@ -import { t } from "../../../services/i18n"; - - export function noteIdToId(noteId: string) { return `rel-map-note-${noteId}`; } @@ -12,22 +9,6 @@ export function idToNoteId(id: string) { /** How far, in pixels, the pointer can move between press and release for the click to count. */ export const CLICK_TOLERANCE = 4; -export 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]); -} - export function getMousePosition(evt: MouseEvent, container: HTMLDivElement, zoom: number) { const rect = container.getBoundingClientRect(); @@ -69,7 +50,7 @@ interface Rect { * an axis on which it fits is left alone. * * Both rectangles are in page coordinates, as `getBoundingClientRect()` gives them, which is also - * what `PanZoom.moveBy()` takes. + * what a pan of the map is measured in. */ export function revealOffset(box: Rect, map: Rect, isRtl: boolean) { const paneReach = map.right - map.left - PANE_REACH >= MIN_UNCOVERED_WIDTH ? PANE_REACH : 0; diff --git a/apps/standalone/package.json b/apps/standalone/package.json index 43be4ad639..c431eb98d6 100644 --- a/apps/standalone/package.json +++ b/apps/standalone/package.json @@ -52,7 +52,6 @@ "marked": "18.0.14", "mermaid": "12.1.0", "mind-elixir": "5.15.1", - "panzoom": "9.4.4", "preact": "11.0.0", "react-i18next": "17.0.15", "react-window": "2.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c3f758805..01c426408c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,9 +331,6 @@ importers: mind-elixir: specifier: 5.15.1 version: 5.15.1 - panzoom: - specifier: 9.4.4 - version: 9.4.4 preact: specifier: 11.0.0 version: 11.0.0(preact-render-to-string@6.8.0) @@ -960,9 +957,6 @@ importers: mind-elixir: specifier: 5.15.1 version: 5.15.1 - panzoom: - specifier: 9.4.4 - version: 9.4.4 preact: specifier: 11.0.0 version: 11.0.0(preact-render-to-string@6.8.0) @@ -5635,9 +5629,6 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - amator@1.1.0: - resolution: {integrity: sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==} - ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -5878,9 +5869,6 @@ packages: resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} engines: {node: '>=22'} - bezier-easing@2.1.0: - resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} - bezier-js@6.1.4: resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} @@ -9033,9 +9021,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - ngraph.events@1.4.0: - resolution: {integrity: sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==} - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -9289,9 +9274,6 @@ packages: pako@2.0.3: resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} - panzoom@9.4.4: - resolution: {integrity: sha512-r1KfkNZvsBw59IPq7Yy+GWZnZE1YCG/t7aG6caSkij/TBqdxTzmxNTm/lHf3h6qlVMFisIGIO+lgS2Ym23PIoA==} - paper@0.12.18: resolution: {integrity: sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==} engines: {node: '>=8.0.0'} @@ -11151,9 +11133,6 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - wheel@1.0.0: - resolution: {integrity: sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -16771,10 +16750,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - amator@1.1.0: - dependencies: - bezier-easing: 2.1.0 - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -17051,8 +17026,6 @@ snapshots: dependencies: node-addon-api: 8.9.2 - bezier-easing@2.1.0: {} - bezier-js@6.1.4: {} bidi-js@1.1.0: @@ -20817,8 +20790,6 @@ snapshots: neo-async@2.6.2: optional: true - ngraph.events@1.4.0: {} - nice-try@1.0.5: optional: true @@ -21102,12 +21073,6 @@ snapshots: pako@2.0.3: {} - panzoom@9.4.4: - dependencies: - amator: 1.1.0 - ngraph.events: 1.4.0 - wheel: 1.0.0 - paper@0.12.18: {} paperjs-offset@2.2.1: {} @@ -23264,8 +23229,6 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - wheel@1.0.0: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0