fix(foundry): stabilize map window compositor and drag

This commit is contained in:
Codex
2026-08-09 17:29:29 +03:00
parent 3e9ae2ecac
commit 6c1265b8b6
6 changed files with 106 additions and 10 deletions
+1 -1
View File
@@ -2628,7 +2628,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
...current,
window: { ...current.window, maximized },
}))}
onActivate={() => openSubjectWindow(summary.bindingId)}
onActivate={() => activateWorkspaceWindow(`binding:${summary.bindingId}`)}
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
+10 -3
View File
@@ -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 {
+17
View File
@@ -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;
+57 -5
View File
@@ -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<HTMLDivElement>(null);
const interactionRef = useRef<WorkspaceInteraction | null>(null);
const interactionFrameRef = useRef(0);
const controlledRectRef = useRef(rect);
const rectRef = useRef(rect);
const onRectChangeRef = useRef(onRectChange);
const headRef = useRef<HTMLElement>(null);
@@ -147,7 +152,8 @@ export function WorkspaceWindow({
const footerRef = useRef<HTMLElement>(null);
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(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<HTMLElement>) => {
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 (
<div
ref={rootRef}
{...props}
className={cn("nodedc-workspace-window nodedc-glass-material nodedc-ui-root", className)}
data-active={active ? "true" : undefined}
+19 -1
View File
@@ -61,13 +61,15 @@ test("dropdown scroll keeps portal geometry stable and contained", async () => {
});
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\("(?<classes>[^"]+)"/)?.groups?.classes ?? "";
const windowRule = styles.match(/\.nodedc-workspace-window \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
const materialOverride = styles.match(/\.nodedc-workspace-window\.nodedc-glass-material::before \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
const mapWindowRule = mapStyles.match(/\.catalog-map-fixture__map-glass-window \{(?<body>[\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\) => \{(?<body>[\s\S]*?)\n \};/)?.groups?.body ?? "";
const pointerEnd = workspace.match(/const endInteraction = \(event: globalThis\.PointerEvent\) => \{(?<body>[\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\)/);
});
+2
View File
@@ -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, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);