From 5b334d66d5160fee853ff93969bef340d2748e5d Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 14:45:32 +0200 Subject: [PATCH 1/7] Cover Canvas camera input paths with a browser story test Wheel pan, Ctrl+wheel zoom toward the pointer, the zoom controls, a background drag, the imperative handle and measured item bounds, asserted through onTransformChange. A planted change to the wheel zoom intensity fails it. --- Source/Canvas/Canvas.stories.tsx | 110 ++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) 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 }]); + }); + }, +}; From da980746801051479e15ccbb462f0c29248d47ba Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 14:48:48 +0200 Subject: [PATCH 2/7] Split Canvas into camera, input, Pixi and navigation hooks Canvas.tsx keeps its props contract and composes useLatestRef, useCanvasItemRegistry, usePixiApplication, useCanvasCamera, useCanvasWheelZoom, useCanvasPointerGestures, usePixiItems and useCanvasNavigation, called in the original order so effects run as before. The exported types move to their own files and are re-exported from Canvas.tsx. The write-only itemsRef is removed. The camera story test, the Canvas specs and the API surface snapshot are unchanged. --- Source/Canvas/Canvas.tsx | 1203 ++--------------- Source/Canvas/CanvasCaptureAttributes.ts | 12 + Source/Canvas/CanvasContext.ts | 12 + Source/Canvas/CanvasHandle.ts | 21 + Source/Canvas/CanvasItemData.ts | 12 + Source/Canvas/CanvasItemRegistryContext.ts | 9 + .../Canvas/CanvasItemRegistryContextValue.ts | 31 + Source/Canvas/CanvasItemRegistryEntry.ts | 22 + Source/Canvas/WebKitGestureEvent.ts | 17 + Source/Canvas/canvasEasing.ts | 14 + Source/Canvas/canvasGestureTuning.ts | 24 + Source/Canvas/useCanvasCamera.ts | 166 +++ Source/Canvas/useCanvasItemRegistry.ts | 97 ++ Source/Canvas/useCanvasNavigation.ts | 252 ++++ Source/Canvas/useCanvasPointerGestures.ts | 302 +++++ Source/Canvas/useCanvasWheelZoom.ts | 225 +++ Source/Canvas/useLatestRef.ts | 18 + Source/Canvas/usePixiApplication.ts | 146 ++ Source/Canvas/usePixiItems.ts | 84 ++ 19 files changed, 1576 insertions(+), 1091 deletions(-) create mode 100644 Source/Canvas/CanvasCaptureAttributes.ts create mode 100644 Source/Canvas/CanvasContext.ts create mode 100644 Source/Canvas/CanvasHandle.ts create mode 100644 Source/Canvas/CanvasItemData.ts create mode 100644 Source/Canvas/CanvasItemRegistryContext.ts create mode 100644 Source/Canvas/CanvasItemRegistryContextValue.ts create mode 100644 Source/Canvas/CanvasItemRegistryEntry.ts create mode 100644 Source/Canvas/WebKitGestureEvent.ts create mode 100644 Source/Canvas/canvasEasing.ts create mode 100644 Source/Canvas/canvasGestureTuning.ts create mode 100644 Source/Canvas/useCanvasCamera.ts create mode 100644 Source/Canvas/useCanvasItemRegistry.ts create mode 100644 Source/Canvas/useCanvasNavigation.ts create mode 100644 Source/Canvas/useCanvasPointerGestures.ts create mode 100644 Source/Canvas/useCanvasWheelZoom.ts create mode 100644 Source/Canvas/useLatestRef.ts create mode 100644 Source/Canvas/usePixiApplication.ts create mode 100644 Source/Canvas/usePixiItems.ts 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, + ]); +}; From 89f89e289cc7bd32b74b24c0f152044d3edc6f99 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 14:55:45 +0200 Subject: [PATCH 3/7] Split PivotCanvas into stage, color, sync and selection hooks PivotCanvas composes usePivotStage (Pixi init, canvas overlay, resize), usePivotCardColors, usePivotGroupBackgrounds, usePivotSpriteSync, usePivotSelectionHighlight and usePivotScrollSync in the original effect order. Dead code is removed: the never-assigned spacer ref and its branch, commented-out listeners and camera effect, unused helpers kept alive with void, and a write-only scroll record. onPanStartRef now follows the latest onPanStart like the other callback refs; the only caller's callback is already stable, so nothing changes today. Rendered pixels in collection view, grouped view and after scrolling match the previous implementation. --- Source/PivotViewer/components/PivotCanvas.tsx | 916 ++---------------- .../components/PivotCanvasProps.ts | 70 ++ .../components/pivot/PivotRenderContext.ts | 19 + .../components/pivot/PivotStage.ts | 22 + .../components/pivot/PivotTransitionState.ts | 21 + .../components/pivot/asReactMouseEvent.ts | 14 + .../components/pivot/usePivotCardColors.ts | 45 + .../pivot/usePivotGroupBackgrounds.ts | 81 ++ .../components/pivot/usePivotScrollSync.ts | 190 ++++ .../pivot/usePivotSelectionHighlight.ts | 74 ++ .../components/pivot/usePivotSpriteSync.ts | 226 +++++ .../components/pivot/usePivotStage.ts | 285 ++++++ .../pivot/usePivotTransitionState.ts | 43 + 13 files changed, 1148 insertions(+), 858 deletions(-) create mode 100644 Source/PivotViewer/components/PivotCanvasProps.ts create mode 100644 Source/PivotViewer/components/pivot/PivotRenderContext.ts create mode 100644 Source/PivotViewer/components/pivot/PivotStage.ts create mode 100644 Source/PivotViewer/components/pivot/PivotTransitionState.ts create mode 100644 Source/PivotViewer/components/pivot/asReactMouseEvent.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotCardColors.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotGroupBackgrounds.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotScrollSync.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotSelectionHighlight.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotSpriteSync.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotStage.ts create mode 100644 Source/PivotViewer/components/pivot/usePivotTransitionState.ts 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, + }; +}; From eabfc119e64f17f3ec9b1d1ea643e52e3da9c675 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 14:57:03 +0200 Subject: [PATCH 4/7] Record the Canvas camera story in the Storybook inventory --- Storybook/scripts/storybook-inventory.json | 1 + 1 file changed, 1 insertion(+) 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", From 987fc0111232e3ef20df28b57239bb30bd0eb138 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 14:57:03 +0200 Subject: [PATCH 5/7] Share one classNames helper across components Thirteen components and the DataTables modules each defined the same class-joining helper. Common/classNames.ts replaces them, accepts false as ComboBox's copy did, and is registered as a kernel module since it is pure. --- ESLint/lib/kernelBoundary.js | 1 + Source/Chat/ChatSidebar.tsx | 4 +--- Source/Common/Breadcrumbs.tsx | 4 +--- Source/Common/ComboBox.tsx | 4 +--- Source/Common/DatePickerInputImplementation.tsx | 4 +--- Source/Common/NumberInput.tsx | 4 +--- Source/Common/Tabs.tsx | 4 +--- Source/Common/TagGroup.tsx | 4 +--- Source/Common/ToggleGroup.tsx | 4 +--- Source/{DataTables => Common}/classNames.ts | 6 +++--- Source/DataTables/ColumnFilterMenu.tsx | 4 +--- Source/DataTables/DataTableBody.tsx | 2 +- Source/DataTables/DataTableCore.tsx | 2 +- Source/DataTables/DataTableHeaderCell.tsx | 2 +- Source/DataTables/DataTableRow.tsx | 2 +- Source/DataTables/DataTableSearch.tsx | 2 +- Source/Dialogs/DialogImplementation.tsx | 4 +--- Source/Dropdown/DropdownImplementation.tsx | 4 +--- Source/Notifications/Toaster.tsx | 3 +-- Source/TimeMachine/EventsView.tsx | 4 +--- Source/scripts/kernel-inventory.json | 1 + 21 files changed, 23 insertions(+), 46 deletions(-) rename Source/{DataTables => Common}/classNames.ts (53%) diff --git a/ESLint/lib/kernelBoundary.js b/ESLint/lib/kernelBoundary.js index 720d9884..76e94972 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/Common/classNames.ts', 'Source/CommandDialog/applyBeforeExecute.ts', 'Source/CommandForm/commandFormMarkers.ts', 'Source/CommandForm/fields/chipValues.ts', diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index c7205208..2e9a26c4 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 '../Common/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 = () => ( ) => - values.filter(Boolean).join(' '); - /** * A breadcrumb trail. * diff --git a/Source/Common/ComboBox.tsx b/Source/Common/ComboBox.tsx index 336e2afe..74af8d7a 100644 --- a/Source/Common/ComboBox.tsx +++ b/Source/Common/ComboBox.tsx @@ -31,6 +31,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from './reactAriaProps'; +import { classNames } from './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..90a6ef91 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'; const asDateValue = ( value: Date | null, diff --git a/Source/Common/NumberInput.tsx b/Source/Common/NumberInput.tsx index a5d2a015..c017bc66 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'; /** 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..646de5c1 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'; /** 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..b9e1cd6f 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'; /** 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..12e21d37 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'; /** 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/classNames.ts b/Source/Common/classNames.ts similarity index 53% rename from Source/DataTables/classNames.ts rename to Source/Common/classNames.ts index 195469d3..f495f3ab 100644 --- a/Source/DataTables/classNames.ts +++ b/Source/Common/classNames.ts @@ -2,9 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. /** - * Joins the class names that are set. - * @param values Class names, some possibly undefined. + * Joins the class names that are set, skipping empty, undefined and false values. + * @param values Class names, some possibly unset. * @returns The space-separated class names. */ -export const classNames = (...values: Array) => +export const classNames = (...values: Array) => values.filter(Boolean).join(' '); diff --git a/Source/DataTables/ColumnFilterMenu.tsx b/Source/DataTables/ColumnFilterMenu.tsx index 4722713b..fe0b6b74 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 '../Common/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..a5be2d42 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 '../Common/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..fab09c8e 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 '../Common/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..f35c70cc 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 '../Common/classNames'; import type { DataTableFilterConstraint, DataTableFilterMeta, diff --git a/Source/DataTables/DataTableRow.tsx b/Source/DataTables/DataTableRow.tsx index 183c39e0..da5c6222 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 '../Common/classNames'; import type { DataTableParts } from './DataTableParts'; import { valueAtPath } from './valueAtPath'; diff --git a/Source/DataTables/DataTableSearch.tsx b/Source/DataTables/DataTableSearch.tsx index 688c2efb..447440a5 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 '../Common/classNames'; import type { DataTableParts } from './DataTableParts'; /** Props for {@link DataTableSearch}. */ diff --git a/Source/Dialogs/DialogImplementation.tsx b/Source/Dialogs/DialogImplementation.tsx index f30cc27a..054ec5c4 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 '../Common/classNames'; const subscribeToBrowserEnvironment = () => () => undefined; const useIsBrowser = () => diff --git a/Source/Dropdown/DropdownImplementation.tsx b/Source/Dropdown/DropdownImplementation.tsx index f3e19a4e..aebfab2d 100644 --- a/Source/Dropdown/DropdownImplementation.tsx +++ b/Source/Dropdown/DropdownImplementation.tsx @@ -34,6 +34,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from '../Common/reactAriaProps'; +import { classNames } from '../Common/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..668d9dd4 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 '../Common/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/TimeMachine/EventsView.tsx b/Source/TimeMachine/EventsView.tsx index a9d03a12..4b3497ec 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 '../Common/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..42e28619 100644 --- a/Source/scripts/kernel-inventory.json +++ b/Source/scripts/kernel-inventory.json @@ -22,6 +22,7 @@ "Source/CommandForm/commandFormMarkers.ts", "Source/CommandForm/fields/chipValues.ts", "Source/CommandForm/fields/fieldValueFromEvent.ts", + "Source/Common/classNames.ts", "Source/DataTables/DataTableFilterMatcherRegistry.ts", "Source/DataTables/DataTableFilterMeta.ts", "Source/DataTables/paginatorRange.ts", From 295fb3c492235a78a61ea8222115d5461e30327e Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 15:06:57 +0200 Subject: [PATCH 6/7] Move the shared classNames helper out of Common Per-area stylesheets follow the source directories a subpath reaches, so importing a pure helper from Common/ pulled Common's CSS into the Chat and TimeMachine areas and broke their budgets. The helper now lives in its own CSS-free ClassNames/ folder, as QueryStatus/ does for its helper. --- ESLint/lib/kernelBoundary.js | 2 +- Source/Chat/ChatSidebar.tsx | 2 +- Source/{Common => ClassNames}/classNames.ts | 0 Source/Common/Breadcrumbs.tsx | 2 +- Source/Common/ComboBox.tsx | 2 +- Source/Common/DatePickerInputImplementation.tsx | 2 +- Source/Common/NumberInput.tsx | 2 +- Source/Common/Tabs.tsx | 2 +- Source/Common/TagGroup.tsx | 2 +- Source/Common/ToggleGroup.tsx | 2 +- Source/DataTables/ColumnFilterMenu.tsx | 2 +- Source/DataTables/DataTableBody.tsx | 2 +- Source/DataTables/DataTableCore.tsx | 2 +- Source/DataTables/DataTableHeaderCell.tsx | 2 +- Source/DataTables/DataTableRow.tsx | 2 +- Source/DataTables/DataTableSearch.tsx | 2 +- Source/Dialogs/DialogImplementation.tsx | 2 +- Source/Dropdown/DropdownImplementation.tsx | 2 +- Source/Notifications/Toaster.tsx | 2 +- Source/TimeMachine/EventsView.tsx | 2 +- Source/scripts/kernel-inventory.json | 2 +- 21 files changed, 20 insertions(+), 20 deletions(-) rename Source/{Common => ClassNames}/classNames.ts (100%) diff --git a/ESLint/lib/kernelBoundary.js b/ESLint/lib/kernelBoundary.js index 76e94972..7770fcf6 100644 --- a/ESLint/lib/kernelBoundary.js +++ b/ESLint/lib/kernelBoundary.js @@ -26,7 +26,7 @@ export const kernelSourcePaths = Object.freeze([ 'Source/DataTables/DataTableFilterMeta.ts', 'Source/DataTables/paginatorRange.ts', 'Source/DataTables/selectionKeys.ts', - 'Source/Common/classNames.ts', + 'Source/ClassNames/classNames.ts', 'Source/CommandDialog/applyBeforeExecute.ts', 'Source/CommandForm/commandFormMarkers.ts', 'Source/CommandForm/fields/chipValues.ts', diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index 2e9a26c4..fcd6dd53 100644 --- a/Source/Chat/ChatSidebar.tsx +++ b/Source/Chat/ChatSidebar.tsx @@ -21,7 +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 '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; type ChatSidebarPartAttributes = HTMLAttributes & { [attribute: `data-${string}`]: string | number | boolean | undefined; diff --git a/Source/Common/classNames.ts b/Source/ClassNames/classNames.ts similarity index 100% rename from Source/Common/classNames.ts rename to Source/ClassNames/classNames.ts diff --git a/Source/Common/Breadcrumbs.tsx b/Source/Common/Breadcrumbs.tsx index 20f2f264..9341e359 100644 --- a/Source/Common/Breadcrumbs.tsx +++ b/Source/Common/Breadcrumbs.tsx @@ -9,7 +9,7 @@ import { } from 'react-aria-components/Breadcrumbs'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; -import { classNames } from './classNames'; +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< diff --git a/Source/Common/ComboBox.tsx b/Source/Common/ComboBox.tsx index 74af8d7a..0d1f8217 100644 --- a/Source/Common/ComboBox.tsx +++ b/Source/Common/ComboBox.tsx @@ -31,7 +31,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from './reactAriaProps'; -import { classNames } from './classNames'; +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< diff --git a/Source/Common/DatePickerInputImplementation.tsx b/Source/Common/DatePickerInputImplementation.tsx index 90a6ef91..71a30cca 100644 --- a/Source/Common/DatePickerInputImplementation.tsx +++ b/Source/Common/DatePickerInputImplementation.tsx @@ -33,7 +33,7 @@ import { toCalendarDate, } from '@internationalized/date'; import type { DatePickerInputProps } from './DatePickerInput'; -import { classNames } from './classNames'; +import { classNames } from '../ClassNames/classNames'; const asDateValue = ( value: Date | null, diff --git a/Source/Common/NumberInput.tsx b/Source/Common/NumberInput.tsx index c017bc66..8f53a830 100644 --- a/Source/Common/NumberInput.tsx +++ b/Source/Common/NumberInput.tsx @@ -20,7 +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'; +import { classNames } from '../ClassNames/classNames'; /** Stable Cratis-owned parts for styling a {@link NumberInput}. */ export interface NumberInputParts { diff --git a/Source/Common/Tabs.tsx b/Source/Common/Tabs.tsx index 646de5c1..b9fc7a66 100644 --- a/Source/Common/Tabs.tsx +++ b/Source/Common/Tabs.tsx @@ -10,7 +10,7 @@ import { } from 'react-aria-components/Tabs'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; -import { classNames } from './classNames'; +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< diff --git a/Source/Common/TagGroup.tsx b/Source/Common/TagGroup.tsx index b9e1cd6f..8c6a7355 100644 --- a/Source/Common/TagGroup.tsx +++ b/Source/Common/TagGroup.tsx @@ -18,7 +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'; +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< diff --git a/Source/Common/ToggleGroup.tsx b/Source/Common/ToggleGroup.tsx index 12e21d37..dab6d607 100644 --- a/Source/Common/ToggleGroup.tsx +++ b/Source/Common/ToggleGroup.tsx @@ -8,7 +8,7 @@ import { } from 'react-aria-components/ToggleButtonGroup'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; -import { classNames } from './classNames'; +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< diff --git a/Source/DataTables/ColumnFilterMenu.tsx b/Source/DataTables/ColumnFilterMenu.tsx index fe0b6b74..090ab562 100644 --- a/Source/DataTables/ColumnFilterMenu.tsx +++ b/Source/DataTables/ColumnFilterMenu.tsx @@ -27,7 +27,7 @@ import { type DataTableFilterConstraint, type DataTableFilterMatchMode as FilterMatchMode, } from './DataTableFilterMeta'; -import { classNames } from '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; /** Value editor used by a built-in column filter. */ export type ColumnFilterDataType = 'text' | 'numeric' | 'date' | 'boolean'; diff --git a/Source/DataTables/DataTableBody.tsx b/Source/DataTables/DataTableBody.tsx index a5be2d42..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 '../Common/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 fab09c8e..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 '../Common/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 f35c70cc..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 '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; import type { DataTableFilterConstraint, DataTableFilterMeta, diff --git a/Source/DataTables/DataTableRow.tsx b/Source/DataTables/DataTableRow.tsx index da5c6222..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 '../Common/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 447440a5..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 '../Common/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 054ec5c4..672cb242 100644 --- a/Source/Dialogs/DialogImplementation.tsx +++ b/Source/Dialogs/DialogImplementation.tsx @@ -18,7 +18,7 @@ import { openDialogTier, } from '../renderer/dialogStack'; import { DialogStackContext } from '../renderer/DialogStackContext'; -import { classNames } from '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; const subscribeToBrowserEnvironment = () => () => undefined; const useIsBrowser = () => diff --git a/Source/Dropdown/DropdownImplementation.tsx b/Source/Dropdown/DropdownImplementation.tsx index aebfab2d..4133c9f8 100644 --- a/Source/Dropdown/DropdownImplementation.tsx +++ b/Source/Dropdown/DropdownImplementation.tsx @@ -34,7 +34,7 @@ import { asReactAriaListBoxItemProps, asReactAriaListBoxProps, } from '../Common/reactAriaProps'; -import { classNames } from '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; type DropdownOptionValue = string | number | boolean | bigint | symbol | object | null | undefined; diff --git a/Source/Notifications/Toaster.tsx b/Source/Notifications/Toaster.tsx index 668d9dd4..60d88f12 100644 --- a/Source/Notifications/Toaster.tsx +++ b/Source/Notifications/Toaster.tsx @@ -20,7 +20,7 @@ import { } from './toast'; import type { ExactPartKeys } from '../types/ExactPartKeys'; import type { PartsOf } from '../types/parts'; -import { classNames } from '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; /** Supported viewport positions for the global toast region. */ export type ToasterPosition = diff --git a/Source/TimeMachine/EventsView.tsx b/Source/TimeMachine/EventsView.tsx index 4b3497ec..ced340e5 100644 --- a/Source/TimeMachine/EventsView.tsx +++ b/Source/TimeMachine/EventsView.tsx @@ -14,7 +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 '../Common/classNames'; +import { classNames } from '../ClassNames/classNames'; /** Stable part attributes for {@link EventsView}. */ export interface EventsViewParts { diff --git a/Source/scripts/kernel-inventory.json b/Source/scripts/kernel-inventory.json index 42e28619..91bf500a 100644 --- a/Source/scripts/kernel-inventory.json +++ b/Source/scripts/kernel-inventory.json @@ -18,11 +18,11 @@ "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", "Source/CommandForm/fields/fieldValueFromEvent.ts", - "Source/Common/classNames.ts", "Source/DataTables/DataTableFilterMatcherRegistry.ts", "Source/DataTables/DataTableFilterMeta.ts", "Source/DataTables/paginatorRange.ts", From 7fc324f8c6a0be34cd994f6d7c851cfd665d5bd9 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 15:13:47 +0200 Subject: [PATCH 7/7] Probe source-map navigation through a type Canvas.tsx still declares CanvasItemRegistryEntry moved to its own declaration file, which the build does not rewrite. CanvasProps is still declared in the rewritten Canvas.d.ts, so the check keeps testing navigation through a rewritten declaration. --- Source/scripts/verify-source-maps.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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);