From 9ea1e903dbdd908ecd1bf6ad807d0d3fd3203a05 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 22 Feb 2024 22:00:52 -0700 Subject: [PATCH] store position in location hash, allowing resharing --- .../client/src/components/CanvasWrapper.tsx | 63 +++++++++++++-- packages/client/src/lib/canvas.ts | 16 ++++ packages/lib/src/renderer/PanZoom.ts | 80 +++++++++++++++++-- 3 files changed, 147 insertions(+), 12 deletions(-) diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx index 2db2dda..38cffd3 100644 --- a/packages/client/src/components/CanvasWrapper.tsx +++ b/packages/client/src/components/CanvasWrapper.tsx @@ -19,17 +19,65 @@ export const CanvasWrapper = () => { ); }; +const parseHashParams = (canvas: Canvas) => { + // maybe move this to a utility inside routes.ts + + let { hash } = new URL(window.location.href); + if (hash.indexOf("#") === 0) { + hash = hash.slice(1); + } + let params = new URLSearchParams(hash); + + let position: { + x?: number; + y?: number; + zoom?: number; + } = {}; + + if (params.has("x") && !isNaN(parseInt(params.get("x")!))) + position.x = parseInt(params.get("x")!); + if (params.has("y") && !isNaN(parseInt(params.get("y")!))) + position.y = parseInt(params.get("y")!); + if (params.has("zoom") && !isNaN(parseInt(params.get("zoom")!))) + position.zoom = parseInt(params.get("zoom")!); + + if ( + typeof position.x === "number" && + typeof position.y === "number" && + typeof position.zoom === "number" + ) { + const { transformX, transformY } = canvas.canvasToPanZoomTransform( + position.x, + position.y + ); + + return { + x: transformX, + y: transformY, + zoom: position.zoom, + }; + } +}; + const CanvasInner = () => { const canvasRef = createRef(); const { config, setCanvasPosition, setCursorPosition } = useAppContext(); const PanZoom = useContext(RendererContext); - // const { centerView } = useControls(); useEffect(() => { if (!config.canvas || !canvasRef.current) return; const canvas = canvasRef.current!; const canvasInstance = new Canvas(config, canvas, PanZoom); - // centerView(); + + { + // TODO: handle hash changes and move viewport + // NOTE: this will need to be cancelled if handleViewportMove was executed recently + + const position = parseHashParams(canvasInstance); + if (position) { + PanZoom.setPosition(position, { suppressEmit: true }); + } + } const handleViewportMove = throttle((state: ViewportMoveEvent) => { const pos = canvasInstance.panZoomTransformToCanvas(); @@ -45,7 +93,7 @@ const CanvasInner = () => { window.location.replace(Routes.canvas(canvasPosition)); }, 1000); - const handleCursorPos = (pos: IPosition) => { + const handleCursorPos = throttle((pos: IPosition) => { if ( pos.x < 0 || pos.y < 0 || @@ -54,9 +102,10 @@ const CanvasInner = () => { ) { setCursorPosition(); } else { - setCursorPosition(pos); + // fixes not passing the current value + setCursorPosition({ ...pos }); } - }; + }, 1); PanZoom.addListener("viewportMove", handleViewportMove); canvasInstance.on("cursorPos", handleCursorPos); @@ -66,7 +115,9 @@ const CanvasInner = () => { PanZoom.removeListener("viewportMove", handleViewportMove); canvasInstance.off("cursorPos", handleCursorPos); }; - }, [PanZoom, canvasRef, config, setCanvasPosition]); + + // do not include canvasRef, it causes infinite re-renders + }, [PanZoom, config, setCanvasPosition, setCursorPosition]); return ( { }); } + canvasToPanZoomTransform(x: number, y: number) { + let transformX = 0; + let transformY = 0; + + if (this.PanZoom.flags.useZoom) { + // CSS Zoom does not alter this (obviously) + transformX = this.canvas.width / 2 - x; + transformY = this.canvas.height / 2 - y; + } else { + transformX = this.canvas.width / 2 - x; + transformY = this.canvas.height / 2 - y; + } + + return { transformX, transformY }; + } + panZoomTransformToCanvas() { const { x, y, scale: zoom } = this.PanZoom.transform; const rect = this.canvas.getBoundingClientRect(); diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts index fec65cc..8d5d24a 100644 --- a/packages/lib/src/renderer/PanZoom.ts +++ b/packages/lib/src/renderer/PanZoom.ts @@ -82,6 +82,8 @@ interface ISetup { * [minimum scale, maximum scale] */ scale: [number, number]; + + initialTransform?: TransformState; } // TODO: move these event interfaces out @@ -106,9 +108,12 @@ interface PanZoomEvents { click: (e: ClickEvent) => void; hover: (e: HoverEvent) => void; viewportMove: (e: ViewportMoveEvent) => void; + initialize: () => void; } export class PanZoom extends EventEmitter { + private initialized = false; + public $wrapper: HTMLDivElement = null as any; public $zoom: HTMLDivElement = null as any; public $move: HTMLDivElement = null as any; @@ -165,6 +170,53 @@ export class PanZoom extends EventEmitter { this.detectFlags(); this.registerMouseEvents(); this.registerTouchEvents(); + + this.initialized = true; + + if (this.setup.initialTransform) { + // use initial transform if it is set + // initialTransform is set from #setPosition() when PanZoom is not initalized + + let { x, y, scale } = this.setup.initialTransform; + + this.transform.x = x; + this.transform.y = y; + this.transform.scale = scale; + this.update({ suppressEmit: true }); + } + + this.emit("initialize"); + } + + /** + * Sets transform data + * + * @param position + * @param position.x Transform X + * @param position.y Transform Y + * @param position.zoom Zoom scale + * @param flags + * @param flags.suppressEmit If true, don't emit a viewport change + * @returns + */ + setPosition( + { x, y, zoom }: { x: number; y: number; zoom: number }, + { suppressEmit } = { suppressEmit: false } + ) { + if (!this.initialized) { + // elements are not yet available, store them to be used upon initialization + this.setup.initialTransform = { + x, + y, + scale: zoom, + }; + return; + } + + this.transform.x = x; + this.transform.y = y; + this.transform.scale = zoom; + this.update({ suppressEmit }); } detectFlags() { @@ -453,12 +505,28 @@ export class PanZoom extends EventEmitter { ); } - update() { - this.emit("viewportMove", { - scale: this.transform.scale, - x: this.transform.x, - y: this.transform.y, - }); + /** + * Update viewport scale and position + * + * @param flags + * @param flags.suppressEmit Do not emit viewportMove + */ + update( + { + suppressEmit, + }: { + suppressEmit: boolean; + } = { + suppressEmit: false, + } + ) { + if (!suppressEmit) { + this.emit("viewportMove", { + scale: this.transform.scale, + x: this.transform.x, + y: this.transform.y, + }); + } if (this.flags.useZoom) { this.$zoom.style.setProperty("zoom", this.transform.scale * 100 + "%");