fix(foundry): stabilize map window compositor and drag
This commit is contained in:
@@ -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} на карте`}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user