mirror of
https://github.com/TriliumNext/Trilium.git
synced 2026-10-12 02:09:59 +00:00
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:
co-authored by
Claude Opus 5.5
parent
20355a26e2
commit
2f93808446
@@ -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",
|
||||
|
||||
Vendored
-25
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-37
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user