diff --git a/src/Web/Avalonia.Web.Sample/Avalonia.Web.Sample.csproj b/src/Web/Avalonia.Web.Sample/Avalonia.Web.Sample.csproj index ad8d14e527..13aad8c13e 100644 --- a/src/Web/Avalonia.Web.Sample/Avalonia.Web.Sample.csproj +++ b/src/Web/Avalonia.Web.Sample/Avalonia.Web.Sample.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Web/Avalonia.Web.Sample/EmbedSample.Browser.cs b/src/Web/Avalonia.Web.Sample/EmbedSample.Browser.cs new file mode 100644 index 0000000000..5baa4a6b35 --- /dev/null +++ b/src/Web/Avalonia.Web.Sample/EmbedSample.Browser.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.InteropServices.JavaScript; + +using Avalonia; +using Avalonia.Platform; +using Avalonia.Web; + +using ControlCatalog.Pages; + +namespace ControlCatalog.Web; + +public class EmbedSampleWeb : INativeDemoControl +{ + public IPlatformHandle CreateControl(bool isSecond, IPlatformHandle parent, Func createDefault) + { + if (isSecond) + { + var iframe = EmbedInterop.CreateElement("iframe"); + iframe.SetProperty("src", "https://www.youtube.com/embed/kZCIporjJ70"); + + return new JSObjectControlHandle(iframe); + } + else + { + var defaultHandle = (JSObjectControlHandle)createDefault(); + + _ = JSHost.ImportAsync("embed.js", "./embed.js").ContinueWith(_ => + { + EmbedInterop.AddAppButton(defaultHandle.Object); + }); + + return defaultHandle; + } + } +} + +internal static partial class EmbedInterop +{ + [JSImport("globalThis.document.createElement")] + public static partial JSObject CreateElement(string tagName); + + [JSImport("addAppButton", "embed.js")] + public static partial void AddAppButton(JSObject parentObject); +} diff --git a/src/Web/Avalonia.Web.Sample/Program.cs b/src/Web/Avalonia.Web.Sample/Program.cs index 233f95852f..52acabb0fa 100644 --- a/src/Web/Avalonia.Web.Sample/Program.cs +++ b/src/Web/Avalonia.Web.Sample/Program.cs @@ -1,12 +1,17 @@ using Avalonia; using Avalonia.Web; using ControlCatalog; +using ControlCatalog.Web; internal partial class Program { private static void Main(string[] args) { - BuildAvaloniaApp().SetupBrowserApp("out"); + BuildAvaloniaApp() + .AfterSetup(_ => + { + ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb(); + }).SetupBrowserApp("out"); } public static AppBuilder BuildAvaloniaApp() diff --git a/src/Web/Avalonia.Web.Sample/embed.js b/src/Web/Avalonia.Web.Sample/embed.js new file mode 100644 index 0000000000..f393c80314 --- /dev/null +++ b/src/Web/Avalonia.Web.Sample/embed.js @@ -0,0 +1,11 @@ +export function addAppButton(parent) { + var button = globalThis.document.createElement('button'); + button.innerText = 'Hello world'; + var clickCount = 0; + button.onclick = () => { + clickCount++; + button.innerText = 'Click count ' + clickCount; + }; + parent.appendChild(button); + return button; +} diff --git a/src/Web/Avalonia.Web/AvaloniaView.cs b/src/Web/Avalonia.Web/AvaloniaView.cs index 1ed1dad3e7..e81620ffde 100644 --- a/src/Web/Avalonia.Web/AvaloniaView.cs +++ b/src/Web/Avalonia.Web/AvaloniaView.cs @@ -333,8 +333,7 @@ namespace Avalonia.Web internal INativeControlHostImpl GetNativeControlHostImpl() { - throw new NotImplementedException(); - //return _nativeControlHost ?? throw new InvalidOperationException("Blazor View wasn't initialized yet"); + return new BrowserNativeControlHost(_nativeControlsContainer); } private void ForceBlit() diff --git a/src/Web/Avalonia.Web/BrowserNativeControlHost.cs b/src/Web/Avalonia.Web/BrowserNativeControlHost.cs new file mode 100644 index 0000000000..4cdcf627e6 --- /dev/null +++ b/src/Web/Avalonia.Web/BrowserNativeControlHost.cs @@ -0,0 +1,136 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices.JavaScript; + +using Avalonia.Controls.Platform; +using Avalonia.Platform; +using Avalonia.Web.Interop; + +namespace Avalonia.Web +{ + internal class BrowserNativeControlHost : INativeControlHostImpl + { + private readonly JSObject _hostElement; + + public BrowserNativeControlHost(JSObject element) + { + _hostElement = element; + } + + public INativeControlHostDestroyableControlHandle CreateDefaultChild(IPlatformHandle parent) + { + var element = NativeControlHostHelper.CreateDefaultChild(null); + return new JSObjectControlHandle(element); + } + + public INativeControlHostControlTopLevelAttachment CreateNewAttachment(Func create) + { + Attachment? a = null; + try + { + var child = create(new JSObjectControlHandle(_hostElement)); + var attachmenetReference = NativeControlHostHelper.CreateAttachment(); + // It has to be assigned to the variable before property setter is called so we dispose it on exception +#pragma warning disable IDE0017 // Simplify object initialization + a = new Attachment(attachmenetReference, child); +#pragma warning restore IDE0017 // Simplify object initialization + a.AttachedTo = this; + return a; + } + catch + { + a?.Dispose(); + throw; + } + } + + public INativeControlHostControlTopLevelAttachment CreateNewAttachment(IPlatformHandle handle) + { + var attachmenetReference = NativeControlHostHelper.CreateAttachment(); + var a = new Attachment(attachmenetReference, handle); + a.AttachedTo = this; + return a; + } + + public bool IsCompatibleWith(IPlatformHandle handle) => handle is JSObjectControlHandle; + + private class Attachment : INativeControlHostControlTopLevelAttachment + { + private const string InitializeWithChildHandleSymbol = "InitializeWithChildHandle"; + private const string AttachToSymbol = "AttachTo"; + private const string ShowInBoundsSymbol = "ShowInBounds"; + private const string HideWithSizeSymbol = "HideWithSize"; + private const string ReleaseChildSymbol = "ReleaseChild"; + + private JSObject? _native; + private BrowserNativeControlHost? _attachedTo; + + public Attachment(JSObject native, IPlatformHandle handle) + { + _native = native; + NativeControlHostHelper.InitializeWithChildHandle(_native, ((JSObjectControlHandle)handle).Object); + } + + public void Dispose() + { + if (_native != null) + { + NativeControlHostHelper.ReleaseChild(_native); + _native.Dispose(); + _native = null; + } + } + + public INativeControlHostImpl? AttachedTo + { + get => _attachedTo!; + set + { + CheckDisposed(); + + var host = (BrowserNativeControlHost?)value; + if (host == null) + { + NativeControlHostHelper.AttachTo(_native, null); + } + else + { + NativeControlHostHelper.AttachTo(_native, host._hostElement); + } + _attachedTo = host; + } + } + + public bool IsCompatibleWith(INativeControlHostImpl host) => host is BrowserNativeControlHost; + + public void HideWithSize(Size size) + { + CheckDisposed(); + if (_attachedTo == null) + return; + + NativeControlHostHelper.HideWithSize(_native, Math.Max(1, size.Width), Math.Max(1, size.Height)); + } + + public void ShowInBounds(Rect bounds) + { + CheckDisposed(); + + if (_attachedTo == null) + throw new InvalidOperationException("Native control isn't attached to a toplevel"); + + bounds = new Rect(bounds.X, bounds.Y, Math.Max(1, bounds.Width), + Math.Max(1, bounds.Height)); + + NativeControlHostHelper.ShowInBounds(_native, bounds.X, bounds.Y, bounds.Width, bounds.Height); + } + + [MemberNotNull(nameof(_native))] + private void CheckDisposed() + { + if (_native == null) + throw new ObjectDisposedException(nameof(Attachment)); + } + } + } +} diff --git a/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs b/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs index d08c153966..b955da6df2 100644 --- a/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs +++ b/src/Web/Avalonia.Web/BrowserTopLevelImpl.cs @@ -35,13 +35,11 @@ namespace Avalonia.Web AcrylicCompensationLevels = new AcrylicPlatformCompensationLevels(1, 1, 1); _touchDevice = new TouchDevice(); _penDevice = new PenDevice(); + NativeControlHost = _avaloniaView.GetNativeControlHostImpl(); } public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds; - - - public void SetClientSize(Size newSize, double dpi) { if (Math.Abs(RenderScaling - dpi) > 0.0001) @@ -222,7 +220,7 @@ namespace Avalonia.Web public ITextInputMethodImpl TextInputMethod => _avaloniaView; - public INativeControlHostImpl? NativeControlHost => _avaloniaView.GetNativeControlHostImpl(); + public INativeControlHostImpl? NativeControlHost { get; } public IStorageProvider StorageProvider { get; } = new BrowserStorageProvider(); } } diff --git a/src/Web/Avalonia.Web/Interop/NativeControlHostHelper.cs b/src/Web/Avalonia.Web/Interop/NativeControlHostHelper.cs new file mode 100644 index 0000000000..5cc86bf622 --- /dev/null +++ b/src/Web/Avalonia.Web/Interop/NativeControlHostHelper.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.InteropServices.JavaScript; + +namespace Avalonia.Web.Interop; + +internal static partial class NativeControlHostHelper +{ + [JSImport("NativeControlHost.createDefaultChild", "avalonia.ts")] + internal static partial JSObject CreateDefaultChild(JSObject? parent); + + [JSImport("NativeControlHost.createAttachment", "avalonia.ts")] + internal static partial JSObject CreateAttachment(); + + [JSImport("NativeControlHost.initializeWithChildHandle", "avalonia.ts")] + internal static partial void InitializeWithChildHandle(JSObject element, JSObject child); + + [JSImport("NativeControlHost.attachTo", "avalonia.ts")] + internal static partial void AttachTo(JSObject element, JSObject? host); + + [JSImport("NativeControlHost.showInBounds", "avalonia.ts")] + internal static partial void ShowInBounds(JSObject element, double x, double y, double width, double height); + + [JSImport("NativeControlHost.hideWithSize", "avalonia.ts")] + internal static partial void HideWithSize(JSObject element, double width, double height); + + [JSImport("NativeControlHost.releaseChild", "avalonia.ts")] + internal static partial void ReleaseChild(JSObject element); +} diff --git a/src/Web/Avalonia.Web/JSObjectControlHandle.cs b/src/Web/Avalonia.Web/JSObjectControlHandle.cs new file mode 100644 index 0000000000..e56ca123eb --- /dev/null +++ b/src/Web/Avalonia.Web/JSObjectControlHandle.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.InteropServices.JavaScript; + +using Avalonia.Controls.Platform; + +namespace Avalonia.Web; + +public class JSObjectControlHandle : INativeControlHostDestroyableControlHandle +{ + internal const string ElementReferenceDescriptor = "JSObject"; + + public JSObjectControlHandle(JSObject reference) + { + Object = reference; + } + + public JSObject Object { get; } + + public IntPtr Handle => throw new NotSupportedException(); + + public string? HandleDescriptor => ElementReferenceDescriptor; + + public void Destroy() + { + if (Object is JSObject inProcess && !inProcess.IsDisposed) + { + inProcess.Dispose(); + } + } +} diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia.ts index 796bae61de..6992921f5b 100644 --- a/src/Web/Avalonia.Web/webapp/modules/avalonia.ts +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia.ts @@ -1,19 +1,20 @@ import { RuntimeAPI } from "../types/dotnet"; import { SizeWatcher, DpiWatcher, Canvas } from "./avalonia/canvas"; - import { InputHelper } from "./avalonia/input"; import { AvaloniaDOM } from "./avalonia/dom"; import { Caniuse } from "./avalonia/caniuse"; import { StreamHelper } from "./avalonia/stream"; +import { NativeControlHost } from "./avalonia/NativeControlHost"; export async function createAvaloniaRuntime(api: RuntimeAPI): Promise { api.setModuleImports("avalonia.ts", { + Caniuse, Canvas, InputHelper, SizeWatcher, DpiWatcher, AvaloniaDOM, - Caniuse, - StreamHelper + StreamHelper, + NativeControlHost }); } diff --git a/src/Web/Avalonia.Web/webapp/modules/avalonia/nativeControlHost.ts b/src/Web/Avalonia.Web/webapp/modules/avalonia/nativeControlHost.ts new file mode 100644 index 0000000000..c65d5836df --- /dev/null +++ b/src/Web/Avalonia.Web/webapp/modules/avalonia/nativeControlHost.ts @@ -0,0 +1,55 @@ +class NativeControlHostTopLevelAttachment { + _child?: HTMLElement; + _host?: HTMLElement; +} + +export class NativeControlHost { + public static createDefaultChild(parent?: HTMLElement): HTMLElement { + return document.createElement("div"); + } + + public static createAttachment(): NativeControlHostTopLevelAttachment { + return new NativeControlHostTopLevelAttachment(); + } + + public static initializeWithChildHandle(element: NativeControlHostTopLevelAttachment, child: HTMLElement): void { + element._child = child; + element._child.style.position = "absolute"; + } + + public static attachTo(element: NativeControlHostTopLevelAttachment, host?: HTMLElement): void { + if (element._host && element._child) { + element._host.removeChild(element._child); + } + + element._host = host; + + if (element._host && element._child) { + element._host.appendChild(element._child); + } + } + + public static showInBounds(element: NativeControlHostTopLevelAttachment, x: number, y: number, width: number, height: number): void { + if (element._child) { + element._child.style.top = `${y}px`; + element._child.style.left = `${x}px`; + element._child.style.width = `${width}px`; + element._child.style.height = `${height}px`; + element._child.style.display = "block"; + } + } + + public static hideWithSize(element: NativeControlHostTopLevelAttachment, width: number, height: number): void { + if (element._child) { + element._child.style.width = `${width}px`; + element._child.style.height = `${height}px`; + element._child.style.display = "none"; + } + } + + public static releaseChild(element: NativeControlHostTopLevelAttachment): void { + if (element._child) { + element._child = undefined; + } + } +}