@agent-native/core 0.137.5 → 0.137.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/app/components/library/library-layout.tsx +95 -17
- package/corpus/templates/clips/app/components/library/space-card.tsx +136 -66
- package/corpus/templates/clips/app/components/library/space-dialogs.tsx +144 -0
- package/corpus/templates/clips/app/hooks/use-library.ts +2 -2
- package/corpus/templates/clips/app/i18n/en-US.ts +13 -0
- package/corpus/templates/clips/app/routes/_app.spaces._index.tsx +6 -2
- package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +106 -87
- package/corpus/templates/clips/desktop/src/styles.css +19 -1
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +7 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +103 -49
- package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +4 -0
- package/corpus/templates/design/app/components/design/multi-screen/paint-suppression.ts +85 -0
- package/corpus/templates/design/changelog/2026-08-03-zoom-screen-blink.md +6 -0
- package/dist/agent/run-store.js +18 -0
- package/dist/client/AssistantChat.js +1 -0
- package/dist/client/chat/message-components.d.ts +10 -1
- package/dist/client/chat/message-components.js +13 -4
- package/dist/client/chat/runtime.js +20 -9
- package/dist/client/chat/tool-call-display.d.ts +7 -0
- package/dist/client/chat/tool-call-display.js +9 -1
- package/dist/client/sse-event-processor.d.ts +2 -0
- package/dist/client/sse-event-processor.js +39 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -104,7 +104,7 @@ export function MediaDeviceRow({
|
|
|
104
104
|
const { open, setOpen, rowRef } = useRowMenu();
|
|
105
105
|
|
|
106
106
|
const disabled = !on;
|
|
107
|
-
const canOpenMenu =
|
|
107
|
+
const canOpenMenu = on || (kind === "mic" && !!onSystemAudioToggle);
|
|
108
108
|
const defaultLabel = kind === "camera" ? "Default camera" : "Default mic";
|
|
109
109
|
const accessLabel =
|
|
110
110
|
kind === "camera" ? "Allow camera access" : "Allow microphone access";
|
|
@@ -112,106 +112,125 @@ export function MediaDeviceRow({
|
|
|
112
112
|
kind === "camera" ? "Refresh cameras" : "Refresh microphones";
|
|
113
113
|
|
|
114
114
|
return (
|
|
115
|
-
<div className=
|
|
116
|
-
<
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
<button
|
|
120
|
-
type="button"
|
|
121
|
-
className="row-button"
|
|
122
|
-
onClick={() => {
|
|
123
|
-
if (canOpenMenu) setOpen((v) => !v);
|
|
124
|
-
}}
|
|
125
|
-
disabled={!canOpenMenu}
|
|
126
|
-
title={label}
|
|
127
|
-
>
|
|
128
|
-
<span className="row-label">{label}</span>
|
|
129
|
-
{kind === "mic" && on ? (
|
|
130
|
-
<MicWave deviceId={selectedId} active={on && meterActive} />
|
|
131
|
-
) : (
|
|
132
|
-
<span className="row-flex" aria-hidden />
|
|
133
|
-
)}
|
|
134
|
-
<span className="row-chev" aria-hidden>
|
|
135
|
-
<ChevronDown />
|
|
115
|
+
<div className="media-device-row" ref={rowRef}>
|
|
116
|
+
<div className={`row ${on ? "row-on" : "row-off"}`}>
|
|
117
|
+
<span className="row-icon">
|
|
118
|
+
<Icon />
|
|
136
119
|
</span>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className="row-button"
|
|
123
|
+
onClick={() => setOpen((v) => !v)}
|
|
124
|
+
disabled={disabled}
|
|
125
|
+
title={label}
|
|
126
|
+
>
|
|
127
|
+
<span className="row-label">{label}</span>
|
|
128
|
+
{kind === "mic" && on ? (
|
|
129
|
+
<MicWave deviceId={selectedId} active={on && meterActive} />
|
|
130
|
+
) : (
|
|
131
|
+
<span className="row-flex" aria-hidden />
|
|
132
|
+
)}
|
|
133
|
+
</button>
|
|
134
|
+
{canOpenMenu ? (
|
|
148
135
|
<button
|
|
149
136
|
type="button"
|
|
150
|
-
className=
|
|
151
|
-
|
|
152
|
-
aria-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
setOpen(false);
|
|
156
|
-
}}
|
|
137
|
+
className="row-menu-trigger"
|
|
138
|
+
onClick={() => setOpen((value) => !value)}
|
|
139
|
+
aria-label={on ? `Choose ${kind}` : "System audio settings"}
|
|
140
|
+
aria-expanded={open}
|
|
141
|
+
aria-haspopup="menu"
|
|
157
142
|
>
|
|
158
|
-
<
|
|
159
|
-
{!selectedId ? <CheckIcon /> : null}
|
|
160
|
-
</span>
|
|
161
|
-
<span className="row-menu-label">{defaultLabel}</span>
|
|
143
|
+
<ChevronDown />
|
|
162
144
|
</button>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
145
|
+
) : (
|
|
146
|
+
<span className="row-chev" aria-hidden>
|
|
147
|
+
<ChevronDown />
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
<Toggle
|
|
151
|
+
on={on}
|
|
152
|
+
onChange={(v) => {
|
|
153
|
+
if (!v) setOpen(false);
|
|
154
|
+
onToggle(v);
|
|
155
|
+
}}
|
|
156
|
+
label={kind === "camera" ? "Camera" : "Microphone"}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
{open && canOpenMenu ? (
|
|
160
|
+
<div className="row-menu" role="menu">
|
|
161
|
+
{on ? (
|
|
177
162
|
<>
|
|
178
|
-
{devices.map((d) => {
|
|
179
|
-
const isSelected = !!selectedId && d.deviceId === selectedId;
|
|
180
|
-
return (
|
|
181
|
-
<button
|
|
182
|
-
key={d.deviceId}
|
|
183
|
-
type="button"
|
|
184
|
-
className={`row-menu-item ${isSelected ? "selected" : ""}`}
|
|
185
|
-
role="menuitemradio"
|
|
186
|
-
aria-checked={isSelected}
|
|
187
|
-
onClick={() => {
|
|
188
|
-
onSelect(d.deviceId, d.label);
|
|
189
|
-
setOpen(false);
|
|
190
|
-
}}
|
|
191
|
-
>
|
|
192
|
-
<span className="row-menu-check" aria-hidden>
|
|
193
|
-
{isSelected ? <CheckIcon /> : null}
|
|
194
|
-
</span>
|
|
195
|
-
<span className="row-menu-label">
|
|
196
|
-
{d.label || (kind === "camera" ? "Camera" : "Microphone")}
|
|
197
|
-
</span>
|
|
198
|
-
</button>
|
|
199
|
-
);
|
|
200
|
-
})}
|
|
201
163
|
<button
|
|
202
164
|
type="button"
|
|
203
|
-
className=
|
|
204
|
-
role="
|
|
165
|
+
className={`row-menu-item ${!selectedId ? "selected" : ""}`}
|
|
166
|
+
role="menuitemradio"
|
|
167
|
+
aria-checked={!selectedId}
|
|
205
168
|
onClick={() => {
|
|
206
|
-
|
|
169
|
+
onSelect("", "");
|
|
207
170
|
setOpen(false);
|
|
208
171
|
}}
|
|
209
172
|
>
|
|
210
|
-
<span className="row-menu-check" aria-hidden
|
|
211
|
-
|
|
173
|
+
<span className="row-menu-check" aria-hidden>
|
|
174
|
+
{!selectedId ? <CheckIcon /> : null}
|
|
175
|
+
</span>
|
|
176
|
+
<span className="row-menu-label">{defaultLabel}</span>
|
|
212
177
|
</button>
|
|
178
|
+
{devices.length === 0 ? (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="row-menu-item row-menu-action"
|
|
182
|
+
role="menuitem"
|
|
183
|
+
onClick={() => {
|
|
184
|
+
onRefresh();
|
|
185
|
+
setOpen(false);
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<span className="row-menu-check" aria-hidden />
|
|
189
|
+
<span className="row-menu-label">{accessLabel}</span>
|
|
190
|
+
</button>
|
|
191
|
+
) : (
|
|
192
|
+
<>
|
|
193
|
+
{devices.map((d) => {
|
|
194
|
+
const isSelected =
|
|
195
|
+
!!selectedId && d.deviceId === selectedId;
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
key={d.deviceId}
|
|
199
|
+
type="button"
|
|
200
|
+
className={`row-menu-item ${isSelected ? "selected" : ""}`}
|
|
201
|
+
role="menuitemradio"
|
|
202
|
+
aria-checked={isSelected}
|
|
203
|
+
onClick={() => {
|
|
204
|
+
onSelect(d.deviceId, d.label);
|
|
205
|
+
setOpen(false);
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<span className="row-menu-check" aria-hidden>
|
|
209
|
+
{isSelected ? <CheckIcon /> : null}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="row-menu-label">
|
|
212
|
+
{d.label ||
|
|
213
|
+
(kind === "camera" ? "Camera" : "Microphone")}
|
|
214
|
+
</span>
|
|
215
|
+
</button>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
className="row-menu-item row-menu-action"
|
|
221
|
+
role="menuitem"
|
|
222
|
+
onClick={() => {
|
|
223
|
+
onRefresh();
|
|
224
|
+
setOpen(false);
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<span className="row-menu-check" aria-hidden />
|
|
228
|
+
<span className="row-menu-label">{refreshLabel}</span>
|
|
229
|
+
</button>
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
213
232
|
</>
|
|
214
|
-
)}
|
|
233
|
+
) : null}
|
|
215
234
|
{kind === "mic" && onSystemAudioToggle ? (
|
|
216
235
|
<div className="row-menu-toggle">
|
|
217
236
|
<span className="row-menu-toggle-label">Record System audio</span>
|
|
@@ -605,6 +605,10 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
605
605
|
}
|
|
606
606
|
|
|
607
607
|
/* Popover menu rendered below a device row. */
|
|
608
|
+
.media-device-row {
|
|
609
|
+
position: relative;
|
|
610
|
+
}
|
|
611
|
+
|
|
608
612
|
.row-menu {
|
|
609
613
|
position: absolute;
|
|
610
614
|
top: calc(100% + 4px);
|
|
@@ -645,7 +649,21 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
645
649
|
background: var(--surface-hover);
|
|
646
650
|
}
|
|
647
651
|
|
|
648
|
-
|
|
652
|
+
.row-menu-trigger {
|
|
653
|
+
display: inline-flex;
|
|
654
|
+
align-items: center;
|
|
655
|
+
padding: 0;
|
|
656
|
+
border: none;
|
|
657
|
+
background: transparent;
|
|
658
|
+
color: var(--fg-muted);
|
|
659
|
+
cursor: pointer;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.row-menu-trigger:focus-visible {
|
|
663
|
+
box-shadow: 0 0 0 2px var(--brand-ring);
|
|
664
|
+
border-radius: 4px;
|
|
665
|
+
}
|
|
666
|
+
|
|
649
667
|
.row-menu-toggle {
|
|
650
668
|
display: flex;
|
|
651
669
|
align-items: center;
|
|
@@ -1293,6 +1293,10 @@ export function DesignCanvas({
|
|
|
1293
1293
|
[probeBridgeReadinessUntilDrained],
|
|
1294
1294
|
);
|
|
1295
1295
|
const [renderedContent, setRenderedContent] = useState(content);
|
|
1296
|
+
// What a freshly loaded document already contains, since srcdoc is built from
|
|
1297
|
+
// it. The load handler below needs this to skip redundant pushes.
|
|
1298
|
+
const renderedContentRef = useRef(renderedContent);
|
|
1299
|
+
renderedContentRef.current = renderedContent;
|
|
1296
1300
|
// True while a drawing send is capturing/compositing/uploading the
|
|
1297
1301
|
// annotated screenshot (see design-canvas/annotation-snapshot.ts). Drives
|
|
1298
1302
|
// SharedDrawOverlay's busy Send state so a slow capture can't be triggered
|
|
@@ -4043,6 +4047,9 @@ export function DesignCanvas({
|
|
|
4043
4047
|
const replaceLatestRuntimeContent = () => {
|
|
4044
4048
|
const nextContent = runtimeReplacementContentRef.current;
|
|
4045
4049
|
if (nextContent === undefined) return;
|
|
4050
|
+
// The document that just loaded was built from these bytes; swapping it
|
|
4051
|
+
// for itself only costs a blank frame.
|
|
4052
|
+
if (renderedContentRef.current === nextContent) return;
|
|
4046
4053
|
if (replaceRuntimeContentInPlace(nextContent)) {
|
|
4047
4054
|
lastRuntimeReplacementKeyRef.current = runtimeReplacementKeyRef.current;
|
|
4048
4055
|
lastRuntimeReplacementContentRef.current = nextContent;
|
|
@@ -244,7 +244,10 @@ import {
|
|
|
244
244
|
sameResolvedMetadata,
|
|
245
245
|
type ResolvedMetadataCacheEntry,
|
|
246
246
|
} from "./multi-screen/screen-content-cache";
|
|
247
|
-
import {
|
|
247
|
+
import {
|
|
248
|
+
isWheelCameraGestureActive,
|
|
249
|
+
setWheelCameraGestureActive,
|
|
250
|
+
} from "./multi-screen/wheel-gesture-state";
|
|
248
251
|
|
|
249
252
|
// Figma parity: a plain click (no drag) with the rectangle or ellipse tool
|
|
250
253
|
// places a 100x100 shape. Both tools share this default via the "else"
|
|
@@ -340,6 +343,13 @@ import {
|
|
|
340
343
|
screenPxToCanvasPx,
|
|
341
344
|
stopPercentFromDraggedPoint,
|
|
342
345
|
} from "./multi-screen/gradient-overlay-geometry";
|
|
346
|
+
import {
|
|
347
|
+
applyScreenPaintSuppression,
|
|
348
|
+
collectScreenPaintTargets,
|
|
349
|
+
resolveSuppressedScreenIds,
|
|
350
|
+
type ScreenPaintCandidate,
|
|
351
|
+
type ScreenPaintTarget,
|
|
352
|
+
} from "./multi-screen/paint-suppression";
|
|
343
353
|
import {
|
|
344
354
|
getPrimitiveDropTargetForPoint,
|
|
345
355
|
getPrimitiveLowZoomHitRect,
|
|
@@ -651,26 +661,33 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
651
661
|
: null,
|
|
652
662
|
[boardFrameGeometry],
|
|
653
663
|
);
|
|
664
|
+
// Both board layers must ask this one question: an empty <body> is a truthy
|
|
665
|
+
// string, and the replica is opaque, so a string-only gate slabs the board.
|
|
666
|
+
const boardSurfaceHtml = hasBoardSurfaceContent(boardFileContent)
|
|
667
|
+
? boardFileContent
|
|
668
|
+
: undefined;
|
|
669
|
+
const boardHasSurfaceContent = boardSurfaceHtml !== undefined;
|
|
654
670
|
const boardStaticPreviewContent = useMemo(() => {
|
|
655
671
|
if (
|
|
656
672
|
!boardFrameGeometry ||
|
|
657
673
|
!boardStaticPreviewViewport ||
|
|
658
|
-
!
|
|
674
|
+
!boardSurfaceHtml
|
|
659
675
|
) {
|
|
660
676
|
return null;
|
|
661
677
|
}
|
|
662
678
|
return getBoardSurfaceStaticPreviewContent({
|
|
663
|
-
html:
|
|
679
|
+
html: boardSurfaceHtml,
|
|
664
680
|
logicalGeometry: boardFrameGeometry,
|
|
665
681
|
viewport: boardStaticPreviewViewport,
|
|
666
682
|
});
|
|
667
|
-
}, [
|
|
683
|
+
}, [boardSurfaceHtml, boardFrameGeometry, boardStaticPreviewViewport]);
|
|
668
684
|
const showBoardStaticPreview = Boolean(
|
|
669
685
|
boardFrameGeometry &&
|
|
670
686
|
boardSurfaceRenderGeometry &&
|
|
671
687
|
boardStaticPreviewContent &&
|
|
672
688
|
shouldRenderBoardSurfaceStaticPreview({
|
|
673
689
|
zoom: canvasZoom,
|
|
690
|
+
hasSurfaceContent: boardHasSurfaceContent,
|
|
674
691
|
viewportGeometry: boardViewportGeometry,
|
|
675
692
|
renderGeometry: boardSurfaceRenderGeometry,
|
|
676
693
|
}),
|
|
@@ -960,6 +977,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
960
977
|
const liveScreenIdsRef = useRef<Set<string>>(new Set());
|
|
961
978
|
const lastVisibleEpochByScreenIdRef = useRef<Map<string, number>>(new Map());
|
|
962
979
|
const cullAccessEpochRef = useRef(0);
|
|
980
|
+
const wheelGestureFilteredIframesRef = useRef<HTMLElement[]>([]);
|
|
981
|
+
// Paint suppression is resolved imperatively against the live camera, so its
|
|
982
|
+
// inputs live in refs readable from applyViewToDom's render-free tick.
|
|
983
|
+
const screenPaintCandidatesRef = useRef<ScreenPaintCandidate[]>([]);
|
|
984
|
+
const screenPaintTargetsRef = useRef<ScreenPaintTarget[]>([]);
|
|
985
|
+
const surfaceSizeRef = useRef({ width: 0, height: 0 });
|
|
963
986
|
useEffect(() => {
|
|
964
987
|
const liveScreenIds = new Set(screens.map((screen) => screen.id));
|
|
965
988
|
for (const id of hasBeenVisibleScreenIdsRef.current) {
|
|
@@ -992,6 +1015,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
992
1015
|
const surface = surfaceRef.current;
|
|
993
1016
|
if (!surface) return;
|
|
994
1017
|
const updateSize = (width: number, height: number) => {
|
|
1018
|
+
surfaceSizeRef.current = { width, height };
|
|
995
1019
|
setSurfaceSize((current) =>
|
|
996
1020
|
current.width === width && current.height === height
|
|
997
1021
|
? current
|
|
@@ -6643,6 +6667,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
6643
6667
|
[activeTool, localActiveTool, updateAltHoverMeasurement, updatePenPointer],
|
|
6644
6668
|
);
|
|
6645
6669
|
|
|
6670
|
+
// Overscan is deliberately excluded: this answers "is it on screen right
|
|
6671
|
+
// now", and anything off screen costs nothing to leave unpainted.
|
|
6672
|
+
const syncScreenPaintSuppression = useCallback(() => {
|
|
6673
|
+
applyScreenPaintSuppression(
|
|
6674
|
+
screenPaintTargetsRef.current,
|
|
6675
|
+
resolveSuppressedScreenIds(
|
|
6676
|
+
screenPaintCandidatesRef.current,
|
|
6677
|
+
getOverscannedViewportCanvasBounds(
|
|
6678
|
+
surfaceSizeRef.current,
|
|
6679
|
+
panRef.current,
|
|
6680
|
+
zoomRef.current,
|
|
6681
|
+
0,
|
|
6682
|
+
),
|
|
6683
|
+
),
|
|
6684
|
+
{ relaxOnly: isWheelCameraGestureActive() },
|
|
6685
|
+
);
|
|
6686
|
+
}, []);
|
|
6687
|
+
|
|
6646
6688
|
// Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must
|
|
6647
6689
|
// NEVER re-render React's canvas tree during the gesture: each render re-runs
|
|
6648
6690
|
// renderScreenContent (which re-creates the active screen's live DesignCanvas
|
|
@@ -6688,7 +6730,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
6688
6730
|
marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
|
|
6689
6731
|
marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
|
|
6690
6732
|
}
|
|
6691
|
-
|
|
6733
|
+
// Same tick as the transform: a screen this move brings on screen must
|
|
6734
|
+
// paint in the frame it becomes visible, not at the debounced commit.
|
|
6735
|
+
syncScreenPaintSuppression();
|
|
6736
|
+
}, [syncScreenPaintSuppression]);
|
|
6692
6737
|
|
|
6693
6738
|
const startChromeSettle = useCallback(() => {
|
|
6694
6739
|
if (chromeSettleTimerRef.current !== null) {
|
|
@@ -6714,6 +6759,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
6714
6759
|
if (wheelGestureActiveRef.current) {
|
|
6715
6760
|
wheelGestureActiveRef.current = false;
|
|
6716
6761
|
setWheelCameraGestureActive(false);
|
|
6762
|
+
wheelGestureFilteredIframesRef.current.forEach((iframe) => {
|
|
6763
|
+
if (iframe.isConnected) iframe.style.filter = "";
|
|
6764
|
+
});
|
|
6765
|
+
wheelGestureFilteredIframesRef.current = [];
|
|
6717
6766
|
const muted = wheelGestureMutedElementsRef.current;
|
|
6718
6767
|
wheelGestureMutedElementsRef.current = null;
|
|
6719
6768
|
if (muted) {
|
|
@@ -6864,6 +6913,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
6864
6913
|
element.style.pointerEvents = "none";
|
|
6865
6914
|
});
|
|
6866
6915
|
wheelGestureMutedElementsRef.current = muted;
|
|
6916
|
+
// A nested frame re-rasters on every scale change no matter what layer the
|
|
6917
|
+
// canvas gets, starving the renderer. A filter — a no-op at this radius —
|
|
6918
|
+
// gives each one a surface the compositor scales from cache. commitView must
|
|
6919
|
+
// clear it, or they stay soft at rest.
|
|
6920
|
+
const filtered: HTMLElement[] = [];
|
|
6921
|
+
surface
|
|
6922
|
+
.querySelectorAll<HTMLElement>("[data-screen-content] iframe")
|
|
6923
|
+
.forEach((iframe) => {
|
|
6924
|
+
iframe.style.filter = "blur(0.001px)";
|
|
6925
|
+
filtered.push(iframe);
|
|
6926
|
+
});
|
|
6927
|
+
wheelGestureFilteredIframesRef.current = filtered;
|
|
6867
6928
|
}, [cancelPendingStaticBoardSelection]);
|
|
6868
6929
|
|
|
6869
6930
|
const flushPendingWheelGesture = useCallback(() => {
|
|
@@ -7615,23 +7676,23 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
7615
7676
|
pan,
|
|
7616
7677
|
canvasZoom,
|
|
7617
7678
|
);
|
|
7679
|
+
const candidates = canvasFrames.map(({ screen, metadata, geometry }) => ({
|
|
7680
|
+
id: screen.id,
|
|
7681
|
+
geometry: getResponsiveScreenCullGeometry(
|
|
7682
|
+
screen,
|
|
7683
|
+
geometry,
|
|
7684
|
+
(widthPx) =>
|
|
7685
|
+
measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
|
|
7686
|
+
),
|
|
7687
|
+
// Count only the breakpoint frames actually mounted (the row filters
|
|
7688
|
+
// duplicates of the device width) so the iframe budget isn't
|
|
7689
|
+
// over-consumed, prematurely evicting visible frames.
|
|
7690
|
+
iframeCount:
|
|
7691
|
+
1 +
|
|
7692
|
+
visibleBreakpointWidths(screen.breakpointWidths, metadata.width).length,
|
|
7693
|
+
}));
|
|
7618
7694
|
const next = computeBoundedScreenCullState({
|
|
7619
|
-
candidates
|
|
7620
|
-
id: screen.id,
|
|
7621
|
-
geometry: getResponsiveScreenCullGeometry(
|
|
7622
|
-
screen,
|
|
7623
|
-
geometry,
|
|
7624
|
-
(widthPx) =>
|
|
7625
|
-
measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
|
|
7626
|
-
),
|
|
7627
|
-
// Count only the breakpoint frames actually mounted (the row filters
|
|
7628
|
-
// duplicates of the device width) so the iframe budget isn't
|
|
7629
|
-
// over-consumed, prematurely evicting visible frames.
|
|
7630
|
-
iframeCount:
|
|
7631
|
-
1 +
|
|
7632
|
-
visibleBreakpointWidths(screen.breakpointWidths, metadata.width)
|
|
7633
|
-
.length,
|
|
7634
|
-
})),
|
|
7695
|
+
candidates,
|
|
7635
7696
|
viewport,
|
|
7636
7697
|
protectedScreenIds: protectedLiveScreenIds,
|
|
7637
7698
|
previousLiveScreenIds: liveScreenIdsRef.current,
|
|
@@ -7642,6 +7703,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
7642
7703
|
liveScreenIdsRef.current = next.liveScreenIds;
|
|
7643
7704
|
hasBeenVisibleScreenIdsRef.current = next.everVisibleScreenIds;
|
|
7644
7705
|
lastVisibleEpochByScreenIdRef.current = next.lastVisibleEpochByScreenId;
|
|
7706
|
+
screenPaintCandidatesRef.current = candidates.map(({ id, geometry }) => ({
|
|
7707
|
+
id,
|
|
7708
|
+
geometry,
|
|
7709
|
+
tier: next.tierByScreenId.get(id) ?? "visible",
|
|
7710
|
+
}));
|
|
7645
7711
|
return next.tierByScreenId;
|
|
7646
7712
|
}, [
|
|
7647
7713
|
canvasFrames,
|
|
@@ -7651,6 +7717,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
7651
7717
|
protectedLiveScreenIds,
|
|
7652
7718
|
surfaceSize,
|
|
7653
7719
|
]);
|
|
7720
|
+
// No dependency array on purpose: any render can mount, unmount, or reorder a
|
|
7721
|
+
// screen's content wrapper, and a wrapper React just mounted carries none of
|
|
7722
|
+
// the suppression state this owns.
|
|
7723
|
+
useEffect(() => {
|
|
7724
|
+
screenPaintTargetsRef.current = collectScreenPaintTargets(
|
|
7725
|
+
surfaceRef.current,
|
|
7726
|
+
);
|
|
7727
|
+
syncScreenPaintSuppression();
|
|
7728
|
+
});
|
|
7729
|
+
|
|
7654
7730
|
const topScreenId = useMemo(
|
|
7655
7731
|
() =>
|
|
7656
7732
|
selectedIds.find((id) =>
|
|
@@ -7897,7 +7973,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
|
7897
7973
|
|
|
7898
7974
|
{boardFileId &&
|
|
7899
7975
|
boardFileContent !== undefined &&
|
|
7900
|
-
|
|
7976
|
+
boardHasSurfaceContent &&
|
|
7901
7977
|
(() => {
|
|
7902
7978
|
const boardGeo = boardSurfaceRenderGeometry ?? {
|
|
7903
7979
|
x: 0,
|
|
@@ -9448,18 +9524,10 @@ const Screen = memo(function Screen({
|
|
|
9448
9524
|
const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
|
|
9449
9525
|
const previewViewport = getScreenPreviewViewport(metadata, geometry);
|
|
9450
9526
|
const suppressNextClick = useRef(false);
|
|
9451
|
-
// Overview viewport culling (PF22): a
|
|
9452
|
-
//
|
|
9453
|
-
//
|
|
9454
|
-
|
|
9455
|
-
// contentVisibility:"hidden" on the content wrapper. Deliberately NOT
|
|
9456
|
-
// display:none (can drop layout/scroll state on some engines) and NOT
|
|
9457
|
-
// will-change (a prior perf attempt using will-change on this same overview
|
|
9458
|
-
// caused permanent blur by pinning a low-res compositor layer — see the
|
|
9459
|
-
// world-transform comment near applyViewToDom). "placeholder" and
|
|
9460
|
-
// "evicted" tiers have no mounted browsing context, so they render the
|
|
9461
|
-
// chrome-only placeholder below regardless of this flag.
|
|
9462
|
-
const { shouldMount: shouldMountContent, isHidden: isCulled } =
|
|
9527
|
+
// Overview viewport culling (PF22): mounting only. Unmounting a culled screen
|
|
9528
|
+
// would lose its iframe's scroll/form/Alpine state, so it stays mounted and
|
|
9529
|
+
// paint-suppression.ts decides paint against the live camera instead.
|
|
9530
|
+
const { shouldMount: shouldMountContent } =
|
|
9463
9531
|
getScreenContentCullState(cullTier);
|
|
9464
9532
|
const [directlyHovered, setDirectlyHovered] = useState(false);
|
|
9465
9533
|
const frameDirectlyHovered =
|
|
@@ -9786,16 +9854,6 @@ const Screen = memo(function Screen({
|
|
|
9786
9854
|
)}
|
|
9787
9855
|
style={{
|
|
9788
9856
|
pointerEvents: screenContentInteractive ? "auto" : "none",
|
|
9789
|
-
// Tier B (PF22): keep the mounted iframe/DesignCanvas alive (see
|
|
9790
|
-
// the isCulled comment above) but skip its paint/layout cost.
|
|
9791
|
-
// visibility:hidden (not display:none) keeps the box in the
|
|
9792
|
-
// layout/measurement tree; contentVisibility:"hidden" skips
|
|
9793
|
-
// rendering its subtree entirely until it's shown again. Neither
|
|
9794
|
-
// property touches will-change/compositor layers, so this can't
|
|
9795
|
-
// reproduce the permanent-blur regression a prior will-change
|
|
9796
|
-
// attempt on this same surface caused.
|
|
9797
|
-
visibility: isCulled ? "hidden" : undefined,
|
|
9798
|
-
contentVisibility: isCulled ? "hidden" : undefined,
|
|
9799
9857
|
}}
|
|
9800
9858
|
>
|
|
9801
9859
|
{!shouldMountContent ? (
|
|
@@ -10167,7 +10225,7 @@ function BreakpointPreviewRow({
|
|
|
10167
10225
|
// draft value of its width input.
|
|
10168
10226
|
const [menuOpenForWidth, setMenuOpenForWidth] = useState<number | null>(null);
|
|
10169
10227
|
const [widthDraft, setWidthDraft] = useState("");
|
|
10170
|
-
const { shouldMount: shouldMountContent
|
|
10228
|
+
const { shouldMount: shouldMountContent } =
|
|
10171
10229
|
getScreenContentCullState(cullTier);
|
|
10172
10230
|
|
|
10173
10231
|
return (
|
|
@@ -10476,10 +10534,6 @@ function BreakpointPreviewRow({
|
|
|
10476
10534
|
data-screen-content
|
|
10477
10535
|
data-cull-tier={cullTier}
|
|
10478
10536
|
className="relative block h-full w-full overflow-hidden rounded-[inherit] bg-white ring-1 ring-inset ring-border"
|
|
10479
|
-
style={{
|
|
10480
|
-
visibility: isCulled ? "hidden" : undefined,
|
|
10481
|
-
contentVisibility: isCulled ? "hidden" : undefined,
|
|
10482
|
-
}}
|
|
10483
10537
|
>
|
|
10484
10538
|
{!shouldMountContent ? (
|
|
10485
10539
|
<div
|
|
@@ -96,9 +96,13 @@ export function getBoardSurfaceLayerStyle(args: {
|
|
|
96
96
|
*/
|
|
97
97
|
export function shouldRenderBoardSurfaceStaticPreview(args: {
|
|
98
98
|
zoom: number;
|
|
99
|
+
hasSurfaceContent: boolean;
|
|
99
100
|
viewportGeometry?: FrameGeometry | null;
|
|
100
101
|
renderGeometry: FrameGeometry;
|
|
101
102
|
}) {
|
|
103
|
+
// The replica is opaque. Backing a layer that is not rendering just slabs the
|
|
104
|
+
// board in its own colour, which reads as a themed background gone wrong.
|
|
105
|
+
if (!args.hasSurfaceContent) return false;
|
|
102
106
|
if (args.viewportGeometry) {
|
|
103
107
|
return (
|
|
104
108
|
args.viewportGeometry.width > args.renderGeometry.width ||
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getScreenContentCullState,
|
|
3
|
+
isFrameWithinOverscannedViewport,
|
|
4
|
+
type OverscannedViewportBounds,
|
|
5
|
+
type ScreenCullTier,
|
|
6
|
+
} from "./culling";
|
|
7
|
+
import type { FrameGeometry } from "./types";
|
|
8
|
+
|
|
9
|
+
// Zoom scales the world viewport rather than translating it, so no overscan
|
|
10
|
+
// covers a gesture's lag. React must never also write visibility here: its style
|
|
11
|
+
// diff would stop re-applying a value this owner has since cleared.
|
|
12
|
+
|
|
13
|
+
/** Lets a freshly mounted wrapper still be written while an unchanged one is
|
|
14
|
+
* skipped. */
|
|
15
|
+
const SUPPRESSED_MARKER_ATTRIBUTE = "screenPaintSuppressed";
|
|
16
|
+
|
|
17
|
+
export interface ScreenPaintCandidate {
|
|
18
|
+
id: string;
|
|
19
|
+
geometry: FrameGeometry;
|
|
20
|
+
tier: ScreenCullTier;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScreenPaintTarget {
|
|
24
|
+
element: HTMLElement;
|
|
25
|
+
screenId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every content wrapper belonging to a screen, including its breakpoint
|
|
29
|
+
* previews — paint is decided per screen, so the whole shell moves together. */
|
|
30
|
+
export function collectScreenPaintTargets(
|
|
31
|
+
surface: HTMLElement | null,
|
|
32
|
+
): ScreenPaintTarget[] {
|
|
33
|
+
if (!surface) return [];
|
|
34
|
+
const targets: ScreenPaintTarget[] = [];
|
|
35
|
+
surface
|
|
36
|
+
.querySelectorAll<HTMLElement>("[data-screen-content]")
|
|
37
|
+
.forEach((element) => {
|
|
38
|
+
const screenId = element.closest<HTMLElement>("[data-screen-shell]")
|
|
39
|
+
?.dataset.frameId;
|
|
40
|
+
if (screenId) targets.push({ element, screenId });
|
|
41
|
+
});
|
|
42
|
+
return targets;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Screens whose paint the browser may skip for the camera passed in. A
|
|
46
|
+
* `liveViewport` of `null` (surface not measured yet) suppresses nothing. */
|
|
47
|
+
export function resolveSuppressedScreenIds(
|
|
48
|
+
candidates: readonly ScreenPaintCandidate[],
|
|
49
|
+
liveViewport: OverscannedViewportBounds | null,
|
|
50
|
+
): Set<string> {
|
|
51
|
+
const suppressed = new Set<string>();
|
|
52
|
+
if (!liveViewport) return suppressed;
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (!getScreenContentCullState(candidate.tier).isHidden) continue;
|
|
55
|
+
if (isFrameWithinOverscannedViewport(candidate.geometry, liveViewport)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
suppressed.add(candidate.id);
|
|
59
|
+
}
|
|
60
|
+
return suppressed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function applyScreenPaintSuppression(
|
|
64
|
+
targets: readonly ScreenPaintTarget[],
|
|
65
|
+
suppressedScreenIds: ReadonlySet<string>,
|
|
66
|
+
options?: { relaxOnly?: boolean },
|
|
67
|
+
): void {
|
|
68
|
+
for (const { element, screenId } of targets) {
|
|
69
|
+
const suppress = suppressedScreenIds.has(screenId);
|
|
70
|
+
const applied = element.dataset[SUPPRESSED_MARKER_ATTRIBUTE] === "true";
|
|
71
|
+
if (applied === suppress) continue;
|
|
72
|
+
// Mid-gesture, only relax: hiding a screen the camera is still crossing
|
|
73
|
+
// guarantees an unpainted frame when it returns.
|
|
74
|
+
if (options?.relaxOnly && suppress) continue;
|
|
75
|
+
if (suppress) {
|
|
76
|
+
// Never content-visibility: it discards the subtree's rendering state, so
|
|
77
|
+
// un-hiding blanks the iframe for a frame.
|
|
78
|
+
element.style.setProperty("visibility", "hidden");
|
|
79
|
+
element.dataset[SUPPRESSED_MARKER_ATTRIBUTE] = "true";
|
|
80
|
+
} else {
|
|
81
|
+
element.style.removeProperty("visibility");
|
|
82
|
+
delete element.dataset[SUPPRESSED_MARKER_ATTRIBUTE];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|