committed by
GitHub
72 changed files with 6433 additions and 24 deletions
@ -0,0 +1,41 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFramework>net7.0</TargetFramework> |
|||
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier> |
|||
<WasmMainJSPath>main.js</WasmMainJSPath> |
|||
<OutputType>Exe</OutputType> |
|||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> |
|||
<MSBuildEnableWorkloadResolver>true</MSBuildEnableWorkloadResolver> |
|||
<WasmBuildNative>true</WasmBuildNative> |
|||
<EmccFlags>-sVERBOSE -sERROR_ON_UNDEFINED_SYMBOLS=0</EmccFlags> |
|||
</PropertyGroup> |
|||
|
|||
<PropertyGroup Condition="'$(Configuration)'=='Release'"> |
|||
<RunAOTCompilation>true</RunAOTCompilation> |
|||
<PublishTrimmed>true</PublishTrimmed> |
|||
<TrimMode>full</TrimMode> |
|||
<WasmBuildNative>true</WasmBuildNative> |
|||
<InvariantGlobalization>true</InvariantGlobalization> |
|||
<WasmEnableSIMD>true</WasmEnableSIMD> |
|||
<EmccCompileOptimizationFlag>-O3</EmccCompileOptimizationFlag> |
|||
<EmccLinkOptimizationFlag>-O3</EmccLinkOptimizationFlag> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\..\samples\ControlCatalog\ControlCatalog.csproj" /> |
|||
<ProjectReference Include="..\Avalonia.Web\Avalonia.Web.csproj" /> |
|||
<ProjectReference Include="..\..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<ItemGroup> |
|||
<WasmExtraFilesToDeploy Include="index.html" /> |
|||
<WasmExtraFilesToDeploy Include="main.js" /> |
|||
<WasmExtraFilesToDeploy Include="embed.js" /> |
|||
<WasmExtraFilesToDeploy Include="favicon.ico" /> |
|||
<WasmExtraFilesToDeploy Include="Logo.svg" /> |
|||
<WasmExtraFilesToDeploy Include="app.css" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\Avalonia.Web\Avalonia.Web.props" /> |
|||
<Import Project="..\Avalonia.Web\Avalonia.Web.targets" /> |
|||
</Project> |
|||
@ -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<IPlatformHandle> 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); |
|||
} |
|||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,19 @@ |
|||
using Avalonia; |
|||
using Avalonia.Web; |
|||
using ControlCatalog; |
|||
using ControlCatalog.Web; |
|||
|
|||
internal partial class Program |
|||
{ |
|||
private static void Main(string[] args) |
|||
{ |
|||
BuildAvaloniaApp() |
|||
.AfterSetup(_ => |
|||
{ |
|||
ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb(); |
|||
}).SetupBrowserApp("out"); |
|||
} |
|||
|
|||
public static AppBuilder BuildAvaloniaApp() |
|||
=> AppBuilder.Configure<App>(); |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
#out { |
|||
height: 100vh; |
|||
width: 100vw |
|||
} |
|||
|
|||
#avalonia-splash { |
|||
position: absolute; |
|||
height: 100%; |
|||
width: 100%; |
|||
color: whitesmoke; |
|||
background: #171C2C; |
|||
font-family: 'Nunito', sans-serif; |
|||
} |
|||
|
|||
#avalonia-splash a{ |
|||
color: whitesmoke; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.center { |
|||
display: flex; |
|||
justify-content: center; |
|||
height: 250px; |
|||
} |
|||
|
|||
.splash-close { |
|||
animation: fadeOut 1s forwards; |
|||
} |
|||
|
|||
@keyframes fadeOut { |
|||
from { |
|||
opacity: 1; |
|||
} |
|||
|
|||
to { |
|||
opacity: 0; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
|
After Width: | Height: | Size: 172 KiB |
@ -0,0 +1,31 @@ |
|||
<!DOCTYPE html> |
|||
<!-- Licensed to the .NET Foundation under one or more agreements. --> |
|||
<!-- The .NET Foundation licenses this file to you under the MIT license. --> |
|||
<html> |
|||
|
|||
<head> |
|||
<title>Avalonia.Web.Sample</title> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<link rel="modulepreload" href="./main.js" /> |
|||
<link rel="modulepreload" href="./dotnet.js" /> |
|||
<link rel="modulepreload" href="./avalonia.js" /> |
|||
<link rel="stylesheet" href="./app.css" /> |
|||
</head> |
|||
|
|||
<body style="margin: 0px"> |
|||
<div id="out"> |
|||
<div id="avalonia-splash"> |
|||
<div class="center"> |
|||
<h2>Powered by</h2> |
|||
<a class="navbar-brand" href="https://www.avaloniaui.net/" target="_blank"> |
|||
<img src="Logo.svg" alt="Avalonia Logo" width="30" height="24" /> |
|||
Avalonia |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<script type='module' src="./main.js"></script> |
|||
</body> |
|||
|
|||
</html> |
|||
@ -0,0 +1,19 @@ |
|||
// Licensed to the .NET Foundation under one or more agreements.
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
|||
|
|||
import { dotnet } from './dotnet.js' |
|||
import { createAvaloniaRuntime } from './avalonia.js'; |
|||
|
|||
const is_browser = typeof window != "undefined"; |
|||
if (!is_browser) throw new Error(`Expected to be running in a browser`); |
|||
|
|||
const dotnetRuntime = await dotnet |
|||
.withDiagnosticTracing(false) |
|||
.withApplicationArgumentsFromQuery() |
|||
.create(); |
|||
|
|||
await createAvaloniaRuntime(dotnetRuntime); |
|||
|
|||
const config = dotnetRuntime.getConfig(); |
|||
|
|||
await dotnetRuntime.runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]); |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"wasmHostProperties": { |
|||
"perHostConfig": [ |
|||
{ |
|||
"name": "browser", |
|||
"html-path": "index.html", |
|||
"Host": "browser" |
|||
} |
|||
] |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFramework>net7.0</TargetFramework> |
|||
<LangVersion>preview</LangVersion> |
|||
<Nullable>enable</Nullable> |
|||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<SupportedPlatform Include="browser" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\..\build\BuildTargets.targets" /> |
|||
<Import Project="..\..\..\build\SkiaSharp.props" /> |
|||
<Import Project="..\..\..\build\HarfBuzzSharp.props" /> |
|||
<Import Project="..\..\..\build\NullableEnable.props" /> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\..\packages\Avalonia\Avalonia.csproj" /> |
|||
<ProjectReference Include="..\..\Skia\Avalonia.Skia\Avalonia.Skia.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<ItemGroup> |
|||
<Content Include="*.props"> |
|||
<Pack>true</Pack> |
|||
<PackagePath>build\</PackagePath> |
|||
</Content> |
|||
<Content Include="*.targets"> |
|||
<Pack>true</Pack> |
|||
<PackagePath>build\;buildTransitive\</PackagePath> |
|||
</Content> |
|||
<Content Include="interop.js"> |
|||
<Pack>true</Pack> |
|||
<PackagePath>build/interop.js;buildTransitive/interop.js</PackagePath> |
|||
</Content> |
|||
<Content Include="wwwroot/**/*.*"> |
|||
<Pack>true</Pack> |
|||
<PackagePath>build\wwwroot;buildTransitive\wwwroot</PackagePath> |
|||
</Content> |
|||
</ItemGroup> |
|||
|
|||
<ItemGroup> |
|||
<Folder Include="wwwroot\" /> |
|||
</ItemGroup> |
|||
|
|||
<Target Name="NpmInstall" Inputs="webapp/package.json" Outputs="webapp/node_modules/.install-stamp"> |
|||
<Exec Command="npm install" WorkingDirectory="webapp" /> |
|||
<!-- Write the stamp file, so incremental builds work --> |
|||
<Touch Files="webapp/node_modules/.install-stamp" AlwaysCreate="true" /> |
|||
</Target> |
|||
<Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="BeforeBuild"> |
|||
<Exec Command="npm run build" WorkingDirectory="webapp" /> |
|||
</Target> |
|||
|
|||
</Project> |
|||
@ -0,0 +1,5 @@ |
|||
<Project> |
|||
<PropertyGroup> |
|||
<EmccExtraLDFlags>$(EmccExtraLDFlags) --js-library="$(MSBuildThisFileDirectory)\interop.js"</EmccExtraLDFlags> |
|||
</PropertyGroup> |
|||
</Project> |
|||
@ -0,0 +1,7 @@ |
|||
<Project> |
|||
<ItemGroup> |
|||
<WasmExtraFilesToDeploy Include="$(MSBuildThisFileDirectory)/wwwroot/**/*.*" /> |
|||
<NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.7\libHarfBuzzSharp.a" /> |
|||
<NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.7\libSkiaSharp.a" /> |
|||
</ItemGroup> |
|||
</Project> |
|||
@ -0,0 +1,451 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.Embedding; |
|||
using Avalonia.Controls.Platform; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Raw; |
|||
using Avalonia.Input.TextInput; |
|||
using Avalonia.Rendering.Composition; |
|||
using Avalonia.Threading; |
|||
using Avalonia.Web.Interop; |
|||
using Avalonia.Web.Skia; |
|||
|
|||
using SkiaSharp; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
|
|||
public partial class AvaloniaView : ITextInputMethodImpl |
|||
{ |
|||
private readonly BrowserTopLevelImpl _topLevelImpl; |
|||
private EmbeddableControlRoot _topLevel; |
|||
|
|||
private readonly JSObject _containerElement; |
|||
private readonly JSObject _canvas; |
|||
private readonly JSObject _nativeControlsContainer; |
|||
private readonly JSObject _inputElement; |
|||
private readonly JSObject? _splash; |
|||
|
|||
private GLInfo? _jsGlInfo = null; |
|||
private double _dpi = 1; |
|||
private Size _canvasSize = new(100.0, 100.0); |
|||
|
|||
private GRContext? _context; |
|||
private GRGlInterface? _glInterface; |
|||
private const SKColorType ColorType = SKColorType.Rgba8888; |
|||
|
|||
private bool _useGL; |
|||
private ITextInputMethodClient? _client; |
|||
private static int _canvasCount; |
|||
|
|||
public AvaloniaView(string divId) |
|||
{ |
|||
var host = DomHelper.GetElementById(divId); |
|||
if (host == null) |
|||
{ |
|||
throw new Exception($"Element with id {divId} was not found in the html document."); |
|||
} |
|||
|
|||
var hostContent = DomHelper.CreateAvaloniaHost(host); |
|||
if (hostContent == null) |
|||
{ |
|||
throw new InvalidOperationException("Avalonia WASM host wasn't initialized."); |
|||
} |
|||
|
|||
_containerElement = hostContent.GetPropertyAsJSObject("host") |
|||
?? throw new InvalidOperationException("Host cannot be null"); |
|||
_canvas = hostContent.GetPropertyAsJSObject("canvas") |
|||
?? throw new InvalidOperationException("Canvas cannot be null"); |
|||
_nativeControlsContainer = hostContent.GetPropertyAsJSObject("nativeHost") |
|||
?? throw new InvalidOperationException("NativeHost cannot be null"); |
|||
_inputElement = hostContent.GetPropertyAsJSObject("inputElement") |
|||
?? throw new InvalidOperationException("InputElement cannot be null"); |
|||
|
|||
_splash = DomHelper.GetElementById("avalonia-splash"); |
|||
|
|||
_canvas.SetProperty("id", $"avaloniaCanvas{_canvasCount++}"); |
|||
|
|||
_topLevelImpl = new BrowserTopLevelImpl(this); |
|||
|
|||
_topLevel = new WebEmbeddableControlRoot(_topLevelImpl, () => |
|||
{ |
|||
Dispatcher.UIThread.Post(() => |
|||
{ |
|||
if (_splash != null) |
|||
{ |
|||
DomHelper.AddCssClass(_splash, "splash-close"); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
_topLevelImpl.SetCssCursor = (cursor) => |
|||
{ |
|||
InputHelper.SetCursor(_containerElement, cursor); // macOS
|
|||
InputHelper.SetCursor(_canvas, cursor); // windows
|
|||
}; |
|||
|
|||
_topLevel.Prepare(); |
|||
|
|||
_topLevel.Renderer.Start(); |
|||
|
|||
InputHelper.SubscribeKeyEvents( |
|||
_containerElement, |
|||
OnKeyDown, |
|||
OnKeyUp); |
|||
|
|||
InputHelper.SubscribeTextEvents( |
|||
_inputElement, |
|||
OnTextInput, |
|||
OnCompositionStart, |
|||
OnCompositionUpdate, |
|||
OnCompositionEnd); |
|||
|
|||
InputHelper.SubscribePointerEvents(_containerElement, OnPointerMove, OnPointerDown, OnPointerUp, OnWheel); |
|||
|
|||
var skiaOptions = AvaloniaLocator.Current.GetService<SkiaOptions>(); |
|||
|
|||
_dpi = DomHelper.ObserveDpi(OnDpiChanged); |
|||
|
|||
_useGL = skiaOptions?.CustomGpuFactory != null; |
|||
|
|||
if (_useGL) |
|||
{ |
|||
_jsGlInfo = CanvasHelper.InitialiseGL(_canvas, OnRenderFrame); |
|||
// create the SkiaSharp context
|
|||
if (_context == null) |
|||
{ |
|||
_glInterface = GRGlInterface.Create(); |
|||
_context = GRContext.CreateGl(_glInterface); |
|||
|
|||
// bump the default resource cache limit
|
|||
_context.SetResourceCacheLimit(skiaOptions?.MaxGpuResourceSizeBytes ?? 32 * 1024 * 1024); |
|||
} |
|||
|
|||
_topLevelImpl.Surfaces = new[] { new BrowserSkiaSurface(_context, _jsGlInfo, ColorType, new PixelSize((int)_canvasSize.Width, (int)_canvasSize.Height), _dpi, GRSurfaceOrigin.BottomLeft) }; |
|||
} |
|||
else |
|||
{ |
|||
//var rasterInitialized = _interop.InitRaster();
|
|||
//Console.WriteLine("raster initialized: {0}", rasterInitialized);
|
|||
|
|||
//_topLevelImpl.SetSurface(ColorType,
|
|||
// new PixelSize((int)_canvasSize.Width, (int)_canvasSize.Height), _dpi, _interop.PutImageData);
|
|||
} |
|||
|
|||
CanvasHelper.SetCanvasSize(_canvas, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi)); |
|||
|
|||
_topLevelImpl.SetClientSize(_canvasSize, _dpi); |
|||
|
|||
DomHelper.ObserveSize(host, divId, OnSizeChanged); |
|||
|
|||
CanvasHelper.RequestAnimationFrame(_canvas, true); |
|||
} |
|||
|
|||
private static RawPointerPoint ExtractRawPointerFromJSArgs(JSObject args) |
|||
{ |
|||
var point = new RawPointerPoint |
|||
{ |
|||
Position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")), |
|||
Pressure = (float)args.GetPropertyAsDouble("pressure"), |
|||
XTilt = (float)args.GetPropertyAsDouble("tiltX"), |
|||
YTilt = (float)args.GetPropertyAsDouble("tiltY"), |
|||
Twist = (float)args.GetPropertyAsDouble("twist") |
|||
}; |
|||
|
|||
return point; |
|||
} |
|||
|
|||
private bool OnPointerMove(JSObject args) |
|||
{ |
|||
var type = args.GetPropertyAsString("pointertype"); |
|||
|
|||
var point = ExtractRawPointerFromJSArgs(args); |
|||
|
|||
return _topLevelImpl.RawPointerEvent(RawPointerEventType.Move, type!, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); |
|||
} |
|||
|
|||
private bool OnPointerDown(JSObject args) |
|||
{ |
|||
var pointerType = args.GetPropertyAsString("pointerType"); |
|||
|
|||
var type = pointerType switch |
|||
{ |
|||
"touch" => RawPointerEventType.TouchBegin, |
|||
_ => args.GetPropertyAsInt32("button") switch |
|||
{ |
|||
0 => RawPointerEventType.LeftButtonDown, |
|||
1 => RawPointerEventType.MiddleButtonDown, |
|||
2 => RawPointerEventType.RightButtonDown, |
|||
3 => RawPointerEventType.XButton1Down, |
|||
4 => RawPointerEventType.XButton2Down, |
|||
// 5 => Pen eraser button,
|
|||
_ => RawPointerEventType.Move |
|||
} |
|||
}; |
|||
|
|||
var point = ExtractRawPointerFromJSArgs(args); |
|||
|
|||
return _topLevelImpl.RawPointerEvent(type, pointerType!, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); |
|||
} |
|||
|
|||
private bool OnPointerUp(JSObject args) |
|||
{ |
|||
var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; |
|||
|
|||
var type = pointerType switch |
|||
{ |
|||
"touch" => RawPointerEventType.TouchEnd, |
|||
_ => args.GetPropertyAsInt32("button") switch |
|||
{ |
|||
0 => RawPointerEventType.LeftButtonUp, |
|||
1 => RawPointerEventType.MiddleButtonUp, |
|||
2 => RawPointerEventType.RightButtonUp, |
|||
3 => RawPointerEventType.XButton1Up, |
|||
4 => RawPointerEventType.XButton2Up, |
|||
// 5 => Pen eraser button,
|
|||
_ => RawPointerEventType.Move |
|||
} |
|||
}; |
|||
|
|||
var point = ExtractRawPointerFromJSArgs(args); |
|||
|
|||
return _topLevelImpl.RawPointerEvent(type, pointerType, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); |
|||
} |
|||
|
|||
private bool OnWheel(JSObject args) |
|||
{ |
|||
return _topLevelImpl.RawMouseWheelEvent(new Point(args.GetPropertyAsDouble("clientX"), args.GetPropertyAsDouble("clientY")), |
|||
new Vector(-(args.GetPropertyAsDouble("deltaX") / 50), -(args.GetPropertyAsDouble("deltaY") / 50)), GetModifiers(args)); |
|||
} |
|||
|
|||
private static RawInputModifiers GetModifiers(JSObject e) |
|||
{ |
|||
var modifiers = RawInputModifiers.None; |
|||
|
|||
if (e.GetPropertyAsBoolean("ctrlKey")) |
|||
modifiers |= RawInputModifiers.Control; |
|||
if (e.GetPropertyAsBoolean("altKey")) |
|||
modifiers |= RawInputModifiers.Alt; |
|||
if (e.GetPropertyAsBoolean("shiftKey")) |
|||
modifiers |= RawInputModifiers.Shift; |
|||
if (e.GetPropertyAsBoolean("metaKey")) |
|||
modifiers |= RawInputModifiers.Meta; |
|||
|
|||
var buttons = e.GetPropertyAsInt32("buttons"); |
|||
if ((buttons & 1L) == 1) |
|||
modifiers |= RawInputModifiers.LeftMouseButton; |
|||
|
|||
if ((buttons & 2L) == 2) |
|||
modifiers |= e.GetPropertyAsString("type") == "pen" ? RawInputModifiers.PenBarrelButton : RawInputModifiers.RightMouseButton; |
|||
|
|||
if ((buttons & 4L) == 4) |
|||
modifiers |= RawInputModifiers.MiddleMouseButton; |
|||
|
|||
if ((buttons & 8L) == 8) |
|||
modifiers |= RawInputModifiers.XButton1MouseButton; |
|||
|
|||
if ((buttons & 16L) == 16) |
|||
modifiers |= RawInputModifiers.XButton2MouseButton; |
|||
|
|||
if ((buttons & 32L) == 32) |
|||
modifiers |= RawInputModifiers.PenEraser; |
|||
|
|||
return modifiers; |
|||
} |
|||
|
|||
private bool OnKeyDown (string code, string key, int modifier) |
|||
{ |
|||
return _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier); |
|||
} |
|||
|
|||
private bool OnKeyUp(string code, string key, int modifier) |
|||
{ |
|||
return _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyUp, code, key, (RawInputModifiers)modifier); |
|||
} |
|||
|
|||
private bool OnTextInput (string type, string? data) |
|||
{ |
|||
if(data == null || IsComposing) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
return _topLevelImpl.RawTextEvent(data); |
|||
} |
|||
|
|||
private bool OnCompositionStart (JSObject args) |
|||
{ |
|||
if (_client == null) |
|||
return false; |
|||
|
|||
_client.SetPreeditText(null); |
|||
IsComposing = true; |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private bool OnCompositionUpdate(JSObject args) |
|||
{ |
|||
if (_client == null) |
|||
return false; |
|||
|
|||
_client.SetPreeditText(args.GetPropertyAsString("data")); |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private bool OnCompositionEnd(JSObject args) |
|||
{ |
|||
if (_client == null) |
|||
return false; |
|||
|
|||
IsComposing = false; |
|||
_client.SetPreeditText(null); |
|||
_topLevelImpl.RawTextEvent(args.GetPropertyAsString("data")!); |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private void OnRenderFrame() |
|||
{ |
|||
if (_useGL && (_jsGlInfo == null)) |
|||
{ |
|||
Console.WriteLine("nothing to render"); |
|||
return; |
|||
} |
|||
if (_canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0) |
|||
{ |
|||
Console.WriteLine("nothing to render"); |
|||
return; |
|||
} |
|||
|
|||
ManualTriggerRenderTimer.Instance.RaiseTick(); |
|||
} |
|||
|
|||
public Control? Content |
|||
{ |
|||
get => (Control)_topLevel.Content!; |
|||
set => _topLevel.Content = value; |
|||
} |
|||
|
|||
public bool IsComposing { get; private set; } |
|||
|
|||
internal INativeControlHostImpl GetNativeControlHostImpl() |
|||
{ |
|||
return new BrowserNativeControlHost(_nativeControlsContainer); |
|||
} |
|||
|
|||
private void ForceBlit() |
|||
{ |
|||
// Note: this is technically a hack, but it's a kinda unique use case when
|
|||
// we want to blit the previous frame
|
|||
// renderer doesn't have much control over the render target
|
|||
// we render on the UI thread
|
|||
// We also don't want to have it as a meaningful public API.
|
|||
// Therefore we have InternalsVisibleTo hack here.
|
|||
|
|||
if (_topLevel.Renderer is CompositingRenderer dr) |
|||
{ |
|||
dr.CompositionTarget.ImmediateUIThreadRender(); |
|||
} |
|||
} |
|||
|
|||
private void OnDpiChanged(double oldDpi, double newDpi) |
|||
{ |
|||
if (Math.Abs(_dpi - newDpi) > 0.0001) |
|||
{ |
|||
_dpi = newDpi; |
|||
|
|||
CanvasHelper.SetCanvasSize(_canvas, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi)); |
|||
|
|||
_topLevelImpl.SetClientSize(_canvasSize, _dpi); |
|||
|
|||
ForceBlit(); |
|||
} |
|||
} |
|||
|
|||
private void OnSizeChanged(int height, int width) |
|||
{ |
|||
var newSize = new Size(height, width); |
|||
|
|||
if (_canvasSize != newSize) |
|||
{ |
|||
_canvasSize = newSize; |
|||
|
|||
CanvasHelper.SetCanvasSize(_canvas, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi)); |
|||
|
|||
_topLevelImpl.SetClientSize(_canvasSize, _dpi); |
|||
|
|||
ForceBlit(); |
|||
} |
|||
} |
|||
|
|||
private void HideIme() |
|||
{ |
|||
InputHelper.HideElement(_inputElement); |
|||
InputHelper.FocusElement(_containerElement); |
|||
} |
|||
|
|||
public void SetClient(ITextInputMethodClient? client) |
|||
{ |
|||
Console.WriteLine("Set Client"); |
|||
if (_client != null) |
|||
{ |
|||
_client.SurroundingTextChanged -= SurroundingTextChanged; |
|||
} |
|||
|
|||
if (client != null) |
|||
{ |
|||
client.SurroundingTextChanged += SurroundingTextChanged; |
|||
} |
|||
|
|||
InputHelper.ClearInputElement(_inputElement); |
|||
|
|||
_client = client; |
|||
|
|||
if (_client != null) |
|||
{ |
|||
InputHelper.ShowElement(_inputElement); |
|||
InputHelper.FocusElement(_inputElement); |
|||
|
|||
var surroundingText = _client.SurroundingText; |
|||
|
|||
InputHelper.SetSurroundingText(_inputElement, surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); |
|||
|
|||
Console.WriteLine("Shown, focused and surrounded."); |
|||
} |
|||
else |
|||
{ |
|||
HideIme(); |
|||
} |
|||
} |
|||
|
|||
private void SurroundingTextChanged(object? sender, EventArgs e) |
|||
{ |
|||
if (_client != null) |
|||
{ |
|||
var surroundingText = _client.SurroundingText; |
|||
|
|||
InputHelper.SetSurroundingText(_inputElement, surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); |
|||
} |
|||
} |
|||
|
|||
public void SetCursorRect(Rect rect) |
|||
{ |
|||
InputHelper.FocusElement(_inputElement); |
|||
InputHelper.SetBounds(_inputElement, (int)rect.X, (int)rect.Y, (int)rect.Width, (int)rect.Height, _client?.SurroundingText.CursorOffset ?? 0); |
|||
InputHelper.FocusElement(_inputElement); |
|||
} |
|||
|
|||
public void SetOptions(TextInputOptions options) |
|||
{ |
|||
} |
|||
|
|||
public void Reset() |
|||
{ |
|||
InputHelper.ClearInputElement(_inputElement); |
|||
InputHelper.SetSurroundingText(_inputElement, "", 0, 0); |
|||
} |
|||
} |
|||
} |
|||
@ -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<IPlatformHandle, IPlatformHandle> 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)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.ApplicationLifetimes; |
|||
using Avalonia.Media; |
|||
using Avalonia.Web.Skia; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
|
|||
public class BrowserSingleViewLifetime : ISingleViewApplicationLifetime |
|||
{ |
|||
public AvaloniaView? View; |
|||
|
|||
public Control? MainView |
|||
{ |
|||
get => View!.Content; |
|||
set => View!.Content = value; |
|||
} |
|||
} |
|||
|
|||
public static partial class WebAppBuilder |
|||
{ |
|||
public static T SetupBrowserApp<T>( |
|||
this T builder, string mainDivId) |
|||
where T : AppBuilderBase<T>, new() |
|||
{ |
|||
var lifetime = new BrowserSingleViewLifetime(); |
|||
|
|||
return builder |
|||
.UseWindowingSubsystem(BrowserWindowingPlatform.Register) |
|||
.UseSkia() |
|||
.With(new SkiaOptions { CustomGpuFactory = () => new BrowserSkiaGpu() }) |
|||
.AfterSetup(b => |
|||
{ |
|||
lifetime.View = new AvaloniaView(mainDivId); |
|||
}) |
|||
.SetupWithLifetime(lifetime); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,226 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics; |
|||
using System.Linq; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.Platform; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Raw; |
|||
using Avalonia.Input.TextInput; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Rendering; |
|||
using Avalonia.Rendering.Composition; |
|||
using Avalonia.Web.Skia; |
|||
using Avalonia.Web.Storage; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
|
|||
internal class BrowserTopLevelImpl : ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider |
|||
{ |
|||
private Size _clientSize; |
|||
private IInputRoot? _inputRoot; |
|||
private readonly Stopwatch _sw = Stopwatch.StartNew(); |
|||
private readonly AvaloniaView _avaloniaView; |
|||
private readonly TouchDevice _touchDevice; |
|||
private readonly PenDevice _penDevice; |
|||
private string _currentCursor = CssCursor.Default; |
|||
|
|||
public BrowserTopLevelImpl(AvaloniaView avaloniaView) |
|||
{ |
|||
Surfaces = Enumerable.Empty<object>(); |
|||
_avaloniaView = avaloniaView; |
|||
TransparencyLevel = WindowTransparencyLevel.None; |
|||
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) |
|||
{ |
|||
if (Surfaces.FirstOrDefault() is BrowserSkiaSurface surface) |
|||
{ |
|||
surface.Scaling = dpi; |
|||
} |
|||
|
|||
ScalingChanged?.Invoke(dpi); |
|||
} |
|||
|
|||
if (newSize != _clientSize) |
|||
{ |
|||
_clientSize = newSize; |
|||
|
|||
if (Surfaces.FirstOrDefault() is BrowserSkiaSurface surface) |
|||
{ |
|||
surface.Size = new PixelSize((int)newSize.Width, (int)newSize.Height); |
|||
} |
|||
|
|||
Resized?.Invoke(newSize, PlatformResizeReason.User); |
|||
} |
|||
} |
|||
|
|||
public bool RawPointerEvent( |
|||
RawPointerEventType eventType, string pointerType, |
|||
RawPointerPoint p, RawInputModifiers modifiers, long touchPointId) |
|||
{ |
|||
if (_inputRoot is { } |
|||
&& Input is { } input) |
|||
{ |
|||
var device = GetPointerDevice(pointerType); |
|||
var args = device is TouchDevice ? |
|||
new RawTouchEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers, touchPointId) : |
|||
new RawPointerEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers) |
|||
{ |
|||
RawPointerId = touchPointId |
|||
}; |
|||
|
|||
input.Invoke(args); |
|||
|
|||
return args.Handled; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private IPointerDevice GetPointerDevice(string pointerType) |
|||
{ |
|||
return pointerType switch |
|||
{ |
|||
"touch" => _touchDevice, |
|||
"pen" => _penDevice, |
|||
_ => MouseDevice |
|||
}; |
|||
} |
|||
|
|||
public bool RawMouseWheelEvent(Point p, Vector v, RawInputModifiers modifiers) |
|||
{ |
|||
if (_inputRoot is { }) |
|||
{ |
|||
var args = new RawMouseWheelEventArgs(MouseDevice, Timestamp, _inputRoot, p, v, modifiers); |
|||
|
|||
Input?.Invoke(args); |
|||
|
|||
return args.Handled; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public bool RawKeyboardEvent(RawKeyEventType type, string code, string key, RawInputModifiers modifiers) |
|||
{ |
|||
if (Keycodes.KeyCodes.TryGetValue(code, out var avkey)) |
|||
{ |
|||
if (_inputRoot is { }) |
|||
{ |
|||
var args = new RawKeyEventArgs(KeyboardDevice, Timestamp, _inputRoot, type, avkey, modifiers); |
|||
|
|||
Input?.Invoke(args); |
|||
|
|||
return args.Handled; |
|||
} |
|||
} |
|||
else if (Keycodes.KeyCodes.TryGetValue(key, out avkey)) |
|||
{ |
|||
if (_inputRoot is { }) |
|||
{ |
|||
var args = new RawKeyEventArgs(KeyboardDevice, Timestamp, _inputRoot, type, avkey, modifiers); |
|||
|
|||
Input?.Invoke(args); |
|||
|
|||
return args.Handled; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public bool RawTextEvent(string text) |
|||
{ |
|||
if (_inputRoot is { }) |
|||
{ |
|||
var args = new RawTextInputEventArgs(KeyboardDevice, Timestamp, _inputRoot, text); |
|||
Input?.Invoke(args); |
|||
|
|||
return args.Handled; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
|
|||
} |
|||
|
|||
public IRenderer CreateRenderer(IRenderRoot root) |
|||
{ |
|||
var loop = AvaloniaLocator.Current.GetRequiredService<IRenderLoop>(); |
|||
return new CompositingRenderer(root, new Compositor(loop, null)); |
|||
} |
|||
|
|||
public void Invalidate(Rect rect) |
|||
{ |
|||
//Console.WriteLine("invalidate rect called");
|
|||
} |
|||
|
|||
public void SetInputRoot(IInputRoot inputRoot) |
|||
{ |
|||
_inputRoot = inputRoot; |
|||
} |
|||
|
|||
public Point PointToClient(PixelPoint point) => new Point(point.X, point.Y); |
|||
|
|||
public PixelPoint PointToScreen(Point point) => new PixelPoint((int)point.X, (int)point.Y); |
|||
|
|||
public void SetCursor(ICursorImpl? cursor) |
|||
{ |
|||
var val = (cursor as CssCursor)?.Value ?? CssCursor.Default; |
|||
if (_currentCursor != val) |
|||
{ |
|||
SetCssCursor?.Invoke(val); |
|||
_currentCursor = val; |
|||
} |
|||
} |
|||
|
|||
public IPopupImpl? CreatePopup() |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel) |
|||
{ |
|||
|
|||
} |
|||
|
|||
public Size ClientSize => _clientSize; |
|||
public Size? FrameSize => null; |
|||
public double RenderScaling => (Surfaces.FirstOrDefault() as BrowserSkiaSurface)?.Scaling ?? 1; |
|||
|
|||
public IEnumerable<object> Surfaces { get; set; } |
|||
|
|||
public Action<string>? SetCssCursor { get; set; } |
|||
public Action<RawInputEventArgs>? Input { get; set; } |
|||
public Action<Rect>? Paint { get; set; } |
|||
public Action<Size, PlatformResizeReason>? Resized { get; set; } |
|||
public Action<double>? ScalingChanged { get; set; } |
|||
public Action<WindowTransparencyLevel>? TransparencyLevelChanged { get; set; } |
|||
public Action? Closed { get; set; } |
|||
public Action? LostFocus { get; set; } |
|||
public IMouseDevice MouseDevice { get; } = new MouseDevice(); |
|||
|
|||
public IKeyboardDevice KeyboardDevice { get; } = BrowserWindowingPlatform.Keyboard; |
|||
public WindowTransparencyLevel TransparencyLevel { get; } |
|||
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } |
|||
|
|||
public ITextInputMethodImpl TextInputMethod => _avaloniaView; |
|||
|
|||
public INativeControlHostImpl? NativeControlHost { get; } |
|||
public IStorageProvider StorageProvider { get; } = new BrowserStorageProvider(); |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Platform; |
|||
using Avalonia.Web.Interop; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
internal class ClipboardImpl : IClipboard |
|||
{ |
|||
public Task<string> GetTextAsync() |
|||
{ |
|||
return InputHelper.ReadClipboardTextAsync(); |
|||
} |
|||
|
|||
public Task SetTextAsync(string text) |
|||
{ |
|||
return InputHelper.WriteClipboardTextAsync(text); |
|||
} |
|||
|
|||
public async Task ClearAsync() => await SetTextAsync(""); |
|||
|
|||
public Task SetDataObjectAsync(IDataObject data) => Task.CompletedTask; |
|||
|
|||
public Task<string[]> GetFormatsAsync() => Task.FromResult(Array.Empty<string>()); |
|||
|
|||
public Task<object> GetDataAsync(string format) => Task.FromResult<object>(new()); |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
using System; |
|||
using System.IO; |
|||
using Avalonia.Input; |
|||
using Avalonia.Platform; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
public class CssCursor : ICursorImpl |
|||
{ |
|||
public const string Default = "default"; |
|||
public string? Value { get; set; } |
|||
|
|||
public CssCursor(StandardCursorType type) |
|||
{ |
|||
Value = ToKeyword(type); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Create a cursor from base64 image
|
|||
/// </summary>
|
|||
public CssCursor(string base64, string format, PixelPoint hotspot, StandardCursorType fallback) |
|||
{ |
|||
Value = $"url(\"data:image/{format};base64,{base64}\") {hotspot.X} {hotspot.Y}, {ToKeyword(fallback)}"; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Create a cursor from url to *.cur file.
|
|||
/// </summary>
|
|||
public CssCursor(string url, StandardCursorType fallback) |
|||
{ |
|||
Value = $"url('{url}'), {ToKeyword(fallback)}"; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Create a cursor from png/svg and hotspot position
|
|||
/// </summary>
|
|||
public CssCursor(string url, PixelPoint hotSpot, StandardCursorType fallback) |
|||
{ |
|||
Value = $"url('{url}') {hotSpot.X} {hotSpot.Y}, {ToKeyword(fallback)}"; |
|||
} |
|||
|
|||
private static string ToKeyword(StandardCursorType type) => type switch |
|||
{ |
|||
StandardCursorType.Hand => "pointer", |
|||
StandardCursorType.Cross => "crosshair", |
|||
StandardCursorType.Help => "help", |
|||
StandardCursorType.Ibeam => "text", |
|||
StandardCursorType.No => "not-allowed", |
|||
StandardCursorType.None => "none", |
|||
StandardCursorType.Wait => "progress", |
|||
StandardCursorType.AppStarting => "wait", |
|||
|
|||
StandardCursorType.DragMove => "move", |
|||
StandardCursorType.DragCopy => "copy", |
|||
StandardCursorType.DragLink => "alias", |
|||
|
|||
StandardCursorType.UpArrow => "default",/*not found matching one*/ |
|||
StandardCursorType.SizeWestEast => "ew-resize", |
|||
StandardCursorType.SizeNorthSouth => "ns-resize", |
|||
StandardCursorType.SizeAll => "move", |
|||
|
|||
StandardCursorType.TopSide => "n-resize", |
|||
StandardCursorType.BottomSide => "s-resize", |
|||
StandardCursorType.LeftSide => "w-resize", |
|||
StandardCursorType.RightSide => "e-resize", |
|||
StandardCursorType.TopLeftCorner => "nw-resize", |
|||
StandardCursorType.TopRightCorner => "ne-resize", |
|||
StandardCursorType.BottomLeftCorner => "sw-resize", |
|||
StandardCursorType.BottomRightCorner => "se-resize", |
|||
|
|||
_ => Default, |
|||
}; |
|||
|
|||
public void Dispose() {} |
|||
} |
|||
|
|||
internal class CssCursorFactory : ICursorFactory |
|||
{ |
|||
public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot) |
|||
{ |
|||
using var imageStream = new MemoryStream(); |
|||
cursor.Save(imageStream); |
|||
|
|||
//not memory optimized because CryptoStream with ToBase64Transform is not supported in the browser.
|
|||
var base64String = Convert.ToBase64String(imageStream.ToArray()); |
|||
return new CssCursor(base64String, "png", hotSpot, StandardCursorType.Arrow); |
|||
} |
|||
|
|||
ICursorImpl ICursorFactory.GetCursor(StandardCursorType cursorType) |
|||
{ |
|||
return new CssCursor(cursorType); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,43 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
|
|||
namespace Avalonia.Web.Interop; |
|||
|
|||
internal record GLInfo(int ContextId, uint FboId, int Stencils, int Samples, int Depth); |
|||
|
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] // gets rid of callsite warnings
|
|||
internal static partial class CanvasHelper |
|||
{ |
|||
|
|||
[DllImport("libSkiaSharp", CallingConvention = CallingConvention.Cdecl)] |
|||
static extern JSObject InterceptGLObject(); |
|||
|
|||
public static GLInfo InitialiseGL(JSObject canvas, Action renderFrameCallback) |
|||
{ |
|||
InterceptGLObject(); |
|||
|
|||
var info = InitGL(canvas, canvas.GetPropertyAsString("id")!, renderFrameCallback); |
|||
|
|||
var glInfo = new GLInfo( |
|||
info.GetPropertyAsInt32("context"), |
|||
(uint)info.GetPropertyAsInt32("fboId"), |
|||
info.GetPropertyAsInt32("stencil"), |
|||
info.GetPropertyAsInt32("sample"), |
|||
info.GetPropertyAsInt32("depth")); |
|||
|
|||
return glInfo; |
|||
} |
|||
|
|||
[JSImport("Canvas.requestAnimationFrame", "avalonia.ts")] |
|||
public static partial void RequestAnimationFrame(JSObject canvas, bool renderLoop); |
|||
|
|||
[JSImport("Canvas.setCanvasSize", "avalonia.ts")] |
|||
public static partial void SetCanvasSize(JSObject canvas, int height, int width); |
|||
|
|||
[JSImport("Canvas.initGL", "avalonia.ts")] |
|||
private static partial JSObject InitGL( |
|||
JSObject canvas, |
|||
string canvasId, |
|||
[JSMarshalAs<JSType.Function>] Action renderFrameCallback); |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
|
|||
namespace Avalonia.Web.Interop; |
|||
|
|||
internal static partial class DomHelper |
|||
{ |
|||
[JSImport("globalThis.document.getElementById")] |
|||
internal static partial JSObject? GetElementById(string id); |
|||
|
|||
[JSImport("AvaloniaDOM.createAvaloniaHost", "avalonia.ts")] |
|||
public static partial JSObject CreateAvaloniaHost(JSObject element); |
|||
|
|||
[JSImport("AvaloniaDOM.addClass", "avalonia.ts")] |
|||
public static partial void AddCssClass(JSObject element, string className); |
|||
|
|||
[JSImport("SizeWatcher.observe", "avalonia.ts")] |
|||
public static partial JSObject ObserveSize( |
|||
JSObject canvas, |
|||
string canvasId, |
|||
[JSMarshalAs<JSType.Function<JSType.Number, JSType.Number>>] |
|||
Action<int, int> onSizeChanged); |
|||
|
|||
[JSImport("DpiWatcher.start", "avalonia.ts")] |
|||
public static partial double ObserveDpi( |
|||
[JSMarshalAs<JSType.Function<JSType.Number, JSType.Number>>] |
|||
Action<double, double> onDpiChanged); |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Web.Interop; |
|||
|
|||
internal static partial class InputHelper |
|||
{ |
|||
[JSImport("InputHelper.subscribeKeyEvents", "avalonia.ts")] |
|||
public static partial void SubscribeKeyEvents( |
|||
JSObject htmlElement, |
|||
[JSMarshalAs<JSType.Function<JSType.String, JSType.String, JSType.Number, JSType.Boolean>>] |
|||
Func<string, string, int, bool> keyDown, |
|||
[JSMarshalAs<JSType.Function<JSType.String, JSType.String, JSType.Number, JSType.Boolean>>] |
|||
Func<string, string, int, bool> keyUp); |
|||
|
|||
[JSImport("InputHelper.subscribeTextEvents", "avalonia.ts")] |
|||
public static partial void SubscribeTextEvents( |
|||
JSObject htmlElement, |
|||
[JSMarshalAs<JSType.Function<JSType.String, JSType.String, JSType.Boolean>>] |
|||
Func<string, string?, bool> onInput, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> onCompositionStart, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> onCompositionUpdate, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> onCompositionEnd); |
|||
|
|||
[JSImport("InputHelper.subscribePointerEvents", "avalonia.ts")] |
|||
public static partial void SubscribePointerEvents( |
|||
JSObject htmlElement, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> pointerMove, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> pointerDown, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> pointerUp, |
|||
[JSMarshalAs<JSType.Function<JSType.Object, JSType.Boolean>>] |
|||
Func<JSObject, bool> wheel); |
|||
|
|||
|
|||
[JSImport("InputHelper.subscribeInputEvents", "avalonia.ts")] |
|||
public static partial void SubscribeInputEvents( |
|||
JSObject htmlElement, |
|||
[JSMarshalAs<JSType.Function<JSType.String, JSType.Boolean>>] |
|||
Func<string, bool> input); |
|||
|
|||
|
|||
[JSImport("InputHelper.clearInput", "avalonia.ts")] |
|||
public static partial void ClearInputElement(JSObject htmlElement); |
|||
|
|||
[JSImport("InputHelper.isInputElement", "avalonia.ts")] |
|||
public static partial void IsInputElement(JSObject htmlElement); |
|||
|
|||
[JSImport("InputHelper.focusElement", "avalonia.ts")] |
|||
public static partial void FocusElement(JSObject htmlElement); |
|||
|
|||
[JSImport("InputHelper.setCursor", "avalonia.ts")] |
|||
public static partial void SetCursor(JSObject htmlElement, string kind); |
|||
|
|||
[JSImport("InputHelper.hide", "avalonia.ts")] |
|||
public static partial void HideElement(JSObject htmlElement); |
|||
|
|||
[JSImport("InputHelper.show", "avalonia.ts")] |
|||
public static partial void ShowElement(JSObject htmlElement); |
|||
|
|||
[JSImport("InputHelper.setSurroundingText", "avalonia.ts")] |
|||
public static partial void SetSurroundingText(JSObject htmlElement, string text, int start, int end); |
|||
|
|||
[JSImport("InputHelper.setBounds", "avalonia.ts")] |
|||
public static partial void SetBounds(JSObject htmlElement, int x, int y, int width, int height, int caret); |
|||
|
|||
[JSImport("globalThis.navigator.clipboard.readText")] |
|||
public static partial Task<string> ReadClipboardTextAsync(); |
|||
|
|||
[JSImport("globalThis.navigator.clipboard.writeText")] |
|||
public static partial Task WriteClipboardTextAsync(string text); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Web.Interop; |
|||
|
|||
internal static partial class StorageHelper |
|||
{ |
|||
[JSImport("Caniuse.canShowOpenFilePicker", "avalonia.ts")] |
|||
public static partial bool CanShowOpenFilePicker(); |
|||
|
|||
[JSImport("Caniuse.canShowSaveFilePicker", "avalonia.ts")] |
|||
public static partial bool CanShowSaveFilePicker(); |
|||
|
|||
[JSImport("Caniuse.canShowDirectoryPicker", "avalonia.ts")] |
|||
public static partial bool CanShowDirectoryPicker(); |
|||
|
|||
[JSImport("StorageProvider.selectFolderDialog", "storage.ts")] |
|||
public static partial Task<JSObject?> SelectFolderDialog(JSObject? startIn); |
|||
|
|||
[JSImport("StorageProvider.openFileDialog", "storage.ts")] |
|||
public static partial Task<JSObject?> OpenFileDialog(JSObject? startIn, bool multiple, |
|||
[JSMarshalAs<JSType.Array<JSType.Any>>] object[]? types, bool excludeAcceptAllOption); |
|||
|
|||
[JSImport("StorageProvider.saveFileDialog", "storage.ts")] |
|||
public static partial Task<JSObject?> SaveFileDialog(JSObject? startIn, string? suggestedName, |
|||
[JSMarshalAs<JSType.Array<JSType.Any>>] object[]? types, bool excludeAcceptAllOption); |
|||
|
|||
[JSImport("StorageProvider.openBookmark", "storage.ts")] |
|||
public static partial Task<JSObject?> OpenBookmark(string key); |
|||
|
|||
[JSImport("StorageItem.saveBookmark", "storage.ts")] |
|||
public static partial Task<string?> SaveBookmark(JSObject item); |
|||
|
|||
[JSImport("StorageItem.deleteBookmark", "storage.ts")] |
|||
public static partial Task DeleteBookmark(JSObject item); |
|||
|
|||
[JSImport("StorageItem.getProperties", "storage.ts")] |
|||
public static partial Task<JSObject?> GetProperties(JSObject item); |
|||
|
|||
[JSImport("StorageItem.openWrite", "storage.ts")] |
|||
public static partial Task<JSObject> OpenWrite(JSObject item); |
|||
|
|||
[JSImport("StorageItem.openRead", "storage.ts")] |
|||
public static partial Task<JSObject> OpenRead(JSObject item); |
|||
|
|||
[JSImport("StorageItem.getItems", "storage.ts")] |
|||
[return: JSMarshalAs<JSType.Promise<JSType.Object>>] |
|||
public static partial Task<JSObject> GetItems(JSObject item); |
|||
|
|||
[JSImport("StorageItems.itemsArray", "storage.ts")] |
|||
public static partial JSObject[] ItemsArray(JSObject item); |
|||
|
|||
[JSImport("StorageProvider.createAcceptType", "storage.ts")] |
|||
public static partial JSObject CreateAcceptType(string description, string[] mimeTypes); |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Web.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Set of FileSystemWritableFileStream and Blob methods.
|
|||
/// </summary>
|
|||
internal static partial class StreamHelper |
|||
{ |
|||
[JSImport("StreamHelper.seek", "avalonia.ts")] |
|||
public static partial void Seek(JSObject stream, [JSMarshalAs<JSType.Number>] long position); |
|||
|
|||
[JSImport("StreamHelper.truncate", "avalonia.ts")] |
|||
public static partial void Truncate(JSObject stream, [JSMarshalAs<JSType.Number>] long size); |
|||
|
|||
[JSImport("StreamHelper.write", "avalonia.ts")] |
|||
public static partial Task WriteAsync(JSObject stream, [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> data); |
|||
|
|||
[JSImport("StreamHelper.close", "avalonia.ts")] |
|||
public static partial Task CloseAsync(JSObject stream); |
|||
|
|||
[JSImport("StreamHelper.byteLength", "avalonia.ts")] |
|||
[return: JSMarshalAs<JSType.Number>] |
|||
public static partial long ByteLength(JSObject stream); |
|||
|
|||
[JSImport("StreamHelper.sliceArrayBuffer", "avalonia.ts")] |
|||
private static partial Task<JSObject> SliceToArrayBuffer(JSObject stream, [JSMarshalAs<JSType.Number>] long offset, int count); |
|||
|
|||
[JSImport("StreamHelper.toMemoryView", "avalonia.ts")] |
|||
[return: JSMarshalAs<JSType.Array<JSType.Number>>] |
|||
private static partial byte[] ArrayBufferToMemoryView(JSObject stream); |
|||
|
|||
public static async Task<byte[]> SliceAsync(JSObject stream, long offset, int count) |
|||
{ |
|||
using var buffer = await SliceToArrayBuffer(stream, offset, count); |
|||
return ArrayBufferToMemoryView(buffer); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,129 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
using Avalonia.Input; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
internal static class Keycodes |
|||
{ |
|||
public static Dictionary<string, Key> KeyCodes = new() |
|||
{ |
|||
{ "Escape", Key.Escape }, |
|||
{ "Digit1", Key.D1 }, |
|||
{ "Digit2", Key.D2 }, |
|||
{ "Digit3", Key.D3 }, |
|||
{ "Digit4", Key.D4 }, |
|||
{ "Digit5", Key.D5 }, |
|||
{ "Digit6", Key.D6 }, |
|||
{ "Digit7", Key.D7 }, |
|||
{ "Digit8", Key.D8 }, |
|||
{ "Digit9", Key.D9 }, |
|||
{ "Digit0", Key.D0 }, |
|||
{ "Minus", Key.OemMinus }, |
|||
//{ "Equal" , Key. },
|
|||
{ "Backspace", Key.Back }, |
|||
{ "Tab", Key.Tab }, |
|||
{ "KeyQ", Key.Q }, |
|||
{ "KeyW", Key.W }, |
|||
{ "KeyE", Key.E }, |
|||
{ "KeyR", Key.R }, |
|||
{ "KeyT", Key.T }, |
|||
{ "KeyY", Key.Y }, |
|||
{ "KeyU", Key.U }, |
|||
{ "KeyI", Key.I }, |
|||
{ "KeyO", Key.O }, |
|||
{ "KeyP", Key.P }, |
|||
{ "BracketLeft", Key.OemOpenBrackets }, |
|||
{ "BracketRight", Key.OemCloseBrackets }, |
|||
{ "Enter", Key.Enter }, |
|||
{ "ControlLeft", Key.LeftCtrl }, |
|||
{ "KeyA", Key.A }, |
|||
{ "KeyS", Key.S }, |
|||
{ "KeyD", Key.D }, |
|||
{ "KeyF", Key.F }, |
|||
{ "KeyG", Key.G }, |
|||
{ "KeyH", Key.H }, |
|||
{ "KeyJ", Key.J }, |
|||
{ "KeyK", Key.K }, |
|||
{ "KeyL", Key.L }, |
|||
{ "Semicolon", Key.OemSemicolon }, |
|||
{ "Quote", Key.OemQuotes }, |
|||
//{ "Backquote" , Key. },
|
|||
{ "ShiftLeft", Key.LeftShift }, |
|||
{ "Backslash", Key.OemBackslash }, |
|||
{ "KeyZ", Key.Z }, |
|||
{ "KeyX", Key.X }, |
|||
{ "KeyC", Key.C }, |
|||
{ "KeyV", Key.V }, |
|||
{ "KeyB", Key.B }, |
|||
{ "KeyN", Key.N }, |
|||
{ "KeyM", Key.M }, |
|||
{ "Comma", Key.OemComma }, |
|||
{ "Period", Key.OemPeriod }, |
|||
//{ "Slash" , Key. },
|
|||
{ "ShiftRight", Key.RightShift }, |
|||
{ "NumpadMultiply", Key.Multiply }, |
|||
{ "AltLeft", Key.LeftAlt }, |
|||
{ "Space", Key.Space }, |
|||
{ "CapsLock", Key.CapsLock }, |
|||
{ "F1", Key.F1 }, |
|||
{ "F2", Key.F2 }, |
|||
{ "F3", Key.F3 }, |
|||
{ "F4", Key.F4 }, |
|||
{ "F5", Key.F5 }, |
|||
{ "F6", Key.F6 }, |
|||
{ "F7", Key.F7 }, |
|||
{ "F8", Key.F8 }, |
|||
{ "F9", Key.F9 }, |
|||
{ "F10", Key.F10 }, |
|||
{ "NumLock", Key.NumLock }, |
|||
{ "ScrollLock", Key.Scroll }, |
|||
{ "Numpad7", Key.NumPad7 }, |
|||
{ "Numpad8", Key.NumPad8 }, |
|||
{ "Numpad9", Key.NumPad9 }, |
|||
{ "NumpadSubtract", Key.Subtract }, |
|||
{ "Numpad4", Key.NumPad4 }, |
|||
{ "Numpad5", Key.NumPad5 }, |
|||
{ "Numpad6", Key.NumPad6 }, |
|||
{ "NumpadAdd", Key.Add }, |
|||
{ "Numpad1", Key.NumPad1 }, |
|||
{ "Numpad2", Key.NumPad2 }, |
|||
{ "Numpad3", Key.NumPad3 }, |
|||
{ "Numpad0", Key.NumPad0 }, |
|||
{ "NumpadDecimal", Key.Decimal }, |
|||
{ "Unidentified", Key.NoName }, |
|||
//{ "IntlBackslash" , Key.bac },
|
|||
{ "F11", Key.F11 }, |
|||
{ "F12", Key.F12 }, |
|||
//{ "IntlRo" , Key.Ro },
|
|||
//{ "Unidentified" , Key. },
|
|||
{ "Convert", Key.ImeConvert }, |
|||
{ "KanaMode", Key.KanaMode }, |
|||
{ "NonConvert", Key.ImeNonConvert }, |
|||
//{ "Unidentified" , Key. },
|
|||
{ "NumpadEnter", Key.Enter }, |
|||
{ "ControlRight", Key.RightCtrl }, |
|||
{ "NumpadDivide", Key.Divide }, |
|||
{ "PrintScreen", Key.PrintScreen }, |
|||
{ "AltRight", Key.RightAlt }, |
|||
//{ "Unidentified" , Key. },
|
|||
{ "Home", Key.Home }, |
|||
{ "ArrowUp", Key.Up }, |
|||
{ "PageUp", Key.PageUp }, |
|||
{ "ArrowLeft", Key.Left }, |
|||
{ "ArrowRight", Key.Right }, |
|||
{ "End", Key.End }, |
|||
{ "ArrowDown", Key.Down }, |
|||
{ "PageDown", Key.PageDown }, |
|||
{ "Insert", Key.Insert }, |
|||
{ "Delete", Key.Delete }, |
|||
//{ "Unidentified" , Key. },
|
|||
{ "AudioVolumeMute", Key.VolumeMute }, |
|||
{ "AudioVolumeDown", Key.VolumeDown }, |
|||
{ "AudioVolumeUp", Key.VolumeUp }, |
|||
//{ "NumpadEqual" , Key. },
|
|||
{ "Pause", Key.Pause }, |
|||
{ "NumpadComma", Key.OemComma } |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
using System; |
|||
using System.Diagnostics; |
|||
using Avalonia.Rendering; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
public class ManualTriggerRenderTimer : IRenderTimer |
|||
{ |
|||
private static readonly Stopwatch s_sw = Stopwatch.StartNew(); |
|||
|
|||
public static ManualTriggerRenderTimer Instance { get; } = new(); |
|||
|
|||
public void RaiseTick() => Tick?.Invoke(s_sw.Elapsed); |
|||
|
|||
public event Action<TimeSpan>? Tick; |
|||
public bool RunsInBackground => false; |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
using System.Collections.Generic; |
|||
using Avalonia.Skia; |
|||
|
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
public class BrowserSkiaGpu : ISkiaGpu |
|||
{ |
|||
public ISkiaGpuRenderTarget? TryCreateRenderTarget(IEnumerable<object> surfaces) |
|||
{ |
|||
foreach (var surface in surfaces) |
|||
{ |
|||
if (surface is BrowserSkiaSurface browserSkiaSurface) |
|||
{ |
|||
return new BrowserSkiaGpuRenderTarget(browserSkiaSurface); |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public ISkiaSurface? TryCreateSurface(PixelSize size, ISkiaGpuRenderSession session) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
using Avalonia.Skia; |
|||
using SkiaSharp; |
|||
|
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
internal class BrowserSkiaGpuRenderSession : ISkiaGpuRenderSession |
|||
{ |
|||
private readonly SKSurface _surface; |
|||
|
|||
public BrowserSkiaGpuRenderSession(BrowserSkiaSurface browserSkiaSurface, GRBackendRenderTarget renderTarget) |
|||
{ |
|||
_surface = SKSurface.Create(browserSkiaSurface.Context, renderTarget, browserSkiaSurface.Origin, browserSkiaSurface.ColorType); |
|||
|
|||
GrContext = browserSkiaSurface.Context; |
|||
|
|||
ScaleFactor = browserSkiaSurface.Scaling; |
|||
|
|||
SurfaceOrigin = browserSkiaSurface.Origin; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_surface.Flush(); |
|||
|
|||
_surface.Dispose(); |
|||
} |
|||
|
|||
public GRContext GrContext { get; } |
|||
|
|||
public SKSurface SkSurface => _surface; |
|||
|
|||
public double ScaleFactor { get; } |
|||
|
|||
public GRSurfaceOrigin SurfaceOrigin { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
using Avalonia.Skia; |
|||
using SkiaSharp; |
|||
|
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
internal class BrowserSkiaGpuRenderTarget : ISkiaGpuRenderTarget |
|||
{ |
|||
private readonly GRBackendRenderTarget _renderTarget; |
|||
private readonly BrowserSkiaSurface _browserSkiaSurface; |
|||
private readonly PixelSize _size; |
|||
|
|||
public BrowserSkiaGpuRenderTarget(BrowserSkiaSurface browserSkiaSurface) |
|||
{ |
|||
_size = browserSkiaSurface.Size; |
|||
|
|||
var glFbInfo = new GRGlFramebufferInfo(browserSkiaSurface.GlInfo.FboId, browserSkiaSurface.ColorType.ToGlSizedFormat()); |
|||
{ |
|||
_browserSkiaSurface = browserSkiaSurface; |
|||
_renderTarget = new GRBackendRenderTarget( |
|||
(int)(browserSkiaSurface.Size.Width * browserSkiaSurface.Scaling), |
|||
(int)(browserSkiaSurface.Size.Height * browserSkiaSurface.Scaling), |
|||
browserSkiaSurface.GlInfo.Samples, |
|||
browserSkiaSurface.GlInfo.Stencils, glFbInfo); |
|||
} |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_renderTarget.Dispose(); |
|||
} |
|||
|
|||
public ISkiaGpuRenderSession BeginRenderingSession() |
|||
{ |
|||
return new BrowserSkiaGpuRenderSession(_browserSkiaSurface, _renderTarget); |
|||
} |
|||
|
|||
public bool IsCorrupted => _browserSkiaSurface.Size != _size; |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
using System; |
|||
using System.Runtime.InteropServices; |
|||
using Avalonia.Controls.Platform.Surfaces; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Skia; |
|||
using SkiaSharp; |
|||
|
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
internal class BrowserSkiaRasterSurface : IBrowserSkiaSurface, IFramebufferPlatformSurface, IDisposable |
|||
{ |
|||
public SKColorType ColorType { get; set; } |
|||
|
|||
public PixelSize Size { get; set; } |
|||
|
|||
public double Scaling { get; set; } |
|||
|
|||
private FramebufferData? _fbData; |
|||
private readonly Action<IntPtr, SKSizeI> _blitCallback; |
|||
private readonly Action _onDisposeAction; |
|||
|
|||
public BrowserSkiaRasterSurface( |
|||
SKColorType colorType, PixelSize size, double scaling, Action<IntPtr, SKSizeI> blitCallback) |
|||
{ |
|||
ColorType = colorType; |
|||
Size = size; |
|||
Scaling = scaling; |
|||
_blitCallback = blitCallback; |
|||
_onDisposeAction = Blit; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_fbData?.Dispose(); |
|||
_fbData = null; |
|||
} |
|||
|
|||
public ILockedFramebuffer Lock() |
|||
{ |
|||
var bytesPerPixel = 4; // TODO: derive from ColorType
|
|||
var dpi = Scaling * 96.0; |
|||
var width = (int)(Size.Width * Scaling); |
|||
var height = (int)(Size.Height * Scaling); |
|||
|
|||
if (_fbData is null || _fbData?.Size.Width != width || _fbData?.Size.Height != height) |
|||
{ |
|||
_fbData?.Dispose(); |
|||
_fbData = new FramebufferData(width, height, bytesPerPixel); |
|||
} |
|||
|
|||
var pixelFormat = ColorType.ToPixelFormat(); |
|||
var data = _fbData.Value; |
|||
return new LockedFramebuffer( |
|||
data.Address, data.Size, data.RowBytes, |
|||
new Vector(dpi, dpi), pixelFormat, _onDisposeAction); |
|||
} |
|||
|
|||
private void Blit() |
|||
{ |
|||
if (_fbData != null) |
|||
{ |
|||
var data = _fbData.Value; |
|||
_blitCallback(data.Address, new SKSizeI(data.Size.Width, data.Size.Height)); |
|||
} |
|||
} |
|||
|
|||
private readonly struct FramebufferData |
|||
{ |
|||
public PixelSize Size { get; } |
|||
|
|||
public int RowBytes { get; } |
|||
|
|||
public IntPtr Address { get; } |
|||
|
|||
public FramebufferData(int width, int height, int bytesPerPixel) |
|||
{ |
|||
Size = new PixelSize(width, height); |
|||
RowBytes = width * bytesPerPixel; |
|||
Address = Marshal.AllocHGlobal(width * height * bytesPerPixel); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Marshal.FreeHGlobal(Address); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
using Avalonia.Web.Interop; |
|||
using SkiaSharp; |
|||
|
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
internal class BrowserSkiaSurface : IBrowserSkiaSurface |
|||
{ |
|||
public BrowserSkiaSurface(GRContext context, GLInfo glInfo, SKColorType colorType, PixelSize size, double scaling, GRSurfaceOrigin origin) |
|||
{ |
|||
Context = context; |
|||
GlInfo = glInfo; |
|||
ColorType = colorType; |
|||
Size = size; |
|||
Scaling = scaling; |
|||
Origin = origin; |
|||
} |
|||
|
|||
public SKColorType ColorType { get; set; } |
|||
|
|||
public PixelSize Size { get; set; } |
|||
|
|||
public GRContext Context { get; set; } |
|||
|
|||
public GRSurfaceOrigin Origin { get; set; } |
|||
|
|||
public double Scaling { get; set; } |
|||
|
|||
public GLInfo GlInfo { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
namespace Avalonia.Web.Skia |
|||
{ |
|||
internal interface IBrowserSkiaSurface |
|||
{ |
|||
public PixelSize Size { get; set; } |
|||
|
|||
public double Scaling { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Web.Storage; |
|||
|
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] |
|||
internal class BlobReadableStream : Stream |
|||
{ |
|||
private JSObject? _jSReference; |
|||
private long _position; |
|||
private readonly long _length; |
|||
|
|||
public BlobReadableStream(JSObject jsStreamReference) |
|||
{ |
|||
_jSReference = jsStreamReference; |
|||
_position = 0; |
|||
_length = StreamHelper.ByteLength(JSReference); |
|||
} |
|||
|
|||
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(WriteableStream)); |
|||
|
|||
public override bool CanRead => true; |
|||
|
|||
public override bool CanSeek => false; |
|||
|
|||
public override bool CanWrite => false; |
|||
|
|||
public override long Length => _length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _position; |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() { } |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
return _position = origin switch |
|||
{ |
|||
SeekOrigin.Current => _position + offset, |
|||
SeekOrigin.End => _length + offset, |
|||
_ => offset |
|||
}; |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
=> throw new NotSupportedException(); |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
=> throw new NotSupportedException(); |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new InvalidOperationException("Browser supports only ReadAsync"); |
|||
} |
|||
|
|||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
=> await ReadAsync(buffer.AsMemory(offset, count), cancellationToken); |
|||
|
|||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) |
|||
{ |
|||
var numBytesToRead = (int)Math.Min(buffer.Length, Length - _position); |
|||
var bytesRead = await StreamHelper.SliceAsync(JSReference, _position, numBytesToRead); |
|||
if (bytesRead.Length != numBytesToRead) |
|||
{ |
|||
throw new EndOfStreamException("Failed to read the requested number of bytes from the stream."); |
|||
} |
|||
|
|||
_position += bytesRead.Length; |
|||
bytesRead.CopyTo(buffer); |
|||
|
|||
return bytesRead.Length; |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (_jSReference is { } jsReference) |
|||
{ |
|||
_jSReference = null; |
|||
jsReference.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,257 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Runtime.Versioning; |
|||
using System.Threading.Tasks; |
|||
|
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Web.Interop; |
|||
|
|||
namespace Avalonia.Web.Storage; |
|||
|
|||
internal record FilePickerAcceptType(string Description, IReadOnlyDictionary<string, IReadOnlyList<string>> Accept); |
|||
|
|||
[SupportedOSPlatform("browser")] |
|||
internal class BrowserStorageProvider : IStorageProvider |
|||
{ |
|||
internal const string PickerCancelMessage = "The user aborted a request"; |
|||
internal const string NoPermissionsMessage = "Permissions denied"; |
|||
|
|||
private readonly Lazy<Task<JSObject>> _lazyModule = new(() => JSHost.ImportAsync("storage.ts", "./storage.js")); |
|||
|
|||
public bool CanOpen => StorageHelper.CanShowOpenFilePicker(); |
|||
public bool CanSave => StorageHelper.CanShowSaveFilePicker(); |
|||
public bool CanPickFolder => StorageHelper.CanShowDirectoryPicker(); |
|||
|
|||
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
_ = await _lazyModule.Value; |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
var (types, exludeAll) = ConvertFileTypes(options.FileTypeFilter); |
|||
|
|||
try |
|||
{ |
|||
using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, exludeAll); |
|||
if (items is null) |
|||
{ |
|||
return Array.Empty<IStorageFile>(); |
|||
} |
|||
|
|||
var itemsArray = StorageHelper.ItemsArray(items); |
|||
return itemsArray.Select(item => new JSStorageFile(item)).ToArray(); |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return Array.Empty<IStorageFile>(); |
|||
} |
|||
finally |
|||
{ |
|||
if (types is not null) |
|||
{ |
|||
foreach (var type in types) |
|||
{ |
|||
type.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
_ = await _lazyModule.Value; |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
var (types, exludeAll) = ConvertFileTypes(options.FileTypeChoices); |
|||
|
|||
try |
|||
{ |
|||
var item = await StorageHelper.SaveFileDialog(startIn, options.SuggestedFileName, types, exludeAll); |
|||
return item is not null ? new JSStorageFile(item) : null; |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return null; |
|||
} |
|||
finally |
|||
{ |
|||
if (types is not null) |
|||
{ |
|||
foreach (var type in types) |
|||
{ |
|||
type.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
_ = await _lazyModule.Value; |
|||
var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; |
|||
|
|||
try |
|||
{ |
|||
var item = await StorageHelper.SelectFolderDialog(startIn); |
|||
return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty<IStorageFolder>(); |
|||
} |
|||
catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) |
|||
{ |
|||
return Array.Empty<IStorageFolder>(); |
|||
} |
|||
} |
|||
|
|||
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
|||
{ |
|||
_ = await _lazyModule.Value; |
|||
var item = await StorageHelper.OpenBookmark(bookmark); |
|||
return item is not null ? new JSStorageFile(item) : null; |
|||
} |
|||
|
|||
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
|||
{ |
|||
_ = await _lazyModule.Value; |
|||
var item = await StorageHelper.OpenBookmark(bookmark); |
|||
return item is not null ? new JSStorageFolder(item) : null; |
|||
} |
|||
|
|||
private static (JSObject[]? types, bool excludeAllOption) ConvertFileTypes(IEnumerable<FilePickerFileType>? input) |
|||
{ |
|||
var types = input? |
|||
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All) |
|||
.Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray())) |
|||
.ToArray(); |
|||
if (types?.Length == 0) |
|||
{ |
|||
types = null; |
|||
} |
|||
|
|||
var inlcudeAll = input?.Contains(FilePickerFileTypes.All) == true || types is null; |
|||
|
|||
return (types, !inlcudeAll); |
|||
} |
|||
} |
|||
|
|||
internal abstract class JSStorageItem : IStorageBookmarkItem |
|||
{ |
|||
internal JSObject? _fileHandle; |
|||
|
|||
protected JSStorageItem(JSObject fileHandle) |
|||
{ |
|||
_fileHandle = fileHandle ?? throw new ArgumentNullException(nameof(fileHandle)); |
|||
} |
|||
|
|||
internal JSObject FileHandle => _fileHandle ?? throw new ObjectDisposedException(nameof(JSStorageItem)); |
|||
|
|||
public string Name => FileHandle.GetPropertyAsString("name") ?? string.Empty; |
|||
|
|||
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
|||
{ |
|||
uri = new Uri(Name, UriKind.Relative); |
|||
return false; |
|||
} |
|||
|
|||
public async Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
using var properties = await StorageHelper.GetProperties(FileHandle); |
|||
var size = (long?)properties?.GetPropertyAsDouble("Size"); |
|||
var lastModified = (long?)properties?.GetPropertyAsDouble("LastModified"); |
|||
|
|||
return new StorageItemProperties( |
|||
(ulong?)size, |
|||
dateCreated: null, |
|||
dateModified: lastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(lastModified.Value) : null); |
|||
} |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Task<string?> SaveBookmarkAsync() |
|||
{ |
|||
return StorageHelper.SaveBookmark(FileHandle); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public Task ReleaseBookmarkAsync() |
|||
{ |
|||
return StorageHelper.DeleteBookmark(FileHandle); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_fileHandle?.Dispose(); |
|||
_fileHandle = null; |
|||
} |
|||
} |
|||
|
|||
internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile |
|||
{ |
|||
public JSStorageFile(JSObject fileHandle) : base(fileHandle) |
|||
{ |
|||
} |
|||
|
|||
public bool CanOpenRead => true; |
|||
public async Task<Stream> OpenReadAsync() |
|||
{ |
|||
try |
|||
{ |
|||
var blob = await StorageHelper.OpenRead(FileHandle); |
|||
return new BlobReadableStream(blob); |
|||
} |
|||
catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage) |
|||
{ |
|||
throw new UnauthorizedAccessException("User denied permissions to open the file", ex); |
|||
} |
|||
} |
|||
|
|||
public bool CanOpenWrite => true; |
|||
public async Task<Stream> OpenWriteAsync() |
|||
{ |
|||
try |
|||
{ |
|||
using var properties = await StorageHelper.GetProperties(FileHandle); |
|||
var streamWriter = await StorageHelper.OpenWrite(FileHandle); |
|||
var size = (long?)properties?.GetPropertyAsDouble("Size") ?? 0; |
|||
|
|||
return new WriteableStream(streamWriter, size); |
|||
} |
|||
catch (JSException ex) when (ex.Message == BrowserStorageProvider.NoPermissionsMessage) |
|||
{ |
|||
throw new UnauthorizedAccessException("User denied permissions to open the file", ex); |
|||
} |
|||
} |
|||
} |
|||
|
|||
internal class JSStorageFolder : JSStorageItem, IStorageBookmarkFolder |
|||
{ |
|||
public JSStorageFolder(JSObject fileHandle) : base(fileHandle) |
|||
{ |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageItem>> GetItemsAsync() |
|||
{ |
|||
using var items = await StorageHelper.GetItems(FileHandle); |
|||
if (items is null) |
|||
{ |
|||
return Array.Empty<IStorageItem>(); |
|||
} |
|||
|
|||
var itemsArray = StorageHelper.ItemsArray(items); |
|||
|
|||
return itemsArray |
|||
.Select(reference => reference.GetPropertyAsString("kind") switch |
|||
{ |
|||
"directory" => (IStorageItem)new JSStorageFolder(reference), |
|||
"file" => new JSStorageFile(reference), |
|||
_ => null |
|||
}) |
|||
.Where(i => i is not null) |
|||
.ToArray()!; |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Runtime.InteropServices.JavaScript; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Web.Storage; |
|||
|
|||
[System.Runtime.Versioning.SupportedOSPlatform("browser")] |
|||
// Loose wrapper implementaion of a stream on top of FileAPI FileSystemWritableFileStream
|
|||
internal sealed class WriteableStream : Stream |
|||
{ |
|||
private JSObject? _jSReference; |
|||
|
|||
// Unfortunatelly we can't read current length/position, so we need to keep it C#-side only.
|
|||
private long _length, _position; |
|||
|
|||
internal WriteableStream(JSObject jSReference, long initialLength) |
|||
{ |
|||
_jSReference = jSReference; |
|||
_length = initialLength; |
|||
} |
|||
|
|||
private JSObject JSReference => _jSReference ?? throw new ObjectDisposedException(nameof(WriteableStream)); |
|||
|
|||
public override bool CanRead => false; |
|||
|
|||
public override bool CanSeek => true; |
|||
|
|||
public override bool CanWrite => true; |
|||
|
|||
public override long Length => _length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _position; |
|||
set => Seek(_position, SeekOrigin.Begin); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
// no-op
|
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
var position = origin switch |
|||
{ |
|||
SeekOrigin.Current => _position + offset, |
|||
SeekOrigin.End => _length + offset, |
|||
_ => offset |
|||
}; |
|||
StreamHelper.Seek(JSReference, position); |
|||
return position; |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
_length = value; |
|||
|
|||
// See https://docs.w3cub.com/dom/filesystemwritablefilestream/truncate
|
|||
// If the offset is smaller than the size, it remains unchanged. If the offset is larger than size, the offset is set to that size
|
|||
if (_position > _length) |
|||
{ |
|||
_position = _length; |
|||
} |
|||
|
|||
StreamHelper.Truncate(JSReference, value); |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new InvalidOperationException("Browser supports only WriteAsync"); |
|||
} |
|||
|
|||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) |
|||
{ |
|||
return new ValueTask(WriteAsyncInternal(buffer.ToArray(), cancellationToken)); |
|||
} |
|||
|
|||
private Task WriteAsyncInternal(byte[] buffer, CancellationToken _) |
|||
{ |
|||
_position += buffer.Length; |
|||
|
|||
return StreamHelper.WriteAsync(JSReference, buffer); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (_jSReference is { } jsReference) |
|||
{ |
|||
_jSReference = null; |
|||
try |
|||
{ |
|||
_ = StreamHelper.CloseAsync(jsReference); |
|||
} |
|||
finally |
|||
{ |
|||
jsReference.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public override async ValueTask DisposeAsync() |
|||
{ |
|||
if (_jSReference is { } jsReference) |
|||
{ |
|||
_jSReference = null; |
|||
try |
|||
{ |
|||
await StreamHelper.CloseAsync(jsReference); |
|||
} |
|||
finally |
|||
{ |
|||
jsReference.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
using System; |
|||
using Avalonia.Controls.Embedding; |
|||
using Avalonia.Media; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Rendering.SceneGraph; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
internal class WebEmbeddableControlRoot : EmbeddableControlRoot |
|||
{ |
|||
class SplashScreenCloseCustomDrawingOperation : ICustomDrawOperation |
|||
{ |
|||
private bool _hasRendered; |
|||
private Action _onFirstRender; |
|||
|
|||
public SplashScreenCloseCustomDrawingOperation(Action onFirstRender) |
|||
{ |
|||
_onFirstRender = onFirstRender; |
|||
} |
|||
|
|||
public Rect Bounds => Rect.Empty; |
|||
|
|||
public bool HasRendered => _hasRendered; |
|||
|
|||
public void Dispose() |
|||
{ |
|||
|
|||
} |
|||
|
|||
public bool Equals(ICustomDrawOperation? other) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
public bool HitTest(Point p) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
public void Render(IDrawingContextImpl context) |
|||
{ |
|||
_hasRendered = true; |
|||
_onFirstRender(); |
|||
} |
|||
} |
|||
|
|||
public WebEmbeddableControlRoot(ITopLevelImpl impl, Action onFirstRender) : base(impl) |
|||
{ |
|||
_splashCloseOp = new SplashScreenCloseCustomDrawingOperation(() => |
|||
{ |
|||
_splashCloseOp = null; |
|||
onFirstRender(); |
|||
}); |
|||
} |
|||
|
|||
private SplashScreenCloseCustomDrawingOperation? _splashCloseOp; |
|||
|
|||
public override void Render(DrawingContext context) |
|||
{ |
|||
base.Render(context); |
|||
|
|||
if (_splashCloseOp != null) |
|||
{ |
|||
context.Custom(_splashCloseOp); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using Avalonia.Platform; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
internal class IconLoaderStub : IPlatformIconLoader |
|||
{ |
|||
private class IconStub : IWindowIconImpl |
|||
{ |
|||
public void Save(Stream outputStream) |
|||
{ |
|||
|
|||
} |
|||
} |
|||
|
|||
public IWindowIconImpl LoadIcon(string fileName) => new IconStub(); |
|||
|
|||
public IWindowIconImpl LoadIcon(Stream stream) => new IconStub(); |
|||
|
|||
public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) => new IconStub(); |
|||
} |
|||
|
|||
internal class ScreenStub : IScreenImpl |
|||
{ |
|||
public int ScreenCount => 1; |
|||
|
|||
public IReadOnlyList<Screen> AllScreens { get; } = |
|||
new[] { new Screen(96, new PixelRect(0, 0, 4000, 4000), new PixelRect(0, 0, 4000, 4000), true) }; |
|||
|
|||
public Screen? ScreenFromPoint(PixelPoint point) |
|||
{ |
|||
return ScreenHelper.ScreenFromPoint(point, AllScreens); |
|||
} |
|||
|
|||
public Screen? ScreenFromRect(PixelRect rect) |
|||
{ |
|||
return ScreenHelper.ScreenFromRect(rect, AllScreens); |
|||
} |
|||
|
|||
public Screen? ScreenFromWindow(IWindowBaseImpl window) |
|||
{ |
|||
return ScreenHelper.ScreenFromWindow(window, AllScreens); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
using System; |
|||
using System.Threading; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Platform; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Rendering; |
|||
using Avalonia.Threading; |
|||
|
|||
namespace Avalonia.Web |
|||
{ |
|||
public class BrowserWindowingPlatform : IWindowingPlatform, IPlatformSettings, IPlatformThreadingInterface |
|||
{ |
|||
private bool _signaled; |
|||
private static KeyboardDevice? s_keyboard; |
|||
|
|||
public IWindowImpl CreateWindow() => throw new NotSupportedException(); |
|||
|
|||
IWindowImpl IWindowingPlatform.CreateEmbeddableWindow() |
|||
{ |
|||
throw new NotImplementedException(); |
|||
} |
|||
|
|||
public ITrayIconImpl? CreateTrayIcon() |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
public static KeyboardDevice Keyboard => s_keyboard ?? |
|||
throw new InvalidOperationException("BrowserWindowingPlatform not registered."); |
|||
|
|||
public static void Register() |
|||
{ |
|||
var instance = new BrowserWindowingPlatform(); |
|||
s_keyboard = new KeyboardDevice(); |
|||
AvaloniaLocator.CurrentMutable |
|||
.Bind<IClipboard>().ToSingleton<ClipboardImpl>() |
|||
.Bind<ICursorFactory>().ToSingleton<CssCursorFactory>() |
|||
.Bind<IKeyboardDevice>().ToConstant(s_keyboard) |
|||
.Bind<IPlatformSettings>().ToConstant(instance) |
|||
.Bind<IPlatformThreadingInterface>().ToConstant(instance) |
|||
.Bind<IRenderLoop>().ToConstant(new RenderLoop()) |
|||
.Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance) |
|||
.Bind<IWindowingPlatform>().ToConstant(instance) |
|||
.Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>() |
|||
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>(); |
|||
} |
|||
|
|||
public Size DoubleClickSize { get; } = new Size(2, 2); |
|||
|
|||
public TimeSpan DoubleClickTime { get; } = TimeSpan.FromMilliseconds(500); |
|||
|
|||
public Size TouchDoubleClickSize => new Size(16, 16); |
|||
|
|||
public TimeSpan TouchDoubleClickTime => DoubleClickTime; |
|||
public void RunLoop(CancellationToken cancellationToken) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) |
|||
{ |
|||
return GetRuntimePlatform() |
|||
.StartSystemTimer(interval, () => |
|||
{ |
|||
Dispatcher.UIThread.RunJobs(priority); |
|||
tick(); |
|||
}); |
|||
} |
|||
|
|||
public void Signal(DispatcherPriority priority) |
|||
{ |
|||
if (_signaled) |
|||
return; |
|||
|
|||
_signaled = true; |
|||
|
|||
IDisposable? disp = null; |
|||
|
|||
disp = GetRuntimePlatform() |
|||
.StartSystemTimer(TimeSpan.FromMilliseconds(1), |
|||
() => |
|||
{ |
|||
_signaled = false; |
|||
disp?.Dispose(); |
|||
|
|||
Signaled?.Invoke(null); |
|||
}); |
|||
} |
|||
|
|||
public bool CurrentThreadIsLoopThread |
|||
{ |
|||
get |
|||
{ |
|||
return true; // Browser is single threaded.
|
|||
} |
|||
} |
|||
|
|||
public event Action<DispatcherPriority?>? Signaled; |
|||
|
|||
private static IRuntimePlatform GetRuntimePlatform() |
|||
{ |
|||
return AvaloniaLocator.Current.GetRequiredService<IRuntimePlatform>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
var LibraryExample = { |
|||
// Internal functions
|
|||
$EXAMPLE: { |
|||
internal_func: function () { |
|||
} |
|||
}, |
|||
InterceptGLObject: function () { |
|||
globalThis.AvaloniaGL = GL |
|||
} |
|||
} |
|||
|
|||
autoAddDeps(LibraryExample, '$EXAMPLE') |
|||
mergeInto(LibraryManager.library, LibraryExample) |
|||
@ -0,0 +1,47 @@ |
|||
{ |
|||
"env": { |
|||
"browser": true, |
|||
"es6": true |
|||
}, |
|||
"extends": "standard-with-typescript", |
|||
"overrides": [], |
|||
"parserOptions": { |
|||
"ecmaVersion": "latest", |
|||
"sourceType": "module", |
|||
"project": [ |
|||
"tsconfig.json" |
|||
] |
|||
}, |
|||
"rules": { |
|||
"indent": [ |
|||
"warn", |
|||
4 |
|||
], |
|||
"@typescript-eslint/indent": [ |
|||
"warn", |
|||
4 |
|||
], |
|||
"quotes": ["warn", "double"], |
|||
"semi": ["error", "always"], |
|||
"@typescript-eslint/quotes": ["warn", "double"], |
|||
"@typescript-eslint/explicit-function-return-type": "off", |
|||
"@typescript-eslint/no-extraneous-class": "off", |
|||
"@typescript-eslint/strict-boolean-expressions": "off", |
|||
"@typescript-eslint/space-before-function-paren": "off", |
|||
"@typescript-eslint/semi": ["error", "always"], |
|||
"@typescript-eslint/member-delimiter-style": [ |
|||
"error", |
|||
{ |
|||
"multiline": { |
|||
"delimiter": "semi", |
|||
"requireLast": true |
|||
}, |
|||
"singleline": { |
|||
"delimiter": "semi", |
|||
"requireLast": false |
|||
} |
|||
} |
|||
] |
|||
}, |
|||
"ignorePatterns": ["types/*"] |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
require("esbuild").build({ |
|||
entryPoints: [ |
|||
"./modules/avalonia.ts", |
|||
"./modules/storage.ts" |
|||
], |
|||
outdir: "../wwwroot", |
|||
bundle: true, |
|||
minify: false, |
|||
format: "esm", |
|||
target: "es2016", |
|||
platform: "browser", |
|||
sourcemap: "linked", |
|||
loader: { ".ts": "ts" } |
|||
}) |
|||
.then(() => console.log("⚡ Done")) |
|||
.catch(() => process.exit(1)); |
|||
@ -0,0 +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<void> { |
|||
api.setModuleImports("avalonia.ts", { |
|||
Caniuse, |
|||
Canvas, |
|||
InputHelper, |
|||
SizeWatcher, |
|||
DpiWatcher, |
|||
AvaloniaDOM, |
|||
StreamHelper, |
|||
NativeControlHost |
|||
}); |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
export class Caniuse { |
|||
public static canShowOpenFilePicker(): boolean { |
|||
return typeof window.showOpenFilePicker !== "undefined"; |
|||
} |
|||
|
|||
public static canShowSaveFilePicker(): boolean { |
|||
return typeof window.showSaveFilePicker !== "undefined"; |
|||
} |
|||
|
|||
public static canShowDirectoryPicker(): boolean { |
|||
return typeof window.showDirectoryPicker !== "undefined"; |
|||
} |
|||
} |
|||
@ -0,0 +1,303 @@ |
|||
interface SKGLViewInfo { |
|||
context: WebGLRenderingContext | WebGL2RenderingContext | undefined; |
|||
fboId: number; |
|||
stencil: number; |
|||
sample: number; |
|||
depth: number; |
|||
} |
|||
|
|||
type CanvasElement = { |
|||
Canvas: Canvas | undefined; |
|||
} & HTMLCanvasElement; |
|||
|
|||
export class Canvas { |
|||
static elements: Map<string, HTMLCanvasElement>; |
|||
|
|||
htmlCanvas: HTMLCanvasElement; |
|||
glInfo?: SKGLViewInfo; |
|||
renderFrameCallback: () => void; |
|||
renderLoopEnabled: boolean = false; |
|||
renderLoopRequest: number = 0; |
|||
newWidth?: number; |
|||
newHeight?: number; |
|||
|
|||
public static initGL(element: HTMLCanvasElement, elementId: string, renderFrameCallback: () => void): SKGLViewInfo | null { |
|||
const view = Canvas.init(true, element, elementId, renderFrameCallback); |
|||
if (!view || !view.glInfo) { |
|||
return null; |
|||
} |
|||
|
|||
return view.glInfo; |
|||
} |
|||
|
|||
static init(useGL: boolean, element: HTMLCanvasElement, elementId: string, renderFrameCallback: () => void): Canvas | null { |
|||
const htmlCanvas = element as CanvasElement; |
|||
if (!htmlCanvas) { |
|||
console.error("No canvas element was provided."); |
|||
return null; |
|||
} |
|||
|
|||
if (!Canvas.elements) { |
|||
Canvas.elements = new Map<string, HTMLCanvasElement>(); |
|||
} |
|||
Canvas.elements.set(elementId, element); |
|||
|
|||
const view = new Canvas(useGL, element, renderFrameCallback); |
|||
|
|||
htmlCanvas.Canvas = view; |
|||
|
|||
return view; |
|||
} |
|||
|
|||
public constructor(useGL: boolean, element: HTMLCanvasElement, renderFrameCallback: () => void) { |
|||
this.htmlCanvas = element; |
|||
this.renderFrameCallback = renderFrameCallback; |
|||
|
|||
if (useGL) { |
|||
const ctx = Canvas.createWebGLContext(element); |
|||
if (!ctx) { |
|||
console.error("Failed to create WebGL context"); |
|||
return; |
|||
} |
|||
|
|||
const GL = (globalThis as any).AvaloniaGL; |
|||
|
|||
// make current
|
|||
GL.makeContextCurrent(ctx); |
|||
|
|||
const GLctx = GL.currentContext.GLctx as WebGLRenderingContext; |
|||
|
|||
// read values
|
|||
const fbo = GLctx.getParameter(GLctx.FRAMEBUFFER_BINDING); |
|||
|
|||
this.glInfo = { |
|||
context: ctx, |
|||
fboId: fbo ? fbo.id : 0, |
|||
stencil: GLctx.getParameter(GLctx.STENCIL_BITS), |
|||
sample: 0, // TODO: GLctx.getParameter(GLctx.SAMPLES)
|
|||
depth: GLctx.getParameter(GLctx.DEPTH_BITS) |
|||
}; |
|||
} |
|||
} |
|||
|
|||
public setEnableRenderLoop(enable: boolean): void { |
|||
this.renderLoopEnabled = enable; |
|||
|
|||
// either start the new frame or cancel the existing one
|
|||
if (enable) { |
|||
// console.info(`Enabling render loop with callback ${this.renderFrameCallback._id}...`);
|
|||
this.requestAnimationFrame(); |
|||
} else if (this.renderLoopRequest !== 0) { |
|||
window.cancelAnimationFrame(this.renderLoopRequest); |
|||
this.renderLoopRequest = 0; |
|||
} |
|||
} |
|||
|
|||
public requestAnimationFrame(renderLoop?: boolean): void { |
|||
// optionally update the render loop
|
|||
if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) { |
|||
this.setEnableRenderLoop(renderLoop); |
|||
} |
|||
|
|||
// skip because we have a render loop
|
|||
if (this.renderLoopRequest !== 0) { |
|||
return; |
|||
} |
|||
|
|||
// add the draw to the next frame
|
|||
this.renderLoopRequest = window.requestAnimationFrame(() => { |
|||
if (this.glInfo) { |
|||
const GL = (globalThis as any).AvaloniaGL; |
|||
// make current
|
|||
GL.makeContextCurrent(this.glInfo.context); |
|||
} |
|||
|
|||
if (this.htmlCanvas.width !== this.newWidth) { |
|||
this.htmlCanvas.width = this.newWidth ?? 0; |
|||
} |
|||
|
|||
if (this.htmlCanvas.height !== this.newHeight) { |
|||
this.htmlCanvas.height = this.newHeight ?? 0; |
|||
} |
|||
|
|||
this.renderFrameCallback(); |
|||
this.renderLoopRequest = 0; |
|||
|
|||
// we may want to draw the next frame
|
|||
if (this.renderLoopEnabled) { |
|||
this.requestAnimationFrame(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public setCanvasSize(width: number, height: number): void { |
|||
this.newWidth = width; |
|||
this.newHeight = height; |
|||
|
|||
if (this.htmlCanvas.width !== this.newWidth) { |
|||
this.htmlCanvas.width = this.newWidth; |
|||
} |
|||
|
|||
if (this.htmlCanvas.height !== this.newHeight) { |
|||
this.htmlCanvas.height = this.newHeight; |
|||
} |
|||
|
|||
if (this.glInfo) { |
|||
const GL = (globalThis as any).AvaloniaGL; |
|||
// make current
|
|||
GL.makeContextCurrent(this.glInfo.context); |
|||
} |
|||
} |
|||
|
|||
public static setCanvasSize(element: HTMLCanvasElement, width: number, height: number): void { |
|||
const htmlCanvas = element as CanvasElement; |
|||
if (!htmlCanvas || !htmlCanvas.Canvas) { |
|||
return; |
|||
} |
|||
|
|||
htmlCanvas.Canvas.setCanvasSize(width, height); |
|||
} |
|||
|
|||
public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean): void { |
|||
const htmlCanvas = element as CanvasElement; |
|||
if (!htmlCanvas || !htmlCanvas.Canvas) { |
|||
return; |
|||
} |
|||
|
|||
htmlCanvas.Canvas.requestAnimationFrame(renderLoop); |
|||
} |
|||
|
|||
static createWebGLContext(htmlCanvas: HTMLCanvasElement): WebGLRenderingContext | WebGL2RenderingContext { |
|||
const contextAttributes = { |
|||
alpha: 1, |
|||
depth: 1, |
|||
stencil: 8, |
|||
antialias: 0, |
|||
premultipliedAlpha: 1, |
|||
preserveDrawingBuffer: 0, |
|||
preferLowPowerToHighPerformance: 0, |
|||
failIfMajorPerformanceCaveat: 0, |
|||
majorVersion: 2, |
|||
minorVersion: 0, |
|||
enableExtensionsByDefault: 1, |
|||
explicitSwapControl: 0, |
|||
renderViaOffscreenBackBuffer: 1 |
|||
}; |
|||
|
|||
const GL = (globalThis as any).AvaloniaGL; |
|||
|
|||
let ctx: WebGLRenderingContext = GL.createContext(htmlCanvas, contextAttributes); |
|||
|
|||
if (!ctx && contextAttributes.majorVersion > 1) { |
|||
console.warn("Falling back to WebGL 1.0"); |
|||
contextAttributes.majorVersion = 1; |
|||
contextAttributes.minorVersion = 0; |
|||
ctx = GL.createContext(htmlCanvas, contextAttributes); |
|||
} |
|||
|
|||
return ctx; |
|||
} |
|||
} |
|||
|
|||
type SizeWatcherElement = { |
|||
SizeWatcher: SizeWatcherInstance; |
|||
} & HTMLElement; |
|||
|
|||
interface SizeWatcherInstance { |
|||
callback: (width: number, height: number) => void; |
|||
} |
|||
|
|||
export class SizeWatcher { |
|||
static observer: ResizeObserver; |
|||
static elements: Map<string, HTMLElement>; |
|||
|
|||
public static observe(element: HTMLElement, elementId: string, callback: (width: number, height: number) => void): void { |
|||
if (!element || !callback) { |
|||
return; |
|||
} |
|||
|
|||
SizeWatcher.init(); |
|||
|
|||
const watcherElement = element as SizeWatcherElement; |
|||
watcherElement.SizeWatcher = { |
|||
callback |
|||
}; |
|||
|
|||
SizeWatcher.elements.set(elementId, element); |
|||
SizeWatcher.observer.observe(element); |
|||
|
|||
SizeWatcher.invoke(element); |
|||
} |
|||
|
|||
public static unobserve(elementId: string): void { |
|||
if (!elementId || !SizeWatcher.observer) { |
|||
return; |
|||
} |
|||
|
|||
const element = SizeWatcher.elements.get(elementId); |
|||
if (element) { |
|||
SizeWatcher.elements.delete(elementId); |
|||
SizeWatcher.observer.unobserve(element); |
|||
} |
|||
} |
|||
|
|||
static init(): void { |
|||
if (SizeWatcher.observer) { |
|||
return; |
|||
} |
|||
|
|||
SizeWatcher.elements = new Map<string, HTMLElement>(); |
|||
SizeWatcher.observer = new ResizeObserver((entries) => { |
|||
for (const entry of entries) { |
|||
SizeWatcher.invoke(entry.target); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
static invoke(element: Element): void { |
|||
const watcherElement = element as SizeWatcherElement; |
|||
const instance = watcherElement.SizeWatcher; |
|||
|
|||
if (!instance || !instance.callback) { |
|||
return; |
|||
} |
|||
|
|||
return instance.callback(element.clientWidth, element.clientHeight); |
|||
} |
|||
} |
|||
|
|||
export class DpiWatcher { |
|||
static lastDpi: number; |
|||
static timerId: number; |
|||
static callback: (old: number, newdpi: number) => void; |
|||
|
|||
public static getDpi(): number { |
|||
return window.devicePixelRatio; |
|||
} |
|||
|
|||
public static start(callback: (old: number, newdpi: number) => void): number { |
|||
DpiWatcher.lastDpi = window.devicePixelRatio; |
|||
DpiWatcher.timerId = window.setInterval(DpiWatcher.update, 1000); |
|||
DpiWatcher.callback = callback; |
|||
|
|||
return DpiWatcher.lastDpi; |
|||
} |
|||
|
|||
public static stop(): void { |
|||
window.clearInterval(DpiWatcher.timerId); |
|||
} |
|||
|
|||
static update(): void { |
|||
if (!DpiWatcher.callback) { |
|||
return; |
|||
} |
|||
|
|||
const currentDpi = window.devicePixelRatio; |
|||
const lastDpi = DpiWatcher.lastDpi; |
|||
DpiWatcher.lastDpi = currentDpi; |
|||
|
|||
if (Math.abs(lastDpi - currentDpi) > 0.001) { |
|||
DpiWatcher.callback(lastDpi, currentDpi); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,149 @@ |
|||
// Based on https://github.com/component/textarea-caret-position/blob/master/index.js
|
|||
export class CaretHelper { |
|||
public static getCaretCoordinates( |
|||
element: HTMLInputElement | HTMLTextAreaElement, |
|||
position: number, |
|||
options?: { debug: boolean } |
|||
) { |
|||
if (!isBrowser) { |
|||
throw new Error( |
|||
"textarea-caret-position#getCaretCoordinates should only be called in a browser" |
|||
); |
|||
} |
|||
|
|||
const debug = options?.debug ?? false; |
|||
if (debug) { |
|||
const el = document.querySelector( |
|||
"#input-textarea-caret-position-mirror-div" |
|||
); |
|||
if (el) el.parentNode?.removeChild(el); |
|||
} |
|||
|
|||
// The mirror div will replicate the textarea's style
|
|||
const div = document.createElement("div"); |
|||
div.id = "input-textarea-caret-position-mirror-div"; |
|||
document.body.appendChild(div); |
|||
|
|||
const style = div.style; |
|||
const computed = window.getComputedStyle |
|||
? window.getComputedStyle(element) |
|||
: ((element as any).currentStyle as CSSStyleDeclaration); // currentStyle for IE < 9
|
|||
const isInput = element.nodeName === "INPUT"; |
|||
|
|||
// Default textarea styles
|
|||
style.whiteSpace = "pre-wrap"; |
|||
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
|
|||
|
|||
// Position off-screen
|
|||
style.position = "absolute"; // required to return coordinates properly
|
|||
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
|
|||
|
|||
// Transfer the element's properties to the div
|
|||
properties.forEach((prop: string) => { |
|||
if (isInput && prop === "lineHeight") { |
|||
// Special case for <input>s because text is rendered centered and line height may be != height
|
|||
if (computed.boxSizing === "border-box") { |
|||
const height = parseInt(computed.height); |
|||
const outerHeight = |
|||
parseInt(computed.paddingTop) + |
|||
parseInt(computed.paddingBottom) + |
|||
parseInt(computed.borderTopWidth) + |
|||
parseInt(computed.borderBottomWidth); |
|||
const targetHeight = outerHeight + parseInt(computed.lineHeight); |
|||
if (height > targetHeight) { |
|||
style.lineHeight = `${height - outerHeight}px`; |
|||
} else if (height === targetHeight) { |
|||
style.lineHeight = computed.lineHeight; |
|||
} else { |
|||
style.lineHeight = "0"; |
|||
} |
|||
} else { |
|||
style.lineHeight = computed.height; |
|||
} |
|||
} else { |
|||
(style as any)[prop] = (computed as any)[prop]; |
|||
} |
|||
}); |
|||
|
|||
if (isFirefox) { |
|||
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
|||
if (element.scrollHeight > parseInt(computed.height)) { |
|||
style.overflowY = "scroll"; |
|||
} |
|||
} else { |
|||
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
|||
} |
|||
|
|||
div.textContent = element.value.substring(0, position); |
|||
// The second special handling for input type="text" vs textarea:
|
|||
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
|||
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0"); |
|||
|
|||
const span = document.createElement("span"); |
|||
// Wrapping must be replicated *exactly*, including when a long word gets
|
|||
// onto the next line, with whitespace at the end of the line before (#7).
|
|||
// The *only* reliable way to do that is to copy the *entire* rest of the
|
|||
// textarea's content into the <span> created at the caret position.
|
|||
// For inputs, just '.' would be enough, but no need to bother.
|
|||
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
|
|||
div.appendChild(span); |
|||
|
|||
const coordinates = { |
|||
top: span.offsetTop + parseInt(computed.borderTopWidth), |
|||
left: span.offsetLeft + parseInt(computed.borderLeftWidth), |
|||
height: parseInt(computed.lineHeight) |
|||
}; |
|||
|
|||
if (debug) { |
|||
span.style.backgroundColor = "#aaa"; |
|||
} else { |
|||
document.body.removeChild(div); |
|||
} |
|||
|
|||
return coordinates; |
|||
} |
|||
} |
|||
|
|||
const properties = [ |
|||
"direction", // RTL support
|
|||
"boxSizing", |
|||
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
|||
"height", |
|||
"overflowX", |
|||
"overflowY", // copy the scrollbar for IE
|
|||
|
|||
"borderTopWidth", |
|||
"borderRightWidth", |
|||
"borderBottomWidth", |
|||
"borderLeftWidth", |
|||
"borderStyle", |
|||
|
|||
"paddingTop", |
|||
"paddingRight", |
|||
"paddingBottom", |
|||
"paddingLeft", |
|||
|
|||
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
|||
"fontStyle", |
|||
"fontVariant", |
|||
"fontWeight", |
|||
"fontStretch", |
|||
"fontSize", |
|||
"fontSizeAdjust", |
|||
"lineHeight", |
|||
"fontFamily", |
|||
|
|||
"textAlign", |
|||
"textTransform", |
|||
"textIndent", |
|||
"textDecoration", // might not make a difference, but better be safe
|
|||
|
|||
"letterSpacing", |
|||
"wordSpacing", |
|||
|
|||
"tabSize", |
|||
"MozTabSize" |
|||
]; |
|||
|
|||
const isBrowser = typeof window !== "undefined"; |
|||
const isFirefox = isBrowser && (window as any).mozInnerScreenX != null; |
|||
@ -0,0 +1,60 @@ |
|||
export class AvaloniaDOM { |
|||
public static addClass(element: HTMLElement, className: string): void { |
|||
element.classList.add(className); |
|||
} |
|||
|
|||
static createAvaloniaHost(host: HTMLElement) { |
|||
// Root element
|
|||
host.classList.add("avalonia-container"); |
|||
host.tabIndex = 0; |
|||
host.oncontextmenu = function () { return false; }; |
|||
|
|||
// Rendering target canvas
|
|||
const canvas = document.createElement("canvas"); |
|||
canvas.classList.add("avalonia-canvas"); |
|||
canvas.style.backgroundColor = "#ccc"; |
|||
canvas.style.width = "100%"; |
|||
canvas.style.height = "100%"; |
|||
canvas.style.position = "absolute"; |
|||
|
|||
// Native controls host
|
|||
const nativeHost = document.createElement("div"); |
|||
nativeHost.classList.add("avalonia-native-host"); |
|||
nativeHost.style.left = "0px"; |
|||
nativeHost.style.top = "0px"; |
|||
nativeHost.style.width = "100%"; |
|||
nativeHost.style.height = "100%"; |
|||
nativeHost.style.position = "absolute"; |
|||
|
|||
// IME
|
|||
const inputElement = document.createElement("input"); |
|||
inputElement.classList.add("avalonia-input-element"); |
|||
inputElement.autocapitalize = "none"; |
|||
inputElement.type = "text"; |
|||
inputElement.spellcheck = false; |
|||
inputElement.style.padding = "0"; |
|||
inputElement.style.margin = "0"; |
|||
inputElement.style.position = "absolute"; |
|||
inputElement.style.overflow = "hidden"; |
|||
inputElement.style.borderStyle = "hidden"; |
|||
inputElement.style.outline = "none"; |
|||
inputElement.style.background = "transparent"; |
|||
inputElement.style.color = "transparent"; |
|||
inputElement.style.display = "none"; |
|||
inputElement.style.height = "20px"; |
|||
inputElement.onpaste = function () { return false; }; |
|||
inputElement.oncopy = function () { return false; }; |
|||
inputElement.oncut = function () { return false; }; |
|||
|
|||
host.prepend(inputElement); |
|||
host.prepend(nativeHost); |
|||
host.prepend(canvas); |
|||
|
|||
return { |
|||
host, |
|||
canvas, |
|||
nativeHost, |
|||
inputElement |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,204 @@ |
|||
import { CaretHelper } from "./caretHelper"; |
|||
|
|||
enum RawInputModifiers { |
|||
None = 0, |
|||
Alt = 1, |
|||
Control = 2, |
|||
Shift = 4, |
|||
Meta = 8, |
|||
|
|||
LeftMouseButton = 16, |
|||
RightMouseButton = 32, |
|||
MiddleMouseButton = 64, |
|||
XButton1MouseButton = 128, |
|||
XButton2MouseButton = 256, |
|||
KeyboardMask = Alt | Control | Shift | Meta, |
|||
|
|||
PenInverted = 512, |
|||
PenEraser = 1024, |
|||
PenBarrelButton = 2048 |
|||
} |
|||
|
|||
export class InputHelper { |
|||
public static subscribeKeyEvents( |
|||
element: HTMLInputElement, |
|||
keyDownCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean, |
|||
keyUpCallback: (code: string, key: string, modifiers: RawInputModifiers) => boolean) { |
|||
const keyDownHandler = (args: KeyboardEvent) => { |
|||
if (keyDownCallback(args.code, args.key, this.getModifiers(args))) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("keydown", keyDownHandler); |
|||
|
|||
const keyUpHandler = (args: KeyboardEvent) => { |
|||
if (keyUpCallback(args.code, args.key, this.getModifiers(args))) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
|
|||
element.addEventListener("keyup", keyUpHandler); |
|||
|
|||
return () => { |
|||
element.removeEventListener("keydown", keyDownHandler); |
|||
element.removeEventListener("keyup", keyUpHandler); |
|||
}; |
|||
} |
|||
|
|||
public static subscribeTextEvents( |
|||
element: HTMLInputElement, |
|||
inputCallback: (type: string, data: string | null) => boolean, |
|||
compositionStartCallback: (args: CompositionEvent) => boolean, |
|||
compositionUpdateCallback: (args: CompositionEvent) => boolean, |
|||
compositionEndCallback: (args: CompositionEvent) => boolean) { |
|||
const inputHandler = (args: Event) => { |
|||
const inputEvent = args as InputEvent; |
|||
|
|||
// todo check cast
|
|||
if (inputCallback(inputEvent.type, inputEvent.data)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("input", inputHandler); |
|||
|
|||
const compositionStartHandler = (args: CompositionEvent) => { |
|||
if (compositionStartCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("compositionstart", compositionStartHandler); |
|||
|
|||
const compositionUpdateHandler = (args: CompositionEvent) => { |
|||
if (compositionUpdateCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("compositionupdate", compositionUpdateHandler); |
|||
|
|||
const compositionEndHandler = (args: CompositionEvent) => { |
|||
if (compositionEndCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("compositionend", compositionEndHandler); |
|||
|
|||
return () => { |
|||
element.removeEventListener("input", inputHandler); |
|||
element.removeEventListener("compositionstart", compositionStartHandler); |
|||
element.removeEventListener("compositionupdate", compositionUpdateHandler); |
|||
element.removeEventListener("compositionend", compositionEndHandler); |
|||
}; |
|||
} |
|||
|
|||
public static subscribePointerEvents( |
|||
element: HTMLInputElement, |
|||
pointerMoveCallback: (args: PointerEvent) => boolean, |
|||
pointerDownCallback: (args: PointerEvent) => boolean, |
|||
pointerUpCallback: (args: PointerEvent) => boolean, |
|||
wheelCallback: (args: WheelEvent) => boolean |
|||
) { |
|||
const pointerMoveHandler = (args: PointerEvent) => { |
|||
if (pointerMoveCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
|
|||
const pointerDownHandler = (args: PointerEvent) => { |
|||
if (pointerDownCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
|
|||
const pointerUpHandler = (args: PointerEvent) => { |
|||
if (pointerUpCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
|
|||
const wheelHandler = (args: WheelEvent) => { |
|||
if (wheelCallback(args)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
|
|||
element.addEventListener("pointermove", pointerMoveHandler); |
|||
element.addEventListener("pointerdown", pointerDownHandler); |
|||
element.addEventListener("pointerup", pointerUpHandler); |
|||
element.addEventListener("wheel", wheelHandler); |
|||
|
|||
return () => { |
|||
element.removeEventListener("pointerover", pointerMoveHandler); |
|||
element.removeEventListener("pointerdown", pointerDownHandler); |
|||
element.removeEventListener("pointerup", pointerUpHandler); |
|||
element.removeEventListener("wheel", wheelHandler); |
|||
}; |
|||
} |
|||
|
|||
public static subscribeInputEvents( |
|||
element: HTMLInputElement, |
|||
inputCallback: (value: string) => boolean |
|||
) { |
|||
const inputHandler = (args: Event) => { |
|||
if (inputCallback((args as any).value)) { |
|||
args.preventDefault(); |
|||
} |
|||
}; |
|||
element.addEventListener("input", inputHandler); |
|||
|
|||
return () => { |
|||
element.removeEventListener("input", inputHandler); |
|||
}; |
|||
} |
|||
|
|||
public static clearInput(inputElement: HTMLInputElement) { |
|||
inputElement.value = ""; |
|||
} |
|||
|
|||
public static focusElement(inputElement: HTMLElement) { |
|||
inputElement.focus(); |
|||
} |
|||
|
|||
public static setCursor(inputElement: HTMLInputElement, kind: string) { |
|||
inputElement.style.cursor = kind; |
|||
} |
|||
|
|||
public static setBounds(inputElement: HTMLInputElement, x: number, y: number, caretWidth: number, caretHeight: number, caret: number) { |
|||
inputElement.style.left = (x).toFixed(0) + "px"; |
|||
inputElement.style.top = (y).toFixed(0) + "px"; |
|||
|
|||
const { left, top } = CaretHelper.getCaretCoordinates(inputElement, caret); |
|||
|
|||
inputElement.style.left = (x - left).toFixed(0) + "px"; |
|||
inputElement.style.top = (y - top).toFixed(0) + "px"; |
|||
} |
|||
|
|||
public static hide(inputElement: HTMLInputElement) { |
|||
inputElement.style.display = "none"; |
|||
} |
|||
|
|||
public static show(inputElement: HTMLInputElement) { |
|||
inputElement.style.display = "block"; |
|||
} |
|||
|
|||
public static setSurroundingText(inputElement: HTMLInputElement, text: string, start: number, end: number) { |
|||
if (!inputElement) { |
|||
return; |
|||
} |
|||
|
|||
inputElement.value = text; |
|||
inputElement.setSelectionRange(start, end); |
|||
inputElement.style.width = "20px"; |
|||
inputElement.style.width = `${inputElement.scrollWidth}px`; |
|||
} |
|||
|
|||
private static getModifiers(args: KeyboardEvent): RawInputModifiers { |
|||
let modifiers = RawInputModifiers.None; |
|||
|
|||
if (args.ctrlKey) { modifiers |= RawInputModifiers.Control; } |
|||
if (args.altKey) { modifiers |= RawInputModifiers.Alt; } |
|||
if (args.shiftKey) { modifiers |= RawInputModifiers.Shift; } |
|||
if (args.metaKey) { modifiers |= RawInputModifiers.Meta; } |
|||
|
|||
return modifiers; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
import { IMemoryView } from "../../types/dotnet"; |
|||
|
|||
export class StreamHelper { |
|||
public static async seek(stream: FileSystemWritableFileStream, position: number) { |
|||
return await stream.seek(position); |
|||
} |
|||
|
|||
public static async truncate(stream: FileSystemWritableFileStream, size: number) { |
|||
return await stream.truncate(size); |
|||
} |
|||
|
|||
public static async close(stream: FileSystemWritableFileStream) { |
|||
return await stream.close(); |
|||
} |
|||
|
|||
public static async write(stream: FileSystemWritableFileStream, span: IMemoryView) { |
|||
const array = new Uint8Array(span.byteLength); |
|||
span.copyTo(array); |
|||
|
|||
const data: WriteParams = { |
|||
type: "write", |
|||
data: array |
|||
}; |
|||
|
|||
return await stream.write(data); |
|||
} |
|||
|
|||
public static byteLength(stream: Blob) { |
|||
return stream.size; |
|||
} |
|||
|
|||
public static async sliceArrayBuffer(stream: Blob, offset: number, count: number) { |
|||
const buffer = await stream.slice(offset, offset + count).arrayBuffer(); |
|||
return new Uint8Array(buffer); |
|||
} |
|||
|
|||
public static toMemoryView(buffer: Uint8Array): Uint8Array { |
|||
return buffer; |
|||
} |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export { StorageItem, StorageItems } from "./storage/storageItem"; |
|||
export { StorageProvider } from "./storage/storageProvider"; |
|||
@ -0,0 +1,84 @@ |
|||
class InnerDbConnection { |
|||
constructor(private readonly database: IDBDatabase) { } |
|||
|
|||
private openStore(store: string, mode: IDBTransactionMode): IDBObjectStore { |
|||
const tx = this.database.transaction(store, mode); |
|||
return tx.objectStore(store); |
|||
} |
|||
|
|||
public async put(store: string, obj: any, key?: IDBValidKey): Promise<IDBValidKey> { |
|||
const os = this.openStore(store, "readwrite"); |
|||
|
|||
return await new Promise((resolve, reject) => { |
|||
const response = os.put(obj, key); |
|||
response.onsuccess = () => { |
|||
resolve(response.result); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public get(store: string, key: IDBValidKey): any { |
|||
const os = this.openStore(store, "readonly"); |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
const response = os.get(key); |
|||
response.onsuccess = () => { |
|||
resolve(response.result); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public async delete(store: string, key: IDBValidKey): Promise<void> { |
|||
const os = this.openStore(store, "readwrite"); |
|||
|
|||
return await new Promise((resolve, reject) => { |
|||
const response = os.delete(key); |
|||
response.onsuccess = () => { |
|||
resolve(); |
|||
}; |
|||
response.onerror = () => { |
|||
reject(response.error); |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public close() { |
|||
this.database.close(); |
|||
} |
|||
} |
|||
|
|||
class IndexedDbWrapper { |
|||
constructor(private readonly databaseName: string, private readonly objectStores: [string]) { |
|||
} |
|||
|
|||
public async connect(): Promise<InnerDbConnection> { |
|||
const conn = window.indexedDB.open(this.databaseName, 1); |
|||
|
|||
conn.onupgradeneeded = event => { |
|||
const db = (event.target as IDBRequest<IDBDatabase>).result; |
|||
this.objectStores.forEach(store => { |
|||
db.createObjectStore(store); |
|||
}); |
|||
}; |
|||
|
|||
return await new Promise((resolve, reject) => { |
|||
conn.onsuccess = event => { |
|||
resolve(new InnerDbConnection((event.target as IDBRequest<IDBDatabase>).result)); |
|||
}; |
|||
conn.onerror = event => { |
|||
reject((event.target as IDBRequest<IDBDatabase>).error); |
|||
}; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export const fileBookmarksStore: string = "fileBookmarks"; |
|||
export const avaloniaDb = new IndexedDbWrapper("AvaloniaDb", [ |
|||
fileBookmarksStore |
|||
]); |
|||
@ -0,0 +1,111 @@ |
|||
import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; |
|||
|
|||
export class StorageItem { |
|||
constructor(public handle: FileSystemHandle, private readonly bookmarkId?: string) { } |
|||
|
|||
public get name(): string { |
|||
return this.handle.name; |
|||
} |
|||
|
|||
public get kind(): string { |
|||
return this.handle.kind; |
|||
} |
|||
|
|||
public static async openRead(item: StorageItem): Promise<Blob> { |
|||
if (!(item.handle instanceof FileSystemFileHandle)) { |
|||
throw new Error("StorageItem is not a file"); |
|||
} |
|||
|
|||
await item.verityPermissions("read"); |
|||
|
|||
const file = await item.handle.getFile(); |
|||
return file; |
|||
} |
|||
|
|||
public static async openWrite(item: StorageItem): Promise<FileSystemWritableFileStream> { |
|||
if (!(item.handle instanceof FileSystemFileHandle)) { |
|||
throw new Error("StorageItem is not a file"); |
|||
} |
|||
|
|||
await item.verityPermissions("readwrite"); |
|||
|
|||
return await item.handle.createWritable({ keepExistingData: true }); |
|||
} |
|||
|
|||
public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> { |
|||
const file = item.handle instanceof FileSystemFileHandle && |
|||
await item.handle.getFile(); |
|||
|
|||
if (!file) { |
|||
return null; |
|||
} |
|||
|
|||
return { |
|||
Size: file.size, |
|||
LastModified: file.lastModified, |
|||
Type: file.type |
|||
}; |
|||
} |
|||
|
|||
public static async getItems(item: StorageItem): Promise<StorageItems> { |
|||
if (item.handle.kind !== "directory") { |
|||
return new StorageItems([]); |
|||
} |
|||
|
|||
const items: StorageItem[] = []; |
|||
for await (const [, value] of (item.handle as any).entries()) { |
|||
items.push(new StorageItem(value)); |
|||
} |
|||
return new StorageItems(items); |
|||
} |
|||
|
|||
private async verityPermissions(mode: FileSystemPermissionMode): Promise<void | never> { |
|||
if (await this.handle.queryPermission({ mode }) === "granted") { |
|||
return; |
|||
} |
|||
|
|||
if (await this.handle.requestPermission({ mode }) === "denied") { |
|||
throw new Error("Permissions denied"); |
|||
} |
|||
} |
|||
|
|||
public static async saveBookmark(item: StorageItem): Promise<string> { |
|||
// If file was previously bookmarked, just return old one.
|
|||
if (item.bookmarkId) { |
|||
return item.bookmarkId; |
|||
} |
|||
|
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
const key = await connection.put(fileBookmarksStore, item.handle, item.generateBookmarkId()); |
|||
return key as string; |
|||
} finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
|
|||
public static async deleteBookmark(item: StorageItem): Promise<void> { |
|||
if (!item.bookmarkId) { |
|||
return; |
|||
} |
|||
|
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
await connection.delete(fileBookmarksStore, item.bookmarkId); |
|||
} finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
|
|||
private generateBookmarkId(): string { |
|||
return Date.now().toString(36) + Math.random().toString(36).substring(2); |
|||
} |
|||
} |
|||
|
|||
export class StorageItems { |
|||
constructor(private readonly items: StorageItem[]) { } |
|||
|
|||
public static itemsArray(instance: StorageItems): StorageItem[] { |
|||
return instance.items; |
|||
} |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; |
|||
import { StorageItem, StorageItems } from "./storageItem"; |
|||
|
|||
declare global { |
|||
type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; |
|||
type StartInDirectory = WellKnownDirectory | FileSystemHandle; |
|||
interface OpenFilePickerOptions { |
|||
startIn?: StartInDirectory; |
|||
} |
|||
interface SaveFilePickerOptions { |
|||
startIn?: StartInDirectory; |
|||
} |
|||
} |
|||
|
|||
export class StorageProvider { |
|||
public static async selectFolderDialog( |
|||
startIn: StorageItem | null): Promise<StorageItem> { |
|||
// 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined.
|
|||
const options: DirectoryPickerOptions = { |
|||
startIn: (startIn?.handle ?? undefined) |
|||
}; |
|||
|
|||
const handle = await window.showDirectoryPicker(options); |
|||
return new StorageItem(handle); |
|||
} |
|||
|
|||
public static async openFileDialog( |
|||
startIn: StorageItem | null, multiple: boolean, |
|||
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItems> { |
|||
const options: OpenFilePickerOptions = { |
|||
startIn: (startIn?.handle ?? undefined), |
|||
multiple, |
|||
excludeAcceptAllOption, |
|||
types: (types ?? undefined) |
|||
}; |
|||
|
|||
const handles = await window.showOpenFilePicker(options); |
|||
return new StorageItems(handles.map((handle: FileSystemHandle) => new StorageItem(handle))); |
|||
} |
|||
|
|||
public static async saveFileDialog( |
|||
startIn: StorageItem | null, suggestedName: string | null, |
|||
types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise<StorageItem> { |
|||
const options: SaveFilePickerOptions = { |
|||
startIn: (startIn?.handle ?? undefined), |
|||
suggestedName: (suggestedName ?? undefined), |
|||
excludeAcceptAllOption, |
|||
types: (types ?? undefined) |
|||
}; |
|||
|
|||
const handle = await window.showSaveFilePicker(options); |
|||
return new StorageItem(handle); |
|||
} |
|||
|
|||
public static async openBookmark(key: string): Promise<StorageItem | null> { |
|||
const connection = await avaloniaDb.connect(); |
|||
try { |
|||
const handle = await connection.get(fileBookmarksStore, key); |
|||
return handle && new StorageItem(handle, key); |
|||
} finally { |
|||
connection.close(); |
|||
} |
|||
} |
|||
|
|||
public static createAcceptType(description: string, mimeTypes: string[]): FilePickerAcceptType { |
|||
const accept: Record<string, string[]> = {}; |
|||
mimeTypes.forEach(a => { accept[a] = []; }); |
|||
return { description, accept }; |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,22 @@ |
|||
{ |
|||
"name": "avalonia.web", |
|||
"scripts": { |
|||
"typecheck": "npx tsc -noEmit", |
|||
"eslint": "npx eslint . --fix", |
|||
"prebuild": "npm-run-all typecheck eslint", |
|||
"build": "node build.js" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/emscripten": "^1.39.6", |
|||
"@types/wicg-file-system-access": "^2020.9.5", |
|||
"@typescript-eslint/eslint-plugin": "^5.38.1", |
|||
"esbuild": "^0.15.7", |
|||
"eslint": "^8.24.0", |
|||
"eslint-config-standard-with-typescript": "^23.0.0", |
|||
"eslint-plugin-import": "^2.26.0", |
|||
"eslint-plugin-n": "^15.3.0", |
|||
"eslint-plugin-promise": "^6.0.1", |
|||
"npm-run-all": "^4.1.5", |
|||
"typescript": "^4.8.3" |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "es2016", |
|||
"module": "es2020", |
|||
"strict": true, |
|||
"sourceMap": true, |
|||
"noEmitOnError": true, |
|||
"isolatedModules": true, // we need it for esbuild |
|||
"lib": [ |
|||
"dom", |
|||
"es2016", |
|||
"esnext.asynciterable" |
|||
] |
|||
}, |
|||
"exclude": [ |
|||
"node_modules" |
|||
] |
|||
} |
|||
|
|||
@ -0,0 +1,270 @@ |
|||
// See https://raw.githubusercontent.com/dotnet/runtime/main/src/mono/wasm/runtime/dotnet.d.ts
|
|||
|
|||
//! Licensed to the .NET Foundation under one or more agreements.
|
|||
//! The .NET Foundation licenses this file to you under the MIT license.
|
|||
//!
|
|||
//! This is generated file, see src/mono/wasm/runtime/rollup.config.js
|
|||
|
|||
//! This is not considered public API with backward compatibility guarantees.
|
|||
|
|||
interface DotnetHostBuilder { |
|||
withConfig(config: MonoConfig): DotnetHostBuilder; |
|||
withConfigSrc(configSrc: string): DotnetHostBuilder; |
|||
withApplicationArguments(...args: string[]): DotnetHostBuilder; |
|||
withEnvironmentVariable(name: string, value: string): DotnetHostBuilder; |
|||
withEnvironmentVariables(variables: { |
|||
[i: string]: string; |
|||
}): DotnetHostBuilder; |
|||
withVirtualWorkingDirectory(vfsPath: string): DotnetHostBuilder; |
|||
withDiagnosticTracing(enabled: boolean): DotnetHostBuilder; |
|||
withDebugging(level: number): DotnetHostBuilder; |
|||
withMainAssembly(mainAssemblyName: string): DotnetHostBuilder; |
|||
withApplicationArgumentsFromQuery(): DotnetHostBuilder; |
|||
create(): Promise<RuntimeAPI>; |
|||
run(): Promise<number>; |
|||
} |
|||
|
|||
declare interface NativePointer { |
|||
__brandNativePointer: "NativePointer"; |
|||
} |
|||
declare interface VoidPtr extends NativePointer { |
|||
__brand: "VoidPtr"; |
|||
} |
|||
declare interface CharPtr extends NativePointer { |
|||
__brand: "CharPtr"; |
|||
} |
|||
declare interface Int32Ptr extends NativePointer { |
|||
__brand: "Int32Ptr"; |
|||
} |
|||
declare interface EmscriptenModule { |
|||
HEAP8: Int8Array; |
|||
HEAP16: Int16Array; |
|||
HEAP32: Int32Array; |
|||
HEAPU8: Uint8Array; |
|||
HEAPU16: Uint16Array; |
|||
HEAPU32: Uint32Array; |
|||
HEAPF32: Float32Array; |
|||
HEAPF64: Float64Array; |
|||
_malloc(size: number): VoidPtr; |
|||
_free(ptr: VoidPtr): void; |
|||
print(message: string): void; |
|||
printErr(message: string): void; |
|||
ccall<T>(ident: string, returnType?: string | null, argTypes?: string[], args?: any[], opts?: any): T; |
|||
cwrap<T extends Function>(ident: string, returnType: string, argTypes?: string[], opts?: any): T; |
|||
cwrap<T extends Function>(ident: string, ...args: any[]): T; |
|||
setValue(ptr: VoidPtr, value: number, type: string, noSafe?: number | boolean): void; |
|||
setValue(ptr: Int32Ptr, value: number, type: string, noSafe?: number | boolean): void; |
|||
getValue(ptr: number, type: string, noSafe?: number | boolean): number; |
|||
UTF8ToString(ptr: CharPtr, maxBytesToRead?: number): string; |
|||
UTF8ArrayToString(u8Array: Uint8Array, idx?: number, maxBytesToRead?: number): string; |
|||
FS_createPath(parent: string, path: string, canRead?: boolean, canWrite?: boolean): string; |
|||
FS_createDataFile(parent: string, name: string, data: TypedArray, canRead: boolean, canWrite: boolean, canOwn?: boolean): string; |
|||
FS_readFile(filename: string, opts: any): any; |
|||
removeRunDependency(id: string): void; |
|||
addRunDependency(id: string): void; |
|||
stackSave(): VoidPtr; |
|||
stackRestore(stack: VoidPtr): void; |
|||
stackAlloc(size: number): VoidPtr; |
|||
ready: Promise<unknown>; |
|||
instantiateWasm?: InstantiateWasmCallBack; |
|||
preInit?: (() => any)[] | (() => any); |
|||
preRun?: (() => any)[] | (() => any); |
|||
onRuntimeInitialized?: () => any; |
|||
postRun?: (() => any)[] | (() => any); |
|||
onAbort?: { |
|||
(error: any): void; |
|||
}; |
|||
} |
|||
declare type InstantiateWasmSuccessCallback = (instance: WebAssembly.Instance, module: WebAssembly.Module) => void; |
|||
declare type InstantiateWasmCallBack = (imports: WebAssembly.Imports, successCallback: InstantiateWasmSuccessCallback) => any; |
|||
declare type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array; |
|||
|
|||
declare type MonoConfig = { |
|||
/** |
|||
* The subfolder containing managed assemblies and pdbs. This is relative to dotnet.js script. |
|||
*/ |
|||
assemblyRootFolder?: string; |
|||
/** |
|||
* A list of assets to load along with the runtime. |
|||
*/ |
|||
assets?: AssetEntry[]; |
|||
/** |
|||
* Additional search locations for assets. |
|||
*/ |
|||
remoteSources?: string[]; |
|||
/** |
|||
* It will not fail the startup is .pdb files can't be downloaded |
|||
*/ |
|||
ignorePdbLoadErrors?: boolean; |
|||
/** |
|||
* We are throttling parallel downloads in order to avoid net::ERR_INSUFFICIENT_RESOURCES on chrome. The default value is 16. |
|||
*/ |
|||
maxParallelDownloads?: number; |
|||
/** |
|||
* Name of the assembly with main entrypoint |
|||
*/ |
|||
mainAssemblyName?: string; |
|||
/** |
|||
* Configures the runtime's globalization mode |
|||
*/ |
|||
globalizationMode?: GlobalizationMode; |
|||
/** |
|||
* debugLevel > 0 enables debugging and sets the debug log level to debugLevel |
|||
* debugLevel == 0 disables debugging and enables interpreter optimizations |
|||
* debugLevel < 0 enabled debugging and disables debug logging. |
|||
*/ |
|||
debugLevel?: number; |
|||
/** |
|||
* Enables diagnostic log messages during startup |
|||
*/ |
|||
diagnosticTracing?: boolean; |
|||
/** |
|||
* Dictionary-style Object containing environment variables |
|||
*/ |
|||
environmentVariables?: { |
|||
[i: string]: string; |
|||
}; |
|||
/** |
|||
* initial number of workers to add to the emscripten pthread pool |
|||
*/ |
|||
pthreadPoolSize?: number; |
|||
}; |
|||
interface ResourceRequest { |
|||
name: string; |
|||
behavior: AssetBehaviours; |
|||
resolvedUrl?: string; |
|||
hash?: string; |
|||
} |
|||
interface LoadingResource { |
|||
name: string; |
|||
url: string; |
|||
response: Promise<Response>; |
|||
} |
|||
interface AssetEntry extends ResourceRequest { |
|||
/** |
|||
* If specified, overrides the path of the asset in the virtual filesystem and similar data structures once downloaded. |
|||
*/ |
|||
virtualPath?: string; |
|||
/** |
|||
* Culture code |
|||
*/ |
|||
culture?: string; |
|||
/** |
|||
* If true, an attempt will be made to load the asset from each location in MonoConfig.remoteSources. |
|||
*/ |
|||
loadRemote?: boolean; |
|||
/** |
|||
* If true, the runtime startup would not fail if the asset download was not successful. |
|||
*/ |
|||
isOptional?: boolean; |
|||
/** |
|||
* If provided, runtime doesn't have to fetch the data. |
|||
* Runtime would set the buffer to null after instantiation to free the memory. |
|||
*/ |
|||
buffer?: ArrayBuffer; |
|||
/** |
|||
* It's metadata + fetch-like Promise<Response> |
|||
* If provided, the runtime doesn't have to initiate the download. It would just await the response. |
|||
*/ |
|||
pendingDownload?: LoadingResource; |
|||
} |
|||
declare type AssetBehaviours = "resource" | "assembly" | "pdb" | "heap" | "icu" | "vfs" | "dotnetwasm" | "js-module-threads"; |
|||
declare type GlobalizationMode = "icu" | // load ICU globalization data from any runtime assets with behavior "icu".
|
|||
"invariant" | // operate in invariant globalization mode.
|
|||
"auto"; |
|||
declare type DotnetModuleConfig = { |
|||
disableDotnet6Compatibility?: boolean; |
|||
config?: MonoConfig; |
|||
configSrc?: string; |
|||
onConfigLoaded?: (config: MonoConfig) => void | Promise<void>; |
|||
onDotnetReady?: () => void | Promise<void>; |
|||
imports?: any; |
|||
exports?: string[]; |
|||
downloadResource?: (request: ResourceRequest) => LoadingResource | undefined; |
|||
} & Partial<EmscriptenModule>; |
|||
declare type APIType = { |
|||
runMain: (mainAssemblyName: string, args: string[]) => Promise<number>; |
|||
runMainAndExit: (mainAssemblyName: string, args: string[]) => Promise<number>; |
|||
setEnvironmentVariable: (name: string, value: string) => void; |
|||
getAssemblyExports(assemblyName: string): Promise<any>; |
|||
setModuleImports(moduleName: string, moduleImports: any): void; |
|||
getConfig: () => MonoConfig; |
|||
setHeapB32: (offset: NativePointer, value: number | boolean) => void; |
|||
setHeapU8: (offset: NativePointer, value: number) => void; |
|||
setHeapU16: (offset: NativePointer, value: number) => void; |
|||
setHeapU32: (offset: NativePointer, value: NativePointer | number) => void; |
|||
setHeapI8: (offset: NativePointer, value: number) => void; |
|||
setHeapI16: (offset: NativePointer, value: number) => void; |
|||
setHeapI32: (offset: NativePointer, value: number) => void; |
|||
setHeapI52: (offset: NativePointer, value: number) => void; |
|||
setHeapU52: (offset: NativePointer, value: number) => void; |
|||
setHeapI64Big: (offset: NativePointer, value: bigint) => void; |
|||
setHeapF32: (offset: NativePointer, value: number) => void; |
|||
setHeapF64: (offset: NativePointer, value: number) => void; |
|||
getHeapB32: (offset: NativePointer) => boolean; |
|||
getHeapU8: (offset: NativePointer) => number; |
|||
getHeapU16: (offset: NativePointer) => number; |
|||
getHeapU32: (offset: NativePointer) => number; |
|||
getHeapI8: (offset: NativePointer) => number; |
|||
getHeapI16: (offset: NativePointer) => number; |
|||
getHeapI32: (offset: NativePointer) => number; |
|||
getHeapI52: (offset: NativePointer) => number; |
|||
getHeapU52: (offset: NativePointer) => number; |
|||
getHeapI64Big: (offset: NativePointer) => bigint; |
|||
getHeapF32: (offset: NativePointer) => number; |
|||
getHeapF64: (offset: NativePointer) => number; |
|||
}; |
|||
declare type RuntimeAPI = { |
|||
/** |
|||
* @deprecated Please use API object instead. See also MONOType in dotnet-legacy.d.ts |
|||
*/ |
|||
MONO: any; |
|||
/** |
|||
* @deprecated Please use API object instead. See also BINDINGType in dotnet-legacy.d.ts |
|||
*/ |
|||
BINDING: any; |
|||
INTERNAL: any; |
|||
Module: EmscriptenModule; |
|||
runtimeId: number; |
|||
runtimeBuildInfo: { |
|||
productVersion: string; |
|||
gitHash: string; |
|||
buildConfiguration: string; |
|||
}; |
|||
} & APIType; |
|||
declare type ModuleAPI = { |
|||
dotnet: DotnetHostBuilder; |
|||
exit: (code: number, reason?: any) => void; |
|||
}; |
|||
declare function createDotnetRuntime(moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)): Promise<RuntimeAPI>; |
|||
declare type CreateDotnetRuntimeType = typeof createDotnetRuntime; |
|||
|
|||
declare global { |
|||
function getDotnetRuntime(runtimeId: number): RuntimeAPI | undefined; |
|||
} |
|||
|
|||
declare const dotnet: ModuleAPI["dotnet"]; |
|||
declare const exit: ModuleAPI["exit"]; |
|||
|
|||
export { CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, ModuleAPI, MonoConfig, RuntimeAPI, createDotnetRuntime as default, dotnet, exit }; |
|||
|
|||
export interface IMemoryView { |
|||
/** |
|||
* copies elements from provided source to the wasm memory. |
|||
* target has to have the elements of the same type as the underlying C# array. |
|||
* same as TypedArray.set() |
|||
*/ |
|||
set(source: TypedArray, targetOffset?: number): void; |
|||
/** |
|||
* copies elements from wasm memory to provided target. |
|||
* target has to have the elements of the same type as the underlying C# array. |
|||
*/ |
|||
copyTo(target: TypedArray, sourceOffset?: number): void; |
|||
/** |
|||
* same as TypedArray.slice() |
|||
*/ |
|||
slice(start?: number, end?: number): TypedArray; |
|||
|
|||
get length(): number; |
|||
get byteLength(): number; |
|||
} |
|||
Loading…
Reference in new issue