From d550621f358a2293300c2abcc5fba5d05ca7115b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Fri, 13 Nov 2026 16:09:41 +0100 Subject: [PATCH 1/3] Fix and clean up the WinUI adapter - Make the controls focusable and keep the root ScrollViewer from taking the focus of the panel - Raise the container events synchronously on the UI thread - Use DoubleTapped and pass every press on to the core - Pass the font file to DirectWrite, which only finds a family that isn't installed that way - Take a font file in HtmlRender.AddFontFamily - Load images without a UI thread continuation and save them asynchronously - Implement drag and drop of the selection - Measure text including the trailing whitespace - Remove the generated comments --- .../Adapters/BrushAdapter.cs | 21 +- .../Adapters/ControlAdapter.cs | 16 +- .../Adapters/FontAdapter.cs | 33 +- .../Adapters/FontFamilyAdapter.cs | 37 +- .../Adapters/GraphicsAdapter.cs | 59 +-- .../Adapters/ImageAdapter.cs | 3 +- .../HtmlRenderer.WinUI/Adapters/PenAdapter.cs | 5 +- .../Adapters/WinUIAdapter.cs | 183 +++---- Source/HtmlRenderer.WinUI/HtmlContainer.cs | 9 +- Source/HtmlRenderer.WinUI/HtmlControl.cs | 467 ++++++++++-------- Source/HtmlRenderer.WinUI/HtmlPanel.cs | 27 +- Source/HtmlRenderer.WinUI/HtmlRender.cs | 31 +- .../Utilities/ClipboardHelper.cs | 54 +- .../Utilities/WindowsTheme.cs | 8 - 14 files changed, 446 insertions(+), 507 deletions(-) diff --git a/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs index c000fd9ad..ab92f24e8 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs @@ -42,27 +42,10 @@ public ICanvasBrush Brush } /// - /// No-op, matching HtmlRenderer.WPF's own BrushAdapter.Dispose exactly. + /// Does nothing, the core also disposes the cached solid brushes, which would break their next use. /// /// - /// calls brush.Dispose() unconditionally - /// after every background fill, including for solid-color brushes obtained from - /// - which is a - /// process-wide singleton CACHE (see that method's own doc comment: "cached... instance"), - /// reused for the lifetime of the adapter, not a fresh per-call resource. WPF's Brush - /// needs no real disposal, so that unconditional call is harmless there - it's the reason this - /// pattern was never caught before. Win2D's is a real unmanaged/GPU - /// resource: actually disposing it here permanently kills the shared cached brush for every - /// color after its first use, so the SECOND time any element needs (for example) a white - /// background, the disposed brush silently paints nothing - confirmed by reproducing exactly - /// this symptom (background-color:white showing the page's own gradient background through - /// instead) via two consecutive real calls against the - /// same document. Not disposing here leaks gradient/texture brushes (the one kind of - /// that genuinely is fresh-per-call, from - /// /) - /// - accepted deliberately, mirroring WPF's identical no-op for its own gradient/texture - /// Brush objects, since the alternative (silently breaking every cached solid-color fill - /// after its first paint) is far worse than a small, bounded resource lifetime extension. + /// Gradient and texture brushes aren't cached, they're left to the finalizer. /// public override void Dispose() { diff --git a/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs index 517c8f671..02a89b70f 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs @@ -14,6 +14,7 @@ using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Utils; +using TheArtOfDev.HtmlRenderer.WinUI.Utilities; namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters { @@ -21,11 +22,7 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters /// Adapter for WinUI Control for core. /// /// - /// Unlike WPF's static queries, WinUI 3 has no queryable - /// global mouse-button state - caches the last-known pressed/position state - /// from its own PointerPressed/PointerReleased/PointerMoved handlers, and this - /// adapter (constructed fresh per call, mirroring HtmlRenderer.WPF's own ControlAdapter - /// usage pattern) just reads those cached fields back off it. + /// WinUI has no global mouse state to query, the pointer state is the last one has seen. /// internal sealed class ControlAdapter : RControl { @@ -85,14 +82,7 @@ public override void SetCursorIBeam() public override void DoDragDropCopy(object dragDropData) { - // WinUI 3 has no synchronous "start a drag now" API analogous to WPF's - // System.Windows.DragDrop.DoDragDrop - a drag operation there is always initiated by the - // framework via CanDrag/DragStarting on the source element in response to a user gesture, not - // called imperatively from application code mid-selection. Wiring that up is a real, separate - // piece of work (hooking DragStarting on the CanvasControl/UserControl and populating its - // DataPackage from dragDropData) that is out of scope for this foundation skeleton - see the - // implementation report's deviations section. Copy-to-clipboard (Ctrl+C) is unaffected, since - // it goes through RAdapter.SetToClipboard, not this method. + (_control as HtmlControl)?.StartDragDrop((ClipboardHelper.Content)dragDropData); } public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth) diff --git a/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs index 609638968..0ad2ff07a 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs @@ -22,24 +22,14 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters /// Adapter for Win2D font. /// /// - /// Win2D/DirectWrite exposes no low-level per-glyph metrics table the way WPF's - /// does (see the plan's Step 1 spike risk #2), so - /// / can't be read directly off the font the way - /// HtmlRenderer.WPF's own FontAdapter does (FontFamily.LineSpacing/ - /// Typeface.UnderlinePosition). Instead they're derived by laying out a representative probe - /// string once at construction time and reading - - /// gives the line height directly, but there is no underline- - /// position metric at all on Win2D's public surface, so is a documented - /// heuristic (see below), not a measured value. + /// The line metrics come from laying out a probe string, the underline position is approximated from them. /// internal sealed class FontAdapter : RFont { #region Fields and Consts /// - /// A representative probe string used to derive line metrics - includes an ascender-tall and a - /// descender-tall glyph so the measured line height reflects a normal line of text, not just - /// x-height characters. + /// Has an ascender and a descender, so the line height covers a normal line of text. /// private const string ProbeString = "Ág"; @@ -49,13 +39,12 @@ internal sealed class FontAdapter : RFont private readonly CanvasTextFormat _textFormat; /// - /// the size of the font, in points (unscaled) - matches the unit - /// receives it in, mirroring HtmlRenderer.WPF's own FontAdapter.Size. + /// the size of the font, in points. /// private readonly double _size; /// - /// Cached font height, in device-independent pixels (Win2D/WinUI's coordinate unit, same as WPF's). + /// Cached font height, in device-independent pixels. /// private readonly double _height; @@ -75,7 +64,7 @@ internal sealed class FontAdapter : RFont /// Init. /// /// the device to build the probe layout with - /// the font family name (system-installed or an @font-face family registered with a live ) + /// the font family name /// the font size, in points /// the font style public FontAdapter(CanvasDevice device, string fontFamily, double size, RFontStyle style) @@ -85,15 +74,11 @@ public FontAdapter(CanvasDevice device, string fontFamily, double size, RFontSty _textFormat = new CanvasTextFormat { FontFamily = fontFamily, - // 96/72 converts points -> device-independent pixels, matching WPF's own FontAdapter, - // whose Height/UnderlineOffset bake in the same conversion factor and whose DrawString/ - // MeasureString multiply the raw font size by it at every use. + // Points to device-independent pixels FontSize = (float)(96d / 72d * size), FontWeight = (style & RFontStyle.Bold) == RFontStyle.Bold ? FontWeights.Bold : FontWeights.Normal, FontStyle = (style & RFontStyle.Italic) == RFontStyle.Italic ? Windows.UI.Text.FontStyle.Italic : Windows.UI.Text.FontStyle.Normal, - // The core engine performs its own line-breaking/word-wrap over runs it measures one at a - // time (see RGraphics.MeasureString) - every layout built from this format, for measuring - // or for drawing, is expected to be a single unwrapped line. + // The core breaks the lines itself WordWrapping = CanvasWordWrapping.NoWrap, }; @@ -104,9 +89,7 @@ public FontAdapter(CanvasDevice device, string fontFamily, double size, RFontSty { _height = lineMetrics[0].Height; var baseline = lineMetrics[0].Baseline; - // No underline-position metric is exposed by Win2D's public surface - approximate it - // as a small step below the baseline, proportional to the line's descent region. This - // is a heuristic, not a measured value; see this class's own remarks. + // A bit below the baseline, into the descent _underlineOffset = baseline + (_height - baseline) * 0.15; } else diff --git a/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs index 797997a36..c7f7cbdb8 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs @@ -10,7 +10,7 @@ // - Sun Tsu, // "The Art of War" -using Microsoft.Graphics.Canvas.Text; +using System; using TheArtOfDev.HtmlRenderer.Adapters; namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters @@ -18,48 +18,39 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters /// /// Adapter for Win2D/DirectWrite font family object for core. /// - /// - /// Unlike WPF's , which is itself the object that must - /// stay alive to keep a loaded font usable, Win2D/DirectWrite resolves a - /// string by name at layout-construction time. For an @font-face family that name only resolves - /// while the backing that registered it is still alive - so this adapter - /// holds a strong reference to that font set (null for ordinary system-installed families) purely to - /// keep it from being garbage collected for as long as this handle is - /// referenced (by 's own cache). - /// internal sealed class FontFamilyAdapter : RFontFamily { /// - /// the family name, as resolved by DirectWrite at text-layout time. + /// the family name. /// private readonly string _name; /// - /// Strong reference to the backing font set for an @font-face family, keeping it alive for - /// as long as this handle is referenced. Null for ordinary system-installed families. + /// The font file of an @font-face or custom family, null for system families. /// - private readonly CanvasFontSet _fontSet; + private readonly Uri _fontFile; /// /// Init. /// - public FontFamilyAdapter(string name, CanvasFontSet fontSet = null) + public FontFamilyAdapter(string name, Uri fontFile = null) { _name = name; - _fontSet = fontSet; + _fontFile = fontFile; } - /// - /// the backing font set for an @font-face family, or null for an ordinary system family. - /// - public CanvasFontSet FontSet + public override string Name { - get { return _fontSet; } + get { return _name; } } - public override string Name + /// + /// The family for , DirectWrite + /// only finds a family that isn't installed with the font file in front of the name. + /// + public string TextFormatFamily { - get { return _name; } + get { return _fontFile != null ? _fontFile.AbsoluteUri + "#" + _name : _name; } } } } diff --git a/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs index b9fbf89f5..195da19c3 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs @@ -33,16 +33,12 @@ internal sealed class GraphicsAdapter : RGraphics #region Fields and Consts /// - /// The wrapped Win2D drawing session - null for a measure-only instance (see the parameterless/ - /// device-only constructors), matching HtmlRenderer.WPF's own GraphicsAdapter's - /// null-DrawingContext measure-only mode. + /// The Win2D drawing session, null for a measure-only instance. /// private readonly CanvasDrawingSession _g; /// - /// The device used to build measurement-only / - /// instances - Win2D's text/geometry APIs need a live device even when there's no drawing session - /// (unlike WPF's , which needs none). + /// Text layouts and geometries need a device, also when only measuring. /// private readonly CanvasDevice _device; @@ -52,17 +48,13 @@ internal sealed class GraphicsAdapter : RGraphics private readonly bool _releaseGraphics; /// - /// The stack of active Win2D clip layers, one pushed per / - /// call - Win2D layers are and must be disposed in LIFO order, unlike - /// WPF's , which needs no such bookkeeping. + /// The active clip layers, one per /, disposed in reverse order. /// private readonly Stack _layerStack = new Stack(); /// - /// A single entry of : the pushed Win2D layer and, for - /// , the geometry masking it. The mask has to outlive the layer - D2D - /// reads it until the layer is popped - so the two are released together in . - /// Both are null on a measure-only instance, which has no session to push a layer on. + /// A clip layer and the geometry masking it, D2D reads the mask until the layer is popped. + /// Both are null on a measure-only instance. /// private readonly struct ClipLayer { @@ -135,9 +127,6 @@ public override void PopClip() public override void PushClip(RRect rect) { _clipStack.Push(rect); - - // Measure-only instance - the clip is still tracked for layout, but there is no session to - // push it onto (same null-session guard as SetAntiAliasSmoothingMode below). _layerStack.Push(_g != null ? new ClipLayer(_g.CreateLayer(1f, Utils.Convert(rect)), null) : new ClipLayer(null, null)); @@ -154,9 +143,6 @@ public override void PushClipExclude(RRect rect) return; } - // full/excluded are only inputs to the combine, so they are released right away; combined is - // handed to the layer and released with it. All three are unmanaged D2D resources created on - // every clip-exclude of every paint, so none of them can be left to the finalizer. CanvasGeometry combined; using (var full = CanvasGeometry.CreateRectangle(_device, Utils.Convert(current))) using (var excluded = CanvasGeometry.CreateRectangle(_device, Utils.Convert(rect))) @@ -185,31 +171,9 @@ public override void ReturnPreviousSmoothingMode(object prevMode) public override RSize MeasureString(string str, RFont font) { - var fontAdapter = (FontAdapter)font; - using (var layout = new CanvasTextLayout(_device, str, fontAdapter.TextFormat, float.MaxValue, float.MaxValue)) - { - var width = layout.LayoutBounds.Width; - if (width <= 0 && str.Length > 0) - { - // CanvasTextLayout.LayoutBounds is an ink-bounds rectangle that excludes trailing - // whitespace advance entirely - a string made up only of whitespace (notably the single - // space GetWhitespaceWidth measures, and cached forever once wrong) always comes back - // as zero-width. Recover the true advance width via an append-a-sentinel-glyph trick: - // lay out str+sentinel and sentinel alone: the difference is str's own advance width. - width = MeasureAdvanceWidthViaSentinel(str, fontAdapter); - } - return new RSize(width, font.Height); - } - } - - private double MeasureAdvanceWidthViaSentinel(string str, FontAdapter fontAdapter) - { - const string sentinel = "|"; - using (var combined = new CanvasTextLayout(_device, str + sentinel, fontAdapter.TextFormat, float.MaxValue, float.MaxValue)) - using (var sentinelOnly = new CanvasTextLayout(_device, sentinel, fontAdapter.TextFormat, float.MaxValue, float.MaxValue)) + using (var layout = new CanvasTextLayout(_device, str, ((FontAdapter)font).TextFormat, float.MaxValue, float.MaxValue)) { - var width = combined.LayoutBounds.Width - sentinelOnly.LayoutBounds.Width; - return width > 0 ? width : 0; + return new RSize(layout.LayoutBoundsIncludingTrailingWhitespace.Width, font.Height); } } @@ -225,10 +189,11 @@ public override void MeasureString(string str, RFont font, double maxWidth, out return; } - if (layout.LayoutBounds.Width <= maxWidth) + var width = layout.LayoutBoundsIncludingTrailingWhitespace.Width; + if (width <= maxWidth) { charFit = str.Length; - charFitWidth = layout.LayoutBounds.Width; + charFitWidth = width; return; } @@ -240,10 +205,8 @@ public override void MeasureString(string str, RFont font, double maxWidth, out } else { - // Beyond the text's own extent but MeasureString(str,font) said it doesn't fit maxWidth - - // shouldn't normally happen, but fail safe to "whole string fits". charFit = str.Length; - charFitWidth = layout.LayoutBounds.Width; + charFitWidth = width; } } } diff --git a/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs index 3d1e4b929..faba2421a 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs @@ -41,8 +41,7 @@ public CanvasBitmap Bitmap get { return _bitmap; } } - // Device pixels, matching WPF's own choice (PixelWidth/PixelHeight) rather than DIPs - // (CanvasBitmap.Size), so image layout matches native resolution the same way across both backends. + // Pixels rather than DIPs, so the image's DPI doesn't change its layout size public override double Width { get { return _bitmap.SizeInPixels.Width; } diff --git a/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs index 52f8ec7c4..18e68a1a8 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs @@ -21,10 +21,7 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters /// Adapter for Win2D pen (stroke) objects for core. /// /// - /// Win2D has no standalone "pen" object the way GDI+/WPF do - stroking is expressed as a - /// (brush, width, ) triple passed to each - /// CanvasDrawingSession.Draw* call. This adapter lazily materializes that triple, mirroring - /// HtmlRenderer.WPF's own PenAdapter's lazy CreatePen() pattern. + /// Win2D has no pen, brush, width and stroke style are passed to every draw call. /// internal sealed class PenAdapter : RPen { diff --git a/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs index 1f0527695..8843e16b9 100644 --- a/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs +++ b/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs @@ -22,12 +22,15 @@ using Microsoft.Graphics.Canvas.Brushes; using Microsoft.Graphics.Canvas.Text; using Microsoft.UI; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Entities; using TheArtOfDev.HtmlRenderer.Core.Network; using TheArtOfDev.HtmlRenderer.WinUI.Utilities; using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; using Windows.Storage.Pickers; using Windows.Storage.Streams; using Windows.UI.ViewManagement; @@ -41,8 +44,7 @@ internal sealed class WinUIAdapter : RAdapter { #region Fields and Consts - // One HttpClient shared for the adapter's (process) lifetime - see WpfAdapter's own field for why - // this must be declared before _instance (static field initializer ordering). + // Has to be initialized before _instance, the constructor uses it private static readonly HttpClient _sharedHttpClient = new HttpClient(); /// @@ -56,30 +58,24 @@ internal sealed class WinUIAdapter : RAdapter private static readonly List ValidColorNamesLc; /// - /// The shared Win2D device this whole adapter (and every control it services) draws with - - /// created once via before any - /// exists, confirmed usable that way by the plan's Step 1 spike. + /// The Win2D device all resources are created on and all controls draw with. /// private readonly CanvasDevice _device; /// - /// The system settings object the app-theme subscription in the constructor hangs off - kept in a - /// field for the adapter lifetime because ColorValuesChanged only lives as long as the UISettings - /// instance does, so a local would be collected and the notification would never arrive. + /// ColorValuesChanged is only raised as long as the UISettings instance is alive. /// private readonly UISettings _uiSettings; - // Backs LoadFontFaceFontInt's temp-file registration - CanvasFontSet's documented in-memory story - // is a file Uri, mirroring WPF's own Fonts.GetFontFamilies(Uri) constraint (see that class's own - // remarks) - one directory per process, cleaned up by the OS's normal temp-file housekeeping. + /// + /// Win2D can only load a font set from a file, so the @font-face fonts are written to this directory. + /// private static readonly string _fontFaceTempDirectory = CreateFontFaceTempDirectory(); - private static string CreateFontFaceTempDirectory() - { - var dir = Path.Combine(Path.GetTempPath(), "HtmlRenderer.FontFace." + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - return dir; - } + /// + /// Cached app theme, null when it needs to be re-read. + /// + private RColorScheme? _colorScheme; #endregion @@ -99,8 +95,6 @@ private WinUIAdapter() { _device = CanvasDevice.GetSharedDevice(); - // Interactive UI backend - fetching a real http(s): image or stylesheet out of the box is the - // expected behavior, same reasoning as WpfAdapter's own constructor. NetworkLoader = new HttpClientNetworkLoader(_sharedHttpClient, (Uri)null); AddFontFamilyMapping("monospace", "Courier New"); @@ -127,8 +121,7 @@ private WinUIAdapter() } catch { - // System font enumeration is a nice-to-have (helps IsFontExists recognize installed - // families upfront); its failure shouldn't prevent the adapter itself from initializing. + // Without the system fonts, families are still resolved by name when the font is created } _uiSettings = new UISettings(); @@ -158,8 +151,7 @@ public CanvasDevice Device } /// - /// Rendering onto a Windows surface, so the document should follow the user's app theme. - /// Cached and invalidated on a system preference change rather than read per query. + /// The user's app theme, cached until the system colors change. /// public override RColorScheme SystemColorScheme { @@ -173,11 +165,6 @@ public override RColorScheme SystemColorScheme } } - /// - /// Cached app theme; null when it needs to be re-read. - /// - private RColorScheme? _colorScheme; - protected override RColor GetColorInt(string colorName) { if (!ValidColorNamesLc.Contains(colorName.ToLower())) @@ -226,9 +213,17 @@ protected override RImage ConvertImageInt(object image) protected override RImage ImageFromStreamInt(Stream memoryStream) { - var randomAccessStream = ToRandomAccessStreamAsync(memoryStream).GetAwaiter().GetResult(); - var bitmap = CanvasBitmap.LoadAsync(_device, randomAccessStream).AsTask().GetAwaiter().GetResult(); - return new ImageAdapter(bitmap); + // Blocking is fine as long as no continuation needs the calling thread, which may be the UI thread + using (var memory = new MemoryStream()) + { + memoryStream.CopyTo(memory); + memory.Position = 0; + using (var randomAccessStream = memory.AsRandomAccessStream()) + { + var bitmap = CanvasBitmap.LoadAsync(_device, randomAccessStream).AsTask().GetAwaiter().GetResult(); + return new ImageAdapter(bitmap); + } + } } protected override RFont CreateFontInt(string family, double size, RFontStyle style) @@ -238,15 +233,9 @@ protected override RFont CreateFontInt(string family, double size, RFontStyle st protected override RFont CreateFontInt(RFontFamily family, double size, RFontStyle style) { - return new FontAdapter(_device, ((FontFamilyAdapter)family).Name, size, style); + return new FontAdapter(_device, ((FontFamilyAdapter)family).TextFormatFamily, size, style); } - /// - /// Loads one @font-face face's bytes as a Win2D via a temp file, - /// mirroring HtmlRenderer.WPF's own WpfAdapter.LoadFontFaceFontInt (same reasoning: - /// there is no supported in-memory-only load path). Confirmed by the plan's Step 1 spike: - /// 's constructor takes a single , not an array. - /// protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath) { var faceDirectory = Path.Combine(_fontFaceTempDirectory, Guid.NewGuid().ToString("N")); @@ -254,17 +243,17 @@ protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string file var tempFilePath = Path.Combine(faceDirectory, "face.ttf"); File.WriteAllBytes(tempFilePath, fontBytes); - var fontSet = new CanvasFontSet(new Uri(tempFilePath)); - if (fontSet.Fonts.Count == 0) - return null; - - var name = GetPreferredFamilyName(fontSet.Fonts[0].FamilyNames); - return name != null ? new FontFamilyAdapter(name, fontSet) : null; + var fontFile = new Uri(tempFilePath); + using (var fontSet = new CanvasFontSet(fontFile)) + { + var name = fontSet.Fonts.Count > 0 ? GetPreferredFamilyName(fontSet.Fonts[0].FamilyNames) : null; + return name != null ? new FontFamilyAdapter(name, fontFile) : null; + } } protected override object GetClipboardDataObjectInt(string html, string plainText) { - return ClipboardHelper.CreateDataObject(html, plainText); + return new ClipboardHelper.Content(html, plainText); } protected override void SetToClipboardInt(string text) @@ -293,50 +282,20 @@ protected override RContextMenu CreateContextMenuInt() return new ContextMenuAdapter(); } - /// - /// Show a save-file dialog and encode the image to the chosen path. - /// - /// - /// An unpackaged WinUI 3 app's needs an owner HWND handed to it via - /// 's runtime counterpart - resolved here - /// from the passed-in control's XamlRoot via , - /// since this adapter (a process-wide singleton) has no Window reference of its own to fall - /// back on. If no control is given, or its window can't be resolved, the save is silently skipped - - /// this is a real, narrow gap versus WPF's parameterless SaveFileDialog.ShowDialog(); see the - /// implementation report. - /// protected override void SaveToFileInt(RImage image, string name, string extension, RControl control = null) { - var element = (control as ControlAdapter)?.Control; - if (element?.XamlRoot == null) - return; - - var hwnd = Win32Interop.GetWindowFromWindowId(element.XamlRoot.ContentIslandEnvironment.AppWindowId); - if (hwnd == IntPtr.Zero) - return; - - var picker = new FileSavePicker - { - SuggestedFileName = name, - DefaultFileExtension = extension, - }; - picker.FileTypeChoices.Add("Image", new List { ".png", ".bmp", ".jpg", ".tif", ".gif" }); - WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); - - var file = picker.PickSaveFileAsync().AsTask().GetAwaiter().GetResult(); - if (file == null) - return; - - var format = GetBitmapFileFormat(Path.GetExtension(file.Path)); - var bitmap = ((ImageAdapter)image).Bitmap; - using (var stream = file.OpenAsync(Windows.Storage.FileAccessMode.ReadWrite).AsTask().GetAwaiter().GetResult()) - { - bitmap.SaveAsync(stream, format).AsTask().GetAwaiter().GetResult(); - } + _ = SaveToFileAsync(((ImageAdapter)image).Bitmap, name, extension, (control as ControlAdapter)?.Control); } #region Private/Protected methods + private static string CreateFontFaceTempDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), "HtmlRenderer.FontFace." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + /// /// Get solid color brush for the given color. /// @@ -346,11 +305,9 @@ private CanvasSolidColorBrush GetSolidColorBrush(RColor color) } /// - /// Pick a family name from a font's family-names table, preferring the "en-us" entry (matching - /// WPF's own XmlLanguage-keyed preference in its FontFamilyAdapter) and falling back - /// to whatever entry exists first. + /// Pick a family name from a font's family names, preferring the "en-us" one. /// - private static string GetPreferredFamilyName(IReadOnlyDictionary familyNames) + internal static string GetPreferredFamilyName(IReadOnlyDictionary familyNames) { if (familyNames == null || familyNames.Count == 0) return null; @@ -387,31 +344,45 @@ private static CanvasBitmapFileFormat GetBitmapFileFormat(string ext) } /// - /// Copy a .NET into an in-memory WinRT - - /// the shape Win2D's - /// needs, since 's seam only ever gives this a plain - /// .NET stream. + /// Show a save file dialog and encode the image to the chosen file. /// - private static async Task ToRandomAccessStreamAsync(Stream stream) + /// + /// The picker of an unpackaged app needs the window handle, which is taken from the control. Without a + /// control nothing is saved. + /// + private static async Task SaveToFileAsync(CanvasBitmap bitmap, string name, string extension, UIElement element) { - byte[] bytes; - using (var memory = new MemoryStream()) + if (element?.XamlRoot == null) + return; + + var hwnd = Win32Interop.GetWindowFromWindowId(element.XamlRoot.ContentIslandEnvironment.AppWindowId); + if (hwnd == IntPtr.Zero) + return; + + try { - await stream.CopyToAsync(memory).ConfigureAwait(false); - bytes = memory.ToArray(); - } + var picker = new FileSavePicker + { + SuggestedFileName = name, + DefaultFileExtension = extension, + }; + picker.FileTypeChoices.Add("Image", new List { ".png", ".bmp", ".jpg", ".tif", ".gif" }); + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + + var file = await picker.PickSaveFileAsync(); + if (file == null) + return; - var randomAccessStream = new InMemoryRandomAccessStream(); - using (var writer = new DataWriter(randomAccessStream.GetOutputStreamAt(0))) + using (var stream = await file.OpenAsync(FileAccessMode.ReadWrite)) + { + stream.Size = 0; + await bitmap.SaveAsync(stream, GetBitmapFileFormat(Path.GetExtension(file.Path))); + } + } + catch (Exception ex) { - writer.WriteBytes(bytes); - await writer.StoreAsync(); - await writer.FlushAsync(); - writer.DetachStream(); + (element as HtmlControl)?.ReportError(HtmlRenderErrorType.Image, "Failed to save the image", ex); } - - randomAccessStream.Seek(0); - return randomAccessStream; } #endregion diff --git a/Source/HtmlRenderer.WinUI/HtmlContainer.cs b/Source/HtmlRenderer.WinUI/HtmlContainer.cs index 29e90c6eb..9d686020f 100644 --- a/Source/HtmlRenderer.WinUI/HtmlContainer.cs +++ b/Source/HtmlRenderer.WinUI/HtmlContainer.cs @@ -350,22 +350,19 @@ public void HandleMouseUp(Control parent, PointerRoutedEventArgs e) ArgChecker.AssertArgNotNull(e, "e"); var point = e.GetCurrentPoint(parent); - var isLeft = point.Properties.PointerUpdateKind == Microsoft.UI.Input.PointerUpdateKind.LeftButtonReleased - || point.Properties.PointerUpdateKind == Microsoft.UI.Input.PointerUpdateKind.Other; - var mouseEvent = new RMouseEvent(isLeft); + var mouseEvent = new RMouseEvent(point.Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased); _htmlContainerInt.HandleMouseUp(new ControlAdapter(parent), Utils.Convert(point.Position), mouseEvent); } /// /// Handle mouse double click to select word under the mouse. /// - public void HandleMouseDoubleClick(Control parent, PointerRoutedEventArgs e) + public void HandleMouseDoubleClick(Control parent, DoubleTappedRoutedEventArgs e) { ArgChecker.AssertArgNotNull(parent, "parent"); ArgChecker.AssertArgNotNull(e, "e"); - var point = e.GetCurrentPoint(parent); - _htmlContainerInt.HandleMouseDoubleClick(new ControlAdapter(parent), Utils.Convert(point.Position)); + _htmlContainerInt.HandleMouseDoubleClick(new ControlAdapter(parent), Utils.Convert(e.GetPosition(parent))); } /// diff --git a/Source/HtmlRenderer.WinUI/HtmlControl.cs b/Source/HtmlRenderer.WinUI/HtmlControl.cs index 3f596537a..4a7499fff 100644 --- a/Source/HtmlRenderer.WinUI/HtmlControl.cs +++ b/Source/HtmlRenderer.WinUI/HtmlControl.cs @@ -11,9 +11,9 @@ // "The Art of War" using System; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Canvas.UI.Xaml; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; @@ -25,6 +25,7 @@ using TheArtOfDev.HtmlRenderer.Core; using TheArtOfDev.HtmlRenderer.Core.Entities; using TheArtOfDev.HtmlRenderer.WinUI.Utilities; +using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; namespace TheArtOfDev.HtmlRenderer.WinUI @@ -32,28 +33,38 @@ namespace TheArtOfDev.HtmlRenderer.WinUI /// /// Provides HTML rendering using the text property.
/// WinUI 3 control that will render html content in it's client rectangle.
- /// The control will handle pointer and keyboard events on it to support html text selection, - /// copy-paste and mouse clicks.
+ /// The control will handle pointer and keyboard events on it to support html text selection, copy-paste and mouse clicks.
/// /// The major differential to use HtmlPanel or HtmlLabel is size and scrollbars.
/// If the size of the control depends on the html content the HtmlLabel should be used.
/// If the size is set by some kind of layout then HtmlPanel is more suitable, also shows scrollbars if the html contents is larger than the control client rectangle.
///
+ /// + ///

LinkClicked event:

+ /// Raised when the user clicks on a link in the html.
+ /// Allows canceling the execution of the link. + ///
+ /// + ///

StylesheetLoad event:

+ /// Raised when a stylesheet is about to be loaded by file path or URI by link element.
+ /// This event allows to provide the stylesheet manually or provide new source (file or uri) to load from.
+ /// If no alternative data is provided the original source will be used.
+ ///
+ /// + ///

ImageLoad event:

+ /// Raised when an image is about to be loaded by file path or URI.
+ /// This event allows to provide the image manually, if not handled the image will be loaded from file or download from URI. + ///
+ /// + ///

RenderError event:

+ /// Raised when an error occurred during html rendering.
+ ///
+ /// + /// All events are raised on the UI thread. + /// ///
- /// - /// WinUI 3's has no OnRender(DrawingContext) - /// override point the way WPF's does, so a composed child replaces - /// inheritance-based rendering - see . WinUI 3 also has no - /// EventManager.RegisterRoutedEvent equivalent for user-defined bubbling events, so unlike - /// HtmlRenderer.WPF's HtmlControl, whose events are custom s - /// wrapped in a generic RoutedEventArgs<T>, this control's events are plain - /// (that wrapper type is dropped entirely, not ported). - /// public class HtmlControl : UserControl { - /// - /// The cursor shapes this control can be asked to show over the rendered html. - /// internal enum CursorShape { Default, @@ -63,37 +74,19 @@ internal enum CursorShape #region Fields and Consts - /// - /// How close together (in milliseconds) two pointer presses must land to be treated as a double-click. - /// - private const double DoubleClickThresholdMs = 500; - - /// - /// How close together (in DIPs) two pointer presses must land to be treated as a double-click. - /// - private const double DoubleClickThresholdPixels = 4; - /// /// Underline html container instance. /// protected readonly HtmlContainer _htmlContainer; /// - /// The composed canvas child that all drawing happens on. + /// The canvas the html is drawn on. /// protected readonly CanvasControl _canvas; /// - /// The root panel hosting (and, for , its scrollbars). + /// The content of the control, holds and any additional chrome like scrollbars. /// - /// - /// WPF's own HtmlPanel adds its scrollbars as raw visual children via - /// FrameworkElement.AddVisualChild/GetVisualChild, a WPF-only low-level visual-tree - /// API with no WinUI 3 equivalent - has no public/protected - /// extensibility point for adding a child that isn't part of an actual XAML content tree. A real - /// panel is used here instead: still a mechanical, idiomatic-WinUI substitute for - /// the same "extra chrome children alongside the canvas" need, not a behavioral rewrite. - /// protected readonly Grid _rootPanel; /// @@ -107,25 +100,36 @@ internal enum CursorShape protected Point _lastScrollOffset; /// - /// The dispatcher queue captured at construction time, used to marshal - /// events (which can fire on a thread-pool thread - see 's async - /// operations) back onto the UI thread, mirroring WPF's CheckAccess()/Dispatcher.Invoke - /// double-dispatch pattern. + /// Used to raise the container events on the UI thread. /// private readonly DispatcherQueue _dispatcherQueue; + /// + /// Canceled when the UI thread's queue shuts down, the queued events then never run. + /// + private readonly CancellationToken _dispatcherShutdown; + + /// + /// One per UI thread, the queue keeps its handler alive, so the handler can't reference a control. + /// + [ThreadStatic] + private static CancellationTokenSource _threadDispatcherShutdown; + /// /// Tracks the in-flight call, if any, so a newer call can supersede an /// older one still awaiting . /// private CancellationTokenSource _pendingLoad; - private RPoint _lastPointerLocation; - private bool _isLeftButtonPressed; - private bool _isRightButtonPressed; + /// + /// The last pointer state seen by the control, there's no global mouse state to query in WinUI. + /// + private PointerPoint _lastPointerPoint; - private DateTime _lastPointerPressedTime = DateTime.MinValue; - private Point _lastPointerPressedPosition; + /// + /// The data of the drag operation started by the selection, null if none is in progress. + /// + private ClipboardHelper.Content _dragData; #endregion @@ -145,17 +149,25 @@ internal enum CursorShape protected HtmlControl() { _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - - _rootPanel = new Grid(); - _canvas = new CanvasControl + if (_threadDispatcherShutdown == null) { - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch, - }; + var shutdown = new CancellationTokenSource(); + _dispatcherQueue.ShutdownStarting += (sender, args) => shutdown.Cancel(); + _threadDispatcherShutdown = shutdown; + } + _dispatcherShutdown = _threadDispatcherShutdown.Token; + + // A UserControl isn't a tab stop by default, without focus there are no key events for copy and scrolling + IsTabStop = true; + + _canvas = new CanvasControl(); _canvas.Draw += OnCanvasDraw; + _rootPanel = new Grid(); _rootPanel.Children.Add(_canvas); Content = _rootPanel; + DragStarting += OnDragStarting; + _htmlContainer = new HtmlContainer(); _htmlContainer.LoadComplete += OnLoadCompleteInternal; _htmlContainer.LinkClicked += OnLinkClickedInternal; @@ -184,17 +196,30 @@ protected HtmlControl() /// /// Raised when a stylesheet is about to be loaded by file path or URI by link element.
+ /// This event allows to provide the stylesheet manually or provide new source (file or uri) to load from.
+ /// If no alternative data is provided the original source will be used.
///
public event EventHandler StylesheetLoad; /// /// Raised when an image is about to be loaded by file path or URI.
+ /// This event allows to provide the image manually, if not handled the image will be loaded from file or download from URI. ///
public event EventHandler ImageLoad; /// - /// Gets or sets a value indicating if image loading only when visible should be avoided (default - false). + /// Gets or sets a value indicating if image loading only when visible should be avoided (default - false).
+ /// True - images are loaded as soon as the html is parsed.
+ /// False - images that are not visible because of scroll location are not loaded until they are scrolled to. ///
+ /// + /// Images late loading improve performance if the page contains image outside the visible scroll area, especially if there is large + /// amount of images, as all image loading is delayed (downloading and loading into memory).
+ /// Late image loading may effect the layout and actual size as image without set size will not have actual size until they are loaded + /// resulting in layout change during user scroll.
+ /// Early image loading may also effect the layout if image without known size above the current scroll location are loaded as they + /// will push the html elements down. + ///
public bool AvoidImagesLateLoading { get { return (bool)GetValue(AvoidImagesLateLoadingProperty); } @@ -202,7 +227,8 @@ public bool AvoidImagesLateLoading } /// - /// Is content selection is enabled for the rendered html (default - true). + /// Is content selection is enabled for the rendered html (default - true).
+ /// If set to 'false' the rendered html will be static only with ability to click on links. ///
public bool IsSelectionEnabled { @@ -254,43 +280,45 @@ public virtual string SelectedHtml } /// - /// The last known pointer location relative to this control, as tracked from its own pointer - /// events - WinUI 3 has no queryable global pointer-button state the way WPF's - /// does, so reads - /// this cached value instead. + /// The last known pointer location relative to this control. /// internal RPoint LastPointerLocation { - get { return _lastPointerLocation; } + get { return _lastPointerPoint != null ? Utils.Convert(_lastPointerPoint.Position) : RPoint.Empty; } } /// - /// Whether the left pointer button was down as of the last pointer event this control observed. + /// Whether the left pointer button was down as of the last pointer event of this control. /// internal bool IsLeftButtonPressed { - get { return _isLeftButtonPressed; } + get { return _lastPointerPoint != null && _lastPointerPoint.Properties.IsLeftButtonPressed; } } /// - /// Whether the right pointer button was down as of the last pointer event this control observed. + /// Whether the right pointer button was down as of the last pointer event of this control. /// internal bool IsRightButtonPressed { - get { return _isRightButtonPressed; } + get { return _lastPointerPoint != null && _lastPointerPoint.Properties.IsRightButtonPressed; } } /// /// Get html from the current DOM tree with inline style. /// + /// generated html public virtual string GetHtml() { return _htmlContainer != null ? _htmlContainer.GetHtml() : null; } /// - /// Get the rectangle of html element as calculated by html layout. + /// Get the rectangle of html element as calculated by html layout.
+ /// Element if found by id (id attribute on the html element).
+ /// Note: to get the screen rectangle you need to adjust by the hosting control.
///
+ /// the id of the element to get its rectangle + /// the rectangle of the element or null if not found public virtual Rect? GetElementRectangle(string elementId) { return _htmlContainer != null ? _htmlContainer.GetElementRectangle(elementId) : null; @@ -305,14 +333,50 @@ public void ClearSelection() } /// - /// Set the cursor shown over this control's rendered html. + /// Sets the html of this control and awaits the async load - the real entry point behind the + /// / dependency-property callbacks, + /// for callers that want to await completion or cancel an in-flight load. /// /// - /// is a protected member - it can only be set from within - /// a subclass's own code, unlike WPF's public Control.Cursor - /// setter, so (an external class) calls through this method - /// rather than setting a cursor property directly. + /// A new call before a previous one finishes supersedes it: the previous call's + /// keeps running (it has no mid-flight cancellation point of + /// its own) but its post-completion side effects - invalidate, error reporting - are discarded + /// once it resumes, so only the newest call's results ever reach the control. /// + /// the html to set + /// optional: cancel this specific call without affecting others + public async Task SetTextAsync(string html, CancellationToken cancellationToken = default) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _pendingLoad?.Cancel(); + _pendingLoad = cts; + + try + { + await _htmlContainer.SetHtml(html, _baseCssData); + } + catch (Exception ex) + { + if (cts == _pendingLoad) + { + OnRenderError(new HtmlRenderErrorEventArgs(HtmlRenderErrorType.General, "Failed to set html", ex)); + } + return; + } + + if (cts != _pendingLoad || cts.IsCancellationRequested) + { + return; + } + + InvalidateMeasure(); + _canvas.Invalidate(); + InvokeMouseMove(); + } + + /// + /// Set the cursor shown over the control, can only be set from within the control. + /// internal void SetCursorShape(CursorShape shape) { InputSystemCursorShape cursorShape; @@ -332,67 +396,43 @@ internal void SetCursorShape(CursorShape shape) } /// - /// Invalidate the composed canvas, causing a repaint. + /// Invalidate the canvas, causing a repaint. /// internal void InvalidateCanvas() { - _canvas?.Invalidate(); + _canvas.Invalidate(); } - #region Protected methods - /// - /// Perform paint of the html in the control. + /// Raise the event, for errors outside of the container. /// - private void OnCanvasDraw(CanvasControl sender, CanvasDrawEventArgs args) + internal void ReportError(HtmlRenderErrorType type, string message, Exception exception) { - var g = args.DrawingSession; - var renderSize = new Size(sender.ActualWidth, sender.ActualHeight); - - if (Background is SolidColorBrush backgroundBrush && backgroundBrush.Opacity > 0) - g.FillRectangle(new Rect(0, 0, renderSize.Width, renderSize.Height), backgroundBrush.Color); - - var borderThickness = BorderThickness; - if (borderThickness != new Thickness(0) && BorderBrush is SolidColorBrush borderColorBrush) - { - var color = borderColorBrush.Color; - if (borderThickness.Top > 0) - g.FillRectangle(new Rect(0, 0, renderSize.Width, borderThickness.Top), color); - if (borderThickness.Bottom > 0) - g.FillRectangle(new Rect(0, renderSize.Height - borderThickness.Bottom, renderSize.Width, borderThickness.Bottom), color); - if (borderThickness.Left > 0) - g.FillRectangle(new Rect(0, 0, borderThickness.Left, renderSize.Height), color); - if (borderThickness.Right > 0) - g.FillRectangle(new Rect(renderSize.Width - borderThickness.Right, 0, borderThickness.Right, renderSize.Height), color); - } + OnRenderError(new HtmlRenderErrorEventArgs(type, message, exception)); + } - var htmlWidth = HtmlWidth(renderSize); - var htmlHeight = HtmlHeight(renderSize); - if (_htmlContainer != null && htmlWidth > 0 && htmlHeight > 0) - { - var clip = new Rect(Padding.Left + borderThickness.Left, Padding.Top + borderThickness.Top, htmlWidth, htmlHeight); - using (g.CreateLayer(1f, clip)) - { - _htmlContainer.Location = new Point(Padding.Left + borderThickness.Left, Padding.Top + borderThickness.Top); - _htmlContainer.PerformPaint(g, clip); - } + /// + /// Start dragging the given data with the pointer that is currently pressed on the control. + /// + internal void StartDragDrop(ClipboardHelper.Content data) + { + if (_dragData != null || _lastPointerPoint == null || !_lastPointerPoint.IsInContact) + return; - if (!_lastScrollOffset.Equals(_htmlContainer.ScrollOffset)) - { - _lastScrollOffset = _htmlContainer.ScrollOffset; - InvokeMouseMove(); - } - } + _dragData = data; + _ = DragDropAsync(_lastPointerPoint); } + #region Protected methods + /// /// Handle pointer move to handle hover cursor and text selection. /// protected override void OnPointerMoved(PointerRoutedEventArgs e) { base.OnPointerMoved(e); - UpdatePointerState(e); - _htmlContainer?.HandleMouseMove(this, Utils.Convert(_lastPointerLocation)); + _lastPointerPoint = e.GetCurrentPoint(this); + _htmlContainer?.HandleMouseMove(this, _lastPointerPoint.Position); } /// @@ -405,30 +445,13 @@ protected override void OnPointerExited(PointerRoutedEventArgs e) } /// - /// Handle pointer press to handle selection, and hand-rolled double-click detection. + /// Handle pointer press to handle selection. /// protected override void OnPointerPressed(PointerRoutedEventArgs e) { base.OnPointerPressed(e); - UpdatePointerState(e); - - var point = e.GetCurrentPoint(this).Position; - var now = DateTime.UtcNow; - var isDoubleClick = (now - _lastPointerPressedTime).TotalMilliseconds <= DoubleClickThresholdMs - && Distance(point, _lastPointerPressedPosition) <= DoubleClickThresholdPixels; - - if (isDoubleClick) - { - // Reset so a third rapid click starts a fresh detection window rather than chaining. - _lastPointerPressedTime = DateTime.MinValue; - _htmlContainer?.HandleMouseDoubleClick(this, e); - } - else - { - _lastPointerPressedTime = now; - _lastPointerPressedPosition = point; - _htmlContainer?.HandleMouseDown(this, e); - } + _lastPointerPoint = e.GetCurrentPoint(this); + _htmlContainer?.HandleMouseDown(this, e); } /// @@ -437,10 +460,19 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) protected override void OnPointerReleased(PointerRoutedEventArgs e) { base.OnPointerReleased(e); - UpdatePointerState(e); + _lastPointerPoint = e.GetCurrentPoint(this); _htmlContainer?.HandleMouseUp(this, e); } + /// + /// Handle double tap to select word under the pointer. + /// + protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) + { + base.OnDoubleTapped(e); + _htmlContainer?.HandleMouseDoubleClick(this, e); + } + /// /// Handle key down event for selection, copy and scrollbars handling. /// @@ -497,7 +529,7 @@ protected virtual void OnRefresh(HtmlRefreshEventArgs e) { if (e.Layout) InvalidateMeasure(); - _canvas?.Invalidate(); + _canvas.Invalidate(); } /// @@ -521,59 +553,83 @@ protected virtual double HtmlHeight(Size size) /// protected virtual void InvokeMouseMove() { - _htmlContainer.HandleMouseMove(this, Utils.Convert(_lastPointerLocation)); + _htmlContainer.HandleMouseMove(this, Utils.Convert(LastPointerLocation)); } + #endregion + + #region Private methods + /// - /// Sets the html of this control and awaits the async load - the real entry point behind the - /// / dependency-property callbacks. + /// Perform paint of the html in the control. /// - public async Task SetTextAsync(string html, CancellationToken cancellationToken = default) + private void OnCanvasDraw(CanvasControl sender, CanvasDrawEventArgs args) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _pendingLoad?.Cancel(); - _pendingLoad = cts; + var g = args.DrawingSession; + var renderSize = new Size(sender.ActualWidth, sender.ActualHeight); - try + if (Background is SolidColorBrush backgroundBrush && backgroundBrush.Opacity > 0) + g.FillRectangle(new Rect(0, 0, renderSize.Width, renderSize.Height), backgroundBrush.Color); + + var borderThickness = BorderThickness; + if (borderThickness != new Thickness(0) && BorderBrush is SolidColorBrush borderBrush) { - await _htmlContainer.SetHtml(html, _baseCssData); + var color = borderBrush.Color; + if (borderThickness.Top > 0) + g.FillRectangle(new Rect(0, 0, renderSize.Width, borderThickness.Top), color); + if (borderThickness.Bottom > 0) + g.FillRectangle(new Rect(0, renderSize.Height - borderThickness.Bottom, renderSize.Width, borderThickness.Bottom), color); + if (borderThickness.Left > 0) + g.FillRectangle(new Rect(0, 0, borderThickness.Left, renderSize.Height), color); + if (borderThickness.Right > 0) + g.FillRectangle(new Rect(renderSize.Width - borderThickness.Right, 0, borderThickness.Right, renderSize.Height), color); } - catch (Exception ex) + + var htmlWidth = HtmlWidth(renderSize); + var htmlHeight = HtmlHeight(renderSize); + if (_htmlContainer != null && htmlWidth > 0 && htmlHeight > 0) { - if (cts == _pendingLoad) + var clip = new Rect(Padding.Left + borderThickness.Left, Padding.Top + borderThickness.Top, htmlWidth, htmlHeight); + using (g.CreateLayer(1f, clip)) { - OnRenderError(new HtmlRenderErrorEventArgs(HtmlRenderErrorType.General, "Failed to set html", ex)); + _htmlContainer.Location = new Point(clip.X, clip.Y); + _htmlContainer.PerformPaint(g, clip); } - return; - } - if (cts != _pendingLoad || cts.IsCancellationRequested) - { - return; + if (!_lastScrollOffset.Equals(_htmlContainer.ScrollOffset)) + { + _lastScrollOffset = _htmlContainer.ScrollOffset; + InvokeMouseMove(); + } } - - InvalidateMeasure(); - _canvas?.Invalidate(); - InvokeMouseMove(); } - #endregion - - #region Private methods - - private void UpdatePointerState(PointerRoutedEventArgs e) + private async Task DragDropAsync(PointerPoint pointerPoint) { - var point = e.GetCurrentPoint(this); - _isLeftButtonPressed = point.Properties.IsLeftButtonPressed; - _isRightButtonPressed = point.Properties.IsRightButtonPressed; - _lastPointerLocation = Utils.Convert(point.Position); + try + { + await StartDragAsync(pointerPoint); + } + catch (Exception ex) + { + OnRenderError(new HtmlRenderErrorEventArgs(HtmlRenderErrorType.KeyboardMouse, "Failed drag and drop", ex)); + } + finally + { + _dragData = null; + } } - private static double Distance(Point a, Point b) + private void OnDragStarting(UIElement sender, DragStartingEventArgs args) { - var dx = a.X - b.X; - var dy = a.Y - b.Y; - return Math.Sqrt(dx * dx + dy * dy); + if (_dragData == null) + { + args.Cancel = true; + return; + } + + ClipboardHelper.Fill(args.Data, _dragData); + args.AllowedOperations = DataPackageOperation.Copy; } /// @@ -611,58 +667,83 @@ private static void OnDependencyProperty_valueChanged(DependencyObject dependenc } } - #region Private event handlers (marshal onto the UI thread) + /// + /// Run the given action on the UI thread and wait for it, the load events need the handler's changes to + /// the event args before the container continues. + /// + private void InvokeOnUIThread(Action action) + { + if (_dispatcherQueue.HasThreadAccess) + { + action(); + return; + } + + // Not disposed, a queued action may still set it after the queue shut down + var done = new ManualResetEventSlim(); + ExceptionDispatchInfo error = null; + var queued = _dispatcherQueue.TryEnqueue(() => + { + try + { + action(); + } + catch (Exception ex) + { + error = ExceptionDispatchInfo.Capture(ex); + } + finally + { + done.Set(); + } + }); + + // Once the queue shuts down, there's no one left to raise the event to + if (!queued) + return; + + try + { + done.Wait(_dispatcherShutdown); + } + catch (OperationCanceledException) + { + return; + } + + error?.Throw(); + } private void OnLoadCompleteInternal(object sender, EventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnLoadComplete(e); - else - _dispatcherQueue.TryEnqueue(() => OnLoadComplete(e)); + InvokeOnUIThread(() => OnLoadComplete(e)); } private void OnLinkClickedInternal(object sender, HtmlLinkClickedEventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnLinkClicked(e); - else - _dispatcherQueue.TryEnqueue(() => OnLinkClicked(e)); + InvokeOnUIThread(() => OnLinkClicked(e)); } private void OnRenderErrorInternal(object sender, HtmlRenderErrorEventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnRenderError(e); - else - _dispatcherQueue.TryEnqueue(() => OnRenderError(e)); + InvokeOnUIThread(() => OnRenderError(e)); } private void OnStylesheetLoadInternal(object sender, HtmlStylesheetLoadEventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnStylesheetLoad(e); - else - _dispatcherQueue.TryEnqueue(() => OnStylesheetLoad(e)); + InvokeOnUIThread(() => OnStylesheetLoad(e)); } private void OnImageLoadInternal(object sender, HtmlImageLoadEventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnImageLoad(e); - else - _dispatcherQueue.TryEnqueue(() => OnImageLoad(e)); + InvokeOnUIThread(() => OnImageLoad(e)); } private void OnRefreshInternal(object sender, HtmlRefreshEventArgs e) { - if (_dispatcherQueue.HasThreadAccess) - OnRefresh(e); - else - _dispatcherQueue.TryEnqueue(() => OnRefresh(e)); + InvokeOnUIThread(() => OnRefresh(e)); } #endregion - - #endregion } } diff --git a/Source/HtmlRenderer.WinUI/HtmlPanel.cs b/Source/HtmlRenderer.WinUI/HtmlPanel.cs index 6e665ca29..06c75116c 100644 --- a/Source/HtmlRenderer.WinUI/HtmlPanel.cs +++ b/Source/HtmlRenderer.WinUI/HtmlPanel.cs @@ -30,14 +30,7 @@ namespace TheArtOfDev.HtmlRenderer.WinUI /// The control will handle pointer and keyboard events on it to support html text selection, copy-paste and mouse clicks.
///
/// - /// See for more info. Unlike WPF's own HtmlPanel, which docks its - /// scrollbars via raw visual children (FrameworkElement.AddVisualChild/manual - /// ArrangeOverride pixel math) - a WPF-only API with no WinUI 3 equivalent - this control docks - /// its two s as ordinary children of , - /// positioned via / - /// instead of a manual per-frame Rect computation. The HTML-side layout math (max width, - /// scrollbar min/max/viewport, visibility toggling to avoid a wrap/re-layout thrash) is still a - /// mechanical, close port of WPF's own. + /// See for more info. /// public class HtmlPanel : HtmlControl { @@ -65,10 +58,7 @@ public HtmlPanel() _verticalScrollBar = new ScrollBar { Orientation = Orientation.Vertical, - // A standalone ScrollBar (outside a ScrollViewer) defaults IndicatorMode to None, which - // renders nothing at all regardless of Visibility - confirmed via a documented WinUI 3 - // issue (https://learn.microsoft.com/en-us/answers/questions/792524), whose fix is - // exactly this: set IndicatorMode explicitly. + // A standalone ScrollBar shows nothing with the default IndicatorMode of None IndicatorMode = ScrollingIndicatorMode.MouseIndicator, Width = 18, Visibility = Visibility.Collapsed, @@ -92,10 +82,6 @@ public HtmlPanel() _htmlContainer.ScrollChange += OnScrollChange; - // WPF overrides TextProperty's own PropertyMetadata for the HtmlPanel type - // (DependencyProperty.OverrideMetadata) to reset scroll position on text change - WinUI 3's - // DependencyProperty has no per-subclass metadata override, so this uses the instance-level - // RegisterPropertyChangedCallback substitute instead. RegisterPropertyChangedCallback(TextProperty, (d, dp) => { _horizontalScrollBar.Value = _verticalScrollBar.Value = 0; @@ -191,12 +177,15 @@ protected Size PerformHtmlLayout(Size constraint) } /// - /// Handle pointer release to set focus on the control. + /// Handle pointer press to set focus on the control. /// - protected override void OnPointerReleased(PointerRoutedEventArgs e) + protected override void OnPointerPressed(PointerRoutedEventArgs e) { - base.OnPointerReleased(e); Focus(FocusState.Pointer); + base.OnPointerPressed(e); + + // Otherwise the root ScrollViewer takes the focus + e.Handled = true; } /// diff --git a/Source/HtmlRenderer.WinUI/HtmlRender.cs b/Source/HtmlRenderer.WinUI/HtmlRender.cs index 1bc086bcd..671aca0a2 100644 --- a/Source/HtmlRenderer.WinUI/HtmlRender.cs +++ b/Source/HtmlRenderer.WinUI/HtmlRender.cs @@ -36,20 +36,23 @@ public static class HtmlRender /// Adds a font family to be used in html rendering.
/// The added font will be used by all rendering function including and all WinUI 3 controls. ///
- /// - /// Unlike WPF's AddFontFamily(FontFamily), WinUI/DirectWrite has no lightweight standalone - /// "font family object" outside of a loaded - pass the font set that - /// registered (see 's - /// own use of it) when adding a custom-loaded family, or leave it null for an already - /// system-installed family name. - /// - /// The font family name to add. - /// optional: the font set that backs the family, kept alive for as long as the family is referenced - public static void AddFontFamily(string familyName, CanvasFontSet fontSet = null) + /// + /// HtmlRender.AddFontFamily(new Uri(@"C:\fonts\CustomFont.ttf")); + /// + /// The font file with the font families to add, a file or ms-appx:/// URI. + public static void AddFontFamily(Uri fontFile) { - ArgChecker.AssertArgNotNullOrEmpty(familyName, "familyName"); + ArgChecker.AssertArgNotNull(fontFile, "fontFile"); - WinUIAdapter.Instance.AddFontFamily(new FontFamilyAdapter(familyName, fontSet)); + using (var fontSet = new CanvasFontSet(fontFile)) + { + foreach (var font in fontSet.Fonts) + { + var name = WinUIAdapter.GetPreferredFamilyName(font.FamilyNames); + if (name != null) + WinUIAdapter.Instance.AddFontFamily(new FontFamilyAdapter(name, fontFile)); + } + } } /// @@ -153,9 +156,7 @@ public static Task RenderAsync(CanvasDrawingSession g, string html, Point /// The HTML will be layout by the given size but will be clipped if cannot fit.
///
/// - /// Unlike WPF's RenderToImageAsync, which needs a UI-thread-affine RenderTargetBitmap, - /// Win2D's is a plain GPU/CPU resource with no UI-thread - /// affinity - this needs no dispatcher/UI thread at all. + /// Doesn't need the UI thread. /// /// HTML source to render /// The size of the image to render into, layout html by width and clipped by height diff --git a/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs b/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs index bcd68e435..bd8c24318 100644 --- a/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs +++ b/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs @@ -15,37 +15,37 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Utilities { /// - /// Helper to set HTML fragment and plain text data to the clipboard. + /// Helper to set HTML fragment and plain text data to the clipboard or a drag operation. /// - /// - /// HtmlRenderer.WPF's own -based helper hand-builds the - /// full CF_HTML header (Version:/StartHTML:/EndHTML:/StartFragment:/ - /// EndFragment: with byte offsets computed via manual UTF-8 byte counting) because WPF's own - /// clipboard API has no higher-level "give me an HTML fragment, I'll build the header" helper. - /// - /// The WinRT clipboard API does: takes a plain HTML - /// fragment string and returns it already wrapped in a correct CF_HTML header (offsets included), and - /// is documented to accept exactly that pre-built CF_HTML - /// string. That makes WPF's entire byte-offset-arithmetic GetHtmlDataString implementation - /// (StartFragment/EndFragment comment injection, UTF-8 byte counting, header back-patching) - /// unnecessary here - confirmed during implementation per the plan's Step 6 note anticipating exactly - /// this outcome. - /// - /// internal static class ClipboardHelper { /// - /// Create a with given html and plain-text ready to be used for clipboard - /// or drag-drop operation. + /// HTML fragment and plain text to put on the clipboard or drag. /// - /// a html fragment - /// the plain text - public static DataPackage CreateDataObject(string html, string plainText) + /// + /// A drag operation brings its own , so the content is kept separate from it. + /// + public sealed class Content { - var dataPackage = new DataPackage(); - dataPackage.SetHtmlFormat(HtmlFormatHelper.CreateHtmlFormat(html ?? string.Empty)); - dataPackage.SetText(plainText ?? string.Empty); - return dataPackage; + public Content(string html, string plainText) + { + Html = html ?? string.Empty; + PlainText = plainText ?? string.Empty; + } + + public string Html { get; } + + public string PlainText { get; } + } + + /// + /// Set the given content as HTML and plain text on the data package. + /// + public static void Fill(DataPackage dataPackage, Content content) + { + // CreateHtmlFormat builds the CF_HTML header with its byte offsets + dataPackage.SetHtmlFormat(HtmlFormatHelper.CreateHtmlFormat(content.Html)); + dataPackage.SetText(content.PlainText); } /// @@ -55,7 +55,9 @@ public static DataPackage CreateDataObject(string html, string plainText) /// the plain text public static void CopyToClipboard(string html, string plainText) { - Clipboard.SetContent(CreateDataObject(html, plainText)); + var dataPackage = new DataPackage(); + Fill(dataPackage, new Content(html, plainText)); + Clipboard.SetContent(dataPackage); } /// diff --git a/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs b/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs index b8f0041e7..489efb628 100644 --- a/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs +++ b/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs @@ -19,14 +19,6 @@ namespace TheArtOfDev.HtmlRenderer.WinUI.Utilities /// Reads the Windows app theme, which is what prefers-color-scheme reports for on-screen /// rendering. /// - /// - /// The registry read is ported byte-for-byte from HtmlRenderer.WPF's own - /// Utilities.WindowsTheme - it has no WPF-specific dependency, it's the same - /// HKCU\...\Personalize\AppsUseLightTheme value WPF reads. Only the change-notification - /// source differs (see 's constructor, which subscribes to - /// Windows.UI.ViewManagement.UISettings.ColorValuesChanged instead of WPF's - /// SystemEvents.UserPreferenceChanged - the modern WinRT-native signal for a WinUI 3 app). - /// internal static class WindowsTheme { private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; From 49fe88c6b0d7f800cecc3d2cbab1e89a837a1359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Fri, 13 Nov 2026 16:09:45 +0100 Subject: [PATCH 2/3] Bring the WinUI demo on par with the WPF demo --- Source/Demo/WinUI/App.xaml | 8 +- Source/Demo/WinUI/App.xaml.cs | 24 +- Source/Demo/WinUI/GenerateImageWindow.xaml | 19 + Source/Demo/WinUI/GenerateImageWindow.xaml.cs | 130 +++++++ .../Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj | 12 +- Source/Demo/WinUI/HtmlRenderingHelper.cs | 143 +++++++ Source/Demo/WinUI/MainControl.xaml | 56 +++ Source/Demo/WinUI/MainControl.xaml.cs | 357 ++++++++++++++++++ Source/Demo/WinUI/MainWindow.xaml | 36 +- Source/Demo/WinUI/MainWindow.xaml.cs | 198 ++++------ Source/Demo/WinUI/SampleWindow.xaml | 45 +++ Source/Demo/WinUI/SampleWindow.xaml.cs | 87 +++++ Source/Demo/WinUI/WindowHelper.cs | 76 ++++ 13 files changed, 1032 insertions(+), 159 deletions(-) create mode 100644 Source/Demo/WinUI/GenerateImageWindow.xaml create mode 100644 Source/Demo/WinUI/GenerateImageWindow.xaml.cs create mode 100644 Source/Demo/WinUI/HtmlRenderingHelper.cs create mode 100644 Source/Demo/WinUI/MainControl.xaml create mode 100644 Source/Demo/WinUI/MainControl.xaml.cs create mode 100644 Source/Demo/WinUI/SampleWindow.xaml create mode 100644 Source/Demo/WinUI/SampleWindow.xaml.cs create mode 100644 Source/Demo/WinUI/WindowHelper.cs diff --git a/Source/Demo/WinUI/App.xaml b/Source/Demo/WinUI/App.xaml index d8ca34c0e..6e653679b 100644 --- a/Source/Demo/WinUI/App.xaml +++ b/Source/Demo/WinUI/App.xaml @@ -1,14 +1,12 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + RequestedTheme="Light"> + - diff --git a/Source/Demo/WinUI/App.xaml.cs b/Source/Demo/WinUI/App.xaml.cs index cad246ffb..ec0316443 100644 --- a/Source/Demo/WinUI/App.xaml.cs +++ b/Source/Demo/WinUI/App.xaml.cs @@ -10,15 +10,14 @@ // - Sun Tsu, // "The Art of War" +using System; +using System.IO; using Microsoft.UI.Xaml; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using TheArtOfDev.HtmlRenderer.WinUI; namespace TheArtOfDev.HtmlRenderer.Demo.WinUI { - /// - /// Minimal application entry point - hosts one with a single - /// rendering a hardcoded sample document, just - /// enough to prove the WinUI 3 control renders real HTML end to end. - /// public partial class App : Application { private Window _window; @@ -30,8 +29,23 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { + SamplesLoader.Init("WinUI", typeof(HtmlRender).Assembly.GetName().Version.ToString()); + + LoadCustomFonts(); + _window = new MainWindow(); _window.Activate(); } + + /// + /// Load custom fonts to be used by renderer HTMLs + /// + private static void LoadCustomFonts() + { + // Win2D loads fonts only from files + var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ttf"); + File.WriteAllBytes(file, Common.Resources.CustomFont); + HtmlRender.AddFontFamily(new Uri(file)); + } } } diff --git a/Source/Demo/WinUI/GenerateImageWindow.xaml b/Source/Demo/WinUI/GenerateImageWindow.xaml new file mode 100644 index 000000000..4b4420f1f --- /dev/null +++ b/Source/Demo/WinUI/GenerateImageWindow.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/GenerateImageWindow.xaml.cs b/Source/Demo/WinUI/GenerateImageWindow.xaml.cs new file mode 100644 index 000000000..ea25efc8b --- /dev/null +++ b/Source/Demo/WinUI/GenerateImageWindow.xaml.cs @@ -0,0 +1,130 @@ +// "Therefore those skilled at the unorthodox +// are infinite as heaven and earth, +// inexhaustible as the great rivers. +// When they come to an end, +// they begin again, +// like the days and months; +// they die and are reborn, +// like the four seasons." +// +// - Sun Tsu, +// "The Art of War" + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media.Imaging; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using TheArtOfDev.HtmlRenderer.WinUI; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + public sealed partial class GenerateImageWindow : Window + { + private readonly string _html; + private CanvasRenderTarget _generatedImage; + + public GenerateImageWindow(string html) + { + _html = html; + + InitializeComponent(); + + WindowHelper.Setup(this, "Generate Image", 0.56, 0.64); + + _imageBoxBorder.SizeChanged += OnFirstSizeChanged; + Closed += (sender, args) => _generatedImage?.Dispose(); + } + + private async void OnFirstSizeChanged(object sender, SizeChangedEventArgs e) + { + _imageBoxBorder.SizeChanged -= OnFirstSizeChanged; + await GenerateImage(); + } + + private async void OnGenerateImage_Click(object sender, RoutedEventArgs e) + { + await GenerateImage(); + } + + private async void OnSaveToFile_Click(object sender, RoutedEventArgs e) + { + if (_generatedImage == null) + return; + + try + { + var picker = WindowHelper.CreateSavePicker(this); + picker.SuggestedFileName = "image"; + picker.DefaultFileExtension = ".png"; + picker.FileTypeChoices.Add("Images", new List { ".png", ".bmp", ".jpg", ".tif", ".gif" }); + + var file = await picker.PickSaveFileAsync(); + if (file == null) + return; + + using var stream = await file.OpenAsync(FileAccessMode.ReadWrite); + stream.Size = 0; + await _generatedImage.SaveAsync(stream, GetBitmapFileFormat(Path.GetExtension(file.Path))); + } + catch (Exception ex) + { + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to save the image"); + } + } + + private async Task GenerateImage() + { + var size = new Size(_imageBoxBorder.ActualWidth, _imageBoxBorder.ActualHeight); + if (size.Width > 0 && size.Height > 0) + { + try + { + _generatedImage?.Dispose(); + _generatedImage = await HtmlRender.RenderToImageAsync(_html, size, null, DemoUtils.OnStylesheetLoad, HtmlRenderingHelper.OnImageLoad); + + using var stream = new InMemoryRandomAccessStream(); + await _generatedImage.SaveAsync(stream, CanvasBitmapFileFormat.Png); + stream.Seek(0); + + var bitmap = new BitmapImage(); + await bitmap.SetSourceAsync(stream); + _imageBox.Source = bitmap; + } + catch (Exception ex) + { + _generatedImage = null; + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to generate the image"); + } + } + } + + /// + /// Get the Win2D format to encode the image with for the given file extension, default is PNG. + /// + private static CanvasBitmapFileFormat GetBitmapFileFormat(string ext) + { + switch (ext.ToLower()) + { + case ".jpg": + case ".jpeg": + return CanvasBitmapFileFormat.Jpeg; + case ".bmp": + return CanvasBitmapFileFormat.Bmp; + case ".tif": + case ".tiff": + return CanvasBitmapFileFormat.Tiff; + case ".gif": + return CanvasBitmapFileFormat.Gif; + default: + return CanvasBitmapFileFormat.Png; + } + } + } +} diff --git a/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj index 14b61a3c3..725b35a20 100644 --- a/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj +++ b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj @@ -6,12 +6,8 @@ TheArtOfDev.HtmlRenderer.Demo.WinUI HtmlRendererWinUIDemo true - true true - + None true true @@ -23,8 +19,7 @@ html.ico - + @@ -34,12 +29,13 @@ + - + diff --git a/Source/Demo/WinUI/HtmlRenderingHelper.cs b/Source/Demo/WinUI/HtmlRenderingHelper.cs new file mode 100644 index 000000000..7f356fbca --- /dev/null +++ b/Source/Demo/WinUI/HtmlRenderingHelper.cs @@ -0,0 +1,143 @@ +// "Therefore those skilled at the unorthodox +// are infinite as heaven and earth, +// inexhaustible as the great rivers. +// When they come to an end, +// they begin again, +// like the days and months; +// they die and are reborn, +// like the four seasons." +// +// - Sun Tsu, +// "The Art of War" + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Microsoft.Graphics.Canvas; +using PdfSharp.Drawing; +using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using Windows.Storage.Streams; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + internal static class HtmlRenderingHelper + { + private const string ByEventImageUrl = "https://images.unsplash.com/photo-1608848461950-0fe51dfc41cb?w=500&q=80"; + + /// + /// Cache for resource images, the images are loaded from the render threads as well + /// + private static readonly Dictionary _imageCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Get image by resource key. + /// + public static CanvasBitmap TryLoadResourceImage(string src) + { + lock (_imageCache) + { + if (!_imageCache.TryGetValue(src, out var image)) + { + using var stream = DemoUtils.GetImageStream(src); + if (stream != null) + { + image = CanvasBitmap.LoadAsync(CanvasDevice.GetSharedDevice(), stream.AsRandomAccessStream()).AsTask().GetAwaiter().GetResult(); + _imageCache[src] = image; + } + } + return image; + } + } + + /// + /// Get the resource image of the given key as data URI, to show the html in a browser. + /// + public static string TryGetResourceImageDataUri(string src) + { + using var stream = DemoUtils.GetImageStream(src); + if (stream == null) + return null; + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + var bytes = memory.ToArray(); + var mimeType = bytes.Length > 3 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F' ? "image/gif" : "image/png"; + return "data:" + mimeType + ";base64," + Convert.ToBase64String(bytes); + } + + /// + /// On image load in renderer set the image by event async. + /// + public static void OnImageLoad(object sender, HtmlImageLoadEventArgs args) + { + ImageLoad(args, false); + } + + /// + /// On image load in renderer set the image by event async. + /// + public static void OnImageLoadPdfSharp(object sender, HtmlImageLoadEventArgs args) + { + ImageLoad(args, true); + } + + /// + /// On image load in renderer set the image by event async. + /// + private static void ImageLoad(HtmlImageLoadEventArgs e, bool pdfSharp) + { + var img = TryLoadResourceImage(e.Src); + var imgObj = pdfSharp ? (object)(img != null ? CreatePdfSharpImage(img) : null) : img; + + if (!e.Handled && e.Attributes != null) + { + if (e.Attributes.ContainsKey("byevent")) + { + if (int.TryParse(e.Attributes["byevent"], out var delay)) + { + e.Handled = true; + ThreadPool.QueueUserWorkItem(state => + { + Thread.Sleep(delay); + e.Callback(ByEventImageUrl); + }); + } + else + { + e.Callback(ByEventImageUrl); + } + return; + } + + if (e.Attributes.ContainsKey("byrect")) + { + var split = e.Attributes["byrect"].Split(','); + var defaultResourceImage = TryLoadResourceImage("htmlicon"); + var defaultImg = pdfSharp && defaultResourceImage != null ? (object)CreatePdfSharpImage(defaultResourceImage) : defaultResourceImage; + e.Callback(imgObj ?? defaultImg, int.Parse(split[0]), int.Parse(split[1]), int.Parse(split[2]), int.Parse(split[3])); + return; + } + } + + if (img != null) + e.Callback(imgObj); + } + + /// + /// Convert the image to PNG before creating the , PdfSharp doesn't support GIF. + /// + private static XImage CreatePdfSharpImage(CanvasBitmap image) + { + using var stream = new InMemoryRandomAccessStream(); + image.SaveAsync(stream, CanvasBitmapFileFormat.Png).AsTask().GetAwaiter().GetResult(); + + stream.Seek(0); + var memory = new MemoryStream(); + stream.AsStreamForRead().CopyTo(memory); + memory.Position = 0; + return XImage.FromStream(memory); + } + } +} diff --git a/Source/Demo/WinUI/MainControl.xaml b/Source/Demo/WinUI/MainControl.xaml new file mode 100644 index 000000000..fbcef0e35 --- /dev/null +++ b/Source/Demo/WinUI/MainControl.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/MainControl.xaml.cs b/Source/Demo/WinUI/MainControl.xaml.cs new file mode 100644 index 000000000..5e821f9cb --- /dev/null +++ b/Source/Demo/WinUI/MainControl.xaml.cs @@ -0,0 +1,357 @@ +// "Therefore those skilled at the unorthodox +// are infinite as heaven and earth, +// inexhaustible as the great rivers. +// When they come to an end, +// they begin again, +// like the days and months; +// they die and are reborn, +// like the four seasons." +// +// - Sun Tsu, +// "The Art of War" + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Demo.Common; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + public sealed partial class MainControl : UserControl + { + #region Fields and Consts + + /// + /// the name of the tree node root for all performance samples + /// + private const string PerformanceSamplesTreeNodeName = "Performance Samples"; + + /// + /// The sample of each tree node, the node itself only shows the name + /// + private readonly Dictionary _nodeToSample = new Dictionary(); + + /// + /// timer to update the rendered html when html in editor changes with delay + /// + private readonly DispatcherQueueTimer _updateHtmlTimer; + + /// + /// The html last set to the editor by the code, to tell it apart from the user's changes + /// + private string _setEditorText; + + #endregion + + public MainControl() + { + InitializeComponent(); + + _htmlPanel.RenderError += OnRenderError; + _htmlPanel.LinkClicked += OnLinkClicked; + _htmlPanel.StylesheetLoad += DemoUtils.OnStylesheetLoad; + _htmlPanel.ImageLoad += HtmlRenderingHelper.OnImageLoad; + _htmlPanel.LoadComplete += (sender, args) => _htmlPanel.ScrollToElement("C4"); + + _htmlTooltipLabel.AvoidImagesLateLoading = true; + _htmlTooltipLabel.StylesheetLoad += DemoUtils.OnStylesheetLoad; + _htmlTooltipLabel.ImageLoad += HtmlRenderingHelper.OnImageLoad; + _htmlTooltipLabel.Text = "
" + Common.Resources.Tooltip + "
"; + + _updateHtmlTimer = DispatcherQueue.CreateTimer(); + _updateHtmlTimer.Interval = TimeSpan.FromSeconds(1); + _updateHtmlTimer.IsRepeating = false; + _updateHtmlTimer.Tick += OnUpdateHtmlTimerTick; + + LoadSamples(); + } + + /// + /// used ignore html editor updates when updating separately + /// + public bool UpdateLock { get; set; } + + /// + /// In browser view if to show original html or the html generated from the html control + /// + public bool UseGeneratedHtml { get; set; } + + /// + /// Show\Hide the web browser viewer. + /// + public void ShowWebBrowserView(bool show) + { + _webView.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + _viewsGrid.ColumnDefinitions[1].Width = show ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + + if (show) + UpdateWebBrowserHtml(); + } + + /// + /// Update the html shown in the web browser + /// + public async void UpdateWebBrowserHtml() + { + if (_webView.Visibility == Visibility.Visible) + { + try + { + await _webView.EnsureCoreWebView2Async(); + _webView.NavigateToString(UseGeneratedHtml ? _htmlPanel.GetHtml() : GetFixedHtml()); + } + catch (Exception ex) + { + await WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to show the HTML in WebView2"); + } + } + } + + public string GetHtml() + { + return UseGeneratedHtml ? _htmlPanel.GetHtml() : GetHtmlEditorText(); + } + + /// + /// Set the html and lay it out right away, used to measure the performance. + /// + public void SetHtml(string html) + { + _htmlPanel.Text = html; + _htmlPanel.UpdateLayout(); + } + + #region Private methods + + /// + /// Loads the tree of document samples + /// + private void LoadSamples() + { + var showcaseRoot = AddRootNode("HTML Renderer", SamplesLoader.ShowcaseSamples); + AddRootNode("Test Samples", SamplesLoader.TestSamples); + if (SamplesLoader.PerformanceSamples.Count > 0) + AddRootNode(PerformanceSamplesTreeNodeName, SamplesLoader.PerformanceSamples); + + showcaseRoot.IsExpanded = true; + if (showcaseRoot.Children.Count > 0) + { + _samplesTreeView.SelectedNode = showcaseRoot.Children[0]; + ShowSample(showcaseRoot.Children[0]); + } + } + + private TreeViewNode AddRootNode(string name, List samples) + { + var root = new TreeViewNode { Content = name }; + _samplesTreeView.RootNodes.Add(root); + foreach (var sample in samples) + { + var node = new TreeViewNode { Content = sample.Name }; + _nodeToSample[node] = sample; + root.Children.Add(node); + } + return root; + } + + /// + /// On tree view node click load the html to the html panel and html editor. + /// + private void OnSamplesTreeView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + { + if (args.AddedItems.Count > 0 && args.AddedItems[0] is TreeViewNode node) + ShowSample(node); + } + + private void ShowSample(TreeViewNode node) + { + if (!_nodeToSample.TryGetValue(node, out var sample)) + return; + + UpdateLock = true; + + // Coloring the large performance samples takes too long + SetEditorText(sample.Html, !Equals(node.Parent?.Content, PerformanceSamplesTreeNodeName) && _coloredCheckBox.IsChecked == true); + + try + { + _htmlPanel.AvoidImagesLateLoading = !sample.FullName.Contains("Many images"); + _htmlPanel.Text = sample.Html; + UpdateFixedHtmlView(); + } + catch (Exception ex) + { + SetFixedHtmlText(string.Empty, false); + _ = WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to render HTML"); + } + + UpdateLock = false; + + UpdateWebBrowserHtml(); + } + + /// + /// On text change in the html editor update + /// + private void OnHtmlEditor_TextChanged(object sender, RoutedEventArgs e) + { + // TextChanged is raised later, also for the text set by the code + if (!UpdateLock && GetHtmlEditorText() != _setEditorText) + { + _updateHtmlTimer.Stop(); + _updateHtmlTimer.Start(); + } + } + + /// + /// Update the html renderer with text from html editor. + /// + private void OnUpdateHtmlTimerTick(DispatcherQueueTimer sender, object args) + { + UpdateLock = true; + + try + { + _htmlPanel.Text = GetHtmlEditorText(); + UpdateFixedHtmlView(); + } + catch (Exception ex) + { + SetFixedHtmlText(string.Empty, false); + _ = WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to render HTML"); + } + + UpdateWebBrowserHtml(); + + UpdateLock = false; + } + + /// + /// Fix the raw html by replacing the resource keys of images and stylesheets with their data, + /// a page shown from a string can't load local files. + /// + /// fixed html + private string GetFixedHtml() + { + var html = GetHtmlEditorText(); + + html = Regex.Replace(html, @"src=\""(\w.*?)\""", match => + { + var dataUri = HtmlRenderingHelper.TryGetResourceImageDataUri(match.Groups[1].Value); + return dataUri != null ? string.Format("src=\"{0}\"", dataUri) : match.Value; + }, RegexOptions.IgnoreCase); + + html = Regex.Replace(html, @"href=\""(\w.*?)\""", match => + { + var stylesheet = DemoUtils.GetStylesheet(match.Groups[1].Value); + return stylesheet != null + ? string.Format("href=\"data:text/css;base64,{0}\"", Convert.ToBase64String(Encoding.UTF8.GetBytes(stylesheet))) + : match.Value; + }, RegexOptions.IgnoreCase); + + return html; + } + + /// + /// Update the fixed html shown in the read-only editor. + /// + private void UpdateFixedHtmlView() + { + SetFixedHtmlText(HtmlPrettyPrinter.Format(_htmlPanel.GetHtml() ?? string.Empty), _coloredCheckBox.IsChecked == true); + } + + /// + /// Set formatted HTML text in the fixed HTML viewer. + /// + private void SetFixedHtmlText(string text, bool color) + { + // The document of a read-only box can't be changed + _fixedHtmlEditor.IsReadOnly = false; + SetRtf(_fixedHtmlEditor, text ?? string.Empty, color); + _fixedHtmlEditor.IsReadOnly = true; + } + + /// + /// Reload the html shown in the html editor by running coloring again. + /// + private void OnRefresh_Click(object sender, RoutedEventArgs e) + { + SetEditorText(GetHtmlEditorText(), true); + } + + /// + /// Toggle if the html syntax is colored. + /// + private void OnColored_Click(object sender, RoutedEventArgs e) + { + SetEditorText(GetHtmlEditorText(), _coloredCheckBox.IsChecked == true); + UpdateFixedHtmlView(); + } + + /// + /// Show error raised from html renderer. + /// + private void OnRenderError(object sender, HtmlRenderErrorEventArgs args) + { + _ = WindowHelper.ShowMessageAsync(XamlRoot, args.Message + (args.Exception != null ? "\r\n" + args.Exception : null), "Error in Html Renderer"); + } + + /// + /// On specific link click handle it here. + /// + private void OnLinkClicked(object sender, HtmlLinkClickedEventArgs args) + { + if (args.Link == "SayHello") + { + _ = WindowHelper.ShowMessageAsync(XamlRoot, "Hello you!", "Hello"); + args.Handled = true; + } + else if (args.Link == "ShowSampleForm") + { + new SampleWindow().Activate(); + args.Handled = true; + } + } + + /// + /// Set the html in the editor without triggering an update of the rendered html. + /// + private void SetEditorText(string text, bool color) + { + SetRtf(_htmlEditor, text, color); + _setEditorText = GetHtmlEditorText(); + } + + /// + /// Set html syntax colored text, uncolored text goes through the highlighter as well to escape it for RTF. + /// + private static void SetRtf(RichEditBox editor, string text, bool color) + { + var black = System.Drawing.Color.Black; + var rtf = color ? HtmlSyntaxHighlighter.Process(text) : HtmlSyntaxHighlighter.Process(text, black, black, black, black, black, black); + + // RichEditBox shows the RTF as text without a version and charset in the header + if (rtf.StartsWith(@"{\rtf{", StringComparison.Ordinal)) + rtf = @"{\rtf1\ansi" + rtf.Substring(5); + + editor.Document.SetText(TextSetOptions.FormatRtf, rtf); + } + + /// + /// Get the html text from the html editor control. + /// + private string GetHtmlEditorText() + { + _htmlEditor.Document.GetText(TextGetOptions.UseCrlf, out var text); + return text; + } + + #endregion + } +} diff --git a/Source/Demo/WinUI/MainWindow.xaml b/Source/Demo/WinUI/MainWindow.xaml index 44b3b4aea..59792b615 100644 --- a/Source/Demo/WinUI/MainWindow.xaml +++ b/Source/Demo/WinUI/MainWindow.xaml @@ -2,19 +2,29 @@ x:Class="TheArtOfDev.HtmlRenderer.Demo.WinUI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:winui="using:TheArtOfDev.HtmlRenderer.WinUI"> - + xmlns:local="using:TheArtOfDev.HtmlRenderer.Demo.WinUI"> - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/MainWindow.xaml.cs b/Source/Demo/WinUI/MainWindow.xaml.cs index 8fb7304d4..853a6572d 100644 --- a/Source/Demo/WinUI/MainWindow.xaml.cs +++ b/Source/Demo/WinUI/MainWindow.xaml.cs @@ -11,176 +11,118 @@ // "The Art of War" using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading.Tasks; -using Microsoft.Graphics.Canvas; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using TheArtOfDev.HtmlRenderer.Core.Entities; +using PdfSharp; using TheArtOfDev.HtmlRenderer.Demo.Common; -using Windows.Storage.Streams; +using TheArtOfDev.HtmlRenderer.PdfSharp; +using Windows.ApplicationModel.DataTransfer; namespace TheArtOfDev.HtmlRenderer.Demo.WinUI { - /// - /// Hosts a of the same showcase/test/performance samples the WPF and WinForms - /// demos expose (via the shared, UI-framework-agnostic / - /// from HtmlRenderer.Demo.Common) next to a - /// that renders whichever sample is selected. - /// - /// - /// Node content is a plain (the sample/category name), matching Microsoft's own - /// documented RootNodes pattern exactly (see - /// https://learn.microsoft.com/en-us/windows/apps/develop/ui/controls/tree-view) - no - /// is needed for that case, since a string displays directly. An - /// earlier version wrapped each node's content in a custom object and bound it via - /// TreeView.ItemTemplate's {Binding Name}, which rendered no text and never expanded - - /// switched to this simpler, documented shape instead of chasing the exact binding-context rules for - /// that pattern. maps each leaf node back to its - /// since the node's own displayed Content is just its name. - /// public sealed partial class MainWindow : Window { - private readonly Dictionary _nodeToSample = new Dictionary(); - public MainWindow() { InitializeComponent(); - Title = "HTML Renderer - WinUI 3 Demo"; - - // Unlike the WPF/WinForms demo windows, a WinUI 3 window does not pick up the executable - // icon stamped by the csproj ApplicationIcon property, so the title bar and taskbar show the - // generic default until AppWindow is pointed at the .ico explicitly. BaseDirectory rather - // than a relative path: SetIcon resolves against the working directory, which is not the app - // directory when the demo is launched from elsewhere. - AppWindow.SetIcon(Path.Combine(AppContext.BaseDirectory, "html.ico")); - - SamplesLoader.Init("WinUI", typeof(TheArtOfDev.HtmlRenderer.WinUI.HtmlRender).Assembly.GetName().Version.ToString()); - - // Without this, showcase samples' never resolves, so - // e.g. Intro.htm's `.whitehole { background-color:white; border-radius:10px }` class is simply - // never applied at all - not a paint bug, the rule itself never loads. DemoUtils.OnStylesheetLoad - // is the same platform-agnostic handler the WPF/WinForms demos use. - RendererPanel.StylesheetLoad += DemoUtils.OnStylesheetLoad; - - // Same idea as StylesheetLoad above: samples reference icons by logical key (e.g. - // src="HtmlIcon") that DemoUtils.GetImageStream resolves to an embedded resource - without - // this, every such falls through to the built-in "failed to load" icon instead. - RendererPanel.ImageLoad += OnImageLoad; - - LoadSamples(); + WindowHelper.Setup(this, "HTML Renderer WinUI Demo", 0.7, 0.8); } - private void LoadSamples() + /// + /// Open sample window. + /// + private void OnOpenSampleWindow_Click(object sender, RoutedEventArgs e) { - var showcaseRoot = new TreeViewNode { Content = "HTML Renderer" }; - SamplesTreeView.RootNodes.Add(showcaseRoot); - foreach (var sample in SamplesLoader.ShowcaseSamples) - AddSampleNode(showcaseRoot, sample); - - var testSamplesRoot = new TreeViewNode { Content = "Test Samples" }; - SamplesTreeView.RootNodes.Add(testSamplesRoot); - foreach (var sample in SamplesLoader.TestSamples) - AddSampleNode(testSamplesRoot, sample); - - if (SamplesLoader.PerformanceSamples.Count > 0) - { - var perfSamplesRoot = new TreeViewNode { Content = "Performance Samples" }; - SamplesTreeView.RootNodes.Add(perfSamplesRoot); - foreach (var sample in SamplesLoader.PerformanceSamples) - AddSampleNode(perfSamplesRoot, sample); - } - - showcaseRoot.IsExpanded = true; - if (showcaseRoot.Children.Count > 0) - { - var firstNode = showcaseRoot.Children[0]; - SamplesTreeView.SelectedNode = firstNode; - ShowSample(_nodeToSample[firstNode]); - } + new SampleWindow().Activate(); } - private void AddSampleNode(TreeViewNode root, HtmlSample sample) + /// + /// Toggle if to show split view of HtmlPanel and WebView2 control. + /// + private void OnShowBrowserView_Click(object sender, RoutedEventArgs e) { - var node = new TreeViewNode { Content = sample.Name }; - _nodeToSample[node] = sample; - root.Children.Add(node); + _mainControl.ShowWebBrowserView(_showBrowserView.IsChecked == true); } - private void OnSamplesTreeViewSelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + /// + /// Open the current html in the default browser. + /// + private void OnOpenExternal_Click(object sender, RoutedEventArgs e) { - foreach (var node in sender.SelectedNodes) - { - if (_nodeToSample.TryGetValue(node, out var sample)) - { - ShowSample(sample); - break; - } - } + var tmpFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".htm"); + File.WriteAllText(tmpFile, _mainControl.GetHtml()); + Process.Start(new ProcessStartInfo(tmpFile) { UseShellExecute = true }); } - private void ShowSample(HtmlSample sample) + /// + /// Toggle the use generated html button state. + /// + private void OnUseGeneratedHtml_Click(object sender, RoutedEventArgs e) { - try - { - RendererPanel.AvoidImagesLateLoading = !sample.FullName.Contains("Many images"); - RendererPanel.Text = sample.Html; - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } + _mainControl.UseGeneratedHtml = _useGeneratedHtml.IsChecked == true; + _mainControl.UpdateWebBrowserHtml(); } /// - /// Resolves the demo's logical icon keys (e.g. src="HtmlIcon") via - /// - real URLs/paths DemoUtils doesn't recognize fall - /// through untouched ( left false) so the default - /// loader still handles them. + /// Open generate image window for the current html. /// - private void OnImageLoad(object sender, HtmlImageLoadEventArgs e) + private void OnGenerateImage_Click(object sender, RoutedEventArgs e) { - var stream = DemoUtils.GetImageStream(e.Src); - if (stream == null) - return; - - e.Handled = true; - _ = LoadIconAsync(e, stream); + new GenerateImageWindow(_mainControl.GetHtml()).Activate(); } - private static async Task LoadIconAsync(HtmlImageLoadEventArgs e, Stream stream) + /// + /// Create PDF using PdfSharp project, save to file and open that file. + /// + private async void OnGeneratePdf_Click(object sender, RoutedEventArgs e) { try { - byte[] bytes; - using (stream) - using (var memory = new MemoryStream()) - { - await stream.CopyToAsync(memory).ConfigureAwait(false); - bytes = memory.ToArray(); - } + var config = new PdfGenerateConfig(); + config.PageSize = PageSize.A4; + config.SetMargins(20); - using var randomAccessStream = new InMemoryRandomAccessStream(); - using (var writer = new DataWriter(randomAccessStream.GetOutputStreamAt(0))) + var html = _mainControl.GetHtml(); + + // Off the UI thread, as the image conversion blocks on Win2D + var pdfFile = await Task.Run(async () => { - writer.WriteBytes(bytes); - await writer.StoreAsync(); - await writer.FlushAsync(); - writer.DetachStream(); - } - randomAccessStream.Seek(0); - - var bitmap = await CanvasBitmap.LoadAsync(CanvasDevice.GetSharedDevice(), randomAccessStream); - e.Callback(bitmap); + var doc = await PdfGenerator.GeneratePdf(html, config, null, DemoUtils.OnStylesheetLoad, HtmlRenderingHelper.OnImageLoadPdfSharp); + var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pdf"); + doc.Save(file); + return file; + }); + + Process.Start(new ProcessStartInfo(pdfFile) { UseShellExecute = true }); } catch (Exception ex) { - Debug.WriteLine(ex); - e.Callback(); + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to generate PDF"); } } + + /// + /// Execute performance test by setting all sample HTMLs in a loop. + /// + private async void OnRunPerformance_Click(object sender, RoutedEventArgs e) + { + _mainControl.UpdateLock = true; + _toolBar.IsEnabled = false; + + // There's no paint in between, unlike WinForms and WPF, WinUI has no way to pump the messages + var msg = DemoUtils.RunSamplesPerformanceTest(_mainControl.SetHtml); + + var dataPackage = new DataPackage(); + dataPackage.SetText(msg); + Clipboard.SetContent(dataPackage); + + await WindowHelper.ShowMessageAsync(Content.XamlRoot, msg, "Test run results"); + + _mainControl.UpdateLock = false; + _toolBar.IsEnabled = true; + } } } diff --git a/Source/Demo/WinUI/SampleWindow.xaml b/Source/Demo/WinUI/SampleWindow.xaml new file mode 100644 index 000000000..8582913cf --- /dev/null +++ b/Source/Demo/WinUI/SampleWindow.xaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +