diff --git a/ESLint/lib/kernelBoundary.js b/ESLint/lib/kernelBoundary.js index 720d9884..7770fcf6 100644 --- a/ESLint/lib/kernelBoundary.js +++ b/ESLint/lib/kernelBoundary.js @@ -26,6 +26,7 @@ export const kernelSourcePaths = Object.freeze([ 'Source/DataTables/DataTableFilterMeta.ts', 'Source/DataTables/paginatorRange.ts', 'Source/DataTables/selectionKeys.ts', + 'Source/ClassNames/classNames.ts', 'Source/CommandDialog/applyBeforeExecute.ts', 'Source/CommandForm/commandFormMarkers.ts', 'Source/CommandForm/fields/chipValues.ts', diff --git a/Source/Canvas/Canvas.stories.tsx b/Source/Canvas/Canvas.stories.tsx index 3f6200ed..85e564f7 100644 --- a/Source/Canvas/Canvas.stories.tsx +++ b/Source/Canvas/Canvas.stories.tsx @@ -3,7 +3,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; -import { Canvas } from './Canvas'; +import { expect, waitFor, within } from 'storybook/test'; +import { Canvas, type CanvasHandle } from './Canvas'; import { CanvasItem } from './CanvasItem'; const meta: Meta = { @@ -180,3 +181,110 @@ export const WithControlsAndMinimap: Story = { return ; }, }; + +/** The camera reported by `onTransformChange`, written where a test can read it. */ +const CameraReadout = ({ zoom, pan }: { zoom: number; pan: { x: number; y: number } }) => ( + + zoom {zoom.toFixed(2)} · pan {pan.x.toFixed(0)}, {pan.y.toFixed(0)} + +); + +/** + * The camera driven through every input path: wheel pan, Ctrl+wheel zoom toward the pointer, a + * background drag, the zoom controls, and the imperative handle. The readout shows what + * `onTransformChange` reports. + */ +export const CameraInteractions: Story = { + render: () => { + const CameraInteractionsDemo = () => { + const [camera, setCamera] = useState({ zoom: 1, pan: { x: 0, y: 0 } }); + const handleRef = useRef(null); + return ( +
+ setCamera({ zoom, pan: { ...pan } })} + onHandleReady={(handle) => { handleRef.current = handle; }} + style={{ width: 800, height: 600 }} + > + +
Sample
+
+
+ + + + + +
+ ); + }; + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const surface = canvasElement.querySelector('.canvas-surface')!; + const camera = () => canvas.getByTestId('camera'); + const expectCamera = async (zoom: number, panX: number, panY: number) => { + await waitFor(() => { + expect(Number(camera().dataset.zoom)).toBeCloseTo(zoom, 3); + expect(Number(camera().dataset.panX)).toBeCloseTo(panX, 1); + expect(Number(camera().dataset.panY)).toBeCloseTo(panY, 1); + }); + }; + await waitFor(() => expect(surface.querySelector('canvas')).not.toBeNull()); + const rect = surface.getBoundingClientRect(); + + // A plain wheel pans by the wheel delta. + surface.dispatchEvent(new WheelEvent('wheel', { deltaX: 30, deltaY: 40, clientX: rect.left + 10, clientY: rect.top + 10, bubbles: true, cancelable: true })); + await expectCamera(1, -30, -40); + + // Ctrl+wheel zooms toward the pointer, holding the world point under it still. + const focusX = 200; + const focusY = 100; + surface.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, ctrlKey: true, clientX: rect.left + focusX, clientY: rect.top + focusY, bubbles: true, cancelable: true })); + const zoomed = Math.exp(100 * 0.008); + await expectCamera(zoomed, focusX - (focusX + 30) * zoomed, focusY - (focusY + 40) * zoomed); + + // The zoom controls: reset returns to 100% and keeps the pan; zoom in scales around the center. + const panX = Number(camera().dataset.panX); + const panY = Number(camera().dataset.panY); + canvas.getByRole('button', { name: 'Reset Zoom' }).click(); + await expectCamera(1, panX, panY); + canvas.getByRole('button', { name: 'Zoom In' }).click(); + await expectCamera(1.2, 400 - (400 - panX) * 1.2, 300 - (300 - panY) * 1.2); + canvas.getByRole('button', { name: 'Zoom Out' }).click(); + await expectCamera(1, panX, panY); + + // A mouse drag on the empty background pans by the drag distance. + const pointer = { pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1, bubbles: true, cancelable: true }; + const background = surface.querySelector('canvas')!; + background.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: rect.left + 500, clientY: rect.top + 400 })); + surface.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: rect.left + 520, clientY: rect.top + 430 })); + surface.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientX: rect.left + 520, clientY: rect.top + 430 })); + await expectCamera(1, panX + 20, panY + 30); + + // The imperative handle centers a world point. + canvas.getByRole('button', { name: 'Center origin' }).click(); + await expectCamera(1, 400, 300); + + // The handle reports the measured bounds of registered items. + canvas.getByRole('button', { name: 'Read bounds' }).click(); + await waitFor(() => { + const bounds = JSON.parse(canvas.getByTestId('bounds').dataset.bounds ?? '[]') as Array<{ x: number; y: number; width: number; height: number }>; + expect(bounds).toEqual([{ x: 100, y: 50, width: 120, height: 60 }]); + }); + }, +}; diff --git a/Source/Canvas/Canvas.tsx b/Source/Canvas/Canvas.tsx index e0eb19e1..54915c7e 100644 --- a/Source/Canvas/Canvas.tsx +++ b/Source/Canvas/Canvas.tsx @@ -1,159 +1,38 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import * as PIXI from 'pixi.js'; +import React, { useRef } from 'react'; +import type * as PIXI from 'pixi.js'; import { CanvasControls, type CanvasControlsIcons, type CanvasControlsLabels, } from './CanvasControls'; +import type { CanvasCaptureAttributes } from './CanvasCaptureAttributes'; +import type { CanvasContext } from './CanvasContext'; +import type { CanvasHandle } from './CanvasHandle'; +import type { CanvasItemData } from './CanvasItemData'; +import { CanvasItemRegistryContext } from './CanvasItemRegistryContext'; import type { CanvasMinimapHandle, MinimapItem } from './CanvasMinimap'; -import { canvasGesture } from './canvasGesture'; -import { canvasTransformActivity } from './canvasTransformActivity'; -import { isBackgroundPointerTarget } from './isBackgroundPointerTarget'; -import { isWithinScrollableContent } from './isWithinScrollableContent'; -import { - type PanSample, - decayVelocity, - trimSamples, - velocityFromSamples, -} from './panMomentum'; -import { - type PinchSnapshot, - type PointerPosition, - pinchChangeBetween, - pinchSnapshotOf, -} from './pinchGesture'; +import type { PointerPosition } from './pinchGesture'; +import { useCanvasCamera } from './useCanvasCamera'; +import { useCanvasItemRegistry } from './useCanvasItemRegistry'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasPointerGestures } from './useCanvasPointerGestures'; +import { useCanvasWheelZoom } from './useCanvasWheelZoom'; import { useDragSelectionGuard } from './useDragSelectionGuard'; -import { - applyZoomLayer, - isMultiTouchCapableDevice, - shouldUseCssZoom, -} from './zoomMechanism'; - -/** - * The easing curve for programmatic camera moves: a cubic ease-in-out, so the camera picks up speed gently - * and settles gently instead of starting at full tilt — the difference between a camera move and a yank. - * @param progress Linear progress through the animation, 0 to 1. - * @returns The eased progress. - */ -function easeInOutCubic(progress: number): number { - return progress < 0.5 - ? 4 * progress * progress * progress - : 1 - (-2 * progress + 2) ** 3 / 2; -} - -/** - * Safari's non-standard trackpad/touch gesture event — absent from the DOM type library, so typed here - * rather than asserted through `any`. `scale` is cumulative relative to the gesture's start. - */ -interface WebKitGestureEvent extends Event { - /** The gesture's cumulative scale factor since gesturestart. */ - readonly scale: number; - - /** The gesture's horizontal viewport coordinate. */ - readonly clientX: number; - - /** The gesture's vertical viewport coordinate. */ - readonly clientY: number; -} - -/** Measured world-space bounds for one registered {@link CanvasItem}. */ -export interface CanvasItemRegistryEntry { - /** World-space horizontal position. */ - x: number; - /** World-space vertical position. */ - y: number; - /** Measured width. */ - width: number; - /** Measured height. */ - height: number; - - /** - * Whether this entry is registered under an internally generated id rather than one the caller - * supplied through {@link CanvasItem}'s `id` prop. An anonymous entry still feeds every consumer - * of the registry — the minimap, fit-to-content — but `Region` (via `itemsWithinRegion`) never - * reports it as a region member, since there is no caller-owned id a host could recognize it by. - */ - anonymous?: boolean; -} - -/** Registry contract used by {@link CanvasItem} to publish measured bounds. */ -export interface CanvasItemRegistryContextValue { - /** Adds or updates one measured item. */ - register: (id: string, entry: CanvasItemRegistryEntry) => void; - /** Removes one measured item. */ - unregister: (id: string) => void; - - /** - * The registry's current content, as a stable-reference snapshot: the returned map's identity only - * changes when the content actually changed (an item registered, unregistered, or moved/resized), - * making it safe to feed straight into `useSyncExternalStore`. The map is rebuilt on change — never - * mutated in place — so a held snapshot stays internally consistent. Optional so a consumer - * providing its own registry value predating this member keeps type-checking; readers must - * fall back gracefully when absent. - */ - getSnapshot?: () => ReadonlyMap; - - /** - * Subscribes to registry changes; the listener fires after every register/unregister/bounds-update - * that actually changed content. Bounds updates can arrive per-frame during a drag — listeners - * decide how much work to do per notification. Returns the unsubscribe function. - * `useSyncExternalStore`-compatible. Optional for the same compatibility reason as - * {@link getSnapshot}. - */ - subscribe?: (listener: () => void) => () => void; -} - -/** Context carrying the nearest Canvas item registry. */ -export const CanvasItemRegistryContext = - React.createContext(null); - -/** Minimum data shape for an optional Pixi-rendered canvas item. */ -export interface CanvasItemData { - /** Stable item identity. */ - id: string; - /** World-space horizontal position. */ - x: number; - /** World-space vertical position. */ - y: number; -} - -/** Pixi objects supplied after the Canvas renderer initializes. */ -export interface CanvasContext { - /** Pixi application owned by the Canvas. */ - app: PIXI.Application; - /** Pixi world container receiving rendered items. */ - world: PIXI.Container; -} - -/** Imperative camera and measurement operations exposed by {@link Canvas}. */ -export interface CanvasHandle { - /** Smoothly centers a world point. */ - smoothPanToWorld(worldX: number, worldY: number, durationMs?: number): void; - /** Smoothly centers a world point while animating to a target zoom. */ - smoothPanZoomToWorld( - worldX: number, - worldY: number, - targetZoom?: number, - durationMs?: number, - ): void; - /** Returns the current viewport rectangle, or `null` before mounting. */ - getContainerRect(): DOMRect | null; - /** Returns world-space bounds for every registered CanvasItem. */ - getItemBounds(): MinimapItem[]; -} - -/** Optional product compositor/capture marker names applied by {@link Canvas}. */ -export interface CanvasCaptureAttributes { - /** Attribute placed on the Pixi canvas so a product capture pipeline can exclude it. */ - layer?: string; - /** Attribute placed on non-plain integrated controls that own composited content. */ - content?: string; - /** Attribute placed on pan/zoom transform hosts whose churn moves existing layers. */ - transformHost?: string; -} +import { useLatestRef } from './useLatestRef'; +import { usePixiApplication } from './usePixiApplication'; +import { usePixiItems } from './usePixiItems'; +import { isMultiTouchCapableDevice, shouldUseCssZoom } from './zoomMechanism'; + +export type { CanvasCaptureAttributes } from './CanvasCaptureAttributes'; +export type { CanvasContext } from './CanvasContext'; +export type { CanvasHandle } from './CanvasHandle'; +export type { CanvasItemData } from './CanvasItemData'; +export { CanvasItemRegistryContext } from './CanvasItemRegistryContext'; +export type { CanvasItemRegistryContextValue } from './CanvasItemRegistryContextValue'; +export type { CanvasItemRegistryEntry } from './CanvasItemRegistryEntry'; /** Props for the pan, zoom, item, minimap, and control surface. */ export interface CanvasProps { @@ -224,28 +103,6 @@ export interface CanvasProps { backgroundDragPans?: boolean; } -const ZOOM_INTENSITY = 0.008; - -// How long after the last wheel event a gesture counts as settled - the moment the crisp resting zoom is -// re-applied and the held-back virtualization updates flush. -const GESTURE_SETTLE_MS = 150; - -// A trackpad's momentum comes for free — the OS keeps delivering decaying wheel events after the -// fingers lift. A touch drag has no such thing: `touch-action: none` hands the whole gesture to us, -// so lifting a finger stops the pan dead unless we fake the same decay ourselves. These tune that feel. -// Only samples within this trailing window (from the drag's last moment, not its whole history) -// contribute to the release velocity, so a drag that was moving fast but came to rest before the -// finger actually lifted correctly produces no momentum. -const MOMENTUM_SAMPLE_WINDOW_MS = 100; - -// Below this speed (px/ms) momentum is imperceptible — used both to skip starting it on a slow -// release and to end the decay loop once it coasts down to a stop. -const MOMENTUM_MIN_VELOCITY = 0.02; - -// Exponential decay rate, chosen so velocity halves roughly every 200ms — fast enough to feel -// responsive, slow enough to read as a coast rather than a snap. -const MOMENTUM_FRICTION = 0.0035; - /** * A pan/zoom/Pixi-backed infinite canvas with optional HTML overlay items, integrated minimap, and * zoom controls. Renders Pixi-backed sprites and DOM content under one synchronized camera, manages @@ -284,16 +141,13 @@ function Canvas({ }: CanvasProps): React.ReactElement { const containerRef = useRef(null); useDragSelectionGuard(containerRef); - const appRef = useRef(null); - const worldRef = useRef(null); const minimapRef = useRef(null); const overlayRef = useRef(null); const zoomLayerRef = useRef(null); - const spritesRef = useRef>(new Map()); + // Shared by touch momentum and the animated camera moves, so only one of them ever drives the + // transform at once; each cancels whichever was still running. const animationFrameRef = useRef(null); - const [pixiReady, setPixiReady] = useState(false); - // Mutable refs so event callbacks always read current values without re-binding const panRef = useRef({ x: initialPan.x, y: initialPan.y }); const zoomRef = useRef(initialZoom); @@ -305,934 +159,101 @@ function Canvas({ // stale values — visible as the viewport flicking to an old position and back. const initialTransformRef = useRef({ pan: initialPan, zoom: initialZoom }); - // Wheel gestures coalesce onto animation frames (trackpads deliver several events per frame, and each - // direct style write would reflow), and while a gesture is in motion the zoom layer stays on the - // composited transform path with virtualization told to hold still. The settle timer restores the crisp - // resting state shortly after the last wheel event. - const gestureActiveRef = useRef(false); - const gestureSettleTimerRef = useRef | undefined>( - undefined, - ); - const transformFramePendingRef = useRef(false); - const isPanningRef = useRef(false); - const lastPointerRef = useRef({ x: 0, y: 0 }); - - // Direct DOM write, not React state — an idle background should not look grabbable until a drag - // starts. Swapping to 'grabbing' for the life of a drag-pan is exactly the kind of per-gesture - // churn this file - // already avoids re-rendering for elsewhere (see the transform writes below). - const setPanCursor = useCallback((panning: boolean) => { - if (containerRef.current) - containerRef.current.style.cursor = panning ? 'grabbing' : 'default'; - }, []); - - // Touch pointers currently down on the canvas, in the order they landed, plus the pinch geometry - // they had on the previous move. Touch is the only input that can deliver several simultaneous - // pointers, so mouse/pen never enter this map and keep the single-pointer drag path below. + // Touch pointers currently down on the canvas, in the order they landed. Touch is the only input + // that can deliver several simultaneous pointers, so mouse/pen never enter this map. const touchPointersRef = useRef>(new Map()); - const pinchRef = useRef(null); - - // Recent positions of the single finger currently panning, newest last, trimmed to - // MOMENTUM_SAMPLE_WINDOW_MS — the trailing window startTouchMomentum reads the release velocity from. - const touchPanSamplesRef = useRef([]); // Keep callback refs current to avoid stale closures in PIXI event handlers - const onReadyRef = useRef(onReady); - const onItemPointerDownRef = useRef(onItemPointerDown); - const onTransformChangeRef = useRef(onTransformChange); - const onHandleReadyRef = useRef(onHandleReady); - const itemsRef = useRef(items); - useEffect(() => { - onReadyRef.current = onReady; - }, [onReady]); - useEffect(() => { - onItemPointerDownRef.current = onItemPointerDown; - }, [onItemPointerDown]); - useEffect(() => { - onTransformChangeRef.current = onTransformChange; - }, [onTransformChange]); - useEffect(() => { - onHandleReadyRef.current = onHandleReady; - }, [onHandleReady]); - useEffect(() => { - itemsRef.current = items; - }, [items]); - - // ── Item registry — auto-builds minimap items from CanvasItem children ── - - const itemRegistryRef = useRef>(new Map()); - const [itemRegistryVersion, setItemRegistryVersion] = useState(0); - - // The read/subscribe side of the registry. The snapshot is a fresh shallow copy taken on every - // actual content change (register/unregister guard against no-op writes below), so its identity - // is a faithful change signal for useSyncExternalStore consumers — same reference back means - // nothing changed. Entry objects are replaced wholesale on update, never mutated, so entries - // shared between an old snapshot and a new one are safe to hold onto. - const registrySnapshotRef = useRef>( - new Map(), - ); - const registryListenersRef = useRef void>>(new Set()); - - const notifyRegistryChanged = useCallback(() => { - registrySnapshotRef.current = new Map(itemRegistryRef.current); - setItemRegistryVersion((version) => version + 1); - registryListenersRef.current.forEach((listener) => listener()); - }, []); - - const registerItem = useCallback( - (id: string, entry: CanvasItemRegistryEntry) => { - const existing = itemRegistryRef.current.get(id); - if ( - existing && - existing.x === entry.x && - existing.y === entry.y && - existing.width === entry.width && - existing.height === entry.height && - existing.anonymous === entry.anonymous - ) - return; - itemRegistryRef.current.set(id, entry); - notifyRegistryChanged(); - }, - [notifyRegistryChanged], - ); - - const unregisterItem = useCallback( - (id: string) => { - if (!itemRegistryRef.current.delete(id)) return; - notifyRegistryChanged(); - }, - [notifyRegistryChanged], - ); - - const getRegistrySnapshot = useCallback( - (): ReadonlyMap => registrySnapshotRef.current, - [], - ); - - const subscribeToRegistry = useCallback((listener: () => void): (() => void) => { - registryListenersRef.current.add(listener); - return () => { - registryListenersRef.current.delete(listener); - }; - }, []); - - const registryContextValue = useMemo( - () => ({ - register: registerItem, - unregister: unregisterItem, - getSnapshot: getRegistrySnapshot, - subscribe: subscribeToRegistry, - }), - [registerItem, unregisterItem, getRegistrySnapshot, subscribeToRegistry], - ); - - const autoMinimapItems = useMemo((): MinimapItem[] => { - void itemRegistryVersion; // subscribe to registry changes - const result: MinimapItem[] = []; - itemRegistryRef.current.forEach((entry) => { - result.push({ - x: entry.x, - y: entry.y, - width: entry.width, - height: entry.height, - }); - }); - return result; - }, [itemRegistryVersion]); + const onReadyRef = useLatestRef(onReady); + const onItemPointerDownRef = useLatestRef(onItemPointerDown); + const onTransformChangeRef = useLatestRef(onTransformChange); + const onHandleReadyRef = useLatestRef(onHandleReady); + const { itemRegistryRef, registryContextValue, autoMinimapItems } = + useCanvasItemRegistry(); const effectiveMinimapItems = minimapItems ?? autoMinimapItems; - const refreshMinimap = useCallback(() => { - const app = appRef.current; - if (!app || !minimapRef.current) return; - minimapRef.current.update( - panRef.current, - zoomRef.current, - app.renderer.width, - app.renderer.height, - ); - }, []); - - const applyWorldTransform = useCallback(() => { - const world = worldRef.current; - if (!world) return; - world.position.set(panRef.current.x, panRef.current.y); - world.scale.set(zoomRef.current); - // Pan is a CSS transform on the outer overlay; zoom is applied to an inner layer. The zoom - // mechanism is hybrid, split at 100% (the two are identical there, so the switch is seamless): - // - Above 100% we use CSS `zoom`, which re-lays-out and re-rasterizes at the effective - // resolution — `transform: scale()` would rasterize once at 1x and stretch, blurring text - // when UPSCALING in Safari. EXCEPT on multi-touch-capable devices (iPad and friends), which - // always use `transform: scale()` even above 100% — see zoomMechanism.ts for why. - // - At or below 100% we use `transform: scale()`, which is GPU-composited: zooming out stays - // smooth (no per-frame full-board reflow) and text scales down properly. CSS `zoom` instead - // reflows every step (jerky) and clamps font-size to a floor when zoomed out, so labels stop - // shrinking and word-wrap. Downscaling never blurs, so the crispness reason does not apply here. - if (overlayRef.current) { - overlayRef.current.style.transform = `translate(${panRef.current.x}px, ${panRef.current.y}px)`; - } - if (zoomLayerRef.current) { - applyZoomLayer( - zoomLayerRef.current, - zoomRef.current, - gestureActiveRef.current, - ); - } - onTransformChangeRef.current?.(zoomRef.current, panRef.current); - // Every applied transform frame is announced so followers (cursors, selection toolbar, tour - // anchors) can ride the transform instead of polling it on their own animation-frame loops. - canvasTransformActivity.notify(); - }, []); - - const render = useCallback(() => { - const app = appRef.current; - if (!app) return; - // Boards that only use the HTML overlay leave the PIXI world empty — skip the per-frame - // GPU pass entirely rather than clearing and presenting an empty stage on every gesture frame. - if ((worldRef.current?.children.length ?? 0) === 0) return; - // The system ticker is stopped (see the init effect below) — drive pixi's scheduled - // housekeeping (texture GC and friends) from the frames that actually render instead. - PIXI.Ticker.system.update(); - app.renderer.render(app.stage); - }, []); - - // Initialize PIXI once - useEffect(() => { - const container = containerRef.current; - if (!container || appRef.current) return; - - let mounted = true; - - (async () => { - const rect = container.getBoundingClientRect(); - const width = rect.width > 0 ? rect.width : container.clientWidth || 800; - const height = rect.height > 0 ? rect.height : container.clientHeight || 600; - - const app = new PIXI.Application(); - await app.init({ - // Transparent clear: the body carries the surface color and the user-selected - // appearance background, which must show through the canvas like everywhere else. - backgroundAlpha: 0, - antialias: true, - autoDensity: true, - resolution: window.devicePixelRatio || 1, - width, - height, - autoStart: false, - }); - - if (!mounted || !containerRef.current) { - app.destroy(true, { children: true }); - return; - } - - appRef.current = app; - - // World container - all items live here; zoom/pan applied via transform - const world = new PIXI.Container(); - world.position.set(panRef.current.x, panRef.current.y); - world.scale.set(zoomRef.current); - app.stage.addChild(world); - worldRef.current = world; - - const canvas = app.canvas as HTMLCanvasElement; - canvas.style.display = 'block'; - canvas.style.touchAction = 'none'; - containerRef.current.appendChild(canvas); - - setPixiReady(true); - app.renderer.render(app.stage); - - // Initializing a renderer hooks pixi's SchedulerSystem onto the auto-starting system - // ticker, which then runs a requestAnimationFrame loop forever even though nothing here - // renders from a ticker (autoStart is false and every render is explicit) — one of the - // permanent loops that kept an idle canvas busy. Stop it after every init, because each - // new renderer restarts it; render() drives the scheduled - // housekeeping instead. - PIXI.Ticker.system.stop(); - - onReadyRef.current?.({ app, world }); - })(); - - return () => { - mounted = false; - if (appRef.current) { - appRef.current.destroy(true, { children: true }); - appRef.current = null; - worldRef.current = null; - } - spritesRef.current.clear(); - }; - }, []); // intentional: PIXI init runs exactly once - - // Products with a capture/compositor pipeline can mark the Pixi canvas without making that - // provider a Components dependency. Keep the marker synchronized if product configuration changes. - useEffect(() => { - const canvas = appRef.current?.canvas as HTMLCanvasElement | undefined; - const attribute = captureAttributes?.layer; - if (!canvas || !attribute) return; - canvas.setAttribute(attribute, 'true'); - return () => canvas.removeAttribute(attribute); - }, [pixiReady, captureAttributes?.layer]); - - // Handle container resize - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver(() => { - const app = appRef.current; - if (!app) return; - app.renderer.resize(container.clientWidth, container.clientHeight); - render(); - }); - observer.observe(container); - return () => observer.disconnect(); - }, [render]); - - // One transform apply per animation frame, no matter how many wheel events arrived - the refs already - // hold the latest pan/zoom, so coalescing loses nothing and avoids multiple reflows per frame. - // No React state is touched here: a gesture must cause zero Canvas re-renders (the controls' - // zoom readout polls the ref on its own slow interval instead). - const scheduleTransformApply = useCallback(() => { - if (transformFramePendingRef.current) return; - transformFramePendingRef.current = true; - requestAnimationFrame(() => { - transformFramePendingRef.current = false; - applyWorldTransform(); - refreshMinimap(); - render(); - }); - }, [applyWorldTransform, refreshMinimap, render]); - - // Marks the gesture active (composited zoom path, virtualization held still) and re-arms the settle - // timer. On settle the crisp resting hybrid is re-applied through the same applyWorldTransform as - // every gesture frame — one writer, one atomic style pass — and a new gesture cancels the timer, - // so a settle can never land in the middle of the next gesture. - const noteGestureActivity = useCallback(() => { - gestureActiveRef.current = true; - canvasGesture.set(true); - clearTimeout(gestureSettleTimerRef.current); - gestureSettleTimerRef.current = setTimeout(() => { - gestureActiveRef.current = false; - applyWorldTransform(); - canvasGesture.set(false); - }, GESTURE_SETTLE_MS); - }, [applyWorldTransform]); - - useEffect( - () => () => { - clearTimeout(gestureSettleTimerRef.current); - canvasGesture.set(false); - }, - [], - ); - - // Zooms toward a fixed focus point on the container, holding the world point under it still — - // the one zoom every input path shares, whether the factor came from a wheel delta or a pinch. - const zoomTowards = useCallback( - (focusX: number, focusY: number, factor: number) => { - const newZoom = Math.max( - minZoom, - Math.min(maxZoom, zoomRef.current * factor), - ); - const worldX = (focusX - panRef.current.x) / zoomRef.current; - const worldY = (focusY - panRef.current.y) / zoomRef.current; - panRef.current = { - x: focusX - worldX * newZoom, - y: focusY - worldY * newZoom, - }; - zoomRef.current = newZoom; - noteGestureActivity(); - scheduleTransformApply(); - }, - [minZoom, maxZoom, noteGestureActivity, scheduleTransformApply], - ); - - // Wheel: pan (scroll) or zoom (Ctrl+scroll / pinch) - const handleWheel = useCallback( - (e: WheelEvent) => { - const container = containerRef.current; - if (!container) return; - - // A plain scroll/trackpad gesture landing over a scrollable overlay (a chat's message list - // and similar) scrolls that content instead of panning the board underneath it. A zoom - // gesture (ctrl/cmd) always wins — pinch-to-zoom over a chat - // panel is still a zoom, not a captured scroll. - if ( - !(e.ctrlKey || e.metaKey) && - isWithinScrollableContent(e.target, container) - ) - return; - - e.preventDefault(); - - // A fresh gesture always wins over a still-coasting one from the last touch pan. - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - const rect = container.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - if (e.ctrlKey || e.metaKey) { - zoomTowards(mouseX, mouseY, Math.exp(-e.deltaY * ZOOM_INTENSITY)); - } else { - panRef.current = { - x: panRef.current.x - e.deltaX, - y: panRef.current.y - e.deltaY, - }; - noteGestureActivity(); - scheduleTransformApply(); - } - }, - [zoomTowards, noteGestureActivity, scheduleTransformApply], - ); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - container.addEventListener('wheel', handleWheel, { passive: false }); - return () => container.removeEventListener('wheel', handleWheel); - }, [handleWheel]); - - // Safari/WebKit reports a trackpad pinch through its own non-standard gesture events — - // including on iPadOS with a Magic Keyboard trackpad — separately from the wheel+ctrlKey path - // above, which Safari never fires for a trackpad pinch. Left unhandled, Safari's default action - // is to zoom the whole page instead of the canvas — but canceling that alone would swallow the - // pinch entirely, so gesturechange also drives the same zoom-towards-focus the ctrl+wheel path - // applies. A touchscreen pinch fires these gesture events too, alongside the pointer events the - // touch path already zooms from — with touch pointers down, this handler only cancels the page - // zoom and leaves the zooming to the touch path. The events don't exist outside WebKit, so all - // of this is a no-op everywhere else. - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let lastScale = 1; - const handleGestureStart = (event: Event) => { - event.preventDefault(); - lastScale = (event as WebKitGestureEvent).scale || 1; - }; - const handleGestureChange = (event: Event) => { - event.preventDefault(); - if (touchPointersRef.current.size > 0) return; - - const gesture = event as WebKitGestureEvent; - if (!gesture.scale || !lastScale) return; - - const rect = container.getBoundingClientRect(); - zoomTowards( - gesture.clientX - rect.left, - gesture.clientY - rect.top, - gesture.scale / lastScale, - ); - lastScale = gesture.scale; - }; - const handleGestureEnd = (event: Event) => event.preventDefault(); - - // Explicitly non-passive: WebKit is free to treat an options-less gesture listener as - // passive, silently ignoring preventDefault() — which would zoom the browser page instead - // of the canvas on an iPad Magic Keyboard trackpad. - container.addEventListener('gesturestart', handleGestureStart, { - passive: false, - }); - container.addEventListener('gesturechange', handleGestureChange, { - passive: false, - }); - container.addEventListener('gestureend', handleGestureEnd, { passive: false }); - return () => { - container.removeEventListener('gesturestart', handleGestureStart); - container.removeEventListener('gesturechange', handleGestureChange); - container.removeEventListener('gestureend', handleGestureEnd); - }; - }, [zoomTowards]); - - // The two handlers above only ever see events that bubble through the canvas surface's own DOM - // subtree — but a toolbar, panel, or minimap floating over the canvas is frequently portalled to - // document.body (or otherwise rendered as a sibling, not a descendant), so a pinch or Ctrl/Cmd+ - // wheel with the cursor sitting over one of those never reaches the listeners above at all, and - // the browser is free to zoom the whole page instead. Listen at the window level too, and go - // purely by screen position — no matter what element actually receives the - // event, if it lands within the canvas's own rectangle the browser's native zoom is canceled. - // For events whose target sits inside the canvas subtree this only cancels the browser zoom — - // the container's own listeners drive the canvas zoom, so applying it here too would double up. - // For a pinch whose target is a portalled sibling (never reaching the container listeners), it - // also drives the same zoom-towards-focus, so a pinch over a floating panel zooms the board - // instead of being swallowed after the browser zoom is canceled. - useEffect(() => { - const isWithinCanvas = (x: number, y: number) => { - const rect = containerRef.current?.getBoundingClientRect(); - return ( - !!rect && - x >= rect.left && - x <= rect.right && - y >= rect.top && - y <= rect.bottom - ); - }; - const reachesContainerListeners = (event: Event) => - event.target instanceof Node && - !!containerRef.current?.contains(event.target); - const handleWindowWheel = (event: WheelEvent) => { - if ( - (event.ctrlKey || event.metaKey) && - isWithinCanvas(event.clientX, event.clientY) - ) { - event.preventDefault(); - } - }; - - let lastScale = 1; - const handleWindowGestureStart = (event: Event) => { - const gesture = event as WebKitGestureEvent; - if (!isWithinCanvas(gesture.clientX, gesture.clientY)) return; - event.preventDefault(); - lastScale = gesture.scale || 1; - }; - const handleWindowGestureChange = (event: Event) => { - const gesture = event as WebKitGestureEvent; - if (!isWithinCanvas(gesture.clientX, gesture.clientY)) return; - event.preventDefault(); - if (reachesContainerListeners(event)) return; - if (touchPointersRef.current.size > 0) return; - if (!gesture.scale || !lastScale) return; - - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - zoomTowards( - gesture.clientX - rect.left, - gesture.clientY - rect.top, - gesture.scale / lastScale, - ); - lastScale = gesture.scale; - }; - const handleWindowGestureEnd = (event: Event) => { - const gesture = event as WebKitGestureEvent; - if (isWithinCanvas(gesture.clientX, gesture.clientY)) event.preventDefault(); - }; - - // Non-passive everywhere: WebKit treating any of these as passive is what lets the page - // zoom through, and window-level listeners are exactly where it is most inclined to. - window.addEventListener('wheel', handleWindowWheel, { passive: false }); - window.addEventListener('gesturestart', handleWindowGestureStart, { - passive: false, - }); - window.addEventListener('gesturechange', handleWindowGestureChange, { - passive: false, - }); - window.addEventListener('gestureend', handleWindowGestureEnd, { passive: false }); - return () => { - window.removeEventListener('wheel', handleWindowWheel); - window.removeEventListener('gesturestart', handleWindowGestureStart); - window.removeEventListener('gesturechange', handleWindowGestureChange); - window.removeEventListener('gestureend', handleWindowGestureEnd); - }; - }, [zoomTowards]); - - // Touch: two fingers pan and zoom together, anywhere on the surface — the trackpad's - // wheel/ctrl+wheel equivalent for a tablet. The world point under the pinch midpoint stays put - // while the midpoint itself drags the board, so zooming and panning feel like one gesture. - const applyPinch = useCallback(() => { - const container = containerRef.current; - const snapshot = pinchSnapshotOf(touchPointersRef.current.values()); - if (!container || !snapshot) return; - - const previous = pinchRef.current; - pinchRef.current = snapshot; - // The first move of a gesture only establishes the baseline — there is nothing to move yet. - if (!previous) return; - - const change = pinchChangeBetween(previous, snapshot); - const rect = container.getBoundingClientRect(); - const focusX = snapshot.center.x - rect.left; - const focusY = snapshot.center.y - rect.top; - const newZoom = Math.max( + const { appRef, worldRef, spritesRef, pixiReady, render } = usePixiApplication({ + containerRef, + panRef, + zoomRef, + onReadyRef, + captureLayer: captureAttributes?.layer, + }); + + const { + applyWorldTransform, + refreshMinimap, + scheduleTransformApply, + noteGestureActivity, + zoomTowards, + } = useCanvasCamera({ + appRef, + worldRef, + minimapRef, + overlayRef, + zoomLayerRef, + panRef, + zoomRef, + onTransformChangeRef, + render, + minZoom, + maxZoom, + }); + + useCanvasWheelZoom({ + containerRef, + animationFrameRef, + panRef, + touchPointersRef, + zoomTowards, + noteGestureActivity, + scheduleTransformApply, + }); + + const { handlePointerDown, handlePointerMove, handlePointerUp } = + useCanvasPointerGestures({ + containerRef, + animationFrameRef, + panRef, + zoomRef, + touchPointersRef, minZoom, - Math.min(maxZoom, zoomRef.current * change.scale), - ); - // The world point that was under the midpoint BEFORE this step is the one being held, so the - // midpoint's own movement is what pans — exactly as zoom-towards-cursor does for the wheel. - const worldX = (focusX - change.panX - panRef.current.x) / zoomRef.current; - const worldY = (focusY - change.panY - panRef.current.y) / zoomRef.current; - panRef.current = { x: focusX - worldX * newZoom, y: focusY - worldY * newZoom }; - zoomRef.current = newZoom; - - noteGestureActivity(); - scheduleTransformApply(); - }, [minZoom, maxZoom, noteGestureActivity, scheduleTransformApply]); - - // Coasts the board after a touch pan release, decaying the release velocity to a stop — the - // manual equivalent of the momentum a trackpad's own wheel-event stream gives panning for free. - // Shares animationFrameRef with smoothPanToWorld/smoothPanZoomToWorld so only one of the three - // ever drives the transform at once; each cancels whichever of the others was still running. - const startTouchMomentum = useCallback(() => { - const initialVelocity = velocityFromSamples( - touchPanSamplesRef.current, - MOMENTUM_MIN_VELOCITY, - ); - if (!initialVelocity) return; - - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - let velocity = initialVelocity; - let lastFrameTime = performance.now(); - - const animate = (currentTime: number) => { - const frameElapsedMs = currentTime - lastFrameTime; - lastFrameTime = currentTime; - - panRef.current = { - x: panRef.current.x + velocity.x * frameElapsedMs, - y: panRef.current.y + velocity.y * frameElapsedMs, - }; - velocity = decayVelocity(velocity, frameElapsedMs, MOMENTUM_FRICTION); - - applyWorldTransform(); - refreshMinimap(); - render(); - noteGestureActivity(); - - if (Math.hypot(velocity.x, velocity.y) > MOMENTUM_MIN_VELOCITY) { - animationFrameRef.current = requestAnimationFrame(animate); - } else { - animationFrameRef.current = null; - } - }; - - animationFrameRef.current = requestAnimationFrame(animate); - }, [applyWorldTransform, refreshMinimap, render, noteGestureActivity]); - - // Pointer: pan via middle mouse, left-click on empty background, or a single finger on it - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - // A fresh gesture always wins over a still-coasting one from the last touch pan. - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - if (e.pointerType === 'touch') { - touchPointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (touchPointersRef.current.size > 1) { - // A second finger turns whatever was happening into a pinch: end the one-finger drag - // so the two paths never both move the board in the same frame. - isPanningRef.current = false; - setPanCursor(false); - pinchRef.current = pinchSnapshotOf(touchPointersRef.current.values()); - return; - } - } - - const isBackground = isBackgroundPointerTarget( - e.pointerType, - readOnly, - e.target, - containerRef.current, - ); - // A host that claims the background drag for itself only gives up the mouse/pen gesture: touch - // has no middle button and no wheel, so one finger must keep panning or the board is stuck. - const mayDragPan = backgroundDragPans || e.pointerType === 'touch'; - if (e.button === 1 || (e.button === 0 && isBackground && mayDragPan)) { - if (e.button === 1) e.preventDefault(); - isPanningRef.current = true; - setPanCursor(true); - lastPointerRef.current = { x: e.clientX, y: e.clientY }; - if (e.pointerType === 'touch') { - touchPanSamplesRef.current = [ - { x: e.clientX, y: e.clientY, time: performance.now() }, - ]; - } - e.currentTarget.setPointerCapture(e.pointerId); - } - }, - [readOnly, backgroundDragPans, setPanCursor], - ); - - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - if (e.pointerType === 'touch' && touchPointersRef.current.has(e.pointerId)) { - touchPointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (touchPointersRef.current.size > 1) { - applyPinch(); - return; - } - } - if (!isPanningRef.current) return; - const deltaX = e.clientX - lastPointerRef.current.x; - const deltaY = e.clientY - lastPointerRef.current.y; - lastPointerRef.current = { x: e.clientX, y: e.clientY }; - panRef.current = { - x: panRef.current.x + deltaX, - y: panRef.current.y + deltaY, - }; - - if (e.pointerType === 'touch') { - const samples = [ - ...touchPanSamplesRef.current, - { x: e.clientX, y: e.clientY, time: performance.now() }, - ]; - touchPanSamplesRef.current = trimSamples( - samples, - MOMENTUM_SAMPLE_WINDOW_MS, - ); - } - - // Same gesture path as wheel pan: coalesce onto one rAF and signal the gesture so - // virtualization holds still during a drag-pan too. - noteGestureActivity(); - scheduleTransformApply(); - }, - [applyPinch, noteGestureActivity, scheduleTransformApply], - ); - - const handlePointerUp = useCallback( - (e: React.PointerEvent) => { - if (touchPointersRef.current.delete(e.pointerId)) { - // Re-baseline on the fingers that are left: keeping the old geometry would register the - // lifted finger's absence as a huge pinch/pan step on the next move. - pinchRef.current = pinchSnapshotOf(touchPointersRef.current.values()); - const [remaining] = [...touchPointersRef.current.values()]; - if (remaining) { - // Lifting back down to one finger continues the same gesture as a plain drag-pan - // rather than stopping dead until the user lifts and starts over. - isPanningRef.current = true; - setPanCursor(true); - lastPointerRef.current = { x: remaining.x, y: remaining.y }; - touchPanSamplesRef.current = [ - { x: remaining.x, y: remaining.y, time: performance.now() }, - ]; - return; - } - } - - const wasPanningTouch = e.pointerType === 'touch' && isPanningRef.current; - isPanningRef.current = false; - setPanCursor(false); - if (wasPanningTouch) startTouchMomentum(); - }, - [startTouchMomentum, setPanCursor], - ); - - // Sync items → PIXI containers - useEffect(() => { - const world = worldRef.current; - if (!pixiReady || !world || !renderItem) return; - - const incoming = new Map(items.map((item) => [item.id, item])); - - // Remove deleted items - spritesRef.current.forEach((container, id) => { - if (!incoming.has(id)) { - world.removeChild(container); - container.destroy({ children: true }); - spritesRef.current.delete(id); - } - }); - - // Add new items; update positions of existing ones - items.forEach((item) => { - const existing = spritesRef.current.get(item.id); - if (existing) { - existing.position.set(item.x, item.y); - // Refresh handler so it always references the latest item object - existing.removeAllListeners('pointerdown'); - existing.on('pointerdown', (event: PIXI.FederatedPointerEvent) => { - onItemPointerDownRef.current?.(item, event); - }); - } else { - const container = renderItem(item); - container.position.set(item.x, item.y); - container.eventMode = 'static'; - container.on('pointerdown', (event: PIXI.FederatedPointerEvent) => { - onItemPointerDownRef.current?.(item, event); - }); - world.addChild(container); - spritesRef.current.set(item.id, container); - } + maxZoom, + readOnly, + backgroundDragPans, + applyWorldTransform, + refreshMinimap, + render, + noteGestureActivity, + scheduleTransformApply, }); - // Render directly (not via the empty-world-skipping render()) so removing the last item - // still clears its pixels from the canvas. - appRef.current?.renderer.render(appRef.current.stage); - }, [pixiReady, items, renderItem]); - - // Controls handlers - const handleZoomIn = useCallback(() => { - const container = containerRef.current; - if (!container) return; - const cx = container.clientWidth / 2; - const cy = container.clientHeight / 2; - const factor = 1.2; - const newZoom = Math.min(maxZoom, zoomRef.current * factor); - const worldX = (cx - panRef.current.x) / zoomRef.current; - const worldY = (cy - panRef.current.y) / zoomRef.current; - panRef.current = { x: cx - worldX * newZoom, y: cy - worldY * newZoom }; - zoomRef.current = newZoom; - applyWorldTransform(); - refreshMinimap(); - render(); - }, [maxZoom, applyWorldTransform, refreshMinimap, render]); - - const handleZoomOut = useCallback(() => { - const container = containerRef.current; - if (!container) return; - const cx = container.clientWidth / 2; - const cy = container.clientHeight / 2; - const factor = 1.2; - const newZoom = Math.max(minZoom, zoomRef.current / factor); - const worldX = (cx - panRef.current.x) / zoomRef.current; - const worldY = (cy - panRef.current.y) / zoomRef.current; - panRef.current = { x: cx - worldX * newZoom, y: cy - worldY * newZoom }; - zoomRef.current = newZoom; - applyWorldTransform(); - refreshMinimap(); - render(); - }, [minZoom, applyWorldTransform, refreshMinimap, render]); - - const handleZoomReset = useCallback(() => { - zoomRef.current = 1; - applyWorldTransform(); - refreshMinimap(); - render(); - }, [applyWorldTransform, refreshMinimap, render]); - - const handleMinimapPan = useCallback( - (pan: { x: number; y: number }) => { - panRef.current = pan; - applyWorldTransform(); - refreshMinimap(); - render(); - }, - [applyWorldTransform, refreshMinimap, render], - ); - - const smoothPanToWorld = useCallback( - (worldX: number, worldY: number, durationMs = 600) => { - const container = containerRef.current; - if (!container) return; - - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - const viewportWidth = container.clientWidth; - const viewportHeight = container.clientHeight; - const targetPanX = viewportWidth / 2 - worldX * zoomRef.current; - const targetPanY = viewportHeight / 2 - worldY * zoomRef.current; - const startPanX = panRef.current.x; - const startPanY = panRef.current.y; - const startTime = performance.now(); - - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / durationMs, 1); - const eased = easeInOutCubic(progress); - panRef.current = { - x: startPanX + (targetPanX - startPanX) * eased, - y: startPanY + (targetPanY - startPanY) * eased, - }; - applyWorldTransform(); - refreshMinimap(); - render(); - if (progress < 1) { - animationFrameRef.current = requestAnimationFrame(animate); - } else { - animationFrameRef.current = null; - } - }; - - animationFrameRef.current = requestAnimationFrame(animate); - }, - [applyWorldTransform, refreshMinimap, render], - ); - - const smoothPanZoomToWorld = useCallback( - (worldX: number, worldY: number, targetZoom = 1, durationMs = 600) => { - const container = containerRef.current; - if (!container) return; - - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - const viewportWidth = container.clientWidth; - const viewportHeight = container.clientHeight; - const startZoom = zoomRef.current; - const endZoom = Math.min(maxZoom, Math.max(minZoom, targetZoom)); - const startPanX = panRef.current.x; - const startPanY = panRef.current.y; - // Final pan that centers the world point at the target zoom. - const endPanX = viewportWidth / 2 - worldX * endZoom; - const endPanY = viewportHeight / 2 - worldY * endZoom; - const startTime = performance.now(); - - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / durationMs, 1); - const eased = easeInOutCubic(progress); - zoomRef.current = startZoom + (endZoom - startZoom) * eased; - panRef.current = { - x: startPanX + (endPanX - startPanX) * eased, - y: startPanY + (endPanY - startPanY) * eased, - }; - applyWorldTransform(); - refreshMinimap(); - render(); - if (progress < 1) { - animationFrameRef.current = requestAnimationFrame(animate); - } else { - animationFrameRef.current = null; - } - }; - - animationFrameRef.current = requestAnimationFrame(animate); - }, - [maxZoom, minZoom, applyWorldTransform, refreshMinimap, render], - ); - - const getContainerRect = useCallback((): DOMRect | null => { - return containerRef.current?.getBoundingClientRect() ?? null; - }, []); - - // Measured, not estimated: the registry holds what each CanvasItem's ResizeObserver last reported, so a - // fit-to-content computed from it includes everything an item really renders (specifications under the - // rows included), unlike the size estimates the minimap falls back to before anything has mounted. - const getItemBounds = useCallback( - (): MinimapItem[] => - Array.from(itemRegistryRef.current.values(), (entry) => ({ - x: entry.x, - y: entry.y, - width: entry.width, - height: entry.height, - })), - [], - ); - - // Expose imperative handle once the canvas is set up - useEffect(() => { - if (!pixiReady) return; - onHandleReadyRef.current?.({ - smoothPanToWorld, - smoothPanZoomToWorld, - getContainerRect, - getItemBounds, - }); - }, [ + usePixiItems({ pixiReady, - smoothPanToWorld, - smoothPanZoomToWorld, - getContainerRect, - getItemBounds, - ]); + appRef, + worldRef, + spritesRef, + items, + renderItem, + onItemPointerDownRef, + }); + + const { handleZoomIn, handleZoomOut, handleZoomReset, handleMinimapPan } = + useCanvasNavigation({ + containerRef, + animationFrameRef, + panRef, + zoomRef, + itemRegistryRef, + minZoom, + maxZoom, + pixiReady, + onHandleReadyRef, + applyWorldTransform, + refreshMinimap, + render, + }); return (
(null); diff --git a/Source/Canvas/CanvasItemRegistryContextValue.ts b/Source/Canvas/CanvasItemRegistryContextValue.ts new file mode 100644 index 00000000..95e7ca49 --- /dev/null +++ b/Source/Canvas/CanvasItemRegistryContextValue.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { CanvasItemRegistryEntry } from './CanvasItemRegistryEntry'; + +/** Registry contract used by {@link CanvasItem} to publish measured bounds. */ +export interface CanvasItemRegistryContextValue { + /** Adds or updates one measured item. */ + register: (id: string, entry: CanvasItemRegistryEntry) => void; + /** Removes one measured item. */ + unregister: (id: string) => void; + + /** + * The registry's current content, as a stable-reference snapshot: the returned map's identity only + * changes when the content actually changed (an item registered, unregistered, or moved/resized), + * making it safe to feed straight into `useSyncExternalStore`. The map is rebuilt on change — never + * mutated in place — so a held snapshot stays internally consistent. Optional so a consumer + * providing its own registry value predating this member keeps type-checking; readers must + * fall back gracefully when absent. + */ + getSnapshot?: () => ReadonlyMap; + + /** + * Subscribes to registry changes; the listener fires after every register/unregister/bounds-update + * that actually changed content. Bounds updates can arrive per-frame during a drag — listeners + * decide how much work to do per notification. Returns the unsubscribe function. + * `useSyncExternalStore`-compatible. Optional for the same compatibility reason as + * {@link getSnapshot}. + */ + subscribe?: (listener: () => void) => () => void; +} diff --git a/Source/Canvas/CanvasItemRegistryEntry.ts b/Source/Canvas/CanvasItemRegistryEntry.ts new file mode 100644 index 00000000..b1c2f293 --- /dev/null +++ b/Source/Canvas/CanvasItemRegistryEntry.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Measured world-space bounds for one registered {@link CanvasItem}. */ +export interface CanvasItemRegistryEntry { + /** World-space horizontal position. */ + x: number; + /** World-space vertical position. */ + y: number; + /** Measured width. */ + width: number; + /** Measured height. */ + height: number; + + /** + * Whether this entry is registered under an internally generated id rather than one the caller + * supplied through {@link CanvasItem}'s `id` prop. An anonymous entry still feeds every consumer + * of the registry — the minimap, fit-to-content — but `Region` (via `itemsWithinRegion`) never + * reports it as a region member, since there is no caller-owned id a host could recognize it by. + */ + anonymous?: boolean; +} diff --git a/Source/Canvas/WebKitGestureEvent.ts b/Source/Canvas/WebKitGestureEvent.ts new file mode 100644 index 00000000..78aee4ca --- /dev/null +++ b/Source/Canvas/WebKitGestureEvent.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Safari's non-standard trackpad/touch gesture event — absent from the DOM type library, so typed here + * rather than asserted through `any`. `scale` is cumulative relative to the gesture's start. + */ +export interface WebKitGestureEvent extends Event { + /** The gesture's cumulative scale factor since gesturestart. */ + readonly scale: number; + + /** The gesture's horizontal viewport coordinate. */ + readonly clientX: number; + + /** The gesture's vertical viewport coordinate. */ + readonly clientY: number; +} diff --git a/Source/Canvas/canvasEasing.ts b/Source/Canvas/canvasEasing.ts new file mode 100644 index 00000000..a0d10a01 --- /dev/null +++ b/Source/Canvas/canvasEasing.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The easing curve for programmatic camera moves: a cubic ease-in-out, so the camera picks up speed gently + * and settles gently instead of starting at full tilt — the difference between a camera move and a yank. + * @param progress Linear progress through the animation, 0 to 1. + * @returns The eased progress. + */ +export function easeInOutCubic(progress: number): number { + return progress < 0.5 + ? 4 * progress * progress * progress + : 1 - (-2 * progress + 2) ** 3 / 2; +} diff --git a/Source/Canvas/canvasGestureTuning.ts b/Source/Canvas/canvasGestureTuning.ts new file mode 100644 index 00000000..f6edb2cf --- /dev/null +++ b/Source/Canvas/canvasGestureTuning.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export const ZOOM_INTENSITY = 0.008; + +// How long after the last wheel event a gesture counts as settled - the moment the crisp resting zoom is +// re-applied and the held-back virtualization updates flush. +export const GESTURE_SETTLE_MS = 150; + +// A trackpad's momentum comes for free — the OS keeps delivering decaying wheel events after the +// fingers lift. A touch drag has no such thing: `touch-action: none` hands the whole gesture to us, +// so lifting a finger stops the pan dead unless we fake the same decay ourselves. These tune that feel. +// Only samples within this trailing window (from the drag's last moment, not its whole history) +// contribute to the release velocity, so a drag that was moving fast but came to rest before the +// finger actually lifted correctly produces no momentum. +export const MOMENTUM_SAMPLE_WINDOW_MS = 100; + +// Below this speed (px/ms) momentum is imperceptible — used both to skip starting it on a slow +// release and to end the decay loop once it coasts down to a stop. +export const MOMENTUM_MIN_VELOCITY = 0.02; + +// Exponential decay rate, chosen so velocity halves roughly every 200ms — fast enough to feel +// responsive, slow enough to read as a coast rather than a snap. +export const MOMENTUM_FRICTION = 0.0035; diff --git a/Source/Canvas/useCanvasCamera.ts b/Source/Canvas/useCanvasCamera.ts new file mode 100644 index 00000000..fa697a39 --- /dev/null +++ b/Source/Canvas/useCanvasCamera.ts @@ -0,0 +1,166 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, useRef, type RefObject } from 'react'; +import type * as PIXI from 'pixi.js'; +import type { CanvasMinimapHandle } from './CanvasMinimap'; +import { canvasGesture } from './canvasGesture'; +import { GESTURE_SETTLE_MS } from './canvasGestureTuning'; +import { canvasTransformActivity } from './canvasTransformActivity'; +import { applyZoomLayer } from './zoomMechanism'; + +/** The camera state and surfaces {@link useCanvasCamera} writes the transform to. */ +export interface CanvasCameraOptions { + appRef: RefObject; + worldRef: RefObject; + minimapRef: RefObject; + overlayRef: RefObject; + zoomLayerRef: RefObject; + panRef: RefObject<{ x: number; y: number }>; + zoomRef: RefObject; + onTransformChangeRef: RefObject< + ((zoom: number, pan: { x: number; y: number }) => void) | undefined + >; + render: () => void; + minZoom: number; + maxZoom: number; +} + +/** + * Applies the camera transform to the Pixi world, the HTML overlay and the minimap, coalesces + * gesture frames, and zooms toward a focus point. + * @param options The camera state, surfaces and zoom bounds. + * @returns The transform operations every input path shares. + */ +export const useCanvasCamera = ({ + appRef, + worldRef, + minimapRef, + overlayRef, + zoomLayerRef, + panRef, + zoomRef, + onTransformChangeRef, + render, + minZoom, + maxZoom, +}: CanvasCameraOptions) => { + // Wheel gestures coalesce onto animation frames (trackpads deliver several events per frame, and each + // direct style write would reflow), and while a gesture is in motion the zoom layer stays on the + // composited transform path with virtualization told to hold still. The settle timer restores the crisp + // resting state shortly after the last wheel event. + const gestureActiveRef = useRef(false); + const gestureSettleTimerRef = useRef | undefined>( + undefined, + ); + const transformFramePendingRef = useRef(false); + + const refreshMinimap = useCallback(() => { + const app = appRef.current; + if (!app || !minimapRef.current) return; + minimapRef.current.update( + panRef.current, + zoomRef.current, + app.renderer.width, + app.renderer.height, + ); + }, [appRef, minimapRef, panRef, zoomRef]); + + const applyWorldTransform = useCallback(() => { + const world = worldRef.current; + if (!world) return; + world.position.set(panRef.current.x, panRef.current.y); + world.scale.set(zoomRef.current); + // Pan is a CSS transform on the outer overlay; zoom is applied to an inner layer. The zoom + // mechanism is hybrid, split at 100% (the two are identical there, so the switch is seamless): + // - Above 100% we use CSS `zoom`, which re-lays-out and re-rasterizes at the effective + // resolution — `transform: scale()` would rasterize once at 1x and stretch, blurring text + // when UPSCALING in Safari. EXCEPT on multi-touch-capable devices (iPad and friends), which + // always use `transform: scale()` even above 100% — see zoomMechanism.ts for why. + // - At or below 100% we use `transform: scale()`, which is GPU-composited: zooming out stays + // smooth (no per-frame full-board reflow) and text scales down properly. CSS `zoom` instead + // reflows every step (jerky) and clamps font-size to a floor when zoomed out, so labels stop + // shrinking and word-wrap. Downscaling never blurs, so the crispness reason does not apply here. + if (overlayRef.current) { + overlayRef.current.style.transform = `translate(${panRef.current.x}px, ${panRef.current.y}px)`; + } + if (zoomLayerRef.current) { + applyZoomLayer( + zoomLayerRef.current, + zoomRef.current, + gestureActiveRef.current, + ); + } + onTransformChangeRef.current?.(zoomRef.current, panRef.current); + // Every applied transform frame is announced so followers (cursors, selection toolbar, tour + // anchors) can ride the transform instead of polling it on their own animation-frame loops. + canvasTransformActivity.notify(); + }, [onTransformChangeRef, overlayRef, panRef, worldRef, zoomLayerRef, zoomRef]); + + // One transform apply per animation frame, no matter how many wheel events arrived - the refs already + // hold the latest pan/zoom, so coalescing loses nothing and avoids multiple reflows per frame. + // No React state is touched here: a gesture must cause zero Canvas re-renders (the controls' + // zoom readout polls the ref on its own slow interval instead). + const scheduleTransformApply = useCallback(() => { + if (transformFramePendingRef.current) return; + transformFramePendingRef.current = true; + requestAnimationFrame(() => { + transformFramePendingRef.current = false; + applyWorldTransform(); + refreshMinimap(); + render(); + }); + }, [applyWorldTransform, refreshMinimap, render]); + + // Marks the gesture active (composited zoom path, virtualization held still) and re-arms the settle + // timer. On settle the crisp resting hybrid is re-applied through the same applyWorldTransform as + // every gesture frame — one writer, one atomic style pass — and a new gesture cancels the timer, + // so a settle can never land in the middle of the next gesture. + const noteGestureActivity = useCallback(() => { + gestureActiveRef.current = true; + canvasGesture.set(true); + clearTimeout(gestureSettleTimerRef.current); + gestureSettleTimerRef.current = setTimeout(() => { + gestureActiveRef.current = false; + applyWorldTransform(); + canvasGesture.set(false); + }, GESTURE_SETTLE_MS); + }, [applyWorldTransform]); + + useEffect( + () => () => { + clearTimeout(gestureSettleTimerRef.current); + canvasGesture.set(false); + }, + [], + ); + + // Zooms toward a fixed focus point on the container, holding the world point under it still — + // the one zoom every input path shares, whether the factor came from a wheel delta or a pinch. + const zoomTowards = useCallback( + (focusX: number, focusY: number, factor: number) => { + const newZoom = Math.max( + minZoom, + Math.min(maxZoom, zoomRef.current * factor), + ); + const worldX = (focusX - panRef.current.x) / zoomRef.current; + const worldY = (focusY - panRef.current.y) / zoomRef.current; + panRef.current = { + x: focusX - worldX * newZoom, + y: focusY - worldY * newZoom, + }; + zoomRef.current = newZoom; + noteGestureActivity(); + scheduleTransformApply(); + }, + [minZoom, maxZoom, noteGestureActivity, scheduleTransformApply, panRef, zoomRef], + ); + + return { + applyWorldTransform, + refreshMinimap, + scheduleTransformApply, + noteGestureActivity, + zoomTowards, + }; +}; diff --git a/Source/Canvas/useCanvasItemRegistry.ts b/Source/Canvas/useCanvasItemRegistry.ts new file mode 100644 index 00000000..581e4015 --- /dev/null +++ b/Source/Canvas/useCanvasItemRegistry.ts @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { MinimapItem } from './CanvasMinimap'; +import type { CanvasItemRegistryContextValue } from './CanvasItemRegistryContextValue'; +import type { CanvasItemRegistryEntry } from './CanvasItemRegistryEntry'; + +/** + * The registry CanvasItem children publish their measured bounds to, with the minimap items + * derived from it. + * @returns The registry, its context value, and the derived minimap items. + */ +export const useCanvasItemRegistry = () => { + const itemRegistryRef = useRef>(new Map()); + const [itemRegistryVersion, setItemRegistryVersion] = useState(0); + + // The read/subscribe side of the registry. The snapshot is a fresh shallow copy taken on every + // actual content change (register/unregister guard against no-op writes below), so its identity + // is a faithful change signal for useSyncExternalStore consumers — same reference back means + // nothing changed. Entry objects are replaced wholesale on update, never mutated, so entries + // shared between an old snapshot and a new one are safe to hold onto. + const registrySnapshotRef = useRef>( + new Map(), + ); + const registryListenersRef = useRef void>>(new Set()); + + const notifyRegistryChanged = useCallback(() => { + registrySnapshotRef.current = new Map(itemRegistryRef.current); + setItemRegistryVersion((version) => version + 1); + registryListenersRef.current.forEach((listener) => listener()); + }, []); + + const registerItem = useCallback( + (id: string, entry: CanvasItemRegistryEntry) => { + const existing = itemRegistryRef.current.get(id); + if ( + existing && + existing.x === entry.x && + existing.y === entry.y && + existing.width === entry.width && + existing.height === entry.height && + existing.anonymous === entry.anonymous + ) + return; + itemRegistryRef.current.set(id, entry); + notifyRegistryChanged(); + }, + [notifyRegistryChanged], + ); + + const unregisterItem = useCallback( + (id: string) => { + if (!itemRegistryRef.current.delete(id)) return; + notifyRegistryChanged(); + }, + [notifyRegistryChanged], + ); + + const getRegistrySnapshot = useCallback( + (): ReadonlyMap => registrySnapshotRef.current, + [], + ); + + const subscribeToRegistry = useCallback((listener: () => void): (() => void) => { + registryListenersRef.current.add(listener); + return () => { + registryListenersRef.current.delete(listener); + }; + }, []); + + const registryContextValue = useMemo( + () => ({ + register: registerItem, + unregister: unregisterItem, + getSnapshot: getRegistrySnapshot, + subscribe: subscribeToRegistry, + }), + [registerItem, unregisterItem, getRegistrySnapshot, subscribeToRegistry], + ); + + const autoMinimapItems = useMemo((): MinimapItem[] => { + void itemRegistryVersion; // subscribe to registry changes + const result: MinimapItem[] = []; + itemRegistryRef.current.forEach((entry) => { + result.push({ + x: entry.x, + y: entry.y, + width: entry.width, + height: entry.height, + }); + }); + return result; + }, [itemRegistryVersion]); + + return { itemRegistryRef, registryContextValue, autoMinimapItems }; +}; diff --git a/Source/Canvas/useCanvasNavigation.ts b/Source/Canvas/useCanvasNavigation.ts new file mode 100644 index 00000000..f2eeb57e --- /dev/null +++ b/Source/Canvas/useCanvasNavigation.ts @@ -0,0 +1,252 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, type RefObject } from 'react'; +import { easeInOutCubic } from './canvasEasing'; +import type { CanvasHandle } from './CanvasHandle'; +import type { CanvasItemRegistryEntry } from './CanvasItemRegistryEntry'; +import type { MinimapItem } from './CanvasMinimap'; + +/** The camera state and operations {@link useCanvasNavigation} drives. */ +export interface CanvasNavigationOptions { + containerRef: RefObject; + animationFrameRef: RefObject; + panRef: RefObject<{ x: number; y: number }>; + zoomRef: RefObject; + itemRegistryRef: RefObject>; + minZoom: number; + maxZoom: number; + pixiReady: boolean; + onHandleReadyRef: RefObject<((handle: CanvasHandle) => void) | undefined>; + applyWorldTransform: () => void; + refreshMinimap: () => void; + render: () => void; +} + +/** + * The zoom controls, minimap panning, animated camera moves, and the imperative handle. + * @param options The container, camera state and operations. + * @returns The handlers for the integrated controls and minimap. + */ +export const useCanvasNavigation = ({ + containerRef, + animationFrameRef, + panRef, + zoomRef, + itemRegistryRef, + minZoom, + maxZoom, + pixiReady, + onHandleReadyRef, + applyWorldTransform, + refreshMinimap, + render, +}: CanvasNavigationOptions) => { + // Controls handlers + const handleZoomIn = useCallback(() => { + const container = containerRef.current; + if (!container) return; + const cx = container.clientWidth / 2; + const cy = container.clientHeight / 2; + const factor = 1.2; + const newZoom = Math.min(maxZoom, zoomRef.current * factor); + const worldX = (cx - panRef.current.x) / zoomRef.current; + const worldY = (cy - panRef.current.y) / zoomRef.current; + panRef.current = { x: cx - worldX * newZoom, y: cy - worldY * newZoom }; + zoomRef.current = newZoom; + applyWorldTransform(); + refreshMinimap(); + render(); + }, [ + maxZoom, + applyWorldTransform, + refreshMinimap, + render, + containerRef, + panRef, + zoomRef, + ]); + + const handleZoomOut = useCallback(() => { + const container = containerRef.current; + if (!container) return; + const cx = container.clientWidth / 2; + const cy = container.clientHeight / 2; + const factor = 1.2; + const newZoom = Math.max(minZoom, zoomRef.current / factor); + const worldX = (cx - panRef.current.x) / zoomRef.current; + const worldY = (cy - panRef.current.y) / zoomRef.current; + panRef.current = { x: cx - worldX * newZoom, y: cy - worldY * newZoom }; + zoomRef.current = newZoom; + applyWorldTransform(); + refreshMinimap(); + render(); + }, [ + minZoom, + applyWorldTransform, + refreshMinimap, + render, + containerRef, + panRef, + zoomRef, + ]); + + const handleZoomReset = useCallback(() => { + zoomRef.current = 1; + applyWorldTransform(); + refreshMinimap(); + render(); + }, [applyWorldTransform, refreshMinimap, render, zoomRef]); + + const handleMinimapPan = useCallback( + (pan: { x: number; y: number }) => { + panRef.current = pan; + applyWorldTransform(); + refreshMinimap(); + render(); + }, + [applyWorldTransform, refreshMinimap, render, panRef], + ); + + const smoothPanToWorld = useCallback( + (worldX: number, worldY: number, durationMs = 600) => { + const container = containerRef.current; + if (!container) return; + + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + const viewportWidth = container.clientWidth; + const viewportHeight = container.clientHeight; + const targetPanX = viewportWidth / 2 - worldX * zoomRef.current; + const targetPanY = viewportHeight / 2 - worldY * zoomRef.current; + const startPanX = panRef.current.x; + const startPanY = panRef.current.y; + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / durationMs, 1); + const eased = easeInOutCubic(progress); + panRef.current = { + x: startPanX + (targetPanX - startPanX) * eased, + y: startPanY + (targetPanY - startPanY) * eased, + }; + applyWorldTransform(); + refreshMinimap(); + render(); + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animate); + }, + [ + applyWorldTransform, + refreshMinimap, + render, + animationFrameRef, + containerRef, + panRef, + zoomRef, + ], + ); + + const smoothPanZoomToWorld = useCallback( + (worldX: number, worldY: number, targetZoom = 1, durationMs = 600) => { + const container = containerRef.current; + if (!container) return; + + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + const viewportWidth = container.clientWidth; + const viewportHeight = container.clientHeight; + const startZoom = zoomRef.current; + const endZoom = Math.min(maxZoom, Math.max(minZoom, targetZoom)); + const startPanX = panRef.current.x; + const startPanY = panRef.current.y; + // Final pan that centers the world point at the target zoom. + const endPanX = viewportWidth / 2 - worldX * endZoom; + const endPanY = viewportHeight / 2 - worldY * endZoom; + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / durationMs, 1); + const eased = easeInOutCubic(progress); + zoomRef.current = startZoom + (endZoom - startZoom) * eased; + panRef.current = { + x: startPanX + (endPanX - startPanX) * eased, + y: startPanY + (endPanY - startPanY) * eased, + }; + applyWorldTransform(); + refreshMinimap(); + render(); + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animate); + }, + [ + maxZoom, + minZoom, + applyWorldTransform, + refreshMinimap, + render, + animationFrameRef, + containerRef, + panRef, + zoomRef, + ], + ); + + const getContainerRect = useCallback((): DOMRect | null => { + return containerRef.current?.getBoundingClientRect() ?? null; + }, [containerRef]); + + // Measured, not estimated: the registry holds what each CanvasItem's ResizeObserver last reported, so a + // fit-to-content computed from it includes everything an item really renders (specifications under the + // rows included), unlike the size estimates the minimap falls back to before anything has mounted. + const getItemBounds = useCallback( + (): MinimapItem[] => + Array.from(itemRegistryRef.current.values(), (entry) => ({ + x: entry.x, + y: entry.y, + width: entry.width, + height: entry.height, + })), + [itemRegistryRef], + ); + + // Expose imperative handle once the canvas is set up + useEffect(() => { + if (!pixiReady) return; + onHandleReadyRef.current?.({ + smoothPanToWorld, + smoothPanZoomToWorld, + getContainerRect, + getItemBounds, + }); + }, [ + pixiReady, + smoothPanToWorld, + smoothPanZoomToWorld, + getContainerRect, + getItemBounds, + onHandleReadyRef, + ]); + + return { handleZoomIn, handleZoomOut, handleZoomReset, handleMinimapPan }; +}; diff --git a/Source/Canvas/useCanvasPointerGestures.ts b/Source/Canvas/useCanvasPointerGestures.ts new file mode 100644 index 00000000..3fb4a2f3 --- /dev/null +++ b/Source/Canvas/useCanvasPointerGestures.ts @@ -0,0 +1,302 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useRef, type PointerEvent, type RefObject } from 'react'; +import { + MOMENTUM_FRICTION, + MOMENTUM_MIN_VELOCITY, + MOMENTUM_SAMPLE_WINDOW_MS, +} from './canvasGestureTuning'; +import { isBackgroundPointerTarget } from './isBackgroundPointerTarget'; +import { + type PanSample, + decayVelocity, + trimSamples, + velocityFromSamples, +} from './panMomentum'; +import { + type PinchSnapshot, + type PointerPosition, + pinchChangeBetween, + pinchSnapshotOf, +} from './pinchGesture'; + +/** The camera state and operations {@link useCanvasPointerGestures} drives. */ +export interface CanvasPointerGesturesOptions { + containerRef: RefObject; + animationFrameRef: RefObject; + panRef: RefObject<{ x: number; y: number }>; + zoomRef: RefObject; + /** Touch pointers currently down on the canvas, in the order they landed. */ + touchPointersRef: RefObject>; + minZoom: number; + maxZoom: number; + readOnly: boolean; + backgroundDragPans: boolean; + applyWorldTransform: () => void; + refreshMinimap: () => void; + render: () => void; + noteGestureActivity: () => void; + scheduleTransformApply: () => void; +} + +/** + * Background drag-pan, middle-button pan, one-finger pan with momentum, and two-finger pinch. + * @param options The container, camera state and operations. + * @returns The pointer handlers for the canvas surface. + */ +export const useCanvasPointerGestures = ({ + containerRef, + animationFrameRef, + panRef, + zoomRef, + touchPointersRef, + minZoom, + maxZoom, + readOnly, + backgroundDragPans, + applyWorldTransform, + refreshMinimap, + render, + noteGestureActivity, + scheduleTransformApply, +}: CanvasPointerGesturesOptions) => { + const isPanningRef = useRef(false); + const lastPointerRef = useRef({ x: 0, y: 0 }); + + // Direct DOM write, not React state — an idle background should not look grabbable until a drag + // starts. Swapping to 'grabbing' for the life of a drag-pan is exactly the kind of per-gesture + // churn this file + // already avoids re-rendering for elsewhere (see the transform writes below). + const setPanCursor = useCallback( + (panning: boolean) => { + if (containerRef.current) + containerRef.current.style.cursor = panning ? 'grabbing' : 'default'; + }, + [containerRef], + ); + + const pinchRef = useRef(null); + + // Recent positions of the single finger currently panning, newest last, trimmed to + // MOMENTUM_SAMPLE_WINDOW_MS — the trailing window startTouchMomentum reads the release velocity from. + const touchPanSamplesRef = useRef([]); + + // Touch: two fingers pan and zoom together, anywhere on the surface — the trackpad's + // wheel/ctrl+wheel equivalent for a tablet. The world point under the pinch midpoint stays put + // while the midpoint itself drags the board, so zooming and panning feel like one gesture. + const applyPinch = useCallback(() => { + const container = containerRef.current; + const snapshot = pinchSnapshotOf(touchPointersRef.current.values()); + if (!container || !snapshot) return; + + const previous = pinchRef.current; + pinchRef.current = snapshot; + // The first move of a gesture only establishes the baseline — there is nothing to move yet. + if (!previous) return; + + const change = pinchChangeBetween(previous, snapshot); + const rect = container.getBoundingClientRect(); + const focusX = snapshot.center.x - rect.left; + const focusY = snapshot.center.y - rect.top; + const newZoom = Math.max( + minZoom, + Math.min(maxZoom, zoomRef.current * change.scale), + ); + // The world point that was under the midpoint BEFORE this step is the one being held, so the + // midpoint's own movement is what pans — exactly as zoom-towards-cursor does for the wheel. + const worldX = (focusX - change.panX - panRef.current.x) / zoomRef.current; + const worldY = (focusY - change.panY - panRef.current.y) / zoomRef.current; + panRef.current = { x: focusX - worldX * newZoom, y: focusY - worldY * newZoom }; + zoomRef.current = newZoom; + + noteGestureActivity(); + scheduleTransformApply(); + }, [ + minZoom, + maxZoom, + noteGestureActivity, + scheduleTransformApply, + containerRef, + panRef, + touchPointersRef, + zoomRef, + ]); + + // Coasts the board after a touch pan release, decaying the release velocity to a stop — the + // manual equivalent of the momentum a trackpad's own wheel-event stream gives panning for free. + // Shares animationFrameRef with smoothPanToWorld/smoothPanZoomToWorld so only one of the three + // ever drives the transform at once; each cancels whichever of the others was still running. + const startTouchMomentum = useCallback(() => { + const initialVelocity = velocityFromSamples( + touchPanSamplesRef.current, + MOMENTUM_MIN_VELOCITY, + ); + if (!initialVelocity) return; + + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + let velocity = initialVelocity; + let lastFrameTime = performance.now(); + + const animate = (currentTime: number) => { + const frameElapsedMs = currentTime - lastFrameTime; + lastFrameTime = currentTime; + + panRef.current = { + x: panRef.current.x + velocity.x * frameElapsedMs, + y: panRef.current.y + velocity.y * frameElapsedMs, + }; + velocity = decayVelocity(velocity, frameElapsedMs, MOMENTUM_FRICTION); + + applyWorldTransform(); + refreshMinimap(); + render(); + noteGestureActivity(); + + if (Math.hypot(velocity.x, velocity.y) > MOMENTUM_MIN_VELOCITY) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animate); + }, [ + applyWorldTransform, + refreshMinimap, + render, + noteGestureActivity, + animationFrameRef, + panRef, + ]); + + // Pointer: pan via middle mouse, left-click on empty background, or a single finger on it + const handlePointerDown = useCallback( + (e: PointerEvent) => { + // A fresh gesture always wins over a still-coasting one from the last touch pan. + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + if (e.pointerType === 'touch') { + touchPointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (touchPointersRef.current.size > 1) { + // A second finger turns whatever was happening into a pinch: end the one-finger drag + // so the two paths never both move the board in the same frame. + isPanningRef.current = false; + setPanCursor(false); + pinchRef.current = pinchSnapshotOf(touchPointersRef.current.values()); + return; + } + } + + const isBackground = isBackgroundPointerTarget( + e.pointerType, + readOnly, + e.target, + containerRef.current, + ); + // A host that claims the background drag for itself only gives up the mouse/pen gesture: touch + // has no middle button and no wheel, so one finger must keep panning or the board is stuck. + const mayDragPan = backgroundDragPans || e.pointerType === 'touch'; + if (e.button === 1 || (e.button === 0 && isBackground && mayDragPan)) { + if (e.button === 1) e.preventDefault(); + isPanningRef.current = true; + setPanCursor(true); + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + if (e.pointerType === 'touch') { + touchPanSamplesRef.current = [ + { x: e.clientX, y: e.clientY, time: performance.now() }, + ]; + } + e.currentTarget.setPointerCapture(e.pointerId); + } + }, + [ + readOnly, + backgroundDragPans, + setPanCursor, + animationFrameRef, + containerRef, + touchPointersRef, + ], + ); + + const handlePointerMove = useCallback( + (e: PointerEvent) => { + if (e.pointerType === 'touch' && touchPointersRef.current.has(e.pointerId)) { + touchPointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (touchPointersRef.current.size > 1) { + applyPinch(); + return; + } + } + if (!isPanningRef.current) return; + const deltaX = e.clientX - lastPointerRef.current.x; + const deltaY = e.clientY - lastPointerRef.current.y; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + panRef.current = { + x: panRef.current.x + deltaX, + y: panRef.current.y + deltaY, + }; + + if (e.pointerType === 'touch') { + const samples = [ + ...touchPanSamplesRef.current, + { x: e.clientX, y: e.clientY, time: performance.now() }, + ]; + touchPanSamplesRef.current = trimSamples( + samples, + MOMENTUM_SAMPLE_WINDOW_MS, + ); + } + + // Same gesture path as wheel pan: coalesce onto one rAF and signal the gesture so + // virtualization holds still during a drag-pan too. + noteGestureActivity(); + scheduleTransformApply(); + }, + [ + applyPinch, + noteGestureActivity, + scheduleTransformApply, + panRef, + touchPointersRef, + ], + ); + + const handlePointerUp = useCallback( + (e: PointerEvent) => { + if (touchPointersRef.current.delete(e.pointerId)) { + // Re-baseline on the fingers that are left: keeping the old geometry would register the + // lifted finger's absence as a huge pinch/pan step on the next move. + pinchRef.current = pinchSnapshotOf(touchPointersRef.current.values()); + const [remaining] = [...touchPointersRef.current.values()]; + if (remaining) { + // Lifting back down to one finger continues the same gesture as a plain drag-pan + // rather than stopping dead until the user lifts and starts over. + isPanningRef.current = true; + setPanCursor(true); + lastPointerRef.current = { x: remaining.x, y: remaining.y }; + touchPanSamplesRef.current = [ + { x: remaining.x, y: remaining.y, time: performance.now() }, + ]; + return; + } + } + + const wasPanningTouch = e.pointerType === 'touch' && isPanningRef.current; + isPanningRef.current = false; + setPanCursor(false); + if (wasPanningTouch) startTouchMomentum(); + }, + [startTouchMomentum, setPanCursor, touchPointersRef], + ); + + return { handlePointerDown, handlePointerMove, handlePointerUp }; +}; diff --git a/Source/Canvas/useCanvasWheelZoom.ts b/Source/Canvas/useCanvasWheelZoom.ts new file mode 100644 index 00000000..b1a576ea --- /dev/null +++ b/Source/Canvas/useCanvasWheelZoom.ts @@ -0,0 +1,225 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, type RefObject } from 'react'; +import { ZOOM_INTENSITY } from './canvasGestureTuning'; +import { isWithinScrollableContent } from './isWithinScrollableContent'; +import type { PointerPosition } from './pinchGesture'; +import type { WebKitGestureEvent } from './WebKitGestureEvent'; + +/** The camera state and operations {@link useCanvasWheelZoom} drives. */ +export interface CanvasWheelZoomOptions { + containerRef: RefObject; + animationFrameRef: RefObject; + panRef: RefObject<{ x: number; y: number }>; + /** Touch pointers currently down; a touch pinch owns zooming while any are. */ + touchPointersRef: RefObject>; + zoomTowards: (focusX: number, focusY: number, factor: number) => void; + noteGestureActivity: () => void; + scheduleTransformApply: () => void; +} + +/** + * Wheel pan, Ctrl/Cmd+wheel zoom, and Safari's trackpad gesture events, on the canvas and for + * floating content over it, without letting the browser zoom the page. + * @param options The container, camera state and operations. + */ +export const useCanvasWheelZoom = ({ + containerRef, + animationFrameRef, + panRef, + touchPointersRef, + zoomTowards, + noteGestureActivity, + scheduleTransformApply, +}: CanvasWheelZoomOptions) => { + // Wheel: pan (scroll) or zoom (Ctrl+scroll / pinch) + const handleWheel = useCallback( + (e: WheelEvent) => { + const container = containerRef.current; + if (!container) return; + + // A plain scroll/trackpad gesture landing over a scrollable overlay (a chat's message list + // and similar) scrolls that content instead of panning the board underneath it. A zoom + // gesture (ctrl/cmd) always wins — pinch-to-zoom over a chat + // panel is still a zoom, not a captured scroll. + if ( + !(e.ctrlKey || e.metaKey) && + isWithinScrollableContent(e.target, container) + ) + return; + + e.preventDefault(); + + // A fresh gesture always wins over a still-coasting one from the last touch pan. + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + if (e.ctrlKey || e.metaKey) { + zoomTowards(mouseX, mouseY, Math.exp(-e.deltaY * ZOOM_INTENSITY)); + } else { + panRef.current = { + x: panRef.current.x - e.deltaX, + y: panRef.current.y - e.deltaY, + }; + noteGestureActivity(); + scheduleTransformApply(); + } + }, + [ + zoomTowards, + noteGestureActivity, + scheduleTransformApply, + animationFrameRef, + containerRef, + panRef, + ], + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [handleWheel, containerRef]); + + // Safari/WebKit reports a trackpad pinch through its own non-standard gesture events — + // including on iPadOS with a Magic Keyboard trackpad — separately from the wheel+ctrlKey path + // above, which Safari never fires for a trackpad pinch. Left unhandled, Safari's default action + // is to zoom the whole page instead of the canvas — but canceling that alone would swallow the + // pinch entirely, so gesturechange also drives the same zoom-towards-focus the ctrl+wheel path + // applies. A touchscreen pinch fires these gesture events too, alongside the pointer events the + // touch path already zooms from — with touch pointers down, this handler only cancels the page + // zoom and leaves the zooming to the touch path. The events don't exist outside WebKit, so all + // of this is a no-op everywhere else. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let lastScale = 1; + const handleGestureStart = (event: Event) => { + event.preventDefault(); + lastScale = (event as WebKitGestureEvent).scale || 1; + }; + const handleGestureChange = (event: Event) => { + event.preventDefault(); + if (touchPointersRef.current.size > 0) return; + + const gesture = event as WebKitGestureEvent; + if (!gesture.scale || !lastScale) return; + + const rect = container.getBoundingClientRect(); + zoomTowards( + gesture.clientX - rect.left, + gesture.clientY - rect.top, + gesture.scale / lastScale, + ); + lastScale = gesture.scale; + }; + const handleGestureEnd = (event: Event) => event.preventDefault(); + + // Explicitly non-passive: WebKit is free to treat an options-less gesture listener as + // passive, silently ignoring preventDefault() — which would zoom the browser page instead + // of the canvas on an iPad Magic Keyboard trackpad. + container.addEventListener('gesturestart', handleGestureStart, { + passive: false, + }); + container.addEventListener('gesturechange', handleGestureChange, { + passive: false, + }); + container.addEventListener('gestureend', handleGestureEnd, { passive: false }); + return () => { + container.removeEventListener('gesturestart', handleGestureStart); + container.removeEventListener('gesturechange', handleGestureChange); + container.removeEventListener('gestureend', handleGestureEnd); + }; + }, [zoomTowards, containerRef, touchPointersRef]); + + // The two handlers above only ever see events that bubble through the canvas surface's own DOM + // subtree — but a toolbar, panel, or minimap floating over the canvas is frequently portalled to + // document.body (or otherwise rendered as a sibling, not a descendant), so a pinch or Ctrl/Cmd+ + // wheel with the cursor sitting over one of those never reaches the listeners above at all, and + // the browser is free to zoom the whole page instead. Listen at the window level too, and go + // purely by screen position — no matter what element actually receives the + // event, if it lands within the canvas's own rectangle the browser's native zoom is canceled. + // For events whose target sits inside the canvas subtree this only cancels the browser zoom — + // the container's own listeners drive the canvas zoom, so applying it here too would double up. + // For a pinch whose target is a portalled sibling (never reaching the container listeners), it + // also drives the same zoom-towards-focus, so a pinch over a floating panel zooms the board + // instead of being swallowed after the browser zoom is canceled. + useEffect(() => { + const isWithinCanvas = (x: number, y: number) => { + const rect = containerRef.current?.getBoundingClientRect(); + return ( + !!rect && + x >= rect.left && + x <= rect.right && + y >= rect.top && + y <= rect.bottom + ); + }; + const reachesContainerListeners = (event: Event) => + event.target instanceof Node && + !!containerRef.current?.contains(event.target); + const handleWindowWheel = (event: WheelEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + isWithinCanvas(event.clientX, event.clientY) + ) { + event.preventDefault(); + } + }; + + let lastScale = 1; + const handleWindowGestureStart = (event: Event) => { + const gesture = event as WebKitGestureEvent; + if (!isWithinCanvas(gesture.clientX, gesture.clientY)) return; + event.preventDefault(); + lastScale = gesture.scale || 1; + }; + const handleWindowGestureChange = (event: Event) => { + const gesture = event as WebKitGestureEvent; + if (!isWithinCanvas(gesture.clientX, gesture.clientY)) return; + event.preventDefault(); + if (reachesContainerListeners(event)) return; + if (touchPointersRef.current.size > 0) return; + if (!gesture.scale || !lastScale) return; + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + zoomTowards( + gesture.clientX - rect.left, + gesture.clientY - rect.top, + gesture.scale / lastScale, + ); + lastScale = gesture.scale; + }; + const handleWindowGestureEnd = (event: Event) => { + const gesture = event as WebKitGestureEvent; + if (isWithinCanvas(gesture.clientX, gesture.clientY)) event.preventDefault(); + }; + + // Non-passive everywhere: WebKit treating any of these as passive is what lets the page + // zoom through, and window-level listeners are exactly where it is most inclined to. + window.addEventListener('wheel', handleWindowWheel, { passive: false }); + window.addEventListener('gesturestart', handleWindowGestureStart, { + passive: false, + }); + window.addEventListener('gesturechange', handleWindowGestureChange, { + passive: false, + }); + window.addEventListener('gestureend', handleWindowGestureEnd, { passive: false }); + return () => { + window.removeEventListener('wheel', handleWindowWheel); + window.removeEventListener('gesturestart', handleWindowGestureStart); + window.removeEventListener('gesturechange', handleWindowGestureChange); + window.removeEventListener('gestureend', handleWindowGestureEnd); + }; + }, [zoomTowards, containerRef, touchPointersRef]); +}; diff --git a/Source/Canvas/useLatestRef.ts b/Source/Canvas/useLatestRef.ts new file mode 100644 index 00000000..1a9715b2 --- /dev/null +++ b/Source/Canvas/useLatestRef.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef } from 'react'; + +/** + * A ref that follows the latest value, updated after each commit, so long-lived event handlers + * read current callbacks without being re-bound. + * @param value The value to follow. + * @returns The ref. + */ +export const useLatestRef = (value: TValue) => { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }, [value]); + return ref; +}; diff --git a/Source/Canvas/usePixiApplication.ts b/Source/Canvas/usePixiApplication.ts new file mode 100644 index 00000000..3519b235 --- /dev/null +++ b/Source/Canvas/usePixiApplication.ts @@ -0,0 +1,146 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import * as PIXI from 'pixi.js'; +import type { CanvasContext } from './CanvasContext'; + +/** What {@link usePixiApplication} renders into and reports to. */ +export interface PixiApplicationOptions { + /** The element the Pixi canvas is appended to and sized from. */ + containerRef: RefObject; + /** The camera pan the world starts at. */ + panRef: RefObject<{ x: number; y: number }>; + /** The camera zoom the world starts at. */ + zoomRef: RefObject; + /** Receives the application once it is initialized. */ + onReadyRef: RefObject<((context: CanvasContext) => void) | undefined>; + /** The capture marker attribute placed on the Pixi canvas, if any. */ + captureLayer?: string; +} + +/** + * Creates the Pixi application once, keeps it sized to its container, and renders on demand. + * @param options The container, camera and callbacks. + * @returns The application, its world, the item sprites, whether it is ready, and a render function. + */ +export const usePixiApplication = ({ + containerRef, + panRef, + zoomRef, + onReadyRef, + captureLayer, +}: PixiApplicationOptions) => { + const appRef = useRef(null); + const worldRef = useRef(null); + const spritesRef = useRef>(new Map()); + const [pixiReady, setPixiReady] = useState(false); + + const render = useCallback(() => { + const app = appRef.current; + if (!app) return; + // Boards that only use the HTML overlay leave the PIXI world empty — skip the per-frame + // GPU pass entirely rather than clearing and presenting an empty stage on every gesture frame. + if ((worldRef.current?.children.length ?? 0) === 0) return; + // The system ticker is stopped (see the init effect below) — drive pixi's scheduled + // housekeeping (texture GC and friends) from the frames that actually render instead. + PIXI.Ticker.system.update(); + app.renderer.render(app.stage); + }, []); + + // Initialize PIXI once + useEffect(() => { + const container = containerRef.current; + if (!container || appRef.current) return; + + let mounted = true; + + (async () => { + const rect = container.getBoundingClientRect(); + const width = rect.width > 0 ? rect.width : container.clientWidth || 800; + const height = rect.height > 0 ? rect.height : container.clientHeight || 600; + + const app = new PIXI.Application(); + await app.init({ + // Transparent clear: the body carries the surface color and the user-selected + // appearance background, which must show through the canvas like everywhere else. + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + resolution: window.devicePixelRatio || 1, + width, + height, + autoStart: false, + }); + + if (!mounted || !containerRef.current) { + app.destroy(true, { children: true }); + return; + } + + appRef.current = app; + + // World container - all items live here; zoom/pan applied via transform + const world = new PIXI.Container(); + world.position.set(panRef.current.x, panRef.current.y); + world.scale.set(zoomRef.current); + app.stage.addChild(world); + worldRef.current = world; + + const canvas = app.canvas as HTMLCanvasElement; + canvas.style.display = 'block'; + canvas.style.touchAction = 'none'; + containerRef.current.appendChild(canvas); + + setPixiReady(true); + app.renderer.render(app.stage); + + // Initializing a renderer hooks pixi's SchedulerSystem onto the auto-starting system + // ticker, which then runs a requestAnimationFrame loop forever even though nothing here + // renders from a ticker (autoStart is false and every render is explicit) — one of the + // permanent loops that kept an idle canvas busy. Stop it after every init, because each + // new renderer restarts it; render() drives the scheduled + // housekeeping instead. + PIXI.Ticker.system.stop(); + + onReadyRef.current?.({ app, world }); + })(); + + return () => { + mounted = false; + if (appRef.current) { + appRef.current.destroy(true, { children: true }); + appRef.current = null; + worldRef.current = null; + } + spritesRef.current.clear(); + }; + }, [containerRef, onReadyRef, panRef, zoomRef]); // intentional: PIXI init runs exactly once + + // Products with a capture/compositor pipeline can mark the Pixi canvas without making that + // provider a Components dependency. Keep the marker synchronized if product configuration changes. + useEffect(() => { + const canvas = appRef.current?.canvas as HTMLCanvasElement | undefined; + const attribute = captureLayer; + if (!canvas || !attribute) return; + canvas.setAttribute(attribute, 'true'); + return () => canvas.removeAttribute(attribute); + }, [pixiReady, captureLayer]); + + // Handle container resize + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + const app = appRef.current; + if (!app) return; + app.renderer.resize(container.clientWidth, container.clientHeight); + render(); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [render, containerRef]); + + return { appRef, worldRef, spritesRef, pixiReady, render }; +}; diff --git a/Source/Canvas/usePixiItems.ts b/Source/Canvas/usePixiItems.ts new file mode 100644 index 00000000..a9da61a7 --- /dev/null +++ b/Source/Canvas/usePixiItems.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, type RefObject } from 'react'; +import * as PIXI from 'pixi.js'; +import type { CanvasItemData } from './CanvasItemData'; + +/** The Pixi world and the items {@link usePixiItems} keeps in sync. */ +export interface PixiItemsOptions { + pixiReady: boolean; + appRef: RefObject; + worldRef: RefObject; + spritesRef: RefObject>; + items: T[]; + renderItem?: (item: T) => PIXI.Container; + onItemPointerDownRef: RefObject< + ((item: T, event: PIXI.FederatedPointerEvent) => void) | undefined + >; +} + +/** + * Adds, moves and removes the Pixi containers for the items, rendering once after each change. + * @param options The world, sprites and items. + */ +export const usePixiItems = ({ + pixiReady, + appRef, + worldRef, + spritesRef, + items, + renderItem, + onItemPointerDownRef, +}: PixiItemsOptions) => { + // Sync items → PIXI containers + useEffect(() => { + const world = worldRef.current; + if (!pixiReady || !world || !renderItem) return; + + const incoming = new Map(items.map((item) => [item.id, item])); + + // Remove deleted items + spritesRef.current.forEach((container, id) => { + if (!incoming.has(id)) { + world.removeChild(container); + container.destroy({ children: true }); + spritesRef.current.delete(id); + } + }); + + // Add new items; update positions of existing ones + items.forEach((item) => { + const existing = spritesRef.current.get(item.id); + if (existing) { + existing.position.set(item.x, item.y); + // Refresh handler so it always references the latest item object + existing.removeAllListeners('pointerdown'); + existing.on('pointerdown', (event: PIXI.FederatedPointerEvent) => { + onItemPointerDownRef.current?.(item, event); + }); + } else { + const container = renderItem(item); + container.position.set(item.x, item.y); + container.eventMode = 'static'; + container.on('pointerdown', (event: PIXI.FederatedPointerEvent) => { + onItemPointerDownRef.current?.(item, event); + }); + world.addChild(container); + spritesRef.current.set(item.id, container); + } + }); + + // Render directly (not via the empty-world-skipping render()) so removing the last item + // still clears its pixels from the canvas. + appRef.current?.renderer.render(appRef.current.stage); + }, [ + pixiReady, + items, + renderItem, + appRef, + onItemPointerDownRef, + spritesRef, + worldRef, + ]); +}; diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index c7205208..fcd6dd53 100644 --- a/Source/Chat/ChatSidebar.tsx +++ b/Source/Chat/ChatSidebar.tsx @@ -21,6 +21,7 @@ import { useCratisIcon } from '../configuration/useCratisIcon'; import { unstable_useOverlayEnvironment } from '../renderer/RendererContext'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; type ChatSidebarPartAttributes = HTMLAttributes & { [attribute: `data-${string}`]: string | number | boolean | undefined; @@ -201,9 +202,6 @@ export interface ChatSidebarProps< pt?: ChatSidebarParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - const BackIcon = () => ( ) => +export const classNames = (...values: Array) => values.filter(Boolean).join(' '); diff --git a/Source/Common/Breadcrumbs.tsx b/Source/Common/Breadcrumbs.tsx index 099c85fa..9341e359 100644 --- a/Source/Common/Breadcrumbs.tsx +++ b/Source/Common/Breadcrumbs.tsx @@ -9,6 +9,7 @@ import { } from 'react-aria-components/Breadcrumbs'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Attributes a consumer may hand to one {@link Breadcrumbs} part: class, style, title and data attributes. */ export type BreadcrumbsPartAttributes = Pick< @@ -60,9 +61,6 @@ export interface BreadcrumbsProps { pt?: BreadcrumbsParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** * A breadcrumb trail. * diff --git a/Source/Common/ComboBox.tsx b/Source/Common/ComboBox.tsx index 336e2afe..0d1f8217 100644 --- a/Source/Common/ComboBox.tsx +++ b/Source/Common/ComboBox.tsx @@ -31,6 +31,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from './reactAriaProps'; +import { classNames } from '../ClassNames/classNames'; /** Attributes a consumer may hand to one {@link ComboBox} part: class, style, title and data attributes. */ export type ComboBoxPartAttributes = Pick< @@ -166,9 +167,6 @@ const partAttributes = (part: ComboBoxPartAttributes | undefined) => ({ style: part?.style as CSSProperties | undefined, }); -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - const matches = (option: ComboBoxOption, text: string, filter: ComboBoxFilter) => { if (filter === 'none' || text === '') return true; const haystack = option.label.toLocaleLowerCase(); diff --git a/Source/Common/DatePickerInputImplementation.tsx b/Source/Common/DatePickerInputImplementation.tsx index 2b8a4061..71a30cca 100644 --- a/Source/Common/DatePickerInputImplementation.tsx +++ b/Source/Common/DatePickerInputImplementation.tsx @@ -33,9 +33,7 @@ import { toCalendarDate, } from '@internationalized/date'; import type { DatePickerInputProps } from './DatePickerInput'; - -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); +import { classNames } from '../ClassNames/classNames'; const asDateValue = ( value: Date | null, diff --git a/Source/Common/NumberInput.tsx b/Source/Common/NumberInput.tsx index a5d2a015..8f53a830 100644 --- a/Source/Common/NumberInput.tsx +++ b/Source/Common/NumberInput.tsx @@ -20,6 +20,7 @@ import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; import { NumberInputCommitReason } from './NumberInputCommitReason'; import type { NumberInputPartAttributes } from './NumberInputPartAttributes'; +import { classNames } from '../ClassNames/classNames'; /** Stable Cratis-owned parts for styling a {@link NumberInput}. */ export interface NumberInputParts { @@ -108,9 +109,6 @@ export interface NumberInputProps { pt?: NumberInputParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - const semanticValue = (value: number | null): number | null => value !== null && Number.isFinite(value) ? value : null; diff --git a/Source/Common/Tabs.tsx b/Source/Common/Tabs.tsx index a12b260e..b9fc7a66 100644 --- a/Source/Common/Tabs.tsx +++ b/Source/Common/Tabs.tsx @@ -10,6 +10,7 @@ import { } from 'react-aria-components/Tabs'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Attributes a consumer may hand to one {@link Tabs} part: class, style, title and data attributes. */ export type TabsPartAttributes = Pick< @@ -66,9 +67,6 @@ export interface TabsProps { pt?: TabsParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** * A tab set: a list of tabs, each optionally revealing a panel. * diff --git a/Source/Common/TagGroup.tsx b/Source/Common/TagGroup.tsx index 09c9a989..8c6a7355 100644 --- a/Source/Common/TagGroup.tsx +++ b/Source/Common/TagGroup.tsx @@ -18,6 +18,7 @@ import { Button as AriaButton } from 'react-aria-components/Button'; import { useCratisIcon } from '../configuration/useCratisIcon'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Attributes a consumer may hand to one {@link TagGroup} part: class, style, title and data attributes. */ export type TagGroupPartAttributes = Pick< @@ -90,9 +91,6 @@ export interface TagGroupProps { pt?: TagGroupParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** * A group of removable values with an optional text entry — the control behind a tag or chips field. * diff --git a/Source/Common/ToggleGroup.tsx b/Source/Common/ToggleGroup.tsx index ebd1c47e..dab6d607 100644 --- a/Source/Common/ToggleGroup.tsx +++ b/Source/Common/ToggleGroup.tsx @@ -8,6 +8,7 @@ import { } from 'react-aria-components/ToggleButtonGroup'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Attributes a consumer may hand to one {@link ToggleGroup} part: class, style, title and data attributes. */ export type ToggleGroupPartAttributes = Pick< @@ -74,9 +75,6 @@ export interface ToggleGroupProps { pt?: ToggleGroupParts; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** * A single-selection group of exclusive choices — a segmented control. * diff --git a/Source/DataTables/ColumnFilterMenu.tsx b/Source/DataTables/ColumnFilterMenu.tsx index 4722713b..090ab562 100644 --- a/Source/DataTables/ColumnFilterMenu.tsx +++ b/Source/DataTables/ColumnFilterMenu.tsx @@ -27,6 +27,7 @@ import { type DataTableFilterConstraint, type DataTableFilterMatchMode as FilterMatchMode, } from './DataTableFilterMeta'; +import { classNames } from '../ClassNames/classNames'; /** Value editor used by a built-in column filter. */ export type ColumnFilterDataType = 'text' | 'numeric' | 'date' | 'boolean'; @@ -192,9 +193,6 @@ const optionsFor = (dataType: ColumnFilterDataType): MatchModeOption[] => { } }; -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - const defaultModeFor = (dataType: ColumnFilterDataType): FilterMatchMode => { if (dataType === 'text') return DataTableFilterMatchMode.Contains; if (dataType === 'date') return DataTableFilterMatchMode.DateIs; diff --git a/Source/DataTables/DataTableBody.tsx b/Source/DataTables/DataTableBody.tsx index 840817a2..35d52f87 100644 --- a/Source/DataTables/DataTableBody.tsx +++ b/Source/DataTables/DataTableBody.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ReactNode } from 'react'; -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableMessages } from './DataTableMessages'; import type { DataTableParts } from './DataTableParts'; import { DataTableStatus } from './DataTableStatus'; diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index 62f063d0..355034cb 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -4,7 +4,7 @@ import React, { useId, useMemo, type CSSProperties, type ReactNode } from 'react'; import { useCratisIcon } from '../configuration/useCratisIcon'; import type { ColumnProps } from './Column'; -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableFilterMeta } from './DataTableFilterMeta'; import { DataTableHeaderCell } from './DataTableHeaderCell'; import type { DataTableParts } from './DataTableParts'; diff --git a/Source/DataTables/DataTableHeaderCell.tsx b/Source/DataTables/DataTableHeaderCell.tsx index d22ef243..4fa26abd 100644 --- a/Source/DataTables/DataTableHeaderCell.tsx +++ b/Source/DataTables/DataTableHeaderCell.tsx @@ -4,7 +4,7 @@ import type { ReactElement, ReactNode, RefObject } from 'react'; import type { ColumnProps } from './Column'; import { ColumnFilterMenu } from './ColumnFilterMenu'; -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableFilterConstraint, DataTableFilterMeta, diff --git a/Source/DataTables/DataTableRow.tsx b/Source/DataTables/DataTableRow.tsx index 183c39e0..b1c2464a 100644 --- a/Source/DataTables/DataTableRow.tsx +++ b/Source/DataTables/DataTableRow.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ReactNode, SyntheticEvent } from 'react'; import type { ColumnProps } from './Column'; -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableParts } from './DataTableParts'; import { valueAtPath } from './valueAtPath'; diff --git a/Source/DataTables/DataTableSearch.tsx b/Source/DataTables/DataTableSearch.tsx index 688c2efb..ae2e4365 100644 --- a/Source/DataTables/DataTableSearch.tsx +++ b/Source/DataTables/DataTableSearch.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableParts } from './DataTableParts'; /** Props for {@link DataTableSearch}. */ diff --git a/Source/Dialogs/DialogImplementation.tsx b/Source/Dialogs/DialogImplementation.tsx index f30cc27a..672cb242 100644 --- a/Source/Dialogs/DialogImplementation.tsx +++ b/Source/Dialogs/DialogImplementation.tsx @@ -18,9 +18,7 @@ import { openDialogTier, } from '../renderer/dialogStack'; import { DialogStackContext } from '../renderer/DialogStackContext'; - -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); +import { classNames } from '../ClassNames/classNames'; const subscribeToBrowserEnvironment = () => () => undefined; const useIsBrowser = () => diff --git a/Source/Dropdown/DropdownImplementation.tsx b/Source/Dropdown/DropdownImplementation.tsx index f3e19a4e..4133c9f8 100644 --- a/Source/Dropdown/DropdownImplementation.tsx +++ b/Source/Dropdown/DropdownImplementation.tsx @@ -34,6 +34,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from '../Common/reactAriaProps'; +import { classNames } from '../ClassNames/classNames'; type DropdownOptionValue = string | number | boolean | bigint | symbol | object | null | undefined; @@ -90,9 +91,6 @@ const resolveOptions = ( }; }); -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** The part of the ComboBox lifecycle a commit has to reach, from outside the ComboBox subtree. */ interface CommittableComboBox { close: () => void; diff --git a/Source/Notifications/Toaster.tsx b/Source/Notifications/Toaster.tsx index fd45063f..60d88f12 100644 --- a/Source/Notifications/Toaster.tsx +++ b/Source/Notifications/Toaster.tsx @@ -20,6 +20,7 @@ import { } from './toast'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Supported viewport positions for the global toast region. */ export type ToasterPosition = @@ -83,8 +84,6 @@ const severitySymbol: Record = { }; const emptyToasts: ToastRecord[] = []; -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); interface ToastFrameProps { item: ToastRecord; diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 0ecb11af..dda7a79b 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -1,879 +1,79 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { useEffect, useMemo, useRef, useState } from 'react'; -import * as PIXI from 'pixi.js'; -import type { ItemId, LayoutResult, GroupingResult } from '../engine/types'; -import type { ViewMode } from './Toolbar'; -import type { PivotViewerColors } from '../types'; -import { - createCssColorResolver, - observeColorEnvironment, - resolveCardColors, -} from './pivot/colorResolver'; -import { - createCardSprite as createCardSpriteExternal, - updateCardContent as updateCardContentExternal, - clearSpritePool, -} from './pivot/sprites'; -import { syncSpritesToViewport } from './pivot/visibility'; -import { syncScrollSprites } from './pivot/syncScrollSprites'; -import { - updateGroupBackgrounds as updateGroupBackgroundsExternal, - updateHighlight as updateHighlightExternal, -} from './pivot/groups'; -import { - startAnimationLoop as startAnimationLoopExternal, - updatePositions as updatePositionsExternal, -} from './pivot/animation'; -import { - ANIMATION_SPEED, - DEFAULT_COLORS, - type CardSprite, - type CardColors, -} from './pivot/constants'; - -export interface PivotCanvasProps { - /** Original items array */ - items: TItem[]; - - /** Layout positions */ - layout: LayoutResult; - - /** Grouping information */ - grouping: GroupingResult; - - /** Visible item IDs */ - visibleIds: Uint32Array; - - /** Card dimensions */ - cardWidth: number; - cardHeight: number; - - /** Zoom level */ - zoomLevel: number; - - /** Pan offset */ - panX: number; - panY: number; - - /** Viewport dimensions (visible area) */ - viewportWidth: number; - viewportHeight: number; - - /** Selected item ID */ - selectedId: ItemId | null; - - /** Hovered group index */ - hoveredGroupIndex: number | null; - - /** Current view mode */ - viewMode: ViewMode; - - /** Is zooming animation in progress */ - isZooming?: boolean; - - /** Card renderer function - returns structured data for display */ - cardRenderer: (item: TItem) => { - title: string; - labels?: string[]; - values?: string[]; - }; - - /** Public color overrides; changes trigger a Pixi color refresh. */ - colorOverrides?: Partial; - - /** ID resolver */ - resolveId: (item: TItem, index: number) => string | number; - - /** Click handler */ - onCardClick: (item: TItem, e: MouseEvent, id: number | string) => void; - - /** Pan handlers */ - onPanStart: (e: React.MouseEvent) => void; - onPanMove: (e: React.MouseEvent) => void; - onPanEnd: () => void; - containerRef: React.RefObject; -} - -// `CardSprite` type moved to ./pivot/constants and imported above - -// constants and CardColors type moved to ./pivot/constants - -const asReactMouseEvent = (event: unknown): React.MouseEvent => { - // SAFETY: Pixi exposes the originating browser pointer event. PivotViewer's pan - // callbacks consume only coordinates/modifiers shared with React.MouseEvent. - return event as React.MouseEvent; -}; - -export function PivotCanvas({ - items, - layout, - grouping, - visibleIds, - cardWidth, - cardHeight, - zoomLevel, - panX, - panY, - viewportWidth, - viewportHeight, - selectedId, - hoveredGroupIndex, - isZooming: _isZooming = false, - resolveId: _resolveId, - onCardClick, - onPanStart, - onPanMove, - onPanEnd, - viewMode, - cardRenderer, - colorOverrides, - containerRef, -}: PivotCanvasProps) { - // Use the containerRef passed from the parent viewport so we append the Pixi - // canvas and spacer into the actual scrollable element. - const parentContainerRef = containerRef; - // Mark intentionally-unused destructured props as used to satisfy lint - void _isZooming; - const resolveId = _resolveId; - const canvasRef = useRef(null); - const spacerRef = useRef(null); - const appRef = useRef(null); - const rootRef = useRef(null); - const groupsContainerRef = useRef(null); - const spritesRef = useRef>(new Map()); - const animationFrameRef = useRef(0); - const mountedRef = useRef(true); - const [pixiReady, setPixiReady] = useState(false); - const [colorRevision, setColorRevision] = useState(0); - const isAnimatingRef = useRef(false); +import { useEffect, useRef } from 'react'; +import type { PivotCanvasProps } from './PivotCanvasProps'; +import { usePivotCardColors } from './pivot/usePivotCardColors'; +import { usePivotGroupBackgrounds } from './pivot/usePivotGroupBackgrounds'; +import { usePivotScrollSync } from './pivot/usePivotScrollSync'; +import { usePivotSelectionHighlight } from './pivot/usePivotSelectionHighlight'; +import { usePivotSpriteSync } from './pivot/usePivotSpriteSync'; +import { usePivotStage } from './pivot/usePivotStage'; +import { usePivotTransitionState } from './pivot/usePivotTransitionState'; + +export type { PivotCanvasProps } from './PivotCanvasProps'; + +/** + * Renders PivotViewer's cards and group backgrounds with Pixi into the parent viewport. The canvas + * overlays the scrollable viewport, which keeps native scrolling; this component renders no DOM. + */ +export function PivotCanvas(props: PivotCanvasProps) { + const { + panX, + panY, + viewportWidth, + viewportHeight, + viewMode, + colorOverrides, + onPanStart, + onPanMove, + onPanEnd, + onCardClick, + } = props; + // The Pixi canvas and the scroll spacer go into the parent's scrollable viewport element. + const parentContainerRef = props.containerRef; const needsRenderRef = useRef(false); - const initializingRef = useRef(false); - const isViewTransitionRef = useRef(false); - const lastViewChangeTimeRef = useRef(0); - const previousViewModeRef = useRef(viewMode); - const prevLayoutRef = useRef(null); - const transitionLayoutRef = useRef(null); - const transitionSeenIdsRef = useRef>(new Set()); - const prevGroupingRef = useRef(null); - const prevScrollTopRef = useRef(0); - const prevScrollLeftRef = useRef(0); - const cardColorsRef = useRef(DEFAULT_COLORS); - - const cssColorResolver = useMemo(() => createCssColorResolver(), []); + const transition = usePivotTransitionState(viewMode, { x: panX, y: panY }); const onPanStartRef = useRef(onPanStart); const onPanMoveRef = useRef(onPanMove); const onPanEndRef = useRef(onPanEnd); const onCardClickRef = useRef(onCardClick); - const prevPanRef = useRef({ x: panX, y: panY }); - - // Initialize Pixi Application useEffect(() => { - // ... existing code ... + onPanStartRef.current = onPanStart; onPanMoveRef.current = onPanMove; onPanEndRef.current = onPanEnd; onCardClickRef.current = onCardClick; }, [onPanStart, onPanMove, onPanEnd, onCardClick]); - useEffect(() => { - const element = parentContainerRef.current; - if (!element) return; - - const refreshColors = () => { - cardColorsRef.current = resolveCardColors(cssColorResolver, element); - needsRenderRef.current = true; - setColorRevision((revision) => revision + 1); - }; - - refreshColors(); - return observeColorEnvironment(element, refreshColors); - }, [cssColorResolver, parentContainerRef, colorOverrides]); - - useEffect(() => { - // Reset mounted flag - mountedRef.current = true; - - if (!parentContainerRef || !parentContainerRef.current) { - return; - } - - // Prevent multiple simultaneous initializations - if (initializingRef.current || appRef.current) { - return; - } - - initializingRef.current = true; - let app: PIXI.Application | null = null; - // Handler references declared here so cleanup can remove them later. - - (async () => { - try { - const options = { - backgroundAlpha: 0, - antialias: false, - autoStart: false, - autoDensity: true, - resolution: window.devicePixelRatio || 1, - width: viewportWidth > 0 ? viewportWidth : 800, - height: viewportHeight > 0 ? viewportHeight : 600, - } as PIXI.ApplicationOptions; - - app = new PIXI.Application(); - await app.init(options); - - if (!mountedRef.current || !parentContainerRef.current) { - // Component unmounted during initialization - if (app && typeof app.destroy === 'function') - app.destroy(true, { children: true }); - initializingRef.current = false; - return; - } - - appRef.current = app; - - const groupsContainer = new PIXI.Container(); - groupsContainerRef.current = groupsContainer; - app.stage.addChild(groupsContainer); - - const root = new PIXI.Container(); - rootRef.current = root; - app.stage.addChild(root); - - const canvasEl = app.canvas; - - // Place canvas outside the scrollable content so native scrolling - // doesn't move the canvas DOM element itself. We overlay the canvas - // on top of the scroll area by inserting it into the parent element - // (or the container itself if parent is not available). This ensures - // the Pixi canvas remains stable while we move the Pixi world inside - // it to represent camera pan. - const overlayParent = - parentContainerRef.current.parentElement ?? - parentContainerRef.current; - - if (canvasEl) { - if (canvasEl.parentElement) { - canvasEl.parentElement.removeChild(canvasEl); - } - overlayParent.appendChild(canvasEl); - canvasRef.current = canvasEl; - } else { - console.error( - 'PivotCanvas: Could not find canvas element from Pixi application', - ); - } - - // Position the canvas to overlay the scrollable container area. - if (canvasRef.current && parentContainerRef.current) { - const parentBounds = - parentContainerRef.current.getBoundingClientRect(); - void parentBounds; - canvasRef.current.style.position = 'absolute'; - // Place canvas relative to the overlayParent's coordinate space. - // If overlayParent is the immediate parent, top/left 0 aligns it. - const offsetLeft = parentContainerRef.current.offsetLeft; - const offsetTop = parentContainerRef.current.offsetTop; - canvasRef.current.style.left = `${offsetLeft}px`; - canvasRef.current.style.top = `${offsetTop}px`; - canvasRef.current.style.width = `${parentContainerRef.current.clientWidth}px`; - canvasRef.current.style.height = `${parentContainerRef.current.clientHeight}px`; - // Place canvas behind the scrollable container (which has z-index 1) - // so scrollbars appear on top. - canvasRef.current.style.zIndex = '0'; - // Disable pointer events on canvas so they pass through to the viewport if needed, - // though viewport is on top anyway. - canvasRef.current.style.pointerEvents = 'none'; - } - - // We handle clicks and interactions manually in PivotViewerMain now, - // so we don't need to configure Pixi events on the container. - // This avoids z-index conflicts and event propagation issues. - - // Make canvas fill container with absolute positioning - if (canvasRef.current) { - canvasRef.current.style.display = 'block'; - // Ensure canvas does not capture events so they pass through to the viewport - canvasRef.current.style.pointerEvents = 'none'; - } - - // Setup stage events for background panning - app.stage.eventMode = 'static'; - app.stage.hitArea = new PIXI.Rectangle( - 0, - 0, - viewportWidth, - viewportHeight, - ); - - app.stage.on('pointerdown', (e) => { - // Only handle if it reached the stage (background) - // Sprites stop propagation, so this is safe. - onPanStartRef.current(asReactMouseEvent(e.nativeEvent)); - }); - - app.stage.on('globalpointermove', (e) => { - onPanMoveRef.current(asReactMouseEvent(e.nativeEvent)); - }); - - app.stage.on('globalpointerup', () => { - onPanEndRef.current(); - }); - - // We no longer need manual event listeners on parentEl because Pixi - // is now listening to events on parentEl directly via setTargetElement. - // This allows Pixi to handle hit testing through the transparent container. - const parentEl = parentContainerRef.current; - if (parentEl) { - // handleMouseDown = (e: Event) => onPanStartRef.current(e as unknown); - // handleMouseMove = (e: Event) => onPanMoveRef.current(e as unknown); - // handleMouseUp = () => onPanEndRef.current(); - // parentEl.addEventListener('mousedown', handleMouseDown); - // parentEl.addEventListener('mousemove', handleMouseMove); - // parentEl.addEventListener('mouseup', handleMouseUp); - // parentEl.addEventListener('mouseleave', handleMouseUp); - // window.addEventListener('mouseup', handleMouseUp); - // window.addEventListener('pointerup', handleMouseUp); - } - - // Immediately size to container to avoid delay - if (viewportWidth > 0 && viewportHeight > 0) { - app.renderer?.resize(viewportWidth, viewportHeight); - } - - setPixiReady(true); - initializingRef.current = false; - - // Trigger initial render - needsRenderRef.current = true; - app.renderer?.render(app.stage); - } catch (error) { - console.error('Failed to initialize Pixi.js:', error); - initializingRef.current = false; - } - })(); - - return () => { - mountedRef.current = false; - setPixiReady(false); - cancelAnimationFrame(animationFrameRef.current); - - if (appRef.current && typeof appRef.current.destroy === 'function') { - appRef.current.destroy(true, { children: true }); - appRef.current = null; - rootRef.current = null; - } - - // Pixi destroys the application's children. Discard detached, pooled - // sprites too so a later application cannot reuse their stale textures. - clearSpritePool(); - spritesRef.current.clear(); - - // Remove any event listeners we attached to the parent container - try { - const parentEl = parentContainerRef.current; - if (parentEl) { - // if (handleMouseDown) parentEl.removeEventListener('mousedown', handleMouseDown); - // if (handleMouseMove) parentEl.removeEventListener('mousemove', handleMouseMove); - // if (handleMouseUp) parentEl.removeEventListener('mouseup', handleMouseUp); - // if (handleMouseUp) parentEl.removeEventListener('mouseleave', handleMouseUp); - // if (handleMouseUp) { - // window.removeEventListener('mouseup', handleMouseUp); - // window.removeEventListener('pointerup', handleMouseUp); - // } - } - } catch (e) { - void e; - } - // Remove DOM nodes we appended - try { - if (canvasRef.current && canvasRef.current.parentElement) { - canvasRef.current.parentElement.removeChild(canvasRef.current); - } - } catch (e) { - void e; - } - }; - }, []); // Only initialize once - resizing handled by separate useEffect - - // Handle canvas resize - useEffect(() => { - if ( - !parentContainerRef || - !parentContainerRef.current || - !appRef.current || - !pixiReady - ) - return; - - const container = parentContainerRef.current; - const app = appRef.current; - - let resizeTimeout: ReturnType; - - const handleResize = () => { - // Size canvas to viewport dimensions from props - if (viewportWidth > 0 && viewportHeight > 0) { - app.renderer?.resize(viewportWidth, viewportHeight); - app.stage.hitArea = new PIXI.Rectangle( - 0, - 0, - viewportWidth, - viewportHeight, - ); - - // Keep canvas DOM size in sync with container - if (canvasRef.current && parentContainerRef.current) { - canvasRef.current.style.width = `${parentContainerRef.current.clientWidth}px`; - canvasRef.current.style.height = `${parentContainerRef.current.clientHeight}px`; - // Also update left/top in case the container moved - canvasRef.current.style.left = `${parentContainerRef.current.offsetLeft}px`; - canvasRef.current.style.top = `${parentContainerRef.current.offsetTop}px`; - } - } - }; - - const debouncedResize = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(handleResize, 150); - }; - - // Initial resize (immediate) - handleResize(); - - // Watch for size changes (debounced) - const resizeObserver = new ResizeObserver(debouncedResize); - resizeObserver.observe(container); - - return () => { - clearTimeout(resizeTimeout); - resizeObserver.disconnect(); - }; - }, [pixiReady, viewportWidth, viewportHeight]); - - // Update group backgrounds only when layout/grouping changes - useEffect(() => { - if (!groupsContainerRef.current || !parentContainerRef.current || !pixiReady) - return; - updateGroupBackgroundsExternal( - groupsContainerRef.current, - parentContainerRef.current, - grouping, - layout, - zoomLevel, - cardColorsRef.current, - viewMode, - ); - needsRenderRef.current = true; - appRef.current?.renderer?.render(appRef.current.stage); - }, [grouping, layout, zoomLevel, viewMode, pixiReady, colorOverrides, colorRevision]); - - // Fade buckets background when switching view modes - useEffect(() => { - const gc = groupsContainerRef.current; - const app = appRef.current; - if (!gc || !app) return; - const target = viewMode === 'grouped' ? 1 : 0; - const start = typeof gc.alpha === 'number' ? gc.alpha : 1; - const duration = 200; // ms - if (Math.abs(start - target) < 0.01) { - gc.alpha = target; - return; - } - const t0 = performance.now(); - const step = () => { - const u = Math.min(1, (performance.now() - t0) / duration); - const eased = u * (2 - u); - gc.alpha = start + (target - start) * eased; - app.renderer?.render(app.stage); - if (u < 1) requestAnimationFrame(step); - }; - requestAnimationFrame(step); - }, [viewMode]); - - useEffect(() => { - if (!rootRef.current || !parentContainerRef.current || !pixiReady) { - return; - } - - // Check if this is a view mode change (not just pan/scroll) - const viewModeChanged = previousViewModeRef.current !== viewMode; - const groupingChanged = prevGroupingRef.current !== grouping; - const layoutChanged = prevLayoutRef.current !== layout; - - if (viewModeChanged || groupingChanged || layoutChanged) { - transitionSeenIdsRef.current.clear(); - if (layoutChanged) transitionLayoutRef.current = prevLayoutRef.current; - isViewTransitionRef.current = true; - lastViewChangeTimeRef.current = Date.now(); - previousViewModeRef.current = viewMode; - prevGroupingRef.current = grouping; - - // Don't hide sprites here - let visibility.ts handle the transition - // The syncSpritesToViewport function will properly animate sprites to new positions - // during view transitions (isViewTransitionRef.current = true), and visibility.ts - // will handle cleanup of sprites that no longer have positions in the layout. - // Previously, hiding sprites here caused sorting/transitions to not work because - // sprites were destroyed before they could animate. - } - - // Update spacer dimensions to match scaled world size - if (spacerRef.current) { - const spacer = spacerRef.current; - const worldWidth = (layout.totalWidth || viewportWidth) * zoomLevel; - const worldHeight = (layout.totalHeight || viewportHeight) * zoomLevel; - spacer.style.width = `${Math.max(worldWidth, viewportWidth)}px`; - spacer.style.height = `${Math.max(worldHeight, viewportHeight)}px`; - } - - // Ensure scroll spacer matches layout so the container becomes scrollable and - // native scrollLeft/scrollTop reflect the camera position. - if (parentContainerRef.current) { - const spacer = spacerRef.current; - if (spacer) { - // Debug: log spacer and layout values to detect mismatches - } - } - - const panDeltaX = panX - prevPanRef.current.x; - const panDeltaY = panY - prevPanRef.current.y; - prevPanRef.current = { x: panX, y: panY }; - - // Sync sprites into viewport and create/remove as needed - // Provide wrappers for sprite creation and content update so helpers have required context - const currentScrollTop = parentContainerRef.current?.scrollTop || 0; - const currentScrollLeft = parentContainerRef.current?.scrollLeft || 0; - - const syncParams = { - root: rootRef.current, - groupsContainer: groupsContainerRef.current, - container: parentContainerRef.current, - sprites: spritesRef.current, - layout, - visibleIds, - items, - cardWidth, - cardHeight, - panX, - panY, - panDeltaX, - panDeltaY, - zoomLevel, - viewportWidth, - viewportHeight, - viewMode, - createCardSprite: (id: string | number, x: number, y: number) => - createCardSpriteExternal( - id, - x, - y, - items as TItem[], - (item: TItem, e: MouseEvent, id: string | number) => - onCardClickRef.current(item, e, id), - (e: MouseEvent) => onPanStart(asReactMouseEvent(e)), - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - resolveId, - ), - updateCardContent: (sprite: CardSprite, item: TItem) => - updateCardContentExternal( - sprite, - item, - selectedId, - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - ), - isViewTransition: isViewTransitionRef.current, - prevLayout: prevLayoutRef.current, - transitionSeenIds: transitionSeenIdsRef.current, - prevScrollTop: prevScrollTopRef.current, - prevScrollLeft: prevScrollLeftRef.current, - }; - syncSpritesToViewport(syncParams); - - // Update previous scroll position for next frame - prevScrollTopRef.current = currentScrollTop; - prevScrollLeftRef.current = currentScrollLeft; - needsRenderRef.current = true; - - // Force an immediate render after syncing sprites to ensure cards appear - if (appRef.current?.renderer && rootRef.current) { - appRef.current.renderer.render(appRef.current.stage); - needsRenderRef.current = false; - } - - startAnimationLoopExternal({ - mountedRef, - appRef, - animationFrameRef, - isAnimatingRef, - needsRenderRef, - spritesRef, - isViewTransitionRef, - syncVisibility: () => syncSpritesToViewport({ ...syncParams, isViewTransition: false, sweepImmediately: true }), - onTransitionComplete: () => transitionSeenIdsRef.current.clear(), - }); - }, [ - layout, - visibleIds, - items, - cardWidth, - cardHeight, - pixiReady, - zoomLevel, - panX, - panY, - grouping, - viewMode, + const { cardColorsRef, colorRevision } = usePivotCardColors( + parentContainerRef, colorOverrides, - colorRevision, - ]); - - // Update prevLayoutRef after processing layout changes - useEffect(() => { - prevLayoutRef.current = layout; - }, [layout]); - - // Duplicate camera position effect removed -- syncSpritesToViewport handles this with correct offsetY logic logic - /* - useEffect(() => { - if (!rootRef.current || !groupsContainerRef.current) return; - - // Camera transform: move world opposite to camera position. Prefer the - // native container scroll positions where available (they are authoritative - // during user scrolls) and fall back to the passed pan props. - const effectivePanX = parentContainerRef.current ? parentContainerRef.current.scrollLeft : panX; - const effectivePanY = parentContainerRef.current ? parentContainerRef.current.scrollTop : panY; - - // Apply zoom and position to root and groups. - if (rootRef.current.scale && groupsContainerRef.current.scale) { - rootRef.current.scale.set(zoomLevel); - groupsContainerRef.current.scale.set(zoomLevel); - } - if (rootRef.current.position && groupsContainerRef.current.position) { - rootRef.current.position.set(-effectivePanX, -effectivePanY); - groupsContainerRef.current.position.set(-effectivePanX, -effectivePanY); - } - appRef.current?.renderer?.render(appRef.current.stage); - }, [zoomLevel, panX, panY]); - */ - - useEffect(() => { - if (!rootRef.current) return; - updateSelection(); - needsRenderRef.current = true; - appRef.current?.renderer.render(appRef.current.stage); - }, [selectedId, items]); - - useEffect(() => { - if (!rootRef.current) return; - updateHighlight(); - needsRenderRef.current = true; - appRef.current?.renderer.render(appRef.current.stage); - }, [hoveredGroupIndex, layout, grouping]); - - // Note: animation loop and group background updates are delegated to - // external helpers (`startAnimationLoopExternal` and - // `updateGroupBackgroundsExternal`) and invoked where needed. We don't - // expose local wrappers to avoid unused-function lint warnings. - - // Listen to native scroll events on the parent container so we update the - // Pixi world immediately when the user scrolls (native scrollbar or - // programmatic). This ensures `syncSpritesToViewport` runs on scroll and - // creates/destroys sprites as the viewport moves. - useEffect(() => { - if ( - !pixiReady || - !parentContainerRef || - !parentContainerRef.current || - !appRef.current || - !rootRef.current - ) - return; - - const container = parentContainerRef.current; - const app = appRef.current; - - // rAF-batched scroll handling: store the latest scroll values and process - // them once per animation frame to avoid heavy synchronous work inside - // the scroll event which causes jank and de-synchronisation between the - // compositor and Pixi render updates. - const lastScroll = { x: container.scrollLeft, y: container.scrollTop }; - const pendingRef = { scheduled: false } as { scheduled: boolean }; - - const processScroll = () => { - pendingRef.scheduled = false; - try { - // Read directly from container to ensure consistency with visibility logic - // and to handle cases where scroll changes without event (e.g. resize clamping) - const effectivePanX = container.scrollLeft; - const effectivePanY = container.scrollTop; - - // Update lastScroll to keep it in sync - lastScroll.x = effectivePanX; - lastScroll.y = effectivePanY; - - // Note: We delegate root/groups container positioning to syncSpritesToViewport - // because it encapsulates the logic for conditional vertical alignment (offsetY) - // in different view modes. Manually setting position here would overwrite that logic. - - syncScrollSprites({ - root: rootRef.current, - groupsContainer: groupsContainerRef.current, - container: parentContainerRef.current, - sprites: spritesRef.current, - layout, - visibleIds, - items, - cardWidth, - cardHeight, - panX, - panY, - zoomLevel, - viewportWidth, - viewportHeight, - createCardSprite: (id: string | number, x: number, y: number) => - createCardSpriteExternal( - id, - x, - y, - items as TItem[], - (item, e, id) => onCardClickRef.current(item, e, id), - (e) => onPanStartRef.current(asReactMouseEvent(e)), - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - resolveId, - ), - updateCardContent: (sprite: CardSprite, item: TItem) => - updateCardContentExternal( - sprite, - item, - selectedId, - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - ), - isViewTransition: isViewTransitionRef.current, - viewMode, - transitionSeenIds: transitionSeenIdsRef.current, - prevScrollTop: prevScrollTopRef.current, - prevScrollLeft: prevScrollLeftRef.current, - }, transitionLayoutRef.current); - - // Update previous scroll position for next frame - prevScrollTopRef.current = container.scrollTop || 0; - prevScrollLeftRef.current = container.scrollLeft || 0; - needsRenderRef.current = true; - app.renderer?.render(app.stage); - } catch (e) { - console.error('[PivotCanvas] processScroll error', e); - } - }; - - const onScroll = () => { - // capture latest scroll positions quickly and schedule work - lastScroll.x = container.scrollLeft; - lastScroll.y = container.scrollTop; - if (!pendingRef.scheduled) { - pendingRef.scheduled = true; - requestAnimationFrame(processScroll); - } - }; - - container.addEventListener('scroll', onScroll, { passive: true }); - - return () => { - container.removeEventListener('scroll', onScroll); - }; - }, [ - pixiReady, - layout, - visibleIds, - items, - cardWidth, - cardHeight, - zoomLevel, + needsRenderRef, + ); + const stage = usePivotStage({ + parentContainerRef, viewportWidth, viewportHeight, - panX, - panY, - grouping, - viewMode, - selectedId, - onCardClick, - onPanStart, - ]); - - function createCardSprite(id: ItemId, x: number, y: number): CardSprite { - return createCardSpriteExternal( - id, - x, - y, - items as TItem[], - (item, e, id) => onCardClickRef.current(item, e, id), - (e) => onPanStartRef.current(asReactMouseEvent(e)), - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - resolveId, - ); - } - // Mark these helpers as used (they may be referenced externally or via callbacks) - void createCardSprite; - - function updateCardContent(sprite: CardSprite, item: TItem) { - return updateCardContentExternal( - sprite, - item, - selectedId, - cardWidth, - cardHeight, - cardColorsRef.current, - cardRenderer, - ); - } - - function updatePositions(): boolean { - return updatePositionsExternal( - spritesRef.current, - isViewTransitionRef, - ANIMATION_SPEED, - ); - } - - void updatePositions; - - function updateSelection() { - const sprites = spritesRef.current; - - for (const sprite of sprites.values()) { - const val = (items as TItem[])[Number(sprite.itemId)]; - updateCardContent(sprite, val); - } - } - - function updateHighlight() { - updateHighlightExternal( - groupsContainerRef.current, - parentContainerRef.current, - grouping, - layout, - hoveredGroupIndex, - cardWidth, - zoomLevel, - ); - } - - void updateHighlight; + needsRenderRef, + onPanStartRef, + onPanMoveRef, + onPanEndRef, + }); + + const context = { + props, + stage, + transition, + cardColorsRef, + colorRevision, + onCardClickRef, + onPanStartRef, + }; + usePivotGroupBackgrounds(context); + usePivotSpriteSync(context); + usePivotSelectionHighlight(context); + usePivotScrollSync(context); // This component renders into the parent `containerRef` (we append Pixi canvas // and spacer directly into that DOM node). Return null so we don't replace or diff --git a/Source/PivotViewer/components/PivotCanvasProps.ts b/Source/PivotViewer/components/PivotCanvasProps.ts new file mode 100644 index 00000000..6c1b8d92 --- /dev/null +++ b/Source/PivotViewer/components/PivotCanvasProps.ts @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { GroupingResult, ItemId, LayoutResult } from '../engine/types'; +import type { PivotViewerColors } from '../types'; +import type { ViewMode } from './Toolbar'; + +/** Props for the Pixi card renderer inside a PivotViewer viewport. */ +export interface PivotCanvasProps { + /** Original items array */ + items: TItem[]; + + /** Layout positions */ + layout: LayoutResult; + + /** Grouping information */ + grouping: GroupingResult; + + /** Visible item IDs */ + visibleIds: Uint32Array; + + /** Card dimensions */ + cardWidth: number; + cardHeight: number; + + /** Zoom level */ + zoomLevel: number; + + /** Pan offset */ + panX: number; + panY: number; + + /** Viewport dimensions (visible area) */ + viewportWidth: number; + viewportHeight: number; + + /** Selected item ID */ + selectedId: ItemId | null; + + /** Hovered group index */ + hoveredGroupIndex: number | null; + + /** Current view mode */ + viewMode: ViewMode; + + /** Is zooming animation in progress */ + isZooming?: boolean; + + /** Card renderer function - returns structured data for display */ + cardRenderer: (item: TItem) => { + title: string; + labels?: string[]; + values?: string[]; + }; + + /** Public color overrides; changes trigger a Pixi color refresh. */ + colorOverrides?: Partial; + + /** ID resolver */ + resolveId: (item: TItem, index: number) => string | number; + + /** Click handler */ + onCardClick: (item: TItem, e: MouseEvent, id: number | string) => void; + + /** Pan handlers */ + onPanStart: (e: React.MouseEvent) => void; + onPanMove: (e: React.MouseEvent) => void; + onPanEnd: () => void; + containerRef: React.RefObject; +} diff --git a/Source/PivotViewer/components/pivot/PivotRenderContext.ts b/Source/PivotViewer/components/pivot/PivotRenderContext.ts new file mode 100644 index 00000000..d606cab0 --- /dev/null +++ b/Source/PivotViewer/components/pivot/PivotRenderContext.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { RefObject } from 'react'; +import type { PivotCanvasProps } from '../PivotCanvasProps'; +import type { CardColors } from './constants'; +import type { PivotStage } from './PivotStage'; +import type { PivotTransitionState } from './PivotTransitionState'; + +/** Everything the PivotCanvas render hooks read: props, stage, transition state and colors. */ +export interface PivotRenderContext { + props: PivotCanvasProps; + stage: PivotStage; + transition: PivotTransitionState; + cardColorsRef: RefObject; + colorRevision: number; + onCardClickRef: RefObject['onCardClick']>; + onPanStartRef: RefObject['onPanStart']>; +} diff --git a/Source/PivotViewer/components/pivot/PivotStage.ts b/Source/PivotViewer/components/pivot/PivotStage.ts new file mode 100644 index 00000000..99756e8d --- /dev/null +++ b/Source/PivotViewer/components/pivot/PivotStage.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { RefObject } from 'react'; +import type * as PIXI from 'pixi.js'; +import type { ItemId } from '../../engine/types'; +import type { CardSprite } from './constants'; + +/** The Pixi application and containers a PivotCanvas renders into. */ +export interface PivotStage { + appRef: RefObject; + rootRef: RefObject; + groupsContainerRef: RefObject; + canvasRef: RefObject; + spritesRef: RefObject>; + animationFrameRef: RefObject; + mountedRef: RefObject; + /** Set when the stage has changed and needs another render. */ + needsRenderRef: RefObject; + /** Whether the Pixi application is initialized. */ + pixiReady: boolean; +} diff --git a/Source/PivotViewer/components/pivot/PivotTransitionState.ts b/Source/PivotViewer/components/pivot/PivotTransitionState.ts new file mode 100644 index 00000000..9e034a36 --- /dev/null +++ b/Source/PivotViewer/components/pivot/PivotTransitionState.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { RefObject } from 'react'; +import type { GroupingResult, ItemId, LayoutResult } from '../../engine/types'; +import type { ViewMode } from '../Toolbar'; + +/** What a PivotCanvas remembers between renders to animate view, grouping and layout changes. */ +export interface PivotTransitionState { + isAnimatingRef: RefObject; + isViewTransitionRef: RefObject; + lastViewChangeTimeRef: RefObject; + previousViewModeRef: RefObject; + prevLayoutRef: RefObject; + transitionLayoutRef: RefObject; + transitionSeenIdsRef: RefObject>; + prevGroupingRef: RefObject; + prevScrollTopRef: RefObject; + prevScrollLeftRef: RefObject; + prevPanRef: RefObject<{ x: number; y: number }>; +} diff --git a/Source/PivotViewer/components/pivot/asReactMouseEvent.ts b/Source/PivotViewer/components/pivot/asReactMouseEvent.ts new file mode 100644 index 00000000..551fe90f --- /dev/null +++ b/Source/PivotViewer/components/pivot/asReactMouseEvent.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Treats the browser pointer event Pixi exposes as the React mouse event PivotViewer's pan + * callbacks take; they read only the coordinates and modifiers both share. + * @param event The originating browser event. + * @returns The same event, typed for the pan callbacks. + */ +export const asReactMouseEvent = (event: unknown): React.MouseEvent => { + // SAFETY: Pixi exposes the originating browser pointer event. PivotViewer's pan + // callbacks consume only coordinates/modifiers shared with React.MouseEvent. + return event as React.MouseEvent; +}; diff --git a/Source/PivotViewer/components/pivot/usePivotCardColors.ts b/Source/PivotViewer/components/pivot/usePivotCardColors.ts new file mode 100644 index 00000000..474ab125 --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotCardColors.ts @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import type { PivotViewerColors } from '../../types'; +import { + createCssColorResolver, + observeColorEnvironment, + resolveCardColors, +} from './colorResolver'; +import { DEFAULT_COLORS, type CardColors } from './constants'; + +/** + * Resolves the card colors from the viewport's CSS and refreshes them when the color environment + * or the overrides change. + * @param parentContainerRef The viewport whose CSS supplies the colors. + * @param colorOverrides The public color overrides; a change triggers a refresh. + * @param needsRenderRef Set when the colors change, so the stage renders again. + * @returns The current card colors and a revision that changes with them. + */ +export const usePivotCardColors = ( + parentContainerRef: RefObject, + colorOverrides: Partial | undefined, + needsRenderRef: RefObject, +) => { + const [colorRevision, setColorRevision] = useState(0); + const cardColorsRef = useRef(DEFAULT_COLORS); + const cssColorResolver = useMemo(() => createCssColorResolver(), []); + + useEffect(() => { + const element = parentContainerRef.current; + if (!element) return; + + const refreshColors = () => { + cardColorsRef.current = resolveCardColors(cssColorResolver, element); + needsRenderRef.current = true; + setColorRevision((revision) => revision + 1); + }; + + refreshColors(); + return observeColorEnvironment(element, refreshColors); + }, [cssColorResolver, parentContainerRef, colorOverrides, needsRenderRef]); + + return { cardColorsRef, colorRevision }; +}; diff --git a/Source/PivotViewer/components/pivot/usePivotGroupBackgrounds.ts b/Source/PivotViewer/components/pivot/usePivotGroupBackgrounds.ts new file mode 100644 index 00000000..65313a35 --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotGroupBackgrounds.ts @@ -0,0 +1,81 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect } from 'react'; +import { updateGroupBackgrounds as updateGroupBackgroundsExternal } from './groups'; +import type { PivotRenderContext } from './PivotRenderContext'; + +/** + * Draws the group backgrounds when the layout, grouping or colors change, and fades them in and out + * when switching between grouped and collection view. + * @param context The props, stage, and colors. + */ +export const usePivotGroupBackgrounds = ({ + props, + stage, + cardColorsRef, + colorRevision, +}: PivotRenderContext) => { + const { + layout, + grouping, + zoomLevel, + viewMode, + colorOverrides, + containerRef: parentContainerRef, + } = props; + const { groupsContainerRef, appRef, needsRenderRef, pixiReady } = stage; + + // Update group backgrounds only when layout/grouping changes + useEffect(() => { + if (!groupsContainerRef.current || !parentContainerRef.current || !pixiReady) + return; + updateGroupBackgroundsExternal( + groupsContainerRef.current, + parentContainerRef.current, + grouping, + layout, + zoomLevel, + cardColorsRef.current, + viewMode, + ); + needsRenderRef.current = true; + appRef.current?.renderer?.render(appRef.current.stage); + }, [ + grouping, + layout, + zoomLevel, + viewMode, + pixiReady, + colorOverrides, + colorRevision, + appRef, + cardColorsRef, + groupsContainerRef, + needsRenderRef, + parentContainerRef, + ]); + + // Fade buckets background when switching view modes + useEffect(() => { + const gc = groupsContainerRef.current; + const app = appRef.current; + if (!gc || !app) return; + const target = viewMode === 'grouped' ? 1 : 0; + const start = typeof gc.alpha === 'number' ? gc.alpha : 1; + const duration = 200; // ms + if (Math.abs(start - target) < 0.01) { + gc.alpha = target; + return; + } + const t0 = performance.now(); + const step = () => { + const u = Math.min(1, (performance.now() - t0) / duration); + const eased = u * (2 - u); + gc.alpha = start + (target - start) * eased; + app.renderer?.render(app.stage); + if (u < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, [viewMode, appRef, groupsContainerRef]); +}; diff --git a/Source/PivotViewer/components/pivot/usePivotScrollSync.ts b/Source/PivotViewer/components/pivot/usePivotScrollSync.ts new file mode 100644 index 00000000..4f9bed2b --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotScrollSync.ts @@ -0,0 +1,190 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect } from 'react'; +import { asReactMouseEvent } from './asReactMouseEvent'; +import type { CardSprite } from './constants'; +import { + createCardSprite as createCardSpriteExternal, + updateCardContent as updateCardContentExternal, +} from './sprites'; +import { syncScrollSprites } from './syncScrollSprites'; +import type { PivotRenderContext } from './PivotRenderContext'; + +/** + * Syncs the card sprites once per animation frame while the viewport scrolls, natively or + * programmatically. + * @param context The props, stage, transition state and colors. + */ +export const usePivotScrollSync = ({ + props, + stage, + transition, + cardColorsRef, + onCardClickRef, + onPanStartRef, +}: PivotRenderContext) => { + const { + items, + layout, + grouping, + visibleIds, + cardWidth, + cardHeight, + zoomLevel, + panX, + panY, + viewportWidth, + viewportHeight, + selectedId, + viewMode, + cardRenderer, + resolveId, + onPanStart, + onCardClick, + containerRef: parentContainerRef, + } = props; + const { rootRef, groupsContainerRef, spritesRef, appRef, needsRenderRef, pixiReady } = + stage; + const { + isViewTransitionRef, + transitionLayoutRef, + transitionSeenIdsRef, + prevScrollTopRef, + prevScrollLeftRef, + } = transition; + + // Listen to native scroll events on the parent container so we update the + // Pixi world immediately when the user scrolls (native scrollbar or + // programmatic). This ensures `syncSpritesToViewport` runs on scroll and + // creates/destroys sprites as the viewport moves. + useEffect(() => { + if ( + !pixiReady || + !parentContainerRef || + !parentContainerRef.current || + !appRef.current || + !rootRef.current + ) + return; + + const container = parentContainerRef.current; + const app = appRef.current; + + // rAF-batched scroll handling: process scroll once per animation frame to avoid heavy synchronous work inside + // the scroll event which causes jank and de-synchronisation between the + // compositor and Pixi render updates. + const pendingRef = { scheduled: false } as { scheduled: boolean }; + + const processScroll = () => { + pendingRef.scheduled = false; + try { + // Note: We delegate root/groups container positioning to syncSpritesToViewport + // because it encapsulates the logic for conditional vertical alignment (offsetY) + // in different view modes. Manually setting position here would overwrite that logic. + + syncScrollSprites( + { + root: rootRef.current, + groupsContainer: groupsContainerRef.current, + container: parentContainerRef.current, + sprites: spritesRef.current, + layout, + visibleIds, + items, + cardWidth, + cardHeight, + panX, + panY, + zoomLevel, + viewportWidth, + viewportHeight, + createCardSprite: (id: string | number, x: number, y: number) => + createCardSpriteExternal( + id, + x, + y, + items as TItem[], + (item, e, id) => onCardClickRef.current(item, e, id), + (e) => onPanStartRef.current(asReactMouseEvent(e)), + cardWidth, + cardHeight, + cardColorsRef.current, + cardRenderer, + resolveId, + ), + updateCardContent: (sprite: CardSprite, item: TItem) => + updateCardContentExternal( + sprite, + item, + selectedId, + cardWidth, + cardHeight, + cardColorsRef.current, + cardRenderer, + ), + isViewTransition: isViewTransitionRef.current, + viewMode, + transitionSeenIds: transitionSeenIdsRef.current, + prevScrollTop: prevScrollTopRef.current, + prevScrollLeft: prevScrollLeftRef.current, + }, + transitionLayoutRef.current, + ); + + // Update previous scroll position for next frame + prevScrollTopRef.current = container.scrollTop || 0; + prevScrollLeftRef.current = container.scrollLeft || 0; + needsRenderRef.current = true; + app.renderer?.render(app.stage); + } catch (e) { + console.error('[PivotCanvas] processScroll error', e); + } + }; + + const onScroll = () => { + // schedule the work for the next animation frame + if (!pendingRef.scheduled) { + pendingRef.scheduled = true; + requestAnimationFrame(processScroll); + } + }; + + container.addEventListener('scroll', onScroll, { passive: true }); + + return () => { + container.removeEventListener('scroll', onScroll); + }; + }, [ + pixiReady, + layout, + visibleIds, + items, + cardWidth, + cardHeight, + zoomLevel, + viewportWidth, + viewportHeight, + panX, + panY, + grouping, + viewMode, + selectedId, + onCardClick, + onPanStart, + appRef, + cardColorsRef, + groupsContainerRef, + isViewTransitionRef, + needsRenderRef, + onCardClickRef, + onPanStartRef, + parentContainerRef, + prevScrollLeftRef, + prevScrollTopRef, + rootRef, + spritesRef, + transitionLayoutRef, + transitionSeenIdsRef, + ]); +}; diff --git a/Source/PivotViewer/components/pivot/usePivotSelectionHighlight.ts b/Source/PivotViewer/components/pivot/usePivotSelectionHighlight.ts new file mode 100644 index 00000000..5da0ac5a --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotSelectionHighlight.ts @@ -0,0 +1,74 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect } from 'react'; +import { updateHighlight as updateHighlightExternal } from './groups'; +import { updateCardContent as updateCardContentExternal } from './sprites'; +import type { PivotRenderContext } from './PivotRenderContext'; + +/** + * Redraws the cards when the selection changes and the group highlight when the hovered group changes. + * @param context The props, stage and colors. + */ +export const usePivotSelectionHighlight = ({ + props, + stage, + cardColorsRef, +}: PivotRenderContext) => { + const { + items, + layout, + grouping, + cardWidth, + cardHeight, + zoomLevel, + selectedId, + hoveredGroupIndex, + cardRenderer, + containerRef: parentContainerRef, + } = props; + const { rootRef, groupsContainerRef, spritesRef, appRef, needsRenderRef } = stage; + + useEffect(() => { + if (!rootRef.current) return; + updateSelection(); + needsRenderRef.current = true; + appRef.current?.renderer.render(appRef.current.stage); + }, [selectedId, items, appRef, needsRenderRef, rootRef]); + + useEffect(() => { + if (!rootRef.current) return; + updateHighlight(); + needsRenderRef.current = true; + appRef.current?.renderer.render(appRef.current.stage); + }, [hoveredGroupIndex, layout, grouping, appRef, needsRenderRef, rootRef]); + + function updateSelection() { + const sprites = spritesRef.current; + + for (const sprite of sprites.values()) { + const val = (items as TItem[])[Number(sprite.itemId)]; + updateCardContentExternal( + sprite, + val, + selectedId, + cardWidth, + cardHeight, + cardColorsRef.current, + cardRenderer, + ); + } + } + + function updateHighlight() { + updateHighlightExternal( + groupsContainerRef.current, + parentContainerRef.current, + grouping, + layout, + hoveredGroupIndex, + cardWidth, + zoomLevel, + ); + } +}; diff --git a/Source/PivotViewer/components/pivot/usePivotSpriteSync.ts b/Source/PivotViewer/components/pivot/usePivotSpriteSync.ts new file mode 100644 index 00000000..718034a7 --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotSpriteSync.ts @@ -0,0 +1,226 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect } from 'react'; +import { startAnimationLoop as startAnimationLoopExternal } from './animation'; +import { asReactMouseEvent } from './asReactMouseEvent'; +import type { CardSprite } from './constants'; +import { + createCardSprite as createCardSpriteExternal, + updateCardContent as updateCardContentExternal, +} from './sprites'; +import { syncSpritesToViewport } from './visibility'; +import type { PivotRenderContext } from './PivotRenderContext'; + +/** + * Keeps the card sprites in step with the layout, filter, zoom and pan, starting transitions when the + * view, grouping or layout changes and running the animation loop. + * @param context The props, stage, transition state and colors. + */ +export const usePivotSpriteSync = ({ + props, + stage, + transition, + cardColorsRef, + colorRevision, + onCardClickRef, +}: PivotRenderContext) => { + const { + items, + layout, + grouping, + visibleIds, + cardWidth, + cardHeight, + zoomLevel, + panX, + panY, + viewportWidth, + viewportHeight, + selectedId, + viewMode, + cardRenderer, + colorOverrides, + resolveId, + onPanStart, + containerRef: parentContainerRef, + } = props; + const { + rootRef, + groupsContainerRef, + spritesRef, + appRef, + animationFrameRef, + mountedRef, + needsRenderRef, + pixiReady, + } = stage; + const { + isAnimatingRef, + isViewTransitionRef, + lastViewChangeTimeRef, + previousViewModeRef, + prevLayoutRef, + transitionLayoutRef, + transitionSeenIdsRef, + prevGroupingRef, + prevScrollTopRef, + prevScrollLeftRef, + prevPanRef, + } = transition; + + useEffect(() => { + if (!rootRef.current || !parentContainerRef.current || !pixiReady) { + return; + } + + // Check if this is a view mode change (not just pan/scroll) + const viewModeChanged = previousViewModeRef.current !== viewMode; + const groupingChanged = prevGroupingRef.current !== grouping; + const layoutChanged = prevLayoutRef.current !== layout; + + if (viewModeChanged || groupingChanged || layoutChanged) { + transitionSeenIdsRef.current.clear(); + if (layoutChanged) transitionLayoutRef.current = prevLayoutRef.current; + isViewTransitionRef.current = true; + lastViewChangeTimeRef.current = Date.now(); + previousViewModeRef.current = viewMode; + prevGroupingRef.current = grouping; + + // Don't hide sprites here - let visibility.ts handle the transition + // The syncSpritesToViewport function will properly animate sprites to new positions + // during view transitions (isViewTransitionRef.current = true), and visibility.ts + // will handle cleanup of sprites that no longer have positions in the layout. + // Previously, hiding sprites here caused sorting/transitions to not work because + // sprites were destroyed before they could animate. + } + + const panDeltaX = panX - prevPanRef.current.x; + const panDeltaY = panY - prevPanRef.current.y; + prevPanRef.current = { x: panX, y: panY }; + + // Sync sprites into viewport and create/remove as needed + // Provide wrappers for sprite creation and content update so helpers have required context + const currentScrollTop = parentContainerRef.current?.scrollTop || 0; + const currentScrollLeft = parentContainerRef.current?.scrollLeft || 0; + + const syncParams = { + root: rootRef.current, + groupsContainer: groupsContainerRef.current, + container: parentContainerRef.current, + sprites: spritesRef.current, + layout, + visibleIds, + items, + cardWidth, + cardHeight, + panX, + panY, + panDeltaX, + panDeltaY, + zoomLevel, + viewportWidth, + viewportHeight, + viewMode, + createCardSprite: (id: string | number, x: number, y: number) => + createCardSpriteExternal( + id, + x, + y, + items as TItem[], + (item: TItem, e: MouseEvent, id: string | number) => + onCardClickRef.current(item, e, id), + (e: MouseEvent) => onPanStart(asReactMouseEvent(e)), + cardWidth, + cardHeight, + cardColorsRef.current, + cardRenderer, + resolveId, + ), + updateCardContent: (sprite: CardSprite, item: TItem) => + updateCardContentExternal( + sprite, + item, + selectedId, + cardWidth, + cardHeight, + cardColorsRef.current, + cardRenderer, + ), + isViewTransition: isViewTransitionRef.current, + prevLayout: prevLayoutRef.current, + transitionSeenIds: transitionSeenIdsRef.current, + prevScrollTop: prevScrollTopRef.current, + prevScrollLeft: prevScrollLeftRef.current, + }; + syncSpritesToViewport(syncParams); + + // Update previous scroll position for next frame + prevScrollTopRef.current = currentScrollTop; + prevScrollLeftRef.current = currentScrollLeft; + needsRenderRef.current = true; + + // Force an immediate render after syncing sprites to ensure cards appear + if (appRef.current?.renderer && rootRef.current) { + appRef.current.renderer.render(appRef.current.stage); + needsRenderRef.current = false; + } + + startAnimationLoopExternal({ + mountedRef, + appRef, + animationFrameRef, + isAnimatingRef, + needsRenderRef, + spritesRef, + isViewTransitionRef, + syncVisibility: () => + syncSpritesToViewport({ + ...syncParams, + isViewTransition: false, + sweepImmediately: true, + }), + onTransitionComplete: () => transitionSeenIdsRef.current.clear(), + }); + }, [ + layout, + visibleIds, + items, + cardWidth, + cardHeight, + pixiReady, + zoomLevel, + panX, + panY, + grouping, + viewMode, + colorOverrides, + colorRevision, + animationFrameRef, + appRef, + cardColorsRef, + groupsContainerRef, + isAnimatingRef, + isViewTransitionRef, + lastViewChangeTimeRef, + mountedRef, + needsRenderRef, + onCardClickRef, + parentContainerRef, + prevGroupingRef, + prevLayoutRef, + prevPanRef, + prevScrollLeftRef, + prevScrollTopRef, + previousViewModeRef, + rootRef, + spritesRef, + transitionLayoutRef, + transitionSeenIdsRef, + ]); + + // Update prevLayoutRef after processing layout changes + useEffect(() => { + prevLayoutRef.current = layout; + }, [layout, prevLayoutRef]); +}; diff --git a/Source/PivotViewer/components/pivot/usePivotStage.ts b/Source/PivotViewer/components/pivot/usePivotStage.ts new file mode 100644 index 00000000..e2eafea8 --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotStage.ts @@ -0,0 +1,285 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState, type RefObject } from 'react'; +import * as PIXI from 'pixi.js'; +import type { ItemId } from '../../engine/types'; +import { asReactMouseEvent } from './asReactMouseEvent'; +import type { CardSprite } from './constants'; +import type { PivotStage } from './PivotStage'; +import { clearSpritePool } from './sprites'; + +/** What {@link usePivotStage} renders into and reports pointer input to. */ +export interface PivotStageOptions { + /** The scrollable viewport the Pixi canvas overlays. */ + parentContainerRef: RefObject; + viewportWidth: number; + viewportHeight: number; + needsRenderRef: RefObject; + onPanStartRef: RefObject<(e: React.MouseEvent) => void>; + onPanMoveRef: RefObject<(e: React.MouseEvent) => void>; + onPanEndRef: RefObject<() => void>; +} + +/** + * Creates the Pixi application once, overlays its canvas on the scrollable viewport, forwards + * background pointer input as panning, and keeps the canvas sized to the viewport. + * @param options The viewport, its size and the pan callbacks. + * @returns The stage. + */ +export const usePivotStage = ({ + parentContainerRef, + viewportWidth, + viewportHeight, + needsRenderRef, + onPanStartRef, + onPanMoveRef, + onPanEndRef, +}: PivotStageOptions): PivotStage => { + const canvasRef = useRef(null); + const appRef = useRef(null); + const rootRef = useRef(null); + const groupsContainerRef = useRef(null); + const spritesRef = useRef>(new Map()); + const animationFrameRef = useRef(0); + const mountedRef = useRef(true); + const initializingRef = useRef(false); + const [pixiReady, setPixiReady] = useState(false); + + useEffect(() => { + // Reset mounted flag + mountedRef.current = true; + + if (!parentContainerRef || !parentContainerRef.current) { + return; + } + + // Prevent multiple simultaneous initializations + if (initializingRef.current || appRef.current) { + return; + } + + initializingRef.current = true; + let app: PIXI.Application | null = null; + + (async () => { + try { + const options = { + backgroundAlpha: 0, + antialias: false, + autoStart: false, + autoDensity: true, + resolution: window.devicePixelRatio || 1, + width: viewportWidth > 0 ? viewportWidth : 800, + height: viewportHeight > 0 ? viewportHeight : 600, + } as PIXI.ApplicationOptions; + + app = new PIXI.Application(); + await app.init(options); + + if (!mountedRef.current || !parentContainerRef.current) { + // Component unmounted during initialization + if (app && typeof app.destroy === 'function') + app.destroy(true, { children: true }); + initializingRef.current = false; + return; + } + + appRef.current = app; + + const groupsContainer = new PIXI.Container(); + groupsContainerRef.current = groupsContainer; + app.stage.addChild(groupsContainer); + + const root = new PIXI.Container(); + rootRef.current = root; + app.stage.addChild(root); + + const canvasEl = app.canvas; + + // Place canvas outside the scrollable content so native scrolling + // doesn't move the canvas DOM element itself. We overlay the canvas + // on top of the scroll area by inserting it into the parent element + // (or the container itself if parent is not available). This ensures + // the Pixi canvas remains stable while we move the Pixi world inside + // it to represent camera pan. + const overlayParent = + parentContainerRef.current.parentElement ?? + parentContainerRef.current; + + if (canvasEl) { + if (canvasEl.parentElement) { + canvasEl.parentElement.removeChild(canvasEl); + } + overlayParent.appendChild(canvasEl); + canvasRef.current = canvasEl; + } else { + console.error( + 'PivotCanvas: Could not find canvas element from Pixi application', + ); + } + + // Position the canvas to overlay the scrollable container area. + if (canvasRef.current && parentContainerRef.current) { + canvasRef.current.style.position = 'absolute'; + // Place canvas relative to the overlayParent's coordinate space. + // If overlayParent is the immediate parent, top/left 0 aligns it. + const offsetLeft = parentContainerRef.current.offsetLeft; + const offsetTop = parentContainerRef.current.offsetTop; + canvasRef.current.style.left = `${offsetLeft}px`; + canvasRef.current.style.top = `${offsetTop}px`; + canvasRef.current.style.width = `${parentContainerRef.current.clientWidth}px`; + canvasRef.current.style.height = `${parentContainerRef.current.clientHeight}px`; + // Place canvas behind the scrollable container (which has z-index 1) + // so scrollbars appear on top. + canvasRef.current.style.zIndex = '0'; + // Disable pointer events on canvas so they pass through to the viewport if needed, + // though viewport is on top anyway. + canvasRef.current.style.pointerEvents = 'none'; + } + + // We handle clicks and interactions manually in PivotViewerMain now, + // so we don't need to configure Pixi events on the container. + // This avoids z-index conflicts and event propagation issues. + + // Make canvas fill container with absolute positioning + if (canvasRef.current) { + canvasRef.current.style.display = 'block'; + // Ensure canvas does not capture events so they pass through to the viewport + canvasRef.current.style.pointerEvents = 'none'; + } + + // Setup stage events for background panning + app.stage.eventMode = 'static'; + app.stage.hitArea = new PIXI.Rectangle( + 0, + 0, + viewportWidth, + viewportHeight, + ); + + app.stage.on('pointerdown', (e) => { + // Only handle if it reached the stage (background) + // Sprites stop propagation, so this is safe. + onPanStartRef.current(asReactMouseEvent(e.nativeEvent)); + }); + + app.stage.on('globalpointermove', (e) => { + onPanMoveRef.current(asReactMouseEvent(e.nativeEvent)); + }); + + app.stage.on('globalpointerup', () => { + onPanEndRef.current(); + }); + + // Immediately size to container to avoid delay + if (viewportWidth > 0 && viewportHeight > 0) { + app.renderer?.resize(viewportWidth, viewportHeight); + } + + setPixiReady(true); + initializingRef.current = false; + + // Trigger initial render + needsRenderRef.current = true; + app.renderer?.render(app.stage); + } catch (error) { + console.error('Failed to initialize Pixi.js:', error); + initializingRef.current = false; + } + })(); + + return () => { + mountedRef.current = false; + setPixiReady(false); + cancelAnimationFrame(animationFrameRef.current); + + if (appRef.current && typeof appRef.current.destroy === 'function') { + appRef.current.destroy(true, { children: true }); + appRef.current = null; + rootRef.current = null; + } + + // Pixi destroys the application's children. Discard detached, pooled + // sprites too so a later application cannot reuse their stale textures. + clearSpritePool(); + spritesRef.current.clear(); + + // Remove DOM nodes we appended + try { + if (canvasRef.current && canvasRef.current.parentElement) { + canvasRef.current.parentElement.removeChild(canvasRef.current); + } + } catch (e) { + void e; + } + }; + }, [needsRenderRef, onPanEndRef, onPanMoveRef, onPanStartRef, parentContainerRef]); // Only initialize once - resizing handled by separate useEffect + + // Handle canvas resize + useEffect(() => { + if ( + !parentContainerRef || + !parentContainerRef.current || + !appRef.current || + !pixiReady + ) + return; + + const container = parentContainerRef.current; + const app = appRef.current; + + let resizeTimeout: ReturnType; + + const handleResize = () => { + // Size canvas to viewport dimensions from props + if (viewportWidth > 0 && viewportHeight > 0) { + app.renderer?.resize(viewportWidth, viewportHeight); + app.stage.hitArea = new PIXI.Rectangle( + 0, + 0, + viewportWidth, + viewportHeight, + ); + + // Keep canvas DOM size in sync with container + if (canvasRef.current && parentContainerRef.current) { + canvasRef.current.style.width = `${parentContainerRef.current.clientWidth}px`; + canvasRef.current.style.height = `${parentContainerRef.current.clientHeight}px`; + // Also update left/top in case the container moved + canvasRef.current.style.left = `${parentContainerRef.current.offsetLeft}px`; + canvasRef.current.style.top = `${parentContainerRef.current.offsetTop}px`; + } + } + }; + + const debouncedResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(handleResize, 150); + }; + + // Initial resize (immediate) + handleResize(); + + // Watch for size changes (debounced) + const resizeObserver = new ResizeObserver(debouncedResize); + resizeObserver.observe(container); + + return () => { + clearTimeout(resizeTimeout); + resizeObserver.disconnect(); + }; + }, [pixiReady, viewportWidth, viewportHeight, parentContainerRef]); + + return { + appRef, + rootRef, + groupsContainerRef, + canvasRef, + spritesRef, + animationFrameRef, + mountedRef, + needsRenderRef, + pixiReady, + }; +}; diff --git a/Source/PivotViewer/components/pivot/usePivotTransitionState.ts b/Source/PivotViewer/components/pivot/usePivotTransitionState.ts new file mode 100644 index 00000000..e4b406c6 --- /dev/null +++ b/Source/PivotViewer/components/pivot/usePivotTransitionState.ts @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useRef } from 'react'; +import type { GroupingResult, ItemId, LayoutResult } from '../../engine/types'; +import type { ViewMode } from '../Toolbar'; +import type { PivotTransitionState } from './PivotTransitionState'; + +/** + * The refs a PivotCanvas keeps between renders to animate view, grouping and layout changes. + * @param viewMode The initial view mode. + * @param pan The initial pan. + * @returns The transition state. + */ +export const usePivotTransitionState = ( + viewMode: ViewMode, + pan: { x: number; y: number }, +): PivotTransitionState => { + const isAnimatingRef = useRef(false); + const isViewTransitionRef = useRef(false); + const lastViewChangeTimeRef = useRef(0); + const previousViewModeRef = useRef(viewMode); + const prevLayoutRef = useRef(null); + const transitionLayoutRef = useRef(null); + const transitionSeenIdsRef = useRef>(new Set()); + const prevGroupingRef = useRef(null); + const prevScrollTopRef = useRef(0); + const prevScrollLeftRef = useRef(0); + const prevPanRef = useRef(pan); + return { + isAnimatingRef, + isViewTransitionRef, + lastViewChangeTimeRef, + previousViewModeRef, + prevLayoutRef, + transitionLayoutRef, + transitionSeenIdsRef, + prevGroupingRef, + prevScrollTopRef, + prevScrollLeftRef, + prevPanRef, + }; +}; diff --git a/Source/TimeMachine/EventsView.tsx b/Source/TimeMachine/EventsView.tsx index a9d03a12..ced340e5 100644 --- a/Source/TimeMachine/EventsView.tsx +++ b/Source/TimeMachine/EventsView.tsx @@ -14,6 +14,7 @@ import { type TimeMachineLabels, defaultTimeMachineLabels } from './TimeMachineL import { Properties } from './Properties'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; +import { classNames } from '../ClassNames/classNames'; /** Stable part attributes for {@link EventsView}. */ export interface EventsViewParts { @@ -54,9 +55,6 @@ export interface EventsViewProps { labels?: TimeMachineLabels; } -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - export const SelectedEventSequenceNumbersContext = createContext | undefined>(undefined); diff --git a/Source/scripts/kernel-inventory.json b/Source/scripts/kernel-inventory.json index d2d34dfd..91bf500a 100644 --- a/Source/scripts/kernel-inventory.json +++ b/Source/scripts/kernel-inventory.json @@ -18,6 +18,7 @@ "Source/Chat/isTopicUnnamed.ts", "Source/Chat/shouldRequestTopicName.ts", "Source/Chat/topicsByActivity.ts", + "Source/ClassNames/classNames.ts", "Source/CommandDialog/applyBeforeExecute.ts", "Source/CommandForm/commandFormMarkers.ts", "Source/CommandForm/fields/chipValues.ts", diff --git a/Source/scripts/verify-source-maps.mjs b/Source/scripts/verify-source-maps.mjs index 04c6c6b2..21d4fd14 100644 --- a/Source/scripts/verify-source-maps.mjs +++ b/Source/scripts/verify-source-maps.mjs @@ -92,9 +92,9 @@ const rewrittenFiles = new Set( // beside this in-repo build, even though Source/*.tsx is excluded from the published archive. const navigationFile = path.join(packageDir, 'scripts', 'source-map-navigation.ts'); const navigationSource = [ - "import type { CanvasItemRegistryEntry } from '../dist/esm/Canvas/Canvas.js';", + "import type { CanvasProps } from '../dist/esm/Canvas/Canvas.js';", "import type { CanvasItemProps } from '../dist/esm/Canvas/CanvasItem.js';", - 'type Rewritten = CanvasItemRegistryEntry;', + 'type Rewritten = CanvasProps;', 'type Unchanged = CanvasItemProps;', ].join('\n'); const navigationHost = { @@ -132,7 +132,7 @@ const sourceMapper = ts.getSourceMapper({ }); try { for (const [symbol, sourceFile] of [ - ['CanvasItemRegistryEntry', 'Canvas/Canvas.tsx'], // Rewritten declaration + ['CanvasProps', 'Canvas/Canvas.tsx'], // Rewritten declaration ['CanvasItemProps', 'Canvas/CanvasItem.tsx'], // Unchanged declaration ]) { const usage = navigationSource.lastIndexOf(symbol); diff --git a/Storybook/scripts/storybook-inventory.json b/Storybook/scripts/storybook-inventory.json index 41988fe3..276df28a 100644 --- a/Storybook/scripts/storybook-inventory.json +++ b/Storybook/scripts/storybook-inventory.json @@ -284,6 +284,7 @@ "Source/Dropdown/Dropdown.tsx" ], "stories": [ + "canvas-canvas--camera-interactions", "canvas-canvas--declarative-children", "canvas-canvas--localized-controls", "canvas-canvas--with-controls-and-minimap",