refactor(relation_map): pan and zoom with react-zoom-pan-pinch

The relation map was the only user of `panzoom`. The image viewer, the
lightbox and the SVG diagram editors already pan and zoom with
react-zoom-pan-pinch and the shared `zoom_pan` helpers, so the map now
does too:

- `useMapZoom` wraps `useZoomPanPinch`, `useZoomPanKeyboard` and
  `useZoomPanWheel`. It restores the saved transform whenever the note's
  content loads, saves every change, and answers the existing
  `relationMapResetPanZoom`/`ZoomIn`/`ZoomOut` commands; reset goes to the
  origin at 100%. Both libraries translate and then scale from the
  top-left corner, so saved `{ x, y, scale }` values carry over.
- `MapViewport` is the focusable viewport with `limitToBounds={false}`,
  and excludes boxes, labels and relation hit paths from panning and
  double-click zoom. Boxes no longer stop `mousedown`/`touchstart`.
- The note pane sits outside the viewport, so its host that kept wheel,
  double-click and touch events from panzoom is gone.
- Drags, placement and drops take the scale from the library's state
  instead of parsing the computed CSS matrix; `getZoom` and its
  `cannot_match_transform` string are gone.
- `MapToolbar` takes `{ scale, canZoomIn, canZoomOut }`.

The library computes `bounds` even with `limitToBounds={false}`, and the
wheel and keyboard helpers clamped to them, which would have kept the
map's pan near the origin. `panBounds()` returns the bounds only for an
instance that limits to them; the image viewer keeps skipping keyboard
pans until its first layout.

`RelationMapApi.setTransform` compared old minus new in one direction
only, so a pan right or down or a zoom in was never saved. It compares
the absolute difference now.

The map gains the image viewer's wheel ladder with its 100% detent,
keyboard pan and zoom while focused, and its focus ring. The
`RelationMap` chunk drops from 40.6 kB (15.0 kB gzip) to 24.4 kB
(9.1 kB gzip); it now shares the 15.6 kB gzip `zoom_pan_keyboard` chunk.
`panzoom` leaves both apps' dependencies, and `amator`, `bezier-easing`,
`ngraph.events` and `wheel` leave the lockfile.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-10-07 18:26:10 +03:00
co-authored by Claude Opus 5.5
parent 20355a26e2
commit 2f93808446
21 changed files with 364 additions and 409 deletions
-1
View File
@@ -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",
@@ -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",
-25
View File
@@ -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;
}
}
+11 -1
View File
@@ -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.
+9 -1
View File
@@ -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 {
@@ -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.
@@ -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<Parameters<typeof MapToolbar>[0]["zoom"]> = {}) => act(() => {
container = renderInto(
<MapToolbar
panZoom={withMap ? map as unknown as Parameters<typeof MapToolbar>[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<HTMLButtonElement>(".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
};
}
@@ -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 (
<OverlayControlGroup className="relation-map-toolbar" placement="bottom-end">
<ZoomControls
percent={scale * 100}
canZoomIn={scale < panZoom.getMaxZoom()}
canZoomOut={scale > 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;
}
@@ -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}
>
<span className={clsx("note-box-icon", icon)} />
<span className="note-box-title">{title}</span>
@@ -88,7 +85,3 @@ export function GhostNoteBox({ elementRef }: { elementRef: RefObject<HTMLDivElem
</div>
);
}
function stopPropagation(e: Event) {
e.stopPropagation();
}
@@ -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);
@@ -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();
});
});
@@ -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.
<div
className="relation-map-note-pane-host"
onWheel={stopPropagation}
onDblClick={stopPropagation}
onTouchStart={stopPropagation}
>
<EmbeddedNoteScope component={paneComponent} noteContext={noteContext}>
<EmbeddedNoteSurface
note={note}
panelClassName="relation-map-note-pane"
sheetClassName="relation-map-note-sheet"
bodyClassName="relation-map-note-pane-body"
closeText={t("relation_map.close_note_pane")}
maximize={{
maximized,
onChange: setMaximized,
expandText: t("relation_map.expand_note_pane"),
restoreText: t("relation_map.restore_note_pane")
}}
onClose={closePane}
onFollowLink={followLink}
>
<EmbeddedNoteActions>
<OpenNoteActions note={note} />
{!isReadOnly && <>
<NoteColorAction note={note} title={t("relation_map.note_color")} />
<ActionButton
className="tn-embedded-note-remove"
icon="bx bx-trash"
text={t("relation_map.remove_from_map")}
onClick={() => void confirmRemoveFromMap(note, mapApiRef)}
/>
</>}
</EmbeddedNoteActions>
<PromotedAttributes />
<NoteDetail />
</EmbeddedNoteSurface>
{selection?.isNew && <SelectTitleOnFirstOpen />}
</EmbeddedNoteScope>
</div>
<EmbeddedNoteScope component={paneComponent} noteContext={noteContext}>
<EmbeddedNoteSurface
note={note}
panelClassName="relation-map-note-pane"
sheetClassName="relation-map-note-sheet"
bodyClassName="relation-map-note-pane-body"
closeText={t("relation_map.close_note_pane")}
maximize={{
maximized,
onChange: setMaximized,
expandText: t("relation_map.expand_note_pane"),
restoreText: t("relation_map.restore_note_pane")
}}
onClose={closePane}
onFollowLink={followLink}
>
<EmbeddedNoteActions>
<OpenNoteActions note={note} />
{!isReadOnly && <>
<NoteColorAction note={note} title={t("relation_map.note_color")} />
<ActionButton
className="tn-embedded-note-remove"
icon="bx bx-trash"
text={t("relation_map.remove_from_map")}
onClick={() => void confirmRemoveFromMap(note, mapApiRef)}
/>
</>}
</EmbeddedNoteActions>
<PromotedAttributes />
<NoteDetail />
</EmbeddedNoteSurface>
{selection?.isNew && <SelectTitleOnFirstOpen />}
</EmbeddedNoteScope>
);
}
const PANE_NTX_ID_PREFIX = "_relation-map-note-pane";
function stopPropagation(e: Event) {
e.stopPropagation();
}
@@ -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 {
@@ -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<string, unknown> | 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 }) => <div>{props.children}</div>
}));
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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
useRevealSelectedBox({ wrapperRef, containerRef: canvasRef, panZoom: { moveBy } as unknown as PanZoom, noteId });
useRevealSelectedBox({ wrapperRef, containerRef: canvasRef, moveBy, noteId });
return (
<div ref={wrapperRef} className="wrapper">
<div ref={canvasRef} className="canvas" />
@@ -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<typeof useMapZoom> | 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<HTMLDivElement | null>(null);
const mapApiRef = useRef({ setTransform } as unknown as RelationMapApi);
zoom = useMapZoom({ ntxId: "map", viewport, loadedTransform, mapApiRef });
return <MapViewport zoom={zoom} viewportRef={setViewport}><div className="note-box" /></MapViewport>;
}
function mount(loadedTransform: MapTransform) {
container = document.createElement("div");
document.body.appendChild(container);
act(() => render(
<ParentComponent.Provider value={component}><Harness loadedTransform={loadedTransform} /></ParentComponent.Provider>,
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);
});
});
@@ -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<MapData>();
@@ -38,6 +37,8 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent
const wrapperRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mapApiRef = useRef<RelationMapApi>(null);
const [ viewport, setViewport ] = useState<HTMLDivElement | null>(null);
const [ loadedTransform, setLoadedTransform ] = useState<MapTransform>();
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<PaneSelection | null>(null);
const noteIdsOnMap = useMemo(() => data?.notes.map((entry) => entry.noteId) ?? [], [ data ]);
const paneRef = useRef<NotePaneHandle>(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<string | null>(null);
const { relations, inverseRelations } = useRelationData(note.noteId, data, mapApiRef);
@@ -158,41 +140,43 @@ export default function RelationMap({ note, noteContext, ntxId, parentComponent
{...clickProps}
{...dragProps}
>
<div ref={containerRef} className="relation-map-container">
<Connections
relations={drawnRelations}
inverseRelations={inverseRelations}
boxes={boxes}
hoveredNoteId={hoveredNoteId}
hoveredRelationId={hoveredRelationId}
pending={pending}
onHoverRelation={setHoveredRelationId}
onContextMenu={onRelationContextMenu}
/>
{data?.notes.map((entry) => {
const position = dragged?.noteId === entry.noteId ? dragged : entry;
return (
<NoteBox
key={entry.noteId}
{...position}
mapApiRef={mapApiRef}
selected={entry.noteId === selection?.noteId}
highlighted={entry.noteId === hoveredRelation?.sourceNoteId || entry.noteId === hoveredRelation?.targetNoteId}
dropTarget={entry.noteId === pending?.targetNoteId}
isReadOnly={isReadOnly}
onPointerDown={(e) => {
if (e.target instanceof Element && e.target.closest(".endpoint")) {
startDrawing(e, entry.noteId);
} else {
startDrag(e, position);
}
}}
onResize={onBoxResize}
/>
);
})}
{placement.placing && <GhostNoteBox elementRef={placement.ghostRef} />}
</div>
<MapViewport zoom={mapZoom} viewportRef={setViewport}>
<div ref={containerRef} className="relation-map-container">
<Connections
relations={drawnRelations}
inverseRelations={inverseRelations}
boxes={boxes}
hoveredNoteId={hoveredNoteId}
hoveredRelationId={hoveredRelationId}
pending={pending}
onHoverRelation={setHoveredRelationId}
onContextMenu={onRelationContextMenu}
/>
{data?.notes.map((entry) => {
const position = dragged?.noteId === entry.noteId ? dragged : entry;
return (
<NoteBox
key={entry.noteId}
{...position}
mapApiRef={mapApiRef}
selected={entry.noteId === selection?.noteId}
highlighted={entry.noteId === hoveredRelation?.sourceNoteId || entry.noteId === hoveredRelation?.targetNoteId}
dropTarget={entry.noteId === pending?.targetNoteId}
isReadOnly={isReadOnly}
onPointerDown={(e) => {
if (e.target instanceof Element && e.target.closest(".endpoint")) {
startDrawing(e, entry.noteId);
} else {
startDrag(e, position);
}
}}
onResize={onBoxResize}
/>
);
})}
{placement.placing && <GhostNoteBox elementRef={placement.ghostRef} />}
</div>
</MapViewport>
{/* 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
/>
<MapToolbar
panZoom={panZoom}
zoom={mapZoom}
onCommand={(command) => 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<HTMLDivElement | null>;
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<RelationMapApi | null>;
}) {
const apiRef = useRef<PanZoom>(null);
const [ panZoom, setPanZoom ] = useState<PanZoom>();
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<typeof useMapZoom>;
viewportRef(element: HTMLDivElement | null): void;
children: ComponentChildren;
}) {
return (
<div ref={viewportRef} className={clsx("relation-map-viewport", ZOOM_PAN_VIEWPORT_CLASS)} tabIndex={0}>
<TransformWrapper
ref={zoom.ref}
minScale={MIN_SCALE}
maxScale={MAX_SCALE}
limitToBounds={false}
wheel={zoom.wheel}
panning={{ excluded: PAN_EXCLUDED }}
doubleClick={{ excluded: PAN_EXCLUDED }}
onTransform={zoom.onTransform}
>
<TransformComponent wrapperClass="relation-map-transform" contentClass="relation-map-content">
{children}
</TransformComponent>
</TransformWrapper>
</div>
);
}
/** 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<RelationMapApi | null>) {
const [ relations, setRelations ] = useState<ClientRelation[]>();
const [ inverseRelations, setInverseRelations ] = useState<RelationMapPostResponse["inverseRelations"]>();
@@ -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<HTMLDivElement | null>;
getScale(): number;
mapApiRef: RefObject<RelationMapApi | null>;
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<HTMLDivElement | null>;
containerRef: RefObject<HTMLDivElement | null>;
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<HTMLDivElement | null>;
getScale(): number;
mapApiRef: RefObject<RelationMapApi | null>;
}): Pick<HTMLAttributes<HTMLDivElement>, "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;
}
@@ -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);
}
@@ -37,20 +37,22 @@ describe("relation map pointer drags", () => {
function Harness() {
const canvasRef = useRef<HTMLDivElement>(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 (
<div ref={canvasRef} className="canvas" style={{ transform: "matrix(2, 0, 0, 2, 0, 0)" }}>
<div ref={canvasRef} className="canvas">
<div id={noteIdToId("target")} className="note-box" />
</div>
);
}
/** 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 });
}
@@ -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<HTMLDivElement | null>;
mapApiRef: RefObject<RelationMapApi | null>;
/** The scale the map is zoomed to. */
getScale(): number;
}) {
const [ dragged, setDragged ] = useState<MapDataNoteEntry | null>(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<HTMLDivElement | null>;
mapApiRef: RefObject<RelationMapApi | null>;
/** The scale the map is zoomed to. */
getScale(): number;
askRelationName: AskRelationName;
}) {
const [ pending, setPending ] = useState<PendingRelation | null>(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 };
}
@@ -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;
-1
View File
@@ -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",
-37
View File
@@ -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([email protected])
@@ -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([email protected])
@@ -5635,9 +5629,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
[email protected]:
resolution: {integrity: sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==}
[email protected]:
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
engines: {node: '>=18'}
@@ -5878,9 +5869,6 @@ packages:
resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==}
engines: {node: '>=22'}
[email protected]:
resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==}
[email protected]:
resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==}
@@ -9033,9 +9021,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
[email protected]:
resolution: {integrity: sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==}
[email protected]:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
@@ -9289,9 +9274,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==}
[email protected]:
resolution: {integrity: sha512-r1KfkNZvsBw59IPq7Yy+GWZnZE1YCG/t7aG6caSkij/TBqdxTzmxNTm/lHf3h6qlVMFisIGIO+lgS2Ym23PIoA==}
[email protected]:
resolution: {integrity: sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==}
engines: {node: '>=8.0.0'}
@@ -11151,9 +11133,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
[email protected]:
resolution: {integrity: sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==}
[email protected]:
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
[email protected]:
dependencies:
bezier-easing: 2.1.0
[email protected]:
dependencies:
environment: 1.1.0
@@ -17051,8 +17026,6 @@ snapshots:
dependencies:
node-addon-api: 8.9.2
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -20817,8 +20790,6 @@ snapshots:
[email protected]:
optional: true
[email protected]: {}
[email protected]:
optional: true
@@ -21102,12 +21073,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
amator: 1.1.0
ngraph.events: 1.4.0
wheel: 1.0.0
[email protected]: {}
[email protected]: {}
@@ -23264,8 +23229,6 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
[email protected]: {}
[email protected]:
dependencies:
is-bigint: 1.1.0