From 6c1265b8b6bb7e6884dbadbc47d25ee92102f3ce Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 17:29:29 +0300 Subject: [PATCH] fix(foundry): stabilize map window compositor and drag --- apps/catalog/src/MapFixturePreview.tsx | 2 +- apps/catalog/src/styles.css | 13 ++++- packages/ui-core/styles.css | 17 ++++++ packages/ui-react/src/WorkspaceWindow.tsx | 62 +++++++++++++++++++-- scripts/floating-position-contract.test.mjs | 20 ++++++- scripts/map-object-layers.test.mjs | 2 + 6 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 26e2d47..648ef01 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -2628,7 +2628,7 @@ export const MapFixturePreview = forwardRef openSubjectWindow(summary.bindingId)} + onActivate={() => activateWorkspaceWindow(`binding:${summary.bindingId}`)} onClose={() => closeSubjectWindow(summary.bindingId)} title={summary.displayName} subtitle={`${summary.total} всего · ${visibleCount} на карте`} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index e63ba46..74d4a07 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -683,11 +683,18 @@ textarea { --nodedc-glass-control-hover: rgba(255, 255, 255, 0.92); --nodedc-glass-control-active: rgba(255, 255, 255, 0.96); --nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96); - background: var(--nodedc-map-glass-bg); + /* Backdrop-filter above Cesium/WebGL is tile-composited by Chromium. A + hover repaint inside one window can otherwise expose a bright raster tile + in a sibling window until the next geometry invalidation. Keep the same + frosted visual as a stable, nearly opaque material instead. */ + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.025)), + rgba(108, 109, 114, 0.96); color: var(--nodedc-text-primary); box-shadow: var(--nodedc-glass-dropdown-shadow); - backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(128%); - -webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(128%); + backdrop-filter: none; + -webkit-backdrop-filter: none; + contain: layout paint; } .catalog-map-fixture__map-glass-window .nodedc-workspace-window__action { diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index a7b778e..a21ef7e 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -2664,6 +2664,23 @@ textarea.nodedc-field__control { cursor: grabbing; } +.nodedc-workspace-window[data-interaction="move"] { + will-change: transform; +} + +.nodedc-workspace-window[data-interaction="resize"] { + will-change: width, height; +} + +.nodedc-workspace-window[data-interaction] { + user-select: none; +} + +.nodedc-workspace-window[data-interaction] .nodedc-workspace-window__body, +.nodedc-workspace-window[data-interaction] .nodedc-workspace-window__footer { + pointer-events: none; +} + .nodedc-workspace-window__titles { display: grid; min-width: 0; diff --git a/packages/ui-react/src/WorkspaceWindow.tsx b/packages/ui-react/src/WorkspaceWindow.tsx index a03993e..084702a 100644 --- a/packages/ui-react/src/WorkspaceWindow.tsx +++ b/packages/ui-react/src/WorkspaceWindow.tsx @@ -53,6 +53,8 @@ type WorkspaceInteraction = { startClientX: number; startClientY: number; startRect: WorkspaceWindowRect; + previewRect: WorkspaceWindowRect; + startInlineTransform: string; }; const rectEquals = (left: WorkspaceWindowRect, right: WorkspaceWindowRect) => ( @@ -138,7 +140,10 @@ export function WorkspaceWindow({ }: WorkspaceWindowProps) { const titleId = useId(); const descriptionId = useId(); + const rootRef = useRef(null); const interactionRef = useRef(null); + const interactionFrameRef = useRef(0); + const controlledRectRef = useRef(rect); const rectRef = useRef(rect); const onRectChangeRef = useRef(onRectChange); const headRef = useRef(null); @@ -147,7 +152,8 @@ export function WorkspaceWindow({ const footerRef = useRef(null); const [interactionKind, setInteractionKind] = useState(null); - rectRef.current = rect; + controlledRectRef.current = rect; + if (!interactionRef.current) rectRef.current = rect; onRectChangeRef.current = onRectChange; const readBounds = (): WorkspaceBounds | null => { @@ -167,6 +173,7 @@ export function WorkspaceWindow({ if (!bounds || maximized) return; const clampToBounds = () => { + if (interactionRef.current) return; const nextBounds = readBounds(); if (nextBounds) emitRect(normalizeRect(rectRef.current, nextBounds, minWidth, minHeight)); }; @@ -192,6 +199,7 @@ export function WorkspaceWindow({ const fitContent = () => { frame = 0; + if (interactionRef.current) return; const nextBounds = readBounds(); if (!nextBounds) return; const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight); @@ -228,6 +236,27 @@ export function WorkspaceWindow({ }, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]); useEffect(() => { + const paintInteraction = () => { + interactionFrameRef.current = 0; + const interaction = interactionRef.current; + const root = rootRef.current; + if (!interaction || !root) return; + if (interaction.kind === "move") { + const deltaX = interaction.previewRect.x - interaction.startRect.x; + const deltaY = interaction.previewRect.y - interaction.startRect.y; + const prefix = interaction.startInlineTransform ? `${interaction.startInlineTransform} ` : ""; + root.style.transform = `${prefix}translate3d(${deltaX}px, ${deltaY}px, 0)`; + return; + } + root.style.width = `${interaction.previewRect.width}px`; + root.style.height = `${interaction.previewRect.height}px`; + }; + + const scheduleInteractionPaint = () => { + if (interactionFrameRef.current) return; + interactionFrameRef.current = window.requestAnimationFrame(paintInteraction); + }; + const handlePointerMove = (event: globalThis.PointerEvent) => { const interaction = interactionRef.current; if (!interaction || event.pointerId !== interaction.pointerId) return; @@ -243,20 +272,40 @@ export function WorkspaceWindow({ y: interaction.startRect.y + deltaY, }, bounds, minWidth, minHeight) : resizeRect(interaction.startRect, deltaX, deltaY, bounds, minWidth, minHeight); - emitRect(nextRect); + interaction.previewRect = nextRect; + rectRef.current = nextRect; + scheduleInteractionPaint(); event.preventDefault(); }; const endInteraction = (event: globalThis.PointerEvent) => { - if (interactionRef.current?.pointerId !== event.pointerId) return; + const interaction = interactionRef.current; + if (!interaction || interaction.pointerId !== event.pointerId) return; + if (interactionFrameRef.current) { + window.cancelAnimationFrame(interactionFrameRef.current); + interactionFrameRef.current = 0; + } + paintInteraction(); + const nextRect = interaction.previewRect; + const root = rootRef.current; + if (root) { + root.style.left = `${nextRect.x}px`; + root.style.top = `${nextRect.y}px`; + root.style.width = `${nextRect.width}px`; + root.style.height = `${nextRect.height}px`; + root.style.transform = interaction.startInlineTransform; + } interactionRef.current = null; setInteractionKind(null); + if (!rectEquals(controlledRectRef.current, nextRect)) onRectChangeRef.current(nextRect); }; window.addEventListener("pointermove", handlePointerMove, { passive: false }); window.addEventListener("pointerup", endInteraction); window.addEventListener("pointercancel", endInteraction); return () => { + if (interactionFrameRef.current) window.cancelAnimationFrame(interactionFrameRef.current); + interactionFrameRef.current = 0; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", endInteraction); window.removeEventListener("pointercancel", endInteraction); @@ -271,19 +320,21 @@ export function WorkspaceWindow({ }, [maximized, resizable]); const beginInteraction = (kind: WorkspaceInteraction["kind"], event: PointerEvent) => { - if (maximized || event.button !== 0 || (kind === "resize" && !resizable)) return; + if (interactionRef.current || maximized || event.button !== 0 || (kind === "resize" && !resizable)) return; if (kind === "move" && (event.target as HTMLElement).closest("button, input, select, textarea, a")) return; const bounds = readBounds(); if (!bounds) return; const startRect = normalizeRect(rectRef.current, bounds, minWidth, minHeight); - emitRect(startRect); interactionRef.current = { kind, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startRect, + previewRect: startRect, + startInlineTransform: rootRef.current?.style.transform ?? "", }; + rectRef.current = startRect; setInteractionKind(kind); event.currentTarget.focus(); event.preventDefault(); @@ -320,6 +371,7 @@ export function WorkspaceWindow({ return (
{ }); test("workspace windows keep their glass rim without masked compositor overlays", async () => { - const [workspace, styles] = await Promise.all([ + const [workspace, styles, mapStyles] = await Promise.all([ readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"), ]); const rootClass = workspace.match(/className=\{cn\("(?[^"]+)"/)?.groups?.classes ?? ""; const windowRule = styles.match(/\.nodedc-workspace-window \{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; const materialOverride = styles.match(/\.nodedc-workspace-window\.nodedc-glass-material::before \{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; + const mapWindowRule = mapStyles.match(/\.catalog-map-fixture__map-glass-window \{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; assert.match(rootClass, /\bnodedc-glass-material\b/); assert.doesNotMatch(rootClass, /\bnodedc-material-rim\b/); @@ -76,4 +78,20 @@ test("workspace windows keep their glass rim without masked compositor overlays" assert.match(windowRule, /border-color:[\s\S]*--nodedc-modal-glass-outline-opacity/); assert.match(materialOverride, /content:\s*none;/); assert.doesNotMatch(materialOverride, /mask/); + assert.match(mapWindowRule, /backdrop-filter:\s*none;/); + assert.match(mapWindowRule, /contain:\s*layout paint;/); + assert.doesNotMatch(mapWindowRule, /backdrop-filter:\s*blur/); +}); + +test("workspace pointer drag paints on animation frames and commits once on release", async () => { + const workspace = await readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"); + const pointerMove = workspace.match(/const handlePointerMove = \(event: globalThis\.PointerEvent\) => \{(?[\s\S]*?)\n \};/)?.groups?.body ?? ""; + const pointerEnd = workspace.match(/const endInteraction = \(event: globalThis\.PointerEvent\) => \{(?[\s\S]*?)\n \};/)?.groups?.body ?? ""; + + assert.match(workspace, /interactionFrameRef\.current = window\.requestAnimationFrame\(paintInteraction\)/); + assert.match(pointerMove, /interaction\.previewRect = nextRect/); + assert.match(pointerMove, /scheduleInteractionPaint\(\)/); + assert.doesNotMatch(pointerMove, /onRectChangeRef|emitRect/); + assert.match(pointerEnd, /onRectChangeRef\.current\(nextRect\)/); + assert.match(workspace, /translate3d\(\$\{deltaX\}px, \$\{deltaY\}px, 0\)/); }); diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index 7b83744..113475a 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -53,6 +53,8 @@ test("bounded map windows keep one stack while layers and settings use their can assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/); assert.match(preview, /const activateWorkspaceWindow = useCallback/); + assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/); + assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/); assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/); assert.match(preview, /