mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 09:17:42 +02:00
[eric] tiling: one writer owns a tiled card's rect, so a stale camera can't freeze or whiten it
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// The ONE writer of a tiled card's on-screen rect. A tiled card lives INSIDE the pan/zoom layer (so
|
||||
// its webview is never remounted, which would log the user out) yet has to hold still against the
|
||||
// viewport, so something must undo the camera on every frame. That job used to be split between
|
||||
// React, framer-motion and an imperative !important writer, and any frame where they disagreed left
|
||||
// the tile frozen at a stale camera. Now React owns only the card's SIZE, which no camera can
|
||||
// change, and this module owns exactly one property, `transform`, written in the same task that
|
||||
// paints the canvas itself. Two sources can no longer hold two different cameras.
|
||||
|
||||
export interface Camera {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export const TILE_ZONES: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
fill: { x: 0, y: 0, w: 1, h: 1 },
|
||||
left: { x: 0, y: 0, w: 0.5, h: 1 },
|
||||
right: { x: 0.5, y: 0, w: 0.5, h: 1 },
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
bottom: { x: 0, y: 0.5, w: 1, h: 0.5 },
|
||||
tl: { x: 0, y: 0, w: 0.5, h: 0.5 },
|
||||
tr: { x: 0.5, y: 0, w: 0.5, h: 0.5 },
|
||||
bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 },
|
||||
br: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
|
||||
t3l: { x: 0, y: 0, w: 1 / 3, h: 1 },
|
||||
t3c: { x: 1 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
};
|
||||
|
||||
// macOS Sequoia leaves a small gap between tiled windows; we match it.
|
||||
const GAP = 8;
|
||||
|
||||
export interface ZoneRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
x0: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// Pure layout, never the camera, so a pan/zoom frame reads nothing back out of the DOM.
|
||||
let workspace: Workspace | null = null;
|
||||
|
||||
function measureWorkspace(): Workspace {
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
const r = el?.getBoundingClientRect();
|
||||
if (!r || !(r.width > 0 && r.height > 0)) return { x0: 0, w: window.innerWidth, h: window.innerHeight };
|
||||
let x0 = 0;
|
||||
const dock = document.querySelector('[data-desktop-dock]');
|
||||
if (dock) {
|
||||
const dr = dock.getBoundingClientRect();
|
||||
// macOS model: a tiled window starts beside the Dock, never underneath it.
|
||||
if (dr.width > 0 && dr.right > r.left && dr.left < r.left + r.width * 0.25) x0 = dr.right - r.left + GAP;
|
||||
}
|
||||
return { x0, w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
// Screen-space rect (relative to the canvas viewport) that a zone occupies.
|
||||
export function zoneRect(zone: string): ZoneRect | null {
|
||||
const ws = workspace ?? (workspace = measureWorkspace());
|
||||
if (zone === 'fullscreen') {
|
||||
return { x: ws.x0 + GAP, y: GAP, w: ws.w - ws.x0 - GAP * 2, h: ws.h - GAP * 2 };
|
||||
}
|
||||
const z = TILE_ZONES[zone];
|
||||
if (!z) return null;
|
||||
const usableW = ws.w - ws.x0;
|
||||
return {
|
||||
x: ws.x0 + z.x * usableW + GAP,
|
||||
y: z.y * ws.h + GAP,
|
||||
w: z.w * usableW - GAP * 2,
|
||||
h: z.h * ws.h - GAP * 2,
|
||||
};
|
||||
}
|
||||
|
||||
interface TiledEntry {
|
||||
el: HTMLElement;
|
||||
zone: string;
|
||||
// The card's own canvas-space origin, which React keeps owning. The transform below is only the
|
||||
// delta from there to the zone, so tiling never rewrites a stored position and untiling never jumps.
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
const entries = new Map<string, TiledEntry>();
|
||||
const workspaceListeners = new Set<() => void>();
|
||||
let lastCamera: Camera = { panX: 0, panY: 0, zoom: 1 };
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
function applyEntry(entry: TiledEntry, cam: Camera): void {
|
||||
const r = zoneRect(entry.zone);
|
||||
if (!r) return;
|
||||
const s = 1 / cam.zoom;
|
||||
const tx = (r.x - cam.panX) * s - entry.originX;
|
||||
const ty = (r.y - cam.panY) * s - entry.originY;
|
||||
entry.el.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`;
|
||||
}
|
||||
|
||||
// Called by the canvas's one camera-to-DOM writer, in the same task as the canvas transform.
|
||||
export function syncTiledGeometry(cam: Camera): void {
|
||||
lastCamera = cam;
|
||||
for (const entry of entries.values()) applyEntry(entry, cam);
|
||||
}
|
||||
|
||||
function onWorkspaceChanged(): void {
|
||||
workspace = null;
|
||||
for (const entry of entries.values()) applyEntry(entry, lastCamera);
|
||||
workspaceListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// Lets a tiled card re-render for its new SIZE when the workspace changes shape.
|
||||
export function subscribeTiledWorkspace(fn: () => void): () => void {
|
||||
workspaceListeners.add(fn);
|
||||
return () => { workspaceListeners.delete(fn); };
|
||||
}
|
||||
|
||||
function startObserving(): void {
|
||||
if (observer) return;
|
||||
observer = new ResizeObserver(onWorkspaceChanged);
|
||||
const vp = document.querySelector('[data-canvas-viewport]');
|
||||
if (vp) observer.observe(vp);
|
||||
const dock = document.querySelector('[data-desktop-dock]');
|
||||
if (dock) observer.observe(dock);
|
||||
window.addEventListener('resize', onWorkspaceChanged);
|
||||
}
|
||||
|
||||
function stopObserving(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
window.removeEventListener('resize', onWorkspaceChanged);
|
||||
}
|
||||
|
||||
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void {
|
||||
const el = document.querySelector<HTMLElement>(`[data-select-id="${CSS.escape(id)}"]`);
|
||||
if (!el) return;
|
||||
const entry: TiledEntry = { el, zone, originX: origin.x, originY: origin.y };
|
||||
entries.set(id, entry);
|
||||
startObserving();
|
||||
// Tiling usually commits alongside chrome collapsing, so a cached workspace is untrustworthy here.
|
||||
workspace = null;
|
||||
lastCamera = cam;
|
||||
applyEntry(entry, cam);
|
||||
}
|
||||
|
||||
export function unregisterTiledCard(id: string): void {
|
||||
const entry = entries.get(id);
|
||||
if (!entry) return;
|
||||
// Hand the property straight back to React's styles; nothing else ever wrote it.
|
||||
entry.el.style.transform = '';
|
||||
entries.delete(id);
|
||||
if (entries.size === 0) stopObserving();
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
clearTiledCard,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import WindowControls, { ARC_CHIP_SX } from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useTiledCard } from './useTiledCard';
|
||||
import { useCardTiling } from './useCardTiling';
|
||||
import AgentNarratorPill from '../desktop/AgentNarratorPill';
|
||||
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
|
||||
@@ -660,19 +660,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const isFullscreen = tileZone === 'fullscreen';
|
||||
// Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom.
|
||||
// Chat cards read the camera via a getter (not props) to avoid re-rendering on every pan tick, so
|
||||
// we subscribe to the pan event ONLY while tiled (one card at most), and read fresh camera then.
|
||||
const [tileTick, setTileTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!tileZone) return undefined;
|
||||
const onPan = (): void => setTileTick((t) => t + 1);
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
}, [tileZone]);
|
||||
void tileTick;
|
||||
const cam = getCanvasState();
|
||||
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, session.id);
|
||||
const isTiled = !!tileZone;
|
||||
// A collapsed chat can never stay tiled: collapsing while fullscreen left a white full-window shell
|
||||
// (the header collapse control still fires in full size view). Seal the state instead of the path.
|
||||
useEffect(() => {
|
||||
@@ -751,6 +739,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const activeW = localResize?.w ?? cardWidth;
|
||||
const activeH = localResize?.h ?? cardHeight;
|
||||
const tiledSize = useTiledCard({ cardId: session.id, zone: tileZone, active: true, originX: activeX, originY: activeY, getCamera: getCanvasState });
|
||||
|
||||
const isBranchSpawn = spawnFrom?.type === 'branch';
|
||||
const spawnInitial = spawnFrom
|
||||
@@ -778,14 +767,14 @@ const AgentCard: React.FC<Props> = ({
|
||||
<motion.div
|
||||
layout={false}
|
||||
initial={spawnInitial}
|
||||
animate={{ opacity: 1, scale: 1, left: tiledStyle ? tiledStyle.left : activeX, top: tiledStyle ? tiledStyle.top : activeY }}
|
||||
animate={{ opacity: 1, scale: 1, left: activeX, top: activeY }}
|
||||
exit={exitAnimation}
|
||||
// While tiled the card is pinned to the viewport: position must track pan instantly, never spring.
|
||||
transition={tiledStyle ? { ...spawnTransition, left: { duration: 0 }, top: { duration: 0 } } : spawnTransition}
|
||||
transition={isTiled ? { ...spawnTransition, left: { duration: 0 }, top: { duration: 0 } } : spawnTransition}
|
||||
onPointerDownCapture={() => onBringToFront?.(session.id, 'agent')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: tiledStyle ? 999990 : isDragging || isResizing ? 999999 : cardZOrder,
|
||||
zIndex: isTiled ? 999990 : isDragging || isResizing ? 999999 : cardZOrder,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -821,7 +810,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
// Hover runway for the pop-above header: the header is pointer-events:none until the CARD
|
||||
// is hovered, but it floats ABOVE the card's box, so without this strip the pointer leaving
|
||||
// the card to reach it dropped :hover and the header died mid-approach (chats ungrabbable).
|
||||
...(expanded && !tiledStyle && !pillMode && {
|
||||
...(expanded && !isTiled && !pillMode && {
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
@@ -835,10 +824,9 @@ const AgentCard: React.FC<Props> = ({
|
||||
contain: 'layout style',
|
||||
// Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas.
|
||||
willChange: 'transform',
|
||||
width: pillMode ? 'fit-content' : tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)),
|
||||
height: tiledStyle ? tiledStyle.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')),
|
||||
transform: tiledStyle ? tiledStyle.transform : undefined,
|
||||
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
|
||||
width: pillMode ? 'fit-content' : tiledSize ? tiledSize.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)),
|
||||
height: tiledSize ? tiledSize.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')),
|
||||
transformOrigin: tiledSize ? '0 0' : undefined,
|
||||
bgcolor: c.bg.surface,
|
||||
border: isHighlighted
|
||||
? `2px solid ${c.accent.primary}`
|
||||
@@ -941,16 +929,16 @@ const AgentCard: React.FC<Props> = ({
|
||||
...(expanded && {
|
||||
// Warm near-neutral dark (the Claude/ChatGPT family) instead of the saturated plum: long
|
||||
// reading sessions want a quiet ground; the accent system carries the brand color.
|
||||
bgcolor: tiledStyle ? 'rgb(33,31,36)' : 'rgba(33,31,36,0.88)',
|
||||
...(tiledStyle ? {} : {
|
||||
bgcolor: isTiled ? 'rgb(33,31,36)' : 'rgba(33,31,36,0.88)',
|
||||
...(isTiled ? {} : {
|
||||
backdropFilter: 'blur(24px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(150%)',
|
||||
}),
|
||||
border: isFullscreen ? 'none' : isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: tiledStyle ? '12px' : '20px',
|
||||
borderRadius: isTiled ? '12px' : '20px',
|
||||
boxShadow: '0 18px 48px rgba(0,0,0,0.4)',
|
||||
// The hover header floats ABOVE the card; the root must not clip it (the chat body clips itself).
|
||||
...(tiledStyle ? {} : { overflow: 'visible' }),
|
||||
...(isTiled ? {} : { overflow: 'visible' }),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
@@ -995,7 +983,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
{/* Grab band: the top sliver of an expanded card drags it, matching the "grab the window by
|
||||
its top edge" instinct; the pop-above header remains the labeled handle. */}
|
||||
{expanded && !tiledStyle && !pillMode && (
|
||||
{expanded && !isTiled && !pillMode && (
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
@@ -1056,7 +1044,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
onLostPointerCapture={abortDrag}
|
||||
sx={{
|
||||
...(expanded
|
||||
? tiledStyle
|
||||
? isTiled
|
||||
? {
|
||||
// Fullscreen/tiled: no room above the card, so the scrim rides on top. Never hidden: in fullscreen the title and the lights are the only way out.
|
||||
position: 'absolute',
|
||||
@@ -1124,7 +1112,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
// Expanded titles wear the same glass bubble as the collapsed pill; a bare label floating over the canvas read as a stray caption.
|
||||
...(expanded && !tiledStyle && {
|
||||
...(expanded && !isTiled && {
|
||||
alignSelf: 'flex-start',
|
||||
flex: '0 1 auto',
|
||||
// mr auto or the row's space-between flings the bubble to the far edge, away from the lights.
|
||||
@@ -1138,7 +1126,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
WebkitBackdropFilter: GLASS_SURFACE_BLUR,
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
}),
|
||||
...(!(expanded && !tiledStyle) && { borderRadius: 1 }),
|
||||
...(!(expanded && !isTiled) && { borderRadius: 1 }),
|
||||
}}
|
||||
>
|
||||
<InlineEditableTitle
|
||||
@@ -1226,7 +1214,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: tiledStyle ? undefined : '20px',
|
||||
borderRadius: isTiled ? undefined : '20px',
|
||||
}}
|
||||
>
|
||||
<DarkTokensScope>
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type BrowserTab,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import WindowControls from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useTiledCard } from './useTiledCard';
|
||||
import { useCardTiling } from './useCardTiling';
|
||||
import { getMinimizedShot, saveMinimizedShot } from '../desktop/minimizedShots';
|
||||
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
|
||||
@@ -234,17 +234,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}, [dispatch, browserId]);
|
||||
const tiling = useCardTiling({ cardId: browserId, getCanvasState, commitPosition: commitCardPosition });
|
||||
const tileZone = tiling.zone;
|
||||
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
|
||||
const [tileTick, setTileTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!tileZone) return undefined;
|
||||
const onPan = (): void => setTileTick((t) => t + 1);
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
}, [tileZone]);
|
||||
void tileTick;
|
||||
const cam = getCanvasState();
|
||||
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, browserId);
|
||||
const isTiled = !!tileZone;
|
||||
const onTile = tiling.applyZone;
|
||||
|
||||
// ---- In-chat dock: while docked to an expanded chat, the card overlays the chat's slot rect.
|
||||
@@ -1015,12 +1005,13 @@ const BrowserCard: React.FC<Props> = ({
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md;
|
||||
|
||||
const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !keepAliveHidden && !isMinimized;
|
||||
const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !keepAliveHidden && !isMinimized;
|
||||
// Chat collapsed: its docked browser parks off-screen and lives on as the pill's frozen shot,
|
||||
// instead of teleporting back to wherever it sat before docking. The park waits for that shot:
|
||||
// an off-screen guest never paints again, and capturePage on one never settles (Electron 42).
|
||||
const wantsDockPark = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dragging && !tiledStyle && !isMinimized && !keepAliveHidden;
|
||||
const wantsDockPark = !!dockedTo && !!dockParentCard && !dockParentExpanded && !dragging && !isTiled && !isMinimized && !keepAliveHidden;
|
||||
const dockParked = wantsDockPark && pillShotSettled;
|
||||
const tiledSize = useTiledCard({ cardId: browserId, zone: tileZone, active: !keepAliveHidden && !isMinimized && !dockParked, originX: displayX, originY: displayY, getCamera: getCanvasState });
|
||||
const pillShotPaintable = !!pillShotOwner && !dockParked && !isMinimized && !keepAliveHidden && !suspendedSnap;
|
||||
useEffect(() => {
|
||||
if (!pillShotPaintable) return undefined;
|
||||
@@ -1098,12 +1089,12 @@ const BrowserCard: React.FC<Props> = ({
|
||||
contain: 'layout style',
|
||||
// Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale.
|
||||
willChange: 'transform',
|
||||
left: keepAliveHidden || isMinimized || dockParked ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
|
||||
top: tiledStyle && !(keepAliveHidden || isMinimized || dockParked) ? tiledStyle.top : dockActive ? dockRect!.y : (dragging ? cardY : displayY),
|
||||
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
|
||||
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
|
||||
width: tiledStyle ? tiledStyle.width : dockActive ? dockRect!.w : displayW,
|
||||
height: tiledStyle ? tiledStyle.height : dockActive ? dockRect!.h : displayH,
|
||||
left: keepAliveHidden || isMinimized || dockParked ? -100000 : (dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
|
||||
top: dockActive ? dockRect!.y : (dragging ? cardY : displayY),
|
||||
transform: tiledSize ? undefined : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
|
||||
transformOrigin: tiledSize ? '0 0' : undefined,
|
||||
width: tiledSize ? tiledSize.width : dockActive ? dockRect!.w : displayW,
|
||||
height: tiledSize ? tiledSize.height : dockActive ? dockRect!.h : displayH,
|
||||
borderRadius: tileZone === 'fullscreen' ? '12px' : dockActive ? '10px' : `${c.radius.lg}px`,
|
||||
border: agentBorder,
|
||||
bgcolor: c.bg.surface,
|
||||
@@ -1111,7 +1102,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
|
||||
zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
...(isHighlighted && {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useTiledCard } from './useTiledCard';
|
||||
import { useCardTiling } from './useCardTiling';
|
||||
import { useCanvasWindowResize } from './useCanvasWindowResize';
|
||||
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||
@@ -66,17 +66,6 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const tiling = useCardTiling({ cardId, getCanvasState, commitPosition: onCommitPosition });
|
||||
// A tile pins the card to the viewport, so its geometry must track pan/zoom like the tiled
|
||||
// agent/browser cards; reuse the exact same helper. Subscribe to pan only while tiled.
|
||||
const [, forceTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!tiling.isTiled) return undefined;
|
||||
const onPan = (): void => forceTick((t) => t + 1);
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
}, [tiling.isTiled]);
|
||||
const cam = getCanvasState();
|
||||
const tiledStyle = useTiledStyle(tiling.zone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardId);
|
||||
|
||||
// ---- Drag (title bar is the handle) ----
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
@@ -169,6 +158,7 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy;
|
||||
const dw = localResize?.w ?? cardWidth;
|
||||
const dh = localResize?.h ?? cardHeight;
|
||||
const tiledSize = useTiledCard({ cardId, zone: tiling.zone, active: !minimized, originX: dx, originY: dy, getCamera: getCanvasState });
|
||||
|
||||
const border = isHighlighted ? `2px solid ${highlightColor}` : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.subtle}`;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
@@ -196,14 +186,12 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
willChange: 'transform',
|
||||
// Parked windows go off-canvas rather than unmounting, so Settings keeps its form and Workflows its view state.
|
||||
pointerEvents: minimized ? 'none' : undefined,
|
||||
// Belt and braces: leaving fullscreen tears down the tiled-style hook, whose cleanup strips the inline left/top React just wrote, and visibility is the one park signal it never touches.
|
||||
visibility: minimized ? 'hidden' : undefined,
|
||||
left: minimized ? -100000 : tiledStyle ? tiledStyle.left : dx,
|
||||
top: minimized ? -100000 : tiledStyle ? tiledStyle.top : dy,
|
||||
width: tiledStyle ? tiledStyle.width : dw,
|
||||
height: tiledStyle ? tiledStyle.height : dh,
|
||||
transform: minimized ? undefined : tiledStyle ? tiledStyle.transform : undefined,
|
||||
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
|
||||
left: minimized ? -100000 : dx,
|
||||
top: minimized ? -100000 : dy,
|
||||
width: tiledSize ? tiledSize.width : dw,
|
||||
height: tiledSize ? tiledSize.height : dh,
|
||||
transformOrigin: tiledSize ? '0 0' : undefined,
|
||||
background,
|
||||
border: tiling.isFullscreen ? 'none' : border,
|
||||
borderRadius: c.radius.lg,
|
||||
@@ -211,7 +199,7 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
|
||||
zIndex: tiledSize ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import WindowControls from './WindowControls';
|
||||
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
|
||||
import { viewCardMenuRows } from './viewCardMenuRows';
|
||||
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import { useTiledCard } from './useTiledCard';
|
||||
import { useCardTiling } from './useCardTiling';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
@@ -167,17 +167,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
if (previewDeferred && (isSelected || interactive)) dispatch(activateViewCardPreview(cardKey));
|
||||
}, [previewDeferred, isSelected, interactive, cardKey, dispatch]);
|
||||
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
|
||||
const [tileTick, setTileTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!tileZone) return undefined;
|
||||
const onPan = (): void => setTileTick((t) => t + 1);
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
|
||||
}, [tileZone]);
|
||||
void tileTick;
|
||||
const cam = getCanvasState();
|
||||
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardKey);
|
||||
const isTiled = !!tileZone;
|
||||
const isFullscreen = tileZone === 'fullscreen';
|
||||
|
||||
// ---- In-chat dock (mirrors BrowserCard): while docked to an expanded chat, overlay its slot rect.
|
||||
@@ -635,7 +625,8 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
const dragTx = dragging ? displayX - cardX : 0;
|
||||
const dragTy = dragging ? displayY - cardY : 0;
|
||||
|
||||
const dockActive = !!dockRect && !dragging && !localResize && !tiledStyle && !isMinimized;
|
||||
const dockActive = !!dockRect && !dragging && !localResize && !isTiled && !isMinimized;
|
||||
const tiledSize = useTiledCard({ cardId: cardKey, zone: tileZone, active: !isMinimized, originX: displayX, originY: displayY, getCamera: getCanvasState });
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -675,12 +666,12 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
// Minimized apps live in the right-edge rail, so the card itself parks off-canvas at full size
|
||||
// (same trick as browser cards) and restores to exactly the geometry it left.
|
||||
pointerEvents: isMinimized ? 'none' : undefined,
|
||||
left: isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
|
||||
top: isMinimized ? -100000 : (tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY)),
|
||||
width: tiledStyle ? tiledStyle.width : displayW,
|
||||
height: tiledStyle ? tiledStyle.height : displayH,
|
||||
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
|
||||
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
|
||||
left: isMinimized ? -100000 : (dockActive ? dockRect!.x : (dragging ? cardX : displayX)),
|
||||
top: isMinimized ? -100000 : (dragging ? cardY : displayY),
|
||||
width: tiledSize ? tiledSize.width : displayW,
|
||||
height: tiledSize ? tiledSize.height : displayH,
|
||||
transform: tiledSize ? undefined : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
|
||||
transformOrigin: tiledSize ? '0 0' : undefined,
|
||||
borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`,
|
||||
border: isHighlighted
|
||||
? `2px solid ${c.accent.primary}`
|
||||
@@ -702,7 +693,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
|
||||
zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
...(isHighlighted && {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { TILE_GROUPS, TILE_ZONES, ZONE_LABELS } from './tileZones';
|
||||
import { TILE_ZONES } from '../canvas/tiledGeometry';
|
||||
import { TILE_GROUPS, ZONE_LABELS } from './tileZones';
|
||||
|
||||
interface WindowControlsProps {
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
// macOS-style tiling zones (fractions of the workspace) + the math to render a tiled card at a
|
||||
// screen-space viewport region WITHOUT leaving the transformed canvas layer, so a browser/app
|
||||
// card's webview is never remounted (which would log the user out). The card stays a child of the
|
||||
// pan/zoom layer; we counter-transform (scale 1/zoom) and place it in canvas coords so it lands
|
||||
// pixel-exact at the viewport region at 100% content scale. Recompute on pan/zoom to stay put.
|
||||
|
||||
export const TILE_ZONES: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
fill: { x: 0, y: 0, w: 1, h: 1 },
|
||||
left: { x: 0, y: 0, w: 0.5, h: 1 },
|
||||
right: { x: 0.5, y: 0, w: 0.5, h: 1 },
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
bottom: { x: 0, y: 0.5, w: 1, h: 0.5 },
|
||||
tl: { x: 0, y: 0, w: 0.5, h: 0.5 },
|
||||
tr: { x: 0.5, y: 0, w: 0.5, h: 0.5 },
|
||||
bl: { x: 0, y: 0.5, w: 0.5, h: 0.5 },
|
||||
br: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
|
||||
t3l: { x: 0, y: 0, w: 1 / 3, h: 1 },
|
||||
t3c: { x: 1 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
t3r: { x: 2 / 3, y: 0, w: 1 / 3, h: 1 },
|
||||
};
|
||||
// The zone catalog the tiling MENUS render. The geometry itself lives in canvas/tiledGeometry.
|
||||
|
||||
export const ZONE_LABELS: Record<string, string> = {
|
||||
fill: 'Fill', left: 'Left half', right: 'Right half', top: 'Top half', bottom: 'Bottom half',
|
||||
@@ -34,176 +13,3 @@ export const TILE_GROUPS: { label: string; zones: string[] }[] = [
|
||||
{ label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] },
|
||||
{ label: 'Thirds', zones: ['t3l', 't3c', 't3r'] },
|
||||
];
|
||||
|
||||
// macOS Sequoia leaves a small gap between tiled windows; we match it.
|
||||
const GAP = 8;
|
||||
|
||||
export interface TiledStyle {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: string;
|
||||
transformOrigin: string;
|
||||
}
|
||||
|
||||
// The workspace = the canvas viewport element (already below the app header), measured live so we
|
||||
// never hardcode chrome sizes that drift. Its screen origin cancels out of the math below because
|
||||
// the card shares the viewport's coordinate system. x0 insets the LEFT edge past the floating dock
|
||||
// rail (macOS model: tiled windows start beside the Dock, never under it), which is what keeps
|
||||
// fullscreen AND half/quarter tiles clear of it.
|
||||
function workspaceRect(): { x0: number; w: number; h: number } {
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
const r = el?.getBoundingClientRect();
|
||||
if (!r || !(r.width > 0 && r.height > 0)) return { x0: 0, w: window.innerWidth, h: window.innerHeight };
|
||||
let x0 = 0;
|
||||
const dock = document.querySelector('[data-desktop-dock]');
|
||||
if (dock) {
|
||||
const dr = dock.getBoundingClientRect();
|
||||
if (dr.width > 0 && dr.right > r.left && dr.left < r.left + r.width * 0.25) x0 = dr.right - r.left + GAP;
|
||||
}
|
||||
return { x0, w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
export function computeTiledStyle(zone: string, panX: number, panY: number, zoom: number): TiledStyle | null {
|
||||
// 'fullscreen' = the card expands into the ENTIRE dashboard, floating on the same gap the tile
|
||||
// zones use. Anchored to the VIEWPORT (not the window) so docked chrome, like the pinned sidebar,
|
||||
// squeezes the card instead of being covered by it (the canvas viewport shrinks with the sidebar).
|
||||
if (zone === 'fullscreen') {
|
||||
const { x0, w: vpW, h: vpH } = workspaceRect();
|
||||
return {
|
||||
left: (x0 + GAP - panX) / zoom,
|
||||
top: (GAP - panY) / zoom,
|
||||
width: vpW - x0 - GAP * 2,
|
||||
height: vpH - GAP * 2,
|
||||
transform: `scale(${1 / zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
};
|
||||
}
|
||||
const z = TILE_ZONES[zone];
|
||||
if (!z) return null;
|
||||
const { x0, w: vpW, h: vpH } = workspaceRect();
|
||||
const usableW = vpW - x0;
|
||||
// Screen region (vpX + GAP, vpY + GAP, ...) converted to canvas coords: card lives inside the
|
||||
// pan/zoom layer, so screen = viewportOrigin + pan + canvasPos*zoom, and viewportOrigin cancels.
|
||||
return {
|
||||
left: (x0 + z.x * usableW + GAP - panX) / zoom,
|
||||
top: (z.y * vpH + GAP - panY) / zoom,
|
||||
width: z.w * usableW - GAP * 2,
|
||||
height: z.h * vpH - GAP * 2,
|
||||
transform: `scale(${1 / zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
};
|
||||
}
|
||||
|
||||
// Tiled geometry depends on live DOM measurements, so re-render when the workspace resizes:
|
||||
// the chrome collapsing on fullscreen-enter, a window resize, a banner appearing. The initial
|
||||
// ResizeObserver fire also re-measures right after the same-commit layout change that set the zone.
|
||||
export function useTiledStyle(
|
||||
zone: string | undefined,
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
// Live sync: with these, every camera write restyles the card element IN THE SAME TASK as the
|
||||
// canvas transform. The React path alone lands a commit behind the compositor, so tiled cards
|
||||
// visibly wobbled against the viewport on every zoom/pan frame.
|
||||
getLive?: () => { panX: number; panY: number; zoom: number },
|
||||
selectId?: string,
|
||||
): TiledStyle | null {
|
||||
const [, bump] = React.useReducer((n: number) => n + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!zone) return undefined;
|
||||
const el = document.querySelector('[data-canvas-viewport]');
|
||||
const ro = new ResizeObserver(() => bump());
|
||||
if (el) ro.observe(el);
|
||||
const onResize = (): void => bump();
|
||||
window.addEventListener('resize', onResize);
|
||||
// The chrome collapse commits in the same flush that set the zone, so the first compute
|
||||
// measures the pre-collapse viewport; re-measure after layout settles. Timeouts, not rAF:
|
||||
// rAF (and ResizeObserver delivery, which rides it) freezes in non-focused tabs.
|
||||
// 700ms outlives the banner Collapse (350ms) plus easing tail; RO covers focused tabs live.
|
||||
const timers = [60, 250, 700].map((ms) => window.setTimeout(() => bump(), ms));
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', onResize); timers.forEach((t) => window.clearTimeout(t)); };
|
||||
}, [zone]);
|
||||
React.useEffect(() => {
|
||||
if (!zone || !getLive || !selectId) return undefined;
|
||||
const el = document.querySelector(`[data-select-id="${CSS.escape(selectId)}"]`) as HTMLElement | null;
|
||||
if (!el) return undefined;
|
||||
// left/top belong on whichever element OWNS position: AgentCard splits into a position:absolute
|
||||
// motion wrapper around a position:relative inner Box (writing left/top on the inner Box ADDS to
|
||||
// the wrapper's, rendering the tile at exactly double the offset); Browser/View cards are one
|
||||
// element. Size + counter-scale always live on the select-id element, mirroring the sx path.
|
||||
const posEl = getComputedStyle(el).position === 'absolute' || getComputedStyle(el).position === 'fixed'
|
||||
? el
|
||||
: (el.parentElement as HTMLElement | null) ?? el;
|
||||
const posProps = ['left', 'top'];
|
||||
const sizeProps = ['width', 'height', 'transform', 'transform-origin', 'transition'];
|
||||
// Drop ONLY the overrides this hook still owns: React re-asserts these props without the important
|
||||
// flag when a card leaves its zone, and deleting those left the window with no geometry at all.
|
||||
const clearOwn = (target: HTMLElement, props: string[]): void => {
|
||||
props.forEach((pr) => { if (target.style.getPropertyPriority(pr) === 'important') target.style.removeProperty(pr); });
|
||||
};
|
||||
const clearAll = (): void => {
|
||||
clearOwn(posEl, posProps);
|
||||
clearOwn(el, sizeProps);
|
||||
};
|
||||
const apply = (): void => {
|
||||
// A parked card (kept alive off-screen / minimized) must keep its sx parking position.
|
||||
if (el.getAttribute('data-keepalive-hidden') === '1') { clearAll(); return; }
|
||||
const cam = getLive();
|
||||
const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom);
|
||||
if (!s) return;
|
||||
// 'important' so framer-motion's own left/top writes (computed off the LAGGING React-committed
|
||||
// camera) can't land after us and shift the tile by the stale-camera delta.
|
||||
posEl.style.setProperty('left', `${s.left}px`, 'important');
|
||||
posEl.style.setProperty('top', `${s.top}px`, 'important');
|
||||
el.style.setProperty('width', `${s.width}px`, 'important');
|
||||
el.style.setProperty('height', `${s.height}px`, 'important');
|
||||
el.style.setProperty('transform', s.transform, 'important');
|
||||
el.style.setProperty('transform-origin', s.transformOrigin, 'important');
|
||||
// Transitions OUTRANK inline important in the cascade, and in an occluded window they pin at
|
||||
// t=0 forever, freezing the tile at stale geometry. Tiled cards track the camera instantly
|
||||
// by design, so geometry must never animate here.
|
||||
el.style.setProperty('transition', 'none', 'important');
|
||||
};
|
||||
apply();
|
||||
window.addEventListener('openswarm:canvas-pan-changed', apply);
|
||||
window.addEventListener('resize', apply);
|
||||
// An occluded window freezes rAF (framer, the tracker below), so geometry set while hidden can
|
||||
// land stale; re-apply the moment the window is visible again.
|
||||
document.addEventListener('visibilitychange', apply);
|
||||
window.addEventListener('focus', apply);
|
||||
// The workspace can change size WITHOUT a window resize (chrome collapsing on fullscreen-enter,
|
||||
// a banner appearing). These inline writes OVERRIDE the class styles, so they must re-measure on
|
||||
// the same signals the React path uses or the tile keeps the pre-collapse viewport (the
|
||||
// fullscreen card that stopped short of the bottom edge).
|
||||
const vp = document.querySelector('[data-canvas-viewport]');
|
||||
const ro = new ResizeObserver(apply);
|
||||
if (vp) ro.observe(vp);
|
||||
const timers = [60, 250, 700].map((ms) => window.setTimeout(apply, ms));
|
||||
// animateTo moves the camera WITHOUT emitting pan-changed, and the important-priority writes
|
||||
// above block React's late self-heal, so a tiled card must track the live camera itself:
|
||||
// a write-on-change rAF loop (frozen while hidden is fine; nothing moves visually then).
|
||||
let raf = 0;
|
||||
let lastKey = '';
|
||||
const tick = (): void => {
|
||||
const cam = getLive();
|
||||
const size = workspaceRect();
|
||||
const key = `${cam.panX}:${cam.panY}:${cam.zoom}:${size.x0}:${size.w}:${size.h}`;
|
||||
if (key !== lastKey) { lastKey = key; apply(); }
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
};
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
window.removeEventListener('openswarm:canvas-pan-changed', apply);
|
||||
window.removeEventListener('resize', apply);
|
||||
document.removeEventListener('visibilitychange', apply);
|
||||
window.removeEventListener('focus', apply);
|
||||
ro.disconnect();
|
||||
timers.forEach((tm) => window.clearTimeout(tm));
|
||||
window.cancelAnimationFrame(raf);
|
||||
clearAll();
|
||||
};
|
||||
}, [zone, getLive, selectId]);
|
||||
return zone ? computeTiledStyle(zone, panX, panY, zoom) : null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { computeTiledStyle } from './tileZones';
|
||||
import { zoneRect } from '../canvas/tiledGeometry';
|
||||
|
||||
// THE tiling rule set. Every card that can sit on the canvas (chat, app, browser, workflow, the
|
||||
// Settings window, the Workflows window) routes through this hook, so tiling behaves identically
|
||||
@@ -50,14 +50,14 @@ export function useCardTiling({ cardId, getCanvasState, commitPosition }: CardTi
|
||||
else dispatch(setTiledCard({ cardId, zone: next }));
|
||||
}, [dispatch, cardId]);
|
||||
|
||||
// The rect a tiled card occupies, in canvas coords. computeTiledStyle sizes in SCREEN px (the card
|
||||
// is counter-scaled by 1/zoom), so the footprint has to come back through the zoom.
|
||||
// The rect a tiled card occupies, in canvas coords. zoneRect is in SCREEN px (the card is
|
||||
// counter-scaled by 1/zoom), so the footprint has to come back through the zoom.
|
||||
const tiledFrame = useCallback((): CardFrame | null => {
|
||||
if (!zone) return null;
|
||||
const cam = getCanvasState();
|
||||
const style = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom);
|
||||
if (!style) return null;
|
||||
return { x: style.left, y: style.top, w: style.width / cam.zoom, h: style.height / cam.zoom };
|
||||
const r = zoneRect(zone);
|
||||
if (!r) return null;
|
||||
return { x: (r.x - cam.panX) / cam.zoom, y: (r.y - cam.panY) / cam.zoom, w: r.w / cam.zoom, h: r.h / cam.zoom };
|
||||
}, [zone, getCanvasState]);
|
||||
|
||||
const untileForResize = useCallback((): CardFrame | null => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useLayoutEffect, useReducer, useRef } from 'react';
|
||||
import { registerTiledCard, subscribeTiledWorkspace, unregisterTiledCard, zoneRect, type Camera } from '../canvas/tiledGeometry';
|
||||
|
||||
export interface TiledSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TiledCardArgs {
|
||||
cardId: string;
|
||||
zone: string | undefined;
|
||||
/** False while the card is parked (minimized, docked, kept alive off-screen), where React owns geometry outright. */
|
||||
active: boolean;
|
||||
/** The card's canvas-space left/top exactly as React renders it, tiled or not. */
|
||||
originX: number;
|
||||
originY: number;
|
||||
getCamera: () => Camera;
|
||||
}
|
||||
|
||||
// Hands back the card's tiled SIZE and nothing else. The camera-dependent half of the rect is
|
||||
// written by canvas/tiledGeometry alone, so a tile can never be pinned to one camera while the
|
||||
// canvas paints another.
|
||||
export function useTiledCard({ cardId, zone, active, originX, originY, getCamera }: TiledCardArgs): TiledSize | null {
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
const cameraRef = useRef(getCamera);
|
||||
cameraRef.current = getCamera;
|
||||
const on = !!zone && active;
|
||||
|
||||
useEffect(() => (on ? subscribeTiledWorkspace(bump) : undefined), [on]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!on || !zone) return undefined;
|
||||
registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current());
|
||||
return () => unregisterTiledCard(cardId);
|
||||
}, [on, zone, cardId, originX, originY]);
|
||||
|
||||
if (!on || !zone) return null;
|
||||
const r = zoneRect(zone);
|
||||
return r ? { width: r.w, height: r.h } : null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 3.0;
|
||||
@@ -89,6 +90,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
// Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes.
|
||||
grid.style.backgroundSize = `${spacing}px ${spacing}px`;
|
||||
}
|
||||
// Tiled cards are counter-transformed against this exact camera, in this exact task: same write,
|
||||
// same frame, so the tile and the canvas can never be painted from two different cameras.
|
||||
syncTiledGeometry(stateRef.current);
|
||||
}, []);
|
||||
|
||||
// Per-frame camera write during a gesture: DOM + live ref only, NO React commit. Dragging cards re-pin to the cursor off the pan-changed event, same signal the old per-frame commit produced.
|
||||
|
||||
Reference in New Issue
Block a user