Web source: crop to the widget's true content rect (union of visible body elements) — bounding box tight on all four sides, pinned to (0,0)
- QueryContentBoundsAsync now returns a real JS object (not JSON.stringify — ExecuteScriptAsync double-encodes strings, which silently killed the old crop) and measures the union of every visible body element's getBoundingClientRect: the widget's actual rect, top-left offset included. - CaptureFrame crops AT that offset (x,y) instead of from (0,0), so the widget anchors at origin and the box is flush on right/bottom. Fill maps the frame flush under the box.
This commit is contained in:
+17
-16
@@ -8,23 +8,24 @@ branches pre-1.0: all work lands on `main` per work unit.
|
|||||||
|
|
||||||
## The web-source rendering work (2026-08-28) — CURRENT MODEL: canvas-size viewport + content crop
|
## The web-source rendering work (2026-08-28) — CURRENT MODEL: canvas-size viewport + content crop
|
||||||
|
|
||||||
**Root cause finally isolated:** hosting the page at the ELEMENT-box size clips full-bleed widgets
|
**Root causes finally isolated:** (1) hosting the page at the element-box size clips full-bleed
|
||||||
(designed for ~1920×1080) to their upper-left chunk inside a small viewport — "image truncated to
|
widgets to their upper-left chunk (fixed — page now renders at the master canvas 1920×1080,
|
||||||
the upper-left corner regardless of scaling." That truncation persisted across every earlier
|
stable). (2) The content-bounds crop that was supposed to kill right/bottom dead space never ran:
|
||||||
model because all of them rendered the page at the box (or a mix); e917c41's full 1920×1080
|
the script returned `JSON.stringify(...)` (a string), and `ExecuteScriptAsync` JSON-encodes the
|
||||||
render was the only one that showed full content, and its letterbox dead space was purely the
|
result, so `JsonDocument.Parse` hit a string node and `GetProperty` threw → bounds stayed unset →
|
||||||
display `Uniform` + the box aspect mismatch — not the render.
|
no crop. ALSO the intended measure was wrong: scroll extents ≠ widget rect (they include the
|
||||||
|
page's own margins). Fixed together: the script now returns a real OBJECT (union of every visible
|
||||||
|
`body *` `getBoundingClientRect`), giving the widget's true rect INCLUDING its top-left offset,
|
||||||
|
and `CaptureFrame` crops AT that offset — so the widget anchors at (0,0) and the box is tight on
|
||||||
|
all four sides. `Stretch="Fill"` maps it flush under the box. Creator confirmed render+resize are
|
||||||
|
solid at the canvas-size change; this commit delivers the tight/0,0 bounding box.
|
||||||
|
|
||||||
**The model (implemented, visual verification pending):**
|
**The CURRENT MODEL:**
|
||||||
- `Services/WebView2Manager.cs` — page renders at the MASTER CANVAS size (1920×1080), stable,
|
- `Services/WebView2Manager.cs` — page renders at 1920×1080 (stable); `overflow:hidden` +
|
||||||
never tracked: full-bleed widgets render FULL (no upper-left truncation); dragging the box never
|
transparent-bg injection; `QueryContentBoundsAsync` measures real widget bounds (object return
|
||||||
reflows the page; `overflow:hidden` (html+body) + transparent-bg injection. After each nav
|
from JS, NOT JSON.stringify); `CaptureFrame` crops at the widget's (x,y) offset → anchored 0,0.
|
||||||
`QueryContentBoundsAsync` reads the page's real content rect
|
- `MainWindow.xaml` — web `<Image>` `Stretch="Fill"`.
|
||||||
(`scrollWidth/scrollHeight` vs `innerWidth/innerHeight`) and `CaptureFrame` **crops to the
|
- Invariant: **render at canvas size; crop to the widget's true bounds; Fill to the box.**
|
||||||
actual content bounds anchored at (0,0)**. Immediate capture on `NavigationCompleted`.
|
|
||||||
- `MainWindow.xaml` — web `<Image>` `Stretch="Fill"`: the cropped 1920×1080 frame maps flush under
|
|
||||||
the box (Box == widget bounds at every size, pinned (0,0), no dead space).
|
|
||||||
- Invariants: **render at canvas size; crop to content; Fill to box.**
|
|
||||||
|
|
||||||
**Property panel / sliders (2026-08-28):** web URI ✕ clears the textbox; web ✓/✕ icon buttons
|
**Property panel / sliders (2026-08-28):** web URI ✕ clears the textbox; web ✓/✕ icon buttons
|
||||||
`IsTabStop="False"` so Tab walks X→Y→W→H→URI; Slider style `IsMoveToPointEnabled="True"`
|
`IsTabStop="False"` so Tab walks X→Y→W→H→URI; Slider style `IsMoveToPointEnabled="True"`
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
public VideoFrame? LatestFrame;
|
public VideoFrame? LatestFrame;
|
||||||
public double ViewportCssW;
|
public double ViewportCssW;
|
||||||
public double ViewportCssH;
|
public double ViewportCssH;
|
||||||
|
public double ContentCssX;
|
||||||
|
public double ContentCssY;
|
||||||
public double ContentCssW;
|
public double ContentCssW;
|
||||||
public double ContentCssH;
|
public double ContentCssH;
|
||||||
public bool Disposed;
|
public bool Disposed;
|
||||||
@@ -163,21 +165,48 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
// viewport for widgets that don't fill it — the leftover area is dead space
|
// viewport for widgets that don't fill it — the leftover area is dead space
|
||||||
// that must not stretch across the selection box. Crop the capture to the
|
// that must not stretch across the selection box. Crop the capture to the
|
||||||
// content bounds (anchored at 0,0) so the box shows exactly the widget.
|
// content bounds (anchored at 0,0) so the box shows exactly the widget.
|
||||||
|
// The widget's ACTUAL visible content bounds — the union of every visible
|
||||||
|
// body rectangle (getBoundingClientRect). This is the real widget rect,
|
||||||
|
// excluding the page's own margins/dead space, with its top-left offset.
|
||||||
|
// A real object is returned (NOT JSON.stringify — ExecuteScriptAsync
|
||||||
|
// JSON-encodes the result, which would double-encode a string).
|
||||||
|
private const string ContentBoundsScript =
|
||||||
|
"(function(){" +
|
||||||
|
"var rs=[];var body=document.body;" +
|
||||||
|
"if(body){var br=body.getBoundingClientRect();if(br.width||br.height)rs.push(br);}" +
|
||||||
|
"var els=document.querySelectorAll('body *');" +
|
||||||
|
"for(var i=0;i<els.length;i++){" +
|
||||||
|
"var s=getComputedStyle(els[i]);" +
|
||||||
|
"if(s&&s.display==='none')continue;" +
|
||||||
|
"var r=els[i].getBoundingClientRect();" +
|
||||||
|
"if(r.width||r.height)rs.push(r);" +
|
||||||
|
"}" +
|
||||||
|
"if(!rs.length)return null;" +
|
||||||
|
"var l=rs[0].left,t=rs[0].top,rt=rs[0].right,bt=rs[0].bottom;" +
|
||||||
|
"for(var j=1;j<rs.length;j++){" +
|
||||||
|
"l=Math.min(l,rs[j].left);" +
|
||||||
|
"t=Math.min(t,rs[j].top);" +
|
||||||
|
"rt=Math.max(rt,rs[j].right);" +
|
||||||
|
"bt=Math.max(bt,rs[j].bottom);" +
|
||||||
|
"}" +
|
||||||
|
"return {l:l,t:t,w:Math.max(1,rt-l),h:Math.max(1,bt-t)," +
|
||||||
|
"vw:window.innerWidth||1,vh:window.innerHeight||1};" +
|
||||||
|
"})()";
|
||||||
|
|
||||||
private async Task QueryContentBoundsAsync(string sourceId)
|
private async Task QueryContentBoundsAsync(string sourceId)
|
||||||
{
|
{
|
||||||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (session.Control.CoreWebView2 is not { } cws) return;
|
if (session.Control.CoreWebView2 is not { } cws) return;
|
||||||
var json = await cws.ExecuteScriptAsync(
|
var json = await cws.ExecuteScriptAsync(ContentBoundsScript);
|
||||||
"JSON.stringify({w:document.documentElement.scrollWidth||0," +
|
|
||||||
"h:document.documentElement.scrollHeight||0," +
|
|
||||||
"vw:window.innerWidth||0,vh:window.innerHeight||0})");
|
|
||||||
if (string.IsNullOrEmpty(json) || json == "null") return;
|
if (string.IsNullOrEmpty(json) || json == "null") return;
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
session.ViewportCssW = root.GetProperty("vw").GetDouble();
|
session.ViewportCssW = root.GetProperty("vw").GetDouble();
|
||||||
session.ViewportCssH = root.GetProperty("vh").GetDouble();
|
session.ViewportCssH = root.GetProperty("vh").GetDouble();
|
||||||
|
session.ContentCssX = root.GetProperty("l").GetDouble();
|
||||||
|
session.ContentCssY = root.GetProperty("t").GetDouble();
|
||||||
session.ContentCssW = root.GetProperty("w").GetDouble();
|
session.ContentCssW = root.GetProperty("w").GetDouble();
|
||||||
session.ContentCssH = root.GetProperty("h").GetDouble();
|
session.ContentCssH = root.GetProperty("h").GetDouble();
|
||||||
_ = CaptureFrame(sourceId);
|
_ = CaptureFrame(sourceId);
|
||||||
@@ -223,23 +252,25 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
|
|
||||||
var stride = pixW * 4;
|
var stride = pixW * 4;
|
||||||
|
|
||||||
int cropW = pixW, cropH = pixH;
|
int cropX = 0, cropY = 0, cropW = pixW, cropH = pixH;
|
||||||
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
|
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
|
||||||
session.ContentCssW >= 1 && session.ContentCssH >= 1)
|
session.ContentCssW >= 1 && session.ContentCssH >= 1)
|
||||||
{
|
{
|
||||||
var sx = pixW / session.ViewportCssW;
|
var sx = pixW / session.ViewportCssW;
|
||||||
var sy = pixH / session.ViewportCssH;
|
var sy = pixH / session.ViewportCssH;
|
||||||
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW);
|
cropX = (int)Math.Clamp(Math.Round(session.ContentCssX * sx), 0, pixW - 1);
|
||||||
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH);
|
cropY = (int)Math.Clamp(Math.Round(session.ContentCssY * sy), 0, pixH - 1);
|
||||||
|
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW - cropX);
|
||||||
|
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH - cropY);
|
||||||
}
|
}
|
||||||
|
|
||||||
var outPixels = pixels;
|
var outPixels = pixels;
|
||||||
if (cropW != pixW || cropH != pixH)
|
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0)
|
||||||
{
|
{
|
||||||
var dstStride = cropW * 4;
|
var dstStride = cropW * 4;
|
||||||
outPixels = new byte[dstStride * cropH];
|
outPixels = new byte[dstStride * cropH];
|
||||||
for (var r = 0; r < cropH; r++)
|
for (var r = 0; r < cropH; r++)
|
||||||
Buffer.BlockCopy(pixels, r * stride, outPixels, r * dstStride, dstStride);
|
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride);
|
||||||
}
|
}
|
||||||
|
|
||||||
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
|||||||
@@ -917,16 +917,16 @@ capturing the primary display (which causes the infinity mirror effect).
|
|||||||
8. ✅ Preview shows live web content (not just a blank rectangle)
|
8. ✅ Preview shows live web content (not just a blank rectangle)
|
||||||
9. ☐ Handle navigation errors, invalid URIs, timeout gracefully
|
9. ☐ Handle navigation errors, invalid URIs, timeout gracefully
|
||||||
|
|
||||||
**Rendering model (2026-08-28, canvas-size viewport + content-bounds crop):**
|
**Rendering model (2026-08-28, canvas-size viewport + real-content-bounds crop):**
|
||||||
the page renders at the MASTER CANVAS size (1920×1080), stable, never tracked. Two earlier bugs
|
the page renders at the MASTER CANVAS size (1920×1080), stable, never tracked (no reflow/truncation;
|
||||||
are both dead: (a) box-sized viewports clip full-bleed widgets to their upper-left chunk
|
scrollbars suppressed via `overflow:hidden`). After each navigation the manager measures the widget's
|
||||||
("truncated to the upper-left corner regardless of scaling" — the image is desktop-line space),
|
ACTUAL visible content rect — union of every visible `body *` `getBoundingClientRect` (excludes the
|
||||||
(b) dragging the box reflowed the page (truncation/scrollbars). Scrollbars suppressed via
|
page's own margins/dead space, includes its top-left offset) — and **crops the capture to that rect**,
|
||||||
`overflow:hidden` html/body injection. After each navigation the manager queries the page's real
|
anchoring the widget at (0,0). `Stretch="Fill"` maps the frame flush under the box: the selection
|
||||||
content rect (`scrollWidth/scrollHeight` vs `innerWidth/innerHeight`) and **crops the capture to
|
box bounds the widget tight on all four sides and is pinned to (0,0) for any widget geometry.
|
||||||
the actual content bounds, anchored at (0,0)**. `Stretch="Fill"` maps the frame flush under the
|
⚠️ `ExecuteScriptAsync` JSON-encodes the returned value — the script must return a real OBJECT (not
|
||||||
box — selection/adornment box always bounds the widget, pinned to (0,0), for any widget geometry.
|
`JSON.stringify`, which double-encodes → parse exception → crop silently skipped). Graceful-error
|
||||||
Graceful-error handling (item 9) is a follow-up.
|
handling (item 9) is a follow-up.
|
||||||
|
|
||||||
**Properties panel (2026-08-28):** web URI ✓/✕ icon buttons are `IsTabStop="False"` so Tab flows
|
**Properties panel (2026-08-28):** web URI ✓/✕ icon buttons are `IsTabStop="False"` so Tab flows
|
||||||
X→Y→W→H→URI; the ✕ button now clears the URI textbox (was reverting to the pre-accept snapshot).
|
X→Y→W→H→URI; the ✕ button now clears the URI textbox (was reverting to the pre-accept snapshot).
|
||||||
|
|||||||
Reference in New Issue
Block a user