Web source: crop capture to actual content bounds (anchor 0,0); slider click-to-position; web URI X clears; tab order
This commit is contained in:
+20
-14
@@ -6,26 +6,32 @@
|
|||||||
working). **NOT pushed — user rule: never push without explicit instruction (2026-08-24).** No feature
|
working). **NOT pushed — user rule: never push without explicit instruction (2026-08-24).** No feature
|
||||||
branches pre-1.0: all work lands on `main` per work unit.
|
branches pre-1.0: all work lands on `main` per work unit.
|
||||||
|
|
||||||
## The web-source rendering work (2026-08-28) — CURRENT MODEL: stable viewport + Fill
|
## The web-source rendering work (2026-08-28) — CURRENT MODEL: stable viewport + content-bounds crop
|
||||||
|
|
||||||
**Background:** viewport==box made dragging the box reflow the page (truncation, scrollbars,
|
**Background:** viewport==box made dragging the box reflow the page (truncation, scrollbars,
|
||||||
unrecoverable image, "janky at stressful geometries"). Fixed-viewport + `Uniform` left letterbox
|
unrecoverable image, "janky at stressful geometries"). Fixed-viewport + `Uniform` left letterbox
|
||||||
dead space. CSS reset enlarged the gap. Scrollbars were called "super fail".
|
dead space. CSS reset enlarged the gap. Scrollbars were called "super fail". The right/bottom
|
||||||
|
dead-space + "not anchored at (0,0)" report persisted across every model — dead space was
|
||||||
|
page-internal (widget content smaller than the viewport), which is now cropped out.
|
||||||
|
|
||||||
**The model that satisfies invariants (implemented, needs visual verification):**
|
**The model that satisfies invariants (implemented, visual verification pending):**
|
||||||
- `Services/WebView2Manager.cs` — the page renders ONCE at the viewport size the element was
|
- `Services/WebView2Manager.cs` — stable viewport = the element's size at `Register` (snapshot,
|
||||||
created at (snapshot in `Register`, never tracked → no reflow, no truncation during resize).
|
never tracked → no reflow/truncation during resize). `overflow:hidden` (html+body) +
|
||||||
`overflow:hidden` (html+body) + transparent-bg injection on every nav — nothing else, no
|
transparent-bg injection on every nav (nothing else). After each navigation,
|
||||||
layout/CSS meddling. Immediate capture on `NavigationCompleted`. `FramePump` unchanged.
|
`QueryContentBoundsAsync` reads the page's real content rect
|
||||||
- `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"`: any box the user draws is covered
|
(`scrollWidth/scrollHeight` vs `innerWidth/innerHeight` via `ExecuteScriptAsync` + JSON) and
|
||||||
edge-to-edge, zero dead space, zero crop (OBS free-transform: fixed browser resolution,
|
`CaptureFrame` **crops the capture to the actual content bounds, anchored at (0,0)** — dead
|
||||||
scene-item scales the texture). Aspect distortion only when the box aspect ≠ viewport aspect.
|
space inside the viewport no longer reaches the box. Immediate capture on `NavigationCompleted`.
|
||||||
- Invariants: **page viewport is stable; display Fill-scales texture to the box.**
|
- `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"`: the cropped frame maps edge-to-edge,
|
||||||
|
so the selection box always bounds the real widget. `FramePump` unchanged.
|
||||||
|
- Invariants: **page viewport stable; display Fill-crops to content and fills the box.**
|
||||||
|
|
||||||
Build: 0 warnings; tests 4/4 (snapshot at register, no tracking after, min 1, transparent bg).
|
**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"`
|
||||||
|
restores click-anywhere-on-bar.
|
||||||
|
|
||||||
**Rejected for the record:** fixed 1920×1080+`Uniform` (letterbox), CSS margin/100% reset
|
Build: 0 warnings; tests 4/4. Rejected for the record: fixed 1920×1080+`Uniform` (letterbox),
|
||||||
(enlarged gap), viewport==box (reflow/truncation/scrollbars).
|
CSS margin/100% reset (enlarged gap), viewport==box (reflow/truncation/scrollbars).
|
||||||
|
|
||||||
## Key facts / landmines
|
## Key facts / landmines
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -546,13 +546,13 @@
|
|||||||
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
Width="22" Height="22" Margin="4,0,0,0"
|
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
||||||
Click="WebAccept_Click">
|
Click="WebAccept_Click">
|
||||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
Width="22" Height="22" ToolTip="Revert"
|
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
|
||||||
Click="WebRevert_Click" Margin="4,0,0,0">
|
Click="WebRevert_Click" Margin="4,0,0,0">
|
||||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
|||||||
+2
-1
@@ -859,7 +859,8 @@ public partial class MainWindow : Window
|
|||||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_snapshotElement is Models.Source src)
|
if (_snapshotElement is Models.Source src)
|
||||||
src.WebUri = _snapshotWebUri;
|
src.WebUri = string.Empty;
|
||||||
|
_snapshotWebUri = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
@@ -26,6 +27,10 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
public required WebView2 Control { get; init; }
|
public required WebView2 Control { get; init; }
|
||||||
public required DispatcherTimer CaptureTimer { get; init; }
|
public required DispatcherTimer CaptureTimer { get; init; }
|
||||||
public VideoFrame? LatestFrame;
|
public VideoFrame? LatestFrame;
|
||||||
|
public double ViewportCssW;
|
||||||
|
public double ViewportCssH;
|
||||||
|
public double ContentCssW;
|
||||||
|
public double ContentCssH;
|
||||||
public bool Disposed;
|
public bool Disposed;
|
||||||
public bool Initialized;
|
public bool Initialized;
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,7 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
"document.documentElement.style.overflow='hidden';" +
|
"document.documentElement.style.overflow='hidden';" +
|
||||||
"document.body.style.background='transparent';" +
|
"document.body.style.background='transparent';" +
|
||||||
"document.body.style.overflow='hidden';");
|
"document.body.style.overflow='hidden';");
|
||||||
|
_ = QueryContentBoundsAsync(source.Id);
|
||||||
_ = CaptureFrame(source.Id);
|
_ = CaptureFrame(source.Id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,6 +159,35 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
|
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page's real content rect (scrollWidth/Height) can be smaller than the
|
||||||
|
// 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
|
||||||
|
// content bounds (anchored at 0,0) so the box shows exactly the widget.
|
||||||
|
private async Task QueryContentBoundsAsync(string sourceId)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (session.Control.CoreWebView2 is not { } cws) return;
|
||||||
|
var json = await cws.ExecuteScriptAsync(
|
||||||
|
"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;
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
session.ViewportCssW = root.GetProperty("vw").GetDouble();
|
||||||
|
session.ViewportCssH = root.GetProperty("vh").GetDouble();
|
||||||
|
session.ContentCssW = root.GetProperty("w").GetDouble();
|
||||||
|
session.ContentCssH = root.GetProperty("h").GetDouble();
|
||||||
|
_ = CaptureFrame(sourceId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"WebView2Manager: content bounds query failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CaptureFrame(string sourceId)
|
private async Task CaptureFrame(string sourceId)
|
||||||
{
|
{
|
||||||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||||
@@ -187,11 +222,31 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
formatted.CopyPixels(pixels, pixW * 4, 0);
|
formatted.CopyPixels(pixels, pixW * 4, 0);
|
||||||
|
|
||||||
var stride = pixW * 4;
|
var stride = pixW * 4;
|
||||||
var wb = new WriteableBitmap(pixW, pixH, 96, 96, PixelFormats.Bgra32, null);
|
|
||||||
wb.WritePixels(new Int32Rect(0, 0, pixW, pixH), pixels, stride, 0);
|
int cropW = pixW, cropH = pixH;
|
||||||
|
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
|
||||||
|
session.ContentCssW >= 1 && session.ContentCssH >= 1)
|
||||||
|
{
|
||||||
|
var sx = pixW / session.ViewportCssW;
|
||||||
|
var sy = pixH / session.ViewportCssH;
|
||||||
|
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW);
|
||||||
|
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH);
|
||||||
|
}
|
||||||
|
|
||||||
|
var outPixels = pixels;
|
||||||
|
if (cropW != pixW || cropH != pixH)
|
||||||
|
{
|
||||||
|
var dstStride = cropW * 4;
|
||||||
|
outPixels = new byte[dstStride * cropH];
|
||||||
|
for (var r = 0; r < cropH; r++)
|
||||||
|
Buffer.BlockCopy(pixels, r * stride, outPixels, r * dstStride, dstStride);
|
||||||
|
}
|
||||||
|
|
||||||
|
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
|
||||||
wb.Freeze();
|
wb.Freeze();
|
||||||
|
|
||||||
session.LatestFrame = new VideoFrame(pixW, pixH, pixels);
|
session.LatestFrame = new VideoFrame(cropW, cropH, outPixels);
|
||||||
|
|
||||||
_ = _dispatcher.BeginInvoke(() =>
|
_ = _dispatcher.BeginInvoke(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -917,17 +917,22 @@ 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, stable-viewport + fill — the working invariant):**
|
**Rendering model (2026-08-28, stable viewport + content-bounds crop):**
|
||||||
the page renders ONCE at the size the element was created at (snapshot in `Register`, never
|
the page renders ONCE at the size the element was created at (snapshot in `Register`, never
|
||||||
tracked) — so dragging the box never reflows the page, never truncates it, and scrollbars are
|
tracked) — dragging the box never reflows the page; scrollbars suppressed via
|
||||||
suppressed (`overflow:hidden` on html/body injection; background-transparent only otherwise,
|
`overflow:hidden` html/body injection (background-transparent only otherwise). After each
|
||||||
no layout meddling). The captured texture is displayed with `Stretch="Fill"`, so any box the
|
navigation the manager queries the page's real content rect (`scrollWidth/scrollHeight` vs
|
||||||
user draws is covered edge-to-edge with zero dead space (OBS free-transform behavior:
|
`innerWidth/innerHeight`) and **crops the capture to the actual content bounds, anchored at
|
||||||
browser renders at a fixed resolution, the scene-item scales the texture). The invariants:
|
(0,0)** — widgets that don't fill their viewport no longer stretch dead space across the box.
|
||||||
the page viewport is stable; the display Fill-scales it to the box. Graceful-error handling
|
`Stretch="Fill"` maps the cropped frame edge-to-edge (box is covered; the fill/adornment rect
|
||||||
(item 9) is a follow-up. Attempts rejected for the record: fixed 1920×1080 + `Uniform`
|
snaps to it). Graceful-error handling (item 9) is a follow-up. Rejected for the record:
|
||||||
(letterbox dead space), CSS margin/100% reset (enlarged the gap), viewport==box (page reflow
|
fixed-viewport 1920×1080 + `Uniform` (letterbox), CSS margin/100% reset (enlarged gaps),
|
||||||
→ truncation/scrollbars/jank).
|
viewport==box (page reflow → truncation/scrollbars).
|
||||||
|
|
||||||
|
**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).
|
||||||
|
Slider style gained `IsMoveToPointEnabled="True"` — click-anywhere-on-bar jumps the thumb to the
|
||||||
|
click (volume sliders keep their manual `SetSliderValueFromClick`, harmless duplication).
|
||||||
|
|
||||||
### Design decisions
|
### Design decisions
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,7 @@
|
|||||||
<Style TargetType="Slider">
|
<Style TargetType="Slider">
|
||||||
<Setter Property="Height" Value="20"/>
|
<Setter Property="Height" Value="20"/>
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="IsMoveToPointEnabled" Value="True"/>
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="Slider">
|
<ControlTemplate TargetType="Slider">
|
||||||
|
|||||||
Reference in New Issue
Block a user