Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions templates/design/app/components/design/DesignCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,10 @@ export function DesignCanvas({
[probeBridgeReadinessUntilDrained],
);
const [renderedContent, setRenderedContent] = useState(content);
// What a freshly loaded document already contains, since srcdoc is built from
// it. The load handler below needs this to skip redundant pushes.
const renderedContentRef = useRef(renderedContent);
renderedContentRef.current = renderedContent;
// True while a drawing send is capturing/compositing/uploading the
// annotated screenshot (see design-canvas/annotation-snapshot.ts). Drives
// SharedDrawOverlay's busy Send state so a slow capture can't be triggered
Expand Down Expand Up @@ -4043,6 +4047,9 @@ export function DesignCanvas({
const replaceLatestRuntimeContent = () => {
const nextContent = runtimeReplacementContentRef.current;
if (nextContent === undefined) return;
// The document that just loaded was built from these bytes; swapping it
// for itself only costs a blank frame.
if (renderedContentRef.current === nextContent) return;
if (replaceRuntimeContentInPlace(nextContent)) {
lastRuntimeReplacementKeyRef.current = runtimeReplacementKeyRef.current;
lastRuntimeReplacementContentRef.current = nextContent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ describe("board surface pointer capture", () => {
expect(
shouldRenderBoardSurfaceStaticPreview({
zoom: 2,
hasSurfaceContent: true,
viewportGeometry: viewport,
renderGeometry: active,
}),
Expand Down Expand Up @@ -312,6 +313,33 @@ describe("board surface pointer capture", () => {
}
});

it("keeps the opaque board replica off when the board has nothing on it", () => {
// A board file can be a full HTML document with an empty <body> — truthy as
// a string, nothing to show. The replica paints itself in the board colour,
// so rendering it there covers the canvas in a full-board slab at low zoom.
const logical = makeGeom(-65536, -65536, 131072, 131072);
const active = makeGeom(-12288, -12288, 24576, 24576);
const viewport = makeGeom(-36000, -22500, 72000, 45000);

expect(
shouldRenderBoardSurfaceStaticPreview({
zoom: 2,
hasSurfaceContent: false,
viewportGeometry: viewport,
renderGeometry: active,
}),
).toBe(false);
// Also below the pre-measurement zoom fallback.
expect(
shouldRenderBoardSurfaceStaticPreview({
zoom: 2,
hasSurfaceContent: false,
viewportGeometry: null,
renderGeometry: active,
}),
).toBe(false);
});

it("treats empty board documents as having no surface content", () => {
expect(
hasBoardSurfaceContent(
Expand Down
152 changes: 103 additions & 49 deletions templates/design/app/components/design/MultiScreenCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ import {
sameResolvedMetadata,
type ResolvedMetadataCacheEntry,
} from "./multi-screen/screen-content-cache";
import { setWheelCameraGestureActive } from "./multi-screen/wheel-gesture-state";
import {
isWheelCameraGestureActive,
setWheelCameraGestureActive,
} from "./multi-screen/wheel-gesture-state";

// Figma parity: a plain click (no drag) with the rectangle or ellipse tool
// places a 100x100 shape. Both tools share this default via the "else"
Expand Down Expand Up @@ -339,6 +342,13 @@ import {
screenPxToCanvasPx,
stopPercentFromDraggedPoint,
} from "./multi-screen/gradient-overlay-geometry";
import {
applyScreenPaintSuppression,
collectScreenPaintTargets,
resolveSuppressedScreenIds,
type ScreenPaintCandidate,
type ScreenPaintTarget,
} from "./multi-screen/paint-suppression";
import {
getPrimitiveDropTargetForPoint,
getPrimitiveLowZoomHitRect,
Expand Down Expand Up @@ -648,26 +658,33 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
: null,
[boardFrameGeometry],
);
// Both board layers must ask this one question: an empty <body> is a truthy
// string, and the replica is opaque, so a string-only gate slabs the board.
const boardSurfaceHtml = hasBoardSurfaceContent(boardFileContent)
? boardFileContent
: undefined;
const boardHasSurfaceContent = boardSurfaceHtml !== undefined;
const boardStaticPreviewContent = useMemo(() => {
if (
!boardFrameGeometry ||
!boardStaticPreviewViewport ||
!boardFileContent
!boardSurfaceHtml
) {
return null;
}
return getBoardSurfaceStaticPreviewContent({
html: boardFileContent,
html: boardSurfaceHtml,
logicalGeometry: boardFrameGeometry,
viewport: boardStaticPreviewViewport,
});
}, [boardFileContent, boardFrameGeometry, boardStaticPreviewViewport]);
}, [boardSurfaceHtml, boardFrameGeometry, boardStaticPreviewViewport]);
const showBoardStaticPreview = Boolean(
boardFrameGeometry &&
boardSurfaceRenderGeometry &&
boardStaticPreviewContent &&
shouldRenderBoardSurfaceStaticPreview({
zoom: canvasZoom,
hasSurfaceContent: boardHasSurfaceContent,
viewportGeometry: boardViewportGeometry,
renderGeometry: boardSurfaceRenderGeometry,
}),
Expand Down Expand Up @@ -957,6 +974,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
const liveScreenIdsRef = useRef<Set<string>>(new Set());
const lastVisibleEpochByScreenIdRef = useRef<Map<string, number>>(new Map());
const cullAccessEpochRef = useRef(0);
const wheelGestureFilteredIframesRef = useRef<HTMLElement[]>([]);
// Paint suppression is resolved imperatively against the live camera, so its
// inputs live in refs readable from applyViewToDom's render-free tick.
const screenPaintCandidatesRef = useRef<ScreenPaintCandidate[]>([]);
const screenPaintTargetsRef = useRef<ScreenPaintTarget[]>([]);
const surfaceSizeRef = useRef({ width: 0, height: 0 });
useEffect(() => {
const liveScreenIds = new Set(screens.map((screen) => screen.id));
for (const id of hasBeenVisibleScreenIdsRef.current) {
Expand Down Expand Up @@ -989,6 +1012,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
const surface = surfaceRef.current;
if (!surface) return;
const updateSize = (width: number, height: number) => {
surfaceSizeRef.current = { width, height };
setSurfaceSize((current) =>
current.width === width && current.height === height
? current
Expand Down Expand Up @@ -6636,6 +6660,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
[activeTool, localActiveTool, updateAltHoverMeasurement, updatePenPointer],
);

// Overscan is deliberately excluded: this answers "is it on screen right
// now", and anything off screen costs nothing to leave unpainted.
const syncScreenPaintSuppression = useCallback(() => {
applyScreenPaintSuppression(
screenPaintTargetsRef.current,
resolveSuppressedScreenIds(
screenPaintCandidatesRef.current,
getOverscannedViewportCanvasBounds(
surfaceSizeRef.current,
panRef.current,
zoomRef.current,
0,
),
),
{ relaxOnly: isWheelCameraGestureActive() },
);
}, []);

// Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must
// NEVER re-render React's canvas tree during the gesture: each render re-runs
// renderScreenContent (which re-creates the active screen's live DesignCanvas
Expand Down Expand Up @@ -6681,7 +6723,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
}
}, []);
// Same tick as the transform: a screen this move brings on screen must
// paint in the frame it becomes visible, not at the debounced commit.
syncScreenPaintSuppression();
}, [syncScreenPaintSuppression]);

const startChromeSettle = useCallback(() => {
if (chromeSettleTimerRef.current !== null) {
Expand All @@ -6707,6 +6752,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
if (wheelGestureActiveRef.current) {
wheelGestureActiveRef.current = false;
setWheelCameraGestureActive(false);
wheelGestureFilteredIframesRef.current.forEach((iframe) => {
if (iframe.isConnected) iframe.style.filter = "";
});
wheelGestureFilteredIframesRef.current = [];
const muted = wheelGestureMutedElementsRef.current;
wheelGestureMutedElementsRef.current = null;
if (muted) {
Expand Down Expand Up @@ -6857,6 +6906,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
element.style.pointerEvents = "none";
});
wheelGestureMutedElementsRef.current = muted;
// A nested frame re-rasters on every scale change no matter what layer the
// canvas gets, starving the renderer. A filter — a no-op at this radius —
// gives each one a surface the compositor scales from cache. commitView must
// clear it, or they stay soft at rest.
const filtered: HTMLElement[] = [];
surface
.querySelectorAll<HTMLElement>("[data-screen-content] iframe")
.forEach((iframe) => {
iframe.style.filter = "blur(0.001px)";
filtered.push(iframe);
});
wheelGestureFilteredIframesRef.current = filtered;
}, [cancelPendingStaticBoardSelection]);

const flushPendingWheelGesture = useCallback(() => {
Expand Down Expand Up @@ -7607,23 +7668,23 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
pan,
canvasZoom,
);
const candidates = canvasFrames.map(({ screen, metadata, geometry }) => ({
id: screen.id,
geometry: getResponsiveScreenCullGeometry(
screen,
geometry,
(widthPx) =>
measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
),
// Count only the breakpoint frames actually mounted (the row filters
// duplicates of the device width) so the iframe budget isn't
// over-consumed, prematurely evicting visible frames.
iframeCount:
1 +
visibleBreakpointWidths(screen.breakpointWidths, metadata.width).length,
}));
const next = computeBoundedScreenCullState({
candidates: canvasFrames.map(({ screen, metadata, geometry }) => ({
id: screen.id,
geometry: getResponsiveScreenCullGeometry(
screen,
geometry,
(widthPx) =>
measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
),
// Count only the breakpoint frames actually mounted (the row filters
// duplicates of the device width) so the iframe budget isn't
// over-consumed, prematurely evicting visible frames.
iframeCount:
1 +
visibleBreakpointWidths(screen.breakpointWidths, metadata.width)
.length,
})),
candidates,
viewport,
protectedScreenIds: protectedLiveScreenIds,
previousLiveScreenIds: liveScreenIdsRef.current,
Expand All @@ -7634,6 +7695,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
liveScreenIdsRef.current = next.liveScreenIds;
hasBeenVisibleScreenIdsRef.current = next.everVisibleScreenIds;
lastVisibleEpochByScreenIdRef.current = next.lastVisibleEpochByScreenId;
screenPaintCandidatesRef.current = candidates.map(({ id, geometry }) => ({
id,
geometry,
tier: next.tierByScreenId.get(id) ?? "visible",
}));
return next.tierByScreenId;
}, [
canvasFrames,
Expand All @@ -7643,6 +7709,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
protectedLiveScreenIds,
surfaceSize,
]);
// No dependency array on purpose: any render can mount, unmount, or reorder a
// screen's content wrapper, and a wrapper React just mounted carries none of
// the suppression state this owns.
useEffect(() => {
screenPaintTargetsRef.current = collectScreenPaintTargets(
surfaceRef.current,
);
syncScreenPaintSuppression();
});

const topScreenId = useMemo(
() =>
selectedIds.find((id) =>
Expand Down Expand Up @@ -7889,7 +7965,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({

{boardFileId &&
boardFileContent !== undefined &&
hasBoardSurfaceContent(boardFileContent) &&
boardHasSurfaceContent &&
(() => {
const boardGeo = boardSurfaceRenderGeometry ?? {
x: 0,
Expand Down Expand Up @@ -9440,18 +9516,10 @@ const Screen = memo(function Screen({
const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
const previewViewport = getScreenPreviewViewport(metadata, geometry);
const suppressNextClick = useRef(false);
// Overview viewport culling (PF22): a "culled" screen keeps its content
// (iframe/DesignCanvas) fully mounted — unmounting would lose all internal
// iframe state (scroll position, form input, in-progress Alpine/JS state)
// — but skips paint/layout cost for it via visibility:hidden +
// contentVisibility:"hidden" on the content wrapper. Deliberately NOT
// display:none (can drop layout/scroll state on some engines) and NOT
// will-change (a prior perf attempt using will-change on this same overview
// caused permanent blur by pinning a low-res compositor layer — see the
// world-transform comment near applyViewToDom). "placeholder" and
// "evicted" tiers have no mounted browsing context, so they render the
// chrome-only placeholder below regardless of this flag.
const { shouldMount: shouldMountContent, isHidden: isCulled } =
// Overview viewport culling (PF22): mounting only. Unmounting a culled screen
// would lose its iframe's scroll/form/Alpine state, so it stays mounted and
// paint-suppression.ts decides paint against the live camera instead.
const { shouldMount: shouldMountContent } =
getScreenContentCullState(cullTier);
const [directlyHovered, setDirectlyHovered] = useState(false);
const frameDirectlyHovered =
Expand Down Expand Up @@ -9778,16 +9846,6 @@ const Screen = memo(function Screen({
)}
style={{
pointerEvents: screenContentInteractive ? "auto" : "none",
// Tier B (PF22): keep the mounted iframe/DesignCanvas alive (see
// the isCulled comment above) but skip its paint/layout cost.
// visibility:hidden (not display:none) keeps the box in the
// layout/measurement tree; contentVisibility:"hidden" skips
// rendering its subtree entirely until it's shown again. Neither
// property touches will-change/compositor layers, so this can't
// reproduce the permanent-blur regression a prior will-change
// attempt on this same surface caused.
visibility: isCulled ? "hidden" : undefined,
contentVisibility: isCulled ? "hidden" : undefined,
}}
>
{!shouldMountContent ? (
Expand Down Expand Up @@ -10159,7 +10217,7 @@ function BreakpointPreviewRow({
// draft value of its width input.
const [menuOpenForWidth, setMenuOpenForWidth] = useState<number | null>(null);
const [widthDraft, setWidthDraft] = useState("");
const { shouldMount: shouldMountContent, isHidden: isCulled } =
const { shouldMount: shouldMountContent } =
getScreenContentCullState(cullTier);

return (
Expand Down Expand Up @@ -10468,10 +10526,6 @@ function BreakpointPreviewRow({
data-screen-content
data-cull-tier={cullTier}
className="relative block h-full w-full overflow-hidden rounded-[inherit] bg-white ring-1 ring-inset ring-border"
style={{
visibility: isCulled ? "hidden" : undefined,
contentVisibility: isCulled ? "hidden" : undefined,
}}
>
{!shouldMountContent ? (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,13 @@ export function getBoardSurfaceLayerStyle(args: {
*/
export function shouldRenderBoardSurfaceStaticPreview(args: {
zoom: number;
hasSurfaceContent: boolean;
viewportGeometry?: FrameGeometry | null;
renderGeometry: FrameGeometry;
}) {
// The replica is opaque. Backing a layer that is not rendering just slabs the
// board in its own colour, which reads as a themed background gone wrong.
if (!args.hasSurfaceContent) return false;
if (args.viewportGeometry) {
return (
args.viewportGeometry.width > args.renderGeometry.width ||
Expand Down
Loading
Loading