Quellcode durchsuchen

Merge branch 'master' into feature/handleTabstopps

Benedikt Stebner vor 4 Jahren
Ursprung
Commit
d6fb97c01b
69 geänderte Dateien mit 3064 neuen und 64 gelöschten Zeilen
  1. 3 0
      .gitignore
  2. 1 1
      .ncrunch/Avalonia.Win32.v3.ncrunchproject
  3. 61 29
      Avalonia.sln
  4. 1 0
      NuGet.Config
  5. 5 5
      build/ApiDiff.props
  6. 3 2
      build/HarfBuzzSharp.props
  7. 3 2
      build/SkiaSharp.props
  8. 1 1
      native/Avalonia.Native/src/OSX/dnd.mm
  9. 10 0
      samples/ControlCatalog.Web/App.razor
  10. 14 0
      samples/ControlCatalog.Web/App.razor.cs
  11. 38 0
      samples/ControlCatalog.Web/ControlCatalog.Web.csproj
  12. 28 0
      samples/ControlCatalog.Web/LinkerConfig.xml
  13. 5 0
      samples/ControlCatalog.Web/Pages/Index.razor
  14. 29 0
      samples/ControlCatalog.Web/Program.cs
  15. 30 0
      samples/ControlCatalog.Web/Properties/launchSettings.json
  16. 7 0
      samples/ControlCatalog.Web/Shared/MainLayout.razor
  17. 70 0
      samples/ControlCatalog.Web/Shared/MainLayout.razor.css
  18. 11 0
      samples/ControlCatalog.Web/_Imports.razor
  19. 90 0
      samples/ControlCatalog.Web/wwwroot/css/app.css
  20. BIN
      samples/ControlCatalog.Web/wwwroot/favicon.ico
  21. 23 0
      samples/ControlCatalog.Web/wwwroot/index.html
  22. 1 0
      samples/ControlCatalog.Web/wwwroot/js/app.js
  23. 7 2
      samples/ControlCatalog/Pages/DragAndDropPage.xaml
  24. 27 6
      samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs
  25. 10 0
      src/Avalonia.Controls/Calendar/Calendar.cs
  26. 27 6
      src/Avalonia.Controls/Primitives/Popup.cs
  27. 11 3
      src/Avalonia.Controls/TreeView.cs
  28. 6 2
      src/Avalonia.Controls/TreeViewItem.cs
  29. 1 0
      src/Skia/Avalonia.Skia/Avalonia.Skia.csproj
  30. 1 1
      src/Skia/Avalonia.Skia/SKTypefaceCollection.cs
  31. 1 1
      src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs
  32. BIN
      src/Web/Avalonia.Web.Blazor/Assets/NotoMono-Regular.ttf
  33. BIN
      src/Web/Avalonia.Web.Blazor/Assets/NotoSans-Italic.ttf
  34. 57 0
      src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj
  35. 6 0
      src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.targets
  36. 18 0
      src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs
  37. 19 0
      src/Web/Avalonia.Web.Blazor/AvaloniaView.razor
  38. 375 0
      src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs
  39. 61 0
      src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs
  40. 33 0
      src/Web/Avalonia.Web.Blazor/BlazorSingleViewLifetime.cs
  41. 25 0
      src/Web/Avalonia.Web.Blazor/BlazorSkiaGpu.cs
  42. 37 0
      src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderSession.cs
  43. 47 0
      src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs
  44. 30 0
      src/Web/Avalonia.Web.Blazor/BlazorSkiaSurface.cs
  45. 78 0
      src/Web/Avalonia.Web.Blazor/CustomFontManagerImpl.cs
  46. 20 0
      src/Web/Avalonia.Web.Blazor/Interop/ActionHelper.cs
  47. 87 0
      src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs
  48. 20 0
      src/Web/Avalonia.Web.Blazor/Interop/FloatFloatActionHelper.cs
  49. 41 0
      src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs
  50. 42 0
      src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs
  51. 79 0
      src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs
  52. 61 0
      src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs
  53. 41 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts
  54. 23 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts
  55. 225 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts
  56. 68 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts
  57. 7 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts
  58. 56 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/index.d.ts
  59. 326 0
      src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts
  60. 127 0
      src/Web/Avalonia.Web.Blazor/Keycodes.cs
  61. 16 0
      src/Web/Avalonia.Web.Blazor/ManualTriggerRenderTimer.cs
  62. 165 0
      src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs
  63. 80 0
      src/Web/Avalonia.Web.Blazor/WinStubs.cs
  64. 100 0
      src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs
  65. 1 0
      src/Web/Avalonia.Web.Blazor/_Imports.razor
  66. 14 0
      src/Web/Avalonia.Web.Blazor/tsconfig.json
  67. 108 0
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
  68. 43 3
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs
  69. 3 0
      tests/Avalonia.UnitTests/MockWindowingPlatform.cs

+ 3 - 0
.gitignore

@@ -210,3 +210,6 @@ obj-Skia/
 coc-settings.json
 .ccls-cache
 .ccls
+*.map
+src/Web/Avalonia.Web.Blazor/wwwroot/*.js
+src/Web/Avalonia.Web.Blazor/Interop/Typescript/*.js

+ 1 - 1
.ncrunch/Avalonia.Win32.v3.ncrunchproject

@@ -1,7 +1,7 @@
 <ProjectConfiguration>
   <Settings>
     <AdditionalFilesToIncludeForProject>
-      <Value>..\..\tools\MicroComGenerator\bin\Debug\netcoreapp3.1\**.*</Value>
+      <Value>..\..\tools\MicroComGenerator\bin\Debug\net6.0\**.*</Value>
     </AdditionalFilesToIncludeForProject>
     <HiddenComponentWarnings>
       <Value>MissingOrIgnoredProjectReference</Value>

+ 61 - 29
Avalonia.sln

@@ -1,7 +1,7 @@
 
 Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 16
-VisualStudioVersion = 16.0.29102.190
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
 MinimumVisualStudioVersion = 10.0.40219.1
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Base", "src\Avalonia.Base\Avalonia.Base.csproj", "{B09B78D8-9B26-48B0-9149-D64A2F120F3F}"
 EndProject
@@ -222,11 +222,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Markup.Xaml.Loader
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sandbox", "samples\Sandbox\Sandbox.csproj", "{11BE52AF-E2DD-4CF0-B19A-05285ACAF571}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroComGenerator", "src\tools\MicroComGenerator\MicroComGenerator.csproj", "{AEC9031E-06EA-4A9E-9E7F-7D7C719404DD}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MicroComGenerator", "src\tools\MicroComGenerator\MicroComGenerator.csproj", "{AEC9031E-06EA-4A9E-9E7F-7D7C719404DD}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.MicroCom", "src\Avalonia.MicroCom\Avalonia.MicroCom.csproj", "{FE2F3E5E-1E34-4972-8DC1-5C2C588E5ECE}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.MicroCom", "src\Avalonia.MicroCom\Avalonia.MicroCom.csproj", "{FE2F3E5E-1E34-4972-8DC1-5C2C588E5ECE}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniMvvm", "samples\MiniMvvm\MiniMvvm.csproj", "{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniMvvm", "samples\MiniMvvm\MiniMvvm.csproj", "{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Web.Blazor", "src\Web\Avalonia.Web.Blazor\Avalonia.Web.Blazor.csproj", "{25831348-EB2A-483E-9576-E8F6528674A5}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlCatalog.Web", "samples\ControlCatalog.Web\ControlCatalog.Web.csproj", "{C08E9894-AA92-426E-BF56-033E262CAD3E}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsInteropTest", "samples\interop\WindowsInteropTest\WindowsInteropTest.csproj", "{26A98DA1-D89D-4A95-8152-349F404DA2E2}"
 EndProject
@@ -1996,30 +2002,6 @@ Global
 		{909A8CBD-7D0E-42FD-B841-022AD8925820}.Release|iPhone.Build.0 = Release|Any CPU
 		{909A8CBD-7D0E-42FD-B841-022AD8925820}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
 		{909A8CBD-7D0E-42FD-B841-022AD8925820}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|Any CPU.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|iPhone.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|iPhone.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|iPhone.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|Any CPU.Build.0 = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|iPhone.ActiveCfg = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|iPhone.Build.0 = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
-		{28F18757-C3E6-4BBE-A37D-11BA2AB9177C}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
 		{11BE52AF-E2DD-4CF0-B19A-05285ACAF571}.Ad-Hoc|Any CPU.ActiveCfg = Release|Any CPU
 		{11BE52AF-E2DD-4CF0-B19A-05285ACAF571}.Ad-Hoc|Any CPU.Build.0 = Release|Any CPU
 		{11BE52AF-E2DD-4CF0-B19A-05285ACAF571}.Ad-Hoc|iPhone.ActiveCfg = Release|Any CPU
@@ -2116,6 +2098,54 @@ Global
 		{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhone.Build.0 = Release|Any CPU
 		{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
 		{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|iPhone.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|iPhone.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|Any CPU.Build.0 = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|iPhone.ActiveCfg = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|iPhone.Build.0 = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+		{25831348-EB2A-483E-9576-E8F6528674A5}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|iPhone.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|iPhone.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|iPhone.ActiveCfg = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|iPhone.Build.0 = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+		{C08E9894-AA92-426E-BF56-033E262CAD3E}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
 		{26A98DA1-D89D-4A95-8152-349F404DA2E2}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
 		{26A98DA1-D89D-4A95-8152-349F404DA2E2}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
 		{26A98DA1-D89D-4A95-8152-349F404DA2E2}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
@@ -2200,6 +2230,8 @@ Global
 		{11BE52AF-E2DD-4CF0-B19A-05285ACAF571} = {9B9E3891-2366-4253-A952-D08BCEB71098}
 		{AEC9031E-06EA-4A9E-9E7F-7D7C719404DD} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637}
 		{BC594FD5-4AF2-409E-A1E6-04123F54D7C5} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+		{25831348-EB2A-483E-9576-E8F6528674A5} = {86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}
+		{C08E9894-AA92-426E-BF56-033E262CAD3E} = {9B9E3891-2366-4253-A952-D08BCEB71098}
 		{26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution

+ 1 - 0
NuGet.Config

@@ -5,5 +5,6 @@
     <clear />
     <add key="api.nuget.org" value="https://api.nuget.org/v3/index.json" />
     <add key="dotnet-eng" value="https://nuget.avaloniaui.net/repository/avalonia-devdeps/index.json" protocolVersion="3" />
+    <add key="skiasharp" value="https://aka.ms/skiasharp-eap/index.json" protocolVersion="3" />
   </packageSources>
 </configuration>

+ 5 - 5
build/ApiDiff.props

@@ -4,9 +4,9 @@
     <NugetPackageName Condition="'$(PackageId)' != ''">$(PackageId)</NugetPackageName>
     <NugetPackageName Condition="'$(PackageId)' == ''">Avalonia</NugetPackageName>
   </PropertyGroup>
-    <ItemGroup>
-      <PackageDownload Include="$(NugetPackageName)" Version="[$(ApiContractPackageVersion)]" />
-      <PackageReference Include="Microsoft.DotNet.ApiCompat" Version="5.0.0-beta.20372.2" PrivateAssets="All" />
-      <ResolvedMatchingContract Include="$(NuGetPackageRoot)\$(NugetPackageName.ToLowerInvariant())\$(ApiContractPackageVersion)\lib\$(TargetFramework)\$(AssemblyName).dll" />
-    </ItemGroup>
+  <ItemGroup>
+    <PackageDownload Include="$(NugetPackageName)" Version="[$(ApiContractPackageVersion)]" />
+    <PackageReference Include="Microsoft.DotNet.ApiCompat" Version="5.0.0-beta.20372.2" PrivateAssets="All" />
+    <ResolvedMatchingContract Include="$(NuGetPackageRoot)\$(NugetPackageName.ToLowerInvariant())\$(ApiContractPackageVersion)\lib\$(TargetFramework)\$(AssemblyName).dll" />
+  </ItemGroup>
 </Project>

+ 3 - 2
build/HarfBuzzSharp.props

@@ -1,6 +1,7 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="HarfBuzzSharp" Version="2.8.2-preview.155" />
-    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.8.2-preview.155" />
+    <PackageReference Include="HarfBuzzSharp" Version="2.8.2-preview.171" />
+    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.8.2-preview.171" />
+    <PackageReference Condition="'$(IncludeWasmSkia)' == 'true'" Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="2.8.2-preview.171"/>
   </ItemGroup>
 </Project>

+ 3 - 2
build/SkiaSharp.props

@@ -1,6 +1,7 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="SkiaSharp" Version="2.88.0-preview.155" />
-    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="2.88.0-preview.155" />
+    <PackageReference Include="SkiaSharp" Version="2.88.0-preview.171" />
+    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="2.88.0-preview.171" />
+    <PackageReference Condition="'$(IncludeWasmSkia)' == 'true'" Include="SkiaSharp.NativeAssets.WebAssembly" Version="2.88.0-preview.171"/>
   </ItemGroup>
 </Project>

+ 1 - 1
native/Avalonia.Native/src/OSX/dnd.mm

@@ -32,7 +32,7 @@ extern NSString* GetAvnCustomDataType()
 
 - (NSDragOperation)draggingSession:(nonnull NSDraggingSession *)session sourceOperationMaskForDraggingContext:(NSDraggingContext)context
 {
-    return NSDragOperationCopy;
+    return _operation;
 }
 
 - (AvnDndSource*) initWithOperation: (NSDragOperation)operation

+ 10 - 0
samples/ControlCatalog.Web/App.razor

@@ -0,0 +1,10 @@
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
+    <Found Context="routeData">
+        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
+    </Found>
+    <NotFound>
+        <LayoutView Layout="@typeof(MainLayout)">
+            <p>Sorry, there's nothing at this address.</p>
+        </LayoutView>
+    </NotFound>
+</Router>

+ 14 - 0
samples/ControlCatalog.Web/App.razor.cs

@@ -0,0 +1,14 @@
+using Avalonia.Web.Blazor;
+
+namespace ControlCatalog.Web;
+
+public partial class App
+{
+    protected override void OnParametersSet()
+    {
+        WebAppBuilder.Configure<ControlCatalog.App>()
+            .SetupWithSingleViewLifetime();
+
+        base.OnParametersSet();
+    }
+}

+ 38 - 0
samples/ControlCatalog.Web/ControlCatalog.Web.csproj

@@ -0,0 +1,38 @@
+<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <BlazorLinkerDescriptor Include="LinkerConfig.xml" />
+  </ItemGroup>
+
+  <!-- In debug, make builds faster by reducing optimizations -->
+  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
+    <WasmNativeStrip>false</WasmNativeStrip>
+    <EmccCompileOptimizationFlag>-O1</EmccCompileOptimizationFlag>
+    <RunAOTCompilation>false</RunAOTCompilation>
+  </PropertyGroup>
+
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
+    <Optimize>true</Optimize>
+    <WasmNativeStrip>true</WasmNativeStrip>
+    <EmccCompileOptimizationFlag>-O3</EmccCompileOptimizationFlag>
+    <EmccLinkOptimizationFlag>-O3</EmccLinkOptimizationFlag>
+    <RunAOTCompilation>false</RunAOTCompilation>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="6.0.0"/>
+    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="6.0.0" PrivateAssets="all"/>
+  </ItemGroup>
+
+  <Import Project="..\..\src\Web\Avalonia.Web.Blazor\Avalonia.Web.Blazor.targets" />
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Web\Avalonia.Web.Blazor\Avalonia.Web.Blazor.csproj"/>
+    <ProjectReference Include="..\ControlCatalog\ControlCatalog.csproj"/>
+  </ItemGroup>
+
+</Project>

+ 28 - 0
samples/ControlCatalog.Web/LinkerConfig.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+  This file specifies which parts of the BCL or Blazor packages must not be
+  stripped by the IL Linker even if they aren't referenced by user code.
+-->
+<linker>
+  <assembly fullname="mscorlib">
+    <!--
+      Preserve the methods in WasmRuntime because its methods are called by 
+      JavaScript client-side code to implement timers.
+      Fixes: https://github.com/dotnet/blazor/issues/239
+    -->
+    <type fullname="System.Threading.WasmRuntime"/>
+  </assembly>
+
+  <assembly fullname="System.Core">
+    <!--
+      System.Linq.Expressions* is required by Json.NET and any 
+      expression.Compile caller. The assembly isn't stripped.
+    -->
+    <type fullname="System.Linq.Expressions*"/>
+  </assembly>
+  <!--
+    In this example, the app's entry point assembly is listed. The assembly
+    isn't stripped by the IL Linker.
+  -->
+  <assembly fullname="ControlCatalog" preserve="All" />
+</linker>

+ 5 - 0
samples/ControlCatalog.Web/Pages/Index.razor

@@ -0,0 +1,5 @@
+@page "/"
+
+@using Avalonia.Web.Blazor
+
+<AvaloniaView />

+ 29 - 0
samples/ControlCatalog.Web/Program.cs

@@ -0,0 +1,29 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using ControlCatalog.Web;
+
+public class Program
+{
+    public static async Task  Main(string[] args)
+    {
+        await CreateHostBuilder(args).Build().RunAsync();
+    }
+
+    public static WebAssemblyHostBuilder CreateHostBuilder(string[] args)
+    {
+        var builder = WebAssemblyHostBuilder.CreateDefault(args);
+        
+        builder.RootComponents.Add<App>("#app");
+
+        builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+
+        return builder;
+    }
+}
+
+
+
+

+ 30 - 0
samples/ControlCatalog.Web/Properties/launchSettings.json

@@ -0,0 +1,30 @@
+{
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:13961",
+      "sslPort": 44319
+    }
+  },
+  "profiles": {
+    "ControlCatalog.Web - IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "ControlCatalog.Web": {
+      "commandName": "Project",
+      "dotnetRunMessages": "true",
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "applicationUrl": "https://localhost:5001;http://localhost:5000",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}

+ 7 - 0
samples/ControlCatalog.Web/Shared/MainLayout.razor

@@ -0,0 +1,7 @@
+@inherits LayoutComponentBase
+
+<div class="page">
+    <div class="main">
+        @Body
+    </div>
+</div>

+ 70 - 0
samples/ControlCatalog.Web/Shared/MainLayout.razor.css

@@ -0,0 +1,70 @@
+.page {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+}
+
+.main {
+    flex: 1;
+}
+
+.sidebar {
+    background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
+}
+
+.top-row {
+    background-color: #f7f7f7;
+    border-bottom: 1px solid #d6d5d5;
+    justify-content: flex-end;
+    height: 3.5rem;
+    display: flex;
+    align-items: center;
+}
+
+    .top-row ::deep a, .top-row .btn-link {
+        white-space: nowrap;
+        margin-left: 1.5rem;
+    }
+
+    .top-row a:first-child {
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+
+@media (max-width: 640.98px) {
+    .top-row:not(.auth) {
+        display: none;
+    }
+
+    .top-row.auth {
+        justify-content: space-between;
+    }
+
+    .top-row a, .top-row .btn-link {
+        margin-left: 0;
+    }
+}
+
+@media (min-width: 641px) {
+    .page {
+        flex-direction: row;
+    }
+
+    .sidebar {
+        width: 250px;
+        height: 100vh;
+        position: sticky;
+        top: 0;
+    }
+
+    .top-row {
+        position: sticky;
+        top: 0;
+        z-index: 1;
+    }
+
+    .main > div {
+        padding-left: 2rem !important;
+        padding-right: 1.5rem !important;
+    }
+}

+ 11 - 0
samples/ControlCatalog.Web/_Imports.razor

@@ -0,0 +1,11 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.WebAssembly.Http
+@using Microsoft.JSInterop
+@using ControlCatalog.Web
+@using ControlCatalog.Web.Shared
+@using SkiaSharp

+ 90 - 0
samples/ControlCatalog.Web/wwwroot/css/app.css

@@ -0,0 +1,90 @@
+html, body {
+    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+    margin: 0;
+    height: 100vh;
+    overflow: hidden;
+    touch-action: none;
+}
+
+a, .btn-link {
+    color: #0366d6;
+}
+
+.btn-primary {
+    color: #fff;
+    background-color: #1b6ec2;
+    border-color: #1861ac;
+}
+
+.content {
+    padding-top: 1.1rem;
+}
+
+.valid.modified:not([type=checkbox]) {
+    outline: 1px solid #26b050;
+}
+
+.invalid {
+    outline: 1px solid red;
+}
+
+.validation-message {
+    color: red;
+}
+
+#blazor-error-ui {
+    background: lightyellow;
+    bottom: 0;
+    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+    display: none;
+    left: 0;
+    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+    position: fixed;
+    width: 100%;
+    z-index: 1000;
+}
+
+    #blazor-error-ui .dismiss {
+        cursor: pointer;
+        position: absolute;
+        right: 0.75rem;
+        top: 0.5rem;
+    }
+
+.canvas-container {
+    opacity:1;
+    background-color:#ccc;
+    position:fixed;
+    width:100%;
+    height:100%;
+    top:0px;
+    left:0px;
+    z-index:500;
+}
+
+canvas
+{
+    opacity:1;
+    background-color:#ccc;
+    position:fixed;
+    width:100%;
+    height:100%;
+    top:0px;
+    left:0px;
+    z-index:500;
+}
+
+#app, .page {
+    height: 100%;
+}
+
+.overlay{
+    opacity:0.0;
+    background-color:#ccc;
+    position:fixed;
+    width:100vw;
+    height:100vh;
+    top:0px;
+    left:0px;
+    z-index:1000;
+}

BIN
samples/ControlCatalog.Web/wwwroot/favicon.ico


+ 23 - 0
samples/ControlCatalog.Web/wwwroot/index.html

@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <title>Avalonia Sample</title>
+    <base href="/" />
+    <link href="css/app.css" rel="stylesheet" />
+</head>
+
+<body>
+    <div id="app">Powered by Avalonia</div>
+
+    <div id="blazor-error-ui">
+        An unhandled error has occurred.
+        <a href="" class="reload">Reload</a>
+        <a class="dismiss">🗙</a>
+    </div>
+    <script src="js/app.js"></script>
+    <script src="_framework/blazor.webassembly.js"></script>
+</body>
+</html>

+ 1 - 0
samples/ControlCatalog.Web/wwwroot/js/app.js

@@ -0,0 +1 @@
+

+ 7 - 2
samples/ControlCatalog/Pages/DragAndDropPage.xaml

@@ -16,11 +16,16 @@
                 <Border BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="2" Padding="16" Name="DragMeCustom">
                   <TextBlock Name="DragStateCustom">Drag Me (custom)</TextBlock>
                 </Border>
+                <TextBlock Name="DropState"></TextBlock>
             </StackPanel>
 
             <Border Background="{DynamicResource SystemAccentColorDark1}" Padding="16" 
-                    DragDrop.AllowDrop="True">
-                <TextBlock Name="DropState">Drop some text or files here</TextBlock>
+                    DragDrop.AllowDrop="True" Name="CopyTarget">
+                <TextBlock>Drop some text or files here (Copy)</TextBlock>
+            </Border>
+            <Border Background="{DynamicResource SystemAccentColorDark1}" Padding="16" 
+                    DragDrop.AllowDrop="True" Name="MoveTarget">
+                <TextBlock>Drop some text or files here (Move)</TextBlock>
             </Border>
         </StackPanel>
     </StackPanel>

+ 27 - 6
samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs

@@ -21,12 +21,12 @@ namespace ControlCatalog.Pages
 
             int textCount = 0;
             SetupDnd("Text", d => d.Set(DataFormats.Text,
-                $"Text was dragged {++textCount} times"));
+                $"Text was dragged {++textCount} times"), DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
 
-            SetupDnd("Custom", d => d.Set(CustomFormat, "Test123"));
+            SetupDnd("Custom", d => d.Set(CustomFormat, "Test123"), DragDropEffects.Move);
         }
 
-        void SetupDnd(string suffix, Action<DataObject> factory, DragDropEffects effects = DragDropEffects.Copy)
+        void SetupDnd(string suffix, Action<DataObject> factory, DragDropEffects effects)
         {
             var dragMe = this.Find<Border>("DragMe" + suffix);
             var dragState = this.Find<TextBlock>("DragState"+suffix);
@@ -36,9 +36,12 @@ namespace ControlCatalog.Pages
                 var dragData = new DataObject();
                 factory(dragData);
 
-                var result = await DragDrop.DoDragDrop(e, dragData, DragDropEffects.Copy);
+                var result = await DragDrop.DoDragDrop(e, dragData, effects);
                 switch (result)
                 {
+                    case DragDropEffects.Move:
+                        dragState.Text = "Data was moved";
+                        break;
                     case DragDropEffects.Copy:
                         dragState.Text = "Data was copied";
                         break;
@@ -48,13 +51,22 @@ namespace ControlCatalog.Pages
                     case DragDropEffects.None:
                         dragState.Text = "The drag operation was canceled";
                         break;
+                    default:
+                        dragState.Text = "Unknown result";
+                        break;
                 }
             }
 
             void DragOver(object sender, DragEventArgs e)
             {
-                // Only allow Copy or Link as Drop Operations.
-                e.DragEffects = e.DragEffects & (DragDropEffects.Copy | DragDropEffects.Link);
+                if (e.Source is Control c && c.Name == "MoveTarget")
+                {
+                    e.DragEffects = e.DragEffects & (DragDropEffects.Move);
+                }
+                else
+                {
+                    e.DragEffects = e.DragEffects & (DragDropEffects.Copy);
+                }
 
                 // Only allow if the dragged data contains text or filenames.
                 if (!e.Data.Contains(DataFormats.Text)
@@ -65,6 +77,15 @@ namespace ControlCatalog.Pages
 
             void Drop(object sender, DragEventArgs e)
             {
+                if (e.Source is Control c && c.Name == "MoveTarget")
+                {
+                    e.DragEffects = e.DragEffects & (DragDropEffects.Move);
+                }
+                else
+                {
+                    e.DragEffects = e.DragEffects & (DragDropEffects.Copy);
+                }
+                
                 if (e.Data.Contains(DataFormats.Text))
                     _DropState.Text = e.Data.GetText();
                 else if (e.Data.Contains(DataFormats.FileNames))

+ 10 - 0
src/Avalonia.Controls/Calendar/Calendar.cs

@@ -1903,6 +1903,11 @@ namespace Avalonia.Controls
         }
         internal void ProcessPageDownKey(bool shift)
         {
+            if (!shift)
+            {
+                OnNextClick();
+                return;
+            }
             switch (DisplayMode)
             {
                 case CalendarMode.Month:
@@ -1927,6 +1932,11 @@ namespace Avalonia.Controls
         }
         internal void ProcessPageUpKey(bool shift)
         {
+            if (!shift)
+            {
+                OnPreviousClick();
+                return;
+            }
             switch (DisplayMode)
             {
                 case CalendarMode.Month:

+ 27 - 6
src/Avalonia.Controls/Primitives/Popup.cs

@@ -8,6 +8,7 @@ using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives.PopupPositioning;
 using Avalonia.Input;
 using Avalonia.Input.Raw;
+using Avalonia.Layout;
 using Avalonia.LogicalTree;
 using Avalonia.Metadata;
 using Avalonia.Platform;
@@ -397,7 +398,7 @@ namespace Avalonia.Controls.Primitives
             _isOpenRequested = false;
             var popupHost = OverlayPopupHost.CreatePopupHost(placementTarget, DependencyResolver);
 
-            var handlerCleanup = new CompositeDisposable(5);
+            var handlerCleanup = new CompositeDisposable(7);
 
             popupHost.BindConstraints(this, WidthProperty, MinWidthProperty, MaxWidthProperty,
                 HeightProperty, MinHeightProperty, MaxHeightProperty, TopmostProperty).DisposeWith(handlerCleanup);
@@ -425,14 +426,28 @@ namespace Avalonia.Controls.Primitives
                     (x, handler) => x.Deactivated -= handler).DisposeWith(handlerCleanup);
                 
                 SubscribeToEventHandler<IWindowImpl, Action>(window.PlatformImpl, WindowLostFocus,
-                        (x, handler) => x.LostFocus += handler,
-                        (x, handler) => x.LostFocus -= handler).DisposeWith(handlerCleanup);
+                    (x, handler) => x.LostFocus += handler,
+                    (x, handler) => x.LostFocus -= handler).DisposeWith(handlerCleanup);
+
+                SubscribeToEventHandler<IWindowImpl, Action<PixelPoint>>(window.PlatformImpl, WindowPositionChanged,
+                    (x, handler) => x.PositionChanged += handler,
+                    (x, handler) => x.PositionChanged -= handler).DisposeWith(handlerCleanup);
+                    
+                if (placementTarget is Layoutable layoutTarget)
+                {
+                    // If the placement target is moved, update the popup position
+                    SubscribeToEventHandler<Layoutable, EventHandler>(layoutTarget, PlacementTargetLayoutUpdated,
+                        (x, handler) => x.LayoutUpdated += handler,
+                        (x, handler) => x.LayoutUpdated -= handler).DisposeWith(handlerCleanup);
+                }
             }
-            else
+            else if (topLevel is PopupRoot parentPopupRoot)
             {
-                var parentPopupRoot = topLevel as PopupRoot;
+                SubscribeToEventHandler<PopupRoot, EventHandler<PixelPointEventArgs>>(parentPopupRoot, ParentPopupPositionChanged,
+                    (x, handler) => x.PositionChanged += handler,
+                    (x, handler) => x.PositionChanged -= handler).DisposeWith(handlerCleanup);
 
-                if (parentPopupRoot?.Parent is Popup popup)
+                if (parentPopupRoot.Parent is Popup popup)
                 {
                     SubscribeToEventHandler<Popup, EventHandler<EventArgs>>(popup, ParentClosed,
                         (x, handler) => x.Closed += handler,
@@ -797,6 +812,12 @@ namespace Avalonia.Controls.Primitives
                 Close();
         }
 
+        private void WindowPositionChanged(PixelPoint pp) => HandlePositionChange();
+
+        private void PlacementTargetLayoutUpdated(object src, EventArgs e) => HandlePositionChange();
+
+        private void ParentPopupPositionChanged(object src, PixelPointEventArgs e) => HandlePositionChange();
+
         private IgnoreIsOpenScope BeginIgnoringIsOpen()
         {
             return new IgnoreIsOpenScope(this);

+ 11 - 3
src/Avalonia.Controls/TreeView.cs

@@ -392,14 +392,22 @@ namespace Avalonia.Controls
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()
         {
-            var result = new TreeItemContainerGenerator<TreeViewItem>(
+            var result = CreateTreeItemContainerGenerator();
+            result.Index.Materialized += ContainerMaterialized;
+            return result;
+        }
+
+        protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator() =>
+            CreateTreeItemContainerGenerator<TreeViewItem>();
+
+        protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator<TVItem>() where TVItem: TreeViewItem, new()
+        {
+            return new TreeItemContainerGenerator<TVItem>(
                 this,
                 TreeViewItem.HeaderProperty,
                 TreeViewItem.ItemTemplateProperty,
                 TreeViewItem.ItemsProperty,
                 TreeViewItem.IsExpandedProperty);
-            result.Index.Materialized += ContainerMaterialized;
-            return result;
         }
 
         /// <inheritdoc/>

+ 6 - 2
src/Avalonia.Controls/TreeViewItem.cs

@@ -92,9 +92,13 @@ namespace Avalonia.Controls
             (ITreeItemContainerGenerator)base.ItemContainerGenerator;
 
         /// <inheritdoc/>
-        protected override IItemContainerGenerator CreateItemContainerGenerator()
+        protected override IItemContainerGenerator CreateItemContainerGenerator() => CreateTreeItemContainerGenerator<TreeViewItem>();
+
+        /// <inheritdoc/>
+        protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator<TVItem>()
+            where TVItem: TreeViewItem, new()
         {
-            return new TreeItemContainerGenerator<TreeViewItem>(
+            return new TreeItemContainerGenerator<TVItem>(
                 this,
                 TreeViewItem.HeaderProperty,
                 TreeViewItem.ItemTemplateProperty,

+ 1 - 0
src/Skia/Avalonia.Skia/Avalonia.Skia.csproj

@@ -5,6 +5,7 @@
     <AssemblyName>Avalonia.Skia</AssemblyName>
     <PackageId>Avalonia.Skia</PackageId>
     <IncludeLinuxSkia>true</IncludeLinuxSkia>
+    <IncludeWasmSkia>true</IncludeWasmSkia>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
   </PropertyGroup>
   <ItemGroup>

+ 1 - 1
src/Skia/Avalonia.Skia/SKTypefaceCollection.cs

@@ -6,7 +6,7 @@ using SkiaSharp;
 
 namespace Avalonia.Skia
 {
-    internal class SKTypefaceCollection
+    public class SKTypefaceCollection
     {
         private readonly ConcurrentDictionary<Typeface, SKTypeface> _typefaces =
             new ConcurrentDictionary<Typeface, SKTypeface>();

+ 1 - 1
src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs

@@ -7,7 +7,7 @@ using SkiaSharp;
 
 namespace Avalonia.Skia
 {
-    internal static class SKTypefaceCollectionCache
+    public static class SKTypefaceCollectionCache
     {
         private static readonly ConcurrentDictionary<FontFamily, SKTypefaceCollection> s_cachedCollections;
 

BIN
src/Web/Avalonia.Web.Blazor/Assets/NotoMono-Regular.ttf


BIN
src/Web/Avalonia.Web.Blazor/Assets/NotoSans-Italic.ttf


+ 57 - 0
src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj

@@ -0,0 +1,57 @@
+<Project Sdk="Microsoft.NET.Sdk.Razor">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <PackageId>Avalonia.Web.Blazor</PackageId>
+    <LangVersion>preview</LangVersion>
+  </PropertyGroup>
+  
+  <ItemGroup>
+    <SupportedPlatform Include="browser" />
+    <Compile Include="..\..\Shared\PlatformSupport\AssetLoader.cs" />
+  </ItemGroup>
+
+  <PropertyGroup>
+    <TypescriptOutDir>wwwroot</TypescriptOutDir>
+    <TypeScriptNoEmitOnError>true</TypeScriptNoEmitOnError>
+    <TypeScriptNoImplicitReturns>true</TypeScriptNoImplicitReturns>
+  </PropertyGroup>
+
+  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
+    <TypeScriptRemoveComments>false</TypeScriptRemoveComments>
+    <TypeScriptSourceMap>true</TypeScriptSourceMap>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
+    <TypeScriptRemoveComments>true</TypeScriptRemoveComments>
+    <TypeScriptSourceMap>false</TypeScriptSourceMap>
+  </PropertyGroup>
+
+  <Import Project="..\..\..\build\BuildTargets.targets" />
+  <Import Project="..\..\..\build\SkiaSharp.props" />
+  <Import Project="..\..\..\build\HarfBuzzSharp.props" />
+
+  <ItemGroup>
+    <AvaloniaResource Include="Assets\*" />
+    <Content Include="*.props">
+      <Pack>true</Pack>
+      <PackagePath>build\;buildTransitive\</PackagePath>
+    </Content>
+    <Content Include="*.targets">
+      <Pack>true</Pack>
+      <PackagePath>build\;buildTransitive\</PackagePath>
+    </Content>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.0" />
+    <PackageReference Include="Microsoft.TypeScript.MSBuild" Version="4.5.2" PrivateAssets="all" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\Avalonia.Base\Avalonia.Base.csproj" />
+    <ProjectReference Include="..\..\Avalonia.Controls\Avalonia.Controls.csproj" />
+    <ProjectReference Include="..\..\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
+  </ItemGroup>
+</Project>

+ 6 - 0
src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.targets

@@ -0,0 +1,6 @@
+<Project>
+  <ItemGroup>
+    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\2.0.23\libHarfBuzzSharp.a" />
+    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\2.0.23\libSkiaSharp.a" />
+  </ItemGroup>
+</Project>

+ 18 - 0
src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs

@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Platform;
+
+namespace Avalonia.Web.Blazor
+{
+    public class AvaloniaBlazorAppBuilder : AppBuilderBase<AvaloniaBlazorAppBuilder>
+    {
+        public AvaloniaBlazorAppBuilder(IRuntimePlatform platform, Action<AvaloniaBlazorAppBuilder> platformServices)
+            : base(platform, platformServices)
+        {
+        }
+
+        public AvaloniaBlazorAppBuilder() : base(BlazorRuntimePlatform.Instance, BlazorRuntimePlatform.RegisterServices)
+        {
+            UseWindowingSubsystem(BlazorWindowingPlatform.Register);
+        }
+    }
+}

+ 19 - 0
src/Web/Avalonia.Web.Blazor/AvaloniaView.razor

@@ -0,0 +1,19 @@
+<div id="container" class="avalonia-container" tabindex="0" oncontextmenu="return false;"
+     ontouchstart="@OnTouchStart"
+     ontouchend="@OnTouchEnd"
+     ontouchcancel="@OnTouchCancel"
+     ontouchmove="@OnTouchMove"
+     onmousemove="@OnMouseMove"
+     onmousedown="@OnMouseDown"
+     onmouseup="@OnMouseUp"
+     onwheel="@OnWheel"
+     onkeydown="@OnKeyDown"
+     onkeyup="@OnKeyUp">
+    
+    <canvas @ref="_htmlCanvas" @attributes="AdditionalAttributes"/>
+    
+    <input @ref="_inputElement"
+           class="overlay"
+           type="text"
+           oninput="@OnInput"/>
+</div>

+ 375 - 0
src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs

@@ -0,0 +1,375 @@
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Embedding;
+using Avalonia.Input;
+using Avalonia.Input.Raw;
+using Avalonia.Input.TextInput;
+using Avalonia.Web.Blazor.Interop;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.JSInterop;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor
+{
+    public partial class AvaloniaView : ITextInputMethodImpl
+    {
+        private readonly RazorViewTopLevelImpl _topLevelImpl;
+        private EmbeddableControlRoot _topLevel;
+
+        // Interop
+        private SKHtmlCanvasInterop _interop = null!;
+        private SizeWatcherInterop _sizeWatcher = null!;
+        private DpiWatcherInterop _dpiWatcher = null!;
+        private SKHtmlCanvasInterop.GLInfo? _jsGlInfo = null!;
+        private InputHelperInterop _inputHelper = null!;
+        private ElementReference _htmlCanvas;
+        private ElementReference _inputElement;
+        private double _dpi;
+        private SKSize _canvasSize;
+
+        private GRContext? _context;
+        private GRGlInterface? _glInterface;
+        private const SKColorType ColorType = SKColorType.Rgba8888;
+
+        private bool _initialised;
+
+        [Inject] private IJSRuntime Js { get; set; } = null!;
+
+        public AvaloniaView()
+        {
+            _topLevelImpl = new RazorViewTopLevelImpl(this);
+
+            _topLevel = new EmbeddableControlRoot(_topLevelImpl);
+
+            if (Application.Current.ApplicationLifetime is ISingleViewApplicationLifetime lifetime)
+            {
+                _topLevel.Content = lifetime.MainView;
+            }
+        }
+
+        private void OnTouchStart(TouchEventArgs e)
+        {
+            foreach (var touch in e.ChangedTouches)
+            {
+                _topLevelImpl.RawTouchEvent(RawPointerEventType.TouchBegin, new Point(touch.ClientX, touch.ClientY),
+                    GetModifiers(e), touch.Identifier);
+            }
+        }
+
+        private void OnTouchEnd(TouchEventArgs e)
+        {
+            foreach (var touch in e.ChangedTouches)
+            {
+                _topLevelImpl.RawTouchEvent(RawPointerEventType.TouchEnd, new Point(touch.ClientX, touch.ClientY),
+                    GetModifiers(e), touch.Identifier);
+            }
+        }
+
+        private void OnTouchCancel(TouchEventArgs e)
+        {
+            foreach (var touch in e.ChangedTouches)
+            {
+                _topLevelImpl.RawTouchEvent(RawPointerEventType.TouchCancel, new Point(touch.ClientX, touch.ClientY),
+                    GetModifiers(e), touch.Identifier);
+            }
+        }
+
+        private void OnTouchMove(TouchEventArgs e)
+        {
+            foreach (var touch in e.ChangedTouches)
+            {
+                _topLevelImpl.RawTouchEvent(RawPointerEventType.TouchUpdate, new Point(touch.ClientX, touch.ClientY),
+                    GetModifiers(e), touch.Identifier);
+            }
+        }
+
+        private void OnMouseMove(MouseEventArgs e)
+        {
+            _topLevelImpl.RawMouseEvent(RawPointerEventType.Move, new Point(e.ClientX, e.ClientY), GetModifiers(e));
+        }
+
+        private void OnMouseUp(MouseEventArgs e)
+        {
+            RawPointerEventType type = default;
+
+            switch (e.Button)
+            {
+                case 0:
+                    type = RawPointerEventType.LeftButtonUp;
+                    break;
+
+                case 1:
+                    type = RawPointerEventType.MiddleButtonUp;
+                    break;
+
+                case 2:
+                    type = RawPointerEventType.RightButtonUp;
+                    break;
+            }
+
+            _topLevelImpl.RawMouseEvent(type, new Point(e.ClientX, e.ClientY), GetModifiers(e));
+        }
+
+        private void OnMouseDown(MouseEventArgs e)
+        {
+            RawPointerEventType type = default;
+
+            switch (e.Button)
+            {
+                case 0:
+                    type = RawPointerEventType.LeftButtonDown;
+                    break;
+
+                case 1:
+                    type = RawPointerEventType.MiddleButtonDown;
+                    break;
+
+                case 2:
+                    type = RawPointerEventType.RightButtonDown;
+                    break;
+            }
+
+            _topLevelImpl.RawMouseEvent(type, new Point(e.ClientX, e.ClientY), GetModifiers(e));
+        }
+
+        private void OnWheel(WheelEventArgs e)
+        {
+            _topLevelImpl.RawMouseWheelEvent(new Point(e.ClientX, e.ClientY),
+                new Vector(-(e.DeltaX / 50), -(e.DeltaY / 50)), GetModifiers(e));
+        }
+
+        private static RawInputModifiers GetModifiers(WheelEventArgs e)
+        {
+            var modifiers = RawInputModifiers.None;
+
+            if (e.CtrlKey)
+                modifiers |= RawInputModifiers.Control;
+            if (e.AltKey)
+                modifiers |= RawInputModifiers.Alt;
+            if (e.ShiftKey)
+                modifiers |= RawInputModifiers.Shift;
+            if (e.MetaKey)
+                modifiers |= RawInputModifiers.Meta;
+
+            if ((e.Buttons & 1L) == 1)
+                modifiers |= RawInputModifiers.LeftMouseButton;
+
+            if ((e.Buttons & 2L) == 2)
+                modifiers |= RawInputModifiers.RightMouseButton;
+
+            if ((e.Buttons & 4L) == 4)
+                modifiers |= RawInputModifiers.MiddleMouseButton;
+
+            return modifiers;
+        }
+
+        private static RawInputModifiers GetModifiers(TouchEventArgs e)
+        {
+            var modifiers = RawInputModifiers.None;
+
+            if (e.CtrlKey)
+                modifiers |= RawInputModifiers.Control;
+            if (e.AltKey)
+                modifiers |= RawInputModifiers.Alt;
+            if (e.ShiftKey)
+                modifiers |= RawInputModifiers.Shift;
+            if (e.MetaKey)
+                modifiers |= RawInputModifiers.Meta;
+
+            return modifiers;
+        }
+
+        private static RawInputModifiers GetModifiers(MouseEventArgs e)
+        {
+            var modifiers = RawInputModifiers.None;
+
+            if (e.CtrlKey)
+                modifiers |= RawInputModifiers.Control;
+            if (e.AltKey)
+                modifiers |= RawInputModifiers.Alt;
+            if (e.ShiftKey)
+                modifiers |= RawInputModifiers.Shift;
+            if (e.MetaKey)
+                modifiers |= RawInputModifiers.Meta;
+
+            if ((e.Buttons & 1L) == 1)
+                modifiers |= RawInputModifiers.LeftMouseButton;
+
+            if ((e.Buttons & 2L) == 2)
+                modifiers |= RawInputModifiers.RightMouseButton;
+
+            if ((e.Buttons & 4L) == 4)
+                modifiers |= RawInputModifiers.MiddleMouseButton;
+
+            return modifiers;
+        }
+
+        private static RawInputModifiers GetModifiers(KeyboardEventArgs e)
+        {
+            var modifiers = RawInputModifiers.None;
+
+            if (e.CtrlKey)
+                modifiers |= RawInputModifiers.Control;
+            if (e.AltKey)
+                modifiers |= RawInputModifiers.Alt;
+            if (e.ShiftKey)
+                modifiers |= RawInputModifiers.Shift;
+            if (e.MetaKey)
+                modifiers |= RawInputModifiers.Meta;
+
+            return modifiers;
+        }
+
+        private void OnKeyDown(KeyboardEventArgs e)
+        {
+            _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyDown, e.Key, GetModifiers(e));
+        }
+
+        private void OnKeyUp(KeyboardEventArgs e)
+        {
+            _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyUp, e.Code, GetModifiers(e));
+        }
+
+        private void OnInput(ChangeEventArgs e)
+        {
+            if (e.Value != null)
+            {
+                var inputData = e.Value.ToString();
+                if (inputData != null)
+                {
+                    _topLevelImpl.RawTextEvent(inputData);
+                }
+            }
+
+            _inputHelper.Clear();
+        }
+
+        [Parameter(CaptureUnmatchedValues = true)]
+        public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
+
+        protected override void OnAfterRender(bool firstRender)
+        {
+            if (firstRender)
+            {
+                Threading.Dispatcher.UIThread.Post(async () =>
+                {
+                    _inputHelper = await InputHelperInterop.ImportAsync(Js, _inputElement);
+
+                    _inputHelper.Hide();
+                    _inputHelper.SetCursor("default");
+
+                    Console.WriteLine("starting html canvas setup");
+                    _interop = await SKHtmlCanvasInterop.ImportAsync(Js, _htmlCanvas, OnRenderFrame);
+
+                    Console.WriteLine("Interop created");
+                    _jsGlInfo = _interop.InitGL();
+
+                    Console.WriteLine("jsglinfo created - init gl");
+
+                    _sizeWatcher = await SizeWatcherInterop.ImportAsync(Js, _htmlCanvas, OnSizeChanged);
+                    _dpiWatcher = await DpiWatcherInterop.ImportAsync(Js, OnDpiChanged);
+
+                    Console.WriteLine("watchers created.");
+
+                    // create the SkiaSharp context
+                    if (_context == null)
+                    {
+                        Console.WriteLine("create glcontext");
+                        _glInterface = GRGlInterface.Create();
+                        _context = GRContext.CreateGl(_glInterface);
+
+
+                        // bump the default resource cache limit
+                        _context.SetResourceCacheLimit(256 * 1024 * 1024);
+                        Console.WriteLine("glcontext created and resource limit set");
+                    }
+
+                    _topLevelImpl.SetSurface(_context, _jsGlInfo, ColorType,
+                        new PixelSize((int)_canvasSize.Width, (int)_canvasSize.Height), _dpi);
+
+                    _initialised = true;
+
+                    _topLevel.Prepare();
+
+                    _topLevel.Renderer.Start();
+                    Invalidate();
+                });
+            }
+        }
+
+        private void OnRenderFrame()
+        {
+            if (_canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0 || _jsGlInfo == null)
+            {
+                Console.WriteLine("nothing to render");
+                return;
+            }
+
+            ManualTriggerRenderTimer.Instance.RaiseTick();
+        }
+
+        public void Dispose()
+        {
+            _dpiWatcher.Unsubscribe(OnDpiChanged);
+            _sizeWatcher.Dispose();
+            _interop.Dispose();
+        }
+
+        private void OnDpiChanged(double newDpi)
+        {
+            _dpi = newDpi;
+
+            _topLevelImpl.SetClientSize(_canvasSize, _dpi);
+
+            Invalidate();
+        }
+
+        private void OnSizeChanged(SKSize newSize)
+        {
+            _canvasSize = newSize;
+
+            _topLevelImpl.SetClientSize(_canvasSize, _dpi);
+
+            Invalidate();
+        }
+
+        public void Invalidate()
+        {
+            if (!_initialised || _canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0 || _jsGlInfo == null)
+            {
+                Console.WriteLine("invalidate ignored");
+                return;
+            }
+
+            _interop.RequestAnimationFrame(true, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi));
+        }
+
+        public void SetActive(bool active)
+        {
+            _inputHelper.Clear();
+
+            if (active)
+            {
+                _inputHelper.Show();
+                _inputHelper.Focus();
+            }
+            else
+            {
+                _inputHelper.Hide();
+            }
+        }
+
+        public void SetCursorRect(Rect rect)
+        {
+        }
+
+        public void SetOptions(TextInputOptionsQueryEventArgs options)
+        {
+        }
+
+        public void Reset()
+        {
+            _inputHelper.Clear();
+        }
+    }
+}

+ 61 - 0
src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs

@@ -0,0 +1,61 @@
+using System.Runtime.InteropServices;
+using Avalonia.Platform;
+using Avalonia.Shared.PlatformSupport;
+
+namespace Avalonia.Web.Blazor
+{
+    internal class BlazorRuntimePlatform : IRuntimePlatform
+    {
+        public static readonly IRuntimePlatform Instance = new BlazorRuntimePlatform();
+        
+        public IDisposable StartSystemTimer(TimeSpan interval, Action tick)
+        {
+            return new Timer(_ => tick(), null, interval, interval);
+        }
+
+        public RuntimePlatformInfo GetRuntimeInfo()
+        {
+            return new RuntimePlatformInfo
+            {
+                IsDesktop = false,
+                IsMobile = false,
+                IsMono = true,
+                IsUnix = false,
+                IsCoreClr = false,
+                IsDotNetFramework = false
+            };
+        }
+
+        private class BasicBlob : IUnmanagedBlob
+        {
+            public BasicBlob(int size)
+            {
+                Address = Marshal.AllocHGlobal(size);
+                Size = size;
+            }
+            public void Dispose()
+            {
+                if (Address != IntPtr.Zero)
+                    Marshal.FreeHGlobal(Address);
+                Address = IntPtr.Zero;
+            }
+
+            public IntPtr Address { get; private set; }
+
+            public int Size { get; }
+            public bool IsDisposed => Address == IntPtr.Zero;
+        }
+
+        public IUnmanagedBlob AllocBlob(int size)
+        {
+            return new BasicBlob(size);
+        }
+
+        public static void RegisterServices(AvaloniaBlazorAppBuilder builder)
+        {
+            AssetLoader.RegisterResUriParsers();
+            AvaloniaLocator.CurrentMutable.Bind<IRuntimePlatform>().ToConstant(Instance);
+            AvaloniaLocator.CurrentMutable.Bind<IAssetLoader>().ToConstant(new AssetLoader());
+        }
+    }
+}

+ 33 - 0
src/Web/Avalonia.Web.Blazor/BlazorSingleViewLifetime.cs

@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Media;
+
+namespace Avalonia.Web.Blazor
+{
+    public class BlazorSingleViewLifetime : ISingleViewApplicationLifetime
+    {
+        public Control? MainView { get; set; }
+    }
+
+    public static class WebAppBuilder
+    {
+        public static T SetupWithSingleViewLifetime<T>(
+            this T builder)
+            where T : AppBuilderBase<T>, new()
+        {
+            return builder.SetupWithLifetime(new BlazorSingleViewLifetime());
+        }
+
+        public static AvaloniaBlazorAppBuilder Configure<TApp>()
+            where TApp : Application, new()
+        {
+            var builder = AvaloniaBlazorAppBuilder.Configure<TApp>()
+                .UseSkia()
+                .With(new SkiaOptions { CustomGpuFactory = () => new BlazorSkiaGpu() });
+
+            AvaloniaLocator.CurrentMutable.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl()));
+
+            return builder;
+        }
+    }
+}

+ 25 - 0
src/Web/Avalonia.Web.Blazor/BlazorSkiaGpu.cs

@@ -0,0 +1,25 @@
+using Avalonia.Skia;
+
+namespace Avalonia.Web.Blazor
+{
+    public class BlazorSkiaGpu : ISkiaGpu
+    {
+        public ISkiaGpuRenderTarget? TryCreateRenderTarget(IEnumerable<object> surfaces)
+        {
+            foreach (var surface in surfaces)
+            {
+                if (surface is BlazorSkiaSurface blazorSkiaSurface)
+                {
+                    return new BlazorSkiaGpuRenderTarget(blazorSkiaSurface);
+                }
+            }
+
+            return null;
+        }
+
+        public ISkiaSurface? TryCreateSurface(PixelSize size, ISkiaGpuRenderSession session)
+        {
+            return null;
+        }
+    }
+}

+ 37 - 0
src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderSession.cs

@@ -0,0 +1,37 @@
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor
+{
+    internal class BlazorSkiaGpuRenderSession : ISkiaGpuRenderSession
+    {
+        private readonly SKSurface _surface;
+
+
+        public BlazorSkiaGpuRenderSession(BlazorSkiaSurface blazorSkiaSurface, GRBackendRenderTarget renderTarget)
+        {
+            _surface = SKSurface.Create(blazorSkiaSurface.Context, renderTarget, blazorSkiaSurface.Origin, blazorSkiaSurface.ColorType);
+
+            GrContext = blazorSkiaSurface.Context;
+
+            ScaleFactor = blazorSkiaSurface.Scaling;
+
+            SurfaceOrigin = blazorSkiaSurface.Origin;
+        }
+
+        public void Dispose()
+        {
+            _surface.Flush();
+
+            _surface.Dispose();
+        }
+
+        public GRContext GrContext { get; }
+
+        public SKSurface SkSurface => _surface;
+
+        public double ScaleFactor { get; }
+
+        public GRSurfaceOrigin SurfaceOrigin { get; }
+    }
+}

+ 47 - 0
src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs

@@ -0,0 +1,47 @@
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor
+{
+    internal class BlazorSkiaGpuRenderTarget : ISkiaGpuRenderTarget
+    {
+        private readonly GRBackendRenderTarget _renderTarget;
+        private readonly BlazorSkiaSurface _blazorSkiaSurface;
+        private readonly PixelSize _size;
+
+        public BlazorSkiaGpuRenderTarget(BlazorSkiaSurface blazorSkiaSurface)
+        {
+            _size = blazorSkiaSurface.Size;
+
+            var glFbInfo = new GRGlFramebufferInfo(blazorSkiaSurface.GlInfo.FboId, blazorSkiaSurface.ColorType.ToGlSizedFormat());
+            {
+                _blazorSkiaSurface = blazorSkiaSurface;
+                _renderTarget = new GRBackendRenderTarget(
+                    (int)(blazorSkiaSurface.Size.Width * blazorSkiaSurface.Scaling),
+                    (int)(blazorSkiaSurface.Size.Height * blazorSkiaSurface.Scaling),
+                    blazorSkiaSurface.GlInfo.Samples,
+                    blazorSkiaSurface.GlInfo.Stencils, glFbInfo);
+            }
+        }
+
+        public void Dispose()
+        {
+            _renderTarget.Dispose();
+        }
+
+        public ISkiaGpuRenderSession BeginRenderingSession()
+        {
+            return new BlazorSkiaGpuRenderSession(_blazorSkiaSurface, _renderTarget);
+        }
+
+        public bool IsCorrupted
+        {
+            get
+            {
+                var result = _size.Width != _renderTarget.Width || _size.Height != _renderTarget.Height;
+
+                return result;
+            }
+        }
+    }
+}

+ 30 - 0
src/Web/Avalonia.Web.Blazor/BlazorSkiaSurface.cs

@@ -0,0 +1,30 @@
+using Avalonia.Web.Blazor.Interop;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor
+{
+    internal class BlazorSkiaSurface
+    {
+        public BlazorSkiaSurface(GRContext context, SKHtmlCanvasInterop.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 SKHtmlCanvasInterop.GLInfo GlInfo { get; set; }
+    }
+}

+ 78 - 0
src/Web/Avalonia.Web.Blazor/CustomFontManagerImpl.cs

@@ -0,0 +1,78 @@
+using System.Globalization;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor
+{
+    public class CustomFontManagerImpl : IFontManagerImpl
+    {
+        private readonly Typeface[] _customTypefaces;
+        private readonly string _defaultFamilyName;
+
+        private readonly Typeface _defaultTypeface =
+            new Typeface("avares://Avalonia.Web.Blazor/Assets#Noto Mono");
+        private readonly Typeface _italicTypeface =
+            new Typeface("avares://Avalonia.Web.Blazor/Assets#Noto Sans");
+
+        public CustomFontManagerImpl()
+        {
+            _customTypefaces = new[] { _italicTypeface, _defaultTypeface };
+            _defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName;
+        }
+
+        public string GetDefaultFontFamilyName()
+        {
+            return _defaultFamilyName;
+        }
+
+        public IEnumerable<string> GetInstalledFontFamilyNames(bool checkForUpdates = false)
+        {
+            return _customTypefaces.Select(x => x.FontFamily.Name);
+        }
+
+        public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontFamily fontFamily,
+            CultureInfo culture, out Typeface typeface)
+        {
+            foreach (var customTypeface in _customTypefaces)
+            {
+                if (customTypeface.GlyphTypeface.GetGlyph((uint)codepoint) == 0)
+                {
+                    continue;
+                }
+
+                typeface = new Typeface(customTypeface.FontFamily, fontStyle, fontWeight);
+
+                return true;
+            }
+
+            typeface = _defaultTypeface;
+
+            return true;
+        }
+
+        public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface)
+        {
+            SKTypeface skTypeface;
+
+            switch (typeface.FontFamily.Name)
+            {
+                case "Noto Sans":
+                    {
+                        var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(_italicTypeface.FontFamily);
+                        skTypeface = typefaceCollection.Get(typeface);
+                        break;
+                    }
+                default:
+                    {
+                        var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(_defaultTypeface.FontFamily);
+                        skTypeface = typefaceCollection.Get(_defaultTypeface);
+                        break;
+                    }
+            }
+
+            return new GlyphTypefaceImpl(skTypeface);
+        }
+    }
+}

+ 20 - 0
src/Web/Avalonia.Web.Blazor/Interop/ActionHelper.cs

@@ -0,0 +1,20 @@
+using System;
+using System.ComponentModel;
+using Microsoft.JSInterop;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    [EditorBrowsable(EditorBrowsableState.Never)]
+    public class ActionHelper
+    {
+        private readonly Action action;
+
+        public ActionHelper(Action action)
+        {
+            this.action = action;
+        }
+
+        [JSInvokable]
+        public void Invoke() => action?.Invoke();
+    }
+}

+ 87 - 0
src/Web/Avalonia.Web.Blazor/Interop/DpiWatcherInterop.cs

@@ -0,0 +1,87 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.JSInterop;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    internal class DpiWatcherInterop : JSModuleInterop
+    {
+        private const string JsFilename = "./_content/Avalonia.Web.Blazor/DpiWatcher.js";
+        private const string StartSymbol = "DpiWatcher.start";
+        private const string StopSymbol = "DpiWatcher.stop";
+        private const string GetDpiSymbol = "DpiWatcher.getDpi";
+
+        private static DpiWatcherInterop? instance;
+
+        private event Action<double>? callbacksEvent;
+        private readonly FloatFloatActionHelper callbackHelper;
+
+        private DotNetObjectReference<FloatFloatActionHelper>? callbackReference;
+
+        public static async Task<DpiWatcherInterop> ImportAsync(IJSRuntime js, Action<double>? callback = null)
+        {
+            var interop = Get(js);
+            await interop.ImportAsync();
+            if (callback != null)
+                interop.Subscribe(callback);
+            return interop;
+        }
+
+        public static DpiWatcherInterop Get(IJSRuntime js) =>
+            instance ??= new DpiWatcherInterop(js);
+
+        private DpiWatcherInterop(IJSRuntime js)
+            : base(js, JsFilename)
+        {
+            callbackHelper = new FloatFloatActionHelper((o, n) => callbacksEvent?.Invoke(n));
+        }
+
+        protected override void OnDisposingModule() =>
+            Stop();
+
+        public void Subscribe(Action<double> callback)
+        {
+            var shouldStart = callbacksEvent == null;
+
+            callbacksEvent += callback;
+
+            var dpi = shouldStart
+                ? Start()
+                : GetDpi();
+
+            callback(dpi);
+        }
+
+        public void Unsubscribe(Action<double> callback)
+        {
+            callbacksEvent -= callback;
+
+            if (callbacksEvent == null)
+                Stop();
+        }
+
+        private double Start()
+        {
+            if (callbackReference != null)
+                return GetDpi();
+
+            callbackReference = DotNetObjectReference.Create(callbackHelper);
+
+            return Invoke<double>(StartSymbol, callbackReference);
+        }
+
+        private void Stop()
+        {
+            if (callbackReference == null)
+                return;
+
+            Invoke(StopSymbol);
+
+            callbackReference?.Dispose();
+            callbackReference = null;
+        }
+
+        public double GetDpi() =>
+            Invoke<double>(GetDpiSymbol);
+    }
+}

+ 20 - 0
src/Web/Avalonia.Web.Blazor/Interop/FloatFloatActionHelper.cs

@@ -0,0 +1,20 @@
+using System;
+using System.ComponentModel;
+using Microsoft.JSInterop;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    [EditorBrowsable(EditorBrowsableState.Never)]
+    public class FloatFloatActionHelper
+    {
+        private readonly Action<float, float> action;
+
+        public FloatFloatActionHelper(Action<float, float> action)
+        {
+            this.action = action;
+        }
+
+        [JSInvokable]
+        public void Invoke(float width, float height) => action?.Invoke(width, height);
+    }
+}

+ 41 - 0
src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs

@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    internal class InputHelperInterop : JSModuleInterop
+    {
+        private const string JsFilename = "./_content/Avalonia.Web.Blazor/InputHelper.js";
+        private const string ClearSymbol = "InputHelper.clear";
+        private const string FocusSymbol = "InputHelper.focus";
+        private const string SetCursorSymbol = "InputHelper.setCursor";
+        private const string HideSymbol = "InputHelper.hide";
+        private const string ShowSymbol = "InputHelper.show";
+
+        private readonly ElementReference inputElement;
+
+        public static async Task<InputHelperInterop> ImportAsync(IJSRuntime js, ElementReference element)
+        {
+            var interop = new InputHelperInterop(js, element);
+            await interop.ImportAsync();
+            return interop;
+        }
+
+        public InputHelperInterop(IJSRuntime js, ElementReference element)
+            : base(js, JsFilename)
+        {
+            inputElement = element;
+        }
+
+        public void Clear() => Invoke(ClearSymbol, inputElement);
+
+        public void Focus() => Invoke(FocusSymbol, inputElement);
+
+        public void SetCursor(string kind) => Invoke(SetCursorSymbol, inputElement, kind);
+
+        public void Hide() => Invoke(HideSymbol, inputElement);
+
+        public void Show() => Invoke(ShowSymbol, inputElement);
+    }
+}

+ 42 - 0
src/Web/Avalonia.Web.Blazor/Interop/JSModuleInterop.cs

@@ -0,0 +1,42 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.JSInterop;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    internal class JSModuleInterop : IDisposable
+    {
+        private readonly Task<IJSUnmarshalledObjectReference> moduleTask;
+        private IJSUnmarshalledObjectReference? module;
+
+        public JSModuleInterop(IJSRuntime js, string filename)
+        {
+            if (js is not IJSInProcessRuntime)
+                throw new NotSupportedException("SkiaSharp currently only works on Web Assembly.");
+
+            moduleTask = js.InvokeAsync<IJSUnmarshalledObjectReference>("import", filename).AsTask();
+        }
+
+        public async Task ImportAsync()
+        {
+            module = await moduleTask;
+        }
+
+        public void Dispose()
+        {
+            OnDisposingModule();
+            Module.Dispose();
+        }
+
+        protected IJSUnmarshalledObjectReference Module =>
+            module ?? throw new InvalidOperationException("Make sure to run ImportAsync() first.");
+
+        protected void Invoke(string identifier, params object?[]? args) =>
+            Module.InvokeVoid(identifier, args);
+
+        protected TValue Invoke<TValue>(string identifier, params object?[]? args) =>
+            Module.Invoke<TValue>(identifier, args);
+
+        protected virtual void OnDisposingModule() { }
+    }
+}

+ 79 - 0
src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs

@@ -0,0 +1,79 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    internal class SKHtmlCanvasInterop : JSModuleInterop
+    {
+        private const string JsFilename = "./_content/Avalonia.Web.Blazor/SKHtmlCanvas.js";
+        private const string InitGLSymbol = "SKHtmlCanvas.initGL";
+        private const string InitRasterSymbol = "SKHtmlCanvas.initRaster";
+        private const string DeinitSymbol = "SKHtmlCanvas.deinit";
+        private const string RequestAnimationFrameSymbol = "SKHtmlCanvas.requestAnimationFrame";
+        private const string PutImageDataSymbol = "SKHtmlCanvas.putImageData";
+
+        private readonly ElementReference htmlCanvas;
+        private readonly string htmlElementId;
+        private readonly ActionHelper callbackHelper;
+
+        private DotNetObjectReference<ActionHelper>? callbackReference;
+
+        public static async Task<SKHtmlCanvasInterop> ImportAsync(IJSRuntime js, ElementReference element, Action callback)
+        {
+            var interop = new SKHtmlCanvasInterop(js, element, callback);
+            await interop.ImportAsync();
+            return interop;
+        }
+
+        public SKHtmlCanvasInterop(IJSRuntime js, ElementReference element, Action renderFrameCallback)
+            : base(js, JsFilename)
+        {
+            htmlCanvas = element;
+            htmlElementId = element.Id;
+
+            callbackHelper = new ActionHelper(renderFrameCallback);
+        }
+
+        protected override void OnDisposingModule() =>
+            Deinit();
+
+        public GLInfo InitGL()
+        {
+            if (callbackReference != null)
+                throw new InvalidOperationException("Unable to initialize the same canvas more than once.");
+
+            callbackReference = DotNetObjectReference.Create(callbackHelper);
+
+            return Invoke<GLInfo>(InitGLSymbol, htmlCanvas, htmlElementId, callbackReference);
+        }
+
+        public bool InitRaster()
+        {
+            if (callbackReference != null)
+                throw new InvalidOperationException("Unable to initialize the same canvas more than once.");
+
+            callbackReference = DotNetObjectReference.Create(callbackHelper);
+
+            return Invoke<bool>(InitRasterSymbol, htmlCanvas, htmlElementId, callbackReference);
+        }
+
+        public void Deinit()
+        {
+            if (callbackReference == null)
+                return;
+
+            Invoke(DeinitSymbol, htmlElementId);
+
+            callbackReference?.Dispose();
+        }
+
+        public void RequestAnimationFrame(bool enableRenderLoop, int rawWidth, int rawHeight) =>
+            Invoke(RequestAnimationFrameSymbol, htmlCanvas, enableRenderLoop, rawWidth, rawHeight);
+
+        public void PutImageData(IntPtr intPtr, SKSizeI rawSize) =>
+            Invoke(PutImageDataSymbol, htmlCanvas, intPtr.ToInt64(), rawSize.Width, rawSize.Height);
+
+        public record GLInfo(int ContextId, uint FboId, int Stencils, int Samples, int Depth);
+    }
+}

+ 61 - 0
src/Web/Avalonia.Web.Blazor/Interop/SizeWatcherInterop.cs

@@ -0,0 +1,61 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+using SkiaSharp;
+
+namespace Avalonia.Web.Blazor.Interop
+{
+    internal class SizeWatcherInterop : JSModuleInterop
+    {
+        private const string JsFilename = "./_content/Avalonia.Web.Blazor/SizeWatcher.js";
+        private const string ObserveSymbol = "SizeWatcher.observe";
+        private const string UnobserveSymbol = "SizeWatcher.unobserve";
+
+        private readonly ElementReference htmlElement;
+        private readonly string htmlElementId;
+        private readonly FloatFloatActionHelper callbackHelper;
+
+        private DotNetObjectReference<FloatFloatActionHelper>? callbackReference;
+
+        public static async Task<SizeWatcherInterop> ImportAsync(IJSRuntime js, ElementReference element, Action<SKSize> callback)
+        {
+            var interop = new SizeWatcherInterop(js, element, callback);
+            await interop.ImportAsync();
+            interop.Start();
+            return interop;
+        }
+
+        public SizeWatcherInterop(IJSRuntime js, ElementReference element, Action<SKSize> callback)
+            : base(js, JsFilename)
+        {
+            htmlElement = element;
+            htmlElementId = element.Id;
+            callbackHelper = new FloatFloatActionHelper((x, y) => callback(new SKSize(x, y)));
+        }
+
+        protected override void OnDisposingModule() =>
+            Stop();
+
+        public void Start()
+        {
+            if (callbackReference != null)
+                return;
+
+            callbackReference = DotNetObjectReference.Create(callbackHelper);
+
+            Invoke(ObserveSymbol, htmlElement, htmlElementId, callbackReference);
+        }
+
+        public void Stop()
+        {
+            if (callbackReference == null)
+                return;
+
+            Invoke(UnobserveSymbol, htmlElementId);
+
+            callbackReference?.Dispose();
+            callbackReference = null;
+        }
+    }
+}

+ 41 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/DpiWatcher.ts

@@ -0,0 +1,41 @@
+
+export class DpiWatcher {
+	static lastDpi: number;
+	static timerId: number;
+	static callback: DotNet.DotNetObjectReference;
+
+	public static getDpi() {
+		return window.devicePixelRatio;
+	}
+
+	public static start(callback: DotNet.DotNetObjectReference): number {
+		//console.info(`Starting DPI watcher with callback ${callback._id}...`);
+
+		DpiWatcher.lastDpi = window.devicePixelRatio;
+		DpiWatcher.timerId = window.setInterval(DpiWatcher.update, 1000);
+		DpiWatcher.callback = callback;
+
+		return DpiWatcher.lastDpi;
+	}
+
+	public static stop() {
+		//console.info(`Stopping DPI watcher with callback ${DpiWatcher.callback._id}...`);
+
+		window.clearInterval(DpiWatcher.timerId);
+
+		DpiWatcher.callback = undefined;
+	}
+
+	static update() {
+		if (!DpiWatcher.callback)
+			return;
+
+		const currentDpi = window.devicePixelRatio;
+		const lastDpi = DpiWatcher.lastDpi;
+		DpiWatcher.lastDpi = currentDpi;
+
+		if (Math.abs(lastDpi - currentDpi) > 0.001) {
+			DpiWatcher.callback.invokeMethod('Invoke', lastDpi, currentDpi);
+		}
+	}
+}

+ 23 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/InputHelper.ts

@@ -0,0 +1,23 @@
+
+export class InputHelper {
+    public static clear (inputElement: HTMLInputElement){
+        inputElement.value = "";
+    }
+    
+    public static focus (inputElement: HTMLInputElement){
+        inputElement.focus();
+        inputElement.setSelectionRange(0, 0);
+    }
+    
+    public static setCursor (inputElement: HTMLInputElement, kind: string) {
+        inputElement.style.cursor = kind;
+    }
+
+    public static hide (inputElement: HTMLInputElement){
+        inputElement.style.display = 'none';
+    }
+
+    public static show (inputElement: HTMLInputElement){
+        inputElement.style.display = 'block';
+    }
+}

+ 225 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts

@@ -0,0 +1,225 @@
+// aliases for emscripten
+declare let GL: any;
+declare let GLctx: WebGLRenderingContext;
+declare let Module: EmscriptenModule;
+
+// container for gl info
+type SKGLViewInfo = {
+	context: WebGLRenderingContext | WebGL2RenderingContext | undefined;
+	fboId: number;
+	stencil: number;
+	sample: number;
+	depth: number;
+}
+
+// alias for a potential skia html canvas
+type SKHtmlCanvasElement = {
+	SKHtmlCanvas: SKHtmlCanvas
+} & HTMLCanvasElement
+
+export class SKHtmlCanvas {
+	static elements: Map<string, HTMLCanvasElement>;
+
+	htmlCanvas: HTMLCanvasElement;
+	glInfo: SKGLViewInfo;
+	renderFrameCallback: DotNet.DotNetObjectReference;
+	renderLoopEnabled: boolean = false;
+	renderLoopRequest: number = 0;
+
+	public static initGL(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): SKGLViewInfo {
+		var view = SKHtmlCanvas.init(true, element, elementId, callback);
+		if (!view || !view.glInfo)
+			return null;
+
+		return view.glInfo;
+	}
+
+	public static initRaster(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): boolean {
+		var view = SKHtmlCanvas.init(false, element, elementId, callback);
+		if (!view)
+			return false;
+
+		return true;
+	}
+
+	static init(useGL: boolean, element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): SKHtmlCanvas {
+		var htmlCanvas = element as SKHtmlCanvasElement;
+		if (!htmlCanvas) {
+			console.error(`No canvas element was provided.`);
+			return null;
+		}
+
+		if (!SKHtmlCanvas.elements)
+			SKHtmlCanvas.elements = new Map<string, HTMLCanvasElement>();
+		SKHtmlCanvas.elements[elementId] = element;
+
+		const view = new SKHtmlCanvas(useGL, element, callback);
+
+		htmlCanvas.SKHtmlCanvas = view;
+
+		return view;
+	}
+
+	public static deinit(elementId: string) {
+		if (!elementId)
+			return;
+
+		const element = SKHtmlCanvas.elements[elementId];
+		SKHtmlCanvas.elements.delete(elementId);
+
+		const htmlCanvas = element as SKHtmlCanvasElement;
+		if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas)
+			return;
+
+		htmlCanvas.SKHtmlCanvas.deinit();
+		htmlCanvas.SKHtmlCanvas = undefined;
+	}
+
+	public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean, width?: number, height?: number) {
+		const htmlCanvas = element as SKHtmlCanvasElement;
+		if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas)
+			return;
+
+		htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop, width, height);
+	}
+
+	public static setEnableRenderLoop(element: HTMLCanvasElement, enable: boolean) {
+		const htmlCanvas = element as SKHtmlCanvasElement;
+		if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas)
+			return;
+
+		htmlCanvas.SKHtmlCanvas.setEnableRenderLoop(enable);
+	}
+
+	public static putImageData(element: HTMLCanvasElement, pData: number, width: number, height: number) {
+		const htmlCanvas = element as SKHtmlCanvasElement;
+		if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas)
+			return;
+
+		htmlCanvas.SKHtmlCanvas.putImageData(pData, width, height);
+	}
+
+	public constructor(useGL: boolean, element: HTMLCanvasElement, callback: DotNet.DotNetObjectReference) {
+		this.htmlCanvas = element;
+		this.renderFrameCallback = callback;
+
+		if (useGL) {
+			const ctx = SKHtmlCanvas.createWebGLContext(this.htmlCanvas);
+			if (!ctx) {
+				console.error(`Failed to create WebGL context: err ${ctx}`);
+				return null;
+			}
+
+			// make current
+			GL.makeContextCurrent(ctx);
+
+			// 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 deinit() {
+		this.setEnableRenderLoop(false);
+	}
+
+	public requestAnimationFrame(renderLoop?: boolean, width?: number, height?: number) {
+		// optionally update the render loop
+		if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop)
+			this.setEnableRenderLoop(renderLoop);
+
+		// make sure the canvas is scaled correctly for the drawing
+		if (width && height) {
+			this.htmlCanvas.width = width;
+			this.htmlCanvas.height = height;
+		}
+
+		// 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) {
+				// make current
+				GL.makeContextCurrent(this.glInfo.context);
+			}
+
+			this.renderFrameCallback.invokeMethod('Invoke');
+			this.renderLoopRequest = 0;
+
+			// we may want to draw the next frame
+			if (this.renderLoopEnabled)
+				this.requestAnimationFrame();
+		});
+	}
+
+	public setEnableRenderLoop(enable: boolean) {
+		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 putImageData(pData: number, width: number, height: number): boolean {
+		if (this.glInfo || !pData || width <= 0 || width <= 0)
+			return false;
+
+		var ctx = this.htmlCanvas.getContext('2d');
+		if (!ctx) {
+			console.error(`Failed to obtain 2D canvas context.`);
+			return false;
+		}
+
+		// make sure the canvas is scaled correctly for the drawing
+		this.htmlCanvas.width = width;
+		this.htmlCanvas.height = height;
+
+		// set the canvas to be the bytes
+		var buffer = new Uint8ClampedArray(Module.HEAPU8.buffer, pData, width * height * 4);
+		var imageData = new ImageData(buffer, width, height);
+		ctx.putImageData(imageData, 0, 0);
+
+		return true;
+	}
+
+	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,
+		};
+
+		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;
+	}
+}

+ 68 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/SizeWatcher.ts

@@ -0,0 +1,68 @@
+
+type SizeWatcherElement = {
+	SizeWatcher: SizeWatcherInstance;
+} & HTMLElement
+
+type SizeWatcherInstance = {
+	callback: DotNet.DotNetObjectReference;
+}
+
+export class SizeWatcher {
+	static observer: ResizeObserver;
+	static elements: Map<string, HTMLElement>;
+
+	public static observe(element: HTMLElement, elementId: string, callback: DotNet.DotNetObjectReference) {
+		if (!element || !callback)
+			return;
+
+		//console.info(`Adding size watcher observation with callback ${callback._id}...`);
+
+		SizeWatcher.init();
+
+		const watcherElement = element as SizeWatcherElement;
+		watcherElement.SizeWatcher = {
+			callback: callback
+		};
+
+		SizeWatcher.elements[elementId] = element;
+		SizeWatcher.observer.observe(element);
+
+		SizeWatcher.invoke(element);
+	}
+
+	public static unobserve(elementId: string) {
+		if (!elementId || !SizeWatcher.observer)
+			return;
+
+		//console.info('Removing size watcher observation...');
+
+		const element = SizeWatcher.elements[elementId];
+
+		SizeWatcher.elements.delete(elementId);
+		SizeWatcher.observer.unobserve(element);
+	}
+
+	static init() {
+		if (SizeWatcher.observer)
+			return;
+
+		//console.info('Starting size watcher...');
+
+		SizeWatcher.elements = new Map<string, HTMLElement>();
+		SizeWatcher.observer = new ResizeObserver((entries) => {
+			for (let entry of entries) {
+				SizeWatcher.invoke(entry.target);
+			}
+		});
+	}
+
+	static invoke(element: Element) {
+		const watcherElement = element as SizeWatcherElement;
+		const instance = watcherElement.SizeWatcher;
+
+		if (!instance || !instance.callback)
+			return;
+
+		return instance.callback.invokeMethod('Invoke', element.clientWidth, element.clientHeight);
+	}
+}

+ 7 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/extras.d.ts

@@ -0,0 +1,7 @@
+
+declare namespace DotNet {
+    interface DotNetObjectReference extends DotNet.DotNetObject {
+        _id: number;
+        dispose();
+    }
+}

+ 56 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/dotnet/index.d.ts

@@ -0,0 +1,56 @@
+// Type definitions for non-npm package @blazor/javascript-interop 3.1
+// Project: https://docs.microsoft.com/en-us/aspnet/core/blazor/javascript-interop?view=aspnetcore-3.1
+// Definitions by: Piotr Błażejewicz (Peter Blazejewicz) <https://github.com/peterblazejewicz>
+// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
+// Minimum TypeScript Version: 3.0
+
+// Here be dragons!
+// This is community-maintained definition file intended to ease the process of developing
+// high quality JavaScript interop code to be used in Blazor application from your C# .Net code.
+// Could be removed without a notice in case official definition types ships with Blazor itself.
+
+// tslint:disable:no-unnecessary-generics
+
+declare namespace DotNet {
+    /**
+     * Invokes the specified .NET public method synchronously. Not all hosting scenarios support
+     * synchronous invocation, so if possible use invokeMethodAsync instead.
+     *
+     * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly containing the method.
+     * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier.
+     * @param args Arguments to pass to the method, each of which must be JSON-serializable.
+     * @returns The result of the operation.
+     */
+    function invokeMethod<T>(assemblyName: string, methodIdentifier: string, ...args: any[]): T;
+    /**
+     * Invokes the specified .NET public method asynchronously.
+     *
+     * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly containing the method.
+     * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier.
+     * @param args Arguments to pass to the method, each of which must be JSON-serializable.
+     * @returns A promise representing the result of the operation.
+     */
+    function invokeMethodAsync<T>(assemblyName: string, methodIdentifier: string, ...args: any[]): Promise<T>;
+    /**
+     * Represents the .NET instance passed by reference to JavaScript.
+     */
+    interface DotNetObject {
+        /**
+         * Invokes the specified .NET instance public method synchronously. Not all hosting scenarios support
+         * synchronous invocation, so if possible use invokeMethodAsync instead.
+         *
+         * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier.
+         * @param args Arguments to pass to the method, each of which must be JSON-serializable.
+         * @returns The result of the operation.
+         */
+        invokeMethod<T>(methodIdentifier: string, ...args: any[]): T;
+        /**
+         * Invokes the specified .NET instance public method asynchronously.
+         *
+         * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier.
+         * @param args Arguments to pass to the method, each of which must be JSON-serializable.
+         * @returns A promise representing the result of the operation.
+         */
+        invokeMethodAsync<T>(methodIdentifier: string, ...args: any[]): Promise<T>;
+    }
+}

+ 326 - 0
src/Web/Avalonia.Web.Blazor/Interop/Typescript/types/emscripten/index.d.ts

@@ -0,0 +1,326 @@
+// Type definitions for Emscripten 1.39.16
+// Project: https://emscripten.org
+// Definitions by: Kensuke Matsuzaki <https://github.com/zakki>
+//                 Periklis Tsirakidis <https://github.com/periklis>
+//                 Bumsik Kim <https://github.com/kbumsik>
+//                 Louis DeScioli <https://github.com/lourd>
+// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
+// TypeScript Version: 2.2
+
+/** Other WebAssembly declarations, for compatibility with older versions of Typescript */
+declare namespace WebAssembly {
+    interface Module {}
+}
+
+declare namespace Emscripten {
+    interface FileSystemType {}
+    type EnvironmentType = 'WEB' | 'NODE' | 'SHELL' | 'WORKER';
+
+    type JSType = 'number' | 'string' | 'array' | 'boolean';
+    type TypeCompatibleWithC = number | string | any[] | boolean;
+
+    type CIntType = 'i8' | 'i16' | 'i32' | 'i64';
+    type CFloatType = 'float' | 'double';
+    type CPointerType = 'i8*' | 'i16*' | 'i32*' | 'i64*' | 'float*' | 'double*' | '*';
+    type CType = CIntType | CFloatType | CPointerType;
+
+    type WebAssemblyImports = Array<{
+        name: string;
+        kind: string;
+    }>;
+
+    type WebAssemblyExports = Array<{
+        module: string;
+        name: string;
+        kind: string;
+    }>;
+
+    interface CCallOpts {
+        async?: boolean | undefined;
+    }
+}
+
+interface EmscriptenModule {
+    print(str: string): void;
+    printErr(str: string): void;
+    arguments: string[];
+    environment: Emscripten.EnvironmentType;
+    preInit: Array<{ (): void }>;
+    preRun: Array<{ (): void }>;
+    postRun: Array<{ (): void }>;
+    onAbort: { (what: any): void };
+    onRuntimeInitialized: { (): void };
+    preinitializedWebGLContext: WebGLRenderingContext;
+    noInitialRun: boolean;
+    noExitRuntime: boolean;
+    logReadFiles: boolean;
+    filePackagePrefixURL: string;
+    wasmBinary: ArrayBuffer;
+
+    destroy(object: object): void;
+    getPreloadedPackage(remotePackageName: string, remotePackageSize: number): ArrayBuffer;
+    instantiateWasm(
+        imports: Emscripten.WebAssemblyImports,
+        successCallback: (module: WebAssembly.Module) => void,
+    ): Emscripten.WebAssemblyExports;
+    locateFile(url: string, scriptDirectory: string): string;
+    onCustomMessage(event: MessageEvent): void;
+
+    // USE_TYPED_ARRAYS == 1
+    HEAP: Int32Array;
+    IHEAP: Int32Array;
+    FHEAP: Float64Array;
+
+    // USE_TYPED_ARRAYS == 2
+    HEAP8: Int8Array;
+    HEAP16: Int16Array;
+    HEAP32: Int32Array;
+    HEAPU8: Uint8Array;
+    HEAPU16: Uint16Array;
+    HEAPU32: Uint32Array;
+    HEAPF32: Float32Array;
+    HEAPF64: Float64Array;
+
+    TOTAL_STACK: number;
+    TOTAL_MEMORY: number;
+    FAST_MEMORY: number;
+
+    addOnPreRun(cb: () => any): void;
+    addOnInit(cb: () => any): void;
+    addOnPreMain(cb: () => any): void;
+    addOnExit(cb: () => any): void;
+    addOnPostRun(cb: () => any): void;
+
+    preloadedImages: any;
+    preloadedAudios: any;
+
+    _malloc(size: number): number;
+    _free(ptr: number): void;
+}
+
+/**
+ * A factory function is generated when setting the `MODULARIZE` build option
+ * to `1` in your Emscripten build. It return a Promise that resolves to an
+ * initialized, ready-to-call `EmscriptenModule` instance.
+ *
+ * By default, the factory function will be named `Module`. It's recommended to
+ * use the `EXPORT_ES6` option, in which the factory function will be the
+ * default export. If used without `EXPORT_ES6`, the factory function will be a
+ * global variable. You can rename the variable using the `EXPORT_NAME` build
+ * option. It's left to you to declare any global variables as needed in your
+ * application's types.
+ * @param moduleOverrides Default properties for the initialized module.
+ */
+type EmscriptenModuleFactory<T extends EmscriptenModule = EmscriptenModule> = (
+    moduleOverrides?: Partial<T>,
+) => Promise<T>;
+
+declare namespace FS {
+    interface Lookup {
+        path: string;
+        node: FSNode;
+    }
+
+    interface FSStream {}
+    interface FSNode {}
+    interface ErrnoError {}
+
+    let ignorePermissions: boolean;
+    let trackingDelegate: any;
+    let tracking: any;
+    let genericErrors: any;
+
+    //
+    // paths
+    //
+    function lookupPath(path: string, opts: any): Lookup;
+    function getPath(node: FSNode): string;
+
+    //
+    // nodes
+    //
+    function isFile(mode: number): boolean;
+    function isDir(mode: number): boolean;
+    function isLink(mode: number): boolean;
+    function isChrdev(mode: number): boolean;
+    function isBlkdev(mode: number): boolean;
+    function isFIFO(mode: number): boolean;
+    function isSocket(mode: number): boolean;
+
+    //
+    // devices
+    //
+    function major(dev: number): number;
+    function minor(dev: number): number;
+    function makedev(ma: number, mi: number): number;
+    function registerDevice(dev: number, ops: any): void;
+
+    //
+    // core
+    //
+    function syncfs(populate: boolean, callback: (e: any) => any): void;
+    function syncfs(callback: (e: any) => any, populate?: boolean): void;
+    function mount(type: Emscripten.FileSystemType, opts: any, mountpoint: string): any;
+    function unmount(mountpoint: string): void;
+
+    function mkdir(path: string, mode?: number): any;
+    function mkdev(path: string, mode?: number, dev?: number): any;
+    function symlink(oldpath: string, newpath: string): any;
+    function rename(old_path: string, new_path: string): void;
+    function rmdir(path: string): void;
+    function readdir(path: string): any;
+    function unlink(path: string): void;
+    function readlink(path: string): string;
+    function stat(path: string, dontFollow?: boolean): any;
+    function lstat(path: string): any;
+    function chmod(path: string, mode: number, dontFollow?: boolean): void;
+    function lchmod(path: string, mode: number): void;
+    function fchmod(fd: number, mode: number): void;
+    function chown(path: string, uid: number, gid: number, dontFollow?: boolean): void;
+    function lchown(path: string, uid: number, gid: number): void;
+    function fchown(fd: number, uid: number, gid: number): void;
+    function truncate(path: string, len: number): void;
+    function ftruncate(fd: number, len: number): void;
+    function utime(path: string, atime: number, mtime: number): void;
+    function open(path: string, flags: string, mode?: number, fd_start?: number, fd_end?: number): FSStream;
+    function close(stream: FSStream): void;
+    function llseek(stream: FSStream, offset: number, whence: number): any;
+    function read(stream: FSStream, buffer: ArrayBufferView, offset: number, length: number, position?: number): number;
+    function write(
+        stream: FSStream,
+        buffer: ArrayBufferView,
+        offset: number,
+        length: number,
+        position?: number,
+        canOwn?: boolean,
+    ): number;
+    function allocate(stream: FSStream, offset: number, length: number): void;
+    function mmap(
+        stream: FSStream,
+        buffer: ArrayBufferView,
+        offset: number,
+        length: number,
+        position: number,
+        prot: number,
+        flags: number,
+    ): any;
+    function ioctl(stream: FSStream, cmd: any, arg: any): any;
+    function readFile(path: string, opts: { encoding: 'binary'; flags?: string | undefined }): Uint8Array;
+    function readFile(path: string, opts: { encoding: 'utf8'; flags?: string | undefined }): string;
+    function readFile(path: string, opts?: { flags?: string | undefined }): Uint8Array;
+    function writeFile(path: string, data: string | ArrayBufferView, opts?: { flags?: string | undefined }): void;
+
+    //
+    // module-level FS code
+    //
+    function cwd(): string;
+    function chdir(path: string): void;
+    function init(
+        input: null | (() => number | null),
+        output: null | ((c: number) => any),
+        error: null | ((c: number) => any),
+    ): void;
+
+    function createLazyFile(
+        parent: string | FSNode,
+        name: string,
+        url: string,
+        canRead: boolean,
+        canWrite: boolean,
+    ): FSNode;
+    function createPreloadedFile(
+        parent: string | FSNode,
+        name: string,
+        url: string,
+        canRead: boolean,
+        canWrite: boolean,
+        onload?: () => void,
+        onerror?: () => void,
+        dontCreateFile?: boolean,
+        canOwn?: boolean,
+    ): void;
+    function createDataFile(
+        parent: string | FSNode,
+        name: string,
+        data: ArrayBufferView,
+        canRead: boolean,
+        canWrite: boolean,
+        canOwn: boolean,
+    ): FSNode;
+}
+
+declare var MEMFS: Emscripten.FileSystemType;
+declare var NODEFS: Emscripten.FileSystemType;
+declare var IDBFS: Emscripten.FileSystemType;
+
+// Below runtime function/variable declarations are exportable by
+// -s EXTRA_EXPORTED_RUNTIME_METHODS. You can extend or merge
+// EmscriptenModule interface to add runtime functions.
+//
+// For example, by using -s "EXTRA_EXPORTED_RUNTIME_METHODS=['ccall']"
+// You can access ccall() via Module["ccall"]. In this case, you should
+// extend EmscriptenModule to pass the compiler check like the following:
+//
+// interface YourOwnEmscriptenModule extends EmscriptenModule {
+//     ccall: typeof ccall;
+// }
+//
+// See: https://emscripten.org/docs/getting_started/FAQ.html#why-do-i-get-typeerror-module-something-is-not-a-function
+
+declare function ccall(
+    ident: string,
+    returnType: Emscripten.JSType | null,
+    argTypes: Emscripten.JSType[],
+    args: Emscripten.TypeCompatibleWithC[],
+    opts?: Emscripten.CCallOpts,
+): any;
+declare function cwrap(
+    ident: string,
+    returnType: Emscripten.JSType | null,
+    argTypes: Emscripten.JSType[],
+    opts?: Emscripten.CCallOpts,
+): (...args: any[]) => any;
+
+declare function setValue(ptr: number, value: any, type: Emscripten.CType, noSafe?: boolean): void;
+declare function getValue(ptr: number, type: Emscripten.CType, noSafe?: boolean): number;
+
+declare function allocate(
+    slab: number[] | ArrayBufferView | number,
+    types: Emscripten.CType | Emscripten.CType[],
+    allocator: number,
+    ptr?: number,
+): number;
+
+declare function stackAlloc(size: number): number;
+declare function stackSave(): number;
+declare function stackRestore(ptr: number): void;
+
+declare function UTF8ToString(ptr: number, maxBytesToRead?: number): string;
+declare function stringToUTF8(str: string, outPtr: number, maxBytesToRead?: number): void;
+declare function lengthBytesUTF8(str: string): number;
+declare function allocateUTF8(str: string): number;
+declare function allocateUTF8OnStack(str: string): number;
+declare function UTF16ToString(ptr: number): string;
+declare function stringToUTF16(str: string, outPtr: number, maxBytesToRead?: number): void;
+declare function lengthBytesUTF16(str: string): number;
+declare function UTF32ToString(ptr: number): string;
+declare function stringToUTF32(str: string, outPtr: number, maxBytesToRead?: number): void;
+declare function lengthBytesUTF32(str: string): number;
+
+declare function intArrayFromString(stringy: string, dontAddNull?: boolean, length?: number): number[];
+declare function intArrayToString(array: number[]): string;
+declare function writeStringToMemory(str: string, buffer: number, dontAddNull: boolean): void;
+declare function writeArrayToMemory(array: number[], buffer: number): void;
+declare function writeAsciiToMemory(str: string, buffer: number, dontAddNull: boolean): void;
+
+declare function addRunDependency(id: any): void;
+declare function removeRunDependency(id: any): void;
+
+declare function addFunction(func: (...args: any[]) => any, signature?: string): number;
+declare function removeFunction(funcPtr: number): void;
+
+declare var ALLOC_NORMAL: number;
+declare var ALLOC_STACK: number;
+declare var ALLOC_STATIC: number;
+declare var ALLOC_DYNAMIC: number;
+declare var ALLOC_NONE: number;

+ 127 - 0
src/Web/Avalonia.Web.Blazor/Keycodes.cs

@@ -0,0 +1,127 @@
+using Avalonia.Input;
+
+namespace Avalonia.Web.Blazor
+{
+    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 }
+        };
+    }
+}

+ 16 - 0
src/Web/Avalonia.Web.Blazor/ManualTriggerRenderTimer.cs

@@ -0,0 +1,16 @@
+using System.Diagnostics;
+using Avalonia.Rendering;
+
+namespace Avalonia.Web.Blazor
+{
+    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;
+    }
+}

+ 165 - 0
src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs

@@ -0,0 +1,165 @@
+using System.Diagnostics;
+using Avalonia.Controls;
+using Avalonia.Controls.Platform;
+using Avalonia.Input;
+using Avalonia.Input.Raw;
+using Avalonia.Input.TextInput;
+using Avalonia.Platform;
+using Avalonia.Rendering;
+using Avalonia.Web.Blazor.Interop;
+using SkiaSharp;
+
+#nullable enable
+
+namespace Avalonia.Web.Blazor
+{
+    internal class RazorViewTopLevelImpl : ITopLevelImplWithTextInputMethod
+    {
+        private Size _clientSize;
+        private BlazorSkiaSurface? _currentSurface;
+        private IInputRoot? _inputRoot;
+        private readonly Stopwatch _sw = Stopwatch.StartNew();
+        private readonly ITextInputMethodImpl _textInputMethod;
+        private readonly TouchDevice _touchDevice;
+
+        public RazorViewTopLevelImpl(ITextInputMethodImpl textInputMethod)
+        {
+            _textInputMethod = textInputMethod;
+            TransparencyLevel = WindowTransparencyLevel.None;
+            AcrylicCompensationLevels = new AcrylicPlatformCompensationLevels(1, 1, 1);
+            _touchDevice = new TouchDevice();
+        }
+
+        public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds;
+
+
+        internal void SetSurface(GRContext context, SKHtmlCanvasInterop.GLInfo glInfo, SKColorType colorType, PixelSize size, double scaling)
+        {
+            _currentSurface =
+                new BlazorSkiaSurface(context, glInfo, colorType, size, scaling, GRSurfaceOrigin.BottomLeft);
+        }
+
+        public void SetClientSize(SKSize size, double dpi)
+        {
+            var newSize = new Size(size.Width, size.Height);
+
+            if (newSize != _clientSize)
+            {
+                _clientSize = newSize;
+
+                if (_currentSurface is { })
+                {
+                    _currentSurface.Size = new PixelSize((int)size.Width, (int)size.Height);
+                }
+
+                Resized?.Invoke(newSize, PlatformResizeReason.User);
+            }
+        }
+
+        public void RawTouchEvent(RawPointerEventType type, Point p, RawInputModifiers modifiers, long touchPointId)
+        {
+            if (_inputRoot is { })
+            {
+                Input?.Invoke(new RawTouchEventArgs(_touchDevice, Timestamp, _inputRoot, type, p, modifiers, touchPointId));
+            }
+        }
+
+        public void RawMouseEvent(RawPointerEventType type, Point p, RawInputModifiers modifiers)
+        {
+            if (_inputRoot is { })
+            {
+                Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, _inputRoot, type, p, modifiers));
+            }
+        }
+
+        public void RawMouseWheelEvent(Point p, Vector v, RawInputModifiers modifiers)
+        {
+            if (_inputRoot is { })
+            {
+                Input?.Invoke(new RawMouseWheelEventArgs(MouseDevice, Timestamp, _inputRoot, p, v, modifiers));
+            }
+        }
+
+        public void RawKeyboardEvent(RawKeyEventType type, string key, RawInputModifiers modifiers)
+        {
+            if (Keycodes.KeyCodes.TryGetValue(key, out var avkey))
+            {
+                if (_inputRoot is { })
+                {
+                    Input?.Invoke(new RawKeyEventArgs(KeyboardDevice, Timestamp, _inputRoot, type, avkey, modifiers));
+                }
+            }
+        }
+
+        public void RawTextEvent(string text)
+        {
+            if (_inputRoot is { })
+            {
+                Input?.Invoke(new RawTextInputEventArgs(KeyboardDevice, Timestamp, _inputRoot, text));
+            }
+        }
+
+        public void Dispose()
+        {
+
+        }
+
+        public IRenderer CreateRenderer(IRenderRoot root)
+        {
+            var loop = AvaloniaLocator.Current.GetService<IRenderLoop>();
+
+            return new DeferredRenderer(root, loop);
+        }
+
+        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)
+        {
+            // nop
+
+        }
+
+        public IPopupImpl? CreatePopup()
+        {
+            return null;
+        }
+
+        public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel)
+        {
+
+        }
+
+        public Size ClientSize => _clientSize;
+        public Size? FrameSize => null;
+        public double RenderScaling => 1;
+
+        public IEnumerable<object> Surfaces => new object[] { _currentSurface! };
+
+        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; } = BlazorWindowingPlatform.Keyboard;
+        public WindowTransparencyLevel TransparencyLevel { get; }
+        public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; }
+
+        public ITextInputMethodImpl TextInputMethod => _textInputMethod;
+    }
+}

+ 80 - 0
src/Web/Avalonia.Web.Blazor/WinStubs.cs

@@ -0,0 +1,80 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Platform;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Platform;
+
+#nullable enable
+
+namespace Avalonia.Web.Blazor
+{
+    internal class ClipboardStub : IClipboard
+    {
+        public Task<string> GetTextAsync() => Task.FromResult("");
+
+        public Task SetTextAsync(string text) => Task.CompletedTask;
+
+        public Task ClearAsync() => Task.CompletedTask;
+
+        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 ());
+    }
+
+    internal class CursorStub : ICursorImpl
+    {
+        public void Dispose()
+        {
+
+        }
+    }
+
+    internal class CursorFactoryStub : ICursorFactory
+    {
+        public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot)
+        {
+            return new CursorStub();
+        }
+
+        ICursorImpl ICursorFactory.GetCursor(StandardCursorType cursorType)
+        {
+            return new CursorStub();
+        }
+    }
+
+    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 SystemDialogsStub : ISystemDialogImpl
+    {
+        public Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) =>
+            Task.FromResult((string[]?)null);
+
+        public Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) =>
+            Task.FromResult((string?)null);
+    }
+
+    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) };
+    }
+}

+ 100 - 0
src/Web/Avalonia.Web.Blazor/WindowingPlatform.cs

@@ -0,0 +1,100 @@
+using Avalonia.Controls.Platform;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Platform;
+using Avalonia.Rendering;
+using Avalonia.Threading;
+
+#nullable enable
+
+namespace Avalonia.Web.Blazor
+{
+    public class BlazorWindowingPlatform : IWindowingPlatform, IPlatformSettings, IPlatformThreadingInterface
+    {
+        private bool _signaled;
+        private static int s_uiThreadId = -1;
+
+        public IWindowImpl CreateWindow() => throw new NotSupportedException();
+
+        IWindowImpl IWindowingPlatform.CreateEmbeddableWindow()
+        {
+            throw new NotImplementedException();
+        }
+
+        public ITrayIconImpl? CreateTrayIcon()
+        {
+            return null;
+        }
+
+        public static KeyboardDevice Keyboard { get; private set; }
+
+        public static void Register()
+        {
+            var instance = new BlazorWindowingPlatform();
+            Keyboard = new KeyboardDevice();
+            AvaloniaLocator.CurrentMutable
+                .Bind<IClipboard>().ToSingleton<ClipboardStub>()
+                .Bind<ICursorFactory>().ToSingleton<CursorFactoryStub>()
+                .Bind<IKeyboardDevice>().ToConstant(Keyboard)
+                .Bind<IPlatformSettings>().ToConstant(instance)
+                .Bind<IPlatformThreadingInterface>().ToConstant(instance)
+                .Bind<IRenderLoop>().ToConstant(new RenderLoop())
+                .Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance)
+                .Bind<ISystemDialogImpl>().ToSingleton<SystemDialogsStub>()
+                .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 void RunLoop(CancellationToken cancellationToken)
+        {
+            throw new NotSupportedException();
+        }
+
+        public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick)
+        {
+            return AvaloniaLocator.Current.GetService<IRuntimePlatform>()
+                .StartSystemTimer(interval, () =>
+                {
+                    Dispatcher.UIThread.RunJobs(priority);
+                    tick();
+                });
+        }
+
+        public void Signal(DispatcherPriority priority)
+        {
+            if (_signaled)
+                return;
+            
+            _signaled = true;
+            
+            IDisposable? disp = null;
+            
+            disp = AvaloniaLocator.Current.GetService<IRuntimePlatform>()
+                .StartSystemTimer(TimeSpan.FromMilliseconds(1),
+                    () =>
+                    {
+                        _signaled = false;
+                        disp?.Dispose();
+
+                        Signaled?.Invoke(null);
+                    });
+        }
+
+        public bool CurrentThreadIsLoopThread
+        {
+            get
+            {
+                return true; // Blazor is single threaded.
+            }
+        }
+
+        public event Action<DispatcherPriority?>? Signaled;
+
+        
+    }
+}

+ 1 - 0
src/Web/Avalonia.Web.Blazor/_Imports.razor

@@ -0,0 +1 @@
+@using Microsoft.AspNetCore.Components.Web

+ 14 - 0
src/Web/Avalonia.Web.Blazor/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "noEmitOnError": true,
+    "removeComments": false,
+    "sourceMap": true,
+    "target": "ES2020",
+    "module": "ES2020",
+    "outDir": "wwwroot"
+  },
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 108 - 0
tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using Moq;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Primitives.PopupPositioning;
 using Avalonia.Controls.Templates;
 using Avalonia.Layout;
 using Avalonia.LogicalTree;
@@ -595,6 +596,113 @@ namespace Avalonia.Controls.UnitTests.Primitives
             }
         }
 
+        [Fact]
+        public void Popup_Should_Follow_Placement_Target_On_Window_Move()
+        {
+            using (CreateServices())
+            {
+                var popup = new Popup { Width = 400, Height = 200 };
+                var window = PreparedWindow(popup);
+                popup.Open();
+
+                if (popup.Host is PopupRoot popupRoot)
+                {
+                    // Moving the window must move the popup (screen coordinates have changed)
+                    var raised = false;
+                    popupRoot.PositionChanged += (_, args) =>
+                    {
+                        Assert.Equal(new PixelPoint(10, 10), args.Point);
+                        raised = true;
+                    };
+
+                    window.Position = new PixelPoint(10, 10);
+                    Assert.True(raised);
+                }
+            }
+        }
+
+        [Fact]
+        public void Popup_Should_Follow_Placement_Target_On_Window_Resize()
+        {
+            using (CreateServices())
+            {
+
+                var placementTarget = new Panel()
+                {
+                    Width = 10,
+                    Height = 10,
+                    HorizontalAlignment = HorizontalAlignment.Center,
+                    VerticalAlignment = VerticalAlignment.Center
+                };
+                var popup = new Popup() { PlacementTarget = placementTarget, Width = 10, Height = 10 };
+                ((ISetLogicalParent)popup).SetParent(popup.PlacementTarget);
+
+                var window = PreparedWindow(placementTarget);
+                window.Show();
+                popup.Open();
+
+                // The target's initial placement is (395,295) which is a 10x10 panel centered in a 800x600 window
+                Assert.Equal(placementTarget.Bounds, new Rect(395D, 295D, 10, 10));
+
+                if (popup.Host is PopupRoot popupRoot)
+                {
+                    // Resizing the window to 700x500 must move the popup to (345,255) as this is the new
+                    // location of the placement target
+                    var raised = false;
+                    popupRoot.PositionChanged += (_, args) =>
+                    {
+                        Assert.Equal(new PixelPoint(345, 255), args.Point);
+                        raised = true;
+                    };
+
+                    window.PlatformImpl?.Resize(new Size(700D, 500D), PlatformResizeReason.Unspecified);
+                    Assert.True(raised);
+                }
+            }
+        }
+
+        [Fact]
+        public void Popup_Should_Follow_Popup_Root_Placement_Target()
+        {
+            // When the placement target of a popup is another popup (e.g. nested menu items), the child popup must
+            // follow the parent popup if it moves (due to root window movement or resize)
+            using (CreateServices())
+            {
+                // The child popup is placed directly over the parent popup for position testing
+                var parentPopup = new Popup() { Width = 10, Height = 10 };
+                var childPopup = new Popup() {
+                    Width = 20,
+                    Height = 20,
+                    PlacementTarget = parentPopup, 
+                    PlacementMode = PlacementMode.AnchorAndGravity,
+                    PlacementAnchor = PopupAnchor.TopLeft,
+                    PlacementGravity = PopupGravity.BottomRight
+                };
+                ((ISetLogicalParent)childPopup).SetParent(childPopup.PlacementTarget);
+                
+                var window = PreparedWindow(parentPopup);
+                window.Show();
+                parentPopup.Open();
+                childPopup.Open();
+                
+                if (childPopup.Host is PopupRoot popupRoot)
+                {
+                    var raised = false;
+                    popupRoot.PositionChanged += (_, args) =>
+                    {
+                        // The parent's initial placement is (395,295) which is a 10x10 popup centered
+                        // in a 800x600 window. When the window is moved, the child's final placement is (405, 305)
+                        // which is the parent's placement moved 10 pixels left and down.
+                        Assert.Equal(new PixelPoint(405, 305), args.Point);
+                        raised = true;
+                    };
+
+                    window.Position = new PixelPoint(10, 10);
+                    Assert.True(raised);
+                }
+            }            
+        }
+
         private IDisposable CreateServices()
         {
             return UnitTestApplication.Start(TestServices.StyledWindow.With(windowingPlatform:

+ 43 - 3
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@@ -6,6 +6,7 @@ using System.ComponentModel;
 using System.Linq;
 using System.Runtime.CompilerServices;
 using Avalonia.Collections;
+using Avalonia.Controls.Generators;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
@@ -24,7 +25,7 @@ namespace Avalonia.Controls.UnitTests
     public class TreeViewTests
     {
         MouseTestHelper _mouse = new MouseTestHelper();
-        
+
         [Fact]
         public void Items_Should_Be_Created()
         {
@@ -675,7 +676,7 @@ namespace Avalonia.Controls.UnitTests
                 Assert.Same(node, focus.Current);
             }
         }
-        
+
         [Fact]
         public void Keyboard_Navigation_Should_Not_Crash_If_Selected_Item_Is_not_In_Tree()
         {
@@ -1166,6 +1167,34 @@ namespace Avalonia.Controls.UnitTests
             Assert.Empty(target.ItemContainerGenerator.Index.Containers);
         }
 
+        [Fact]
+        public void Can_Use_Derived_TreeViewItem()
+        {
+            var tree = CreateTestTreeData();
+            var target = new DerivedTreeViewWithDerivedTreeViewItems
+            {
+                Template = CreateTreeViewTemplate(),
+                Items = tree,
+            };
+
+            ApplyTemplates(target);
+
+            // Verify that all items are DerivedTreeViewItem
+            VerifyItemType(target.ItemContainerGenerator);
+
+            void VerifyItemType(ITreeItemContainerGenerator containerGenerator)
+            {
+                foreach (var container in containerGenerator.Index.Containers)
+                {
+                    var item = Assert.IsType<DerivedTreeViewItem>(container);
+                    if (item.ItemCount > 0)
+                    {
+                        VerifyItemType(item.ItemContainerGenerator);
+                    }
+                }
+            }
+        }
+
         private void ApplyTemplates(TreeView tree)
         {
             tree.ApplyTemplate();
@@ -1376,6 +1405,17 @@ namespace Avalonia.Controls.UnitTests
         {
         }
 
+        private class DerivedTreeViewWithDerivedTreeViewItems : TreeView
+        {
+            protected override ITreeItemContainerGenerator CreateTreeItemContainerGenerator() =>
+                CreateTreeItemContainerGenerator<DerivedTreeViewItem>();
+        }
+
+        private class DerivedTreeViewItem : TreeViewItem
+        {
+            protected override IItemContainerGenerator CreateItemContainerGenerator() => CreateTreeItemContainerGenerator<DerivedTreeViewItem>();
+        }
+
         private class TestDataContext : INotifyPropertyChanged
         {
             private string _selectedItem;
@@ -1398,7 +1438,7 @@ namespace Avalonia.Controls.UnitTests
             }
 
             public event PropertyChangedEventHandler PropertyChanged;
-            
+
         }
     }
 }

+ 3 - 0
tests/Avalonia.UnitTests/MockWindowingPlatform.cs

@@ -64,6 +64,9 @@ namespace Avalonia.UnitTests
                 windowImpl.Object.Activated?.Invoke();
             });
 
+            windowImpl.Setup(x => x.PointToScreen(It.IsAny<Point>()))
+                .Returns((Point p) => PixelPoint.FromPoint(p, 1D) + position);
+
             return windowImpl;
         }