Repository navigation
feat(studio): read overlay geometry from an opaque preview over its channel - #7628
Conversation
…hannel The runtime's editing channel gains a `geometry` request: per element handle its client rect, the corners it paints at when rotated, whether it renders, its clip-path, its source boundary's size, a group wrapper's member rects, and optionally one element's descendant boxes. On a preview frame Studio cannot script, the selection, child, group and hover boxes, the root scale (from the mirrored stage size) and a press held across a preview change read that geometry, with one read in flight at a time. Same-origin frames keep reading the DOM directly; the measuring code they use now lives in core so both paths share one rule. Also from the review of #7613: opaque selections carry `hasAudio` and `src` read off the description, a description with a cut attribute or inline style builds no selection, a failed computed-styles or root read builds none either, and the group refresh threads its frame and leaves a group the user changed meanwhile. `findClosestByAttribute`, left without a caller by #7613, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit accuracy: accurate 267 (base branch 267), smooth 99 of thoseThe gate passes. Quarantined, measured but not gated (8)
Unstable (1)
|
…try read The producer ships the preview runtime, which now carries the editing channel's `geometry` request and the measuring code it shares with Studio. The packed tarball measured 888,810 bytes in CI against an 880,000 budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: APPROVE · 0 blocker · 1 important · 0 nits
Opaque mode is still off, so the one finding (↓ inline) is for before it's turned on. The channel rects match the same-origin update() branch by branch: selection visibility, child bound and 2px floor, group crop hug, hover dedupe. Stale answers are dropped, and the held-press channel wait stays bounded by the probe and request timeouts.
Scope
Read end-to-end: editingGeometry.ts, editingPort.ts parser and types, previewOverlayRects.ts, useDomEditOverlayRects.ts (both paths), domEditOverlayGeometry.ts/Crop.ts extractions, previewGestureStarts.ts, previewElements.ts readers, previewSelection.ts, useDomSelection.ts group guard, fill/facts reads, size budget. Trusting: the test files (CI green on d11007b: CI, regression, preview-regression, CodeQL). No prior reviews on the PR.
— Genesis
| const attr = (element: PreviewElement, name: string) => | ||
| element.attributes.find(([key]) => key === name)?.[1] ?? null; | ||
|
|
||
| const mayBeCut = (value: string) => |
There was a problem hiding this comment.
important The runtime's cut() (editingHandles.ts:90) cuts every data: attribute to its header, whatever its length, and marks it truncated. So with this guard, an <img src="data:image/svg+xml,…"> of even 200 bytes builds no selection on an opaque frame. The element can't be selected at all, not even to move it, and nothing is written back from src in that case. The tests only cover a 3,000+ char data: value. Suggested fix: have cut keep whole any data: value at or under MAX_ELEMENT_VALUE, so only one that is really too long still reads as cut here. Or, if you want to keep this behavior, add a test that pins it and say in the body that it applies to all data: srcs.
On a preview frame Studio cannot script, Studio now reads overlay geometry over the runtime's editing channel. That covers the selection, child, group and hover boxes, the root scale, and the box a press held across a preview change starts from. Same-origin frames still read the DOM directly. The measuring code they use moves to core, so both kinds of frame measure an element by one rule. Nothing turns the opaque mode on yet.
This PR also lands the follow-ups from the review of #7613 that have to be in before that mode is switched on.
The captures come from real Studio, with boxes drawn from the measured rects. Cyan is the selection, white the child outline, green the group items (one of them hugged to its inset crop), and red the hover box on the element rotated 18°.
Before
Same-origin preview: boxes from the DOM reads the overlay already does.
After
The same preview reloaded as an opaque
sandbox="allow-scripts"frame: boxes fromgeometryover the editing channel. They match the same-origin boxes to 0.001px.Claude Opus 5.5
What
Runtime: new
geometry { handles, children? }request (packages/core/src/runtime/editingGeometry.ts,editingPort.ts,editingAgent.ts)nullfor an unknown or removed element, as indescribe. Each entry carries:rect: the client rect.angle: the rotation the element paints under, composed over its ancestors.corners: its painted border-box corners, for a rotated element only.visible: whether it and every ancestor render.clipPath: inline first, then computed. It isnullwhen it is longer than 2,000 characters.source: the nearest source boundary's rect and declareddata-width/data-height.members: adata-hf-groupwrapper's member rects, up to 256.childrennames one handle. Its descendants' boxes come back too, when it has 1 to 60 of them, which is the bound the overlay already drew child outlines to. Otherwise the list is empty.childrenmust be a well-formed handle when it is present.domEditOverlayTransform.ts, renamed tocore/src/runtime/elementTransform.ts), the visibility walk, the transform snapshot, rotation decomposition and the corner math.Studio
computeOverlayRootScalescales an opaque frame by the stage size its runtime posts.domEditOverlayGeometryplaces frame-px measurements on the overlay withframeRectToOverlay,frameCornersToOverlayandorientOverlayRect. The same-origin functions call these with the same reads as before.hugRectForClipPathsplits out ofhugRectForElementin the same way.previewOverlayRects.tsbuilds each box from geometry with that arithmetic:useDomEditOverlayRectstakes the channel path for an opaque frame. At most one geometry read is in flight. A frame that fires while a read is outstanding skips its read. An answer is dropped if the selection, group, hover or frame changed while it was in flight.previewGestureStarts: on an opaque frame, a press held across a preview change waits until the preview settles. It then re-finds the pressed selections on the page now shown, byqueryanddescribe, and measures them in one geometry read. The press starts from that, or is dropped if any of them is gone.#7613 review follow-ups
hasAudioandsrc, read off the description.domEditSelectionToFactsand the image fill field prefer them. Same-origin selections leave both unset and read the element as before.data:value cut to its header, or the entry count is at its cap. Cut text or children do not block, since neither is written back from a description. Fetching the full value was the alternative; blocking is the simpler of the two safe options.computed-stylesread now builds nothing, where it used to build with{}. A failed composition-root read builds nothing too, where it used to fall back to the document element's rect.findClosestByAttributehad no caller left after feat(studio): timeline select and selection refresh read an opaque preview over its channel #7613, so it is removed.Package size
geometryrequest and the measuring code it shares with Studio. CI measured the tarball at 888,810 bytes. feat(core): give the preview parent a typed editing port to prepare for an opaque frame #7556 raised this budget the same way for the editing agent.Why
This is the next slice of letting Studio's preview run in an opaque-origin frame. Studio cannot read layout off such a frame, so the overlay reads it through the frame's runtime instead.
Related work
Refs #7602, #7603, #7613. #5675 also edits
useDomEditOverlayRects.ts. This PR adds a channel branch beside the existingupdateand leavesupdateitself unchanged.Not in this PR: marquee, snap targets, the layers panel and the motion path still read the same-origin DOM. So do the gesture bodies themselves (drag, resize, rotate and crop writes), canvas click and hover, and the timeline click path in the running app.
Test plan
Unit tests added/updated:
editingGeometry.test.ts: rect, painted corners for a quarter turn, visibility, clip-path, source boundary, group members, descendant boxes and their bound, andnullonce an element is removed.editingAgent.test.ts: 12 malformedgeometryparams are rejected withinvalid-params.previewOverlayRects.test.ts: the same hook runs over a same-origin frame and over an opaque frame served by the real editing agent, and the two outputs are compared on the same DOM. The cases are a source host with child outlines, a group and a clipped member, a rotated hover, a rotated selection, and a hidden selection. The other tests check concrete numbers, one read in flight across several frames, and a held press that is re-found and measured over the channel or dropped.previewSelection.test.ts:hasAudiofor muted,data-has-audio="false"and audible videos;srcon an image; cut attribute and style values build nothing while long text does not block; failed computed-styles and root reads build nothing; a group the user changed during a refresh is left alone.Mutation probes: 15 of the changes were reverted one at a time, and a test failed for each.
Full Studio suite: 748 files passed. Full core suite: 294 files passed. Typecheck, oxlint, oxfmt,
fallow auditandcomment-ratchetare clean.Manual testing in real Studio, on a dev server with a three-element composition: a card with a child, an element rotated 18°, and an element with an inset crop.
sandbox="allow-scripts", so its document was unreadable, and the same boxes were measured withmeasurePreviewOverlayover the real port, using the posted stage size.The captures are under Before and After above.
Clicking a timeline clip on the sandboxed frame did not select it, because the timeline's click path in the running app still reads the frame's document. That is left for the next slice, so this check calls the measuring function directly.
Comments follow CONTRIBUTING.md "Comments"
— Rames Jusso
🤖 Generated with Claude Code