From bd4d40aa380ec7b20e020cb4cd25c15bbb98653c Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 19 Oct 2026 08:28:21 +0330 Subject: [PATCH 1/3] add new Bdnd project #13931 --- .github/agents/dependency-update.md | 2 +- .github/agents/test-platform.md | 4 +- .github/copilot-instructions.md | 4 +- .github/workflows/all.cd.yml | 6 + .github/workflows/all.ci.yml | 14 + .github/workflows/bdnd.demo.cd.yml | 75 ++ .github/workflows/bit.ci.Bdnd.e2e.yml | 63 ++ .github/workflows/bit.ci.Bdnd.yml | 62 ++ .github/workflows/nuget.org.yml | 6 + .github/workflows/prerelease.nuget.org.yml | 5 + .gitignore | 3 + README.md | 1 + src/Bdnd/Bit.Bdnd.slnx | 19 + src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj | 110 +++ src/Bdnd/Bit.Bdnd/BitBdnd.cs | 43 + .../Bit.Bdnd/Components/BdndDragHandle.razor | 34 + .../Components/BdndDragHandle.razor.cs | 11 + .../Bit.Bdnd/Components/BdndDragLayer.razor | 50 + .../Components/BdndDragLayer.razor.cs | 12 + src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs | 288 ++++++ .../Bit.Bdnd/Components/BdndDropTarget.cs | 273 ++++++ .../Bit.Bdnd/Components/BdndProvider.razor | 25 + .../Bit.Bdnd/Components/BdndProvider.razor.cs | 11 + .../Components/IBdndDragHandleController.cs | 11 + src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs | 234 +++++ src/Bdnd/Bit.Bdnd/Core/BdndDragEndContext.cs | 25 + src/Bdnd/Bit.Bdnd/Core/BdndDragLayerState.cs | 36 + src/Bdnd/Bit.Bdnd/Core/BdndDragState.cs | 17 + src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs | 21 + src/Bdnd/Bit.Bdnd/Core/BdndDropState.cs | 27 + src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs | 16 + src/Bdnd/Bit.Bdnd/Core/BdndDropTargetScope.cs | 32 + .../Bit.Bdnd/Core/BdndNativeDropEventArgs.cs | 29 + .../Bit.Bdnd/Core/BdndNativeDropPayload.cs | 14 + src/Bdnd/Bit.Bdnd/Core/BdndNativeFile.cs | 14 + src/Bdnd/Bit.Bdnd/Core/BdndNativeTypes.cs | 34 + src/Bdnd/Bit.Bdnd/Core/BdndXYCoord.cs | 13 + src/Bdnd/Bit.Bdnd/Core/IBdndMonitor.cs | 58 ++ src/Bdnd/Bit.Bdnd/Interop/BdndDomRect.cs | 17 + .../Bit.Bdnd/Interop/BdndDragSourceOptions.cs | 10 + .../Bit.Bdnd/Interop/BdndDropTargetOptions.cs | 9 + src/Bdnd/Bit.Bdnd/Interop/BdndInterop.cs | 73 ++ .../Bit.Bdnd/Interop/BdndJsRegistration.cs | 105 +++ src/Bdnd/Bit.Bdnd/README.md | 70 ++ src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts | 423 +++++++++ src/Bdnd/Bit.Bdnd/_Imports.razor | 2 + src/Bdnd/Bit.Bdnd/package-lock.json | 511 ++++++++++ src/Bdnd/Bit.Bdnd/package.json | 6 + src/Bdnd/Bit.Bdnd/tsconfig.json | 21 + .../Demo/Client/Bit.Bdnd.Demo.Client.csproj | 128 +++ src/Bdnd/Demo/Client/Examples/DemoCode.razor | 56 ++ .../IServiceCollectionExtensions.cs | 20 + src/Bdnd/Demo/Client/ItemTypes.cs | 13 + src/Bdnd/Demo/Client/Layout/MainLayout.razor | 21 + .../Demo/Client/Layout/MainLayout.razor.css | 78 ++ src/Bdnd/Demo/Client/Layout/NavMenu.razor | 103 ++ src/Bdnd/Demo/Client/Layout/NavMenu.razor.css | 114 +++ .../Client/Pages/Customize/DropEffects.razor | 53 ++ .../Demo/Client/Pages/Customize/Handles.razor | 47 + .../Client/Pages/DragAround/CustomLayer.razor | 73 ++ .../Demo/Client/Pages/DragAround/Naive.razor | 64 ++ .../Client/Pages/Dustbin/CopyOrMove.razor | 75 ++ .../Demo/Client/Pages/Dustbin/Iframe.razor | 38 + .../Pages/Dustbin/MultipleTargets.razor | 96 ++ .../Client/Pages/Dustbin/SingleTarget.razor | 68 ++ .../Client/Pages/Dustbin/StressTest.razor | 125 +++ src/Bdnd/Demo/Client/Pages/Home.razor | 57 ++ src/Bdnd/Demo/Client/Pages/McpServer.razor | 100 ++ .../Client/Pages/Nesting/DragSources.razor | 45 + .../Client/Pages/Nesting/DropTargets.razor | 74 ++ src/Bdnd/Demo/Client/Pages/NotFound.razor | 5 + .../Demo/Client/Pages/Other/NativeFiles.razor | 68 ++ .../Demo/Client/Pages/Other/NativeHtml.razor | 65 ++ .../Pages/Sortable/CancelOnDropOutside.razor | 71 ++ .../Demo/Client/Pages/Sortable/Simple.razor | 48 + .../Client/Pages/Sortable/SortableCard.razor | 88 ++ .../Client/Pages/Sortable/StressTest.razor | 40 + src/Bdnd/Demo/Client/Pages/Tutorial.razor | 81 ++ src/Bdnd/Demo/Client/Program.cs | 11 + src/Bdnd/Demo/Client/Routes.razor | 6 + src/Bdnd/Demo/Client/_Imports.razor | 12 + .../Demo/Server/Bit.Bdnd.Demo.Server.csproj | 68 ++ src/Bdnd/Demo/Server/Components/App.razor | 34 + .../Demo/Server/Components/Pages/Error.razor | 36 + .../Demo/Server/Components/_Imports.razor | 13 + .../Demo/Server/Controllers/McpController.cs | 165 ++++ .../Demo/Server/Controllers/McpPrompts.cs | 125 +++ .../Demo/Server/Controllers/McpResources.cs | 82 ++ src/Bdnd/Demo/Server/Dtos/BdndMcpDtos.cs | 264 ++++++ src/Bdnd/Demo/Server/Program.cs | 105 +++ .../Server/Properties/launchSettings.json | 15 + .../Demo/Server/Services/BdndApiCatalog.cs | 660 +++++++++++++ .../Demo/Server/Services/BdndCompletions.cs | 93 ++ src/Bdnd/Demo/Server/Services/BdndDropHost.cs | 487 ++++++++++ src/Bdnd/Demo/Server/Services/BdndDropLab.cs | 820 ++++++++++++++++ .../Demo/Server/Services/BdndSearchIndex.cs | 527 +++++++++++ .../Server/Services/BdndServerInstructions.cs | 60 ++ .../Demo/Server/Services/BdndSetupGuide.cs | 392 ++++++++ .../Demo/Server/Services/BdndSourceCatalog.cs | 497 ++++++++++ src/Bdnd/Demo/Server/Services/BdndXmlDocs.cs | 216 +++++ src/Bdnd/Demo/Server/appsettings.json | 9 + src/Bdnd/Demo/Server/wwwroot/app.css | 515 ++++++++++ src/Bdnd/Demo/Server/wwwroot/favicon.png | Bin 0 -> 1148 bytes src/Bdnd/README.md | 876 ++++++++++++++++++ .../Tests/Bit.Bdnd.Tests.E2E/AssemblySetup.cs | 31 + .../Bit.Bdnd.Tests.E2E.csproj | 31 + .../Bit.Bdnd.Tests.E2E/CustomizeTests.cs | 110 +++ .../Bit.Bdnd.Tests.E2E/DemoPagesTests.cs | 117 +++ .../Bit.Bdnd.Tests.E2E/DragAroundTests.cs | 76 ++ .../Tests/Bit.Bdnd.Tests.E2E/DustbinTests.cs | 260 ++++++ .../Infrastructure/BdndTest.cs | 485 ++++++++++ .../Infrastructure/ChildProcess.cs | 115 +++ .../Infrastructure/DemoHost.cs | 54 ++ .../Infrastructure/E2EEnvironment.cs | 52 ++ .../Infrastructure/PlaywrightSession.cs | 112 +++ .../Infrastructure/RepoLayout.cs | 41 + .../Bit.Bdnd.Tests.E2E/NativeDragTests.cs | 129 +++ .../Tests/Bit.Bdnd.Tests.E2E/NestingTests.cs | 97 ++ src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md | 112 +++ .../Tests/Bit.Bdnd.Tests.E2E/SortableTests.cs | 92 ++ .../Tests/Bit.Bdnd.Tests.E2E/TutorialTests.cs | 102 ++ .../Bit.Bdnd.Tests.Mcp.csproj | 37 + src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpCall.cs | 66 ++ .../Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs | 408 ++++++++ .../Bit.Bdnd.Tests.Mcp/McpOracleTests.cs | 354 +++++++ .../McpResourcePromptTests.cs | 215 +++++ .../Bit.Bdnd.Tests.Mcp/McpSearchTests.cs | 236 +++++ .../Bit.Bdnd.Tests.Mcp/McpSurfaceTests.cs | 220 +++++ .../Tests/Bit.Bdnd.Tests.Mcp/McpTestHost.cs | 62 ++ .../Tests/Bit.Bdnd.Tests.Mcp/McpToolTests.cs | 306 ++++++ .../Tests/Bit.Bdnd.Tests.Mcp/ToolNames.cs | 20 + .../Bit.Bdnd.Tests/Bit.Bdnd.Tests.csproj | 25 + src/Bdnd/Tests/Bit.Bdnd.Tests/BitBdndTests.cs | 75 ++ .../Components/BdndComponentTestBase.cs | 158 ++++ .../Components/BdndDragHandleTests.cs | 118 +++ .../Components/BdndDragLayerTests.cs | 141 +++ .../Components/BdndDragLifecycleTests.cs | 619 +++++++++++++ .../Components/BdndDraggableTests.cs | 211 +++++ .../Components/BdndDropTargetTests.cs | 386 ++++++++ .../Components/BdndNativeDragTests.cs | 237 +++++ .../Components/BdndProviderTests.cs | 136 +++ .../Core/BdndDragDropManagerTests.cs | 635 +++++++++++++ .../Bit.Bdnd.Tests/Core/BdndValueTests.cs | 180 ++++ .../Fakes/FakeJSObjectReference.cs | 59 ++ .../Bit.Bdnd.Tests/Fakes/FakeJSRuntime.cs | 36 + src/Bdnd/Tests/Bit.Bdnd.Tests/GlobalUsings.cs | 2 + .../Interop/BdndInteropTests.cs | 168 ++++ .../Interop/BdndJsRegistrationTests.cs | 266 ++++++ src/Bit.CI.Release.slnx | 7 + src/Bit.slnx | 13 + src/BlazorUI/CLAUDE.md | 2 +- .../SignalR/AppHub.cs | 2 +- .../Client/Pages/McpServerPage.razor | 2 +- .../.docs/19- Project Miscellaneous Files.md | 2 +- .../Pages/Home/Products.razor | 18 + .../Shared/Footer.razor | 1 + .../Shared/Header.razor.cs | 1 + .../Shared/Urls.cs | 1 + .../Services/Mcp/CodebaseMemoryCli.cs | 2 +- .../Services/Mcp/McpDocumentationSite.cs | 1 + .../Services/Mcp/McpFeedbackTool.cs | 2 +- .../Services/Mcp/McpProxyService.cs | 4 +- .../SignalR/AppHub.cs | 4 +- .../Startup/Services.cs | 2 +- 164 files changed, 18391 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/bdnd.demo.cd.yml create mode 100644 .github/workflows/bit.ci.Bdnd.e2e.yml create mode 100644 .github/workflows/bit.ci.Bdnd.yml create mode 100644 src/Bdnd/Bit.Bdnd.slnx create mode 100644 src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj create mode 100644 src/Bdnd/Bit.Bdnd/BitBdnd.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor create mode 100644 src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor.cs create mode 100644 src/Bdnd/Bit.Bdnd/Components/IBdndDragHandleController.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDragEndContext.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDragLayerState.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDragState.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDropState.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndDropTargetScope.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndNativeDropPayload.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndNativeFile.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndNativeTypes.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/BdndXYCoord.cs create mode 100644 src/Bdnd/Bit.Bdnd/Core/IBdndMonitor.cs create mode 100644 src/Bdnd/Bit.Bdnd/Interop/BdndDomRect.cs create mode 100644 src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs create mode 100644 src/Bdnd/Bit.Bdnd/Interop/BdndDropTargetOptions.cs create mode 100644 src/Bdnd/Bit.Bdnd/Interop/BdndInterop.cs create mode 100644 src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs create mode 100644 src/Bdnd/Bit.Bdnd/README.md create mode 100644 src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts create mode 100644 src/Bdnd/Bit.Bdnd/_Imports.razor create mode 100644 src/Bdnd/Bit.Bdnd/package-lock.json create mode 100644 src/Bdnd/Bit.Bdnd/package.json create mode 100644 src/Bdnd/Bit.Bdnd/tsconfig.json create mode 100644 src/Bdnd/Demo/Client/Bit.Bdnd.Demo.Client.csproj create mode 100644 src/Bdnd/Demo/Client/Examples/DemoCode.razor create mode 100644 src/Bdnd/Demo/Client/Extensions/IServiceCollectionExtensions.cs create mode 100644 src/Bdnd/Demo/Client/ItemTypes.cs create mode 100644 src/Bdnd/Demo/Client/Layout/MainLayout.razor create mode 100644 src/Bdnd/Demo/Client/Layout/MainLayout.razor.css create mode 100644 src/Bdnd/Demo/Client/Layout/NavMenu.razor create mode 100644 src/Bdnd/Demo/Client/Layout/NavMenu.razor.css create mode 100644 src/Bdnd/Demo/Client/Pages/Customize/DropEffects.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Customize/Handles.razor create mode 100644 src/Bdnd/Demo/Client/Pages/DragAround/CustomLayer.razor create mode 100644 src/Bdnd/Demo/Client/Pages/DragAround/Naive.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Dustbin/CopyOrMove.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Dustbin/Iframe.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Dustbin/MultipleTargets.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Dustbin/SingleTarget.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Dustbin/StressTest.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Home.razor create mode 100644 src/Bdnd/Demo/Client/Pages/McpServer.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Nesting/DragSources.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Nesting/DropTargets.razor create mode 100644 src/Bdnd/Demo/Client/Pages/NotFound.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Other/NativeFiles.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Other/NativeHtml.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Sortable/CancelOnDropOutside.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Sortable/Simple.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Sortable/SortableCard.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Sortable/StressTest.razor create mode 100644 src/Bdnd/Demo/Client/Pages/Tutorial.razor create mode 100644 src/Bdnd/Demo/Client/Program.cs create mode 100644 src/Bdnd/Demo/Client/Routes.razor create mode 100644 src/Bdnd/Demo/Client/_Imports.razor create mode 100644 src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj create mode 100644 src/Bdnd/Demo/Server/Components/App.razor create mode 100644 src/Bdnd/Demo/Server/Components/Pages/Error.razor create mode 100644 src/Bdnd/Demo/Server/Components/_Imports.razor create mode 100644 src/Bdnd/Demo/Server/Controllers/McpController.cs create mode 100644 src/Bdnd/Demo/Server/Controllers/McpPrompts.cs create mode 100644 src/Bdnd/Demo/Server/Controllers/McpResources.cs create mode 100644 src/Bdnd/Demo/Server/Dtos/BdndMcpDtos.cs create mode 100644 src/Bdnd/Demo/Server/Program.cs create mode 100644 src/Bdnd/Demo/Server/Properties/launchSettings.json create mode 100644 src/Bdnd/Demo/Server/Services/BdndApiCatalog.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndCompletions.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndDropHost.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndDropLab.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndSearchIndex.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndServerInstructions.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndSetupGuide.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndSourceCatalog.cs create mode 100644 src/Bdnd/Demo/Server/Services/BdndXmlDocs.cs create mode 100644 src/Bdnd/Demo/Server/appsettings.json create mode 100644 src/Bdnd/Demo/Server/wwwroot/app.css create mode 100644 src/Bdnd/Demo/Server/wwwroot/favicon.png create mode 100644 src/Bdnd/README.md create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/AssemblySetup.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/CustomizeTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DemoPagesTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DragAroundTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DustbinTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/BdndTest.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/ChildProcess.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/DemoHost.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/E2EEnvironment.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/PlaywrightSession.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/RepoLayout.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NativeDragTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NestingTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/SortableTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/TutorialTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/Bit.Bdnd.Tests.Mcp.csproj create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpCall.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpOracleTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpResourcePromptTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSearchTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSurfaceTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpTestHost.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpToolTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/ToolNames.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Bit.Bdnd.Tests.csproj create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/BitBdndTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndComponentTestBase.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragHandleTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLayerTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDropTargetTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndProviderTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndDragDropManagerTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndValueTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSRuntime.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/GlobalUsings.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs create mode 100644 src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs diff --git a/.github/agents/dependency-update.md b/.github/agents/dependency-update.md index 2aee7a26924..d66edcb3eec 100644 --- a/.github/agents/dependency-update.md +++ b/.github/agents/dependency-update.md @@ -90,7 +90,7 @@ higher-numbered lines exist there. **TypeScript is held at 5.9.3 in the libraries.** 7.x is the Go rewrite (`microsoft/typescript-go`), not an incremental release. `Boilerplate.Client.Core` is the canary and is already on 7.0.2 — if a bump is wanted, move one library project, build it, and only then the -rest. Do not move all twenty-two at once. +rest. Do not move all twenty-three at once. **Vitest is held at `^3`** in `src/Bmotion/Tests/bit-bmotion-js`, `src/Bswup/Tests/bit-bswup-js` and `src/Bsqlite/Tests/bit-bsqlite-js`. diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index 9ebf869d629..fecdf3fe656 100644 --- a/.github/agents/test-platform.md +++ b/.github/agents/test-platform.md @@ -369,7 +369,7 @@ does not serve silently gets the newest one it does. `feat(release): v-X.Y.Z` commit on `develop` has its tag: an untagged release is answered by the previous one, without a word. 2. **What it does serve.** `mcp -McpVersion X.Y.Z` for each. The library tools (`Search*`, `Get*` and `Inspect*` of BlazorUI, - Brouter, Butil, Bswup, Bmotion and - from the first release that ships them - Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage and Bsqlite) and the source tools (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, + Brouter, Butil, Bswup, Bmotion and - from the first release that ships them - Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage and Bsqlite) and the source tools (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, `SearchBitPlatformCode`) must all be there. Only the five shared ones (`AskGitHubRepository`, `SendBitPlatformFeedback`, `microsoft_docs_*`) means no release is served at all, and the README's *PlatformWebsite's MCP* section says why that happens. The server version it reports is the website's own build, @@ -388,7 +388,7 @@ does not serve silently gets the newest one it does. 4. **The answers are good.** Put one real task to each library's `Search` tool, worded the way a developer would describe it - BlazorUI "a dropdown the user can also type into", Butil "copy text to the clipboard", Bmotion "fade a list item in", Brouter "a route parameter that must be a number", Bswup "show the download progress of the app's - update", Bink "a signature pad that exports a PNG", Balette "a command that asks for a name before it runs", BbScanner "scan a QR code from an uploaded photo", BlazeDb "a todo list that survives a page reload", Blow "a flow chart that lays itself out left to right", Bonboarding "a step that waits for a panel that loads later", Blex "a counter whose value survives a page reload", Bmage "shrink an uploaded photo under 200 KB without its location", Bsqlite "keep the database when OPFS is not available" - follow up with the `Get` tool it points to, and ask `SearchBitPlatformCode` about the Boilerplate. An + update", Bink "a signature pad that exports a PNG", Balette "a command that asks for a name before it runs", BbScanner "scan a QR code from an uploaded photo", Bdnd "reorder a list by dragging its items", BlazeDb "a todo list that survives a page reload", Blow "a flow chart that lays itself out left to right", Bonboarding "a step that waits for a panel that loads later", Blex "a counter whose value survives a page reload", Bmage "shrink an uploaded photo under 200 KB without its location", Bsqlite "keep the database when OPFS is not available" - follow up with the `Get` tool it points to, and ask `SearchBitPlatformCode` about the Boilerplate. An answer must be on topic, specific and true to the version asked for; one that answers wrongly is a failure too. Like an E2E failure, an MCP problem is fixed in `src/Websites/Platform` or in the library's own MCP server, and goes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2fa4b233d6d..142baffc60b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,8 +46,8 @@ ## Rules **RULE 1:** If a task (question, code modification or review) involves the use of bit BlazorUI components (e.g., `BitButton`, `BitTooltip`, `BitTextField`), -or refers to `bitplatform`, `bit Bswup`, `bit Butil`, `bit Bmotion`, `bit Bink`, `bit Balette`, `bit BbScanner`, `bit BlazeDb`, `bit Blow`, `bit Bonboarding`, `bit Blex`, `bit Bmage`, `bit Bsqlite`, `bit Brouter`, or `bit Boilerplate`, or involves UI components without explicitly specifying their UI toolkit, -you **MUST** use the `bitplatform` MCP server's tools - start with the matching `Search` tool (e.g. `SearchBitBlazorUI`, `SearchBswup`, `SearchButil`, `SearchBmotion`, `SearchBink`, `SearchBalette`, `SearchBbScanner`, `SearchBlazeDb`, `SearchBlow`, `SearchBonboarding`, `SearchBlex`, `SearchBmage`, `SearchBsqlite`, `SearchBrouter`) and follow the calls it names - to find the correct implementation and usage patterns before writing or changing any code. +or refers to `bitplatform`, `bit Bswup`, `bit Butil`, `bit Bmotion`, `bit Bink`, `bit Balette`, `bit BbScanner`, `bit Bdnd`, `bit BlazeDb`, `bit Blow`, `bit Bonboarding`, `bit Blex`, `bit Bmage`, `bit Bsqlite`, `bit Brouter`, or `bit Boilerplate`, or involves UI components without explicitly specifying their UI toolkit, +you **MUST** use the `bitplatform` MCP server's tools - start with the matching `Search` tool (e.g. `SearchBitBlazorUI`, `SearchBswup`, `SearchButil`, `SearchBmotion`, `SearchBink`, `SearchBalette`, `SearchBbScanner`, `SearchBdnd`, `SearchBlazeDb`, `SearchBlow`, `SearchBonboarding`, `SearchBlex`, `SearchBmage`, `SearchBsqlite`, `SearchBrouter`) and follow the calls it names - to find the correct implementation and usage patterns before writing or changing any code. **End of RULE 1** diff --git a/.github/workflows/all.cd.yml b/.github/workflows/all.cd.yml index 283bc6f3095..a23e3d98d32 100644 --- a/.github/workflows/all.cd.yml +++ b/.github/workflows/all.cd.yml @@ -95,6 +95,12 @@ jobs: uses: ./.github/workflows/bbscanner.demo.cd.yml secrets: inherit + bdnd_demo: + name: bdnd demo + if: inputs.server + uses: ./.github/workflows/bdnd.demo.cd.yml + secrets: inherit + blazedb_demo: name: blazedb demo if: inputs.server diff --git a/.github/workflows/all.ci.yml b/.github/workflows/all.ci.yml index da4c009b4f6..ac593b75036 100644 --- a/.github/workflows/all.ci.yml +++ b/.github/workflows/all.ci.yml @@ -71,6 +71,10 @@ on: description: BbScanner solution type: boolean default: true + bdnd: + description: Bdnd solution + type: boolean + default: true blazedb: description: BlazeDb solution type: boolean @@ -658,6 +662,11 @@ jobs: dotnet build src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false dotnet pack src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj --output ./packages --configuration Release + - name: Build and pack Bdnd + run: | + dotnet build src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + dotnet pack src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj --output ./packages --configuration Release + - name: Build and pack BlazeDb run: | dotnet build src/BlazeDb/Bit.BlazeDb/Bit.BlazeDb.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false @@ -796,6 +805,11 @@ jobs: if: ${{ inputs.bbscanner }} uses: ./.github/workflows/bit.ci.BbScanner.yml + bdnd: + name: bdnd + if: ${{ inputs.bdnd }} + uses: ./.github/workflows/bit.ci.Bdnd.yml + blazedb: name: blazedb if: ${{ inputs.blazedb }} diff --git a/.github/workflows/bdnd.demo.cd.yml b/.github/workflows/bdnd.demo.cd.yml new file mode 100644 index 00000000000..b3007f4680b --- /dev/null +++ b/.github/workflows/bdnd.demo.cd.yml @@ -0,0 +1,75 @@ +name: Bdnd Demo CD + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + + build_blazor_wasm: + name: build blazor wasm + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Install wasm + run: cd src && dotnet workload install wasm-tools + + - name: Publish + run: dotnet publish src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj -c Release -o server -p:Version="${{ vars.APP_VERSION}}" -p:InvariantGlobalization=true + + # Server-owned; C:\inetpub keeps its own copies. + - name: Strip server-owned files from bundle + run: rm -fv server/appsettings*.json server/AppCertificate.* server/AppleAuthKey.p8 server/*.pfx + + - name: Upload artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: BdndDemo + path: server + include-hidden-files: true + + deploy_server: + name: deploy + needs: build_blazor_wasm + runs-on: [self-hosted, Windows, X64, bitplatform-iis] + environment: + name: production + url: 'https://bdnd.bitplatform.dev' + + steps: + + - name: Checkout deploy action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + sparse-checkout: .github/actions + + - name: Deploy + uses: ./.github/actions/deploy-to-server + with: + artifact-name: BdndDemo + app-pool: Bdnd + site-path: C:\inetpub\BdndDemo + sentinel-file: Bit.Bdnd.Demo.Server.dll + health-check-url: 'https://bdnd.bitplatform.dev/' + cloudflare-zone-id: ${{ secrets.CLOUDFLARE_ZONE_ID }} + cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.github/workflows/bit.ci.Bdnd.e2e.yml b/.github/workflows/bit.ci.Bdnd.e2e.yml new file mode 100644 index 00000000000..540ceac8f43 --- /dev/null +++ b/.github/workflows/bit.ci.Bdnd.e2e.yml @@ -0,0 +1,63 @@ +name: bit platform CI - Bdnd E2E + +# The browser suite: the demo (prerendered, then running on WebAssembly) driven in Chromium with Playwright. It +# takes far longer than the build-and-unit pipeline (bit.ci.Bdnd.yml), so it runs only when started by hand. See +# src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md for what it proves and how to run it locally. + +on: + workflow_dispatch: + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + + e2e: + name: Bdnd E2E (Chromium) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: dotnet workload restore + run: cd src/Bdnd && dotnet workload restore + + - name: Build the demo and the E2E suite + run: | + dotnet build src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj -c Release + dotnet build src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj -c Release + + - name: Install Playwright Chromium + run: pwsh src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests + # Run from inside src so src/global.json's Microsoft.Testing.Platform runner setting applies. + run: > + cd src/Bdnd/Tests/Bit.Bdnd.Tests.E2E && + dotnet test -c Release --no-build + --report-trx --report-trx-filename bdnd-e2e.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: bdnd-e2e + path: '**/bdnd-e2e.trx' + if-no-files-found: ignore diff --git a/.github/workflows/bit.ci.Bdnd.yml b/.github/workflows/bit.ci.Bdnd.yml new file mode 100644 index 00000000000..16824df015e --- /dev/null +++ b/.github/workflows/bit.ci.Bdnd.yml @@ -0,0 +1,62 @@ +name: bit platform CI - Bdnd + +# Build, unit tests and MCP server tests. The browser suite lives in bit.ci.Bdnd.e2e.yml and runs on demand only. + +on: + workflow_dispatch: + workflow_call: + pull_request: + paths: + - 'src/Bdnd/**' + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + build-Bdnd: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: build Bit.Bdnd + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - name: Setup .NET 9.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 9.0.x + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: dotnet workload restore + run: cd src/Bdnd && dotnet workload restore + + - name: dotnet build + run: dotnet build src/Bdnd/Bit.Bdnd.slnx + + - name: dotnet test + # Run from inside src so src/global.json's "test" runner setting applies + # (from the repo root the SDK falls back to the unsupported VSTest path). + run: cd src/Bdnd/Tests/Bit.Bdnd.Tests && dotnet test --no-build + + - name: dotnet test (MCP server) + run: cd src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp && dotnet test --no-build diff --git a/.github/workflows/nuget.org.yml b/.github/workflows/nuget.org.yml index 83ba8b2bcb4..5301d2dd054 100644 --- a/.github/workflows/nuget.org.yml +++ b/.github/workflows/nuget.org.yml @@ -8,6 +8,7 @@ on: paths: - 'src/Balette/**' - 'src/BbScanner/**' + - 'src/Bdnd/**' - 'src/BlazorUI/**' - 'src/Bink/**' - 'src/BlazeDb/**' @@ -215,6 +216,11 @@ jobs: - name: dotnet pack BbScanner run: dotnet pack src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj --output . --configuration Release + - name: dotnet build Bdnd + run: dotnet build src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + - name: dotnet pack Bdnd + run: dotnet pack src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj --output . --configuration Release + - name: dotnet build BlazeDb run: dotnet build src/BlazeDb/Bit.BlazeDb/Bit.BlazeDb.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false - name: dotnet pack BlazeDb diff --git a/.github/workflows/prerelease.nuget.org.yml b/.github/workflows/prerelease.nuget.org.yml index 1a2225626b5..c0d9f4925c2 100644 --- a/.github/workflows/prerelease.nuget.org.yml +++ b/.github/workflows/prerelease.nuget.org.yml @@ -193,6 +193,11 @@ jobs: - name: dotnet pack BbScanner run: dotnet pack src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj --output . --configuration Release + - name: dotnet build Bdnd + run: dotnet build src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + - name: dotnet pack Bdnd + run: dotnet pack src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj --output . --configuration Release + - name: dotnet build BlazeDb run: dotnet build src/BlazeDb/Bit.BlazeDb/Bit.BlazeDb.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false - name: dotnet pack BlazeDb diff --git a/.gitignore b/.gitignore index bda3fd72a76..54422735c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -297,6 +297,9 @@ _book /src/BlazeDb/Bit.BlazeDb.Browser/Scripts/**/*.js /src/BlazeDb/Bit.BlazeDb.Browser/wwwroot/**/*.js +/src/Bdnd/Bit.Bdnd/Scripts/**/*.js +/src/Bdnd/Bit.Bdnd/wwwroot/**/*.js + /src/Blow/Bit.Blow/Scripts/**/*.js /src/Blow/Bit.Blow/wwwroot/**/*.js /src/Blow/Bit.Blow/wwwroot/**/*.css diff --git a/README.md b/README.md index a9e2da205e1..4b5f94f0946 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ bit new MyApp - [***bit BlazorUI***](https://blazorui.bitplatform.dev) (`Bit.BlazorUI`): 90+ high-performance, accessible components with Dark and Light themes and RTL support, split across `Bit.BlazorUI`, `Bit.BlazorUI.Extras` (DataGrid, Chart, PdfViewer, RichTextEditor, Map, FullCalendar, CountryPicker, TimeZonePicker, and more), `Bit.BlazorUI.Icons` and `Bit.BlazorUI.Assets`. - [***BbScanner***](src/BbScanner/README.md) (`Bit.BbScanner`): A dependency-free barcode scanner for Blazor that reads QR Code, Data Matrix, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E and ITF from the camera or from still images. The decoding is written from scratch in C#; the browser only opens the camera and copies pixels. +- [***Bdnd***](src/Bdnd/README.md) (`Bit.Bdnd`): A declarative drag-and-drop library for Blazor inspired by React DnD, with typed items matched to drop targets by type, custom drag previews, drag handles, sortable lists, nested sources and targets, and native file, text and link drops. The drag state lives in C#; the browser only configures the native drag and tracks hover. - [***Bink***](src/Bink/README.md) (`Bit.Bink`): A Blazor-native drawing and ink library, with pressure- and tilt-aware strokes, brushes, erasers, shapes, annotations, selection, layers, undo/redo, real-time collaboration and SVG/PNG export. The document model and the drawing engine run in C#; the browser only captures input and paints. - [***Balette***](src/Balette/README.md) (`Bit.Balette`): A Blazor-native, keyboard-first command palette (`Ctrl+K` / `⌘K`), with fzf-style fuzzy search, nested pages, prefix modes, async sources, recents and frecency, global shortcuts and sequences, prompts, multi-select and full theming. Matching and ranking run in C#; the browser only opens the dialog and listens for keys. - [***Blow***](src/Blow/README.md) (`Bit.Blow`): A Blazor-native library for node-based UIs, editors, flow charts and diagrams - a Blazor counterpart to React Flow - with a pannable, zoomable canvas, draggable, resizable and connectable nodes, sub-flows, auto-layout, alignment guides, undo/redo, copy/paste and full keyboard accessibility. Interaction, layout and geometry run in C#; the browser only measures the DOM. diff --git a/src/Bdnd/Bit.Bdnd.slnx b/src/Bdnd/Bit.Bdnd.slnx new file mode 100644 index 00000000000..a659e0034fe --- /dev/null +++ b/src/Bdnd/Bit.Bdnd.slnx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj b/src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj new file mode 100644 index 00000000000..e9756c9688b --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj @@ -0,0 +1,110 @@ + + + + + + net10.0;net9.0;net8.0 + Bit.Bdnd + enable + true + A declarative drag-and-drop library for Blazor inspired by React DnD: composable BdndDraggable, BdndDropTarget, BdndDragLayer and BdndDragHandle components on the HTML5 drag-and-drop API, with strongly typed items, type-matched targets, custom pointer-following drag previews, drag handles, nested sources and targets, and native OS drags of files, text, links and HTML. The drag state lives in C#; the browser layer only configures the native drag and tracks hover. + Bit;Blazor;DragAndDrop;DnD;Drag;Drop;Sortable;ReactDnD;HTML5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_BdndStaleJsStamps Include="$(BaseIntermediateOutputPath)bit-bdnd.js.*.stamp" /> + + + + + + + + + + + diff --git a/src/Bdnd/Bit.Bdnd/BitBdnd.cs b/src/Bdnd/Bit.Bdnd/BitBdnd.cs new file mode 100644 index 00000000000..747aed55028 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/BitBdnd.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Bdnd; + +/// +/// Extension methods to register Bit.Bdnd services in the DI container. +/// +public static class BitBdnd +{ + /// + /// Registers the Bit.Bdnd interop service. The itself is + /// provided per-subtree by the component, so it is not registered + /// here; this only wires up the JS interop dependency. Calling it more than once is harmless. + /// Call this in Program.cs before builder.Build(): + /// builder.Services.AddBitBdndServices(); + /// + public static IServiceCollection AddBitBdndServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Keep the JSON-marshaled interop DTOs trim/AOT-safe. These types cross the JS<->.NET + // boundary via reflection-based System.Text.Json (InvokeAsync, [JSInvokable] params), + // so the trimmer can't see their members as used and would otherwise strip them - leading + // to empty/failed (de)serialization in published WebAssembly builds. + PreserveInteropContracts(); + + services.TryAddScoped(); + + return services; + } + + // Roots the public properties + constructors of every type that is (de)serialized across JS + // interop so the trimmer preserves them. The [DynamicDependency] attributes take effect because + // this method is reachable from AddBitBdndServices (an app entry point). + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BdndDomRect))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BdndNativeDropPayload))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BdndNativeFile))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(BdndDragSourceOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(BdndDropTargetOptions))] + private static void PreserveInteropContracts() { } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor b/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor new file mode 100644 index 00000000000..5152087b7a9 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor @@ -0,0 +1,34 @@ +@namespace Bit.Bdnd + +
+ @ChildContent +
+ +@code { + [CascadingParameter] private IBdndDragHandleController? Controller { get; set; } + + /// The handle's content, e.g. a grip icon. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Inline style of the handle element; a cursor: move is prepended while the draggable can be dragged. + [Parameter] public string? Style { get; set; } + + /// Any other attribute, applied to the handle element. + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + private bool IsActive => Controller is { RequiresHandle: true, CanDrag: true }; + + private string? StyleWithCursor + { + get + { + var cursor = Controller?.CanDrag == true ? "cursor: move;" : null; + if (string.IsNullOrEmpty(Style)) return cursor; + return cursor is null ? Style : $"{cursor} {Style}"; + } + } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor.cs new file mode 100644 index 00000000000..12dd3c7e78f --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDragHandle.razor.cs @@ -0,0 +1,11 @@ +namespace Bit.Bdnd; + +/// +/// Designates a sub-region of a configured with +/// UseHandle="true" as its grab handle. The handle is the native draggable element, so only +/// drags that start on it move the item; the whole draggable is still used as the drag image, and the +/// rest of it stays interactive. +/// +public partial class BdndDragHandle +{ +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor b/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor new file mode 100644 index 00000000000..dc63e70fd2d --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor @@ -0,0 +1,50 @@ +@namespace Bit.Bdnd +@typeparam TItem +@implements IDisposable + +@if (ChildContent is not null) +{ + @ChildContent(BuildState()) +} + +@code { + [CascadingParameter] private BdndDragDropManager? Manager { get; set; } + + /// The preview to render; it receives the live drag state, offsets included. + [Parameter] public RenderFragment>? ChildContent { get; set; } + + protected override void OnInitialized() + { + if (Manager is null) + throw new InvalidOperationException( + $"{nameof(BdndDragLayer)} must be placed inside a {nameof(BdndProvider)}."); + Manager.StateChanged += OnChanged; + Manager.OffsetChanged += OnChanged; + } + + private void OnChanged() => InvokeAsync(StateHasChanged); + + private BdndDragLayerState BuildState() + { + var m = Manager!.Monitor; + return new BdndDragLayerState + { + Monitor = m, + IsDragging = m.IsDragging(), + ItemType = m.GetItemType(), + Item = m.GetItem() is TItem t ? t : default, + InitialClientOffset = m.GetInitialClientOffset(), + InitialSourceClientOffset = m.GetInitialSourceClientOffset(), + ClientOffset = m.GetClientOffset(), + DifferenceFromInitialOffset = m.GetDifferenceFromInitialOffset(), + SourceClientOffset = m.GetSourceClientOffset(), + }; + } + + public void Dispose() + { + if (Manager is null) return; + Manager.StateChanged -= OnChanged; + Manager.OffsetChanged -= OnChanged; + } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor.cs new file mode 100644 index 00000000000..339d2adda2d --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDragLayer.razor.cs @@ -0,0 +1,12 @@ +namespace Bit.Bdnd; + +/// +/// Renders a custom drag preview that follows the pointer, the equivalent of React DnD's +/// useDragLayer. Pair it with UseCustomDragLayer="true" on the +/// to hide the native preview. The child content receives a +/// with the live offsets. +/// +/// The type of the dragged item payload the preview renders. +public partial class BdndDragLayer +{ +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs new file mode 100644 index 00000000000..7ea5f2fd130 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs @@ -0,0 +1,288 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace Bit.Bdnd; + +/// +/// Wires its host element into the DnD system as a drag source. +/// This is the component equivalent of React DnD's useDrag hook: you declare a +/// and an (the data), and the child content receives +/// a with the collected props (e.g. IsDragging). +/// +/// The type of the dragged item payload. +public sealed class BdndDraggable : ComponentBase, IHandleEvent, IAsyncDisposable +{ + [CascadingParameter] private BdndDragDropManager? Manager { get; set; } + [Inject] private BdndInterop Interop { get; set; } = default!; + + /// Required. The item type used to match against drop-target Accept lists. + [Parameter, EditorRequired] public string Type { get; set; } = default!; + + /// The item payload. Ignored when is supplied. + [Parameter] public TItem? Item { get; set; } + + /// + /// Produces the item lazily at drag start. Return null to cancel the drag (use a nullable + /// for value types, where default is a valid item). + /// + [Parameter] public Func? ItemFactory { get; set; } + + /// Whether dragging is allowed. Defaults to true. + [Parameter] public bool CanDrag { get; set; } = true; + + /// Optional predicate overriding , evaluated at render time and at drag start. + [Parameter] public Func? CanDragPredicate { get; set; } + + /// Custom isDragging implementation (e.g. id-based for re-parented items). + [Parameter] public Func? IsDraggingOverride { get; set; } + + /// Hide the native drag image (use together with a ). + [Parameter] public bool UseCustomDragLayer { get; set; } + + /// Require a child to initiate the drag. + [Parameter] public bool UseHandle { get; set; } + + /// + /// The drop effects the source allows, assigned to the native dataTransfer.effectAllowed + /// (drives the cursor): move (default), copy, link, copyMove, + /// copyLink, linkMove or all. + /// + [Parameter] public string DropEffect { get; set; } = "move"; + + /// + /// Fired when the drag begins, right after the browser started the drag and before any hover, + /// drop or end callback of that drag. + /// + [Parameter] public EventCallback OnBegin { get; set; } + + /// Fired when the drag ends (drop or cancel), even if this component was removed meanwhile. + [Parameter] public EventCallback> OnEnd { get; set; } + + /// Host element tag name. Defaults to div. + [Parameter] public string Tag { get; set; } = "div"; + + /// The draggable's content; it receives the collected . + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Any other attribute (class, style, id...), applied to the host element. + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + private readonly string _handlerId; + private readonly BdndHandleController _handleController; + private BdndJsRegistration? _registration; + private DotNetObjectReference>? _selfRef; + private ElementReference _element; + private bool _canDrag; + private TItem? _draggingItem; + private bool _disposed; + + public BdndDraggable() + { + _handlerId = Guid.NewGuid().ToString("N"); + _handleController = new BdndHandleController(this); + } + + protected override void OnInitialized() + { + if (Manager is null) + throw new InvalidOperationException( + $"{nameof(BdndDraggable)} must be placed inside a {nameof(BdndProvider)}."); + Manager.StateChanged += OnStateChanged; + _registration = new(RegisterAsync); + } + + private bool EffectiveCanDrag => CanDragPredicate?.Invoke() ?? CanDrag; + + private void OnStateChanged() => InvokeAsync(StateHasChanged); + + // Drag events must not trigger the automatic re-render ComponentBase performs after every + // event (e.g. while dragstart is still being dispatched); the manager drives re-renders. + Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var monitor = Manager!.Monitor; + _canDrag = EffectiveCanDrag; + var state = new BdndDragState + { + Monitor = monitor, + IsDragging = IsDraggingOverride?.Invoke(monitor) ?? monitor.IsDragging(_handlerId), + CanDrag = _canDrag, + }; + + builder.OpenElement(0, Tag); + builder.AddMultipleAttributes(1, AdditionalAttributes); + // with UseHandle the BdndDragHandle element is the (native) draggable one + builder.AddAttribute(2, "draggable", _canDrag && UseHandle is false ? "true" : "false"); + builder.AddAttribute(3, "ondragstart", + EventCallback.Factory.Create(this, HandleDragStartAsync)); + // stop nested (ancestor) draggables from also starting a drag + builder.AddEventStopPropagationAttribute(4, "ondragstart", true); + builder.AddElementReferenceCapture(5, el => _element = el); + // expose handle wiring to descendant BdndDragHandle components; not fixed, so + // handles re-render when CanDrag/UseHandle change + builder.OpenComponent>(6); + builder.AddAttribute(7, "Value", _handleController); + builder.AddAttribute(8, "ChildContent", (RenderFragment)(b => + { + b.AddContent(0, ChildContent?.Invoke(state)); + })); + builder.CloseComponent(); + builder.CloseElement(); + } + + protected override Task OnAfterRenderAsync(bool firstRender) => + _registration!.SyncAsync(_element, new BdndDragSourceOptions( + _handlerId, _canDrag, UseHandle, UseCustomDragLayer, + string.IsNullOrWhiteSpace(DropEffect) ? "move" : DropEffect)); + + private ValueTask RegisterAsync(ElementReference element, BdndDragSourceOptions options) + { + _selfRef ??= DotNetObjectReference.Create(this); + return Interop.RegisterDragSourceAsync(element, _selfRef, options); + } + + private async Task HandleDragStartAsync(DragEventArgs e) + { + var manager = Manager!; + // The JS registration decides which source a dragstart belongs to (the innermost one, + // and only from a handle when UseHandle is set) and tags the drag with this type. + if (e.DataTransfer?.Types is not { } types || types.Contains(BdndInterop.SourceType(_handlerId)) is false) + return; + + TItem? item = default; + var canDrag = EffectiveCanDrag; + if (canDrag) + { + item = ItemFactory is not null ? ItemFactory() : Item; + canDrag = ItemFactory is null || item is not null; + } + if (canDrag is false) + { + CancelNativeDrag(); + return; + } + + // In Blazor WebAssembly this handler runs while dragstart is being dispatched, so the + // source rectangle JS measured for this drag can be read synchronously. Otherwise start + // from an estimate and correct it once the rectangle has been fetched. + var rect = ReadDragStartRect(); + var sourceOffset = rect is not null + ? new BdndXYCoord(rect.Left, rect.Top) + : new BdndXYCoord(e.ClientX - e.OffsetX, e.ClientY - e.OffsetY); + + _draggingItem = item; + manager.BeginDrag(_handlerId, item, Type, new BdndXYCoord(e.ClientX, e.ClientY), sourceOffset, + () => OnBegin.InvokeAsync(item)); + + // The browser snapshots the drag image after dragstart has been dispatched, and some + // browsers abort a drag when the DOM changes during dragstart. Blazor WebAssembly would + // apply re-renders synchronously, so publish the drag only after dispatch has finished. + if (OperatingSystem.IsBrowser()) await Task.Delay(1); + await manager.FlushBeginAsync(); + + if (rect is null && _registration!.Reference is { } registration) + { + rect = await registration.InvokeAsync("getDragStartRect"); + if (rect is not null && manager.IsDragging(_handlerId)) + manager.SetInitialSourceClientOffset(new BdndXYCoord(rect.Left, rect.Top)); + } + } + + // The synchronous read works only in-process (Blazor WebAssembly); elsewhere it answers null. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "BdndDomRect's public properties and constructor are rooted by BitBdnd.PreserveInteropContracts.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "BdndDomRect is a plain DTO that System.Text.Json reads without runtime code generation.")] + private BdndDomRect? ReadDragStartRect() => + (_registration!.Reference as IJSInProcessObjectReference)?.Invoke("getDragStartRect"); + + // Possible only while dragstart is still being dispatched (Blazor WebAssembly). Elsewhere + // the native drag goes on as an inert drag: no drop target accepts it. + private void CancelNativeDrag() + { + if (_registration?.Reference is IJSInProcessObjectReference registration) + registration.InvokeVoid("cancelDrag"); + } + + /// Invoked from JS with the pointer position while this source is being dragged. + [JSInvokable, EditorBrowsable(EditorBrowsableState.Never)] + public async Task HandleDragMove(double clientX, double clientY) + { + var manager = Manager!; + if (manager.IsDragging(_handlerId) is false) return; + await manager.FlushBeginAsync(); + manager.SetClientOffset(new BdndXYCoord(clientX, clientY)); + } + + /// Invoked from JS when the drag of this source ends (drop or cancel). + [JSInvokable, EditorBrowsable(EditorBrowsableState.Never)] + public async Task HandleDragEnd() + { + var manager = Manager!; + try + { + if (manager.IsDragging(_handlerId) is false) return; + await manager.FlushBeginAsync(); + if (manager.IsDragging(_handlerId) is false) return; + + var context = new BdndDragEndContext + { + Monitor = manager.Monitor, + Item = _draggingItem, + DidDrop = manager.DidDrop(), + DropResult = manager.GetDropResult(), + }; + _draggingItem = default; + var onEnd = Task.CompletedTask; + try + { + if (OnEnd.HasDelegate) onEnd = OnEnd.InvokeAsync(context); + } + finally + { + // end right after OnEnd's synchronous part, so an async handler does not keep + // the drag state (and every target's highlight) alive + manager.EndDrag(); + } + await onEnd; + } + finally + { + if (_disposed) await ReleaseInteropAsync(); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + if (Manager is null) return; + Manager.StateChanged -= OnStateChanged; + // While this source is being dragged, keep its JS registration and this instance alive: + // the element-level 'dragend' listener ends the drag even after the element was removed + // (e.g. by the re-render of a drop), and releases them through HandleDragEnd. + if (Manager.IsDragging(_handlerId)) return; + await ReleaseInteropAsync(); + } + + private async ValueTask ReleaseInteropAsync() + { + if (_registration is not null) await _registration.DisposeAsync(); + _selfRef?.Dispose(); + _selfRef = null; + } + + private sealed class BdndHandleController : IBdndDragHandleController + { + private readonly BdndDraggable _owner; + public BdndHandleController(BdndDraggable owner) => _owner = owner; + public bool RequiresHandle => _owner.UseHandle; + public bool CanDrag => _owner._canDrag; + } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs new file mode 100644 index 00000000000..0f2d15a101b --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs @@ -0,0 +1,273 @@ +using System.ComponentModel; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace Bit.Bdnd; + +/// +/// Wires its host element into the DnD system as a drop target (React DnD's useDrop). +/// Besides in-app drags it can also accept native OS drags (files, text, urls, html) by +/// listing one of the in /. +/// +/// The expected dragged item type. +public sealed class BdndDropTarget : ComponentBase, IHandleEvent, IAsyncDisposable +{ + [CascadingParameter] private BdndDragDropManager? Manager { get; set; } + [CascadingParameter] private BdndDropTargetScope? ParentScope { get; set; } + [Inject] private BdndInterop Interop { get; set; } = default!; + + /// A single accepted item type. + [Parameter] public string? Accept { get; set; } + + /// Multiple accepted item types (combined with ). + [Parameter] public IEnumerable? AcceptTypes { get; set; } + + /// + /// Called when a compatible in-app item is dropped. Set args.Result to publish a result. + /// Nested targets are called innermost first; args.Monitor.DidDrop() tells whether an inner + /// target already handled the drop. + /// + [Parameter] public EventCallback> OnDrop { get; set; } + + /// + /// Called repeatedly while an in-app item of an accepted type is hovered - like React DnD's hover, + /// also when refuses the item. + /// + [Parameter] public EventCallback> OnHover { get; set; } + + /// Called when a native OS drag (file/text/url/html) is dropped. + [Parameter] public EventCallback OnNativeDrop { get; set; } + + /// Predicate gating whether this target can accept the current in-app item. + [Parameter] public Func? CanDropPredicate { get; set; } + + /// Host element tag name. Defaults to div. + [Parameter] public string Tag { get; set; } = "div"; + + /// The drop target's content; it receives the collected . + [Parameter] public RenderFragment>? ChildContent { get; set; } + + /// Any other attribute (class, style, id...), applied to the host element. + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + private readonly string _handlerId; + private BdndDropTargetScope _scope = default!; + private string[] _accept = []; + private (string Token, string NativeType)[] _nativeAccepts = []; + private string[] _nativeTokens = []; + private ElementReference _element; + private BdndJsRegistration? _registration; + private DotNetObjectReference>? _selfRef; + private bool _canDropInApp; + private bool _disposed; + + // native (OS) drag local state - the manager only tracks in-app drags + private string? _nativeItemType; + + public BdndDropTarget() + { + _handlerId = Guid.NewGuid().ToString("N"); + } + + protected override void OnInitialized() + { + if (Manager is null) + throw new InvalidOperationException( + $"{nameof(BdndDropTarget)} must be placed inside a {nameof(BdndProvider)}."); + _scope = new BdndDropTargetScope(Manager, _handlerId, ParentScope); + _registration = new(RegisterAsync); + Manager.StateChanged += OnStateChanged; + } + + protected override void OnParametersSet() + { + var list = new List(); + if (string.IsNullOrEmpty(Accept) is false) list.Add(Accept); + if (AcceptTypes is not null) list.AddRange(AcceptTypes.Where(t => string.IsNullOrEmpty(t) is false)); + _accept = list.Distinct().ToArray(); + _nativeAccepts = _accept + .Where(BdndNativeTypes.IsNative) + .Select(t => (BdndNativeTypes.ToDataTransferType(t), t)) + .ToArray(); + var tokens = _nativeAccepts.Select(na => na.Token).ToArray(); + if (tokens.SequenceEqual(_nativeTokens) is false) _nativeTokens = tokens; + + var predicate = CanDropPredicate; + Manager!.RegisterTarget(new BdndDropTargetEntry + { + Id = _handlerId, + ParentId = ParentScope?.FindTargetId(Manager), + Accept = _accept, + CanDrop = predicate is null + ? null + : (item, monitor) => predicate(Cast(item), monitor), + }); + } + + protected override Task OnAfterRenderAsync(bool firstRender) => + _registration!.SyncAsync(_element, new BdndDropTargetOptions(_canDropInApp, _nativeTokens)); + + private ValueTask RegisterAsync(ElementReference element, BdndDropTargetOptions options) + { + _selfRef ??= DotNetObjectReference.Create(this); + return Interop.RegisterDropTargetAsync(element, _selfRef, options); + } + + private void OnStateChanged() => InvokeAsync(StateHasChanged); + + // Drag events must not trigger the automatic re-render ComponentBase performs after every + // event (dragover fires continuously); the manager and the native hover state drive renders. + Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg); + + private static TItem? Cast(object? item) => item is TItem t ? t : default; + + private bool Accepts() => + Manager!.Monitor.GetItemType() is { } type && _accept.Contains(type); + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var monitor = Manager!.Monitor; + var native = monitor.IsDragging() is false && _nativeItemType is not null; + _canDropInApp = monitor.CanDrop(_handlerId); + + var state = new BdndDropState + { + Monitor = monitor, + IsOver = native || monitor.IsOver(_handlerId), + IsOverShallow = native || monitor.IsOver(_handlerId, shallow: true), + CanDrop = native || _canDropInApp, + ItemType = native ? _nativeItemType : monitor.GetItemType(), + Item = Cast(monitor.GetItem()), + }; + + // Hover tracking, drop-effect feedback (preventDefault/dropEffect) and native drops are + // handled by the JS registration; the managed handlers cover in-app hover and drops. + builder.OpenElement(0, Tag); + builder.AddMultipleAttributes(1, AdditionalAttributes); + if (OnHover.HasDelegate) + { + builder.AddAttribute(2, "ondragover", + EventCallback.Factory.Create(this, HandleDragOverAsync)); + } + builder.AddAttribute(3, "ondrop", + EventCallback.Factory.Create(this, HandleDropAsync)); + builder.AddElementReferenceCapture(4, el => _element = el); + // lets nested drop targets find their parent target (shallow IsOver) + builder.OpenComponent>(5); + builder.AddAttribute(6, "Value", _scope); + builder.AddAttribute(7, "IsFixed", true); + builder.AddAttribute(8, "ChildContent", (RenderFragment)(b => + { + b.AddContent(0, ChildContent?.Invoke(state)); + })); + builder.CloseComponent(); + builder.CloseElement(); + } + + private string? MatchNative(IReadOnlyCollection? types) + { + if (types is null) return null; + foreach (var (token, nativeType) in _nativeAccepts) + if (types.Contains(token)) + return nativeType; + return null; + } + + // Drags started by a BdndDraggable of any provider (or document) are never native. + private static bool IsInAppDrag(IEnumerable? types) => + types?.Any(t => t.StartsWith(BdndInterop.SourceTypePrefix, StringComparison.Ordinal)) == true; + + private void SetNativeItemType(string? nativeType) + { + if (_nativeItemType == nativeType) return; + _nativeItemType = nativeType; + StateHasChanged(); + } + + private async Task HandleDragOverAsync(DragEventArgs e) + { + var manager = Manager!; + if (manager.IsDragging() is false || Accepts() is false) return; + await manager.FlushBeginAsync(); + manager.SetClientOffset(new BdndXYCoord(e.ClientX, e.ClientY)); + await OnHover.InvokeAsync(new BdndDropEventArgs + { + Monitor = manager.Monitor, + Item = Cast(manager.GetItem()), + }); + } + + private async Task HandleDropAsync(DragEventArgs e) + { + var manager = Manager!; + if (manager.IsDragging() is false) return; // native drops are handled in JS + await manager.FlushBeginAsync(); + if (Accepts() is false || manager.CanDrop(_handlerId) is false) return; + + // the drop event reports the exact final pointer position + manager.SetClientOffset(new BdndXYCoord(e.ClientX, e.ClientY)); + var dragId = manager.DragId; + var args = new BdndDropEventArgs + { + Monitor = manager.Monitor, + Item = Cast(manager.GetItem()), + }; + var onDrop = OnDrop.InvokeAsync(args); + // An async handler may still run when the source's dragend arrives: mark the drop as + // handled right away so OnEnd sees DidDrop; the result is recorded once it completes. + if (onDrop.IsCompleted is false) manager.RecordDrop(dragId, null); + await onDrop; + manager.RecordDrop(dragId, args.Result); + } + + /// Invoked from JS when the pointer enters or leaves this target during any drag. + [JSInvokable, EditorBrowsable(EditorBrowsableState.Never)] + public async Task HandleHoverChanged(bool isOver, string[]? types) + { + if (_disposed) return; + var manager = Manager!; + if (manager.IsDragging()) + { + await manager.FlushBeginAsync(); + SetNativeItemType(null); + manager.SetTargetOver(_handlerId, isOver && Accepts()); + return; + } + // external/native drag + SetNativeItemType(isOver && IsInAppDrag(types) is false ? MatchNative(types) : null); + } + + /// Invoked from JS when a native OS drop occurs on this target. + [JSInvokable, EditorBrowsable(EditorBrowsableState.Never)] + public async Task HandleNativeDrop(BdndNativeDropPayload payload) + { + if (_disposed) return; + SetNativeItemType(null); + if (Manager!.IsDragging() || IsInAppDrag(payload.Types)) return; + var nativeType = MatchNative(payload.Types); + if (nativeType is null) return; + + await OnNativeDrop.InvokeAsync(new BdndNativeDropEventArgs + { + ItemType = nativeType, + Files = payload.Files ?? [], + Data = payload.Data ?? new Dictionary(), + }); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + if (Manager is not null) + { + Manager.StateChanged -= OnStateChanged; + Manager.UnregisterTarget(_handlerId); + } + if (_registration is not null) await _registration.DisposeAsync(); + _selfRef?.Dispose(); + } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor b/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor new file mode 100644 index 00000000000..4a01fc3835a --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor @@ -0,0 +1,25 @@ +@namespace Bit.Bdnd + + + @ChildContent + + +@code { + /// Optional externally-owned manager. When omitted, one is created for this subtree. + [Parameter] public BdndDragDropManager? Manager { get; set; } + + /// The drag-and-drop content: the draggables, drop targets and drag layers of this context. + [Parameter] public RenderFragment? ChildContent { get; set; } + + private BdndDragDropManager? _manager; + + protected override void OnParametersSet() + { + var manager = Manager ?? _manager ?? new BdndDragDropManager(); + // the cascade is fixed: descendants keep the manager they were initialized with + if (_manager is not null && ReferenceEquals(manager, _manager) is false) + throw new InvalidOperationException( + $"The {nameof(Manager)} of a {nameof(BdndProvider)} cannot be changed after it has been set."); + _manager = manager; + } +} diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor.cs b/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor.cs new file mode 100644 index 00000000000..55068b3afbd --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/BdndProvider.razor.cs @@ -0,0 +1,11 @@ +namespace Bit.Bdnd; + +/// +/// The root of a drag-and-drop context, the equivalent of React DnD's +/// <DndProvider backend={HTML5Backend}>. It creates a and +/// cascades it to every descendant , +/// and . Nest providers for isolated drag contexts. +/// +public partial class BdndProvider +{ +} diff --git a/src/Bdnd/Bit.Bdnd/Components/IBdndDragHandleController.cs b/src/Bdnd/Bit.Bdnd/Components/IBdndDragHandleController.cs new file mode 100644 index 00000000000..3b2e37e9476 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Components/IBdndDragHandleController.cs @@ -0,0 +1,11 @@ +namespace Bit.Bdnd; + +/// Non-generic seam so can control any draggable. +public interface IBdndDragHandleController +{ + /// True when the owning draggable only starts drags from a . + bool RequiresHandle { get; } + + /// True when the owning draggable currently allows dragging. + bool CanDrag { get; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs new file mode 100644 index 00000000000..b50f574a33b --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs @@ -0,0 +1,234 @@ +namespace Bit.Bdnd; + +/// +/// The central, scoped state container for a subtree. +/// This is the Blazor equivalent of React DnD's internal Redux store plus the +/// drag-drop manager. It owns the authoritative drag state, exposes it through the +/// interface, and notifies subscribers when things change. +/// +public sealed class BdndDragDropManager : IBdndMonitor +{ + private readonly Dictionary _targets = []; + private int _idSeed; + + // ---- authoritative drag state ------------------------------------------------ + private long _dragId; + private bool _isDragging; + private object? _item; + private string? _itemType; + private string? _sourceId; + private object? _dropResult; + private bool _didDrop; + private BdndXYCoord? _initialClientOffset; + private BdndXYCoord? _initialSourceClientOffset; + private BdndXYCoord? _clientOffset; + private Func? _pendingBegin; + + /// Raised on structural changes (begin/end/enter/leave). + public event Action? StateChanged; + + /// Raised frequently while dragging as the pointer offset changes. + public event Action? OffsetChanged; + + /// The shared monitor instance for this provider subtree. + public IBdndMonitor Monitor => this; + + /// Generates a handler id unique within this manager, e.g. card-3. + public string GenerateHandlerId(string prefix) => $"{prefix}-{++_idSeed}"; + + /// Identifies the current drag; changes with every . + internal long DragId => _dragId; + + // ---- registration ------------------------------------------------------------ + + internal void RegisterTarget(BdndDropTargetEntry entry) + { + // Upsert: preserve a live IsOver across re-renders so an in-flight hover + // is not lost when the component re-registers with new parameters. + if (_targets.TryGetValue(entry.Id, out var existing)) + { + existing.Accept = entry.Accept; + existing.CanDrop = entry.CanDrop; + } + else + { + _targets[entry.Id] = entry; + } + } + + internal void UnregisterTarget(string id) + { + // a hovered target going away changes the shallow IsOver of its ancestors + if (_targets.Remove(id, out var entry) && entry.IsOver && _isDragging) + NotifyStateChanged(); + } + + // ---- lifecycle --------------------------------------------------------------- + + /// + /// Starts a drag without notifying subscribers yet: the drag source publishes it with + /// once re-rendering can no longer affect the browser's + /// drag image. runs when the drag is published. + /// + internal void BeginDrag(string sourceId, object? item, string itemType, + BdndXYCoord initialClientOffset, BdndXYCoord initialSourceClientOffset, + Func? onBegun = null) + { + _dragId++; + _isDragging = true; + _sourceId = sourceId; + _item = item; + _itemType = itemType; + _dropResult = null; + _didDrop = false; + _initialClientOffset = initialClientOffset; + _initialSourceClientOffset = initialSourceClientOffset; + _clientOffset = initialClientOffset; + foreach (var t in _targets.Values) t.IsOver = false; + _pendingBegin = onBegun ?? (() => Task.CompletedTask); + } + + /// + /// Publishes the drag started by (notifies subscribers and runs its + /// begin callback) if that has not happened yet. Every other drag callback calls this first, + /// so the source's begin callback always runs before hover, drop and end callbacks. + /// + internal Task FlushBeginAsync() + { + var onBegun = _pendingBegin; + if (onBegun is null) return Task.CompletedTask; + _pendingBegin = null; + NotifyStateChanged(); + return onBegun(); + } + + internal void SetClientOffset(BdndXYCoord offset) + { + if (_isDragging is false || _clientOffset == offset) return; + _clientOffset = offset; + OffsetChanged?.Invoke(); + } + + /// + /// Patches the source element's initial offset shortly after the drag begins (when the + /// bounding rect has to be fetched asynchronously). Keeps custom drag layers aligned. + /// + internal void SetInitialSourceClientOffset(BdndXYCoord offset) + { + if (_isDragging is false || _initialSourceClientOffset == offset) return; + _initialSourceClientOffset = offset; + OffsetChanged?.Invoke(); + } + + /// Marks whether the pointer is over a target that accepts the dragged item. + internal void SetTargetOver(string id, bool isOver) + { + if (_isDragging is false || _targets.TryGetValue(id, out var entry) is false || entry.IsOver == isOver) return; + entry.IsOver = isOver; + NotifyStateChanged(); + } + + /// + /// Records that a drop occurred during drag . Called by each drop + /// target in the bubbling chain (innermost first). A non-null result from a later (outer) + /// target overrides the result set by an inner target, matching React DnD semantics. + /// Calls for a drag that already ended (e.g. from a slow async handler) are ignored. + /// + internal void RecordDrop(long dragId, object? result) + { + if (_isDragging is false || dragId != _dragId) return; + _didDrop = true; + if (result is not null) _dropResult = result; + } + + internal void EndDrag() + { + if (_isDragging is false) return; + _isDragging = false; + _pendingBegin = null; + _item = null; + _itemType = null; + _sourceId = null; + _dropResult = null; + _didDrop = false; + _initialClientOffset = null; + _initialSourceClientOffset = null; + _clientOffset = null; + foreach (var t in _targets.Values) t.IsOver = false; + NotifyStateChanged(); + } + + private void NotifyStateChanged() => StateChanged?.Invoke(); + + // ---- IBdndMonitor ------------------------------------------------------------- + + public bool IsDragging() => _isDragging; + + public bool IsDragging(string? sourceId) + { + if (_isDragging is false) return false; + if (sourceId is null) return true; + return _sourceId == sourceId; + } + + public string? GetItemType() => _itemType; + + public object? GetItem() => _item; + + public T? GetItem() => _item is T t ? t : default; + + public object? GetDropResult() => _dropResult; + + public T? GetDropResult() => _dropResult is T t ? t : default; + + public bool DidDrop() => _didDrop; + + public bool CanDrop(string handlerId) + { + if (_isDragging is false) return false; + if (_targets.TryGetValue(handlerId, out var entry) is false) return false; + if (_itemType is null || entry.Accept.Contains(_itemType) is false) return false; + return entry.CanDrop?.Invoke(_item, this) ?? true; + } + + public bool IsOver(string handlerId, bool shallow = false) + { + if (_isDragging is false || _targets.TryGetValue(handlerId, out var entry) is false || entry.IsOver is false) return false; + if (shallow is false) return true; + // shallow: no hovered target is nested inside this one + foreach (var other in _targets.Values) + { + if (other.IsOver && ReferenceEquals(other, entry) is false && IsNestedIn(other, handlerId)) return false; + } + return true; + } + + private bool IsNestedIn(BdndDropTargetEntry entry, string ancestorId) + { + for (var parentId = entry.ParentId; parentId is not null; + parentId = _targets.TryGetValue(parentId, out var parent) ? parent.ParentId : null) + { + if (parentId == ancestorId) return true; + } + return false; + } + + public BdndXYCoord? GetInitialClientOffset() => _initialClientOffset; + + public BdndXYCoord? GetInitialSourceClientOffset() => _initialSourceClientOffset; + + public BdndXYCoord? GetClientOffset() => _clientOffset; + + public BdndXYCoord? GetDifferenceFromInitialOffset() + { + if (_clientOffset is null || _initialClientOffset is null) return null; + return _clientOffset.Value - _initialClientOffset.Value; + } + + public BdndXYCoord? GetSourceClientOffset() + { + var diff = GetDifferenceFromInitialOffset(); + if (diff is null || _initialSourceClientOffset is null) return null; + return _initialSourceClientOffset.Value + diff.Value; + } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDragEndContext.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDragEndContext.cs new file mode 100644 index 00000000000..5728f40b489 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDragEndContext.cs @@ -0,0 +1,25 @@ +namespace Bit.Bdnd; + +/// +/// Passed to when a drag operation finishes, +/// mirroring the arguments of React DnD's end(item, monitor) callback. +/// +/// The type of the dragged item payload. +public sealed class BdndDragEndContext +{ + /// + /// The monitor of the provider the drag ran in. It is live: the drag state is cleared right after the + /// synchronous part of OnEnd, so after an await it reports no drag; , + /// and keep the values of the drag that ended. + /// + public required IBdndMonitor Monitor { get; init; } + + /// The item that was being dragged. + public TItem? Item { get; init; } + + /// True if a compatible drop target handled the drop. + public bool DidDrop { get; init; } + + /// The drop result returned by the handling target, if any. + public object? DropResult { get; init; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDragLayerState.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDragLayerState.cs new file mode 100644 index 00000000000..d5389c11864 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDragLayerState.cs @@ -0,0 +1,36 @@ +namespace Bit.Bdnd; + +/// +/// State surfaced to a 's child content, equivalent to the +/// values exposed by React DnD's useDragLayer / DragLayerMonitor. +/// +/// The type of the dragged item payload. +public sealed class BdndDragLayerState +{ + /// The monitor of the provider the layer is in. + public required IBdndMonitor Monitor { get; init; } + + /// True while any drag of the provider is in progress. + public bool IsDragging { get; init; } + + /// The type of the item being dragged, or null. + public string? ItemType { get; init; } + + /// The item being dragged (if assignable to ). + public TItem? Item { get; init; } + + /// The pointer client offset at the time the drag started. + public BdndXYCoord? InitialClientOffset { get; init; } + + /// The source element's top-left client offset at the time the drag started. + public BdndXYCoord? InitialSourceClientOffset { get; init; } + + /// The last recorded pointer client offset. + public BdndXYCoord? ClientOffset { get; init; } + + /// The delta between the current and initial pointer client offsets. + public BdndXYCoord? DifferenceFromInitialOffset { get; init; } + + /// The projected top-left client offset of the source element: where to draw the preview. + public BdndXYCoord? SourceClientOffset { get; init; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDragState.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDragState.cs new file mode 100644 index 00000000000..36c44c212d0 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDragState.cs @@ -0,0 +1,17 @@ +namespace Bit.Bdnd; + +/// +/// State surfaced to a 's child content, equivalent to the +/// "collected props" returned by React DnD's useDrag. +/// +public sealed class BdndDragState +{ + /// The monitor of the provider the draggable is in. + public required IBdndMonitor Monitor { get; init; } + + /// True while this source is being dragged. + public bool IsDragging { get; init; } + + /// True if dragging is currently allowed for this source. + public bool CanDrag { get; init; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs new file mode 100644 index 00000000000..b4d7ec219fe --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs @@ -0,0 +1,21 @@ +namespace Bit.Bdnd; + +/// +/// Passed to and , +/// mirroring React DnD's drop(item, monitor) / hover(item, monitor) callbacks. +/// +/// The expected dragged item type. +public sealed class BdndDropEventArgs +{ + /// The monitor of the provider the drag runs in. + public required IBdndMonitor Monitor { get; init; } + + /// The item currently being dragged. + public TItem? Item { get; init; } + + /// + /// Set this inside an OnDrop handler to publish a drop result. It becomes available + /// to the drag source via monitor.GetDropResult() in its OnEnd callback. + /// + public object? Result { get; set; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropState.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropState.cs new file mode 100644 index 00000000000..b975e5b5c23 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropState.cs @@ -0,0 +1,27 @@ +namespace Bit.Bdnd; + +/// +/// State surfaced to a 's child content, equivalent to the +/// "collected props" returned by React DnD's useDrop. +/// +/// The expected dragged item type. +public sealed class BdndDropState +{ + /// The monitor of the provider the drop target is in. + public required IBdndMonitor Monitor { get; init; } + + /// True if a drag is in progress and the pointer is over this target (or a child). + public bool IsOver { get; init; } + + /// True if the pointer is over this target but not a nested target. + public bool IsOverShallow { get; init; } + + /// True if this target can accept the item currently being dragged. + public bool CanDrop { get; init; } + + /// The type of the item currently being dragged, or null. + public string? ItemType { get; init; } + + /// The item currently being dragged (if assignable to ). + public TItem? Item { get; init; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs new file mode 100644 index 00000000000..1f54978064e --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs @@ -0,0 +1,16 @@ +namespace Bit.Bdnd; + +/// Internal registration record for a drop target. +internal sealed class BdndDropTargetEntry +{ + public required string Id { get; init; } + + /// The id of the nearest enclosing drop target of the same manager, if any. + public string? ParentId { get; init; } + + public string[] Accept { get; set; } = []; + public Func? CanDrop { get; set; } + + /// True while the pointer is over the target and the target accepts the item type. + public bool IsOver { get; set; } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetScope.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetScope.cs new file mode 100644 index 00000000000..6e46de035e0 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetScope.cs @@ -0,0 +1,32 @@ +namespace Bit.Bdnd; + +/// +/// Cascaded by every to its content so nested drop +/// targets know their enclosing target (needed for shallow IsOver). +/// +internal sealed class BdndDropTargetScope +{ + public BdndDropTargetScope(BdndDragDropManager manager, string handlerId, BdndDropTargetScope? parent) + { + Manager = manager; + HandlerId = handlerId; + Parent = parent; + } + + public BdndDragDropManager Manager { get; } + public string HandlerId { get; } + public BdndDropTargetScope? Parent { get; } + + /// + /// The handler id of the nearest target in this scope chain (this one included) that belongs + /// to ; targets of nested providers are skipped. + /// + public string? FindTargetId(BdndDragDropManager manager) + { + for (var scope = this; scope is not null; scope = scope.Parent) + { + if (ReferenceEquals(scope.Manager, manager)) return scope.HandlerId; + } + return null; + } +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs new file mode 100644 index 00000000000..5cac51a6f17 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs @@ -0,0 +1,29 @@ +namespace Bit.Bdnd; + +/// Payload delivered to . +public sealed class BdndNativeDropEventArgs +{ + /// The matched native item type (one of ). + public required string ItemType { get; init; } + + /// Files for a drop. + public IReadOnlyList Files { get; init; } = []; + + /// Raw data keyed by DataTransfer type (e.g. text/plain, text/html). + public IReadOnlyDictionary Data { get; init; } = + new Dictionary(); + + /// The dropped plain text (text/plain), if any. + public string? Text => Data.TryGetValue("text/plain", out var v) ? v : null; + + /// The dropped HTML (text/html), if any. + public string? Html => Data.TryGetValue("text/html", out var v) ? v : null; + + /// The raw dropped uri-list (text/uri-list), if any. + public string? Url => Data.TryGetValue("text/uri-list", out var v) ? v : null; + + /// URLs for a drop (uri-list split by lines). + public IReadOnlyList Urls => Url is null + ? [] + : Url.Split('\n').Select(s => s.Trim()).Where(s => s.Length > 0 && s.StartsWith('#') is false).ToArray(); +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropPayload.cs b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropPayload.cs new file mode 100644 index 00000000000..7af9325b7a3 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropPayload.cs @@ -0,0 +1,14 @@ +namespace Bit.Bdnd; + +/// Raw payload shape received from JS for a native drop. +public sealed class BdndNativeDropPayload +{ + /// The DataTransfer types of the drop. + public string[] Types { get; set; } = []; + + /// The string data of the drop, keyed by DataTransfer type. + public Dictionary Data { get; set; } = []; + + /// The metadata of the dropped files. + public BdndNativeFile[] Files { get; set; } = []; +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndNativeFile.cs b/src/Bdnd/Bit.Bdnd/Core/BdndNativeFile.cs new file mode 100644 index 00000000000..e75f599eb7c --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndNativeFile.cs @@ -0,0 +1,14 @@ +namespace Bit.Bdnd; + +/// A single file described by a native file drop (metadata only; no contents). +public sealed class BdndNativeFile +{ + /// The file's name, without its path. + public string Name { get; set; } = ""; + + /// The file's size in bytes. + public long Size { get; set; } + + /// The file's MIME type as the browser reports it; empty when unknown. + public string Type { get; set; } = ""; +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndNativeTypes.cs b/src/Bdnd/Bit.Bdnd/Core/BdndNativeTypes.cs new file mode 100644 index 00000000000..fec7ba4112e --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndNativeTypes.cs @@ -0,0 +1,34 @@ +namespace Bit.Bdnd; + +/// +/// Item types for OS-level (native) drags, mirroring React DnD's HTML5 backend +/// NativeTypes (FILE, URL, TEXT, HTML). A +/// that accepts one of these will react to drags originating outside the application +/// (dragged files, selected text, links, etc.). +/// +public static class BdndNativeTypes +{ + /// Dropped files (DataTransfer type Files). + public const string File = "__NATIVE_FILE__"; + + /// Dragged links (DataTransfer type text/uri-list). + public const string Url = "__NATIVE_URL__"; + + /// Selected text (DataTransfer type text/plain). + public const string Text = "__NATIVE_TEXT__"; + + /// Dragged HTML (DataTransfer type text/html). + public const string Html = "__NATIVE_HTML__"; + + internal static bool IsNative(string type) => type.StartsWith("__NATIVE_", StringComparison.Ordinal); + + /// Maps a native item type to its DataTransfer type token. + internal static string ToDataTransferType(string nativeType) => nativeType switch + { + File => "Files", + Url => "text/uri-list", + Text => "text/plain", + Html => "text/html", + _ => nativeType, + }; +} diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndXYCoord.cs b/src/Bdnd/Bit.Bdnd/Core/BdndXYCoord.cs new file mode 100644 index 00000000000..61538a36e0b --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/BdndXYCoord.cs @@ -0,0 +1,13 @@ +namespace Bit.Bdnd; + +/// +/// Represents an { x, y } coordinate pair, mirroring React DnD's XYCoord. +/// Used for the various client/source offsets exposed by the monitor. +/// +/// The horizontal client coordinate, in CSS pixels. +/// The vertical client coordinate, in CSS pixels. +public readonly record struct BdndXYCoord(double X, double Y) +{ + public static BdndXYCoord operator -(BdndXYCoord a, BdndXYCoord b) => new(a.X - b.X, a.Y - b.Y); + public static BdndXYCoord operator +(BdndXYCoord a, BdndXYCoord b) => new(a.X + b.X, a.Y + b.Y); +} diff --git a/src/Bdnd/Bit.Bdnd/Core/IBdndMonitor.cs b/src/Bdnd/Bit.Bdnd/Core/IBdndMonitor.cs new file mode 100644 index 00000000000..d6a73cbba2f --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Core/IBdndMonitor.cs @@ -0,0 +1,58 @@ +namespace Bit.Bdnd; + +/// +/// A read-only view over the drag-and-drop state, mirroring the union of React DnD's +/// DragSourceMonitor, DropTargetMonitor and DragLayerMonitor. +/// A single monitor instance is shared through the and +/// passed into the collecting/lifecycle callbacks of draggables, drop targets and layers. +/// +public interface IBdndMonitor +{ + /// True if a drag operation is currently in progress. + bool IsDragging(); + + /// + /// True if the given source is the one being dragged. When + /// is null this is equivalent to . + /// + bool IsDragging(string? sourceId); + + /// Returns the type string of the item currently being dragged, or null. + string? GetItemType(); + + /// Returns the plain item object currently being dragged, or null. + object? GetItem(); + + /// Strongly-typed convenience accessor for . + T? GetItem(); + + /// Returns the last recorded drop result, or null. + object? GetDropResult(); + + /// Strongly-typed convenience accessor for . + T? GetDropResult(); + + /// True if a compatible drop target has handled the drop. + bool DidDrop(); + + /// True if the given handler can currently accept a drop. + bool CanDrop(string handlerId); + + /// True if a drag is in progress and the pointer is over the given handler. + bool IsOver(string handlerId, bool shallow = false); + + /// The pointer client offset at the time the drag started. + BdndXYCoord? GetInitialClientOffset(); + + /// The source element's top-left client offset at the time the drag started. + BdndXYCoord? GetInitialSourceClientOffset(); + + /// The last recorded pointer client offset during the drag. + BdndXYCoord? GetClientOffset(); + + /// The delta between the current and initial pointer client offsets. + BdndXYCoord? GetDifferenceFromInitialOffset(); + + /// The projected top-left client offset of the source element. + BdndXYCoord? GetSourceClientOffset(); +} diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndDomRect.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndDomRect.cs new file mode 100644 index 00000000000..b4d5e2ff559 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndDomRect.cs @@ -0,0 +1,17 @@ +namespace Bit.Bdnd; + +/// A DOMRect projected from JS. +public sealed class BdndDomRect +{ + /// The left edge, in CSS pixels relative to the viewport. + public double Left { get; set; } + + /// The top edge, in CSS pixels relative to the viewport. + public double Top { get; set; } + + /// The width, in CSS pixels. + public double Width { get; set; } + + /// The height, in CSS pixels. + public double Height { get; set; } +} diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs new file mode 100644 index 00000000000..e564d5c216a --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs @@ -0,0 +1,10 @@ +namespace Bit.Bdnd; + +/// Options of a drag source registration in bit-bdnd.js. +/// The draggable's handler id (also part of the drag's DataTransfer type). +/// Whether a drag may start. +/// Whether a drag must start on a . +/// Hide the native drag image (custom drag layers). +/// The value assigned to dataTransfer.effectAllowed. +internal sealed record BdndDragSourceOptions( + string Id, bool CanDrag, bool UseHandle, bool UseEmptyPreview, string EffectAllowed); diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndDropTargetOptions.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndDropTargetOptions.cs new file mode 100644 index 00000000000..511ed8e69e5 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndDropTargetOptions.cs @@ -0,0 +1,9 @@ +namespace Bit.Bdnd; + +/// Options of a drop target registration in bit-bdnd.js. +/// Whether the target can accept the in-app item currently being dragged. +/// +/// The DataTransfer types of the native drags the target accepts. The same array instance +/// is reused while the accepted types are unchanged, so unchanged options compare equal. +/// +internal sealed record BdndDropTargetOptions(bool CanDrop, string[] NativeTypes); diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndInterop.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndInterop.cs new file mode 100644 index 00000000000..b8cebc0d548 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndInterop.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bdnd; + +/// +/// Thin wrapper around the bit-bdnd.js module. Registered as a scoped +/// service by AddBitBdndServices() and used by the drag/drop components. +/// +public sealed class BdndInterop : IAsyncDisposable +{ + /// + /// Prefix of the DataTransfer type that marks drags started by a + /// ; the draggable's handler id follows it. + /// + internal const string SourceTypePrefix = "application/x-bit-bdnd-"; + + private const string ModulePath = "./_content/Bit.Bdnd/bit-bdnd.js"; + + private readonly IJSRuntime _js; + private Task? _module; + + public BdndInterop(IJSRuntime js) => _js = js; + + internal static string SourceType(string handlerId) => SourceTypePrefix + handlerId; + + /// + /// Reads an element's bounding client rectangle, e.g. to compare the pointer against a hovered + /// item's midpoint in a sortable list. Returns null when the element is not rendered. + /// + public async ValueTask GetBoundingClientRectAsync(ElementReference element) + { + var module = await GetModuleAsync(); + return await module.InvokeAsync("getBoundingClientRect", element); + } + + internal async ValueTask RegisterDragSourceAsync( + ElementReference element, object dotNetRef, BdndDragSourceOptions options) + { + var module = await GetModuleAsync(); + return await module.InvokeAsync( + "registerDragSource", element, dotNetRef, options); + } + + internal async ValueTask RegisterDropTargetAsync( + ElementReference element, object dotNetRef, BdndDropTargetOptions options) + { + var module = await GetModuleAsync(); + return await module.InvokeAsync( + "registerDropTarget", element, dotNetRef, options); + } + + // A failed import is retried by the next call instead of being cached for the whole scope. + private Task GetModuleAsync() + { + if (_module is null || _module.IsFaulted || _module.IsCanceled) + _module = _js.InvokeAsync("import", ModulePath).AsTask(); + return _module; + } + + public async ValueTask DisposeAsync() + { + var module = _module; + _module = null; + if (module is null) return; + try + { + await (await module).DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException + or OperationCanceledException or InvalidOperationException) { } + } +} diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs new file mode 100644 index 00000000000..fd788b56c87 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bdnd; + +/// +/// Owns the JS-side registration of a component's host element (an object exposing +/// update(options) and dispose()) and keeps it in sync across renders: the element +/// is registered again when Blazor re-creates it, and changed options are pushed to JS. +/// +internal sealed class BdndJsRegistration : IAsyncDisposable where TOptions : class +{ + private readonly Func> _register; + private IJSObjectReference? _reference; + private string? _elementId; + private TOptions? _options; + private (ElementReference Element, TOptions Options)? _pending; + private bool _syncing; + private bool _disposed; + + public BdndJsRegistration(Func> register) + => _register = register; + + /// The live JS registration, or null until the element has been registered. + public IJSObjectReference? Reference => _reference; + + /// + /// Registers the element or pushes changed options. Calls made while a previous sync is still + /// awaiting JS (renders can happen meanwhile) are coalesced into one more pass. + /// + public async Task SyncAsync(ElementReference element, TOptions options) + { + _pending = (element, options); + if (_syncing) return; + _syncing = true; + try + { + while (_pending is { } next && _disposed is false) + { + _pending = null; + await ApplyAsync(next.Element, next.Options); + } + } + catch (JSDisconnectedException) { } + // disposed while a call was in flight (e.g. its DotNetObjectReference was released) + catch (Exception) when (_disposed) { } + finally + { + _syncing = false; + } + } + + private async Task ApplyAsync(ElementReference element, TOptions options) + { + if (_reference is not null && _elementId == element.Id) + { + if (Equals(options, _options) is false) + { + _options = options; + await _reference.InvokeVoidAsync("update", options); + } + return; + } + + await ReleaseAsync(); + var reference = await _register(element, options); + if (_disposed) + { + await DisposeReferenceAsync(reference); + return; + } + _reference = reference; + _elementId = element.Id; + _options = options; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + await ReleaseAsync(); + } + + private async Task ReleaseAsync() + { + var reference = _reference; + _reference = null; + _elementId = null; + _options = null; + await DisposeReferenceAsync(reference); + } + + private static async Task DisposeReferenceAsync(IJSObjectReference? reference) + { + if (reference is null) return; + try { await reference.InvokeVoidAsync("dispose"); } + catch (Exception ex) when (IsExpectedDisposalException(ex)) { } + try { await reference.DisposeAsync(); } + catch (Exception ex) when (IsExpectedDisposalException(ex)) { } + } + + // Disposal is best-effort: the circuit may be gone or the JS object already released. + private static bool IsExpectedDisposalException(Exception ex) => + ex is JSDisconnectedException or JSException or OperationCanceledException or ObjectDisposedException; +} diff --git a/src/Bdnd/Bit.Bdnd/README.md b/src/Bdnd/Bit.Bdnd/README.md new file mode 100644 index 00000000000..4a40db575eb --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/README.md @@ -0,0 +1,70 @@ +# Bit.Bdnd + +A declarative drag-and-drop library for Blazor, inspired by [React DnD](https://react-dnd.github.io/react-dnd/). +Instead of hooks, Bdnd gives you composable components - `BdndDraggable`, `BdndDropTarget`, `BdndDragLayer` and +`BdndDragHandle` - backed by the HTML5 drag-and-drop API. You drag *data* (a typed item) of a *type* (a string), +and drop targets accept the types they list. The drag state lives in C#; the browser layer only configures the +native drag, tracks hover and reads native drops. + +## Quick start + +```bash +dotnet add package Bit.Bdnd +``` + +```csharp +// Program.cs +builder.Services.AddBitBdndServices(); +``` + +```razor +@* _Imports.razor *@ +@using Bit.Bdnd +``` + +No stylesheet to link: the script module, `_content/Bit.Bdnd/bit-bdnd.js`, is imported by the components on demand. + +```razor + + +
@card.Title
+
+ + +
Drop cards here
+
+
+ +@code { + private record Card(string Title); + + private Card card = new("Write the docs"); + + private void HandleDrop(BdndDropEventArgs args) => Console.WriteLine($"Dropped {args.Item?.Title}"); +} +``` + +## Features + +- Declarative `useDrag` / `useDrop` style components with strongly typed item payloads. +- Type matching between draggables and drop targets through `Accept` / `AcceptTypes`, plus `CanDropPredicate`. +- Custom drag layers for fully styled, pointer-following previews (`BdndDragLayer`). +- Drag handles that restrict where a drag can start (`BdndDragHandle`). +- Nested drag sources and nested drop targets, with shallow `IsOverShallow` hover and innermost-first drops. +- Drop results passed back to the source's `OnEnd`, and `DidDrop` to tell a drop from a cancel. +- Native OS drags: dropped files, selected text, links and HTML (`BdndNativeTypes`). +- Isolated drag contexts through nestable providers, and a read-only `IBdndMonitor` over the live drag state. +- Blazor WebAssembly, Server, Web App (including prerendering) and Hybrid on .NET 8, 9 and 10. + +## For AI agents + +The demo site hosts an MCP server at `https://bdnd.bitplatform.dev/mcp` (also served through +`https://bitplatform.dev/mcp`) that answers from the shipped library: its API, its guide and the demo's working +pages, and it can run a drag through the real components to show which targets accept it. + +## Documentation + +The full guide - providers, draggables, drop targets, drag layers, handles, nesting, sortable lists, native drags, +the monitor, hosting models and a React DnD mapping - is in the +[Bdnd README](https://github.com/bitfoundation/bitplatform/tree/develop/src/Bdnd). See it running at +[bdnd.bitplatform.dev](https://bdnd.bitplatform.dev). diff --git a/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts b/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts new file mode 100644 index 00000000000..75f102fbf54 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts @@ -0,0 +1,423 @@ +// Bit.Bdnd HTML5 backend interop. +// Blazor raises the managed drag events (ondragstart/ondragover/ondrop). This module covers what +// managed handlers cannot do, because they cannot touch the live DataTransfer and (in Blazor +// Server) do not run while the native event is being dispatched: +// 1) configure a drag at dragstart: drag data, effectAllowed, drag image and drag handles, +// 2) track the pointer during a drag (Firefox reports 0,0 in 'drag' events), +// 3) report the end of a drag, even when the source element was removed from the DOM, +// 4) track hover per drop target in a way that survives re-rendered/removed child nodes, +// 5) give drop-effect feedback and read native drop payloads (files/text/url/html), +// 6) read an element's bounding rectangle. + +/** The subset of DotNetObjectReference this module calls into. */ +interface DotNetObject { + invokeMethodAsync(method: string, ...args: unknown[]): Promise; +} + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +/** Mirrors BdndDragSourceOptions. */ +interface DragSourceOptions { + id?: string; + canDrag?: boolean; + useHandle?: boolean; + useEmptyPreview?: boolean; + effectAllowed?: DataTransfer['effectAllowed']; +} + +/** Mirrors BdndDropTargetOptions. */ +interface DropTargetOptions { + /** Whether the target can accept the in-app item currently being dragged. */ + canDrop?: boolean; + /** The DataTransfer types of the native drags the target accepts. */ + nativeTypes?: string[]; +} + +interface DragSource { + element: HTMLElement; + dotNetRef: DotNetObject; + options: DragSourceOptions; + rect: Rect | null; +} + +interface DropTarget { + options: DropTargetOptions; + entered: Set; + over: boolean; + reset: () => void; +} + +/** The in-app drag in progress, started by one of this document's sources. */ +interface Session { + source: DragSource; + startEvent: DragEvent; + x: number; + y: number; + sentX: number; + sentY: number; + frame: number; + timer: number; + onDragOver: (e: DragEvent) => void; + onDrop: () => void; + onMouseMove: () => void; +} + +interface DropEffectState { + accepted: boolean; + rejected: boolean; +} + +// Marks drags started by a registered source; the handler id follows the prefix. +const SOURCE_TYPE_PREFIX = 'application/x-bit-bdnd-'; +const HANDLE_ATTRIBUTE = 'data-bit-bdnd-handle'; +const EMPTY_IMAGE_SRC = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +// Firefox can raise a mousemove right as a drag starts, so mouse movement is only taken as +// "the drag is over" after this delay (the same approach as React DnD's HTML5 backend). +const SOURCE_REMOVED_CHECK_DELAY = 1000; + +const claimedEvents = new WeakSet(); // dragstart events already handled by an inner source +const dropEffectStates = new WeakMap(); // dragenter/dragover event -> nested targets' decision +const targets = new Set(); +let emptyImage: HTMLImageElement | null = null; +let session: Session | null = null; +let cancelRequested = false; + +// Runs after Blazor's document-level listener, i.e. after the managed dragstart handler. +window.addEventListener('dragstart', (e) => { + // .NET calls cancelDrag() synchronously while dragstart is being dispatched (WebAssembly) + if (cancelRequested) { + cancelRequested = false; + e.preventDefault(); + endSession(false); + return; + } + // other code cancelled the drag: no dragend will follow + if (e.defaultPrevented && session && session.startEvent === e) endSession(true); +}); + +function getEmptyImage(): HTMLImageElement { + if (!emptyImage) { + emptyImage = new Image(); + emptyImage.src = EMPTY_IMAGE_SRC; + } + return emptyImage; +} + +function typesOf(dataTransfer: DataTransfer | null): string[] { + return dataTransfer && dataTransfer.types ? Array.from(dataTransfer.types) : []; +} + +function isInAppDrag(dataTransfer: DataTransfer | null): boolean { + return !!session || typesOf(dataTransfer).some((t) => t.startsWith(SOURCE_TYPE_PREFIX)); +} + +// .NET callbacks are fire-and-forget; a disposed component or a dropped circuit must not +// surface as an unhandled promise rejection. +function invoke(dotNetRef: DotNetObject, method: string, ...args: unknown[]): void { + try { + dotNetRef.invokeMethodAsync(method, ...args).catch(() => { }); + } catch { + /* ignore */ + } +} + +function toRect(element: Element): Rect { + const r = element.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; +} + +export function getBoundingClientRect(element: Element | null): Rect | null { + return element ? toRect(element) : null; +} + +// ---- drag sources ------------------------------------------------------------------------- + +function findHandle(node: Node | null, root: Element): Element | null { + for (; node && node !== root; node = node.parentNode) { + if (node.nodeType === Node.ELEMENT_NODE && (node as Element).hasAttribute(HANDLE_ATTRIBUTE)) { + return node as Element; + } + } + return null; +} + +export function registerDragSource(element: HTMLElement | null, dotNetRef: DotNetObject, options: DragSourceOptions | null) { + if (!element) return null; + const source: DragSource = { element, dotNetRef, options: {}, rect: null }; + + const configure = (o: DragSourceOptions | null) => { + source.options = o || {}; + // decode the transparent image before the first drag needs it + if (source.options.useEmptyPreview) getEmptyImage(); + }; + + const onDragStart = (e: DragEvent) => { + // dragstart bubbles: the innermost registered source decides, its ancestors stay out + // of it (the managed handler stops propagation the same way). + if (claimedEvents.has(e)) return; + claimedEvents.add(e); + + const o = source.options; + const dataTransfer = e.dataTransfer; + if (!dataTransfer || !o.canDrag || !o.id) return; + const handle = o.useHandle ? findHandle(e.target as Node | null, element) : null; + if (o.useHandle && !handle) return; + + // measured before anything re-renders; read by .NET as the source's initial offset + const r = toRect(element); + source.rect = r; + try { + // marks the drag as ours (Firefox also requires some data to start a drag) + dataTransfer.setData(SOURCE_TYPE_PREFIX + o.id, o.id); + dataTransfer.effectAllowed = o.effectAllowed || 'move'; + if (o.useEmptyPreview) { + dataTransfer.setDragImage(getEmptyImage(), 0, 0); + } else if (handle) { + // the browser would picture just the handle; show the whole source instead + dataTransfer.setDragImage(element, e.clientX - r.left, e.clientY - r.top); + } + } catch { + /* ignore */ + } + beginSession(source, e); + }; + + const onDragEnd = () => { + if (session && session.source === source) endSession(true); + }; + + configure(options); + // Element-level listeners still fire after the element was removed from the DOM, whereas + // Blazor's document-level listener never sees events of detached elements. + element.addEventListener('dragstart', onDragStart); + element.addEventListener('dragend', onDragEnd); + + return { + update: configure, + getDragStartRect: () => source.rect, + cancelDrag: () => { + if (!session || session.source !== source) return; + cancelRequested = true; + setTimeout(() => (cancelRequested = false), 0); + }, + dispose: () => { + element.removeEventListener('dragstart', onDragStart); + element.removeEventListener('dragend', onDragEnd); + if (session && session.source === source) endSession(false); + }, + }; +} + +function beginSession(source: DragSource, startEvent: DragEvent): void { + if (session) endSession(true); // the previous drag never reported its end + + const s: Session = { + source, + startEvent, + x: 0, + y: 0, + sentX: NaN, + sentY: NaN, + frame: 0, + timer: 0, + onDragOver: (e: DragEvent) => { + s.x = e.clientX; + s.y = e.clientY; + if (!s.frame) { + s.frame = requestAnimationFrame(() => { + s.frame = 0; + if (session !== s || (s.x === s.sentX && s.y === s.sentY)) return; + s.sentX = s.x; + s.sentY = s.y; + invoke(source.dotNetRef, 'HandleDragMove', s.x, s.y); + }); + } + }, + // Browsers may never raise 'dragend' for a source that was removed from the DOM (e.g. by + // the re-render a drop causes), so also end the drag after a drop or once the mouse moves. + onDrop: () => { setTimeout(() => endIfSourceRemoved(s), 0); }, + onMouseMove: () => endIfSourceRemoved(s), + }; + + document.addEventListener('dragover', s.onDragOver, true); + document.addEventListener('drop', s.onDrop, true); + s.timer = window.setTimeout( + () => window.addEventListener('mousemove', s.onMouseMove, true), + SOURCE_REMOVED_CHECK_DELAY); + session = s; +} + +function endIfSourceRemoved(s: Session): void { + if (session === s && !s.source.element.isConnected) endSession(true); +} + +function endSession(notify: boolean): void { + const s = session; + if (!s) return; + session = null; + document.removeEventListener('dragover', s.onDragOver, true); + document.removeEventListener('drop', s.onDrop, true); + window.removeEventListener('mousemove', s.onMouseMove, true); + clearTimeout(s.timer); + if (s.frame) cancelAnimationFrame(s.frame); + for (const target of targets) target.reset(); + if (notify) invoke(s.source.dotNetRef, 'HandleDragEnd'); +} + +// ---- drop targets ------------------------------------------------------------------------- + +// Native drags over a text field or editable content are left to the browser, so text can still +// be dropped into inputs placed inside a drop target. +function isNativeDragOverEditable(e: DragEvent): boolean { + if (isInAppDrag(e.dataTransfer)) return false; + let node = e.target as Node | null; + if (node && node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement; + const el = node as HTMLElement | null; + return !!el && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); +} + +// dragenter/dragover bubble through nested drop targets. The innermost target that can drop +// accepts the event; outer targets must not undo that decision. +function applyDropEffect(e: DragEvent, canDrop: boolean): void { + const dataTransfer = e.dataTransfer; + if (!dataTransfer) return; + let state = dropEffectStates.get(e); + if (!state) dropEffectStates.set(e, (state = { accepted: false, rejected: false })); + if (state.accepted) return; + if (isNativeDragOverEditable(e)) { + state.accepted = true; // the editable element handles it, untouched + return; + } + e.preventDefault(); + if (canDrop) { + state.accepted = true; + // an inner target rejected the event first: restore an effect the source allows + if (state.rejected) dataTransfer.dropEffect = defaultDropEffect(dataTransfer.effectAllowed); + } else { + state.rejected = true; + dataTransfer.dropEffect = 'none'; // no-drop cursor; the drop event will not fire + } +} + +function defaultDropEffect(effectAllowed: DataTransfer['effectAllowed']): DataTransfer['dropEffect'] { + switch (effectAllowed) { + case 'move': + case 'linkMove': + return 'move'; + case 'link': + return 'link'; + default: + return 'copy'; + } +} + +export function registerDropTarget(element: HTMLElement | null, dotNetRef: DotNetObject, options: DropTargetOptions | null) { + if (!element) return null; + const target: DropTarget = { + options: options || {}, + entered: new Set(), + over: false, + reset: () => { + target.entered.clear(); + target.over = false; + }, + }; + + // Hover is tracked per node that raised dragenter. Nodes removed or moved out of the target + // since (e.g. re-rendered by Blazor) never raise dragleave, so they are pruned here. + const refresh = (dataTransfer: DataTransfer | null) => { + for (const node of target.entered) { + if (!(node instanceof Node) || !node.isConnected || !element.contains(node)) target.entered.delete(node); + } + const over = target.entered.size > 0; + if (over !== target.over) { + target.over = over; + invoke(dotNetRef, 'HandleHoverChanged', over, typesOf(dataTransfer)); + } + }; + + const canDrop = (dataTransfer: DataTransfer | null): boolean => { + if (isInAppDrag(dataTransfer)) return !!session && !!target.options.canDrop; + const types = typesOf(dataTransfer); + return (target.options.nativeTypes || []).some((t) => types.includes(t)); + }; + + const onDragEnter = (e: DragEvent) => { + if (e.target) target.entered.add(e.target); + refresh(e.dataTransfer); + applyDropEffect(e, canDrop(e.dataTransfer)); + }; + + const onDragOver = (e: DragEvent) => { + // the hover may have started before this target was registered + if (target.entered.size === 0 && e.target) target.entered.add(e.target); + refresh(e.dataTransfer); + applyDropEffect(e, canDrop(e.dataTransfer)); + }; + + const onDragLeave = (e: DragEvent) => { + if (e.target) target.entered.delete(e.target); + refresh(e.dataTransfer); + }; + + const onDrop = (e: DragEvent) => { + const dataTransfer = e.dataTransfer; + const wasOver = target.over; + // No dragleave follows a drop. Reset silently: managed OnDrop handlers may still query + // IsOver, and the managed state is reset when the drag ends. + target.reset(); + if (isNativeDragOverEditable(e)) { + // The field takes the drop and no HandleNativeDrop follows, so nothing else would clear + // the native hover reported for this target: report the leave. + if (wasOver) invoke(dotNetRef, 'HandleHoverChanged', false, typesOf(dataTransfer)); + return; + } + e.preventDefault(); // never let the browser open or navigate to dropped content + // in-app drops are handled by the managed ondrop handler + if (!dataTransfer || isInAppDrag(dataTransfer) || !canDrop(dataTransfer)) return; + + // getData only works during the drop event, hence it is read here + const types = typesOf(dataTransfer); + const data: Record = {}; + for (const t of types) { + if (t === 'Files') continue; + try { + data[t] = dataTransfer.getData(t); + } catch { + /* ignore */ + } + } + const files = Array.from(dataTransfer.files || [], (f) => ({ + name: f.name, + size: f.size, + type: f.type, + })); + invoke(dotNetRef, 'HandleNativeDrop', { types, data, files }); + }; + + element.addEventListener('dragenter', onDragEnter); + element.addEventListener('dragover', onDragOver); + element.addEventListener('dragleave', onDragLeave); + element.addEventListener('drop', onDrop); + targets.add(target); + + return { + update: (o: DropTargetOptions | null) => { + target.options = o || {}; + }, + dispose: () => { + element.removeEventListener('dragenter', onDragEnter); + element.removeEventListener('dragover', onDragOver); + element.removeEventListener('dragleave', onDragLeave); + element.removeEventListener('drop', onDrop); + targets.delete(target); + }, + }; +} diff --git a/src/Bdnd/Bit.Bdnd/_Imports.razor b/src/Bdnd/Bit.Bdnd/_Imports.razor new file mode 100644 index 00000000000..7164279c2c2 --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/_Imports.razor @@ -0,0 +1,2 @@ +@using Microsoft.AspNetCore.Components.Web +@using Bit.Bdnd diff --git a/src/Bdnd/Bit.Bdnd/package-lock.json b/src/Bdnd/Bit.Bdnd/package-lock.json new file mode 100644 index 00000000000..0eaf6e9048e --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/package-lock.json @@ -0,0 +1,511 @@ +{ + "name": "Bit.Bdnd", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "esbuild": "0.28.2", + "typescript": "5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/src/Bdnd/Bit.Bdnd/package.json b/src/Bdnd/Bit.Bdnd/package.json new file mode 100644 index 00000000000..9d770fb1e8b --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "esbuild": "0.28.2", + "typescript": "5.9.3" + } +} diff --git a/src/Bdnd/Bit.Bdnd/tsconfig.json b/src/Bdnd/Bit.Bdnd/tsconfig.json new file mode 100644 index 00000000000..19897362edf --- /dev/null +++ b/src/Bdnd/Bit.Bdnd/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "allowJs": true, + "target": "ES2019", + // bit-bdnd.js is loaded by the C# interop through a dynamic `import()` and its + // functions are called as named module exports, so the output must stay an ES module: + // `module: ESNext` with `rootDir`/`outDir` emits Scripts/bit-bdnd.ts straight to + // wwwroot/bit-bdnd.js with its `export`s preserved. + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ "DOM", "DOM.Iterable", "ESNext" ], + "rootDir": "Scripts", + "outDir": "wwwroot", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ "./Scripts/**/*.ts" ] +} diff --git a/src/Bdnd/Demo/Client/Bit.Bdnd.Demo.Client.csproj b/src/Bdnd/Demo/Client/Bit.Bdnd.Demo.Client.csproj new file mode 100644 index 00000000000..aaeca1d791e --- /dev/null +++ b/src/Bdnd/Demo/Client/Bit.Bdnd.Demo.Client.csproj @@ -0,0 +1,128 @@ + + + + net10.0 + enable + enable + true + + true + + Default + true + + + + + + + + + + + + + + + + + + + + + + +"); +sb.AppendLine("namespace Bit.Bdnd.Demo.Client.Examples;"); +sb.AppendLine("public static class DemoSources"); +sb.AppendLine("{"); +// Case-insensitive, like the router: /Sortable/Simple renders the same page as /sortable/simple. +sb.AppendLine(" public static readonly System.Collections.Generic.Dictionary ByRoute = new(System.StringComparer.OrdinalIgnoreCase)"); +sb.AppendLine(" {"); +foreach (var file in Directory.GetFiles(SourceDir, "*.razor", SearchOption.AllDirectories)) +{ + var content = File.ReadAllText(file); + var match = Regex.Match(content, "@page\\s+\"([^\"]+)\""); + if (!match.Success) continue; + var route = match.Groups[1].Value.TrimStart('/'); + + // Strip demo-app chrome so the snippet shows only the example itself. + // 1. The @page routing directive. + content = Regex.Replace(content, @"(?m)^[ \t]*@page[ \t]+""[^""]*""[ \t]*\r?\n", ""); + // 2. The that sets the browser tab title. + content = Regex.Replace(content, @"(?s)[ \t]*.*?[ \t]*\r?\n", ""); + // 3. The demo-header description block on the hand-written demo pages. Its end is found by + // balancing
tags rather than with a lazy match, which would stop at the first + //
nested inside the header and leave the rest of it behind. + var header = Regex.Match(content, @"[ \t]*
"); + if (header.Success) + { + var depth = 0; + var end = -1; + for (var tag = Regex.Match(content.Substring(header.Index), @""); tag.Success; tag = tag.NextMatch()) + { + depth += tag.Value.StartsWith("= 0) + { + // Take the rest of the closing tag's line with it. + var rest = Regex.Match(content.Substring(end), @"^[ \t]*\r?\n"); + if (rest.Success) end += rest.Length; + content = content.Remove(header.Index, end - header.Index); + } + } + // 4. The toggle element (it powers this snippet, it isn't part of the demo). + content = Regex.Replace(content, @"(?s)[ \t]*[ \t]*\r?\n", ""); + // Collapse any runs of blank lines left by the removals. + content = Regex.Replace(content, @"(\r?\n){3,}", "\n\n"); + // Drop any blank lines left at the very top. + content = content.TrimStart('\r', '\n', ' ', '\t'); + + var keyEsc = route.Replace("\"", "\"\""); + var valEsc = content.Replace("\"", "\"\""); + sb.Append(" [@\"").Append(keyEsc).Append("\"] = @\"").Append(valEsc).AppendLine("\","); +} +sb.AppendLine(" };"); +sb.AppendLine("}"); + +// Only touch the file when its content changed: rewriting it on every build would bump its +// timestamp and force a full recompile of the demo even when no page was edited. +var text = sb.ToString(); +if (!File.Exists(OutputFile) || File.ReadAllText(OutputFile) != text) +{ + Directory.CreateDirectory(Path.GetDirectoryName(OutputFile)); + File.WriteAllText(OutputFile, text, new UTF8Encoding(false)); +} +]]> + + + + + + + $(IntermediateOutputPath)DemoSources.g.cs + + + + + + + + + + diff --git a/src/Bdnd/Demo/Client/Examples/DemoCode.razor b/src/Bdnd/Demo/Client/Examples/DemoCode.razor new file mode 100644 index 00000000000..8a06c25106c --- /dev/null +++ b/src/Bdnd/Demo/Client/Examples/DemoCode.razor @@ -0,0 +1,56 @@ +@namespace Bit.Bdnd.Demo.Client.Examples +@inject NavigationManager Nav + +@* A collapsible "View code" panel that shows the .razor source of the current demo page. + The source is resolved from the build-generated DemoSources lookup, keyed by route. *@ + +
+ + + @if (_open) + { + @if (_source is null) + { +
Source for this page is not available.
+ } + else + { +
@_source
+ } + } +
+ +@code { + private bool _open; + private string? _source; + private bool _resolved; + + private void Toggle() + { + _open = !_open; + if (_open && !_resolved) + { + _source = Resolve(); + _resolved = true; + } + } + + private string? Resolve() + { + var key = Nav.ToBaseRelativePath(Nav.Uri); + + // Drop any query string or fragment so the key matches the @page route. The lookup + // itself ignores case, as the router does. + var cut = key.IndexOfAny(['?', '#']); + if (cut >= 0) key = key[..cut]; + + return DemoSources.ByRoute.TryGetValue(key.TrimEnd('/'), out var src) ? src : null; + } +} diff --git a/src/Bdnd/Demo/Client/Extensions/IServiceCollectionExtensions.cs b/src/Bdnd/Demo/Client/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 00000000000..fd6d6b75373 --- /dev/null +++ b/src/Bdnd/Demo/Client/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Bit.Bdnd; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// The services shared by the WebAssembly client and the server host. Both containers have to +/// register the same set: every page is prerendered on the server before it runs in the browser, +/// so anything a page injects must resolve in both. +/// +public static class IServiceCollectionExtensions +{ + public static IServiceCollection AddDemoServices(this IServiceCollection services) + { + // The draggables and drop targets inject BdndInterop, and so does the sortable card, + // which measures the hovered card through it. + services.AddBitBdndServices(); + + return services; + } +} diff --git a/src/Bdnd/Demo/Client/ItemTypes.cs b/src/Bdnd/Demo/Client/ItemTypes.cs new file mode 100644 index 00000000000..2eea5baf38c --- /dev/null +++ b/src/Bdnd/Demo/Client/ItemTypes.cs @@ -0,0 +1,13 @@ +namespace Bit.Bdnd.Demo.Client; + +/// +/// Central enumeration of drag item types used across the demo, mirroring the +/// "ItemTypes" constant module recommended by the React DnD docs. +/// +public static class ItemTypes +{ + public const string Box = "box"; + public const string Knight = "knight"; + public const string Card = "card"; + public const string DragAroundBox = "dragAroundBox"; +} diff --git a/src/Bdnd/Demo/Client/Layout/MainLayout.razor b/src/Bdnd/Demo/Client/Layout/MainLayout.razor new file mode 100644 index 00000000000..6df47f31d81 --- /dev/null +++ b/src/Bdnd/Demo/Client/Layout/MainLayout.razor @@ -0,0 +1,21 @@ +@inherits LayoutComponentBase + +@* data-interactive turns true once the page runs in the browser. Prerendered markup is on screen + long before its event handlers are, which is what the E2E suite waits out with it. *@ +
+ + +
+
+ GitHub + React DnD (inspiration) + Blazor Docs +
+ +
+ @Body +
+
+
diff --git a/src/Bdnd/Demo/Client/Layout/MainLayout.razor.css b/src/Bdnd/Demo/Client/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cdeea32197f --- /dev/null +++ b/src/Bdnd/Demo/Client/Layout/MainLayout.razor.css @@ -0,0 +1,78 @@ +.page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.sidebar { + background: linear-gradient(180deg, #1e1b4b 0%, #312e81 100%); + display: flex; + flex-direction: column; + min-height: 0; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.top-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1.5rem; + height: 3.5rem; + padding: 0 1.5rem; + background: var(--demo-surface); + border-bottom: 1px solid var(--demo-border); +} + + .top-row ::deep a { + color: var(--demo-muted); + font-weight: 500; + font-size: 0.9rem; + white-space: nowrap; + } + + .top-row ::deep a:hover { + color: var(--demo-text); + text-decoration: none; + } + +.content { + flex: 1; + padding: 1.75rem 2rem; + min-width: 0; +} + +@media (max-width: 640.98px) { + .top-row { + justify-content: center; + } + + .content { + padding: 1.25rem; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 260px; + height: 100vh; + position: sticky; + top: 0; + flex-shrink: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 10; + } +} diff --git a/src/Bdnd/Demo/Client/Layout/NavMenu.razor b/src/Bdnd/Demo/Client/Layout/NavMenu.razor new file mode 100644 index 00000000000..9dffc4a0549 --- /dev/null +++ b/src/Bdnd/Demo/Client/Layout/NavMenu.razor @@ -0,0 +1,103 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "nav-menu collapse" : "nav-menu"; + + private const string LogoSvg = + "" + + "" + + "" + + "" + + "" + + ""; + + private void ToggleNavMenu() => collapseNavMenu = !collapseNavMenu; + + private void CollapseNavMenu() => collapseNavMenu = true; +} diff --git a/src/Bdnd/Demo/Client/Layout/NavMenu.razor.css b/src/Bdnd/Demo/Client/Layout/NavMenu.razor.css new file mode 100644 index 00000000000..ad9d0c356b3 --- /dev/null +++ b/src/Bdnd/Demo/Client/Layout/NavMenu.razor.css @@ -0,0 +1,114 @@ +.nav-top { + display: flex; + align-items: center; + justify-content: space-between; + height: 3.5rem; + padding: 0 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.brand { + display: flex; + align-items: center; + gap: 0.6rem; + color: #fff; + font-weight: 600; + font-size: 1.05rem; +} + +.brand:hover { + text-decoration: none; +} + +.brand-mark { + display: inline-flex; + align-items: center; +} + +.nav-toggle { + display: inline-flex; + flex-direction: column; + justify-content: center; + gap: 4px; + width: 36px; + height: 36px; + padding: 0 8px; + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 8px; + cursor: pointer; +} + + .nav-toggle span { + display: block; + height: 2px; + background: #fff; + border-radius: 2px; + } + +.nav-menu { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0.75rem; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.nav-menu ::deep .nav-link { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 0.75rem; + color: rgba(255, 255, 255, 0.75); + font-size: 0.92rem; + font-weight: 500; + border-radius: 8px; +} + +.nav-menu ::deep .nav-link:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + text-decoration: none; +} + +.nav-menu ::deep .nav-link.active { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +.nav-group-title { + margin: 0.85rem 0 0.2rem; + padding: 0 0.75rem; + color: rgba(255, 255, 255, 0.45); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nav-icon { + display: inline-flex; + width: 1.1rem; + justify-content: center; + font-size: 0.85rem; + color: #a5b4fc; +} + +@media (max-width: 640.98px) { + .nav-menu.collapse { + display: none; + } +} + +@media (min-width: 641px) { + .nav-toggle { + display: none; + } + + .nav-menu.collapse { + display: flex; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Customize/DropEffects.razor b/src/Bdnd/Demo/Client/Pages/Customize/DropEffects.razor new file mode 100644 index 00000000000..2e14bdd96e4 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Customize/DropEffects.razor @@ -0,0 +1,53 @@ +@page "/customize/drop-effects" + +Customize · Drop Effects - bit Bdnd + +
+

Customize - Drop Effects

+

+ A drag source can advertise a drop effect through DropEffect, which sets the + native dataTransfer.effectAllowed. Drag the two boxes below and watch the + cursor: the copy box shows a “+” badge, the move box does not. +

+
+ + + +
+ +
+ +
+ Copy me (effect = copy) +
+
+ + +
+ Move me (effect = move) +
+
+
+ + +
+ @(string.IsNullOrEmpty(_last) ? "Drop here" : $"Last drop used: {_last}") +
+
+
+ +

+ Tip: the browser only offers the effects a source allows. With DropEffect="copyMove" + the user can switch to copying by holding Ctrl (⌥ on macOS) during the drag; + the boxes above allow a single effect each, so modifier keys do not change them. +

+
+ +@code { + private string? _last; + + private void HandleDrop(BdndDropEventArgs e) => _last = e.Item; +} diff --git a/src/Bdnd/Demo/Client/Pages/Customize/Handles.razor b/src/Bdnd/Demo/Client/Pages/Customize/Handles.razor new file mode 100644 index 00000000000..7f5cc6b3548 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Customize/Handles.razor @@ -0,0 +1,47 @@ +@page "/customize/handles" + +Customize · Handles & Previews - bit Bdnd + +
+

Customize - Handles & Previews

+

+ With UseHandle="true" a draggable can only be picked up by a nested + <BdndDragHandle>. The rest of the element stays interactive. Compare the two + boxes below: the first drags from anywhere, the second only from its grip. +

+
+ + + +
+ +
+ +
+ Drag me from anywhere +
+
+ + +
+ + ⠿ + + Only the grip drags me +
+
+
+ + +
+ @(string.IsNullOrEmpty(_last) ? "Drop a box here" : $"Dropped: {_last}") +
+
+
+
+ +@code { + private string? _last; +} diff --git a/src/Bdnd/Demo/Client/Pages/DragAround/CustomLayer.razor b/src/Bdnd/Demo/Client/Pages/DragAround/CustomLayer.razor new file mode 100644 index 00000000000..e08c0ffa912 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/DragAround/CustomLayer.razor @@ -0,0 +1,73 @@ +@page "/drag-around/custom-layer" +@using System.Globalization + +Drag Around · Custom Drag Layer - bit Bdnd + +
+

Drag Around - Custom Drag Layer

+

+ The boxes set UseCustomDragLayer="true" to hide the native preview, and a + <BdndDragLayer> renders our own preview positioned at + Monitor.GetSourceClientOffset(). This gives full control over how the + dragged item looks. +

+
+ + + + + +
+ @foreach (var box in _boxes) + { + + @box.Title + + } +
+
+ + + @if (layer.IsDragging && layer.SourceClientOffset is { } pos && layer.Item is { } item) + { +
+ @item.Title +
+ } +
+
+ +@code { + public class DragBox + { + public required string Title { get; init; } + public double Left { get; set; } + public double Top { get; set; } + } + + private readonly List _boxes = + [ + new() { Title = "Custom preview", Left = 40, Top = 40 }, + new() { Title = "Drag me", Left = 220, Top = 170 }, + ]; + + private DragBox? _draggingBox; + + private void HandleDrop(BdndDropEventArgs e) + { + var delta = e.Monitor.GetDifferenceFromInitialOffset(); + if (e.Item is null || delta is null) return; + e.Item.Left = Math.Round(e.Item.Left + delta.Value.X); + e.Item.Top = Math.Round(e.Item.Top + delta.Value.Y); + } + + // Format with the invariant culture so the decimal separator is always '.' + // (a comma would produce invalid CSS like left:282,5px). + private static string Px(double v) => v.ToString("0.###", CultureInfo.InvariantCulture) + "px"; +} diff --git a/src/Bdnd/Demo/Client/Pages/DragAround/Naive.razor b/src/Bdnd/Demo/Client/Pages/DragAround/Naive.razor new file mode 100644 index 00000000000..5dbdd64f41c --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/DragAround/Naive.razor @@ -0,0 +1,64 @@ +@page "/drag-around/naive" +@using System.Globalization + +Drag Around · Naive - bit Bdnd + +
+

Drag Around - Naive

+

+ The whole container is a BdndDropTarget. Each box remembers its position; + on drop the new position is computed from + Monitor.GetDifferenceFromInitialOffset(). This uses the native HTML5 + drag preview (note the "ghost" while dragging). +

+
+ + + + + +
+ @foreach (var box in _boxes) + { + + @box.Title + + } +
+
+
+ +@code { + public class DragBox + { + public required string Title { get; init; } + public double Left { get; set; } + public double Top { get; set; } + } + + private readonly List _boxes = + [ + new() { Title = "Drag me around", Left = 40, Top = 40 }, + new() { Title = "Drag me too", Left = 200, Top = 160 }, + ]; + + private DragBox? _draggingBox; + + private void HandleDrop(BdndDropEventArgs e) + { + var delta = e.Monitor.GetDifferenceFromInitialOffset(); + if (e.Item is null || delta is null) return; + e.Item.Left = Math.Round(e.Item.Left + delta.Value.X); + e.Item.Top = Math.Round(e.Item.Top + delta.Value.Y); + } + + // Format with the invariant culture so the decimal separator is always '.' + // (a comma would produce invalid CSS like left:282,5px). + private static string Px(double v) => v.ToString("0.###", CultureInfo.InvariantCulture) + "px"; +} diff --git a/src/Bdnd/Demo/Client/Pages/Dustbin/CopyOrMove.razor b/src/Bdnd/Demo/Client/Pages/Dustbin/CopyOrMove.razor new file mode 100644 index 00000000000..4b523534d68 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Dustbin/CopyOrMove.razor @@ -0,0 +1,75 @@ +@page "/dustbin/copy-or-move" + +Dustbin · Copy or Move - bit Bdnd + +
+

Dustbin - Copy or Move

+

+ A drag source can describe whether it should be copied or moved. Here the + copy box can be dropped any number of times, while the move box is + removed from the palette once it has been accepted by the dustbin (using the + OnEnd callback and its DidDrop). Each box also sets its + DropEffect, so the cursor shows whether it will be copied or moved. +

+
+ + + +
+ + +
+
+
Dropped @_dropped.Count item(s)
+ @foreach (var name in _dropped) + { +
@name
+ } +
+
+
+ +
+ @foreach (var box in _boxes) + { + +
+ @box.Name (@box.Effect.ToString().ToLowerInvariant()) +
+
+ } +
+
+
+ +@code { + public enum DropEffect { Copy, Move } + + public class DragBox + { + public required string Name { get; init; } + public DropEffect Effect { get; init; } + } + + private readonly List _boxes = + [ + new() { Name = "Glass (keep)", Effect = DropEffect.Copy }, + new() { Name = "Banana (take)", Effect = DropEffect.Move }, + ]; + + private readonly List _dropped = []; + + private void HandleDrop(BdndDropEventArgs e) + { + if (e.Item is not null) _dropped.Add(e.Item.Name); + } + + private void HandleEnd(DragBox box, BdndDragEndContext e) + { + if (e.DidDrop && box.Effect == DropEffect.Move) + _boxes.Remove(box); + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Dustbin/Iframe.razor b/src/Bdnd/Demo/Client/Pages/Dustbin/Iframe.razor new file mode 100644 index 00000000000..cd983516192 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Dustbin/Iframe.razor @@ -0,0 +1,38 @@ +@page "/dustbin/iframe" + +Dustbin · Within an iframe - bit Bdnd + +
+

Dustbin - Within an iframe

+

+ The HTML5 backend works inside an iframe because Bdnd listens to the native drag + events of whatever document it runs in. Below, a self-contained dustbin demo is hosted + in its own iframe document - dragging works entirely within it. +

+
+ + + +@* The iframe is rendered only once this page runs in the browser. A prerendered one is replaced, together with the + rest of the prerendered markup, when WebAssembly takes the page over: the page inside it would be loaded twice, + its first boot aborted halfway. *@ +@if (RendererInfo.IsInteractive) +{ + +} +else +{ +
+} + +

+ Note: a single BdndProvider (and its BdndDragDropManager) lives in one + document, so dragging across the iframe boundary into the parent page is not + supported by the HTML5 backend - the same limitation React DnD has without a + cross-window backend setup. +

+ +@code { + private const string FrameStyle = + "display:block;width:100%;height:520px;border:1px solid var(--demo-border);border-radius:var(--demo-radius);background:var(--demo-surface);"; +} diff --git a/src/Bdnd/Demo/Client/Pages/Dustbin/MultipleTargets.razor b/src/Bdnd/Demo/Client/Pages/Dustbin/MultipleTargets.razor new file mode 100644 index 00000000000..b86d3f073f4 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Dustbin/MultipleTargets.razor @@ -0,0 +1,96 @@ +@page "/dustbin/multiple" + +Dustbin · Multiple Targets - bit Bdnd + +
+

Dustbin - Multiple Targets

+

+ Each dustbin Accepts a different set of types, so a box only highlights + the dustbins that can receive it. Boxes themselves carry a type. +

+
+ + + +
+ +
+ @foreach (var bin in _bins) + { + +
+
+
accepts: @string.Join(", ", bin.Accepts)
+ @if (t.IsOver && t.CanDrop) + { +
release to drop
+ } + else if (bin.LastDropped is not null) + { +
last: @bin.LastDropped
+ } +
+
+
+ } +
+ +
+ @foreach (var box in _boxes) + { + +
+ @box.Name +
+
+ } +
+
+
+ +@code { + public record SourceBox(string Name, string Type); + + public class Bin + { + public required string Name { get; init; } + public required string[] Accepts { get; init; } + public string? LastDropped { get; set; } + public Color Color { get; init; } + } + + public enum Color { Green, Blue, Purple } + + private readonly List _boxes = + [ + new("Glass", ItemTypes.Box), + new("Banana", ItemTypes.Box), + new("Knight", ItemTypes.Knight), + new("Card #1", ItemTypes.Card), + ]; + + private readonly List _bins = + [ + new() { Name = "Boxes", Accepts = [ItemTypes.Box], Color = Color.Green }, + new() { Name = "Pieces", Accepts = [ItemTypes.Knight, ItemTypes.Card], Color = Color.Blue }, + new() { Name = "Anything", Accepts = [ItemTypes.Box, ItemTypes.Knight, ItemTypes.Card], Color = Color.Purple }, + ]; + + private void HandleDrop(Bin bin, BdndDropEventArgs e) + => bin.LastDropped = e.Item?.Name; + + private string BinStyle(Bin bin, BdndDropState t) + { + var baseColor = bin.Color switch + { + Color.Green => "#2e7d32", + Color.Blue => "#1565c0", + _ => "#6a1b9a", + }; + if (t.IsOver && t.CanDrop) return "background-color:#111;"; + if (t.CanDrop) return $"background-color:{baseColor};outline:3px dashed #fff;"; + return $"background-color:{baseColor};opacity:{(t.Monitor.IsDragging() ? "0.5" : "1")};"; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Dustbin/SingleTarget.razor b/src/Bdnd/Demo/Client/Pages/Dustbin/SingleTarget.razor new file mode 100644 index 00000000000..13a2ff729d1 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Dustbin/SingleTarget.razor @@ -0,0 +1,68 @@ +@page "/dustbin/single" + +Dustbin · Single Target - bit Bdnd + +
+

Dustbin - Single Target

+

+ The simplest setup: a single BdndDropTarget that accepts one item type. + Drag a box onto the dustbin and it reacts to hover and records the drop. +

+
+ + + +
+ + +
+ @if (t.IsOver) + { + Release to drop + } + else + { + @_label + } +
+
+ +
+ @foreach (var box in _boxes) + { + +
+ @box.Name +
+
+ } +
+
+
+ +@code { + public record BoxItem(string Name); + + private readonly List _boxes = + [ + new("Glass"), new("Banana"), new("Paper"), + ]; + + private string _label = "Drag a box here"; + private int _droppedCount; + + private void HandleDrop(BdndDropEventArgs e) + { + _droppedCount++; + _label = $"Last dropped: {e.Item?.Name} (total {_droppedCount})"; + } + + private string DustbinStyle(BdndDropState t) + { + var bg = t.IsOver + ? (t.CanDrop ? "darkgreen" : "darkred") + : "#222"; + return $"background-color:{bg};"; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Dustbin/StressTest.razor b/src/Bdnd/Demo/Client/Pages/Dustbin/StressTest.razor new file mode 100644 index 00000000000..73ce6e70e30 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Dustbin/StressTest.razor @@ -0,0 +1,125 @@ +@page "/dustbin/stress-test" + +Dustbin · Stress Test - bit Bdnd + +
+

Dustbin - Stress Test

+

+ Many sources and many targets at once. Each box has a random type; each dustbin accepts + a random subset. Use this to gauge how the engine behaves with a lot of registered + handlers re-rendering on every state change. +

+
+ + + +
+ + + @_boxes.Count boxes · @_bins.Count dustbins · @_totalDrops drops +
+ +
+ +
+ @foreach (var bin in _bins) + { + +
+
+
@string.Join("/", bin.Accepts.Select(ShortName))
+
@bin.Count dropped
+
+
+
+ } +
+ +
+ @foreach (var box in _boxes) + { + +
@ShortName(box.Type)#@box.Id
+
+ } +
+
+
+ +@code { + private static readonly string[] AllTypes = + [ItemTypes.Box, ItemTypes.Knight, ItemTypes.Card, ItemTypes.DragAroundBox]; + + public class Box + { + public int Id { get; init; } + public string Type { get; set; } = ItemTypes.Box; + } + + public class Bin + { + public string[] Accepts { get; set; } = []; + public int Count { get; set; } + public int Hue { get; init; } + } + + private readonly Random _rng = new(42); + private readonly List _boxes = []; + private readonly List _bins = []; + private int _nextId = 1; + private int _totalDrops; + + protected override void OnInitialized() + { + for (int i = 0; i < 30; i++) _boxes.Add(NewBox()); + for (int i = 0; i < 6; i++) + _bins.Add(new Bin { Accepts = RandomTypes(), Hue = i * 60 }); + } + + private Box NewBox() => new() { Id = _nextId++, Type = AllTypes[_rng.Next(AllTypes.Length)] }; + + private string[] RandomTypes() => + AllTypes.Where(_ => _rng.NextDouble() > 0.5).DefaultIfEmpty(ItemTypes.Box).ToArray(); + + private void Shuffle() + { + foreach (var b in _boxes) b.Type = AllTypes[_rng.Next(AllTypes.Length)]; + foreach (var bin in _bins) bin.Accepts = RandomTypes(); + } + + private void AddMore() + { + for (int i = 0; i < 10; i++) _boxes.Add(NewBox()); + } + + private void OnDrop(Bin bin, BdndDropEventArgs e) + { + bin.Count++; + _totalDrops++; + } + + private static string ShortName(string t) => t switch + { + ItemTypes.Box => "box", + ItemTypes.Knight => "knight", + ItemTypes.Card => "card", + ItemTypes.DragAroundBox => "drag", + _ => t, + }; + + private string BinStyle(Bin bin, BdndDropState t) + { + if (t.IsOver && t.CanDrop) return "background:#111;"; + var c = $"hsl({bin.Hue},45%,40%)"; + if (t.CanDrop) return $"background:{c};outline:3px dashed #fff;"; + return $"background:{c};opacity:{(t.Monitor.IsDragging() ? "0.5" : "1")};"; + } + + private string BoxStyle(Box box, BdndDragState d) + { + var hue = Array.IndexOf(AllTypes, box.Type) * 90; + return $"opacity:{(d.IsDragging ? "0.4" : "1")};border-color:hsl({hue},50%,40%);"; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Home.razor b/src/Bdnd/Demo/Client/Pages/Home.razor new file mode 100644 index 00000000000..5ad0d66dc0b --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Home.razor @@ -0,0 +1,57 @@ +@page "/" + +bit Bdnd - drag and drop for Blazor + +
+

bit Bdnd

+

+ A declarative drag-and-drop library for Blazor inspired by + React DnD. + It brings the same data-driven model - items, types, monitors and an HTML5 drag-and-drop + backend - to Blazor as composable components. Try it: drag a box onto the dustbin. +

+
+ + + +
+ +
+ @foreach (var name in _boxes) + { + +
@name
+
+ } +
+ + +
+ @(drop.IsOver ? "Release to drop" : _label) +
+
+
+
+ +
+

Core concepts

+
    +
  • Items & types - you drag data (an item object) of a certain type (a string), not a view.
  • +
  • Monitor (IBdndMonitor) - exposes live drag state: IsDragging, IsOver, CanDrop, offsets, the item, and more.
  • +
  • Components - <BdndProvider>, <BdndDraggable>, <BdndDropTarget>, <BdndDragLayer> and <BdndDragHandle> map to React DnD's provider and hooks.
  • +
  • Backend - built on the browser's native HTML5 drag events, so native OS drags of files, text and links work too.
  • +
+ +

Quick start

+

Register the service in Program.cs:

+
builder.Services.AddBitBdndServices();
+

Then wrap a region in a BdndProvider and declare sources and targets - "View code" above shows this very page.

+

Pick an example from the sidebar to see more.

+
+ +@code { + private readonly string[] _boxes = ["Glass", "Banana", "Paper"]; + private string _label = "Drag a box here"; + + private void HandleDrop(BdndDropEventArgs e) => _label = $"Dropped: {e.Item}"; +} diff --git a/src/Bdnd/Demo/Client/Pages/McpServer.razor b/src/Bdnd/Demo/Client/Pages/McpServer.razor new file mode 100644 index 00000000000..4898682562c --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/McpServer.razor @@ -0,0 +1,100 @@ +@page "/mcp-server" + +@* Describes the MCP server this demo's host maps at /mcp. Its route differs from the endpoint's on + purpose: two literal endpoints on /mcp would make a GET of it ambiguous. *@ + +MCP server - bit Bdnd + +
+

MCP server

+

+ This site hosts a Model Context Protocol server at /mcp, so an AI agent writing drag-and-drop + code with Bit.Bdnd reads the library itself - its compiled API documentation, its guide, the components and + these demo pages - instead of guessing at an API it half remembers, or borrowing one from React DnD. Every + tool is read-only. +

+
+ +
+

Connect a client

+ +

+ The endpoint is plain Streamable HTTP, so any MCP client connects with a URL and nothing else. The same tools + are also served, version by version, through the bit platform's own server at + https://bitplatform.dev/mcp, next to the other bit libraries. +

+ +
@ClientConfig
+
+ +
+

Tools

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolWhat it answers
SearchBdndEverything at once - the guide, every public member, the setup guides and the demo's pages - each hit with the exact follow-up call.
GetBdndSetupGuideThe complete wiring for one render mode: server, wasm, auto, standalone-wasm or hybrid - the service registration and the provider included.
GetBdndGuideSectionOne section of the reference guide, or the index of sections.
GetBdndApiOne public type with every parameter, property and method, its real default and its documentation - or the index of types.
InspectBdndDropRuns a drag through the real components - a draggable and a tree of drop targets - and reports which targets can drop, which are hovered (deep and shallow), the order OnDrop runs in and what OnEnd receives - or a sample scenario, to see the format.
GetBdndSourceFileOne demo or library file verbatim - every page here is a working example - or the index of files.
+ +

+ The guide, the API, a sample scenario and the sources are also exposed as bdnd:// + resources, and three prompts - add-bdnd-to-app, implement-bdnd-feature and + debug-bdnd-drag - walk an agent through the common jobs in the right order. +

+
+ +
+

The same tools over plain HTTP

+ +

+ Every tool is also a GET under /api/mcp/...: open one in a browser tab to read + exactly what an agent is handed. +

+ + +
+ +@code { + private const string ClientConfig = """ + // .mcp.json (Claude Code, VS Code, and anything else reading this shape) + { + "mcpServers": { + "bdnd": { + "type": "http", + "url": "https://bdnd.bitplatform.dev/mcp" + } + } + } + """; +} diff --git a/src/Bdnd/Demo/Client/Pages/Nesting/DragSources.razor b/src/Bdnd/Demo/Client/Pages/Nesting/DragSources.razor new file mode 100644 index 00000000000..e65b61141e8 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Nesting/DragSources.razor @@ -0,0 +1,45 @@ +@page "/nesting/drag-sources" + +Nesting · Drag Sources - bit Bdnd + +
+

Nesting - Drag Sources

+

+ Drag sources can be nested. Grabbing an inner box drags that box, not its parent, + because the inner BdndDraggable stops the dragstart from bubbling. + Drop any of them on the bin to see which item was actually dragged. +

+
+ + + +
+ + +
+ outer (drag the margin) + +
+ middle + +
+ inner +
+
+
+
+
+
+ + +
+ @(string.IsNullOrEmpty(_last) ? "Drop a box here" : $"You dragged: {_last}") +
+
+
+
+ +@code { + private string? _last; +} diff --git a/src/Bdnd/Demo/Client/Pages/Nesting/DropTargets.razor b/src/Bdnd/Demo/Client/Pages/Nesting/DropTargets.razor new file mode 100644 index 00000000000..b0baf206536 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Nesting/DropTargets.razor @@ -0,0 +1,74 @@ +@page "/nesting/drop-targets" + +Nesting · Drop Targets - bit Bdnd + +
+

Nesting - Drop Targets

+

+ Drop targets can be nested. IsOver is true for every target under the + pointer, while IsOverShallow (React DnD's isOver({ shallow: true })) + is true only for the innermost one. Drag the box over the nested squares to see the + difference. +

+
+ + + +
+ + +
+ Drag me into the nest +
+
+ + @RenderNest(0) +
+ +

+ + = shallow hover (innermost)   + + = also over (ancestor) +

+
+ +@code { + private const int Depth = 3; + + private RenderFragment RenderNest(int level) => builder => + { + // recursive nested drop targets + var seq = 0; + builder.OpenComponent>(seq++); + builder.AddAttribute(seq++, "Accept", ItemTypes.Box); + builder.AddAttribute(seq++, "style", "display:contents"); + builder.AddAttribute(seq++, "ChildContent", + (RenderFragment>)(t => childBuilder => + { + childBuilder.OpenElement(0, "div"); + childBuilder.AddAttribute(1, "class", "nest-box"); + childBuilder.AddAttribute(2, "data-level", level); + childBuilder.AddAttribute(3, "style", NestStyle(level, t)); + childBuilder.AddContent(4, $"level {level}"); + if (level < Depth) + { + childBuilder.AddContent(5, RenderNest(level + 1)); + } + childBuilder.CloseElement(); + })); + builder.CloseComponent(); + }; + + private static string NestStyle(int level, BdndDropState t) + { + // base colour gets darker as we nest + var shade = 200 - level * 45; + var baseColor = $"rgb({shade},{shade + 20},{shade + 40})"; + var bg = baseColor; + if (t.IsOverShallow) bg = "#ffeb3b"; + else if (t.IsOver) bg = "rgba(255,235,59,0.25)"; + var margin = level == 0 ? "" : "margin-top:0.75rem;"; + return $"background:{bg};{margin}color:#222;"; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/NotFound.razor b/src/Bdnd/Demo/Client/Pages/NotFound.razor new file mode 100644 index 00000000000..091b6b8bc5e --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

diff --git a/src/Bdnd/Demo/Client/Pages/Other/NativeFiles.razor b/src/Bdnd/Demo/Client/Pages/Other/NativeFiles.razor new file mode 100644 index 00000000000..b90106af05a --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Other/NativeFiles.razor @@ -0,0 +1,68 @@ +@page "/other/native-files" + +Other · Native Files - bit Bdnd + +
+

Other - Native Files

+

+ A drop target can accept OS-level drags by listing a BdndNativeTypes value. Here + the target accepts BdndNativeTypes.File; drag files from your file explorer onto + it and the OnNativeDrop callback receives the file list. +

+
+ + + +
+ + +
+ @(t.IsOver ? "Release to drop the files" : "Drag files here from your OS") +
+
+
+ + @if (_files.Count > 0) + { +

Dropped files (@_files.Count)

+ + + + @foreach (var f in _files) + { + + + + + + } + +
NameTypeSize
@f.Name@(string.IsNullOrEmpty(f.Type) ? "-" : f.Type)@FormatSize(f.Size)
+ } + +

+ Only file metadata (name, size, MIME type) is exposed by the browser during a drop; + reading contents would use an additional file API. +

+
+ +@code { + private readonly List _files = []; + + private void HandleNativeDrop(BdndNativeDropEventArgs e) + { + _files.Clear(); + _files.AddRange(e.Files); + } + + private static string FormatSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB"]; + double size = bytes; + int u = 0; + while (size >= 1024 && u < units.Length - 1) { size /= 1024; u++; } + return $"{size:0.#} {units[u]}"; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Other/NativeHtml.razor b/src/Bdnd/Demo/Client/Pages/Other/NativeHtml.razor new file mode 100644 index 00000000000..cc0a4dd18e1 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Other/NativeHtml.razor @@ -0,0 +1,65 @@ +@page "/other/native-html" + +Other · Native HTML - bit Bdnd + +
+

Other - Native HTML, Text & URLs

+

+ The target below accepts BdndNativeTypes.Html, BdndNativeTypes.Text and + BdndNativeTypes.Url. Select some text on another page (or in the box on the right) + and drag it here, or drag a link / image - the dropped HTML, plain text and any URLs + are read from the native DataTransfer. +

+
+ + + +
+ +
+ +
+ @(t.IsOver ? $"Release ({t.ItemType})" : "Drag selected text / a link here") +
+
+ +
+

Try selecting and dragging this rich text with a + link inside.

+
+
+
+ + @if (_last is not null) + { +
+

Last native drop - @_last.ItemType

+ @if (string.IsNullOrEmpty(_last.Text) is false) + { +

Text: @_last.Text

+ } + @if (_last.Urls.Count > 0) + { +

URLs: @string.Join(", ", _last.Urls)

+ } + @if (string.IsNullOrEmpty(_last.Html) is false) + { +
+ HTML (raw): +
@_last.Html
+
+ } +
+ } +
+ +@code { + private readonly string[] _accepted = + [BdndNativeTypes.Html, BdndNativeTypes.Text, BdndNativeTypes.Url]; + + private BdndNativeDropEventArgs? _last; + + private void HandleNativeDrop(BdndNativeDropEventArgs e) => _last = e; +} diff --git a/src/Bdnd/Demo/Client/Pages/Sortable/CancelOnDropOutside.razor b/src/Bdnd/Demo/Client/Pages/Sortable/CancelOnDropOutside.razor new file mode 100644 index 00000000000..6138bdda4c8 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Sortable/CancelOnDropOutside.razor @@ -0,0 +1,71 @@ +@page "/sortable/cancel-on-drop-outside" + +Sortable · Cancel on Drop Outside - bit Bdnd + +
+

Sortable - Cancel on Drop Outside

+

+ Like the simple sortable, but the order is only committed if you drop on the list. + Drop a card anywhere else and it snaps back to where it started. This uses + OnEnd with its DidDrop to detect a drop outside. The list + itself is a drop target too, so a drop in the gap between two cards still counts. +

+
+ + + +
+ + + @foreach (var (card, index) in _cards.Select((c, i) => (c, i))) + { + + } + + + +

Order: @string.Join(", ", _cards.Select(c => c.Text))

+
+ +@code { + private readonly List _cards = + [ + new(1, "Alpha"), + new(2, "Bravo"), + new(3, "Charlie"), + new(4, "Delta"), + new(5, "Echo"), + ]; + + private SortableCard.CardModel? _dragged; + private int _originalIndex; + + private void OnBegin(int index) + { + _originalIndex = index; + _dragged = _cards[index]; + } + + private void Move((int From, int To) move) + { + var (from, to) = move; + if (from < 0 || from >= _cards.Count || to < 0 || to >= _cards.Count) return; + var card = _cards[from]; + _cards.RemoveAt(from); + _cards.Insert(to, card); + } + + private void OnEnd(bool didDrop) + { + if (didDrop || _dragged is null) return; + // dropped outside the list -> revert to original position + var current = _cards.IndexOf(_dragged); + if (current >= 0 && current != _originalIndex) + { + _cards.RemoveAt(current); + _cards.Insert(_originalIndex, _dragged); + } + _dragged = null; + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Sortable/Simple.razor b/src/Bdnd/Demo/Client/Pages/Sortable/Simple.razor new file mode 100644 index 00000000000..60b3c5c67ca --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Sortable/Simple.razor @@ -0,0 +1,48 @@ +@page "/sortable/simple" + +Sortable · Simple - bit Bdnd + +
+

Sortable - Simple

+

+ Each card is both a BdndDraggable and a BdndDropTarget. While + dragging, the OnHover handler compares the pointer against each card's + midpoint and reorders the list live. Reordering happens during hover, not on drop. +

+
+ + + +
+ + @* the list accepts cards too, so the gaps between cards are not "no-drop" zones *@ + + @foreach (var (card, index) in _cards.Select((c, i) => (c, i))) + { + + } + + + +

Order: @string.Join(", ", _cards.Select(c => c.Text))

+
+ +@code { + private readonly List _cards = + [ + new(1, "Write the docs"), + new(2, "Review the PR"), + new(3, "Ship the release"), + new(4, "Celebrate"), + new(5, "Sleep"), + ]; + + private void Move((int From, int To) move) + { + var (from, to) = move; + if (from < 0 || from >= _cards.Count || to < 0 || to >= _cards.Count) return; + var card = _cards[from]; + _cards.RemoveAt(from); + _cards.Insert(to, card); + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Sortable/SortableCard.razor b/src/Bdnd/Demo/Client/Pages/Sortable/SortableCard.razor new file mode 100644 index 00000000000..83bd89e7ab3 --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Sortable/SortableCard.razor @@ -0,0 +1,88 @@ +@inject BdndInterop Interop + + + +
+ @Card.Text +
+
+
+ +@code { + public record CardModel(int Id, string Text); + + public class DragItem + { + public int Id { get; init; } + public int Index { get; set; } + } + + [Parameter, EditorRequired] public CardModel Card { get; set; } = default!; + [Parameter, EditorRequired] public int Index { get; set; } + + /// Invoked with (dragIndex, hoverIndex) when a reorder should occur. + [Parameter] public EventCallback<(int From, int To)> OnMove { get; set; } + + /// Invoked when this card begins dragging (passes its current index). + [Parameter] public EventCallback OnDragBegin { get; set; } + + /// Invoked when this card stops dragging (passes whether a target handled the drop). + [Parameter] public EventCallback OnDragEnd { get; set; } + + private ElementReference _el; + private DragItem _dragItem = new(); + private bool _measuring; + + private Task HandleBegin(DragItem? _) => OnDragBegin.InvokeAsync(Index); + + private Task HandleEnd(BdndDragEndContext e) => OnDragEnd.InvokeAsync(e.DidDrop); + + // Any drop on a card already counts as handled (monitor.DidDrop() == true); the result is + // published for the drag source to inspect through monitor.GetDropResult(). + private void HandleDrop(BdndDropEventArgs e) => e.Result = "reordered"; + + protected override void OnParametersSet() + { + // keep the (stable) drag item in sync with the card's current position + _dragItem = _dragItem.Id == Card.Id + ? _dragItem + : new DragItem { Id = Card.Id }; + _dragItem.Index = Index; + } + + private async Task HandleHover(BdndDropEventArgs e) + { + var item = e.Item; + if (item is null || item.Index == Index || _measuring) return; + + // Measuring is asynchronous: skip hovers that arrive meanwhile, and re-read the indices + // afterwards because other cards may have reordered the list in between. + BdndDomRect? rect; + _measuring = true; + try + { + rect = await Interop.GetBoundingClientRectAsync(_el); + } + finally + { + _measuring = false; + } + + int dragIndex = item.Index; + int hoverIndex = Index; + var clientOffset = e.Monitor.GetClientOffset(); + if (rect is null || clientOffset is null || dragIndex == hoverIndex || e.Monitor.IsDragging() is false) return; + + // Only move past the halfway line, to avoid flicker (React DnD's algorithm). + double hoverMiddleY = rect.Height / 2; + double hoverClientY = clientOffset.Value.Y - rect.Top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + await OnMove.InvokeAsync((dragIndex, hoverIndex)); + item.Index = hoverIndex; // mutate the monitored item, like React DnD + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Sortable/StressTest.razor b/src/Bdnd/Demo/Client/Pages/Sortable/StressTest.razor new file mode 100644 index 00000000000..756fd0e593a --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Sortable/StressTest.razor @@ -0,0 +1,40 @@ +@page "/sortable/stress-test" + +Sortable · Stress Test - bit Bdnd + +
+

Sortable - Stress Test

+

+ A long sortable list (@_cards.Count items). Reordering still runs the per-item midpoint + hit-test on every hover. Handy for spotting performance regressions. +

+
+ + + +
+ + @* the list accepts cards too, so the gaps between cards are not "no-drop" zones *@ + + @foreach (var (card, index) in _cards.Select((c, i) => (c, i))) + { + + } + + +
+ +@code { + private readonly List _cards = + Enumerable.Range(1, 100).Select(i => new SortableCard.CardModel(i, $"Item #{i}")).ToList(); + + private void Move((int From, int To) move) + { + var (from, to) = move; + if (from < 0 || from >= _cards.Count || to < 0 || to >= _cards.Count) return; + var card = _cards[from]; + _cards.RemoveAt(from); + _cards.Insert(to, card); + } +} diff --git a/src/Bdnd/Demo/Client/Pages/Tutorial.razor b/src/Bdnd/Demo/Client/Pages/Tutorial.razor new file mode 100644 index 00000000000..c3f1cf78b2a --- /dev/null +++ b/src/Bdnd/Demo/Client/Pages/Tutorial.razor @@ -0,0 +1,81 @@ +@page "/tutorial" + +Chessboard Tutorial - bit Bdnd + +
+

Chessboard Tutorial

+

+ The canonical React DnD tutorial. The knight (♘) is a BdndDraggable; every + square is a BdndDropTarget whose CanDropPredicate only allows legal + knight moves. Legal targets are highlighted while dragging. +

+
+ + + +
+ +
+ @for (int row = 0; row < 8; row++) + { + @for (int col = 0; col < 8; col++) + { + var c = col; + var r = row; + var black = (c + r) % 2 == 1; + var hasKnight = _knightCol == c && _knightRow == r; + + +
+ @if (t.IsOver && t.CanDrop) + { +
+ } + else if (t.CanDrop) + { +
+ } + else if (t.IsOver) + { +
+ } + + @if (hasKnight) + { + +
♘
+
+ } +
+
+ } + } +
+
+ +

+ Knight at column @_knightCol, row @_knightRow. Drag it to a highlighted square. +

+
+ +@code { + private int _knightCol = 1; + private int _knightRow = 7; + + private bool CanMoveKnight(int toCol, int toRow) + { + int dx = Math.Abs(toCol - _knightCol); + int dy = Math.Abs(toRow - _knightRow); + return (dx == 2 && dy == 1) || (dx == 1 && dy == 2); + } + + private void MoveKnight(int toCol, int toRow) + { + _knightCol = toCol; + _knightRow = toRow; + } +} diff --git a/src/Bdnd/Demo/Client/Program.cs b/src/Bdnd/Demo/Client/Program.cs new file mode 100644 index 00000000000..b387466b43d --- /dev/null +++ b/src/Bdnd/Demo/Client/Program.cs @@ -0,0 +1,11 @@ +// The WebAssembly half of the demo: the same service registration as the server, through AddDemoServices. + +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddDemoServices(); + +// No RootComponents are registered on purpose: the host page drives the app through +// blazor.web.js render-mode markers (). +await builder.Build().RunAsync(); diff --git a/src/Bdnd/Demo/Client/Routes.razor b/src/Bdnd/Demo/Client/Routes.razor new file mode 100644 index 00000000000..4b567cd117d --- /dev/null +++ b/src/Bdnd/Demo/Client/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bdnd/Demo/Client/_Imports.razor b/src/Bdnd/Demo/Client/_Imports.razor new file mode 100644 index 00000000000..a65a4500f32 --- /dev/null +++ b/src/Bdnd/Demo/Client/_Imports.razor @@ -0,0 +1,12 @@ +@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 Bit.Bdnd.Demo.Client +@using Bit.Bdnd.Demo.Client.Layout +@using Bit.Bdnd +@using Bit.Bdnd.Demo.Client.Examples diff --git a/src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj b/src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj new file mode 100644 index 00000000000..b4b50c6c735 --- /dev/null +++ b/src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj @@ -0,0 +1,68 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BdndSource/Library/Components/%(Filename)%(Extension) + + + + + + + BdndSource/Demo/Client/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + BdndSource/Demo/Server/Components/%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/src/Bdnd/Demo/Server/Components/App.razor b/src/Bdnd/Demo/Server/Components/App.razor new file mode 100644 index 00000000000..d5dcab6a925 --- /dev/null +++ b/src/Bdnd/Demo/Server/Components/App.razor @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + @* Bit.Bdnd ships no stylesheet: its script module is imported by the components on demand. *@ + + + + + + + + @* InteractiveWebAssembly rather than Auto or Server: dragover fires continuously during a drag, + and on WebAssembly none of its handlers waits on a network round trip. The pages are still + prerendered, so the first response carries them. *@ + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/src/Bdnd/Demo/Server/Components/Pages/Error.razor b/src/Bdnd/Demo/Server/Components/Pages/Error.razor new file mode 100644 index 00000000000..d154c1a4ebb --- /dev/null +++ b/src/Bdnd/Demo/Server/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/Bdnd/Demo/Server/Components/_Imports.razor b/src/Bdnd/Demo/Server/Components/_Imports.razor new file mode 100644 index 00000000000..59576ee61dd --- /dev/null +++ b/src/Bdnd/Demo/Server/Components/_Imports.razor @@ -0,0 +1,13 @@ +@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 static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Bdnd.Demo.Server +@using Bit.Bdnd.Demo.Client +@using Bit.Bdnd.Demo.Client.Layout +@using Bit.Bdnd.Demo.Server.Components +@using Bit.Bdnd diff --git a/src/Bdnd/Demo/Server/Controllers/McpController.cs b/src/Bdnd/Demo/Server/Controllers/McpController.cs new file mode 100644 index 00000000000..6cf8551ec3c --- /dev/null +++ b/src/Bdnd/Demo/Server/Controllers/McpController.cs @@ -0,0 +1,165 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Bit.Bdnd.Demo.Server.Dtos; +using Bit.Bdnd.Demo.Server.Services; + +namespace Bit.Bdnd.Demo.Server.Controllers; + +/// +/// The Bdnd MCP server: the tools an AI agent calls to write drag-and-drop code with Bit.Bdnd +/// without guessing at its API - or borrowing React DnD's. +/// +/// Every tool answers from the shipped library or from this site's own content - the XML +/// documentation compiled into Bit.Bdnd, the README, the components themselves and the demo's source +/// files - so an agent gets what the current version actually does rather than a snapshot someone +/// wrote down. The same methods are exposed as plain HTTP GET endpoints under /api/mcp/..., which +/// makes each of them inspectable from a browser. +/// +/// +/// One rule holds across the reference tools: a tool takes the key of the thing you want and +/// answers with that thing; leave the key out and it answers with the index of what there is. That +/// is what a listing tool used to be - and a listing tool costs a name, a description and a schema +/// in every request an agent makes, forever, to say what an optional argument says for nothing. +/// +/// +/// Each tool carries the two annotations that mean something for a tool which only reads: +/// ReadOnly, which is what lets a client run it without stopping to ask a person, and +/// OpenWorld = false, which says the answers come from a closed, known body of material +/// rather than from the internet. +/// +/// +/// Only the two tools whose answer is machine-actionable - a ranked hit carrying its own follow-up +/// call, a drop report - set UseStructuredContent and publish an output schema. The rest +/// answer in Markdown: a structured answer goes over the wire twice, once as the object and once as +/// the JSON text the spec asks for on its behalf, and documentation read as Markdown is smaller than +/// either half. There is no theme tool, because Bit.Bdnd ships no stylesheet: the look of a drag is +/// the app's own markup, rendered from the collected state. +/// +/// +[ApiController] +[McpServerToolType] +[Route("api/[controller]/[action]")] +public class McpController : ControllerBase +{ + // The longest real answers are the README's longest sections and the drop target's reference, all + // well under 20,000 characters. The cap sits far above them so each arrives whole, and is there so + // a pathological answer cannot dominate a client's context window. + private const int MaxDocumentLength = 60_000; + + [HttpGet] + [McpServerTool(Name = nameof(SearchBdnd), Title = "Search everything about Bit.Bdnd", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Searches everything known about Bit.Bdnd at once - the reference guide, every public type and member, the setup guides and the demo's pages and source files - and returns the best matches, each with the exact follow-up call that returns its full text. Call it first whenever you do not already know which section or type holds the answer, then call the hit's tool verbatim. Example queries: 'sortable list', 'drop files from desktop', 'custom drag preview', 'drag handle', 'nested drop targets', 'drop result', 'Blazor Server'.")] + public BdndSearchResultDto SearchBdnd( + [Description("What you are looking for, in the words you would use for it - a feature, a symptom, or a member name. A few words rank better than a whole sentence.")] string query, + [Description("How many hits to return. Clamped to 1..50.")] int limit = 12) + { + return BdndSearchIndex.Search(query, limit); + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBdndSetupGuide), Title = "Setup guide for one render mode", ReadOnly = true, OpenWorld = false)] + [Description("Gets the complete wiring needed to add Bit.Bdnd to a Blazor app in one render mode: 'server' (InteractiveServer), 'wasm' (InteractiveWebAssembly, which is how this demo is built and comes with its real files), 'auto' (InteractiveAuto), 'standalone-wasm' or 'hybrid' (MAUI, WPF, Windows Forms). Call this before writing setup code - builder.Services.AddBitBdndServices() is required (in a Blazor Web App in both Program.cs files), there is no stylesheet to link, every component must sit inside a BdndProvider, and the page must render interactively.")] + public string GetBdndSetupGuide( + // Declared rather than merely described: the values then sit in the JSON schema itself, + // where a model cannot pass another one past them, and a client offers them as completions. + [AllowedValues("server", "wasm", "auto", "standalone-wasm", "hybrid")] + [Description("The app's Blazor render mode.")] string renderMode) + { + return Truncate(BdndSetupGuide.Get(renderMode) + ?? $"'{renderMode}' is not a known render mode. Use one of: {string.Join(", ", BdndSetupGuide.RenderModes)}."); + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBdndGuideSection), Title = "Read a reference-guide section", ReadOnly = true, OpenWorld = false)] + [Description("Gets one section of the Bit.Bdnd reference guide (the library's README) as Markdown, with its code samples - e.g. 'Getting started', 'The provider', 'Draggables', 'Drop targets', 'Drop results', 'Nesting', 'Custom drag layers', 'Drag handles', 'Sortable lists', 'Native drags', 'The monitor', 'Hosting models', 'Troubleshooting', 'Coming from React DnD'. Sub-sections are included, and heading matching ignores case and punctuation. Omit the heading for the index of sections with their sizes.")] + public string GetBdndGuideSection( + [Description("The section's heading, e.g. 'Drop results'. Case and punctuation are ignored; a heading the guide uses twice is told apart as 'Parent > Heading', as the index shows. Omit it for the index of sections.")] string? heading = null) + { + if (string.IsNullOrWhiteSpace(heading)) return BdndSourceCatalog.RenderGuideIndex(); + + var section = BdndSourceCatalog.GetGuideSection(heading); + + if (section is not null) return Truncate(section); + + var candidates = BdndSourceCatalog.GuideSections + .Where(s => s.Heading.Contains(heading, StringComparison.OrdinalIgnoreCase) || heading.Contains(s.Heading, StringComparison.OrdinalIgnoreCase)) + .Select(s => $"'{s.Key}'") + .Distinct(StringComparer.Ordinal) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"The guide has no section called '{heading}'. Did you mean: {string.Join(", ", candidates)}?" + : $"The guide has no section called '{heading}'. Call GetBdndGuideSection with no heading for the index of sections, or SearchBdnd to find the topic."; + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBdndApi), Title = "Public API reference", ReadOnly = true, OpenWorld = false)] + [Description("Gets the public API of Bit.Bdnd as Markdown, read out of the shipped assembly: one type with every Blazor parameter, property, method or constant it has - each with its C# type, its real default value and its documentation. Omit the type name for the index of every public type. Call it before using a member you are unsure about, e.g. 'BdndDraggable', 'BdndDropTarget', 'BdndDragLayer', 'BdndDragHandle', 'BdndProvider', 'BdndDropState', 'BdndDragEndContext', 'BdndDropEventArgs', 'IBdndMonitor', 'BdndNativeTypes', 'BdndInterop'.")] + public string GetBdndApi( + [Description("The type's name without its namespace or generic arguments, e.g. 'BdndDropTarget' or 'IBdndMonitor'. Omit it for the index of types.")] string? typeName = null) + { + if (string.IsNullOrWhiteSpace(typeName)) return BdndApiCatalog.RenderIndex(); + + var rendered = BdndApiCatalog.RenderType(typeName); + + if (rendered is not null) return Truncate(rendered); + + var candidates = BdndApiCatalog.Types + .Where(t => t.Name.Contains(typeName.Trim(), StringComparison.OrdinalIgnoreCase)) + .Select(t => t.Name) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"Bit.Bdnd has no public type called '{typeName}'. Did you mean: {string.Join(", ", candidates)}?" + : $"Bit.Bdnd has no public type called '{typeName}'. Every public type is prefixed 'Bdnd' (e.g. 'BdndDraggable' rather than 'useDrag', 'BdndDropTarget' rather than 'useDrop', 'IBdndMonitor' rather than 'DropTargetMonitor'); call GetBdndApi with no type name for the index."; + } + + [HttpGet] + [McpServerTool(Name = nameof(InspectBdndDrop), Title = "Inspect a Bdnd drag", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Runs one drag through the real BdndProvider, BdndDraggable and BdndDropTarget components and reports what the library does with it: for every drop target its CanDrop, IsOver and IsOverShallow while the item hovers, the order OnDrop runs in across nested targets, the Result each one published and the DidDrop and DropResult the draggable's OnEnd receives - with notes on what to fix (a type the target does not accept, a drop that falls through to an ancestor, an outer result overriding an inner one, near-miss type names). The scenario is JSON: a draggable (type, canDrag, dropEffect), a tree of targets (id, accept, canDrop, result, children) and the id of the hovered target. A native type (BdndNativeTypes.File...) as the draggable's type runs a drag from outside the app through OnNativeDrop. Call it with no json for a sample scenario.")] + public Task InspectBdndDrop( + [Description("The scenario as JSON (up to 256 KB): { \"draggable\": { \"type\": \"card\" }, \"targets\": [ { \"id\": \"list\", \"accept\": [\"card\"], \"result\": \"list\", \"children\": [ ... ] } ], \"hover\": \"list\" }. 'canDrop': false emulates a CanDropPredicate answering false; 'hover' is the innermost target under the pointer (null for a drop outside every target). Omit it to get a sample scenario instead.")] string? json = null) + { + return BdndDropLab.InspectAsync(json); + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBdndSourceFile), Title = "Read a source file", ReadOnly = true, OpenWorld = false)] + [Description("Gets one working Bit.Bdnd source file verbatim - e.g. 'Demo/Client/Pages/Sortable/SortableCard.razor' (a sortable card that is both a draggable and a drop target), 'Demo/Client/Pages/Nesting/DropTargets.razor' (deep and shallow hover), 'Demo/Client/Pages/Tutorial.razor' (a chessboard with CanDropPredicate), 'Demo/Client/Pages/Other/NativeFiles.razor' (files from the desktop), 'Library/Components/BdndDropTarget.cs' (the component itself) or 'Library/Scripts/bit-bdnd.ts' (the browser layer). Omit the path for the index of files: the demo pages, each a working example, the hosting files of a Blazor Web App and the library's components. These are worked examples - for what a parameter means, GetBdndApi answers in a fraction of the characters.")] + public string GetBdndSourceFile( + [Description("The file's path as the index lists it, e.g. 'Demo/Client/Pages/Sortable/Simple.razor'. Omit it for the index of files.")] string? path = null) + { + if (string.IsNullOrWhiteSpace(path)) return Truncate(BdndSourceCatalog.RenderSourceIndex()); + + var content = BdndSourceCatalog.GetSourceFile(path); + + if (content is null) + { + var candidates = BdndSourceCatalog.SourceFiles + .Where(f => f.Path.Contains(path.Trim(), StringComparison.OrdinalIgnoreCase)) + .Select(f => f.Path) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"No source file at '{path}'. Did you mean: {string.Join(", ", candidates)}?" + : $"No source file at '{path}'. Call GetBdndSourceFile with no path for the index of files."; + } + + return Truncate(content); + } + + internal static string Truncate(string text) + { + if (text.Length <= MaxDocumentLength) return text; + + // Never between the two halves of a surrogate pair: half a pair is not text, and a client + // that re-encodes the answer turns it into a replacement character or rejects it outright. + var cut = char.IsHighSurrogate(text[MaxDocumentLength - 1]) ? MaxDocumentLength - 1 : MaxDocumentLength; + + return $"{text[..cut]}\n\n[truncated - the full text is longer than {MaxDocumentLength} characters]"; + } +} diff --git a/src/Bdnd/Demo/Server/Controllers/McpPrompts.cs b/src/Bdnd/Demo/Server/Controllers/McpPrompts.cs new file mode 100644 index 00000000000..77a60eef058 --- /dev/null +++ b/src/Bdnd/Demo/Server/Controllers/McpPrompts.cs @@ -0,0 +1,125 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Bdnd.Demo.Server.Controllers; + +/// +/// Ready-made workflows for the three things people actually ask a drag-and-drop library for: add it +/// to an app, build a drag-and-drop feature with it, and work out why a drag does not behave. +/// +/// Each prompt spends its words on the order to call the tools in and on the mistakes that are +/// expensive to make - the ones that produce a target that silently never lights up, or an +/// OnDrop that silently runs twice, rather than a compiler error - because the failure mode of +/// an agent with a handful of tools is not ignorance, it is calling them in a sequence that skips the +/// check which would have caught the bug. +/// +/// +[McpServerPromptType] +public static class McpPrompts +{ + [McpServerPrompt(Name = "add-bdnd-to-app", Title = "Add Bit.Bdnd to a Blazor app")] + [Description("Walks through adding Bit.Bdnd to an existing Blazor app, in the right order for its render mode.")] + public static string AddBdndToApp( + // The values go into the prompt's own argument schema, which is what a client offers as + // completions - so picking the mode is a choice from a list rather than a spelling test. + [AllowedValues("unknown", "server", "wasm", "auto", "standalone-wasm", "hybrid")] + [Description("The app's Blazor render mode: server, wasm, auto, standalone-wasm or hybrid. Pass 'unknown' to have it determined from the project first.")] string renderMode = "unknown") + { + return $""" + Add Bit.Bdnd to this Blazor app. Its render mode is: {renderMode}. + + Work in this order: + + 1. If the render mode is 'unknown', determine it from the project first: look for + AddInteractiveServerComponents / AddInteractiveWebAssemblyComponents in Program.cs, for a separate + .Client project, and for a MAUI/WPF/Windows Forms BlazorWebView host, then continue with what you find. + 2. Call `GetBdndSetupGuide` with that render mode and follow it. In a Blazor Web App with an interactive + client the package and the pages that drag belong in the .Client project. + 3. DO register the service: `builder.Services.AddBitBdndServices();` in every Program.cs that renders the + components - in a Blazor Web App BOTH the server's and the client's, because the pages are prerendered on + the server. Missing it fails with "Cannot provide a value for property 'Interop'". + 4. Do NOT link a stylesheet or add a script tag: Bit.Bdnd ships no stylesheet, and the components import their + script module themselves. Inventing a `bit-bdnd.css` link is a common mistake. + 5. Add `@using Bit.Bdnd` to `_Imports.razor`. + 6. Wrap every region that drags and drops in a `` - a `BdndDraggable`, `BdndDropTarget` or + `BdndDragLayer` outside one throws as it initializes - and put it on an interactively rendered page. A + statically rendered page shows the markup and nothing drags. + 7. Build the app and fix what the compiler says. + + Show me the diff for each file you change, and say which project the drag-and-drop pages live in and why. + """; + } + + [McpServerPrompt(Name = "implement-bdnd-feature", Title = "Implement a drag-and-drop feature")] + [Description("Implements a drag-and-drop feature with Bit.Bdnd - kanban boards, sortable lists, palettes, dustbins, nested targets, custom drag previews, drag handles, file drop zones - using its real API rather than a guessed one or React DnD's.")] + public static string ImplementBdndFeature( + [Description("What the feature should do, in your own words - e.g. 'a sortable list reordered by dragging' or 'drop files from the desktop onto an upload zone'.")] string feature) + { + return $""" + Implement this with Bit.Bdnd: {feature} + + Work in this order: + + 1. Call `SearchBdnd` with the request above. It searches the guide, every public member, the setup guides and + the demo's pages at once, and every hit tells you the exact follow-up call to make. + 2. Read the best hit in full with the call it hands you, before writing any code. When a demo page does what you + need - sortable lists, nesting, drag layers, handles, native files - fetch it with `GetBdndSourceFile` and + follow its shape. + 3. Call `GetBdndApi` for every Bdnd type you are about to use, and match the member names, types and defaults + exactly. There is one package and one namespace, `Bit.Bdnd`, and every public type is prefixed `Bdnd`; + React DnD's hooks and connector refs (`useDrag`, `useDrop`, `drag(ref)`) do not exist - the component's + host element is the source or the target. + 4. Put every `BdndDraggable` and `BdndDropTarget` of the feature inside one `BdndProvider`. Give each draggable + a `Type` string from a constants class and list it in the target's `Accept` or `AcceptTypes` - types are + matched as strings, never by the item's .NET type. Render from the collected state: `IsDragging` on the + draggable's `BdndDragState`, `IsOver`, `IsOverShallow` and `CanDrop` on the target's `BdndDropState`. + 5. Change your data in the target's `OnDrop` (with `args.Item`), or in the draggable's `OnEnd` from `DidDrop` + and `DropResult`; set `args.Result` in `OnDrop` to tell the source what happened. Nested targets ALL get + `OnDrop`, innermost first, and an outer non-null `Result` overrides an inner one - check + `args.Monitor.DidDrop()` in an outer target. Gate drops with `CanDropPredicate`, reorder live from + `OnHover`, and take files, text, links or HTML from outside the app with `BdndNativeTypes` and + `OnNativeDrop` (never `OnDrop`). + 6. Prefer the library's own mechanism over hand-rolling one: `UseCustomDragLayer` and `BdndDragLayer` for a + custom preview, `UseHandle` and `BdndDragHandle` for a grip, `DropEffect` for the cursor, + `BdndInterop.GetBoundingClientRectAsync` to measure an element. + 7. If the feature nests targets or depends on which target handles a drop, describe the draggable and the + target tree to `InspectBdndDrop`: it runs the drag through the real components and reports the hover + state, the order the drops run in and what `OnEnd` receives. + 8. Write the code, then say which tool call each non-obvious choice came from. + """; + } + + [McpServerPrompt(Name = "debug-bdnd-drag", Title = "Debug a drag that misbehaves")] + [Description("Diagnoses a Bit.Bdnd problem - nothing drags, a target never lights up, OnDrop never runs or runs twice, OnEnd reports DidDrop = false, files open in the browser, a custom preview flickers, an exception about BdndProvider or Interop.")] + public static string DebugBdndDrag( + [Description("What goes wrong, with the page, the draggable and the targets involved if you know them.")] string symptom) + { + return $""" + Diagnose this Bit.Bdnd problem: {symptom} + + Work in this order: + + 1. Check the setup against `GetBdndSetupGuide` for this render mode first - the four failures it prevents: + `AddBitBdndServices()` missing from a Program.cs (in a Blazor Web App it must be in both - "Cannot provide a + value for property 'Interop'"), a component outside a `BdndProvider` (it throws), a statically rendered page + (nothing drags), and source and target in different providers (the target never reacts). + 2. Compare the draggable's `Type` with the target's `Accept`/`AcceptTypes` character by character - they are + compared as strings, case included. A `CanDropPredicate` answering false keeps `IsOver` true but + `CanDrop` false, and `OnDrop` does not run. + 3. Describe the draggable and the target tree to `InspectBdndDrop` - with `canDrop: false` where a predicate + says no and the `result` each `OnDrop` sets - and hover the target in question. It runs the drag through + the real components and reports each target's `CanDrop`, `IsOver` and `IsOverShallow`, the order `OnDrop` + ran in, and the `DidDrop` and `DropResult` the draggable's `OnEnd` received, with notes on near-miss types. + 4. Call `SearchBdnd` with the symptom and read the guide section it points to - "Troubleshooting", "Drop + targets", "Nesting", "Sortable lists" and "Native drags" cover the usual causes: nested targets all get + `OnDrop` (check `args.Monitor.DidDrop()`), the gaps of a list are no-drop zones unless the list is a target + too, native drops arrive through `OnNativeDrop`, a preview needs `pointer-events: none`. + 5. Confirm the API you are relying on with `GetBdndApi` before concluding it is a bug - check the actual + default of the parameter involved (`CanDrag` is true, `DropEffect` is "move", `UseHandle` and + `UseCustomDragLayer` are false). + + Tell me the cause and the fix, and cite the tool call that established it. + """; + } +} diff --git a/src/Bdnd/Demo/Server/Controllers/McpResources.cs b/src/Bdnd/Demo/Server/Controllers/McpResources.cs new file mode 100644 index 00000000000..1f158790214 --- /dev/null +++ b/src/Bdnd/Demo/Server/Controllers/McpResources.cs @@ -0,0 +1,82 @@ +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using System.ComponentModel; +using Bit.Bdnd.Demo.Server.Services; + +namespace Bit.Bdnd.Demo.Server.Controllers; + +/// +/// The same body of knowledge the tools serve, exposed as MCP resources. +/// +/// Tools are for an agent that has decided what it needs; resources are for a client that wants to +/// attach documentation to a conversation up front, or let a person browse and pin it. Both read +/// the same catalogs, so neither can go stale relative to the other - and where a tool and a +/// resource take the same argument, they resolve it through the same method, so a key that works +/// in one cannot fail in the other. +/// +/// +/// A client showing bdnd://guide/{heading} in a picker cannot fill it in, because nothing on +/// the wire says which headings exist. Two things close that gap - the top-level guide sections are +/// also listed as concrete resources (see ListGuideSections, wired up in Program.cs), and +/// every placeholder here is completable through completion/complete. +/// +/// +[McpServerResourceType] +public static class McpResources +{ + [McpServerResource(UriTemplate = "bdnd://guide", Name = "bdnd-guide", Title = "Bit.Bdnd reference guide", MimeType = "text/markdown")] + [Description("The complete Bit.Bdnd reference guide (the library's README), every section in one document.")] + public static string Guide() => BdndSourceCatalog.Readme; + + [McpServerResource(UriTemplate = "bdnd://guide/{heading}", Name = "bdnd-guide-section", Title = "Guide section", MimeType = "text/markdown")] + [Description("One section of the Bit.Bdnd reference guide by heading, e.g. bdnd://guide/Drop%20results.")] + public static string GuideSection(string heading) + => BdndSourceCatalog.GetGuideSection(heading) ?? $"The guide has no section called '{heading}'."; + + [McpServerResource(UriTemplate = "bdnd://api", Name = "bdnd-api", Title = "Bit.Bdnd public API", MimeType = "text/markdown")] + [Description("Every public Bit.Bdnd type with its kind and summary.")] + public static string ApiList() => BdndApiCatalog.RenderIndex(); + + [McpServerResource(UriTemplate = "bdnd://api/{typeName}", Name = "bdnd-api-type", Title = "Type reference", MimeType = "text/markdown")] + [Description("The full reference of one Bit.Bdnd type, e.g. bdnd://api/BdndDropTarget.")] + public static string ApiType(string typeName) + => BdndApiCatalog.RenderType(typeName) ?? $"Bit.Bdnd has no public type called '{typeName}'."; + + [McpServerResource(UriTemplate = "bdnd://sample-scenario", Name = "bdnd-sample-scenario", Title = "Sample drag scenario", MimeType = "application/json")] + [Description("A drag over nested drop targets in the shape InspectBdndDrop reads - a draggable, a tree of targets and the hovered one: the format, by example.")] + public static string SampleScenario() => BdndDropLab.Sample; + + [McpServerResource(UriTemplate = "bdnd://source/{path}", Name = "bdnd-source", Title = "Source file", MimeType = "text/plain")] + [Description("One source file of the demo or the library, e.g. bdnd://source/Demo%2FClient%2FPages%2FSortable%2FSortableCard.razor.")] + public static string Source(string path) + => BdndSourceCatalog.GetSourceFile(path) ?? $"No source file at '{path}'."; + + /// + /// The top-level sections of the guide as resources a client can list, show and pin, rather than + /// as a placeholder someone has to already know how to fill in. + /// + /// These sit alongside the attributed resources above rather than replacing the + /// bdnd://guide/{heading} template: the template is what actually serves a read - the + /// exact URI is looked for first and the templates are matched afterwards - while these give + /// resources/list something a person can choose from. The sub-sections, the API types and + /// the source files stay templated, because listing all of them would bury the handful anyone + /// browses to. + /// + /// + public static IEnumerable ListGuideSections() + { + return BdndSourceCatalog.GuideSections + .Where(section => section.Level == 2) + .Select(section => new Resource + { + Uri = $"bdnd://guide/{Uri.EscapeDataString(section.Key)}", + Name = $"bdnd-guide-{Slug(section.Heading)}", + Title = $"Guide: {section.Heading}", + Description = $"The '{section.Heading}' section of the Bit.Bdnd reference guide ({section.Lines} lines).", + MimeType = "text/markdown" + }); + } + + private static string Slug(string heading) + => string.Join('-', heading.ToLowerInvariant().Split(heading.Where(c => char.IsLetterOrDigit(c) is false).Distinct().ToArray(), StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/src/Bdnd/Demo/Server/Dtos/BdndMcpDtos.cs b/src/Bdnd/Demo/Server/Dtos/BdndMcpDtos.cs new file mode 100644 index 00000000000..fda2da3c5dc --- /dev/null +++ b/src/Bdnd/Demo/Server/Dtos/BdndMcpDtos.cs @@ -0,0 +1,264 @@ +namespace Bit.Bdnd.Demo.Server.Dtos; + +/// +/// One heading of the library's README, which doubles as its reference guide. The guide is read +/// through GetBdndGuideSection (and the bdnd://guide resources); this record is how the catalog, +/// the search index and the completions describe a section to each other. +/// +public record BdndGuideSectionDto +{ + /// The heading text, e.g. "Drop results". + public required string Heading { get; init; } + + /// + /// What to pass to GetBdndGuideSection for exactly this section: the heading itself, or - for a + /// heading the README uses more than once - "Parent > Heading". + /// + public required string Key { get; init; } + + /// Markdown heading level: 2 for a top-level section, 3 or 4 for a sub-section. + public required int Level { get; init; } + + /// The heading this one sits under, or null when this entry is itself level 2. + public string? Parent { get; init; } + + /// Number of markdown lines in the section (including its sub-sections). + public required int Lines { get; init; } +} + +/// A public type of the Bit.Bdnd assembly. +public record BdndApiTypeDto +{ + /// The C# name, generic parameters included: "BdndDraggable<TItem>". + public required string Name { get; init; } + + /// Component, Interface, Enum, Attribute, Delegate, Static class, Class, Struct or Record. + public required string Kind { get; init; } + + public string? Summary { get; init; } +} + +/// A member (parameter, property, method or constant) of a public Bit.Bdnd type. +public record BdndApiMemberDto +{ + public required string Name { get; init; } + + /// Parameter, Property, Field, Method, Event or EnumValue. "Parameter" means a Blazor [Parameter]. + public required string Kind { get; init; } + + /// The C# type of the member, or the signature's return type for a method. + public string? Type { get; init; } + + /// The method's parameter list, e.g. "(string handlerId, bool shallow = false)". + public string? Signature { get; init; } + + /// The value the member has on a freshly created instance, when it could be determined. + public string? Default { get; init; } + + /// True for [EditorRequired] Blazor parameters. + public bool Required { get; init; } + + public string? Summary { get; init; } + + /// The XML remarks, when the member has any - they carry the caveats. + public string? Remarks { get; init; } +} + +/// The full reference of one public Bit.Bdnd type. +public record BdndApiTypeDetailsDto +{ + public required string Name { get; init; } + + public required string FullName { get; init; } + + public required string Kind { get; init; } + + public string? BaseType { get; init; } + + public string[]? Implements { get; init; } + + public string? Summary { get; init; } + + public string? Remarks { get; init; } + + public required BdndApiMemberDto[] Members { get; init; } +} + +/// A source file this server can hand out, retrievable through GetBdndSourceFile. +public record BdndSourceFileDto +{ + /// The path to pass to GetBdndSourceFile, e.g. "Demo/Client/Pages/Sortable/SortableCard.razor". + public required string Path { get; init; } + + /// + /// Page (a demo page, each one a working example), Shared (what the pages share), Hosting (the + /// wiring of the app) or Library (a file the package itself ships). + /// + public required string Kind { get; init; } + + /// The heading a demo page shows its reader, e.g. "Sortable - Simple", when it has one. + public string? Title { get; init; } + + /// The file's own summary, header comment or page introduction, when it has one. + public string? Description { get; init; } + + /// The route the file is served at, for a routable page. + public string? Route { get; init; } + + public required int Lines { get; init; } +} + +/// One result of a search across everything this MCP server knows about Bit.Bdnd. +public record BdndSearchHitDto +{ + /// What was found: "Guide section", "API component", "API parameter", "Setup guide", "Demo page", "Source file", ... + public required string Kind { get; init; } + + public required string Title { get; init; } + + /// Where the hit sits: the owning section, type or route. + public string? Context { get; init; } + + /// The tool call that returns the full text of this hit - call it verbatim. + public required string Tool { get; init; } + + /// The matching text, with a little of what surrounds it. + public required string Snippet { get; init; } +} + +/// What SearchBdnd answers: the ranked hits, or - when there are none - why, and what to try instead. +public record BdndSearchResultDto +{ + /// The matches, best first. Empty when nothing matched - Message then says what to do about it. + public required BdndSearchHitDto[] Hits { get; init; } + + /// True when the ranking was cut short at the requested limit, so a narrower query would show more. + public bool HasMore { get; init; } + + /// + /// The words the query was actually searched by - punctuation, duplicates and filler words + /// removed. Present only when nothing matched, which is the one time it explains something the + /// caller could not work out from the query it just sent. + /// + public string[]? Terms { get; init; } + + /// Set only when Hits is empty: the reason, and the tool to fall back to. + public string? Message { get; init; } + + /// Titles that nearly matched, when nothing matched outright - a query worth retrying with. + public string[]? DidYouMean { get; init; } +} + +/// +/// What one drop target of the scenario reported - read off the BdndDropState the real +/// BdndDropTarget handed its child content, and off the callbacks it raised. +/// +public record BdndDropTargetReportDto +{ + /// The target's id in the scenario. + public required string Id { get; init; } + + /// The id of the target this one is nested in, or null for a root target. + public string? Parent { get; init; } + + /// How many targets this one is nested in (0 for a root). + public required int Depth { get; init; } + + /// + /// The item types the target was rendered with (Accept and AcceptTypes combined, duplicates + /// dropped, native names resolved to their BdndNativeTypes constants). + /// + public required string[] Accept { get; init; } + + /// True when the pointer is inside the target: it is the hovered one or one of its ancestors. + public required bool Hovered { get; init; } + + /// BdndDropState.CanDrop while the item hovered: the type matches and no CanDropPredicate said no - hovered or not. + public required bool CanDrop { get; init; } + + /// BdndDropState.IsOver while the item hovered: the pointer is over it or a target nested in it, and it accepts the type. + public required bool IsOver { get; init; } + + /// BdndDropState.IsOverShallow while the item hovered: over it, and over no hovered target nested in it. + public required bool IsOverShallow { get; init; } + + /// The 1-based position at which this target's OnDrop (or, for a native drag, OnNativeDrop) ran; null when it did not run. + public int? DropOrder { get; init; } + + /// What args.Monitor.DidDrop() answered inside this target's OnDrop - true when an inner target had already handled the drop. + public bool? InnerAlreadyDropped { get; init; } + + /// The value this target's OnDrop published as args.Result, when it published one. + public string? Result { get; init; } + + /// For a native drag: the BdndNativeDropEventArgs.ItemType its OnNativeDrop received. + public string? NativeItemType { get; init; } +} + +/// What the draggable's OnEnd received in its BdndDragEndContext. +public record BdndDragEndReportDto +{ + /// BdndDragEndContext.DidDrop: a target accepted the drop. False for a drop on nothing, or on a target that cannot drop. + public required bool DidDrop { get; init; } + + /// BdndDragEndContext.DropResult: the result the drop ended with - the outermost non-null one. + public string? DropResult { get; init; } +} + +/// +/// What Bit.Bdnd does with one drag - the answer the running components give for the same draggable, +/// the same tree of drop targets and the same pointer position. +/// +public record BdndDropReportDto +{ + /// True when the scenario was read and the drag ran. + public required bool Valid { get; init; } + + /// Why the scenario could not be run: the JSON parser's own message, or what the scenario is missing. + public string? Error { get; init; } + + /// The size of the input, in UTF-8 bytes. + public required int InputBytes { get; init; } + + /// The dragged item's type, as the draggable was rendered with it (a native type for an OS drag). + public string? ItemType { get; init; } + + /// True for a native drag - files, text, a link or HTML from outside the app - which no BdndDraggable starts. + public bool? Native { get; init; } + + /// The DataTransfer types the drag carried: the draggable's own application/x-bit-bdnd-* tag, or the native type token. + public string[]? DataTransferTypes { get; init; } + + /// The dataTransfer.effectAllowed the draggable's JS registration was given - what the browser draws the cursor from. + public string? EffectAllowed { get; init; } + + /// True when the drag began: the draggable recognised its own dragstart and its BdndDragState.IsDragging turned true. + public bool? DragStarted { get; init; } + + /// True when the draggable's OnBegin ran. + public bool? BeginFired { get; init; } + + /// The hovered targets from the outermost to the one under the pointer; empty when the item was dropped outside every target. + public string[]? HoverPath { get; init; } + + /// + /// True when the browser raises a drop event at all - when some target in the hovered chain can + /// drop. With none, the browser shows the no-drop cursor and the drop never happens. + /// + public bool? DropFired { get; init; } + + /// The targets whose OnDrop (or OnNativeDrop) ran, in the order they ran. + public string[]? DropOrder { get; init; } + + /// What the draggable's OnEnd received; null for a native drag or a drag that never began. + public BdndDragEndReportDto? End { get; init; } + + /// Every target of the scenario, in document order. + public BdndDropTargetReportDto[]? Targets { get; init; } + + /// A sample scenario, returned when no scenario was passed - the format, by example. + public string? Sample { get; init; } + + /// What the numbers mean in practice, and what to change when the outcome is not the one wanted. + public required string[] Notes { get; init; } +} diff --git a/src/Bdnd/Demo/Server/Program.cs b/src/Bdnd/Demo/Server/Program.cs new file mode 100644 index 00000000000..6f288e2d006 --- /dev/null +++ b/src/Bdnd/Demo/Server/Program.cs @@ -0,0 +1,105 @@ +// The host of the demo: a Blazor Web App that prerenders the client's pages and then runs them in the +// browser on WebAssembly (InteractiveWebAssembly), where every drag event is handled without a network round trip. + +using Bit.Bdnd.Demo.Server.Components; +using Bit.Bdnd.Demo.Server.Controllers; +using Bit.Bdnd.Demo.Server.Services; +using Microsoft.AspNetCore.HttpOverrides; +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +// The same registration as the WebAssembly client, so the components get the same services while +// they are prerendered here. +builder.Services.AddDemoServices(); + +// The MCP server (Controllers/McpController.cs) and the plain HTTP endpoints that mirror it. A +// member with nothing in it is left out of the JSON, exactly as the MCP transport leaves it out, so +// the browser view of a tool is the answer an agent gets rather than that answer plus a column of nulls. +builder.Services.AddControllers() + .AddJsonOptions(options => options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull); + +builder.Services.AddMcpServer(options => +{ + // The name a person sees in their client's server list, and the version they would quote in a + // bug report - the library's, not this web app's, because the library is what the answers are about. + options.ServerInfo = new Implementation + { + Name = "bit-bdnd", + Title = "Bit.Bdnd", + Version = BdndServerInstructions.BdndVersion, + Description = "Documentation, public API, setup guides and drag inspection for the Bit.Bdnd drag-and-drop library for Blazor.", + WebsiteUrl = "https://github.com/bitfoundation/bitplatform/tree/develop/src/Bdnd" + }; + + // Returned from `initialize` and put in front of the model before it has called anything. + options.ServerInstructions = BdndServerInstructions.Text; +}) + .WithHttpTransport() + .WithToolsFromAssembly() + .WithResourcesFromAssembly() + .WithPromptsFromAssembly() + // Every key this server takes - a guide heading, a type name, a source path - comes from a closed + // set nothing on the wire spells out; completions let a client type ahead into it. + .WithCompleteHandler((request, cancellationToken) => ValueTask.FromResult(new CompleteResult + { + Completion = BdndCompletions.Complete(request.Params?.Argument?.Name, request.Params?.Argument?.Value) + })) + // Additive to the attributed resources, not a replacement for them: a template cannot be listed + // as something to click, so the guide's top-level sections are enumerated one by one as well. + .WithListResourcesHandler((request, cancellationToken) => ValueTask.FromResult(new ListResourcesResult + { + Resources = [.. McpResources.ListGuideSections()] + })); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.All; + options.ForwardedHostHeaderName = "X-Host"; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + options.ForwardLimit = 1; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); + +// Both are literal routes, so they are matched before any component route regardless of order; +// declaring them first says so out loud. +app.MapControllers(); +app.MapMcp("/mcp"); + +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(Bit.Bdnd.Demo.Client._Imports).Assembly); + +app.Run(); + +/// +/// Named so the MCP tests can host this exact file in-memory. Top-level statements compile to an +/// internal Program, which WebApplicationFactory cannot reach from another assembly on its own. +/// +public partial class Program; diff --git a/src/Bdnd/Demo/Server/Properties/launchSettings.json b/src/Bdnd/Demo/Server/Properties/launchSettings.json new file mode 100644 index 00000000000..bb0073aacda --- /dev/null +++ b/src/Bdnd/Demo/Server/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Bit.Bdnd.Demo.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:5561;http://localhost:5560", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Bdnd/Demo/Server/Services/BdndApiCatalog.cs b/src/Bdnd/Demo/Server/Services/BdndApiCatalog.cs new file mode 100644 index 00000000000..b40eb381b69 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndApiCatalog.cs @@ -0,0 +1,660 @@ +using System.Text; +using System.Reflection; +using System.Collections.Concurrent; +using Bit.Bdnd.Demo.Server.Dtos; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Builds the API reference the MCP tools serve, by reflecting over the public surface of the +/// Bit.Bdnd assembly and pairing every type and member with its XML documentation. +/// +/// Reflection rather than a hand-maintained table: the reference then cannot drift from the +/// shipped library - a new parameter shows up the moment it is written, with the default value it +/// actually has (read off a freshly constructed instance) instead of one someone remembered to +/// copy into a document. +/// +/// +public static class BdndApiCatalog +{ + private const BindingFlags Declared = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + private static readonly Assembly _assembly = typeof(BdndProvider).Assembly; + + // _Imports is left out: Razor compiles a library's _Imports.razor into a public class of that name, + // which carries the @using lines and nothing anybody could call. + private static readonly Lazy _publicTypes = new(() => + [.. _assembly.GetExportedTypes() + .Where(t => t.IsNested is false && t.Name.Contains('<', StringComparison.Ordinal) is false && t.Name != "_Imports") + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)]); + + private static readonly ConcurrentDictionary _typeDetails = new(StringComparer.Ordinal); + + private static readonly Lazy _types = new(() => + [.. _publicTypes.Value.Select(t => new BdndApiTypeDto + { + Name = FriendlyName(t), + Kind = KindOf(t), + Summary = BdndXmlDocs.GetSummary(DocumentationId(t)) + })]); + + /// Every public type of Bit.Bdnd, with its summary. + public static BdndApiTypeDto[] Types => _types.Value; + + /// + /// The index of the public types, as GetBdndApi answers when it is called without a name. + /// + /// A summary is cut to its first sentence here: what an index is for is choosing what to read + /// next, and the paragraph that follows the first sentence is the answer to the call the reader + /// has not made yet. Whole summaries made this list four times longer than the reference of the + /// one type anybody wanted. + /// + /// + public static string RenderIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Bdnd public API").AppendLine(); + builder.AppendLine($"{Types.Length} public types. Call `GetBdndApi(typeName: \"...\")` for one of them in full - ") + .AppendLine("its parameters, properties, methods or constants, with the default value each one really has. ") + .AppendLine("A generic type is found by its name alone: `BdndDraggable` is `BdndDraggable`.") + .AppendLine(); + + foreach (var type in Types) + { + builder.Append($"- **{type.Name}** ({type.Kind})"); + if (FirstSentence(type.Summary) is string summary) builder.Append($" - {summary}"); + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// + /// The full reference of one type as Markdown, or null when no public type goes by that name. + /// + /// Markdown rather than an object, and one renderer rather than two: the tool and the + /// bdnd://api/{typeName} resource hand out the same text, so neither can drift from + /// the other - and a reference a model reads costs less as prose than as JSON sent twice, once + /// under an output schema and once as the text that mirrors it. + /// + /// + public static string? RenderType(string typeName) + { + var details = GetTypeDetails(typeName); + if (details is null) return null; + + var builder = new StringBuilder(); + + builder.AppendLine($"# {details.Name} ({details.Kind})").AppendLine(); + // One line: the fragments continue the FullName rather than standing on their own, so the + // line is only ended once every part of it has been written. + builder.Append($"`{details.FullName}`"); + + if (details.BaseType is not null) builder.Append($" - inherits `{details.BaseType}`"); + if (details.Implements?.Length > 0) builder.Append($" - implements {string.Join(", ", details.Implements.Select(i => $"`{i}`"))}"); + + builder.AppendLine().AppendLine(); + + if (details.Summary is not null) builder.AppendLine(details.Summary).AppendLine(); + if (details.Remarks is not null) builder.AppendLine(details.Remarks).AppendLine(); + + foreach (var group in details.Members.GroupBy(member => member.Kind)) + { + builder.AppendLine($"## {group.Key}").AppendLine(); + + foreach (var member in group) + { + builder.Append($"- **{member.Name}**{member.Signature}"); + if (member.Type is not null) builder.Append($" : `{member.Type}`"); + if (member.Default is not null) builder.Append($" = `{member.Default}`"); + if (member.Required) builder.Append(" **(required)**"); + if (member.Summary is not null) builder.Append($" - {member.Summary}"); + builder.AppendLine(); + + // The caveats live in the remarks, which is exactly the half of the documentation + // an agent writing against a member needs and the least likely to be remembered. + if (member.Remarks is not null) builder.AppendLine($" {member.Remarks.Replace("\n", "\n ", StringComparison.Ordinal)}"); + } + + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// The first sentence of a summary, for a listing that is read to choose by. + private static string? FirstSentence(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) return null; + + var text = summary.ReplaceLineEndings(" ").Trim(); + + var stop = text.IndexOf(". ", StringComparison.Ordinal); + + return stop > 0 ? text[..(stop + 1)] : text; + } + + /// + /// The full reference of one type - its Blazor parameters, properties, methods, events or enum + /// values - or null when no public type goes by that name. + /// + public static BdndApiTypeDetailsDto? GetTypeDetails(string typeName) + { + var type = Find(typeName); + if (type is null) return null; + + // Every hit costs a full reflection walk plus an XML lookup per member, and the search index + // asks for all of them at once; the answer only changes when the assembly does. + return _typeDetails.GetOrAdd(type.FullName ?? type.Name, _ => BuildTypeDetails(type)); + } + + private static BdndApiTypeDetailsDto BuildTypeDetails(Type type) + { + var instance = TryCreateInstance(type); + + // A second instance, compared against the first, is what tells a real default from a value + // made up per instance - a Guid or a counter minted in a constructor - and reporting one of + // those as "the default" would hand a caller a value nobody ever gets. + var twin = TryCreateInstance(type); + + var members = new List(); + + if (type.IsEnum) + { + members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(field => new BdndApiMemberDto + { + Name = field.Name, + Kind = "EnumValue", + Type = FriendlyName(type.GetEnumUnderlyingType()), + // Formatted straight off the raw constant: a [Flags] enum backed by + // ulong has members that do not fit in an Int64. + Default = Format(field.GetRawConstantValue()), + Summary = BdndXmlDocs.GetSummary(DocumentationId(field)), + Remarks = BdndXmlDocs.GetRemarks(DocumentationId(field)) + })); + } + else + { + members.AddRange(Properties(type, instance, twin)); + members.AddRange(Fields(type)); + members.AddRange(Methods(type)); + members.AddRange(Events(type)); + } + + return new BdndApiTypeDetailsDto + { + Name = FriendlyName(type), + FullName = type.FullName ?? type.Name, + Kind = KindOf(type), + BaseType = type.BaseType is null || type.BaseType == typeof(object) || type.BaseType == typeof(ValueType) + ? null + : FriendlyName(type.BaseType), + Implements = [.. type.GetInterfaces() + .Where(i => i.IsPublic && i.DeclaringType is null) + .Select(FriendlyName) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)], + Summary = BdndXmlDocs.GetSummary(DocumentationId(type)), + Remarks = BdndXmlDocs.GetRemarks(DocumentationId(type)), + Members = [.. members.OrderBy(m => KindOrder(m.Kind)).ThenBy(m => m.Name, StringComparer.OrdinalIgnoreCase)] + }; + } + + /// Resolves a type by its simple name, with or without a generic arity or argument list. + private static Type? Find(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) return null; + + var name = typeName.Trim(); + + var generic = name.IndexOf('<', StringComparison.Ordinal); + if (generic > 0) name = name[..generic]; + + return _publicTypes.Value.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? _publicTypes.Value.FirstOrDefault(t => string.Equals(StripArity(t.Name), name, StringComparison.OrdinalIgnoreCase)) + ?? _publicTypes.Value.FirstOrDefault(t => string.Equals(t.FullName, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// The type and every base of it that Bit.Bdnd itself declares. A component's inherited + /// parameters belong in its reference; the members it gets from ComponentBase do not. + /// + private static IEnumerable Hierarchy(Type type) + { + for (var current = type; current is not null && current.Assembly == _assembly; current = current.BaseType) + { + yield return current; + } + } + + private static IEnumerable Properties(Type type, object? instance, object? twin) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var property in Hierarchy(type).SelectMany(t => t.GetProperties(Declared))) + { + if (property.GetIndexParameters().Length > 0 || IsHidden(property)) continue; + if (seen.Add(property.Name) is false) continue; + + var isParameter = property.IsDefined(typeof(ParameterAttribute), inherit: true); + + yield return new BdndApiMemberDto + { + Name = property.Name, + Kind = isParameter ? "Parameter" : "Property", + Type = FriendlyName(property.PropertyType), + Default = DefaultValueOf(property, instance, twin), + Required = property.IsDefined(typeof(EditorRequiredAttribute), inherit: true), + Summary = Documentation(property, BdndXmlDocs.GetSummary), + Remarks = Documentation(property, BdndXmlDocs.GetRemarks) + }; + } + } + + private static IEnumerable Fields(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var field in Hierarchy(type).SelectMany(t => t.GetFields(Declared))) + { + if (field.Name.Contains('<', StringComparison.Ordinal) || IsHidden(field)) continue; + if (seen.Add(field.Name) is false) continue; + + yield return new BdndApiMemberDto + { + Name = field.Name, + Kind = "Field", + Type = FriendlyName(field.FieldType), + Default = field.IsLiteral ? Format(field.GetRawConstantValue()) : null, + Summary = BdndXmlDocs.GetSummary(DocumentationId(field)), + Remarks = BdndXmlDocs.GetRemarks(DocumentationId(field)) + }; + } + } + + private static IEnumerable Methods(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var method in Hierarchy(type).SelectMany(t => t.GetMethods(Declared))) + { + if (method.IsSpecialName) continue; // property/event accessors and operators + if (method.Name.Contains('<', StringComparison.Ordinal)) continue; + if (method.DeclaringType == typeof(object)) continue; + // What the compiler writes for a record - Equals, GetHashCode, ToString, Deconstruct - is the + // same on every record and documented nowhere; listing it on each of the library's many records + // would bury the members someone wrote on purpose. + if (method.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)) continue; + if (IsHidden(method)) continue; + + // A generic method is named with its type parameters: IBdndMonitor.GetItem() and GetItem() + // are two members with the same parameter list, and without them the second would be dropped + // as a repeat of the first. + var name = method.IsGenericMethodDefinition ? $"{method.Name}<{string.Join(", ", method.GetGenericArguments().Select(argument => argument.Name))}>" : method.Name; + var signature = Signature(method); + if (seen.Add($"{name}{signature}") is false) continue; + + yield return new BdndApiMemberDto + { + Name = name, + Kind = "Method", + Type = FriendlyName(method.ReturnType), + Signature = signature, + Summary = Documentation(method, BdndXmlDocs.GetSummary), + Remarks = Documentation(method, BdndXmlDocs.GetRemarks) + }; + } + } + + private static IEnumerable Events(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var member in Hierarchy(type).SelectMany(t => t.GetEvents(Declared))) + { + if (IsHidden(member) || seen.Add(member.Name) is false) continue; + + yield return new BdndApiMemberDto + { + Name = member.Name, + Kind = "Event", + Type = FriendlyName(member.EventHandlerType ?? typeof(void)), + Summary = BdndXmlDocs.GetSummary(DocumentationId(member)), + Remarks = BdndXmlDocs.GetRemarks(DocumentationId(member)) + }; + } + } + + /// + /// A member the library hides from IntelliSense with [EditorBrowsable(Never)]. In Bit.Bdnd those + /// are the [JSInvokable] entry points its script module calls - HandleDragEnd, + /// HandleHoverChanged and the rest - which are public only because JS interop needs them to be. A + /// reference that listed them would invite an agent to call into the drag machinery from app code. + /// + private static bool IsHidden(MemberInfo member) + => member.GetCustomAttribute()?.State == System.ComponentModel.EditorBrowsableState.Never; + + /// + /// The documentation of a property, or - when it says no more than <inheritdoc />, as an + /// override or an interface implementation often does (BdndDragDropManager implements IBdndMonitor, + /// whose members are documented once, on the interface) - that of the member it overrides or + /// implements, which is what the compiler would show for it. + /// + private static string? Documentation(PropertyInfo property, Func read) + { + if (read(DocumentationId(property)) is string own) return own; + + var accessor = property.GetMethod ?? property.SetMethod; + var root = accessor?.GetBaseDefinition(); + + if (root is not null && root.DeclaringType != property.DeclaringType && + root.DeclaringType!.GetProperty(property.Name, Declared) is { } overridden && + read(DocumentationId(overridden)) is string inherited) return inherited; + + foreach (var contract in property.DeclaringType!.GetInterfaces()) + { + if (contract.GetProperty(property.Name) is { } declared && read(DocumentationId(declared)) is string implemented) return implemented; + } + + return null; + } + + /// The documentation of a method, falling back to the one it overrides or implements. + private static string? Documentation(MethodInfo method, Func read) + { + if (read(DocumentationId(method)) is string own) return own; + + var root = method.GetBaseDefinition(); + + if (root.DeclaringType != method.DeclaringType && read(DocumentationId(root)) is string inherited) return inherited; + + var owner = method.DeclaringType!; + if (owner.IsInterface) return null; + + foreach (var contract in owner.GetInterfaces()) + { + InterfaceMapping map; + + try + { + map = owner.GetInterfaceMap(contract); + } + catch (Exception) + { + // A generic or otherwise unmappable interface simply has nothing to lend. + continue; + } + + var index = Array.IndexOf(map.TargetMethods, method); + + if (index >= 0 && read(DocumentationId(map.InterfaceMethods[index])) is string implemented) return implemented; + } + + return null; + } + + /// + /// The value a property holds on a newly constructed instance - the default a caller gets when + /// the parameter is left unset. + /// + private static string? DefaultValueOf(PropertyInfo property, object? instance, object? twin) + { + if (property.GetMethod is null || property.GetMethod.IsPublic is false) return null; + if (property.GetMethod.IsStatic is false && instance is null) return null; + + try + { + if (property.GetMethod.IsStatic) return property.DeclaringType!.ContainsGenericParameters ? null : Format(property.GetValue(null)); + + var raw = ValueOf(property, instance!); + + // The manager is its own monitor: its Monitor property answers the instance itself, which + // is worth saying in so many words rather than as "new BdndDragDropManager()". + if (ReferenceEquals(raw, instance)) return "this"; + + var value = Format(raw); + + return twin is null || value == Format(ValueOf(property, twin)) ? value : null; + } + catch (Exception) + { + // Members that only make sense on a mounted component (or throw by design) simply have + // no observable default; that is not a reason to fail the whole type's reference. + return null; + } + } + + /// + /// A property's value on an instance. A generic type is documented open and instantiated closed + /// (see ), and a property of the open type cannot be read off the + /// closed one - so it is looked up again, by name, on the type the instance really has. + /// + private static object? ValueOf(PropertyInfo property, object instance) + { + var owner = instance.GetType(); + + var actual = property.DeclaringType!.ContainsGenericParameters + ? owner.GetProperty(property.Name, BindingFlags.Public | BindingFlags.Instance) + : property; + + return actual?.GetValue(instance); + } + + /// + /// A fresh instance to read defaults off, or null when there is no parameterless way to make one. + /// + /// Every component of Bit.Bdnd but the provider and the handle is generic over its item type, so a + /// generic type definition is closed over object first - which is what TItem="object" + /// in markup makes of it, and none of its defaults depends on the item type. + /// + /// + private static object? TryCreateInstance(Type type) + { + if (type.IsAbstract || type.IsInterface || type.IsEnum) return null; + + try + { + if (type.IsGenericTypeDefinition) type = type.MakeGenericType([.. type.GetGenericArguments().Select(_ => typeof(object))]); + + if (type.IsValueType is false && type.GetConstructor(Type.EmptyTypes) is null) return null; + + return Activator.CreateInstance(type); + } + catch (Exception) + { + // A constraint object does not satisfy, or a constructor that needs a running app: no defaults to read. + return null; + } + } + + private static string? Format(object? value) => value switch + { + null => null, + // An event parameter nobody has bound is not a value worth printing: "new EventCallback<...>()" + // on every OnBegin, OnEnd, OnDrop and OnHover says only that it starts out unset, as they all do. + _ when IsUnboundCallback(value) => null, + string text => $"\"{text}\"", + bool flag => flag ? "true" : "false", + Enum enumValue => $"{enumValue.GetType().Name}.{enumValue}", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + // A collection default is its contents: BdndNativeDropEventArgs.Files starts out as an empty + // list, and "new BdndNativeFile[]()" would say only that it is a list. + System.Collections.IDictionary dictionary => dictionary.Count == 0 + ? "{}" + : $"{{ {string.Join(", ", dictionary.Keys.Cast().Take(MaxListedItems).Select(key => $"{Format(key)} = {Format(dictionary[key]) ?? "null"}"))} }}", + System.Collections.IEnumerable items => $"[{string.Join(", ", items.Cast().Take(MaxListedItems).Select(item => Format(item) ?? "null"))}]", + _ => Describe(value) + }; + + private static bool IsUnboundCallback(object value) + { + var type = value.GetType(); + + var isCallback = type == typeof(EventCallback) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(EventCallback<>)); + + return isCallback && type.GetProperty(nameof(EventCallback.HasDelegate))?.GetValue(value) is false; + } + + /// The most items of a collection default that are spelled out; none of the library's comes close. + private const int MaxListedItems = 12; + + /// + /// A reference-typed default is worth reporting: a parameter that starts out holding an object is + /// not the same thing as one that starts out null. + /// + private static string Describe(object value) + { + var type = value.GetType(); + + // A default that is one of the type's own named values reads best by that name - a "Zero" or + // an "Empty" - rather than as a dump of its fields. + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType == type && field.IsInitOnly | field.IsLiteral && Equals(field.GetValue(null), value)) return $"{FriendlyName(type)}.{field.Name}"; + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (property.PropertyType != type || property.GetIndexParameters().Length > 0) continue; + + try + { + if (Equals(property.GetValue(null), value)) return $"{FriendlyName(type)}.{property.Name}"; + } + catch (Exception) + { + // A static property that throws simply names nothing. + } + } + + var text = value.ToString(); + + // A ToString that is only the type's name says nothing about the value - and for a generic + // type it is the CLR spelling ("EventCallback`1[...]") no C# reader would recognise. + return string.IsNullOrEmpty(text) || text == type.FullName || text == type.Name || text.Contains('`', StringComparison.Ordinal) + ? $"new {FriendlyName(type)}()" + : text; + } + + private static string Signature(MethodInfo method) + { + var builder = new StringBuilder("("); + + var parameters = method.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + if (i > 0) builder.Append(", "); + if (i == 0 && method.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false)) builder.Append("this "); + if (parameter.IsOut) builder.Append("out "); + else if (parameter.ParameterType.IsByRef) builder.Append("ref "); + + builder.Append(FriendlyName(parameter.ParameterType)).Append(' ').Append(parameter.Name); + + if (parameter.HasDefaultValue) builder.Append(" = ").Append(Format(parameter.DefaultValue) ?? "null"); + } + + return builder.Append(')').ToString(); + } + + private static string KindOf(Type type) + { + if (type.IsEnum) return "Enum"; + // Before the IComponent test: an interface that extends IComponent is still an interface. + if (type.IsInterface) return "Interface"; + if (typeof(IComponent).IsAssignableFrom(type)) return "Component"; + if (typeof(Attribute).IsAssignableFrom(type)) return "Attribute"; + if (typeof(MulticastDelegate).IsAssignableFrom(type)) return "Delegate"; + if (type.IsAbstract && type.IsSealed) return "Static class"; + if (type.IsValueType) return "Struct"; + if (type.GetMethod("$", BindingFlags.Public | BindingFlags.Instance) is not null) return "Record"; + + return "Class"; + } + + private static int KindOrder(string kind) => kind switch + { + "Parameter" => 0, + "Property" => 1, + "Field" => 2, + "EnumValue" => 3, + "Method" => 4, + "Event" => 5, + _ => 6 + }; + + /// The C# spelling of a type: "int?", "string[]", "Func<TItem?, IBdndMonitor, bool>". + public static string FriendlyName(Type type) + { + if (type.IsByRef) return FriendlyName(type.GetElementType()!); + if (type.IsArray) return $"{FriendlyName(type.GetElementType()!)}[]"; + + var nullable = Nullable.GetUnderlyingType(type); + if (nullable is not null) return $"{FriendlyName(nullable)}?"; + + if (type == typeof(void)) return "void"; + if (type == typeof(object)) return "object"; + if (type == typeof(string)) return "string"; + if (type == typeof(bool)) return "bool"; + if (type == typeof(int)) return "int"; + if (type == typeof(long)) return "long"; + if (type == typeof(double)) return "double"; + if (type == typeof(float)) return "float"; + if (type == typeof(decimal)) return "decimal"; + + if (type.IsGenericType is false) return type.Name; + + var arguments = string.Join(", ", type.GetGenericArguments().Select(FriendlyName)); + + return $"{StripArity(type.Name)}<{arguments}>"; + } + + private static string StripArity(string name) + { + var arity = name.IndexOf('`', StringComparison.Ordinal); + + return arity < 0 ? name : name[..arity]; + } + + private static string DocumentationId(Type type) => $"T:{type.FullName}"; + + private static string DocumentationId(PropertyInfo property) => $"P:{property.DeclaringType!.FullName}.{property.Name}"; + + private static string DocumentationId(FieldInfo field) => $"F:{field.DeclaringType!.FullName}.{field.Name}"; + + private static string DocumentationId(EventInfo member) => $"E:{member.DeclaringType!.FullName}.{member.Name}"; + + private static string DocumentationId(MethodInfo method) + { + var id = new StringBuilder("M:").Append(method.DeclaringType!.FullName).Append('.').Append(method.Name); + + if (method.IsGenericMethodDefinition) id.Append("``").Append(method.GetGenericArguments().Length); + + var parameters = method.GetParameters(); + if (parameters.Length == 0) return id.ToString(); + + id.Append('(') + .Append(string.Join(',', parameters.Select(p => DocumentationTypeName(p.ParameterType)))) + .Append(')'); + + return id.ToString(); + } + + /// Spells a type the way the C# compiler spells it inside a documentation id. + private static string DocumentationTypeName(Type type) + { + if (type.IsByRef) return $"{DocumentationTypeName(type.GetElementType()!)}@"; + if (type.IsArray) return $"{DocumentationTypeName(type.GetElementType()!)}[]"; + if (type.IsGenericParameter) return type.DeclaringMethod is null ? $"`{type.GenericParameterPosition}" : $"``{type.GenericParameterPosition}"; + + if (type.IsGenericType is false) return type.FullName ?? type.Name; + + var definition = type.GetGenericTypeDefinition().FullName ?? type.Name; + var arguments = string.Join(',', type.GetGenericArguments().Select(DocumentationTypeName)); + + return $"{StripArity(definition)}{{{arguments}}}"; + } +} diff --git a/src/Bdnd/Demo/Server/Services/BdndCompletions.cs b/src/Bdnd/Demo/Server/Services/BdndCompletions.cs new file mode 100644 index 00000000000..97856a9417d --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndCompletions.cs @@ -0,0 +1,93 @@ +using ModelContextProtocol.Protocol; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Answers completion/complete: the values a client offers while someone fills in a prompt +/// argument or a resource template's placeholder. +/// +/// The protocol allows this for two things only - a prompt's arguments and a resource template's +/// placeholders - and both of those are, on this server, keys into a closed set that nothing on the +/// wire spells out: a guide heading, a public type name, an embedded source path. Without +/// completions a person picking bdnd://api/{typeName} in a client has to go and call a +/// listing tool first, just to learn what a type name looks like. +/// +/// +public static class BdndCompletions +{ + /// The protocol caps one response at 100 values; anything past that is reported through hasMore. + private const int MaxValues = 100; + + // Freeform arguments cannot be completed, only started off: these are the phrasings the matching + // prompt was written for, so picking one lands in the workflow it was designed around instead of + // in a sentence the tools have nothing to say about. + private static readonly string[] _symptoms = + [ + "nothing can be dragged at all", + "the item drags but the drop target never lights up", + "OnDrop never runs when I drop on the target", + "OnDrop runs on the outer target as well as the inner one", + "the no-drop cursor shows in the gaps between list items", + "OnEnd reports DidDrop = false although I dropped on the list", + "InvalidOperationException: must be placed inside a BdndProvider", + "Cannot provide a value for property 'Interop'", + "files dropped from the desktop open in the browser instead", + "my custom drag preview flickers or blocks the drop", + ]; + + private static readonly string[] _features = + [ + "a kanban board whose cards move between columns", + "a sortable list reordered by dragging", + "drop files from the desktop onto an upload zone", + "a custom drag preview that follows the pointer", + "drag items only by a grip handle", + "nested drop targets that highlight only the innermost one", + "drag tools from a palette onto a canvas, copying them", + "a chessboard that highlights the legal squares while a piece is dragged", + "snap an item back when it is dropped outside the list", + ]; + + /// + /// The values matching what has been typed so far. The argument's name is enough to identify the + /// set - no two arguments on this server mean different things by the same name - so one table + /// serves a prompt argument and a resource template placeholder without either knowing about the + /// other, and an argument it has nothing for is answered with an empty list rather than an error. + /// + public static Completion Complete(string? argumentName, string? typed) + { + var candidates = CandidatesFor(argumentName); + if (candidates.Length == 0) return new Completion { Values = [] }; + + var prefix = (typed ?? string.Empty).Trim(); + + var matches = prefix.Length == 0 + ? candidates + : [.. candidates + .Where(value => value.Contains(prefix, StringComparison.OrdinalIgnoreCase)) + // What was typed is a prefix far more often than it is a substring, so the values it + // starts stay above the ones it merely appears inside. + .OrderByDescending(value => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ThenBy(value => value, StringComparer.OrdinalIgnoreCase)]; + + return new Completion + { + Values = [.. matches.Take(MaxValues)], + Total = matches.Length, + HasMore = matches.Length > MaxValues + }; + } + + // "renderMode" is deliberately missing here: its values are declared with [AllowedValues], which + // the SDK completes from on its own, and anything this returned would be merged with that rather + // than replace it - the same value offered to someone twice. + private static string[] CandidatesFor(string? argumentName) => argumentName switch + { + "heading" => [.. BdndSourceCatalog.GuideSections.Select(section => section.Key).Distinct(StringComparer.Ordinal)], + "typeName" => [.. BdndApiCatalog.Types.Select(type => type.Name)], + "path" => [.. BdndSourceCatalog.SourceFiles.Select(file => file.Path)], + "symptom" => _symptoms, + "feature" => _features, + _ => [] + }; +} diff --git a/src/Bdnd/Demo/Server/Services/BdndDropHost.cs b/src/Bdnd/Demo/Server/Services/BdndDropHost.cs new file mode 100644 index 00000000000..d8b7289c188 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndDropHost.cs @@ -0,0 +1,487 @@ +// A Renderer is the framework's own extension point for running components outside a browser - +// bUnit and the static HTML renderer are built on it - and it lives in the RenderTree namespace the +// analyzer guards against everyday use. Hosting the real BdndProvider, BdndDraggable and +// BdndDropTarget is the whole point here. +#pragma warning disable BL0006 + +using Microsoft.JSInterop; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Runs one drag through the real components, in-process and with no browser behind them: a +/// holding a and a tree of +/// s, rendered by a renderer of its own. +/// +/// Every decision that matters lives inside those components and their manager, and most of it is +/// private - whether a target accepts the item, which hovered target is the shallow one, which +/// OnDrop runs and in what order, which Result survives. Driving the components is the +/// only way to answer "what happens when I drop here?" without writing a second copy of those rules, +/// and a second copy is exactly what drifts: it would keep describing a drag the shipped library has +/// since learnt to handle differently. +/// +/// +/// The browser half is a stand-in for bit-bdnd.js, and it does only what the script does: +/// it hands back a registration for each component and keeps the options the component pushes to it, +/// tags the drag's DataTransfer with the draggable's own type (only when the draggable can +/// drag), calls HandleHoverChanged on every target under the pointer, innermost first, as +/// dragenter bubbles, lets the browser fire drop only when some hovered target accepted the +/// dragover - the script's applyDropEffect - and reports the end of the drag through +/// HandleDragEnd. The managed events, ondragstart and ondrop, are dispatched through +/// the renderer itself, to the handlers the components rendered, innermost first as Blazor's own event +/// delegation bubbles them. The registration is not an in-process one, so the components take the code +/// path they take on Blazor Server and in a BlazorWebView; nothing a scenario can express differs on +/// WebAssembly. +/// +/// +internal static class BdndDropHost +{ + /// + /// The prefix of the DataTransfer type a draggable's registration tags its drags with - the + /// draggable's handler id follows it. The same constant as the library's (internal) one. + /// + public const string SourceTypePrefix = "application/x-bit-bdnd-"; + + /// The attribute each rendered component carries, so its element and its registration can be told apart. + private const string LabAttribute = "data-bdnd-lab"; + + private const string DraggableKey = "draggable"; + + /// A scenario runs in milliseconds; this is only there so a component that never settles cannot hold a request forever. + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(15); + + /// One drop target of the scenario, as it is rendered. + public sealed class Target + { + /// The target's position in document order, which is what keys it - ids may repeat. + public required int Index { get; init; } + + public required string Id { get; init; } + + public Target? Parent { get; init; } + + public required int Depth { get; init; } + + /// The item types handed to AcceptTypes, native names already resolved. + public required string[] Accept { get; init; } + + /// False to render the target with a CanDropPredicate that answers false. + public bool CanDrop { get; init; } = true; + + /// What its OnDrop sets as args.Result; null sets nothing. + public string? Result { get; init; } + + public List Children { get; } = []; + + public string Key => $"t{Index}"; + } + + /// + /// What is dragged: an in-app item of started by a draggable, or - + /// with a - a drag from outside the app carrying that DataTransfer type. + /// + public sealed record Drag(string ItemType, string? NativeToken, bool CanDrag, string DropEffect); + + /// One target's collected state while the item hovered, and what its callbacks saw. + public sealed record TargetOutcome(bool CanDrop, bool IsOver, bool IsOverShallow); + + /// One OnDrop (or OnNativeDrop) that ran. + public sealed record DropCall(Target Target, bool InnerAlreadyDropped, string? Result, string? NativeItemType); + + /// Everything one drag reported. + public sealed record Outcome( + bool DragStarted, + bool BeginFired, + string? EffectAllowed, + string[] DataTransferTypes, + bool DropFired, + IReadOnlyList Drops, + BdndDragEndContext? End, + IReadOnlyDictionary Targets, + IReadOnlyList Exceptions); + + /// + /// Renders the scenario and plays the drag: start it, hover the + /// (outermost first), drop, end it. + /// + public static async Task RunAsync(Drag drag, IReadOnlyList roots, IReadOnlyList hoverChain) + { + var recorder = new Recorder(); + var js = new LabJSRuntime(); + + await using var renderer = new LabRenderer(new ServiceProviderStub(js, new BdndInterop(js))); + + Outcome? outcome = null; + + await renderer.Dispatcher.InvokeAsync(async () => + { + await renderer.RenderAsync(Content(drag, roots, recorder)); + + // dragenter fires on the element under the pointer first and bubbles out from there. + var innermostFirst = hoverChain.Reverse().ToArray(); + + outcome = drag.NativeToken is { } token + ? await PlayNativeAsync(token, innermostFirst, js, recorder, renderer) + : await PlayInAppAsync(innermostFirst, js, recorder, renderer); + }).WaitAsync(_timeout); + + return outcome!; + } + + private static async Task PlayInAppAsync(Target[] innermostFirst, LabJSRuntime js, Recorder recorder, LabRenderer renderer) + { + var options = js.DraggableRegistration?.Options; + var handlerId = Option(options, "Id"); + var effectAllowed = Option(options, "EffectAllowed"); + + // The script only tags a drag - and so only lets the draggable recognise it as its own - when + // the registration says it can drag; a draggable that cannot renders draggable="false" anyway. + string[] types = Option(options, "CanDrag") && handlerId is not null ? [SourceTypePrefix + handlerId] : []; + + if (renderer.FindEventHandler(DraggableKey, "ondragstart") is { } dragStart) + { + await renderer.DispatchAsync(dragStart, DragEvent("dragstart", types, effectAllowed)); + } + + await Task.Yield(); + + if (recorder.DragState?.IsDragging is not true) + { + return new Outcome(false, recorder.BeginFired, effectAllowed, types, false, [], null, Snapshot(recorder), [.. renderer.Exceptions]); + } + + foreach (var target in innermostFirst) + { + if (js.TargetOf(target) is { } registered) await registered.Component.HandleHoverChanged(true, types); + } + + await Task.Yield(); + + var hovering = Snapshot(recorder); + + // The browser fires drop only if some target under the pointer accepted the dragover - what the + // script decides from the CanDrop the target last pushed to it - and never under effectAllowed + // "none", which turns every drop operation off. + var dropFired = effectAllowed != "none" && innermostFirst.Any(target => Option(js.TargetOf(target)?.Registration.Options, "CanDrop")); + + if (dropFired) + { + foreach (var target in innermostFirst) + { + if (renderer.FindEventHandler(target.Key, "ondrop") is { } drop) + { + await renderer.DispatchAsync(drop, DragEvent("drop", types, effectAllowed)); + } + } + } + + // dragend reaches the source whether or not anything was dropped. + if (js.Draggable is { } draggable) await draggable.HandleDragEnd(); + + await Task.Yield(); + + return new Outcome(true, recorder.BeginFired, effectAllowed, types, dropFired, [.. recorder.Drops], recorder.End, hovering, [.. renderer.Exceptions]); + } + + private static async Task PlayNativeAsync(string token, Target[] innermostFirst, LabJSRuntime js, Recorder recorder, LabRenderer renderer) + { + string[] types = [token]; + + foreach (var target in innermostFirst) + { + if (js.TargetOf(target) is { } registered) await registered.Component.HandleHoverChanged(true, types); + } + + await Task.Yield(); + + var hovering = Snapshot(recorder); + + // The script accepts a native dragover on a target whose registration lists the token, and + // hands the drop to every such target the drop event bubbles through - it does not stop at the + // first one. + var accepting = innermostFirst.Where(target => Option(js.TargetOf(target)?.Registration.Options, "NativeTypes")?.Contains(token) is true).ToArray(); + + foreach (var target in accepting) + { + if (js.TargetOf(target) is { } registered) await registered.Component.HandleNativeDrop(NativePayload(token)); + } + + await Task.Yield(); + + return new Outcome(false, false, null, types, accepting.Length > 0, [.. recorder.Drops], null, hovering, [.. renderer.Exceptions]); + } + + /// What the script reads out of a native drop, for each kind of native drag. + private static BdndNativeDropPayload NativePayload(string token) => token switch + { + "Files" => new() { Types = [token], Files = [new() { Name = "report.pdf", Size = 48_213, Type = "application/pdf" }] }, + "text/uri-list" => new() { Types = [token], Data = new() { [token] = "https://bitplatform.dev" } }, + "text/html" => new() { Types = [token], Data = new() { [token] = "bit" } }, + _ => new() { Types = [token], Data = new() { [token] = "bit" } }, + }; + + private static DragEventArgs DragEvent(string type, string[] types, string? effectAllowed) => new() + { + Type = type, + ClientX = 120, + ClientY = 80, + OffsetX = 20, + OffsetY = 10, + DataTransfer = new DataTransfer { Types = types, EffectAllowed = effectAllowed, DropEffect = "none", Files = [], Items = [] } + }; + + private static Dictionary Snapshot(Recorder recorder) + => recorder.States.ToDictionary(state => state.Key, state => new TargetOutcome(state.Value.CanDrop, state.Value.IsOver, state.Value.IsOverShallow)); + + /// Reads one member of the options a component pushed to its registration - internal records of the library. + private static T? Option(object? options, string name) + => options?.GetType().GetProperty(name)?.GetValue(options) is T value ? value : default; + + /// The provider, the draggable (unless the drag comes from outside the app) and the targets, as markup would declare them. + private static RenderFragment Content(Drag drag, IReadOnlyList roots, Recorder recorder) => builder => + { + if (drag.NativeToken is null) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, nameof(BdndDraggable.Type), drag.ItemType); + builder.AddComponentParameter(2, nameof(BdndDraggable.Item), drag.ItemType); + builder.AddComponentParameter(3, nameof(BdndDraggable.CanDrag), drag.CanDrag); + builder.AddComponentParameter(4, nameof(BdndDraggable.DropEffect), drag.DropEffect); + builder.AddComponentParameter(5, nameof(BdndDraggable.OnBegin), EventCallback.Factory.Create(recorder, _ => { recorder.BeginFired = true; })); + builder.AddComponentParameter(6, nameof(BdndDraggable.OnEnd), EventCallback.Factory.Create>(recorder, context => { recorder.End = context; })); + builder.AddComponentParameter(7, LabAttribute, DraggableKey); + builder.AddComponentParameter(8, nameof(BdndDraggable.ChildContent), (RenderFragment)(state => _ => { recorder.DragState = state; })); + builder.CloseComponent(); + } + + foreach (var target in roots) RenderTarget(builder, target, recorder); + }; + + private static void RenderTarget(RenderTreeBuilder builder, Target target, Recorder recorder) + { + // A region per target, keyed by the target: the scenario is a loop over data, as a page's markup + // would be, so the sequence numbers inside stay the literals the analyzer wants. + builder.OpenRegion(0); + builder.OpenComponent>(0); + builder.SetKey(target.Index); + builder.AddComponentParameter(1, nameof(BdndDropTarget.AcceptTypes), target.Accept); + + if (target.CanDrop is false) + { + builder.AddComponentParameter(2, nameof(BdndDropTarget.CanDropPredicate), (Func)((_, _) => false)); + } + + builder.AddComponentParameter(3, nameof(BdndDropTarget.OnDrop), EventCallback.Factory.Create>(recorder, args => + { + recorder.Drops.Add(new DropCall(target, args.Monitor.DidDrop(), target.Result, null)); + + if (target.Result is not null) args.Result = target.Result; + })); + builder.AddComponentParameter(4, nameof(BdndDropTarget.OnNativeDrop), EventCallback.Factory.Create(recorder, args => + { + recorder.Drops.Add(new DropCall(target, false, null, args.ItemType)); + })); + builder.AddComponentParameter(5, LabAttribute, target.Key); + builder.AddComponentParameter(6, nameof(BdndDropTarget.ChildContent), (RenderFragment>)(state => content => + { + // The state a page would render from - kept per target, the latest render winning. + recorder.States[target.Index] = state; + + foreach (var child in target.Children) RenderTarget(content, child, recorder); + })); + builder.CloseComponent(); + builder.CloseRegion(); + } + + /// What the components handed their content and their callbacks while the drag ran. + private sealed class Recorder + { + public BdndDragState? DragState { get; set; } + + public Dictionary> States { get; } = []; + + public bool BeginFired { get; set; } + + public BdndDragEndContext? End { get; set; } + + public List Drops { get; } = []; + } + + /// + /// The renderer: a render batch goes nowhere, and an exception is kept for the report instead + /// of tearing the request down - a component that throws on some scenario is a finding, not a crash. + /// + private sealed class LabRenderer(IServiceProvider services) : Renderer(services, NullLoggerFactory.Instance) + { + private readonly List _exceptions = []; + private int _rootId; + + public IReadOnlyList Exceptions => _exceptions; + + public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); + + // Interactive, because that is the only way the components are ever meant to run: it is what + // a component asking ComponentBase.RendererInfo would be told in the browser. + protected override RendererInfo RendererInfo { get; } = new("BdndDropLab", isInteractive: true); + + public async Task RenderAsync(RenderFragment content) + { + var provider = InstantiateComponent(typeof(BdndProvider)); + _rootId = AssignRootComponentId(provider); + + await RenderRootComponentAsync(_rootId, ParameterView.FromDictionary(new Dictionary + { + [nameof(BdndProvider.ChildContent)] = content + })); + } + + public Task DispatchAsync(ulong eventHandlerId, EventArgs eventArgs) => DispatchEventAsync(eventHandlerId, null, eventArgs); + + /// + /// The id the renderer gave the handler of on the element of the + /// component marked . Read afresh before every dispatch: a re-render may + /// hand an element's handler a new id, and the old one then names nothing. + /// + public ulong? FindEventHandler(string key, string eventName) => Find(_rootId, key, eventName); + + private ulong? Find(int componentId, string key, string eventName) + { + var frames = GetCurrentRenderTreeFrames(componentId); + + for (var i = 0; i < frames.Count; i++) + { + var frame = frames.Array[i]; + + // A child component's output is a render tree of its own; the attribute frames that + // follow the component frame here are only the parameters it was given. + if (frame.FrameType == RenderTreeFrameType.Component) + { + if (Find(frame.ComponentId, key, eventName) is { } nested) return nested; + continue; + } + + if (frame.FrameType != RenderTreeFrameType.Element) continue; + + string? marked = null; + ulong handler = 0; + + // An element's attributes are the frames right after it. + for (var j = i + 1; j < frames.Count && frames.Array[j].FrameType == RenderTreeFrameType.Attribute; j++) + { + var attribute = frames.Array[j]; + + if (attribute.AttributeName == LabAttribute) marked = attribute.AttributeValue as string; + else if (attribute.AttributeName == eventName && attribute.AttributeEventHandlerId != 0) handler = attribute.AttributeEventHandlerId; + } + + if (marked == key && handler != 0) return handler; + } + + return null; + } + + protected override void HandleException(Exception exception) => _exceptions.Add(exception); + + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; + } + + /// The components inject one service, BdndInterop, which needs one more, the JS runtime; nothing else is resolved. + private sealed class ServiceProviderStub(IJSRuntime js, BdndInterop interop) : IServiceProvider + { + public object? GetService(Type serviceType) + { + if (serviceType == typeof(BdndInterop)) return interop; + if (serviceType == typeof(IJSRuntime)) return js; + + return null; + } + } + + /// + /// Stands in for bit-bdnd.js. The module is imported once and every registration goes + /// through it; each one is answered with a that keeps the options the + /// component pushes, and the component behind it - reached the way the script reaches it, through + /// the DotNetObjectReference it was handed - is kept by the attribute it was rendered with. + /// + private sealed class LabJSRuntime : IJSRuntime, IJSObjectReference + { + private readonly Dictionary Component, Registration Registration)> _targets = new(StringComparer.Ordinal); + + public BdndDraggable? Draggable { get; private set; } + + public Registration? DraggableRegistration { get; private set; } + + public (BdndDropTarget Component, Registration Registration)? TargetOf(Target target) + => _targets.TryGetValue(target.Key, out var registered) ? registered : null; + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => InvokeAsync(identifier, CancellationToken.None, args); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + object? answer = identifier switch + { + // The module is this same object: one stand-in, one table of answers. + "import" => this, + "registerDragSource" => RegisterDragSource(args), + "registerDropTarget" => RegisterDropTarget(args), + _ => null + }; + + return ValueTask.FromResult(answer is TValue value ? value : default!); + } + + private Registration RegisterDragSource(object?[]? args) + { + var registration = new Registration(args?.ElementAtOrDefault(2)); + + Draggable = (args?.ElementAtOrDefault(1) as DotNetObjectReference>)?.Value; + DraggableRegistration = registration; + + return registration; + } + + private Registration RegisterDropTarget(object?[]? args) + { + var registration = new Registration(args?.ElementAtOrDefault(2)); + + if (args?.ElementAtOrDefault(1) is DotNetObjectReference> { Value: var component } && + component.AdditionalAttributes?.GetValueOrDefault(LabAttribute) is string key) + { + // A target registered again - its element re-created - replaces its old registration. + _targets[key] = (component, registration); + } + + return registration; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// + /// One registration of the script: update replaces the options it acts on, and everything + /// else - getDragStartRect included, which the draggable asks for once the drag is published - + /// answers with nothing, the way the script answers for an element it never measured. + /// + private sealed class Registration(object? options) : IJSObjectReference + { + public object? Options { get; private set; } = options; + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => InvokeAsync(identifier, CancellationToken.None, args); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + if (identifier == "update") Options = args?.ElementAtOrDefault(0); + + return ValueTask.FromResult(default(TValue)!); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/Bdnd/Demo/Server/Services/BdndDropLab.cs b/src/Bdnd/Demo/Server/Services/BdndDropLab.cs new file mode 100644 index 00000000000..9b68ed229d9 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndDropLab.cs @@ -0,0 +1,820 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bit.Bdnd.Demo.Server.Dtos; +using Target = Bit.Bdnd.Demo.Server.Services.BdndDropHost.Target; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Runs a drag scenario - a draggable, a tree of drop targets and where the pointer is - through the +/// library itself, so "which targets light up, which OnDrop runs, in what order, and what does +/// OnEnd get?" is answered by the code that decides it at runtime rather than by a reading of the +/// docs. +/// +/// The work is done by the real , and +/// (see ): each target's +/// CanDrop, IsOver and IsOverShallow are read off the state it hands its own child +/// content, and the drop order, the results and the end context are what its callbacks received. What +/// this class adds is only the reading of the scenario and the notes that say what each finding means +/// for the page being written - the near-miss type, the result an outer target overwrote, the drop that +/// fell through to an ancestor. +/// +/// +public static partial class BdndDropLab +{ + /// + /// The largest scenario this tool reads. A scenario arrives as a tool argument, and one past this + /// size is past anything a question about one drag needs. + /// + public const int MaxInputBytes = 256 * 1024; + + /// The most drop targets one scenario may declare. + public const int MaxTargets = 500; + + /// How deep targets may nest; a page nesting them deeper than this is not the page in question. + public const int MaxDepth = 32; + + /// The most items one note lists by id before it says how many more there are. + private const int MaxListed = 8; + + /// + /// The native drags, by the DataTransfer type the browser gives them - the table the README's + /// "Native drags" section documents, and the one the components match their accept lists against. + /// + private static readonly (string Constant, string Name, string Token, string Source)[] _nativeTypes = + [ + (BdndNativeTypes.File, nameof(BdndNativeTypes.File), "Files", "files dragged in from the desktop"), + (BdndNativeTypes.Url, nameof(BdndNativeTypes.Url), "text/uri-list", "a link dragged in from another page"), + (BdndNativeTypes.Text, nameof(BdndNativeTypes.Text), "text/plain", "selected text dragged in"), + (BdndNativeTypes.Html, nameof(BdndNativeTypes.Html), "text/html", "HTML dragged in from another page"), + ]; + + /// The values dataTransfer.effectAllowed takes; the browser ignores anything else, case included. + private static readonly string[] _effects = ["none", "copy", "copyLink", "copyMove", "link", "linkMove", "move", "all"]; + + private static readonly string[] _rootProperties = ["draggable", "targets", "hover"]; + private static readonly string[] _draggableProperties = ["type", "canDrag", "dropEffect"]; + private static readonly string[] _targetProperties = ["id", "accept", "acceptTypes", "canDrop", "result", "children"]; + + private static readonly JsonDocumentOptions _options = new() + { + // Each nested target costs two levels - its object and its children array. + MaxDepth = MaxDepth * 2 + 8, + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + + private static readonly Lazy _sample = new(BuildSample); + + /// + /// A kanban scenario that shows the rules worth knowing in one drag: nested targets dropped on + /// innermost first, shallow hover on the innermost only, an outer result overriding an inner one, + /// a target whose CanDropPredicate says no, and one that only takes files. + /// + public static string Sample => _sample.Value; + + public static async Task InspectAsync(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new BdndDropReportDto + { + Valid = false, + InputBytes = 0, + Sample = Sample, + Notes = + [ + "No scenario was passed, so nothing was run. Sample is a kanban board: a 'card' hovered over a card that is itself a " + + "drop target, inside a column, inside the board - plus a column whose CanDropPredicate says no and an attachments zone " + + "that only takes files. Pass it, or your own page's draggable and targets in the same shape, as json to see the report. " + + "A target takes 'id', 'accept' (a type or an array; BdndNativeTypes.File and friends for native drags), 'acceptTypes', " + + "'canDrop' (false emulates a CanDropPredicate answering false), 'result' (what its OnDrop sets as args.Result) and " + + "'children'; 'hover' is the id of the target under the pointer, whose ancestors are hovered with it. A draggable whose " + + "type is a native one ('BdndNativeTypes.File') is a drag from outside the app." + ] + }; + } + + var inputBytes = Encoding.UTF8.GetByteCount(json); + + if (inputBytes > MaxInputBytes) + { + return Invalid(inputBytes, $"The scenario is {inputBytes} bytes; this tool reads scenarios of up to {MaxInputBytes} bytes."); + } + + JsonDocument document; + + try + { + document = JsonDocument.Parse(json, _options); + } + catch (JsonException exception) + { + return Invalid(inputBytes, exception.Message); + } + + using (document) + { + var readingNotes = new List(); + + var error = ReadScenario(document.RootElement, readingNotes, out var scenario); + + if (error is not null) return Invalid(inputBytes, error); + + BdndDropHost.Outcome outcome; + + try + { + outcome = await BdndDropHost.RunAsync(scenario!.Drag, scenario.Roots, scenario.HoverChain); + } + catch (TimeoutException) + { + return Invalid(inputBytes, "The drag did not settle in time - the components never finished reacting to it. That is a bug in the library or in this tool, not in the scenario."); + } + + return Report(inputBytes, scenario, outcome, readingNotes); + } + } + + private static BdndDropReportDto Invalid(int inputBytes, string error) => new() + { + Valid = false, + Error = error, + InputBytes = inputBytes, + Notes = [] + }; + + // ---- Reading the scenario --------------------------------------------------------- + + private sealed record Scenario(BdndDropHost.Drag Drag, string RawType, List Roots, List All, List HoverChain, bool HoverGiven); + + /// Reads the scenario, or says - with the JSON path - what is wrong with it. + private static string? ReadScenario(JsonElement root, List readingNotes, out Scenario? scenario) + { + scenario = null; + + if (root.ValueKind != JsonValueKind.Object) + { + return "A scenario is a JSON object with a \"draggable\", a \"targets\" array and a \"hover\" - call InspectBdndDrop with no json for a sample."; + } + + NoteUnknownProperties(root, _rootProperties, "the scenario", readingNotes); + + if (TryGet(root, "draggable", out var draggable) is false || draggable.ValueKind != JsonValueKind.Object) + { + return TryGet(root, "type", out _) + ? "The draggable goes in a \"draggable\" object: { \"draggable\": { \"type\": \"card\" }, \"targets\": [...], \"hover\": \"...\" }." + : "The scenario needs a \"draggable\" object with the item's \"type\" - the BdndDraggable's Type, e.g. { \"type\": \"card\" }."; + } + + NoteUnknownProperties(draggable, _draggableProperties, "draggable", readingNotes); + + if (TryGet(draggable, "type", out var typeElement) is false || typeElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(typeElement.GetString())) + { + return "draggable.type is required: it is BdndDraggable.Type ([EditorRequired]), the string every drop target matches against its Accept list."; + } + + var rawType = typeElement.GetString()!; + var resolvedType = Resolve(rawType); + var native = Native(resolvedType); + + var canDrag = true; + if (TryGet(draggable, "canDrag", out var canDragElement) && canDragElement.ValueKind != JsonValueKind.Null) + { + if (canDragElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return "draggable.canDrag must be true or false - it is BdndDraggable.CanDrag."; + + canDrag = canDragElement.GetBoolean(); + } + + var dropEffect = "move"; + if (TryGet(draggable, "dropEffect", out var effectElement) && effectElement.ValueKind != JsonValueKind.Null) + { + if (effectElement.ValueKind != JsonValueKind.String) return $"draggable.dropEffect must be a string, one of: {string.Join(", ", _effects.Where(effect => effect != "none"))}."; + + dropEffect = effectElement.GetString()!; + } + + if (native is not null && (TryGet(draggable, "canDrag", out _) || TryGet(draggable, "dropEffect", out _))) + { + readingNotes.Add("canDrag and dropEffect were ignored: they configure a BdndDraggable, and a native drag comes from outside the app, where no BdndDraggable is involved."); + } + + var all = new List(); + var roots = new List(); + + if (TryGet(root, "targets", out var targets) && targets.ValueKind != JsonValueKind.Null) + { + if (targets.ValueKind != JsonValueKind.Array) return "\"targets\" must be an array of drop targets, each { \"id\": ..., \"accept\": [...], \"children\": [...] }."; + + var error = ReadTargets(targets, "targets", null, 0, all, roots, readingNotes); + if (error is not null) return error; + } + + var hoverGiven = TryGet(root, "hover", out var hover) && hover.ValueKind != JsonValueKind.Null; + var hoverError = ResolveHover(hoverGiven ? hover : default, hoverGiven, all, out var hovered); + + if (hoverError is not null) return hoverError; + + var chain = new List(); + for (var target = hovered; target is not null; target = target.Parent) chain.Insert(0, target); + + scenario = new Scenario( + new BdndDropHost.Drag(resolvedType, native?.Token, canDrag, dropEffect), + rawType, roots, all, chain, hoverGiven); + + return null; + } + + private static string? ReadTargets(JsonElement array, string path, Target? parent, int depth, List all, List siblings, List readingNotes) + { + if (depth >= MaxDepth) return $"{path} nests targets {depth + 1} deep; this tool runs scenarios nested up to {MaxDepth} deep."; + + var position = 0; + + foreach (var element in array.EnumerateArray()) + { + var at = $"{path}[{position++}]"; + + if (element.ValueKind != JsonValueKind.Object) return $"{at} is not a drop target: a target is an object, {{ \"id\": \"...\", \"accept\": [...] }}."; + + if (all.Count >= MaxTargets) return $"The scenario declares more than {MaxTargets} targets; this tool runs up to {MaxTargets} of them."; + + NoteUnknownProperties(element, _targetProperties, at, readingNotes); + + if (TryGet(element, "id", out var idElement) is false || idElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(idElement.GetString())) + { + return $"{at}.id is required: every target needs an id - it is what \"hover\" names and what the report lists."; + } + + var accept = new List(); + + if (TryGet(element, "accept", out var acceptElement)) + { + var error = ReadTypes(acceptElement, $"{at}.accept", allowSingle: true, accept); + if (error is not null) return error; + } + + if (TryGet(element, "acceptTypes", out var acceptTypesElement)) + { + var error = ReadTypes(acceptTypesElement, $"{at}.acceptTypes", allowSingle: false, accept); + if (error is not null) return error; + } + + var canDrop = true; + if (TryGet(element, "canDrop", out var canDropElement) && canDropElement.ValueKind != JsonValueKind.Null) + { + if (canDropElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return $"{at}.canDrop must be true or false; false renders the target with a CanDropPredicate that answers false."; + + canDrop = canDropElement.GetBoolean(); + } + + string? result = null; + if (TryGet(element, "result", out var resultElement) && resultElement.ValueKind != JsonValueKind.Null) + { + result = resultElement.ValueKind == JsonValueKind.String ? resultElement.GetString() : resultElement.GetRawText(); + } + + var target = new Target + { + Index = all.Count, + Id = idElement.GetString()!, + Parent = parent, + Depth = depth, + // The component combines Accept and AcceptTypes and drops duplicates; so does this. + Accept = [.. accept.Distinct(StringComparer.Ordinal)], + CanDrop = canDrop, + Result = result + }; + + all.Add(target); + siblings.Add(target); + + if (TryGet(element, "children", out var children) && children.ValueKind != JsonValueKind.Null) + { + if (children.ValueKind != JsonValueKind.Array) return $"{at}.children must be an array of drop targets."; + + var error = ReadTargets(children, $"{at}.children", target, depth + 1, all, target.Children, readingNotes); + if (error is not null) return error; + } + } + + return null; + } + + private static string? ReadTypes(JsonElement element, string path, bool allowSingle, List types) + { + if (element.ValueKind == JsonValueKind.Null) return null; + + if (element.ValueKind == JsonValueKind.String && allowSingle) + { + if (string.IsNullOrEmpty(element.GetString()) is false) types.Add(Resolve(element.GetString()!)); + return null; + } + + if (element.ValueKind != JsonValueKind.Array) + { + return allowSingle + ? $"{path} must be an item type or an array of them, e.g. \"card\" or [\"card\", \"BdndNativeTypes.File\"]." + : $"{path} must be an array of item types, e.g. [\"card\", \"column\"]."; + } + + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) return $"{path} holds {item.GetRawText()}, which is not an item type: every entry is a string."; + + // The component skips empty entries, and so does this. + if (string.IsNullOrEmpty(item.GetString()) is false) types.Add(Resolve(item.GetString()!)); + } + + return null; + } + + /// The target under the pointer: one id, or a path of ids from an outer target to it. + private static string? ResolveHover(JsonElement hover, bool given, List all, out Target? hovered) + { + hovered = null; + + if (given is false) return null; + + if (hover.ValueKind == JsonValueKind.String) + { + var id = hover.GetString()!; + if (id.Length == 0) return null; + + hovered = all.FirstOrDefault(target => target.Id == id); + + return hovered is null ? UnknownId(id, all) : null; + } + + if (hover.ValueKind != JsonValueKind.Array) return "\"hover\" is the id of the target under the pointer (or an array of ids, from an outer target to it), or null for a drop outside every target."; + + var path = new List(); + + foreach (var item in hover.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) return $"\"hover\" holds {item.GetRawText()}, which is not a target id."; + path.Add(item.GetString()!); + } + + if (path.Count == 0) return null; + + foreach (var id in path.Distinct(StringComparer.Ordinal)) + { + if (all.Any(target => target.Id == id) is false) return UnknownId(id, all); + } + + // The path may start anywhere above the hovered target; its ancestors further out are hovered too. + hovered = all.FirstOrDefault(target => target.Id == path[^1] && Matches(target, path)); + + return hovered is null + ? $"\"hover\" [{string.Join(", ", path.Select(id => $"'{id}'"))}] is not a chain of nested targets: each id has to be the parent of the next. Pass just the id of the target under the pointer - its ancestors are hovered with it." + : null; + + static bool Matches(Target target, List path) + { + var current = target; + + for (var i = path.Count - 1; i >= 0; i--, current = current.Parent) + { + if (current is null || current.Id != path[i]) return false; + } + + return true; + } + } + + private static string UnknownId(string id, List all) + { + var close = all.Select(target => target.Id).Distinct(StringComparer.Ordinal).Where(other => IsNearMiss(other, id)).Take(MaxListed).ToArray(); + + return close.Length > 0 + ? $"\"hover\" names '{id}', which is not the id of any target. Did you mean: {string.Join(", ", close.Select(other => $"'{other}'"))}?" + : $"\"hover\" names '{id}', which is not the id of any target. The ids are: {List(all.Select(target => target.Id).Distinct(StringComparer.Ordinal).ToArray())}."; + } + + private static void NoteUnknownProperties(JsonElement element, string[] known, string owner, List readingNotes) + { + foreach (var property in element.EnumerateObject()) + { + if (known.Contains(property.Name, StringComparer.OrdinalIgnoreCase)) continue; + + var close = known.FirstOrDefault(name => IsNearMiss(name, property.Name)); + + readingNotes.Add(close is null + ? $"'{property.Name}' is not a property of {owner} and was ignored; it takes {string.Join(", ", known)}." + : $"'{property.Name}' is not a property of {owner} and was ignored - did you mean '{close}'?"); + } + } + + // ---- Native types --------------------------------------------------------------- + + private static (string Constant, string Name, string Token, string Source)? Native(string type) + { + foreach (var native in _nativeTypes) + { + if (native.Constant == type) return native; + } + + return null; + } + + /// + /// A type as the scenario spells it, made into what the component is given: BdndNativeTypes.File + /// (as C# writes it) and native:file become the constant "__NATIVE_FILE__"; anything else is + /// an in-app type and stays exactly as written - a plain "file" is a type an app may well use for its own items. + /// + private static string Resolve(string type) + { + var match = NativeNameRegex().Match(type.Trim()); + if (match.Success is false) return type; + + var name = match.Groups["name"].Value; + + foreach (var native in _nativeTypes) + { + if (string.Equals(native.Name, name, StringComparison.OrdinalIgnoreCase)) return native.Constant; + } + + return type; + } + + [GeneratedRegex(@"^(?:(?:Bit\.Bdnd\.)?(?:Bdnd)?NativeTypes\.|native:)(?File|Url|Text|Html)$", RegexOptions.IgnoreCase)] + private static partial Regex NativeNameRegex(); + + // ---- The report ----------------------------------------------------------------- + + private static BdndDropReportDto Report(int inputBytes, Scenario scenario, BdndDropHost.Outcome outcome, List readingNotes) + { + var hovered = scenario.HoverChain.ToHashSet(); + var drops = outcome.Drops.Select((drop, index) => (Drop: drop, Order: index + 1)).ToDictionary(entry => entry.Drop.Target.Index); + + var targets = scenario.All.Select(target => + { + var state = outcome.Targets.GetValueOrDefault(target.Index) ?? new BdndDropHost.TargetOutcome(false, false, false); + var drop = drops.TryGetValue(target.Index, out var entry) ? entry : default; + + return new BdndDropTargetReportDto + { + Id = target.Id, + Parent = target.Parent?.Id, + Depth = target.Depth, + Accept = target.Accept, + Hovered = hovered.Contains(target), + CanDrop = state.CanDrop, + IsOver = state.IsOver, + IsOverShallow = state.IsOverShallow, + DropOrder = drop.Drop is null ? null : drop.Order, + InnerAlreadyDropped = drop.Drop is null || scenario.Drag.NativeToken is not null ? null : drop.Drop.InnerAlreadyDropped, + Result = drop.Drop?.Result, + NativeItemType = drop.Drop?.NativeItemType + }; + }).ToArray(); + + var notes = new List(); + + Explain(scenario, outcome, targets, notes); + + notes.AddRange(readingNotes); + + var native = scenario.Drag.NativeToken is not null; + + return new BdndDropReportDto + { + Valid = true, + InputBytes = inputBytes, + ItemType = scenario.Drag.ItemType, + Native = native, + DataTransferTypes = outcome.DataTransferTypes, + EffectAllowed = outcome.EffectAllowed, + DragStarted = native ? null : outcome.DragStarted, + BeginFired = native ? null : outcome.BeginFired, + HoverPath = [.. scenario.HoverChain.Select(target => target.Id)], + DropFired = outcome.DropFired, + DropOrder = [.. outcome.Drops.Select(drop => drop.Target.Id)], + End = outcome.End is { } end ? new BdndDragEndReportDto { DidDrop = end.DidDrop, DropResult = end.DropResult?.ToString() } : null, + Targets = targets, + Notes = [.. notes] + }; + } + + /// What the outcome means for the page being written - most important first. + private static void Explain(Scenario scenario, BdndDropHost.Outcome outcome, BdndDropTargetReportDto[] reports, List notes) + { + var shown = Quote(scenario.RawType); + var report = scenario.All.Zip(reports).ToDictionary(pair => pair.First, pair => pair.Second); + + foreach (var exception in outcome.Exceptions.Take(MaxListed)) + { + notes.Add($"Bit.Bdnd threw while running this drag - {exception.GetType().Name}: {exception.Message} That is a bug in the library, not in the scenario."); + } + + if (scenario.Drag.NativeToken is { } token) + { + ExplainNative(scenario, outcome, report, token, notes); + } + else if (outcome.DragStarted is false) + { + notes.Add(scenario.Drag.CanDrag + ? "The draggable did not recognise its own dragstart, so the drag never began. That is a bug in the library or in this tool, not in the scenario." + : "The drag never began: canDrag is false, so BdndDraggable renders draggable=\"false\" and its script registration tags no drag - " + + "the browser starts none, and OnBegin, every hover, OnDrop and OnEnd stay silent. A CanDragPredicate that answers false does the same."); + } + else + { + ExplainInApp(scenario, outcome, report, shown, notes); + } + + ExplainTypes(scenario, notes); + + foreach (var target in scenario.All.Where(target => target.Accept.Length == 0).Take(MaxListed)) + { + notes.Add($"'{target.Id}' accepts nothing (no accept or acceptTypes): it never reports IsOver or CanDrop and never gets OnDrop. Give it Accept or AcceptTypes."); + } + + foreach (var group in scenario.All.GroupBy(target => target.Id, StringComparer.Ordinal).Where(group => group.Count() > 1).Take(MaxListed)) + { + notes.Add($"The id '{group.Key}' is used by {group.Count()} targets; \"hover\" resolves it to the first one in document order. Give each target its own id."); + } + + if (scenario.Drag.NativeToken is null && outcome.EffectAllowed is { } effect) + { + if (effect == "none") + { + notes.Add("dropEffect is 'none', which turns every drop operation off: the browser shows the no-drop cursor everywhere and fires no drop event, so OnEnd gets DidDrop = false. Use move, copy, link, copyMove, copyLink, linkMove or all."); + } + else if (_effects.Contains(effect, StringComparer.Ordinal) is false) + { + var close = _effects.FirstOrDefault(value => string.Equals(value, effect, StringComparison.OrdinalIgnoreCase)); + + notes.Add($"dropEffect '{effect}' is not a value dataTransfer.effectAllowed takes{(close is null ? null : $" - it is case-sensitive: '{close}'")}, so the browser ignores the " + + "assignment and leaves effectAllowed 'uninitialized', which allows every effect. Use move, copy, link, copyMove, copyLink, linkMove or all."); + } + } + } + + private static void ExplainInApp(Scenario scenario, BdndDropHost.Outcome outcome, Dictionary report, string shown, List notes) + { + var type = scenario.Drag.ItemType; + var chain = scenario.HoverChain; + var end = outcome.End; + + if (chain.Count == 0) + { + notes.Add($"Nothing was hovered: the item was dropped outside every target. OnEnd ran with DidDrop = false and no DropResult - " + + "the place to snap the item back or, for 'drag out to remove', to delete it."); + } + else + { + var innermost = chain[^1]; + var fallback = chain.Take(chain.Count - 1).Reverse().FirstOrDefault(target => report[target].CanDrop); + + if (innermost.Accept.Contains(type, StringComparer.Ordinal) is false) + { + notes.Add($"The hovered target '{innermost.Id}' does not accept {shown} ({Accepts(innermost)}), so it stays IsOver = false and never gets OnDrop. " + + (fallback is null + ? "No target around it can drop the item either, so the browser shows the no-drop cursor and fires no drop event - OnEnd gets DidDrop = false." + : outcome.DropFired + ? $"Over it alone the browser would show the no-drop cursor; '{fallback.Id}' around it can drop the item, so the cursor follows the draggable's DropEffect and the drop falls through to '{fallback.Id}'." + : $"'{fallback.Id}' around it could take the item, but the draggable's dropEffect turns every drop off.")); + } + + foreach (var target in chain.Where(target => target.Accept.Contains(type, StringComparer.Ordinal) && report[target].CanDrop is false)) + { + var rest = outcome.DropFired is false + ? " No target in the hovered chain can drop the item, so the browser shows the no-drop cursor and fires no drop event." + : ReferenceEquals(target, innermost) ? $" The drop goes on to the targets around it that can drop it: {List(outcome.Drops.Select(drop => drop.Target.Id).ToArray())}." : null; + + notes.Add($"'{target.Id}' accepts {shown} but its CanDropPredicate answered false: it reports IsOver = {Bool(report[target].IsOver)} - hover is tracked by the type alone - " + + $"with CanDrop = false, so render the 'not here' state from IsOver && !CanDrop. Its OnDrop does not run.{rest}"); + } + } + + if (outcome.Drops.Count > 1) + { + var after = outcome.Drops.Where(drop => drop.InnerAlreadyDropped).Select(drop => drop.Target.Id).ToArray(); + + notes.Add($"OnDrop ran on every target in the hovered chain that can drop, innermost first: {string.Join(", ", outcome.Drops.Select(drop => drop.Target.Id))}. " + + $"Inside {(after.Length == 1 ? $"'{after[0]}'" : List(after))} args.Monitor.DidDrop() was already true - check it there when only the innermost target should act."); + } + + var published = outcome.Drops.Where(drop => drop.Result is not null).ToArray(); + + if (published.Length > 1 && published[^1].Result != published[^2].Result) + { + notes.Add($"'{published[^1].Target.Id}' published Result '{published[^1].Result}' after '{published[^2].Target.Id}' had published '{published[^2].Result}': a non-null Result " + + $"from an outer target replaces an inner one (React DnD's rule), so OnEnd received DropResult '{end?.DropResult}'. To keep the innermost result, leave " + + "args.Result unset in an outer OnDrop when args.Monitor.DidDrop() is already true."); + } + else if (end is { DidDrop: true, DropResult: null }) + { + notes.Add("OnEnd got DidDrop = true and DropResult = null: a target accepted the drop, but no OnDrop set args.Result. Set it where the source needs to know where the item went."); + } + + var overs = chain.Where(target => report[target].IsOver).ToArray(); + + if (overs.Length > 1) + { + var shallow = overs.Where(target => report[target].IsOverShallow).Select(target => target.Id).ToArray(); + var skipped = chain.Count > 0 && report[chain[^1]].IsOver is false ? $" (the hovered '{chain[^1].Id}' does not accept the type, so it does not count)" : null; + + notes.Add($"IsOver is true on every hovered target that accepts {shown} ({List(overs.Select(target => target.Id).ToArray())}), IsOverShallow only on " + + $"{List(shallow)} - the innermost of them{skipped}. Highlight from IsOverShallow when only the target directly under the pointer should light up."); + } + + var elsewhere = scenario.All.Where(target => chain.Contains(target) is false && report[target].CanDrop).Select(target => target.Id).ToArray(); + + if (elsewhere.Length > 0) + { + notes.Add($"{List(elsewhere)} {(elsewhere.Length == 1 ? "is" : "are")} not hovered and still report{(elsewhere.Length == 1 ? "s" : null)} CanDrop = true: CanDrop is about the item, " + + "not the pointer - it is how a chessboard highlights every legal square while the piece is in the air."); + } + + // Under effectAllowed "none" the reason is the effect, which has a note of its own. + if (chain.Count > 0 && end is { DidDrop: false } && outcome.EffectAllowed != "none") + { + notes.Add("OnEnd got DidDrop = false: no target in the hovered chain could drop the item, so to the draggable this drag was a cancel."); + } + } + + private static void ExplainNative(Scenario scenario, BdndDropHost.Outcome outcome, Dictionary report, string token, List notes) + { + var native = Native(scenario.Drag.ItemType)!.Value; + var chain = scenario.HoverChain; + + notes.Add($"{Quote(scenario.RawType)} is a native type (BdndNativeTypes.{native.Name}, DataTransfer type '{token}'): this is {native.Source}, which no BdndDraggable " + + "starts - so there is no OnBegin or OnEnd, and the drop arrives through OnNativeDrop, never OnDrop."); + + if (chain.Count == 0) + { + notes.Add("Nothing was hovered, so the drag left without a drop: OnNativeDrop ran nowhere."); + return; + } + + var lit = chain.Where(target => report[target].IsOver).Select(target => target.Id).ToArray(); + + if (lit.Length > 1) + { + notes.Add($"While a native drag hovers, every hovered target that accepts it reports IsOver, IsOverShallow and CanDrop as true ({List(lit)}): " + + "there is no shallow distinction for native drags."); + } + + var waiting = scenario.All.Where(target => chain.Contains(target) is false && target.Accept.Contains(native.Constant, StringComparer.Ordinal)).Select(target => target.Id).ToArray(); + + if (waiting.Length > 0) + { + notes.Add($"{List(waiting)} accept{(waiting.Length == 1 ? "s" : null)} it too but {(waiting.Length == 1 ? "is" : "are")} not hovered, and report{(waiting.Length == 1 ? "s" : null)} CanDrop = false: for a native " + + "drag the state only lights up under the pointer, because the browser does not say what is being dragged until it is over a target."); + } + + if (outcome.Drops.Count == 0) + { + notes.Add($"No hovered target accepts BdndNativeTypes.{native.Name}, so the browser shows the no-drop cursor and no drop happens - OnNativeDrop ran nowhere."); + } + else if (outcome.Drops.Count > 1) + { + notes.Add($"OnNativeDrop ran on every hovered target that accepts the drag, innermost first: {string.Join(", ", outcome.Drops.Select(drop => drop.Target.Id))}. " + + "Unlike OnDrop, a native drop carries no DidDrop telling an outer target that an inner one took it - accept the native type on one target of a chain."); + } + + if (scenario.All.Any(target => target.Result is not null)) + { + notes.Add("'result' was not published: it is what OnDrop sets as args.Result, and OnDrop never runs for a native drag."); + } + } + + /// Accept lists that will never match the dragged type although they nearly do. + private static void ExplainTypes(Scenario scenario, List notes) + { + var type = scenario.Drag.ItemType; + + if (scenario.All.Count > 0 && scenario.All.Any(target => target.Accept.Contains(type, StringComparer.Ordinal)) is false) + { + notes.Add($"No target accepts {Quote(type)}: a target reacts only to item types in its Accept/AcceptTypes, compared as strings, case included."); + } + + var listed = 0; + + foreach (var target in scenario.All) + { + foreach (var accepted in target.Accept.Where(accepted => accepted != type)) + { + string? why = null; + + if (scenario.Drag.NativeToken is not null && Native(type) is { } native && PlainNativeName(accepted, native.Name)) + { + why = $"'{target.Id}' accepts '{accepted}' - an in-app type name. A native drag only matches BdndNativeTypes.{native.Name} (\"{native.Constant}\"): write Accept=\"@BdndNativeTypes.{native.Name}\"."; + } + else if (IsNearMiss(accepted, type)) + { + why = $"'{target.Id}' accepts '{accepted}', which is not {Quote(type)}: item types are compared ordinally, case included - a typo here leaves the target dark for this item."; + } + + if (why is null) continue; + + notes.Add(why); + + if (++listed >= MaxListed) return; + } + } + } + + private static bool PlainNativeName(string accepted, string nativeName) + => accepted.TrimEnd('s').Equals(nativeName, StringComparison.OrdinalIgnoreCase) || (nativeName == "Url" && accepted.Equals("link", StringComparison.OrdinalIgnoreCase)); + + private static string Accepts(Target target) => target.Accept.Length == 0 ? "it accepts nothing" : $"it accepts {List(target.Accept)}"; + + private static string Quote(string value) => $"'{value}'"; + + private static string Bool(bool value) => value ? "true" : "false"; + + private static string List(IReadOnlyCollection items) + { + var shown = string.Join(", ", items.Take(MaxListed).Select(item => $"'{item}'")); + + return items.Count > MaxListed ? $"{shown} and {items.Count - MaxListed} more" : shown; + } + + /// + /// Two names close enough to be a typo of each other: the same but for case, or one or two edits + /// apart (one for short names) - "Card" for "card", "cards" for "card", "colum" for "column", and + /// two swapped letters count as one edit, "alpah" for "alpha". + /// + private static bool IsNearMiss(string a, string b) + { + if (string.Equals(a, b, StringComparison.Ordinal)) return false; + if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) return true; + + var shortest = Math.Min(a.Length, b.Length); + if (shortest < 3) return false; + + return Distance(a.ToLowerInvariant(), b.ToLowerInvariant()) <= (shortest < 6 ? 1 : 2); + } + + /// The optimal string alignment distance: insertions, deletions, substitutions and swaps of two neighbours. + private static int Distance(string a, string b) + { + var d = new int[a.Length + 1, b.Length + 1]; + + for (var i = 0; i <= a.Length; i++) d[i, 0] = i; + for (var j = 0; j <= b.Length; j++) d[0, j] = j; + + for (var i = 1; i <= a.Length; i++) + { + for (var j = 1; j <= b.Length; j++) + { + var cost = a[i - 1] == b[j - 1] ? 0 : 1; + + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + + if (i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + 1); + } + } + + return d[a.Length, b.Length]; + } + + private static bool TryGet(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string BuildSample() => """ + { + "draggable": { "type": "card" }, + "targets": [ + { + "id": "board", + "accept": ["card", "column"], + "result": "board", + "children": [ + { + "id": "todo", + "accept": "card", + "result": "todo", + "children": [ + { "id": "card-2", "accept": "card" } + ] + }, + { "id": "done", "accept": "card", "canDrop": false }, + { "id": "attachments", "accept": "BdndNativeTypes.File" } + ] + } + ], + "hover": "card-2" + } + """.ReplaceLineEndings("\n"); +} diff --git a/src/Bdnd/Demo/Server/Services/BdndSearchIndex.cs b/src/Bdnd/Demo/Server/Services/BdndSearchIndex.cs new file mode 100644 index 00000000000..998fcfc7f9c --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndSearchIndex.cs @@ -0,0 +1,527 @@ +using Bit.Bdnd.Demo.Server.Dtos; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// One searchable index over everything this MCP server knows: the reference guide, every public +/// type and member, the setup guides, the drop lab, the demo's pages and the rest of its source files. +/// +/// Without it an agent has to guess which corpus holds the answer and what it is called there - +/// "drop files from the desktop" is a guide section, a drop target parameter, a constant and a demo +/// page all at once. Each hit therefore carries the exact follow-up tool call that returns the full +/// text, so one search is enough to know what to ask for next. +/// +/// +public static class BdndSearchIndex +{ + private sealed record Entry(string Kind, string Title, string? Context, string Tool, string Body, string Boosted, int Weight = ReferenceWeight, bool SharesAnswer = false, string? Name = null) + { + /// + /// The title split into words, camel-case humps included, so "IsOverShallow" is found by + /// "is over shallow" - and so a query word only counts as a title hit when it IS one of those + /// words, rather than merely appearing inside one ("drag" inside "Draggable"). Two neighbouring + /// words also count joined, because a hump is not always where a person breaks the word: + /// "DragLayer" has to be found by "draglayer", "OnDrop" by "ondrop" - and each identifier + /// counts whole, so "BdndDropTarget" finds the type it names. The words every title carries + /// ("Bdnd" in each type, "drag" and "drop" in half of them) are left out as single words - a + /// title word that every title has picks out nothing. , when given, is what + /// the words are read from instead of the title. + /// + public string[] TitleWords { get; } = TitleWordsOf(Name ?? Title); + } + + private const int MaxTerms = 16; + + /// The most hits one search may return, whatever the caller asked for. + private const int MaxHits = 50; + + private const int MaxSuggestions = 8; + + /// The weight of an entry that answers a question: a type, a member, a setup guide. + private const int ReferenceWeight = 10; + + /// + /// The weight of a guide section: a little above a member, because between two equally good + /// matches the section is the one written to explain - "drop result" is answered by the section on + /// drop results, not by the Result property it sets. + /// + private const int GuideWeight = 12; + + /// + /// The weight of a demo page or a source file. They are worked examples, not answers, and their + /// paths are full of segments ("Demo", "Client", "Pages") that are common words carrying no topic - + /// without this, every question phrased with one of them is answered with a directory listing. + /// + private const int ExampleWeight = 4; + + /// + /// The most a term can earn from the words an entry is boosted by. A signature is boosted so a + /// member is found by its types, but a signature also repeats them, and counted in full it + /// outranks the guide section that answers the question. + /// + private const int MaxBoostedCount = 2; + + /// + /// The most an entry can earn from prose, however much of it there is. A long section mentions + /// everything, so without a ceiling the biggest document in the corpus is the top hit for every + /// query - and a name match, which is the far stronger signal, never gets ahead of it. + /// + private const int MaxBodyScore = 8; + + private static readonly Lazy _entries = new(Build); + + /// + /// The words every entry in this index carries because of what the index is about. They + /// separate nothing, and they do worse than nothing: a term that matches an entry counts + /// towards the "how many terms matched" multiplier, so left in, the longest documents win every + /// query on the strength of a word that is in all of them. "Drag" and "drop" are two of them - a + /// drag-and-drop library says both in every paragraph - while "files", next to them in "drop files + /// from the desktop", is not. + /// + /// Unlike a stop word they are dropped only when the query says something else as well, since + /// "drag and drop" and "bdnd" are what a caller reaches for first and are made of nothing but these. + /// + /// + private static readonly HashSet _ambientWords = new(StringComparer.OrdinalIgnoreCase) + { + "bdnd", "bit", "blazor", "library", "drag", "drop" + }; + + private static readonly HashSet _metaSections = new(StringComparer.OrdinalIgnoreCase) { "Documentation", "MCP server" }; + + private static readonly HashSet _stopWords = new(StringComparer.OrdinalIgnoreCase) + { + "how", "the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "which", + "does", "did", "are", "was", "you", "your", "than", "then", "its", "but", "any", "some", + "please", "help", "about", "into", "way", "make", "want", "need", "would", "should", "could", + "there", "here", "have", "has", "get", "got", "let", "one", "two", "per", "via", "onto" + }; + + public static BdndSearchResultDto Search(string? query, int limit) + { + var terms = Tokenize(query); + + if (terms.Length == 0) + { + // Nothing was left to search by, which is a different failure from "searched and found + // nothing" - and the fix is on the caller's side, so it has to be said rather than + // returned as an empty list the agent would read as "Bdnd cannot do this". + return new BdndSearchResultDto + { + Terms = [], + Hits = [], + Message = "The query held no word longer than two letters that was not a filler word, so there was " + + "nothing to rank by. Search for the thing itself - 'drop target', 'sortable', 'native files', " + + "'drag layer' - rather than for a sentence." + }; + } + + var ranked = _entries.Value + .Select(entry => (Entry: entry, Score: Score(entry, terms))) + .Where(hit => hit.Score > 0) + .OrderByDescending(hit => hit.Score) + .ThenBy(hit => hit.Entry.Title, StringComparer.OrdinalIgnoreCase) + // Two entries that would be read through the same call under the same name are one hit: + // a method with two overloads is two members and one place to read about them, and the + // second copy costs a hit out of the caller's budget to say nothing new. A call that takes + // no argument returns the same text whichever entry led to it, and so does a call the + // entries share by design - every setup guide quotes the same checklist. + .DistinctBy(hit => hit.Entry.SharesAnswer || hit.Entry.Tool.EndsWith("()", StringComparison.Ordinal) ? (string.Empty, hit.Entry.Tool) : (hit.Entry.Title, hit.Entry.Tool)) + .ToArray(); + + var take = Math.Clamp(limit, 1, MaxHits); + + var hits = Cover(ranked, terms, take).Select(hit => new BdndSearchHitDto + { + Kind = hit.Entry.Kind, + Title = hit.Entry.Title, + Context = hit.Entry.Context, + Tool = hit.Entry.Tool, + Snippet = Snippet(hit.Entry.Body, terms) + }).ToArray(); + + if (hits.Length > 0) + { + // No echo of the query and no list of the terms it was reduced to: the caller sent that + // query one message ago, and what it was tokenized into changes nothing about a hit it + // can read for itself. Both are worth saying only when there is nothing else to say. + return new BdndSearchResultDto + { + Hits = hits, + HasMore = ranked.Length > hits.Length + }; + } + + // A search that found nothing is where an agent is most likely to conclude the library has + // no such feature and hand-roll one. Whatever came closest is named instead, so the next + // call is a better query rather than a worse decision. + var nearby = Suggest(terms); + + return new BdndSearchResultDto + { + Terms = terms, + Hits = [], + Message = nearby.Length > 0 + ? $"Nothing matched {string.Join(" + ", terms)}. The closest names are listed in didYouMean - " + + "search for one of those, or for a single word out of this query." + : $"Nothing matched {string.Join(" + ", terms)}. Try one word rather than several, or call " + + "GetBdndGuideSection or GetBdndApi with no argument at all for the index of what " + + "there is. A concept Bdnd does not name is often the same idea under another word - " + + "a 'drag source' is a BdndDraggable, a 'drop zone' or 'dropzone' is a BdndDropTarget, a 'ghost' or " + + "'drag preview' is a BdndDragLayer, a 'grip' is a BdndDragHandle, 'reorder' is a sortable list, " + + "'useDrag' and 'useDrop' are BdndDraggable and BdndDropTarget, and 'files from the desktop' are BdndNativeTypes.", + DidYouMean = nearby.Length > 0 ? nearby : null + }; + } + + /// + /// The first hits, with every query word that names something kept in + /// view. A request usually joins two things - "sortable list with handles" is a sortable list AND + /// a drag handle - and the one with more material behind it would otherwise fill every place, + /// leaving the caller to conclude the other has no answer. So for each word that is the name of + /// some entry yet names none of the top hits, the best entry it does name takes the place of the + /// lowest-ranked hit that is not itself there for that reason. The order stays the ranking's. + /// + private static (Entry Entry, int Score)[] Cover((Entry Entry, int Score)[] ranked, string[] terms, int take) + { + var top = ranked.Take(take).ToList(); + if (top.Count < take || terms.Length < 2) return [.. top]; + + var kept = new HashSet(); + + foreach (var term in terms) + { + if (top.Any(hit => hit.Entry.TitleWords.Any(word => Equivalent(word, term)))) continue; + + var best = ranked.Skip(take).FirstOrDefault(hit => hit.Entry.TitleWords.Any(word => Equivalent(word, term))); + if (best.Entry is null) continue; + + var replace = top.FindLastIndex(hit => kept.Contains(hit.Entry) is false); + if (replace < 0) break; + + top[replace] = best; + kept.Add(best.Entry); + } + + return [.. top.OrderByDescending(hit => hit.Score).ThenBy(hit => hit.Entry.Title, StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// The titles that nearly matched: an entry whose name merely begins with one of the query's + /// words, which the ranking itself ignores on purpose - a prefix is far too weak a signal to + /// rank a hit by, and exactly strong enough to suggest a word to search for instead. + /// + private static string[] Suggest(string[] terms) + { + return [.. _entries.Value + .Where(entry => terms.Any(term => term.Length >= 4 + && entry.TitleWords.Any(word => word.StartsWith(term, StringComparison.OrdinalIgnoreCase) + // A one- or two-letter title word ("X", "On") is the start of + // half the dictionary, so it suggests nothing. + || (word.Length >= 4 && term.StartsWith(word, StringComparison.OrdinalIgnoreCase))))) + .Select(entry => entry.Title) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .Take(MaxSuggestions)]; + } + + private static int Score(Entry entry, string[] terms) + { + var score = 0; + var matched = 0; + var named = 0; + + foreach (var term in terms) + { + var isTitleWord = entry.TitleWords.Any(word => Equivalent(word, term)); + + // Text is counted by the singular, which a plural query word would otherwise never find + // inside: "targets" has to count the "Target" of BdndDropTarget, and "files" every "file". + var stem = Stem(term); + var inTitle = isTitleWord ? 0 : Count(entry.Title, stem); + var inBoosted = Math.Min(Count(entry.Boosted, stem), MaxBoostedCount); + var inBody = Count(entry.Body, stem); + + if (isTitleWord is false && inTitle + inBoosted + inBody == 0) continue; + + matched++; + if (isTitleWord || inTitle + inBoosted > 0) named++; + + // A term in a name is worth far more than the same term buried in prose: someone asking + // for "IsOverShallow" wants the property, not the paragraphs that happen to mention it. + score += (isTitleWord ? 12 : 0) + (inTitle * 3) + (inBoosted * 5) + Math.Min(inBody, MaxBodyScore); + } + + // Every term matching is the strongest signal a hit is the right one - and a term the entry is + // named by counts twice, since every long section mentions "item", "target" and "type" + // somewhere, and a multiplier fed only by prose hands the longest of them every query phrased + // in plain words. The weight is what keeps a worked example from outranking the reference that + // answers the question. + return matched == 0 ? 0 : score * (matched + named) * entry.Weight; + } + + /// + /// Same word, whatever form it is in - "sort" has to find the section called "Sortable lists", + /// "handle" the one about handles, and nobody phrases a question in the number or tense the + /// heading happens to use. + /// + private static bool Equivalent(string word, string term) + { + return string.Equals(word, term, StringComparison.OrdinalIgnoreCase) + || string.Equals(Stem(word), Stem(term), StringComparison.OrdinalIgnoreCase); + } + + /// + /// A word without its plural, its -ing or -ed, and a final silent e: "handles", "handling" and + /// "handled" are all "handl", "nesting" and "nested" both "nest". Only ever cut where at least three + /// letters remain, so "css" or "used" keep the letters that make them a word. Prose is counted by + /// the stem too, which as a substring also finds every longer form of it. + /// + private static string Stem(string word) + { + var stem = word.ToLowerInvariant(); + + if (stem.Length > 4 && stem.EndsWith('s') && stem.EndsWith("ss", StringComparison.Ordinal) is false) stem = stem[..^1]; + if (stem.Length > 5 && stem.EndsWith("ing", StringComparison.Ordinal)) stem = stem[..^3]; + else if (stem.Length > 4 && stem.EndsWith("ed", StringComparison.Ordinal)) stem = stem[..^2]; + if (stem.Length > 3 && stem.EndsWith('e')) stem = stem[..^1]; + + return stem; + } + + /// + /// How often a term starts a word of the text. Only the start of a word counts - the start of the + /// text, a letter after a non-letter, or a capital after a lower-case letter, so "target" counts in + /// "DropTarget" - because inside a word a term is a different word: "rag" inside every "drag" would + /// turn a search for rags into one for the whole library. The end of the word stays open, so a stem + /// still finds its longer forms ("handl" in "handles"). + /// + private static int Count(string text, string term) + { + if (text.Length == 0) return 0; + + var count = 0; + var index = text.IndexOf(term, StringComparison.OrdinalIgnoreCase); + + while (index >= 0) + { + if (StartsWord(text, index)) count++; + index = text.IndexOf(term, index + term.Length, StringComparison.OrdinalIgnoreCase); + } + + return count; + } + + private static bool StartsWord(string text, int index) + { + if (index == 0) return true; + + var previous = text[index - 1]; + + return char.IsLetterOrDigit(previous) is false || (char.IsUpper(text[index]) && char.IsLower(previous)); + } + + private static string Snippet(string body, string[] terms) + { + if (body.Length == 0) return string.Empty; + + var index = -1; + foreach (var term in terms) + { + index = body.IndexOf(term, StringComparison.OrdinalIgnoreCase); + if (index >= 0) break; + } + + if (index < 0) index = 0; + + // The same word starts the snippet that was counted for the score, not "rag" inside "drag". + foreach (var term in terms) + { + var at = body.IndexOf(term, StringComparison.OrdinalIgnoreCase); + while (at >= 0 && StartsWord(body, at) is false) at = body.IndexOf(term, at + term.Length, StringComparison.OrdinalIgnoreCase); + + if (at >= 0) + { + index = at; + break; + } + } + + // A snippet is a reason to make the follow-up call, not a substitute for making it: enough + // of the surrounding text to tell the right hit from the wrong one, and no more. Every extra + // character here is paid for by every hit of every search, most of which are not the one. + var start = Math.Max(0, index - 60); + var length = Math.Min(180, body.Length - start); + var snippet = body.Substring(start, length).Replace('\n', ' ').Replace('\r', ' ').Trim(); + + return $"{(start > 0 ? "..." : null)}{snippet}{(start + length < body.Length ? "..." : null)}"; + } + + /// + /// The words a title is found by: its single words without the ambient ones, every two neighbours + /// joined - read off the full list, so "OnDrop" survives although "drop" alone does not count - and + /// every identifier in it whole ("BdndDropTarget", "IsOverShallow"). + /// + private static string[] TitleWordsOf(string title) + { + var words = SplitWords(title); + var pairs = words.Zip(words.Skip(1), (first, second) => first + second); + var identifiers = title.Split(title.Where(c => char.IsLetterOrDigit(c) is false).Distinct().ToArray(), StringSplitOptions.RemoveEmptyEntries) + .Where(identifier => SplitWords(identifier).Length > 2); + + return [.. words.Where(word => _ambientWords.Contains(word) is false).Concat(pairs).Concat(identifiers).Distinct(StringComparer.OrdinalIgnoreCase)]; + } + + /// Splits a name or heading into words, breaking camel-case humps as well as punctuation. + private static string[] SplitWords(string text) + { + var words = new List(); + var current = new System.Text.StringBuilder(); + + foreach (var c in text) + { + if (char.IsLetterOrDigit(c) is false) + { + if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); } + continue; + } + + // A capital after a lowercase letter starts a new word ("IsOverShallow" -> is over shallow), + // while a run of capitals stays together ("XY", "DnD" keeps its "D"). + if (char.IsUpper(c) && current.Length > 0 && char.IsLower(current[^1])) + { + words.Add(current.ToString()); + current.Clear(); + } + + current.Append(c); + } + + if (current.Length > 0) words.Add(current.ToString()); + + return [.. words]; + } + + private static string[] Tokenize(string? query) + { + if (string.IsNullOrWhiteSpace(query)) return []; + + string[] terms = [.. query.Split(['.', ',', ';', ':', '?', '!', '"', '\'', '(', ')', '[', ']', '{', '}', '/', '\\', '<', '>', '-', '_', ' ', '\t', '\n', '\r'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + // One- and two-letter words ("a", "in", "do") match everything and rank nothing - and + // the words a question is phrased with do worse than nothing: "how do I drop files FROM + // the desktop" would otherwise score a section whose heading merely contains "from". + .Where(term => term.Length > 2 && _stopWords.Contains(term) is false) + .Distinct(StringComparer.OrdinalIgnoreCase) + // Every term is counted in every entry's body, so the work is terms x corpus. No question + // is phrased in more words than this, while a pasted file as a query would scan for hours. + .Take(MaxTerms)]; + + return DropAmbientWords(terms); + } + + /// A type's name as people write it in prose and in a tool call: "BdndDraggable", not "BdndDraggable<TItem>". + private static string SimpleName(string typeName) + { + var generic = typeName.IndexOf('<', StringComparison.Ordinal); + + return generic < 0 ? typeName : typeName[..generic]; + } + + private static Entry[] Build() + { + var entries = new List(512); + + foreach (var section in BdndSourceCatalog.GuideSections) + { + // Two sections of the README are about the README rather than about the library: the + // table of contents, which names every topic and so matches every query, and the one about + // this MCP server, whose paragraph about the tools mentions targets, drops and setup while + // answering none of them. Both stay readable through GetBdndGuideSection; they are only + // kept out of the ranking. + if (_metaSections.Contains(section.Key)) continue; + + var body = BdndSourceCatalog.GetGuideSection(section.Key) ?? string.Empty; + + // A sub-section is named by its parent as well as its own heading: "Files" is the section on + // dropped files because it sits under "Native drags", and "drop files" has to find it there + // rather than lose it to every member that happens to carry both words in its name. + entries.Add(new Entry("Guide section", section.Heading, section.Parent, + $"GetBdndGuideSection(heading: \"{section.Key}\")", body, string.Empty, GuideWeight, + Name: section.Parent is null ? null : $"{section.Parent} {section.Heading}")); + } + + foreach (var type in BdndApiCatalog.Types) + { + var name = SimpleName(type.Name); + + entries.Add(new Entry($"API {type.Kind.ToLowerInvariant()}", name, null, + $"GetBdndApi(typeName: \"{name}\")", type.Summary ?? string.Empty, string.Empty)); + + var details = BdndApiCatalog.GetTypeDetails(type.Name); + if (details is null) continue; + + // One entry per member NAME, not per member: the two IsDragging overloads are two members + // and one thing to know about, and both are read by the same call anyway. Their + // documentation is searched as one body, so a term in either overload finds the member. + foreach (var overloads in details.Members.GroupBy(member => member.Name, StringComparer.Ordinal)) + { + var member = overloads.First(); + + entries.Add(new Entry($"API {member.Kind.ToLowerInvariant()}", $"{name}.{member.Name}", name, + $"GetBdndApi(typeName: \"{name}\")", + string.Join(' ', overloads.Select(o => $"{o.Summary} {o.Remarks}".Trim())).Trim(), + string.Join(' ', overloads.Select(o => $"{o.Type} {o.Signature}".Trim())).Trim())); + } + } + + foreach (var mode in BdndSetupGuide.RenderModes) + { + entries.Add(new Entry("Setup guide", $"Setup: {mode}", null, + $"GetBdndSetupGuide(renderMode: \"{mode}\")", BdndSetupGuide.Summary(mode), + $"setup install wiring services register render mode {mode.Replace('-', ' ')}", SharesAnswer: true)); + } + + // The checklist every setup guide opens with is where the failures are explained - the missing + // service registration, the component outside a provider, the page that never becomes + // interactive - so it is searchable on its own. It is read through the guide of the demo's own + // render mode, and is the same hit as that guide rather than a second one. + entries.Add(new Entry("Setup guide", "Setup checklist", null, + "GetBdndSetupGuide(renderMode: \"wasm\")", BdndSetupGuide.Checklist, + "setup checklist register services AddBitBdndServices provider interactive stylesheet nothing drags", SharesAnswer: true)); + + entries.Add(new Entry("Drop lab", "Inspect a Bdnd drop", null, + "InspectBdndDrop()", + "Runs a drag - a draggable's type and a tree of nested drop targets with their accept lists, CanDropPredicate and results, " + + "as JSON - through the real BdndProvider, BdndDraggable and BdndDropTarget, and reports which targets can drop, which are " + + "hovered (IsOver, IsOverShallow), the order OnDrop runs in and what OnEnd receives (DidDrop, DropResult); called with no " + + "scenario it returns a sample one.", + "inspect check debug simulate scenario sample json nested hover shallow order result didDrop")); + + foreach (var file in BdndSourceCatalog.SourceFiles) + { + // A demo page is filed under the heading it shows a person - "Sortable - Simple", not + // "Demo/Client/Pages/Sortable/Simple.razor" - since that is what anyone would call it. + entries.Add(file.Title is not null + ? new Entry("Demo page", file.Title, file.Route, + $"GetBdndSourceFile(path: \"{file.Path}\")", file.Description ?? string.Empty, + (file.Route ?? string.Empty).Replace('/', ' ').Replace('-', ' '), ExampleWeight) + : new Entry("Source file", file.Path, file.Kind, + $"GetBdndSourceFile(path: \"{file.Path}\")", file.Description ?? string.Empty, string.Empty, ExampleWeight)); + } + + return [.. entries]; + } + + /// + /// The query without the words that are true of everything here - unless that is all it said, + /// in which case they are all it can be searched by. + /// + private static string[] DropAmbientWords(string[] terms) + { + // By the stem as well, so "drops" is as ambient as "drop". + var meaningful = terms.Where(term => _ambientWords.Contains(term) is false && _ambientWords.Contains(Stem(term)) is false).ToArray(); + + return meaningful.Length > 0 ? meaningful : terms; + } +} diff --git a/src/Bdnd/Demo/Server/Services/BdndServerInstructions.cs b/src/Bdnd/Demo/Server/Services/BdndServerInstructions.cs new file mode 100644 index 00000000000..9a8c6970fca --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndServerInstructions.cs @@ -0,0 +1,60 @@ +using System.Reflection; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// The text the server returns as instructions from the initialize handshake. +/// +/// A client puts this in front of the model once, before any tool has been called, which makes it +/// the only place where the server can say something the model will read whether or not it decides +/// to use the tools at all. So it stays short - every word is permanently in someone's context +/// window - and spends itself on the working rules rather than on describing the library, which the +/// tools do far better. +/// +/// +public static class BdndServerInstructions +{ + /// + /// The build the answers come from, so the text names it rather than claiming to be timeless. + /// Read off the library assembly rather than written down here: the package version is stamped + /// into it by Bit.Build.props, so there is no second copy for a release to forget. The + /// informational version's build metadata - everything after '+', usually the commit hash - is + /// dropped: it identifies nothing a caller could act on. + /// + public static readonly string BdndVersion = ( + typeof(BdndProvider).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(BdndProvider).Assembly.GetName().Version?.ToString() + ?? "unknown").Split('+')[0]; + + public static readonly string Text = $""" + Bit.Bdnd is a declarative drag-and-drop library for Blazor inspired by React DnD: `BdndDraggable` (useDrag), + `BdndDropTarget` (useDrop), `BdndDragLayer` (useDragLayer) and `BdndDragHandle` inside a `BdndProvider`, on the + browser's HTML5 drag-and-drop API - typed items matched to targets by type strings, drop results, custom drag + previews, drag handles, nested sources and targets, and native drops of files, text, links and HTML. + + Every answer here is read out of Bit.Bdnd {BdndVersion} as it is loaded in this server - its compiled XML + documentation, its README, the components themselves and the demo's source files. Where what you remember (of + React DnD too) and what these tools say disagree, the tools are describing the build in front of you. + + Working rules: + + - Start with `SearchBdnd` unless you already know the section or type name you want. It covers the guide, every + public member, the setup guides and the demo's pages at once, and every hit names the exact follow-up call + that returns its full text. + - Every reference tool takes the key of one thing and answers with that thing; leave the key out and it answers + with the index of what there is - guide sections, public types, source files. + - Do not write a Bdnd type or member from memory. There is one package and one namespace, `Bit.Bdnd`, and every + public type is prefixed `Bdnd`; React DnD's hooks and connector refs do not exist here. `GetBdndApi` gives the + real names, types and default values. + - Call `GetBdndSetupGuide` before writing wiring. `builder.Services.AddBitBdndServices()` is required - in a + Blazor Web App in both Program.cs files - there is no stylesheet to link, every draggable and drop target must + sit inside a `BdndProvider`, and the page must render interactively. + - A target only reacts to an item whose draggable `Type` is in its `Accept`/`AcceptTypes`; nested targets all + get `OnDrop`, innermost first; native OS drags arrive through `OnNativeDrop`, never `OnDrop`. + - To check how a drag plays out - which targets can drop, deep and shallow hover, the order of the drops and + what `OnEnd` receives - call `InspectBdndDrop`: it runs the drag through the real components. + + Every tool on this server is read-only and cheap: none of them changes anything, anywhere, so there is no reason + to ration them or to ask before calling one. + """; +} diff --git a/src/Bdnd/Demo/Server/Services/BdndSetupGuide.cs b/src/Bdnd/Demo/Server/Services/BdndSetupGuide.cs new file mode 100644 index 00000000000..3043682a5e3 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndSetupGuide.cs @@ -0,0 +1,392 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Assembles the wiring for one Blazor render mode: the checklist, the guide's own sections on +/// installing the package, registering its service and on hosting models, and - where the demo is +/// that render mode - the demo's real hosting files. +/// +/// Bdnd needs little wiring, and each part of it fails in its own way. The service registration is +/// required - every draggable and drop target injects BdndInterop - and in a Blazor Web App it +/// has to be in both containers, because the pages are prerendered on the server before they run in +/// the browser. Every component must sit inside a BdndProvider, or it throws as it +/// initializes. And a statically rendered page renders its draggables as inert markup that never +/// wires up a drag event. What does differ per render mode is where the drag callbacks run - in the +/// browser on WebAssembly, a round trip away under Blazor Server - and so which project the pages +/// belong in. +/// +/// +public static partial class BdndSetupGuide +{ + public static readonly string[] RenderModes = ["server", "wasm", "auto", "standalone-wasm", "hybrid"]; + + /// The demo files that make up its wiring - the demo is a Blazor Web App using InteractiveWebAssembly. + private static readonly string[] _hostingFiles = + [ + "Demo/Client/Extensions/IServiceCollectionExtensions.cs", + "Demo/Server/Program.cs", + "Demo/Client/Program.cs", + "Demo/Server/Components/App.razor", + "Demo/Client/Routes.razor", + "Demo/Client/_Imports.razor", + ]; + + private sealed record Mode(string Title, string Summary, string[] GuideSections, string? Files, bool IncludeDemo); + + /// The wiring for one render mode as Markdown, or null when the mode is not one of . + public static string? Get(string? renderMode) => Describe(renderMode) is { } mode ? Compose(mode) : null; + + /// What sets one render mode apart, in a paragraph - the line the search index ranks a setup guide by. + public static string Summary(string renderMode) => Describe(renderMode) is { } mode ? $"{mode.Title}. {mode.Summary.ReplaceLineEndings(" ")}" : string.Empty; + + private static Mode? Describe(string? renderMode) + { + var mode = (renderMode ?? string.Empty).Trim().ToLowerInvariant().Replace(" ", "-", StringComparison.Ordinal); + + mode = mode switch + { + "interactiveserver" or "blazor-server" => "server", + "webassembly" or "interactivewebassembly" or "blazor-wasm" => "wasm", + "interactiveauto" => "auto", + "standalone" or "standalone-webassembly" or "standalone-wasm-app" => "standalone-wasm", + "maui" or "blazor-hybrid" or "wpf" or "winforms" => "hybrid", + _ => mode + }; + + return mode switch + { + "server" => new Mode( + "Blazor Web App - InteractiveServer", + """ + One project, and the drag callbacks run on the server: the browser raises dragstart, dragenter and drop + at once, and each managed handler - OnBegin, OnHover, OnDrop, OnEnd - runs a SignalR round trip later. + Drops still land where they should; what changes is that `ItemFactory` returning null can no longer stop + the native drag (it goes on as an inert drag no target accepts), a custom drag layer starts from an + estimated position that is corrected a moment later, and `OnHover` sends a message over the circuit for + every dragover - bind it only where you need it. + """, + ["Install", "Register the service", "Blazor Server", "Prerendering"], + ServerFiles, + IncludeDemo: false), + + "wasm" => new Mode( + "Blazor Web App - InteractiveWebAssembly", + """ + The drag callbacks run in the browser, so none of them waits on the network - this is how the demo is + built, and the files below are its own. The pages that drag must live in the .Client project so their + assembly is downloaded to the browser; the server project prerenders the same pages, where the + components render their idle state and come alive once WebAssembly starts. Reference `Bit.Bdnd` from the + .Client project (the server gets it transitively) and call `AddBitBdndServices()` in BOTH Program.cs + files - the demo does it once, in the `AddDemoServices` extension both of them call. + """, + ["Install", "Register the service", "Blazor Web App", "Blazor WebAssembly", "Prerendering"], + null, + IncludeDemo: true), + + "auto" => new Mode( + "Blazor Web App - InteractiveAuto", + """ + The first visit renders on the server while the WebAssembly runtime downloads, later ones in the browser, + so the same components run in either place - with a round trip per drag callback on the first visit and + none afterwards. The wiring is the same in both: `AddBitBdndServices()` in BOTH Program.cs files, the + server's and the client's, and the pages in the .Client project. The demo below uses InteractiveWebAssembly; for + InteractiveAuto add `AddInteractiveServerComponents()` and `AddInteractiveServerRenderMode()` to the + server's `Program.cs` and use `@rendermode="InteractiveAuto"` in `App.razor` - nothing else changes. + """, + ["Install", "Register the service", "Blazor Web App", "Blazor WebAssembly", "Blazor Server", "Prerendering"], + null, + IncludeDemo: true), + + "standalone-wasm" => new Mode( + "standalone Blazor WebAssembly", + """ + One project and no prerendering: the components run in the browser from the first render, and every drag + callback is handled without a network round trip. Register the service in the one `Program.cs` there is. + """, + ["Install", "Register the service", "Blazor WebAssembly"], + StandaloneFiles, + IncludeDemo: false), + + "hybrid" => new Mode( + "Blazor Hybrid (MAUI, WPF, Windows Forms)", + """ + The components run in-process and render into the embedded WebView, whose JS runtime is not in-process - + so they behave as on Blazor Server, without the network latency. Drag and drop needs a web view that + raises HTML5 drag events: desktop web views do, touch input on mobile web views generally does not. + """, + ["Install", "Register the service", "Blazor Hybrid"], + HybridFiles, + IncludeDemo: false), + + _ => null + }; + } + + private static string Compose(Mode mode) + { + var builder = new StringBuilder(); + + builder.AppendLine($"# Setting Bdnd up - {mode.Title}").AppendLine(); + builder.AppendLine(mode.Summary).AppendLine(); + builder.AppendLine(Checklist).AppendLine(); + + if (mode.Files is not null) builder.AppendLine(mode.Files).AppendLine(); + + if (mode.IncludeDemo) + { + builder.AppendLine("## The files, from this demo").AppendLine(); + builder.AppendLine("Everything below compiles as-is; rename the projects and namespaces to your own. The server's `Program.cs` ") + .AppendLine("also maps this MCP server - leave those lines out of your app.") + .AppendLine(); + + foreach (var path in _hostingFiles) + { + var content = BdndSourceCatalog.GetSourceFile(path); + if (content is null) continue; + + AppendFile(builder, path, content); + } + } + + // The guide's own words on installing and on this hosting model, so the caveats are the + // library's rather than a paraphrase of them that can drift. + var quoted = mode.GuideSections.Select(BdndSourceCatalog.GetGuideSection).Where(section => section is not null).ToArray(); + + if (quoted.Length > 0) + { + builder.AppendLine("## From the guide").AppendLine(); + + foreach (var section in quoted) builder.AppendLine(Demote(section!)).AppendLine(); + } + + builder.AppendLine(Next); + + return builder.ToString(); + } + + /// The checklist every render mode's guide opens with - the same few steps, and the failure each one prevents. + public const string Checklist = """ + ## Checklist + + 1. `dotnet add package Bit.Bdnd` - one package, everything under the `Bit.Bdnd` namespace. In a Blazor Web App + with an interactive client, add it to the .Client project. + 2. Register the service - it is REQUIRED: `builder.Services.AddBitBdndServices();` in `Program.cs`, in EVERY project + that renders the components. In a Blazor Web App that is both the server's and the client's `Program.cs`: + the pages are prerendered on the server, so what a component injects has to resolve in both containers. + Without it a page throws `Cannot provide a value for property 'Interop'`. Calling it twice is harmless. + 3. Add `@using Bit.Bdnd` to `_Imports.razor`. + 4. There is NO stylesheet to link and no script tag to add: the components import their script module, + `_content/Bit.Bdnd/bit-bdnd.js`, themselves the first time they need it. Style the drag states yourself, + from the collected state (`IsDragging`, `IsOver`, `CanDrop`). + 5. Wrap every region that drags and drops in a ``. A `BdndDraggable`, `BdndDropTarget` or + `BdndDragLayer` outside one throws `InvalidOperationException: ... must be placed inside a BdndProvider`, and + a draggable only reaches the targets of its own provider. + 6. Render the page interactively (`@rendermode InteractiveWebAssembly`, `InteractiveServer` or `InteractiveAuto`, + or an interactive parent). On a statically rendered page the markup appears and nothing can be dragged. + While prerendering, the components render their idle state and start working once the page is interactive. + 7. Match the types: a target only reacts to a draggable whose `Type` is in its `Accept`/`AcceptTypes` - compared + as strings, case included, never by the item's .NET type. Keep the strings in one constants class. + 8. Drags need a pointer that raises HTML5 drag events - a mouse, a pen or a trackpad. Like React DnD's HTML5 + backend, Bdnd has no touch backend. + """; + + private const string ServerFiles = """ + ## The files + + ### `Program.cs` + + ```csharp + using Bit.Bdnd; + + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + + builder.Services.AddBitBdndServices(); + + var app = builder.Build(); + + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + + app.Run(); + ``` + + ### `Components/_Imports.razor` + + ```razor + @using Bit.Bdnd + ``` + + ### `Components/Pages/Board.razor` + + ```razor + @page "/board" + @rendermode InteractiveServer + + + +
Write the docs
+
+ + +
Done: @_done
+
+
+ + @code { + private string? _done; + } + ``` + """; + + private const string StandaloneFiles = """ + ## The files + + ### `Program.cs` + + ```csharp + using Bit.Bdnd; + using Microsoft.AspNetCore.Components.Web; + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddBitBdndServices(); + + await builder.Build().RunAsync(); + ``` + + ### `_Imports.razor` + + ```razor + @using Bit.Bdnd + ``` + + ### `Pages/Board.razor` + + ```razor + @page "/board" + + + +
Write the docs
+
+ + +
Done: @_done
+
+
+ + @code { + private string? _done; + } + ``` + + Nothing goes into `wwwroot/index.html`: there is no stylesheet, and the script module is imported on demand. + """; + + private const string HybridFiles = """ + ## The files + + ### `MauiProgram.cs` (or the WPF / Windows Forms host) + + ```csharp + using Bit.Bdnd; + + builder.Services.AddMauiBlazorWebView(); + + builder.Services.AddBitBdndServices(); + ``` + + ### `_Imports.razor` + + ```razor + @using Bit.Bdnd + ``` + + Put a `` with its draggables and drop targets on any page the BlazorWebView renders; nothing goes + into `wwwroot/index.html`. + """; + + private const string Next = """ + The drag itself is markup - call `GetBdndGuideSection(heading: "Getting started")` for a first drag and drop, + `GetBdndApi(typeName: "BdndDraggable")` or `GetBdndApi(typeName: "BdndDropTarget")` for every parameter with its + real default, `GetBdndSourceFile()` for the index of the demo's working pages, or `InspectBdndDrop()` to see how a + drag over nested targets plays out. + """; + + /// + /// Pushes a quoted guide section down so its own heading is a third-level one, nested under "From + /// the guide" instead of competing with the headings of this document - whatever level it started + /// at, so a top-level section of the README quoted here would nest just the same as a sub-section. + /// + private static string Demote(string section) + { + var lines = section.Split('\n'); + var fenced = false; + + var top = lines.Select(line => line.TakeWhile(c => c == '#').Count()).FirstOrDefault(level => level > 0); + var shift = top == 0 ? 0 : Math.Max(0, 3 - top); + + for (int i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + fenced = fenced is false; + continue; + } + + if (fenced is false && lines[i].StartsWith('#')) lines[i] = $"{new string('#', shift)}{lines[i]}"; + } + + return string.Join('\n', lines); + } + + private static void AppendFile(StringBuilder builder, string path, string content) + { + content = content.TrimStart('\uFEFF').TrimEnd(); + var fence = Fence(content); + + builder.AppendLine($"### `{path}`").AppendLine() + .AppendLine($"{fence}{Language(path)}") + .AppendLine(content) + .AppendLine(fence) + .AppendLine(); + } + + /// + /// The fence for a file's code block. It has to outrun the longest run of backticks in the file + /// itself - a file that shows Markdown would otherwise end the block in the middle of its own + /// content, and the rest of the file would render as prose. + /// + private static string Fence(string content) + { + var longest = BacktickRunRegex().Matches(content).Select(match => match.Length).DefaultIfEmpty(0).Max(); + + return new string('`', Math.Max(3, longest + 1)); + } + + private static string Language(string path) => Path.GetExtension(path).ToLowerInvariant() switch + { + ".razor" => "razor", + ".cs" => "csharp", + ".csproj" => "xml", + ".ts" => "typescript", + ".js" => "javascript", + _ => string.Empty + }; + + [GeneratedRegex("`+")] + private static partial Regex BacktickRunRegex(); +} diff --git a/src/Bdnd/Demo/Server/Services/BdndSourceCatalog.cs b/src/Bdnd/Demo/Server/Services/BdndSourceCatalog.cs new file mode 100644 index 00000000000..f58318df697 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndSourceCatalog.cs @@ -0,0 +1,497 @@ +using System.Text; +using System.Reflection; +using System.Collections.Frozen; +using Bit.Bdnd.Demo.Server.Dtos; +using System.Text.RegularExpressions; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Serves the hand-written material the MCP tools hand out: the library's README (its reference +/// guide), the library's components and script module as they ship, and every source file of this +/// demo - the pages are working drag-and-drop examples, and the hosting files are the wiring of a real +/// Blazor Web App. +/// +/// All of them are embedded into this assembly by the .csproj rather than read from disk, so the +/// MCP server keeps working from a published, single-folder deployment where the repository is +/// nowhere to be found. +/// +/// +public static partial class BdndSourceCatalog +{ + private const string ReadmeResource = "BdndDocs/README.md"; + private const string SourcePrefix = "BdndSource/"; + + /// The path the library's script module is served under, next to the demo's files. + public const string ScriptPath = "Library/Scripts/bit-bdnd.ts"; + + /// Where the demo's pages live. + public const string PagesFolder = "Demo/Client/Pages/"; + + /// Separates a parent heading from a child one in the key of a heading the README uses twice. + public const string KeySeparator = " > "; + + private sealed record Section(BdndGuideSectionDto Dto, int Start, int End); + + private static readonly Assembly _assembly = typeof(BdndSourceCatalog).Assembly; + + private static readonly Lazy _readme = new(() => ReadResource(ReadmeResource) ?? string.Empty); + private static readonly Lazy _readmeLines = new(() => Readme.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')); + private static readonly Lazy _sections = new(BuildSections); + private static readonly Lazy _guideSections = new(() => [.. _sections.Value.Select(section => section.Dto)]); + private static readonly Lazy> _sourceFiles = new(BuildSourceFiles); + private static readonly Lazy _sourceFileList = new(BuildSourceFileList); + + /// The library's README, in full. + public static string Readme => _readme.Value; + + /// + /// The guide's sections, as the index GetBdndGuideSection answers with when it is called without + /// a heading. Plain text rather than a list of objects: an index is read, not parsed, and the + /// same list as JSON costs twice the characters to say the same thing. + /// + public static string RenderGuideIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Bdnd reference guide").AppendLine(); + builder.AppendLine("The library's README, section by section. Call `GetBdndGuideSection(heading: \"...\")` for one of them; ") + .AppendLine("a section's line count is there so a heading that runs to a hundred lines is a deliberate choice rather than a surprise.") + .AppendLine(); + + foreach (var section in GuideSections) + { + var indent = new string(' ', (section.Level - 2) * 2); + var key = section.Key == section.Heading ? null : $" - key `{section.Key}`"; + + builder.AppendLine($"{indent}- {section.Heading} ({section.Lines} lines){key}"); + } + + return builder.ToString(); + } + + /// + /// The files GetBdndSourceFile can hand out, as the index it answers with when it is called + /// without a path. + /// + public static string RenderSourceIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Bdnd source files").AppendLine(); + builder.AppendLine("Working code this server can hand out verbatim. Call `GetBdndSourceFile(path: \"...\")` for one. ") + .AppendLine("A demo page says where the running demo serves it.") + .AppendLine(); + + foreach (var group in SourceFiles.GroupBy(file => file.Kind)) + { + builder.AppendLine($"## {group.Key}").AppendLine(); + + foreach (var file in group) + { + builder.Append($"- `{file.Path}` ({file.Lines} lines"); + if (file.Route is not null) builder.Append($", `{file.Route}`"); + builder.Append(')'); + if (file.Title is not null) builder.Append($" - **{file.Title}**"); + if (file.Description is not null) builder.Append($" - {file.Description}"); + builder.AppendLine(); + } + + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// Every level 2, 3 and 4 heading of the README, in reading order. + public static BdndGuideSectionDto[] GuideSections => _guideSections.Value; + + /// Every embedded source file, keyed by the path the tools expose. + public static BdndSourceFileDto[] SourceFiles => _sourceFileList.Value; + + /// + /// The README text under a heading, including its sub-sections. Matching is case- and + /// punctuation-insensitive so "drop result" finds "Drop results" and "coming from react dnd" + /// finds "Coming from React DnD"; a heading the README uses more than once is told apart by its + /// key, "Parent > Heading", and otherwise answers with the first section of that name. + /// + public static string? GetGuideSection(string heading) + { + var section = FindSection(heading); + if (section is null) return null; + + return string.Join('\n', _readmeLines.Value[section.Start..section.End]).TrimEnd(); + } + + /// The content of an embedded source file, or null when the path is unknown. + public static string? GetSourceFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + + return _sourceFiles.Value.GetValueOrDefault(Normalize(path)); + } + + private static Section? FindSection(string? heading) + { + if (string.IsNullOrWhiteSpace(heading)) return null; + + var sections = _sections.Value; + + var separator = heading.LastIndexOf('>'); + if (separator > 0) + { + var parent = NormalizeHeading(heading[..separator]); + var child = NormalizeHeading(heading[(separator + 1)..]); + + var qualified = sections.FirstOrDefault(section => section.Dto.Parent is not null + && NormalizeHeading(section.Dto.Parent) == parent + && NormalizeHeading(section.Dto.Heading) == child); + if (qualified is not null) return qualified; + } + + var normalized = NormalizeHeading(heading); + + // The singular as well: "drop result" is the section called "Drop results", and nobody asks in + // the number the heading happens to use. + return sections.FirstOrDefault(section => NormalizeHeading(section.Dto.Heading) == normalized) + ?? sections.FirstOrDefault(section => NormalizeHeading(section.Dto.Key) == normalized) + ?? sections.FirstOrDefault(section => Singular(NormalizeHeading(section.Dto.Heading)) == Singular(normalized)); + } + + private static string Singular(string normalized) => normalized.EndsWith('s') ? normalized[..^1] : normalized; + + private static string? ReadResource(string name) + { + using var stream = _assembly.GetManifestResourceStream(name); + if (stream is null) return null; + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } + + private static Section[] BuildSections() + { + var lines = _readmeLines.Value; + var headings = new List<(int Index, int Level, string Text)>(); + + var fenced = false; + for (int i = 0; i < lines.Length; i++) + { + if (IsCodeFence(lines[i])) + { + fenced = fenced is false; + continue; + } + + if (fenced) continue; + + if (TryReadHeading(lines[i], out var level, out var text) && level is >= 2 and <= 4) + { + headings.Add((i, level, text)); + } + } + + var duplicated = headings.GroupBy(heading => NormalizeHeading(heading.Text)) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + + var sections = new List
(headings.Count); + var parents = new string?[5]; + + for (int i = 0; i < headings.Count; i++) + { + var (index, level, text) = headings[i]; + + parents[level] = text; + for (int deeper = level + 1; deeper < parents.Length; deeper++) parents[deeper] = null; + + string? parent = null; + for (int upper = level - 1; upper >= 2 && parent is null; upper--) parent = parents[upper]; + + // The section runs until the next heading of the same or a higher rank. + var end = lines.Length; + for (int j = i + 1; j < headings.Count; j++) + { + if (headings[j].Level > level) continue; + end = headings[j].Index; + break; + } + + sections.Add(new Section(new BdndGuideSectionDto + { + Heading = text, + Key = parent is not null && duplicated.Contains(NormalizeHeading(text)) ? $"{parent}{KeySeparator}{text}" : text, + Level = level, + Parent = parent, + Lines = end - index + }, index, end)); + } + + return [.. sections]; + } + + private static FrozenDictionary BuildSourceFiles() + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var resource in _assembly.GetManifestResourceNames()) + { + var normalized = Normalize(resource); + + if (normalized.StartsWith(SourcePrefix, StringComparison.OrdinalIgnoreCase) is false) continue; + + var content = ReadResource(resource); + if (content is null) continue; + + files[normalized[SourcePrefix.Length..]] = content; + } + + return files.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + private static BdndSourceFileDto[] BuildSourceFileList() + { + return [.. _sourceFiles.Value + .Select(file => new BdndSourceFileDto + { + Path = file.Key, + Kind = KindOf(file.Key, file.Value), + Title = TitleOf(file.Value), + Description = DescribeSource(file.Key, file.Value), + Route = RouteOf(file.Value), + Lines = CountLines(file.Value) + }) + .OrderBy(file => KindOrder(file.Kind)) + .ThenBy(file => file.Path, StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// What a file is for: a page is one of the demo's working examples, the hosting files are the + /// wiring every app needs once, a library file is shipped by the package itself, and the rest - + /// the sortable card several pages render, the item types, the code viewer - is what the pages share. + /// + private static string KindOf(string path, string content) + { + if (path.StartsWith("Library/", StringComparison.OrdinalIgnoreCase)) return "Library"; + if (path.StartsWith(PagesFolder, StringComparison.OrdinalIgnoreCase) && RouteOf(content) is not null) return "Page"; + if (path.StartsWith("Demo/Server/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Demo/Client/Extensions/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Demo/Client/Layout/", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/Program.cs", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/Routes.razor", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/_Imports.razor", StringComparison.OrdinalIgnoreCase)) return "Hosting"; + + return "Shared"; + } + + private static int KindOrder(string kind) => kind switch + { + "Page" => 0, + "Shared" => 1, + "Hosting" => 2, + "Library" => 3, + _ => 4 + }; + + /// + /// The number of lines in a file. A trailing newline ends the last line rather than starting + /// another one, so a file that has one is not reported a line longer than it is. + /// + private static int CountLines(string content) + { + if (content.Length == 0) return 0; + + var newlines = content.Count(c => c == '\n'); + + return content[^1] == '\n' ? newlines : newlines + 1; + } + + private static string? RouteOf(string content) + { + var match = PageDirectiveRegex().Match(content); + + return match.Success ? match.Groups["route"].Value : null; + } + + /// + /// The heading a demo page shows its reader - "Sortable - Simple", "Nesting - Drop Targets" - which + /// is what a person calls the example, and so what the search index files it under. + /// + private static string? TitleOf(string content) + { + var match = DemoTitleRegex().Match(content); + + return match.Success ? Decode(XmlTagRegex().Replace(match.Groups["text"].Value, string.Empty)).Trim() : null; + } + + /// + /// A one-line description of a source file, taken from whatever the file itself already says: + /// its leading razor/C#/TypeScript comment, the type summary it opens with, or - for a demo page - + /// the introduction it shows the person reading it. + /// + private static string? DescribeSource(string path, string content) + { + // A library component's markup says nothing about itself - its summary is on the partial class + // next to it, which is where IntelliSense reads it from too. + if (path.StartsWith("Library/", StringComparison.OrdinalIgnoreCase) && path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) && + _sourceFiles.Value.TryGetValue($"{path}.cs", out var codeBehind)) + { + content = codeBehind; + path = $"{path}.cs"; + } + + // A byte order mark is not text, and it would stop every pattern anchored at the start of the file. + content = content.TrimStart('\uFEFF'); + + var leadingComment = LeadingRazorCommentRegex().Match(content); + if (leadingComment.Success) return Summarize(leadingComment.Groups["text"].Value); + + // A demo page opens by saying what it demonstrates, to the person reading it, in the paragraph + // under its heading. That is the best description of it there is, far better than the first + // comment somewhere in its code. + var intro = DemoHeaderRegex().Match(content); + if (intro.Success) return Summarize(Decode(XmlTagRegex().Replace(intro.Groups["text"].Value, string.Empty))); + + var blockComment = LeadingBlockCommentRegex().Match(content); + if (blockComment.Success) return Summarize(blockComment.Groups["text"].Value); + + // A comment the file opens with describes the file; an XML summary further down describes + // one type inside it (Program.cs ends with one about the partial Program class). + var lineComment = LeadingLineCommentRegex().Match(content); + if (lineComment.Success) return Summarize(lineComment.Value.Replace("//", " ", StringComparison.Ordinal)); + + // In a .razor file the first XML summary belongs to a parameter in its @code block, which says + // nothing about the component as a whole. + var xmlSummary = path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) ? Match.Empty : XmlSummaryRegex().Match(content); + if (xmlSummary.Success) + { + // An XML summary is markup: a is the name it points at, the other tags are + // noise in a listing, and the text is entity-encoded ("<DndProvider>"). + var text = xmlSummary.Groups["text"].Value.Replace("///", " ", StringComparison.Ordinal); + + text = SeeCrefRegex().Replace(text, see => see.Groups["name"].Value); + + return Summarize(Decode(XmlTagRegex().Replace(text, string.Empty))); + } + + var title = PageTitleRegex().Match(content); + if (title.Success) return Summarize(Decode(title.Groups["text"].Value)); + + // Nothing at the top of the file said what it is - the first commentary in it will do. + var comment = RazorCommentRegex().Match(content); + if (comment.Success) return Summarize(comment.Groups["text"].Value); + + // A component that says nothing about itself is still described by what it is made of - the + // sortable card is a draggable and a drop target in one, and that is what a search should find. + var used = BdndComponentRegex().Matches(content).Select(match => match.Groups["name"].Value).Distinct(StringComparer.Ordinal).ToArray(); + + return used.Length > 0 ? $"Renders {string.Join(", ", used)}." : null; + } + + /// The few entities the demo's markup writes, so a description reads as text rather than as HTML. + private static string Decode(string text) => System.Net.WebUtility.HtmlDecode(text); + + private static string? Summarize(string text) + { + text = WhitespaceRegex().Replace(text, " ").Trim(); + if (text.Length == 0) return null; + + // The first sentence is the description; the rest is the file's own commentary. + var stop = text.IndexOf(". ", StringComparison.Ordinal); + if (stop > 0) text = text[..(stop + 1)]; + + return text.Length <= 220 ? text : $"{text[..217]}..."; + } + + /// + /// A Markdown code fence. A '#' line inside one is a shell comment or a C# preprocessor + /// directive, not a heading, and must not start or end a guide section. + /// + private static bool IsCodeFence(string line) + { + var text = line.TrimStart(); + + return text.StartsWith("```", StringComparison.Ordinal) || text.StartsWith("~~~", StringComparison.Ordinal); + } + + private static bool TryReadHeading(string line, out int level, out string text) + { + level = 0; + text = string.Empty; + + while (level < line.Length && line[level] == '#') level++; + + if (level is 0 or > 6 || level >= line.Length || line[level] != ' ') return false; + + // A heading is read the way Markdown renders it: "BdndDraggable<TItem>" is the heading + // "BdndDraggable", and that is what a caller asks for and what a search hit shows. + text = System.Net.WebUtility.HtmlDecode(line[(level + 1)..].Trim()); + + return text.Length > 0; + } + + /// Reduces a heading to its comparable core, so "drop targets nesting" finds nothing it should not and "the provider" finds "### The provider". + private static string NormalizeHeading(string heading) + { + var builder = new StringBuilder(heading.Length); + + // The little words carry nothing a heading is told apart by, and they are the ones a caller + // most often drops ("coming react dnd") or adds ("the custom drag layers"). + foreach (var word in heading.Split(heading.Where(c => char.IsLetterOrDigit(c) is false).Distinct().ToArray(), StringSplitOptions.RemoveEmptyEntries)) + { + if (_fillerWords.Contains(word)) continue; + + foreach (var c in word) builder.Append(char.ToLowerInvariant(c)); + } + + return builder.ToString(); + } + + private static readonly HashSet _fillerWords = new(StringComparer.OrdinalIgnoreCase) { "a", "an", "and", "the", "of", "from" }; + + private static string Normalize(string path) => path.Replace('\\', '/').Trim('/'); + + // The directives a component opens with come before the comment that describes it - but not + // @page, since a page describes itself to its reader below its heading, which is the better line. + [GeneratedRegex(@"^(?:\s*@(?:namespace|using|inject|implements|inherits|layout|attribute|typeparam)\b[^\n]*\n)*\s*@\*(?.*?)\*@", RegexOptions.Singleline)] + private static partial Regex LeadingRazorCommentRegex(); + + [GeneratedRegex(@"^\s*/\*(?.*?)\*/", RegexOptions.Singleline)] + private static partial Regex LeadingBlockCommentRegex(); + + [GeneratedRegex(@"@\*(?.*?)\*@", RegexOptions.Singleline)] + private static partial Regex RazorCommentRegex(); + + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex XmlTagRegex(); + + [GeneratedRegex(@"///\s*(?.*?)", RegexOptions.Singleline)] + private static partial Regex XmlSummaryRegex(); + + // The last segment of the cref, without its generic parameters: "BdndDraggable{TItem}" reads "BdndDraggable". + [GeneratedRegex(@"[^"".{]+)(?:\{[^""]*\})?""\s*/>")] + private static partial Regex SeeCrefRegex(); + + [GeneratedRegex(@"<(?Bdnd[A-Z][A-Za-z]*)\b")] + private static partial Regex BdndComponentRegex(); + + [GeneratedRegex(@"
\s*

.*?

\s*]*>(?.*?)

", RegexOptions.Singleline)] + private static partial Regex DemoHeaderRegex(); + + [GeneratedRegex(@"
\s*

(?.*?)

", RegexOptions.Singleline)] + private static partial Regex DemoTitleRegex(); + + [GeneratedRegex(@"(?.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex PageTitleRegex(); + + [GeneratedRegex(@"^\s*@page\s+""(?[^""]+)""", RegexOptions.Multiline)] + private static partial Regex PageDirectiveRegex(); + + [GeneratedRegex(@"^\s*(//[^\n]*\n)+")] + private static partial Regex LeadingLineCommentRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); +} diff --git a/src/Bdnd/Demo/Server/Services/BdndXmlDocs.cs b/src/Bdnd/Demo/Server/Services/BdndXmlDocs.cs new file mode 100644 index 00000000000..aca47784d62 --- /dev/null +++ b/src/Bdnd/Demo/Server/Services/BdndXmlDocs.cs @@ -0,0 +1,216 @@ +using System.Text; +using System.Xml.Linq; +using System.Reflection; +using System.Collections.Frozen; +using System.Text.RegularExpressions; + +namespace Bit.Bdnd.Demo.Server.Services; + +/// +/// Reads the XML documentation the Bit.Bdnd build emits next to its assembly and flattens each +/// entry into plain text: <see cref="T:Bit.Bdnd.BdndDragLayer`1"/> becomes +/// BdndDragLayer, <c> spans become their content, and paragraphs become blank +/// lines. That text is the same prose a developer reads in IntelliSense, which makes it the most +/// accurate answer an MCP client can get about a member - no second copy to keep in sync. +/// +public static partial class BdndXmlDocs +{ + private const char CodeMarker = (char)1; + + private static readonly Lazy> _members = new(Load); + private static readonly Lazy> _overloads = new(BuildOverloads); + + /// The flattened <summary> of a documented member, or null. + public static string? GetSummary(string documentationId) => GetSection(documentationId, "summary"); + + /// The flattened <remarks> of a documented member, or null. + public static string? GetRemarks(string documentationId) => GetSection(documentationId, "remarks"); + + private static string? GetSection(string documentationId, string section) + { + // Overloads are told apart by their parameter list; when building it did not produce an exact + // hit (generics, modifiers), one overload's documentation still beats none. + if (_members.Value.TryGetValue(documentationId, out var member) is false && + _overloads.Value.TryGetValue(documentationId, out member) is false) return null; + + var element = member.Element(section); + + return element is null ? null : Flatten(element); + } + + /// + /// Every documented method by its id without the parameter list, so an inexact id still finds an + /// overload without walking the whole table. Which overload is decided by ordering the candidates + /// - a FrozenDictionary enumerates in whatever order it hashed into, so taking one off it + /// directly would answer differently per build. + /// + private static FrozenDictionary BuildOverloads() + { + return _members.Value.Where(m => m.Key.Contains('(', StringComparison.Ordinal)) + .OrderBy(m => m.Key, StringComparer.Ordinal) + .GroupBy(m => m.Key[..m.Key.IndexOf('(', StringComparison.Ordinal)], StringComparer.Ordinal) + .ToFrozenDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); + } + + private static FrozenDictionary Load() + { + var path = Path.Combine(AppContext.BaseDirectory, $"{typeof(BdndProvider).Assembly.GetName().Name}.xml"); + + if (File.Exists(path) is false) return FrozenDictionary.Empty; + + try + { + var document = XDocument.Load(path); + + return document.Descendants("member") + .Where(m => m.Attribute("name") is not null) + .GroupBy(m => m.Attribute("name")!.Value, StringComparer.Ordinal) + .ToFrozenDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + } + catch (Exception) + { + // Documentation is a nicety: a malformed or half-written file must not take the tools down. + return FrozenDictionary.Empty; + } + } + + private static string Flatten(XElement element) + { + var builder = new StringBuilder(); + var codeSamples = new List(); + + Write(element, builder, codeSamples); + + var text = builder.ToString().Replace("\r\n", "\n", StringComparison.Ordinal); + + // The source wraps its prose across lines, so a single newline is just a space - while the + // blank line a emits is a real paragraph break and has to survive. + text = WrappedLineRegex().Replace(text, " "); + text = ParagraphBreakRegex().Replace(text, "\n\n"); + text = RepeatedSpaceRegex().Replace(text, " "); + + // A code sample only stood in as a placeholder while the prose was unwrapped: its own line + // breaks and indentation are the sample, not source wrapping, and go back in verbatim. + for (int i = 0; i < codeSamples.Count; i++) + { + text = text.Replace(CodePlaceholder(i), codeSamples[i], StringComparison.Ordinal); + } + + return text.Trim(); + } + + /// + /// Stands in for a code sample while the prose is unwrapped. U+0001 never occurs in + /// documentation text, and none of the whitespace passes above can touch it. + /// + private static string CodePlaceholder(int index) => $"{CodeMarker}{index}{CodeMarker}"; + + private static void Write(XNode node, StringBuilder builder, List codeSamples) + { + switch (node) + { + case XText text: + builder.Append(text.Value); + return; + + case XElement element: + switch (element.Name.LocalName) + { + case "see" or "seealso": + builder.Append(Reference(element)); + return; + + case "paramref" or "typeparamref": + builder.Append(element.Attribute("name")?.Value); + return; + + case "para": + builder.Append('\n'); + foreach (var child in element.Nodes()) Write(child, builder, codeSamples); + builder.Append('\n'); + return; + + case "code": + // Blank lines on both sides, so the sample is a block of its own rather than + // a sentence that happens to continue in code. + builder.Append("\n\n").Append(CodePlaceholder(codeSamples.Count)).Append("\n\n"); + codeSamples.Add($"```\n{Dedent(element.Value)}\n```"); + return; + + case "param" or "typeparam": + builder.Append('\n').Append(element.Attribute("name")?.Value).Append(": "); + foreach (var child in element.Nodes()) Write(child, builder, codeSamples); + return; + + default: + foreach (var child in element.Nodes()) Write(child, builder, codeSamples); + return; + } + } + } + + /// + /// A code sample without the indentation of the comment it was written in. The compiler keeps + /// every line's leading spaces, so a sample in an indented doc comment arrives with its first line + /// flush (it follows the <code> tag) and every other line pushed right by the comment's own + /// indentation - which reads as a sample whose braces do not line up. + /// + private static string Dedent(string code) + { + var lines = code.ReplaceLineEndings("\n").Split('\n').ToList(); + + while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0])) lines.RemoveAt(0); + while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1])) lines.RemoveAt(lines.Count - 1); + + if (lines.Count == 0) return string.Empty; + + // The first line is measured only when it is indented itself: one that follows on the + // same line starts at column 0 whatever the rest of the sample does. + var measured = lines.Skip(char.IsWhiteSpace(lines[0], 0) ? 0 : 1).Where(line => string.IsNullOrWhiteSpace(line) is false).ToArray(); + var indent = measured.Length == 0 ? 0 : measured.Min(line => line.Length - line.TrimStart().Length); + + return string.Join('\n', lines.Select((line, index) => + index == 0 && char.IsWhiteSpace(line, 0) is false ? line : line.Length >= indent ? line[indent..].TrimEnd() : line.Trim())); + } + + /// Turns a cref such as "P:Bit.Bdnd.BdndDropTarget`1.OnDrop" into "BdndDropTarget.OnDrop". + private static string Reference(XElement element) + { + var target = element.Attribute("cref")?.Value ?? element.Attribute("langword")?.Value ?? element.Value; + + if (string.IsNullOrEmpty(target)) return string.Empty; + + var kind = target.Length > 2 && target[1] == ':' ? target[0] : '\0'; + if (kind != '\0') target = target[2..]; + + // A method cref carries its parameter list ("M:Bit.Bdnd.IBdndMonitor.IsOver(System.String,System.Boolean)"). + // Fully qualified argument types read as noise in prose, and the '.'s inside them would + // otherwise be split on below - which would keep the arguments and drop the owning type. + var arguments = target.IndexOf('(', StringComparison.Ordinal); + if (arguments > 0) target = target[..arguments]; + + // Drop the arity markers of generic types and methods ("BdndDropTarget`1", "GetItem``1") wherever + // they sit. Most of this library's types are generic, so a member's cref names its type with one + // in the middle - and cutting the text at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); + + var parts = target.Split('.'); + + // A type reads best on its own; a member keeps the type it belongs to. + var keep = kind is 'T' or 'N' ? 1 : 2; + + return parts.Length <= keep ? target : string.Join('.', parts[^keep..]); + } + + [GeneratedRegex(@"(?9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ + @foreach (var card in _cards) + { + +
@card.Title
+
+ } + + +
Drop a card to archive it
+
+ + +@code { + private record Card(string Title); + + private readonly List _cards = [new("Write the docs"), new("Review the PR")]; + + private void Archive(BdndDropEventArgs args) => _cards.Remove(args.Item!); +} +``` + +## What it is for + +Kanban boards and sortable lists, palettes you drag tools out of, dustbins and trays, file drop zones, chess and +card games, dashboards whose widgets can be rearranged, and any screen where something is picked up in one place and +put down in another - including files, text and links dragged in from outside the browser. + +## Highlights + +- **Declarative sources and targets**: `BdndDraggable` is React DnD's `useDrag`, `BdndDropTarget` is `useDrop`, and + their child content receives the collected state (`IsDragging`, `IsOver`, `CanDrop`, ...) to render from. +- **Strongly typed items** matched by **type strings**: a target accepts the types it lists in `Accept` or + `AcceptTypes`, and `CanDropPredicate` narrows that further from the item itself. +- **Custom drag layers**: hide the native drag image and render your own pointer-following preview with + `BdndDragLayer`, from the live offsets. +- **Drag handles**: restrict where a drag may start with `BdndDragHandle`, while the rest of the item stays + interactive. +- **Nesting**: nested drag sources drag the innermost one; nested drop targets report deep and shallow hover, and + drops reach them innermost first. +- **Drop results**: a target publishes a result, and the source's `OnEnd` receives it together with `DidDrop`, which + tells a drop from a cancel. +- **Native OS drags**: accept dropped files, selected text, links and HTML with `BdndNativeTypes`. +- **Isolated contexts** through nestable providers, and a read-only `IBdndMonitor` over the live drag state. +- **Robust** across re-renders: a dragged item may be re-parented, re-keyed or removed mid-drag and its `OnEnd` still + runs; hover survives the target's children being re-rendered. +- Works on **Blazor WebAssembly, Blazor Server, Blazor Web App and Blazor Hybrid**, on .NET 8, 9 and 10. + +## Quick start + +```bash +dotnet add package Bit.Bdnd +``` + +Register the service in `Program.cs` (in every project that renders the components - in a Blazor Web App, both +the server and the client): + +```csharp +builder.Services.AddBitBdndServices(); +``` + +Add the namespace to `_Imports.razor`: + +```razor +@using Bit.Bdnd +``` + +Wrap the region that drags and drops in a `BdndProvider` and declare sources and targets inside it: + +```razor + + +
Drag me
+
+ + +
@(drop.IsOver ? "Release!" : _dropped ?? "Drop here")
+
+
+ +@code { + private string? _dropped; +} +``` + +There is no stylesheet: the script module, `_content/Bit.Bdnd/bit-bdnd.js`, is imported by the components +themselves the first time they need it. The full walkthrough is in [getting started](#getting-started). + +## Requirements + +- .NET 8, .NET 9 or .NET 10 +- Blazor WebAssembly, Blazor Server, Blazor Web App or Blazor Hybrid, on an interactive render mode +- A pointer that raises HTML5 drag events (a mouse, a pen or a trackpad). Like React DnD's HTML5 backend, Bdnd has + no touch backend. + +## The demo + +The demo site, [bdnd.bitplatform.dev](https://bdnd.bitplatform.dev), ports React DnD's own examples, so a React DnD +user can open the same example on both sides: the chessboard tutorial, dustbins (single and multiple targets, copy +or move, inside an iframe, a stress test), drag-around (naive and with a custom drag layer), nesting (drag sources +and drop targets), sortable lists (simple, cancel on drop outside, a hundred-item stress test), handles and previews, +drop effects, and native files and HTML. Every page can show its own source. + +To run it locally: + +```bash +dotnet run --project src/Bdnd/Demo/Server +``` + +## How it fits together + +```text +BdndProvider creates a BdndDragDropManager and cascades it + | + |-- BdndDraggable drag source: Type + Item, renders from BdndDragState + | `-- BdndDragHandle optional grab region + |-- BdndDropTarget drop target: Accept + OnDrop, renders from BdndDropState + `-- BdndDragLayer custom preview, renders from BdndDragLayerState + +BdndDragDropManager the authoritative drag state, read through IBdndMonitor + | +bit-bdnd.js dragstart configuration, pointer tracking, hover tracking, drop effects, native drops +``` + +Everything ships in one package, `Bit.Bdnd`, under one namespace, `Bit.Bdnd`: + +| Part | Contents | +| --- | --- | +| The components | `BdndProvider`, `BdndDraggable`, `BdndDropTarget`, `BdndDragLayer`, `BdndDragHandle`. | +| The state | `BdndDragDropManager` and its read-only view `IBdndMonitor`; the collected states `BdndDragState`, `BdndDropState`, `BdndDragLayerState`; the callback arguments `BdndDropEventArgs`, `BdndDragEndContext` and `BdndNativeDropEventArgs`. | +| The values | `BdndXYCoord`, `BdndNativeTypes`, `BdndNativeFile`, `BdndDomRect`. | +| The services | `AddBitBdndServices()` and `BdndInterop`, which also measures elements for you. | + +## MCP server + +The demo hosts a [Model Context Protocol](https://modelcontextprotocol.io) server at +`https://bdnd.bitplatform.dev/mcp`, so an AI agent writing drag-and-drop code reads the library itself - its API, +this guide and the demo's working pages - instead of guessing at an API it half remembers. It is also served, next +to the other bit libraries, through the aggregated `https://bitplatform.dev/mcp` endpoint. + +```json +{ + "mcpServers": { + "bdnd": { "type": "http", "url": "https://bdnd.bitplatform.dev/mcp" } + } +} +``` + +Start with `SearchBdnd`, which searches the guide, every public type and member, the setup guides and the demo's +pages at once, and points each hit at the exact follow-up call. `GetBdndSetupGuide` gives the wiring for one render +mode, `GetBdndGuideSection` one section of this guide, `GetBdndApi` one public type with its members, real defaults +and documentation, and `GetBdndSourceFile` one of the demo's working pages; each returns its index when called +without a key. `InspectBdndDrop` runs a drag - a draggable's type and a tree of drop targets, as JSON - through the +real components and reports which targets can drop, which are hovered, the order the drops run in and what `OnEnd` +receives, or, called with no scenario, returns a sample one. Every tool is read-only. + +## Documentation + +- [Getting started](#getting-started) +- [The provider](#the-provider) +- [Draggables](#draggables) +- [Drop targets](#drop-targets) +- [Nesting](#nesting) +- [Custom drag layers](#custom-drag-layers) +- [Drag handles](#drag-handles) +- [Sortable lists](#sortable-lists) +- [Native drags](#native-drags) +- [The monitor](#the-monitor) +- [Hosting models](#hosting-models) +- [Troubleshooting](#troubleshooting) +- [Architecture](#architecture) +- [Coming from React DnD](#coming-from-react-dnd) +- [Component reference](#component-reference) + +## Getting started + +### Install + +```bash +dotnet add package Bit.Bdnd +``` + +### Register the service + +```csharp +using Bit.Bdnd; + +builder.Services.AddBitBdndServices(); +``` + +`AddBitBdndServices()` registers `BdndInterop`, the scoped wrapper around the script module that every draggable +and drop target injects. Calling it more than once is harmless. The drag-and-drop manager itself is not a service: +each `BdndProvider` creates its own and shares it with the components inside it. + +In a Blazor Web App with an interactive client, register it in both `Program.cs` files: the pages are prerendered on +the server before they run in the browser, so anything a component injects has to resolve in both containers. + +### A first drag and drop + +```razor +@page "/kanban" +@rendermode InteractiveWebAssembly + + +
+ @foreach (var column in _columns) + { + +
+

@column

+ @foreach (var todo in _todos.Where(t => t.Column == column)) + { + +
@todo.Title
+
+ } +
+
+ } +
+
+ +@code { + private class TodoItem + { + public required string Title { get; init; } + public required string Column { get; set; } + } + + private readonly string[] _columns = ["To do", "Doing", "Done"]; + + private readonly List _todos = + [ + new() { Title = "Write the docs", Column = "To do" }, + new() { Title = "Review the PR", Column = "Doing" }, + ]; + + private void Move(TodoItem todo, string column) => todo.Column = column; +} +``` + +Three things make this work: the components sit inside one `BdndProvider`; the draggable's `Type` is one of the +types the target lists in `Accept`; and the page is interactive, because a statically rendered page never wires up +drag events. + +### Where to go next + +- Reorder items in a list: [sortable lists](#sortable-lists). +- Render your own preview instead of the browser's ghost image: [custom drag layers](#custom-drag-layers). +- Only start a drag from a grip: [drag handles](#drag-handles). +- Accept files dropped from the desktop: [native drags](#native-drags). + +## The provider + +`BdndProvider` is the root of a drag-and-drop context, the equivalent of React DnD's +``. It creates a `BdndDragDropManager` and cascades it to every descendant +`BdndDraggable`, `BdndDropTarget` and `BdndDragLayer`. A draggable, drop target or drag layer outside a provider +throws an `InvalidOperationException` when it initializes. + +### One context per provider + +A drag started by a draggable is seen by the drop targets and drag layers of the same provider. Put a provider +around each region that drags independently - or one around the whole layout when items move between regions. + +### Nesting providers + +Providers nest. The components inside an inner provider belong to its manager and ignore drags of the outer one: +an item of the outer provider is not accepted by a target of the inner provider, even when its type matches, and a +nested target's shallow hover only considers targets of its own provider. + +### Supplying your own manager + +Pass `Manager` to share one manager between two providers, or to read the monitor from outside the subtree: + +```razor +... + +@code { + private readonly BdndDragDropManager _manager = new(); + + private bool IsBusy => _manager.Monitor.IsDragging(); +} +``` + +The manager of a provider cannot be changed once set; replacing it throws, because the descendants keep the manager +they were initialized with. `BdndDragDropManager` raises `StateChanged` when a drag begins or ends or a hover +changes, and `OffsetChanged` as the pointer moves. + +## Draggables + +`BdndDraggable` renders a host element (a `div`, or the `Tag` you choose) and wires it into the system as a +drag source. Every attribute it does not know - `class`, `style`, `id`, `data-*` - goes onto that element. + +### Type and item + +`Type` is required: it is the string drop targets match against their `Accept` list. `Item` is the payload a target +receives. Use a constant class for the type strings, as the React DnD docs recommend: + +```csharp +public static class ItemTypes +{ + public const string Card = "card"; + public const string Knight = "knight"; +} +``` + +```razor +... +``` + +### Collected state + +The child content receives a `BdndDragState`: `IsDragging` (this source is the one being dragged), `CanDrag` and the +`Monitor`. Name it with `Context`: + +```razor + +
@card.Title
+
+``` + +### Lazy items and cancelling a drag + +`ItemFactory` produces the item when the drag starts instead of at render time; it wins over `Item`. Returning +`null` cancels the drag (for a value-type `TItem`, use a nullable one such as `int?`, since `0` is a valid item). In +Blazor WebAssembly the native drag is cancelled too; in Blazor Server the browser has +already started it by the time .NET answers, so it goes on as an inert drag that no target accepts. + +```razor + +``` + +### Allowing and forbidding drags + +`CanDrag` (default `true`) turns dragging on and off. `CanDragPredicate` overrides it with a function evaluated at +render time and again when a drag starts. A draggable that cannot drag renders `draggable="false"`. + +### Lifecycle: OnBegin and OnEnd + +`OnBegin` runs when the drag begins, with the item, before any hover, drop or end callback of that drag. `OnEnd` +runs when it ends - dropped or cancelled - with a `BdndDragEndContext`: + +| Member | Meaning | +| --- | --- | +| `Item` | The item that was dragged. | +| `DidDrop` | True if a drop target accepted the drop; false for a cancel or a drop on nothing. | +| `DropResult` | The result the handling target published, if any. | +| `Monitor` | The provider's monitor. | + +`OnEnd` runs even if the draggable was removed during the drag - for instance because the drop re-rendered the list +it was in. The drag state is cleared right after `OnEnd`'s synchronous part, so an async handler does not keep every +target highlighted. + +```razor + +``` + +### Drop effects + +`DropEffect` sets the native `dataTransfer.effectAllowed`, which is what the browser draws the cursor from: `move` +(default), `copy`, `link`, `copyMove`, `copyLink`, `linkMove` or `all`. With a combined value such as `copyMove` +the user picks with a modifier key (Ctrl, or ⌥ on macOS). + +### Host element and attributes + +`Tag` picks the host element (`li`, `tr`, `span`...). The host is a real element, so a draggable inside a flex or +grid container is a flex or grid item; give it `style="display:contents"` to take it out of the layout and let its +child be the item instead. + +### Custom isDragging + +`IsDragging` is true for the instance that started the drag. When an item can be re-rendered by a different +component instance mid-drag - moved to another list, re-parented - decide it from the item instead: + +```razor + +``` + +## Drop targets + +`BdndDropTarget` renders a host element (or `Tag`) and wires it into the system as a drop target. Unknown +attributes go onto that element. + +### Accepting types + +`Accept` takes one type; `AcceptTypes` takes several; both combine. A target accepts a drag whose item type is in +the list - the item's .NET type does not take part: + +```razor + +``` + +### Collected state + +The child content receives a `BdndDropState`: + +| Member | Meaning | +| --- | --- | +| `IsOver` | A drag is in progress and the pointer is over this target or a target nested in it. | +| `IsOverShallow` | The pointer is over this target and not over a nested one. | +| `CanDrop` | This target can accept the item being dragged - for every compatible in-app drag, hovered or not (a native drag is only known once it hovers the target). | +| `ItemType` | The type of the item being dragged, or null. | +| `Item` | The item being dragged, when it is a `TItem`. | +| `Monitor` | The provider's monitor. | + +`CanDrop` without `IsOver` is how a chessboard highlights the legal squares while a piece is in the air. + +### Dropping + +`OnDrop` receives a `BdndDropEventArgs` with the `Item` and the `Monitor`. It only runs for a drag this +target can accept, and the drop's final pointer position is recorded first, so +`Monitor.GetDifferenceFromInitialOffset()` gives the exact distance moved: + +```razor + + +@code { + private void MoveBox(BdndDropEventArgs e) + { + var delta = e.Monitor.GetDifferenceFromInitialOffset()!.Value; + e.Item!.Left += delta.X; + e.Item.Top += delta.Y; + } +} +``` + +### Drop results + +Set `args.Result` in `OnDrop` to tell the source what happened. The source reads it in `OnEnd` as `DropResult` +(or `Monitor.GetDropResult()`). With nested targets every accepting target's `OnDrop` runs, innermost first, and +a non-null result set by an outer target overrides an inner one - React DnD's rule. An outer target can check +`args.Monitor.DidDrop()` to see whether an inner one already handled the drop. + +### Hovering + +`OnHover` runs repeatedly while an item of an accepted type is over the target, with the same arguments as +`OnDrop` - like React DnD's `hover`, also when `CanDropPredicate` refuses the item. It is what sortable lists +reorder from. The `dragover` listener behind it is only attached when `OnHover` has a handler, +because `dragover` fires continuously and, on Blazor Server, each one is a message over the circuit. + +### Gating drops with CanDropPredicate + +`CanDropPredicate` receives the item and the monitor and decides whether this target accepts it, on top of the type +check: + +```razor + +``` + +It is evaluated when the target renders, so keep it cheap and free of side effects. + +### Feedback while dragging + +Over a target that cannot accept the drag - wrong type, or a predicate answering false - the browser shows its +no-drop cursor and no drop happens; an enclosing target that can accept the item still receives it. Over a target +that can, the cursor follows the source's `DropEffect`. + +## Nesting + +### Nested drag sources + +A draggable inside another draggable drags itself, not its ancestors: the innermost source claims the `dragstart` +and stops it from bubbling to the outer ones. Grab the outer source by the area not covered by the inner one. + +### Nested drop targets + +Drop targets nest naturally. While the pointer is over an inner target, both it and its ancestors report `IsOver`; +only the innermost hovered one reports `IsOverShallow`. On drop, every accepting target in the chain runs `OnDrop`, +innermost first (see [drop results](#drop-results)). + +Targets of a nested provider are skipped when Bdnd works out a target's parent, so shallow hover is decided among +the targets of one provider. + +## Custom drag layers + +The browser draws a translucent snapshot of the source while it is dragged. To draw your own preview, set +`UseCustomDragLayer="true"` on the draggable - which replaces the native image with an empty one - and render the +preview in a `BdndDragLayer` inside the same provider: + +```razor +@using System.Globalization + + + @if (layer.IsDragging && layer.SourceClientOffset is { } pos) + { + @* format with the invariant culture: a decimal comma would be invalid CSS *@ +
+ @layer.Item?.Title +
+ } +
+``` + +The layer's `BdndDragLayerState` carries `IsDragging`, `ItemType`, `Item` and the offsets: + +| Offset | Meaning | +| --- | --- | +| `InitialClientOffset` | The pointer when the drag started. | +| `InitialSourceClientOffset` | The source element's top-left corner when the drag started. | +| `ClientOffset` | The pointer now. | +| `DifferenceFromInitialOffset` | How far the pointer has moved. | +| `SourceClientOffset` | Where the source's top-left corner would be now: where to draw the preview. | + +The layer re-renders on every pointer move, so keep its markup small, give it `pointer-events: none` (or the +preview itself becomes the drop target under the pointer), and position it `fixed`, because the offsets are client +coordinates. + +## Drag handles + +Set `UseHandle="true"` on a draggable and put a `BdndDragHandle` inside it; drags then only start from the handle, +and the rest of the item - inputs, buttons, selectable text - stays interactive: + +```razor + +
+ + ⠿ + + +
+
+``` + +The handle is the element the browser drags, but Bdnd still pictures the whole draggable as the drag image. The +handle prepends `cursor: move` to its `Style` while the draggable can drag, and takes any other attribute you give it. + +## Sortable lists + +A sortable list is a list of cards that are each both a draggable and a drop target. While a card is hovered, its +`OnHover` compares the pointer with the hovered card's vertical midpoint and moves the dragged card past it - React +DnD's algorithm, which avoids flicker: + +```razor +@inject BdndInterop Interop + + + +
@Card.Text
+
+
+ +@code { + public class DragItem { public int Id { get; init; } public int Index { get; set; } } + + [Parameter, EditorRequired] public CardModel Card { get; set; } = default!; + [Parameter] public int Index { get; set; } + [Parameter] public EventCallback<(int From, int To)> OnMove { get; set; } + + private ElementReference _element; + private DragItem _dragItem = new(); + + protected override void OnParametersSet() + { + if (_dragItem.Id != Card.Id) _dragItem = new DragItem { Id = Card.Id }; + _dragItem.Index = Index; + } + + private async Task HandleHover(BdndDropEventArgs e) + { + if (e.Item is not { } item || item.Index == Index) return; + var rect = await Interop.GetBoundingClientRectAsync(_element); + if (rect is null || e.Monitor.GetClientOffset() is not { } pointer) return; + + var middle = rect.Height / 2; + var y = pointer.Y - rect.Top; + if (item.Index < Index && y < middle) return; + if (item.Index > Index && y > middle) return; + + await OnMove.InvokeAsync((item.Index, Index)); + item.Index = Index; // the dragged item now sits here + } +} +``` + +Three details matter: + +- Render the cards with `@key`, so a reorder moves elements instead of re-binding them. +- Mutate the dragged item's index after a move, as the code above does: the next hover compares against it. +- Make the list itself a drop target that accepts the card type as well. Then the gaps between cards are not no-drop + zones, and a drop in a gap still counts as a drop (`DidDrop` is true), which is what "cancel on drop outside" - + reverting the order in `OnEnd` when `DidDrop` is false - relies on. + +The demo's `Pages/Sortable/SortableCard.razor` is the complete card, measuring guard included. + +## Native drags + +A drop target can accept drags that start outside the application by listing one of the `BdndNativeTypes`: + +| Constant | Source | DataTransfer type | +| --- | --- | --- | +| `BdndNativeTypes.File` | Dropped files | `Files` | +| `BdndNativeTypes.Url` | Dragged links | `text/uri-list` | +| `BdndNativeTypes.Text` | Selected text | `text/plain` | +| `BdndNativeTypes.Html` | Dragged HTML | `text/html` | + +Native drops arrive through `OnNativeDrop`, not `OnDrop`, with a `BdndNativeDropEventArgs`: the matched `ItemType`, +the `Files`, the raw `Data` keyed by DataTransfer type, and the `Text`, `Html`, `Url` and `Urls` shortcuts. While a +native drag hovers, the target's state reports `IsOver`, `IsOverShallow` and `CanDrop` as true and `ItemType` as the +matched native type. Use `TItem="object"` on a target that only takes native drags. + +Native drops have no `DidDrop`: when drop targets that accept the same native type are nested, each of them +receives `OnNativeDrop`, innermost first, and an outer one cannot tell that an inner one already took the drop. Give +only one of them the native type, or keep the decision in your own state. + +### Files + +```razor + +
Drop files here
+
+ +@code { + private void HandleFiles(BdndNativeDropEventArgs e) + { + foreach (var file in e.Files) + Console.WriteLine($"{file.Name} ({file.Type}, {file.Size} bytes)"); + } +} +``` + +The browser only exposes a dropped file's metadata - name, size and MIME type - during the drop. To read the +contents, use an `InputFile` (for instance an invisible one layered over the drop zone) or your own JS interop. + +### Text, links and HTML + +```razor + +``` + +The first accepted type found in the drag decides `ItemType`, in the order the target lists them; `Data` holds every +string the drag carried, whichever matched. + +### Editable fields inside a target + +A native drag over a text input, a textarea or a `contenteditable` element inside a drop target is left to the +browser, so text can still be dropped into the field. A drop anywhere else on a target is never handed to the +browser, which would otherwise open or navigate to the dropped file or link. + +## The monitor + +`IBdndMonitor` is a read-only view over a provider's drag state - the union of React DnD's `DragSourceMonitor`, +`DropTargetMonitor` and `DragLayerMonitor`. Every collected state and callback argument carries it as `Monitor`, and +`BdndDragDropManager.Monitor` exposes it outside the components. + +| Member | Answers | +| --- | --- | +| `IsDragging()` | Whether a drag is in progress. | +| `IsDragging(sourceId)` | Whether the given source is the one being dragged (`null` means any). | +| `GetItemType()` | The dragged item's type, or null. | +| `GetItem()` / `GetItem()` | The dragged item. | +| `GetDropResult()` / `GetDropResult()` | The result recorded by the targets that handled the drop. | +| `DidDrop()` | Whether a target has handled the drop. | +| `CanDrop(handlerId)` / `IsOver(handlerId, shallow)` | A target's state by handler id. | +| `GetInitialClientOffset()` | The pointer when the drag started. | +| `GetInitialSourceClientOffset()` | The source's top-left corner when the drag started. | +| `GetClientOffset()` | The pointer now. | +| `GetDifferenceFromInitialOffset()` | How far the pointer has moved. | +| `GetSourceClientOffset()` | Where the source's top-left corner would be now. | + +Offsets are `BdndXYCoord` values in client (viewport) CSS pixels; they subtract and add with `-` and `+`. The +handler-id members mirror React DnD's monitors, but the components identify themselves with private handler ids, +so application code reads a component's own answers from its collected state (`IsDragging`, `CanDrop`, `IsOver`, +`IsOverShallow`) rather than through those members; `IsDragging(null)` is the same as `IsDragging()`. +`BdndDragDropManager.GenerateHandlerId(prefix)` hands out ids unique within a manager for code of your own. + +The monitor is live: inside an async `OnEnd`, `OnDrop` or `OnHover` handler, read what you need from it before the +first `await` - the drag may have ended by the time the handler resumes. `BdndDragEndContext` carries `Item`, +`DidDrop` and `DropResult` as they were when the drag ended. + +`BdndInterop.GetBoundingClientRectAsync(element)` measures an element in the same client coordinates - the +measurement a sortable list compares the pointer against. + +## Hosting models + +Bdnd needs an interactive render mode: drag events are wired up by the components after they render interactively. +On a statically rendered page the markup appears but nothing can be dragged. + +### Blazor WebAssembly + +The best fit. Every `dragover` and hover change is handled without a network round trip, `ItemFactory` can cancel +the native drag, and the source's start rectangle is read synchronously while `dragstart` is dispatched, so a custom +drag layer is aligned from the very first frame. + +### Blazor Server + +Works the same, with three differences that come from the circuit: drag callbacks run a round trip later than the +native events; a drag `ItemFactory` cancels cannot be stopped in the browser any more, so it goes on as an inert +drag that no target accepts; and a custom drag layer starts from an estimated source position that is corrected as +soon as the measured one arrives. Prefer `OnHover` only where you need it - it is the one callback that fires +continuously. + +### Blazor Web App + +Register `AddBitBdndServices()` in the server's and the client's `Program.cs`, and put the pages that drag in the +client project with `InteractiveWebAssembly` (or `InteractiveAuto`); the demo is built exactly this way. + +### Blazor Hybrid + +In a MAUI, WPF or Windows Forms `BlazorWebView` the JS runtime is not in-process, so the components behave as on +Blazor Server (without the network latency). Drag-and-drop support of the platform's web view applies: desktop web +views support HTML5 drags; touch input on mobile web views generally does not raise them. + +### Prerendering + +Prerendered markup is shown before the page is interactive; draggables and drop targets render their idle state +and start working once the page becomes interactive. No browser API is called while prerendering. + +## Troubleshooting + +| Symptom | Cause and fix | +| --- | --- | +| `InvalidOperationException: ... must be placed inside a BdndProvider` | Wrap the components in a `BdndProvider`. | +| `Cannot provide a value for property 'Interop'` | `AddBitBdndServices()` is missing - in a Web App, from one of the two `Program.cs` files. | +| Nothing drags at all | The page is not interactive: add a render mode (`@rendermode InteractiveWebAssembly`), or render it from an interactive parent. | +| The item drags but the target never lights up | The draggable's `Type` is not in the target's `Accept`/`AcceptTypes`, the target's `CanDropPredicate` answers false, or source and target are in different providers. | +| The no-drop cursor shows in the gaps of a list | Make the list a drop target that accepts the item type too. | +| `OnDrop` runs on the outer target as well | Nested targets all receive the drop, innermost first; check `args.Monitor.DidDrop()` in the outer one. | +| A custom preview flickers or blocks the drop | Give the preview `pointer-events: none` and `position: fixed`. | +| `left: 12,5px` in a preview's style | Format offsets with `CultureInfo.InvariantCulture`. | +| A dropped file has no contents | Browsers expose only file metadata during a drop; read the contents with `InputFile` or your own interop. | +| `OnEnd` reports `DidDrop = false` after dropping between two items | The drop landed on no target; see the gaps row above. | + +## Architecture + +### The shape of the system + +```text +Blazor managed events ondragstart, ondragover (only with OnHover), ondrop + | | +BdndDraggable / BdndDropTarget -> BdndDragDropManager -> StateChanged / OffsetChanged -> re-render + ^ ^ + | [JSInvokable] HandleDragMove, HandleDragEnd, HandleHoverChanged, HandleNativeDrop +bit-bdnd.js (one registration per draggable and per drop target) +``` + +`BdndDragDropManager` owns the authoritative state - the item, its type, the source, the offsets, every target's +accept list and hover flag, the drop result. Components read it through `IBdndMonitor` when they render, and +re-render when the manager raises `StateChanged` (begin, end, hover changes) or, for drag layers, `OffsetChanged`. +Draggables and drop targets suppress the automatic re-render `ComponentBase` performs after each event, so a +`dragover` storm does not re-render the page; the manager decides when something changed. + +### The browser layer + +Managed event handlers cannot touch a live `DataTransfer`, and on Blazor Server they do not run while the native +event is being dispatched. The script module covers exactly that gap: + +1. At `dragstart`, it decides which source a drag belongs to (the innermost registered one, and only from a handle + when `UseHandle` is set), tags the drag with a `DataTransfer` type carrying the source's handler id, sets + `effectAllowed` and the drag image, and measures the source. +2. During the drag, it follows the pointer from `dragover` (Firefox reports `0,0` in `drag` events) and reports it + once per animation frame. +3. It reports the end of a drag from the source element's own `dragend` listener, which still fires after the + element was removed from the DOM - and, because some browsers never raise `dragend` for a removed source, also + after a drop or the next mouse move. +4. It tracks hover per drop target by the nodes that raised `dragenter`, pruning nodes Blazor re-rendered away, + so a target's hover survives its children changing. +5. It gives drop-effect feedback - `preventDefault` and `dropEffect` on `dragenter`/`dragover`, innermost target + first - and reads native drop payloads during the `drop` event, the only moment `getData` works. + +### How a drag flows + +1. The browser raises `dragstart`; the module tags it; the draggable's managed handler sees its own tag, produces + the item and calls `BeginDrag` on the manager - without notifying anyone yet, because a re-render during + `dragstart` can abort the drag in some browsers. +2. Once dispatch is over, the manager publishes the drag: `StateChanged` re-renders the components and `OnBegin` + runs. Every later callback publishes a pending begin first, so `OnBegin` always comes first. +3. Hover changes arrive from the module; targets that accept the type set their hover flag; `CanDrop` is decided + from the accept list and the predicate. +4. On drop, each accepting target in the chain runs `OnDrop`, innermost first, and records the drop and its result. +5. The source's `dragend` reaches .NET; `OnEnd` runs with `DidDrop` and `DropResult`, and the manager clears the + drag. + +## Coming from React DnD + +| React DnD | Bit.Bdnd | +| --- | --- | +| `` | `` (the HTML5 backend is built in) | +| `useDrag({ type, item })` | `` | +| `item: () => ...` | `ItemFactory` | +| `canDrag` | `CanDrag` / `CanDragPredicate` | +| `isDragging: monitor => ...` | `IsDraggingOverride` | +| `end(item, monitor)` | `OnEnd` with `BdndDragEndContext` | +| `collect` of `useDrag` | the child content's `BdndDragState` | +| `useDrop({ accept })` | `` | +| `drop(item, monitor)` returning a result | `OnDrop`, setting `args.Result` | +| `hover(item, monitor)` | `OnHover` | +| `canDrop(item, monitor)` | `CanDropPredicate` | +| `isOver({ shallow: true })` | `IsOverShallow` | +| `collect` of `useDrop` | the child content's `BdndDropState` | +| `useDragLayer` | `` and `BdndDragLayerState` | +| `getEmptyImage()` + `preview` | `UseCustomDragLayer="true"` | +| `dragPreview` / a separate drag handle ref | `UseHandle="true"` and `` | +| `dropEffect` option | `DropEffect` | +| `NativeTypes.FILE` / `URL` / `TEXT` / `HTML` | `BdndNativeTypes.File` / `Url` / `Text` / `Html` | +| `DragSourceMonitor` / `DropTargetMonitor` / `DragLayerMonitor` | `IBdndMonitor` | +| `XYCoord` | `BdndXYCoord` | + +What is different: there are no connector refs to attach - the component's host element is the source or target; +the item type is matched by string, never by .NET type; and native drops have their own callback, `OnNativeDrop`, +instead of arriving in `drop`. + +## Component reference + +### BdndProvider + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `Manager` | `BdndDragDropManager?` | `null` | An externally-owned manager. When omitted, one is created for the subtree. Cannot change once set. | +| `ChildContent` | `RenderFragment?` | | The drag-and-drop content. | + +### BdndDraggable<TItem> + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `Type` | `string` | (required) | The item type matched against drop targets' `Accept` lists. | +| `Item` | `TItem?` | | The item payload. Ignored when `ItemFactory` is supplied. | +| `ItemFactory` | `Func?` | | Produces the item at drag start; returning `null` cancels the drag. | +| `CanDrag` | `bool` | `true` | Whether dragging is allowed. | +| `CanDragPredicate` | `Func?` | | Overrides `CanDrag`; evaluated at render time and at drag start. | +| `IsDraggingOverride` | `Func?` | | A custom `IsDragging`, e.g. by item id. | +| `UseCustomDragLayer` | `bool` | `false` | Hide the native drag image (pair with a `BdndDragLayer`). | +| `UseHandle` | `bool` | `false` | Only start drags from a `BdndDragHandle` inside. | +| `DropEffect` | `string` | `"move"` | `dataTransfer.effectAllowed`: `move`, `copy`, `link`, `copyMove`, `copyLink`, `linkMove` or `all`. | +| `OnBegin` | `EventCallback` | | The drag began. | +| `OnEnd` | `EventCallback>` | | The drag ended (drop or cancel), even if the draggable was removed meanwhile. | +| `Tag` | `string` | `"div"` | The host element's tag. | +| `ChildContent` | `RenderFragment?` | | The content, given the collected state. | +| (any other attribute) | | | Applied to the host element. | + +### BdndDropTarget<TItem> + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `Accept` | `string?` | | One accepted item type. | +| `AcceptTypes` | `IEnumerable?` | | More accepted item types, combined with `Accept`. | +| `OnDrop` | `EventCallback>` | | A compatible in-app item was dropped; set `args.Result` to publish a result. Nested targets run innermost first. | +| `OnHover` | `EventCallback>` | | An in-app item of an accepted type is hovering (called repeatedly, whatever `CanDropPredicate` answers). | +| `OnNativeDrop` | `EventCallback` | | A native drag of an accepted `BdndNativeTypes` type was dropped. | +| `CanDropPredicate` | `Func?` | | Whether this target accepts the current item, on top of the type check. | +| `Tag` | `string` | `"div"` | The host element's tag. | +| `ChildContent` | `RenderFragment>?` | | The content, given the collected state. | +| (any other attribute) | | | Applied to the host element. | + +### BdndDragLayer<TItem> + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `ChildContent` | `RenderFragment>?` | | The preview, given the live drag state and offsets. | + +### BdndDragHandle + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `ChildContent` | `RenderFragment?` | | The handle's content. | +| `Style` | `string?` | | The handle's inline style; `cursor: move` is prepended while the draggable can drag. | +| (any other attribute) | | | Applied to the handle element. | + +## License + +Bit.Bdnd is part of the [bit platform](https://github.com/bitfoundation/bitplatform) and is licensed under the +[MIT License](https://github.com/bitfoundation/bitplatform/blob/develop/LICENSE). diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/AssemblySetup.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/AssemblySetup.cs new file mode 100644 index 00000000000..d670338e657 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/AssemblySetup.cs @@ -0,0 +1,31 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; + +// The tests run one at a time. A drag is a sequence of native events whose handlers round-trip into .NET between +// them, and a page booting WebAssembly in the next context over is exactly the kind of load that turns "the hover +// has been rendered by now" into a flake on a two-core runner. +[assembly: DoNotParallelize] + +namespace Bit.Bdnd.Tests.E2E; + +[TestClass] +public static class AssemblySetup +{ + [AssemblyInitialize] + public static async Task InitializeAsync(TestContext context) + { + // A WebAssembly boot routinely takes more than Playwright's 5 second default on a CI runner, without + // anything being wrong. + Assertions.SetDefaultExpectTimeout(20_000); + + // Started here rather than by the first test, so the boot is not charged to that test's time. Nothing + // throws: a browser or a demo that is missing is reported by every test (see BdndTest). + await PlaywrightSession.GetAsync(); + } + + [AssemblyCleanup] + public static async Task CleanupAsync() + { + await PlaywrightSession.StopAsync(); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj new file mode 100644 index 00000000000..b8742ede33a --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + false + $(NoWarn);CS1591 + + + true + Exe + + + + + + + + + + + diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/CustomizeTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/CustomizeTests.cs new file mode 100644 index 00000000000..969241b789a --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/CustomizeTests.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; +using Bit.Bdnd.Tests.E2E.Infrastructure; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Customizing a source: a drag handle decides where a drag may start, which the browser decides from the +/// draggable attribute and the module from the handle under the pointer; and a source's drop effect is what +/// the module puts on the native drag at dragstart, tagged with the source's own id. +/// +[TestClass] +public sealed partial class CustomizeTests : BdndTest +{ + private const string DarkGreen = "rgb(0, 100, 0)"; + + [TestMethod] + public async Task A_box_without_a_handle_drags_from_anywhere_on_it() + { + var page = await OpenAsync("/customize/handles"); + var whole = page.Locator(".box", new() { HasText = "Drag me from anywhere" }); + var bin = page.Locator(".dustbin"); + var box = await BoxAsync(whole); + + // By its left edge rather than its middle. + await StartDragAsync(page, new Point(box.X + 8, box.Centre.Y)); + await Expect(whole).ToHaveCSSAsync("opacity", "0.4"); + await DragToAsync(page, (await BoxAsync(bin)).Centre, () => Expect(bin).ToHaveCSSAsync("background-color", DarkGreen)); + + await Expect(bin).ToHaveTextAsync("Dropped: whole"); + } + + [TestMethod] + public async Task A_box_with_a_handle_drags_only_from_its_grip() + { + var page = await OpenAsync("/customize/handles"); + var handleBox = page.Locator(".box", new() { HasText = "Only the grip drags me" }); + var label = page.GetByText("Only the grip drags me"); + var bin = page.Locator(".dustbin"); + var binCentre = (await BoxAsync(bin)).Centre; + + // Pressed on its text, the box does not drag: the browser starts no drag at all, and the bin never reacts. + await StartDragAsync(page, label); + await MoveAsync(page, binCentre); + await WaitForQuietAsync(page); + await Expect(bin).ToHaveCSSAsync("background-color", "rgb(51, 51, 51)"); + await Expect(handleBox).ToHaveCSSAsync("opacity", "1"); + await page.Mouse.UpAsync(); + await WaitForQuietAsync(page); + Assert.IsEmpty(await DragStartsAsync(page.MainFrame), "a press on the text of a box with a handle started a drag"); + await Expect(bin).ToHaveTextAsync("Drop a box here"); + + // The press selected some text; a press on a selection would drag the text instead of the box. + await page.EvaluateAsync("() => getSelection().removeAllRanges()"); + + // Pressed on the grip, the whole box drags. + await StartDragAsync(page, page.Locator(".handle")); + await Expect(handleBox).ToHaveCSSAsync("opacity", "0.4"); + await DragToAsync(page, binCentre, () => Expect(bin).ToHaveCSSAsync("background-color", DarkGreen)); + + await Expect(bin).ToHaveTextAsync("Dropped: handle"); + await Expect(handleBox).ToHaveCSSAsync("opacity", "1"); + } + + [TestMethod] + public async Task A_drag_is_tagged_with_its_source_and_carries_the_drop_effect_the_source_declares() + { + var page = await OpenAsync("/customize/drop-effects"); + var bin = page.Locator(".dustbin"); + var tags = new List(); + + foreach (var effect in new[] { "copy", "move" }) + { + var source = page.Locator(".box", new() { HasText = $"(effect = {effect})" }); + var centre = (await BoxAsync(bin)).Centre; + await StartDragAsync(page, source); + await MoveAsync(page, centre); + await Expect(bin).ToHaveCSSAsync("background-color", DarkGreen); + + // Over a target that takes the item, the module accepts each dragover and leaves the effect to the + // browser, which picks it from what the source allows: the cursor shows a copy or a move. (A dragover + // raised now, once .NET has told the module the bin can take the item.) + var over = await DragOverAtAsync(page, bin, centre); + Assert.IsTrue(over.Inside && over.Prevented, $"the dragover over the bin was not accepted ({effect}): {over}"); + Assert.AreEqual(effect, over.DropEffect, $"the drop effect over the bin: {over}"); + + await DragToAsync(page, centre); + await Expect(bin).ToHaveTextAsync($"Last drop used: {effect}"); + + // At dragstart the module marked the drag as the library's, with the source's id as both the type's + // suffix and its data, and set the effects the source allows. + var start = (await DragStartsAsync(page.MainFrame)).Last(); + Assert.IsNotNull(start.Tag, "the dragstart carries no library tag: " + string.Join(", ", start.Types)); + StringAssert.Matches(start.Tag, TagPattern()); + Assert.AreEqual(start.Tag["application/x-bit-bdnd-".Length..], start.TagData, "the tag's data is not the source's id"); + Assert.AreEqual(effect, start.EffectAllowed, "effectAllowed at dragstart"); + tags.Add(start.Tag); + } + + Assert.AreNotEqual(tags[0], tags[1], "two sources tagged their drags with the same id"); + + // Every drop was kept from the browser, which would otherwise act on the dropped data. + var (count, prevented) = await DropsAsync(page.MainFrame); + Assert.AreEqual(2, count, "drops"); + Assert.AreEqual(2, prevented, "drops the module left to the browser"); + } + + [GeneratedRegex("^application/x-bit-bdnd-[0-9a-f]{32}$")] + private static partial Regex TagPattern(); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DemoPagesTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DemoPagesTests.cs new file mode 100644 index 00000000000..215c5028696 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DemoPagesTests.cs @@ -0,0 +1,117 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Every page of the demo: each one boots on WebAssembly, loads the browser module and registers every draggable +/// with it, shows its own source, and survives every button on it without an error; and moving between them in the +/// app leaves a page that still drags. +/// +[TestClass] +public sealed class DemoPagesTests : BdndTest +{ + /// Every routable page of the demo, read from the @page directives of its sources. + public static IEnumerable DemoRoutes => RepoLayout.DemoRoutes().Select(route => new object[] { route }); + + [TestMethod] + [DynamicData(nameof(DemoRoutes))] + public async Task Every_demo_page_loads_interactively_and_runs_every_button_without_an_error(string path) + { + var page = await NewPageAsync(); + + var response = await GotoAsync(page, path); + Assert.AreEqual(200, response?.Status, $"{path} answered {response?.Status}"); + await WaitForInteractiveAsync(page.MainFrame); + + // A page with draggables has loaded the library's browser module, and registered every one of them with it: + // an unregistered draggable is one nothing can drag. + int draggables = await page.Locator(DraggableHosts).CountAsync(); + if (draggables > 0) + { + await WaitForBdndAsync(page.MainFrame); + Assert.IsTrue(await page.EvaluateAsync( + // The import map may point the module at a fingerprinted file, bit-bdnd..js. + @"() => performance.getEntriesByType('resource').some(e => /^\/_content\/Bit\.Bdnd\/bit-bdnd(\.\w+)?\.js$/.test(new URL(e.name).pathname))"), + $"{path} has {draggables} draggable(s) but never loaded bit-bdnd.js"); + } + + // "View code" shows the page's own source, stripped of its routing directive. + var toggle = page.Locator(".demo-code__toggle"); + if (await toggle.CountAsync() > 0) + { + await toggle.ClickAsync(); + var code = page.Locator(".demo-code__code"); + await Expect(code).ToBeVisibleAsync(); + var source = await code.TextContentAsync() ?? ""; + Assert.IsTrue(source.Length > 100, $"{path} shows a source of only {source.Length} characters"); + Assert.DoesNotContain("@page", source, $"{path}: the snippet still carries the routing directive"); + } + + // Every other enabled button of the page must run without throwing: a dead or broken control is a demo bug. + var buttons = page.Locator(".content button:enabled:visible:not(.demo-code__toggle)"); + int count = await buttons.CountAsync(); + for (int i = 0; i < count; i++) + { + await buttons.Nth(i).ClickAsync(new LocatorClickOptions { Timeout = 3000 }); + await WaitForQuietAsync(page, 100); + } + await WaitForQuietAsync(page); + + // Draggables a button added (the stress test's "Add 10 boxes") are registered like the first ones. + if (draggables > 0) await WaitForBdndAsync(page.MainFrame); + + Assert.IsFalse(await page.Locator("#blazor-error-ui").IsVisibleAsync(), $"{path} shows the unhandled-error banner"); + TestContext.WriteLine($"{path}: {draggables} draggable(s), {count} button(s) exercised"); + } + + [TestMethod] + public async Task View_code_shows_the_source_of_the_page_and_hides_it_again() + { + var page = await OpenAsync("/dustbin/single"); + + var toggle = page.GetByRole(AriaRole.Button, new() { Name = "View code" }); + await toggle.ClickAsync(); + + var code = page.Locator(".demo-code__code"); + await Expect(code).ToBeVisibleAsync(); + var source = await code.TextContentAsync() ?? ""; + StringAssert.Contains(source, "("a => a.href"); + await links.Nth(i).ClickAsync(); + await page.WaitForURLAsync(href); + await WaitForBdndAsync(page.MainFrame); + await WaitForQuietAsync(page); + } + + await links.Nth(0).ClickAsync(); + await page.WaitForURLAsync(BaseUrl + "/"); + + // Back on the overview, a box still drops into the dustbin. + var bin = page.Locator(".dustbin"); + await DragAsync(page, page.Locator(".box", new() { HasText = "Banana" }), bin, + () => Expect(bin).ToHaveTextAsync("Release to drop")); + await Expect(bin).ToHaveTextAsync("Dropped: Banana"); + Assert.IsFalse(await page.Locator("#blazor-error-ui").IsVisibleAsync(), "the unhandled-error banner is showing"); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DragAroundTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DragAroundTests.cs new file mode 100644 index 00000000000..041672d7ea4 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DragAroundTests.cs @@ -0,0 +1,76 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Drag around: the container is a drop target that moves a box by how far the pointer travelled, which the library +/// works out from the pointer at dragstart and at drop. With a custom drag layer the browser's own drag +/// image is hidden and a preview follows the pointer instead, drawn from the offsets the module reports while the +/// drag runs. +/// +[TestClass] +public sealed class DragAroundTests : BdndTest +{ + [TestMethod] + public async Task Dropping_a_box_moves_it_by_exactly_the_distance_the_pointer_travelled() + { + var page = await OpenAsync("/drag-around/naive"); + var box = page.Locator(".drag-around-box", new() { HasText = "Drag me around" }); + var before = await BoxAsync(box); + var grab = before.Centre.Rounded(); + var drop = grab.Offset(180, 110); + + await StartDragAsync(page, grab); + await Expect(box).ToHaveCSSAsync("opacity", "0.5"); + await DragToAsync(page, drop); + await Expect(box).ToHaveCSSAsync("opacity", "1"); + + // The browser starts a drag where the button went down (a few pixels of movement trigger it, but are not + // part of it): that is where the travelled distance is measured from. + var start = (await DragStartsAsync(page.MainFrame)).Single(); + Assert.AreEqual(grab, new Point(start.X, start.Y), "where the drag started"); + + var after = await BoxAsync(box); + AssertNear(before.TopLeft.Offset(drop.X - start.X, drop.Y - start.Y), after.TopLeft, 1, "the box after the drop"); + + // The other box did not move. + var other = await BoxAsync(page.Locator(".drag-around-box", new() { HasText = "Drag me too" })); + AssertNear(before.TopLeft.Offset(160, 120), other.TopLeft, 1, "the box that was not dragged"); + } + + [TestMethod] + public async Task The_custom_drag_layer_follows_the_pointer_and_goes_away_on_drop() + { + var page = await OpenAsync("/drag-around/custom-layer"); + var box = page.Locator(".drag-around-box", new() { HasText = "Drag me" }); + var preview = page.Locator(".drag-layer-preview"); + await Expect(preview).ToHaveCountAsync(0); + + var before = await BoxAsync(box); + var grab = before.Centre.Rounded(); + await StartDragAsync(page, grab); + await Expect(box).ToHaveCSSAsync("opacity", "0.5"); + var start = (await DragStartsAsync(page.MainFrame)).Single(); + + // Wherever the pointer goes, the preview is drawn where the box would be if it moved with the pointer: the + // box's corner at the start, plus how far the pointer has gone since. + var at = grab; + foreach (var (dx, dy) in new[] { (60.0, 40.0), (220.0, -30.0), (-40.0, 150.0) }) + { + at = grab.Offset(dx, dy); + await MoveAsync(page, at); + var expected = before.TopLeft.Offset(at.X - start.X, at.Y - start.Y); + await Expect(preview).ToHaveTextAsync("Drag me"); + await EventuallyAsync(async () => (await BoxAsync(preview)).TopLeft.DistanceTo(expected) <= 1, + $"the preview is drawn at {expected} for the pointer at {at}"); + } + + await DragToAsync(page, at); + + // The drag is over: the preview is gone and the box itself was dropped where the preview was. + await Expect(preview).ToHaveCountAsync(0); + await Expect(box).ToHaveCSSAsync("opacity", "1"); + AssertNear(before.TopLeft.Offset(at.X - start.X, at.Y - start.Y), (await BoxAsync(box)).TopLeft, 1, "the box after the drop"); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DustbinTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DustbinTests.cs new file mode 100644 index 00000000000..7ca9777deeb --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/DustbinTests.cs @@ -0,0 +1,260 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// The dustbin examples: a target that reacts to the hover and records drops, targets that accept different types +/// and only light up (and only take a drop) for the types they accept, a source removed from the palette once a +/// drop took it, and the same demo running inside an iframe. +/// +[TestClass] +public sealed class DustbinTests : BdndTest +{ + private const string DarkGreen = "rgb(0, 100, 0)"; + + [TestMethod] + public async Task A_box_dropped_on_the_dustbin_is_recorded_and_the_bin_reacts_while_it_hovers() + { + var page = await OpenAsync("/dustbin/single"); + var bin = page.Locator(".dustbin"); + var glass = page.Locator(".box", new() { HasText = "Glass" }); + + await StartDragAsync(page, glass); + + // .NET began the drag: the source renders its dragging state, and the bin is not hovered yet. + await Expect(glass).ToHaveCSSAsync("opacity", "0.4"); + await Expect(bin).ToHaveTextAsync("Drag a box here"); + + await DragToAsync(page, (await BoxAsync(bin)).Centre, async () => + { + await Expect(bin).ToHaveTextAsync("Release to drop"); + await Expect(bin).ToHaveCSSAsync("background-color", DarkGreen); + }); + + await Expect(bin).ToHaveTextAsync("Last dropped: Glass (total 1)"); + await Expect(bin).ToHaveCSSAsync("background-color", "rgb(34, 34, 34)"); + await Expect(glass).ToHaveCSSAsync("opacity", "1"); + + // The browser ended the drag with a dragend on the source, which is still in the document (so the event + // bubbled up to the window), after the one drop. + Assert.AreEqual(1, await page.EvaluateAsync("() => window.__bdndE2E.dragends"), "dragend events"); + Assert.AreEqual(1, (await DropsAsync(page.MainFrame)).Count, "drop events"); + + // The next drag is a drag of its own, with its own item. + await DragAsync(page, page.Locator(".box", new() { HasText = "Banana" }), bin, + () => Expect(bin).ToHaveTextAsync("Release to drop")); + await Expect(bin).ToHaveTextAsync("Last dropped: Banana (total 2)"); + } + + [TestMethod] + public async Task The_hover_survives_the_bin_replacing_the_element_under_the_pointer() + { + var page = await OpenAsync("/dustbin/single"); + var bin = page.Locator(".dustbin"); + var centre = (await BoxAsync(bin)).Centre; + + // The pointer enters the bin over its label. Hovered, the bin renders another span in its place: the element + // the pointer entered is gone, without the browser ever raising a dragleave for it. + await StartDragAsync(page, page.Locator(".box", new() { HasText = "Glass" })); + await MoveAsync(page, centre); + await Expect(bin).ToHaveTextAsync("Release to drop"); + + // Every text the bin shows from now on is recorded, so a hover that drops out for a single render shows up. + await bin.EvaluateAsync( + """ + (el) => { + window.__binTexts = []; + new MutationObserver(() => window.__binTexts.push(el.textContent.trim())) + .observe(el, { subtree: true, childList: true, characterData: true }); + } + """); + + // Wandering over the bin (each move a dragover, and a dragenter whenever the element under the pointer + // changes) keeps it hovered throughout. + foreach (var (dx, dy) in new[] { (3, 2), (-20, 10), (25, -15), (0, 30), (-30, -30), (0, 0) }) + { + await MoveAsync(page, centre.Offset(dx, dy), steps: 2); + await WaitForQuietAsync(page, 100); + } + await Expect(bin).ToHaveTextAsync("Release to drop"); + var texts = await page.EvaluateAsync("() => window.__binTexts"); + Assert.IsTrue(texts.All(t => t == "Release to drop"), "the hover dropped out on the way: " + string.Join(" | ", texts)); + + await DragToAsync(page, centre); + await Expect(bin).ToHaveTextAsync("Last dropped: Glass (total 1)"); + } + + [TestMethod] + public async Task Leaving_the_dustbin_clears_its_hover_and_a_drop_elsewhere_records_nothing() + { + var page = await OpenAsync("/dustbin/single"); + var bin = page.Locator(".dustbin"); + var paper = page.Locator(".box", new() { HasText = "Paper" }); + var binBox = await BoxAsync(bin); + + await StartDragAsync(page, paper); + await MoveAsync(page, binBox.Centre); + await Expect(bin).ToHaveTextAsync("Release to drop"); + + // Out to the empty part of the stage, right of the bin, which is no drop target. + await DragToAsync(page, new Point(binBox.Right + 300, binBox.Centre.Y), + () => Expect(bin).ToHaveTextAsync("Drag a box here")); + + // The drag ended as a cancel: the source is back to normal and nothing was recorded. + await Expect(paper).ToHaveCSSAsync("opacity", "1"); + await Expect(bin).ToHaveTextAsync("Drag a box here"); + Assert.AreEqual(0, (await DropsAsync(page.MainFrame)).Count, "a drop was raised where no target took it"); + } + + [TestMethod] + public async Task A_box_lights_up_only_the_bins_that_accept_its_type_and_drops_into_one_of_them() + { + var page = await OpenAsync("/dustbin/multiple"); + var boxes = Bin(page, "Boxes"); + var pieces = Bin(page, "Pieces"); + var anything = Bin(page, "Anything"); + + // While a box (type "box") is in the air, every bin that can take it shows the dashed outline and the + // one that cannot is dimmed. + await StartDragAsync(page, Source(page, "Glass")); + await Expect(boxes).ToHaveCSSAsync("outline-style", "dashed"); + await Expect(anything).ToHaveCSSAsync("outline-style", "dashed"); + await Expect(pieces).ToHaveCSSAsync("outline-style", "none"); + await Expect(pieces).ToHaveCSSAsync("opacity", "0.5"); + + await DragToAsync(page, (await BoxAsync(boxes)).Centre, () => Expect(boxes).ToContainTextAsync("release to drop")); + await Expect(boxes).ToContainTextAsync("last: Glass"); + await Expect(anything).Not.ToContainTextAsync("last:"); + + // The drag is over: no bin is highlighted or dimmed any more. + await Expect(anything).ToHaveCSSAsync("outline-style", "none"); + await Expect(pieces).ToHaveCSSAsync("opacity", "1"); + + // A knight is a piece: now Pieces and Anything light up, and Boxes does not. + await StartDragAsync(page, Source(page, "Knight")); + await Expect(pieces).ToHaveCSSAsync("outline-style", "dashed"); + await Expect(anything).ToHaveCSSAsync("outline-style", "dashed"); + await Expect(boxes).ToHaveCSSAsync("outline-style", "none"); + await Expect(boxes).ToHaveCSSAsync("opacity", "0.5"); + + await DragToAsync(page, (await BoxAsync(anything)).Centre, () => Expect(anything).ToContainTextAsync("release to drop")); + await Expect(anything).ToContainTextAsync("last: Knight"); + await Expect(pieces).Not.ToContainTextAsync("last:"); + await Expect(boxes).ToContainTextAsync("last: Glass"); + } + + [TestMethod] + public async Task A_bin_that_does_not_accept_the_type_refuses_the_drop_with_the_no_drop_effect() + { + var page = await OpenAsync("/dustbin/multiple"); + var pieces = Bin(page, "Pieces"); + var banana = Source(page, "Banana"); + + await StartDragAsync(page, banana); + await Expect(pieces).ToHaveCSSAsync("opacity", "0.5"); + + // Over the empty stage, right of the last bin, the browser's own default applies: nothing accepts the dragover. + var piecesBox = await BoxAsync(pieces); + var anythingBox = await BoxAsync(Bin(page, "Anything")); + var empty = new Point(anythingBox.Right + 150, anythingBox.Centre.Y); + await MoveAsync(page, empty); + var overPage = await DragOverAtAsync(page, page.Locator(".demo-stage"), empty); + Assert.IsFalse(overPage.Prevented, $"a dragover over no drop target was accepted: {overPage}"); + + await DragToAsync(page, piecesBox.Centre, async () => + { + // The module answers every dragover over the refusing bin: taken (so the browser does not fall back to + // its own handling), but with the "none" effect, which shows the no-drop cursor and rules out a drop. + var over = await DragOverAtAsync(page, pieces, piecesBox.Centre); + Assert.IsTrue(over.Inside, $"the last dragover was not over the Pieces bin: {over}"); + Assert.IsTrue(over.Prevented, $"the module did not answer the dragover over a refusing bin: {over}"); + Assert.AreEqual("none", over.DropEffect, $"a refusing bin must show the no-drop effect: {over}"); + + // Hovered but refusing: the bin is neither "over" nor ready to take the box. + await Expect(pieces).Not.ToContainTextAsync("release to drop"); + await Expect(pieces).ToHaveCSSAsync("opacity", "0.5"); + }); + + // No drop event reached the page, and the drag ended as a cancel. + Assert.AreEqual(0, (await DropsAsync(page.MainFrame)).Count, "the browser raised a drop on a refusing bin"); + await Expect(banana).ToHaveCSSAsync("opacity", "1"); + await Expect(pieces).ToHaveCSSAsync("opacity", "1"); + await Expect(pieces).Not.ToContainTextAsync("last:"); + } + + [TestMethod] + public async Task A_moved_box_leaves_the_palette_once_dropped_while_a_copied_one_stays() + { + var page = await OpenAsync("/dustbin/copy-or-move"); + var bin = page.Locator(".dustbin"); + var keep = page.Locator(".box", new() { HasText = "Glass (keep)" }); + var take = page.Locator(".box", new() { HasText = "Banana (take)" }); + + await DragAsync(page, take, bin, () => Expect(bin).ToHaveCSSAsync("background-color", DarkGreen)); + await Expect(bin).ToContainTextAsync("Dropped 1 item(s)"); + + // OnEnd saw DidDrop for the "move" box, and took it out of the palette. + await Expect(take).ToHaveCountAsync(0); + + // The "copy" box stays, however often it is dropped. + for (var dropped = 2; dropped <= 3; dropped++) + { + await DragAsync(page, keep, bin, () => Expect(bin).ToHaveCSSAsync("background-color", DarkGreen)); + await Expect(bin).ToContainTextAsync($"Dropped {dropped} item(s)"); + await Expect(keep).ToBeVisibleAsync(); + } + await Expect(bin.Locator(":scope > div > div")).ToHaveTextAsync(["Dropped 3 item(s)", "Banana (take)", "Glass (keep)", "Glass (keep)"]); + } + + [TestMethod] + public async Task Boxes_dropped_outside_the_dustbin_both_stay_in_the_palette() + { + var page = await OpenAsync("/dustbin/copy-or-move"); + var bin = page.Locator(".dustbin"); + var binBox = await BoxAsync(bin); + var outside = new Point(binBox.Right + 300, binBox.Centre.Y); + + foreach (var name in new[] { "Banana (take)", "Glass (keep)" }) + { + var box = page.Locator(".box", new() { HasText = name }); + await StartDragAsync(page, box); + await Expect(box).ToHaveCSSAsync("opacity", "0.4"); + await DragToAsync(page, outside); + + // OnEnd ran with DidDrop false: the box is back to normal, and still there. + await Expect(box).ToHaveCSSAsync("opacity", "1"); + await Expect(box).ToBeVisibleAsync(); + } + + await Expect(page.Locator(".box")).ToHaveCountAsync(2); + await Expect(bin).ToHaveTextAsync("Dropped 0 item(s)"); + } + + [TestMethod] + public async Task The_dustbin_works_inside_an_iframe() + { + var page = await OpenAsync("/dustbin/iframe", bdnd: false); + + // The iframe hosts a page of its own, which boots its own WebAssembly app and loads the module itself. + var frame = await (await page.Locator("iframe").ElementHandleAsync()).ContentFrameAsync(); + Assert.IsNotNull(frame, "the iframe has no document"); + await WaitForInteractiveAsync(frame); + await WaitForBdndAsync(frame); + + var bin = frame.Locator(".dustbin"); + var glass = frame.Locator(".box", new() { HasText = "Glass" }); + + await StartDragAsync(page, (await BoxAsync(glass)).Centre, frame); + await Expect(glass).ToHaveCSSAsync("opacity", "0.4"); + await DragToAsync(page, (await BoxAsync(bin)).Centre, () => Expect(bin).ToHaveTextAsync("Release to drop")); + + await Expect(bin).ToHaveTextAsync("Last dropped: Glass (total 1)"); + await Expect(glass).ToHaveCSSAsync("opacity", "1"); + } + + private static ILocator Bin(IPage page, string name) => page.Locator($".dustbin[data-bin=\"{name}\"]"); + + private static ILocator Source(IPage page, string name) => page.Locator(".box", new() { HasText = name }); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/BdndTest.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/BdndTest.cs new file mode 100644 index 00000000000..4383238d546 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/BdndTest.cs @@ -0,0 +1,485 @@ +using System.Globalization; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// Base of every suite: gives each test a browser context of its own against the shared demo, waits until a +/// page runs in the browser and its draggables and drop targets are registered with the browser module, drives +/// native HTML5 drags with the real mouse, and fails a test whose page reported a console error, an unhandled +/// exception or a failed request on the way. +/// +public abstract class BdndTest +{ + /// + /// Runs before any script of every page and frame, and does two things. + /// + /// The browser module registers a draggable or a drop target by adding element-level listeners to its host + /// (dragstart for a source, dragenter for a target), and that registration is the moment a host + /// starts working: until then a drag from it is not tagged and the managed handler ignores it, and a target + /// refuses every drop. Nothing in the DOM says it happened, so the listeners are counted here, which lets + /// wait for exactly that instead of sleeping. Blazor's own listeners are + /// on the document, so only the module's are counted. + /// + /// + /// It also records the native drag events as they finish bubbling, on the window: by then the module's + /// element-level listeners have tagged the dragstart, and accepted or refused each dragover + /// (preventDefault and dropEffect), so the record is what the browser itself acts on. + /// + /// + private const string BrowserProbe = + """ + (() => { + const state = { + sources: new WeakSet(), registrations: 0, last: 0, + dragstarts: [], lastDragOver: null, drops: [], dragends: 0, + }; + Object.defineProperty(window, '__bdndE2E', { value: state }); + window.addEventListener('dragstart', e => { + const dt = e.dataTransfer, types = dt ? [...dt.types] : []; + const tag = types.find(t => t.startsWith('application/x-bit-bdnd-')) ?? null; + state.dragstarts.push({ + x: e.clientX, y: e.clientY, types, tag, + tagData: tag ? dt.getData(tag) : null, effectAllowed: dt ? dt.effectAllowed : null, + }); + }); + window.addEventListener('dragover', e => { + state.lastDragOver = { + target: e.target, x: e.clientX, y: e.clientY, + prevented: e.defaultPrevented, dropEffect: e.dataTransfer ? e.dataTransfer.dropEffect : null, + }; + }); + window.addEventListener('drop', e => state.drops.push({ target: e.target, prevented: e.defaultPrevented })); + window.addEventListener('dragend', () => state.dragends++); + const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener; + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (this instanceof Element && (type === 'dragstart' || type === 'dragenter')) { + if (type === 'dragstart') state.sources.add(this); + state.registrations++; + state.last = performance.now(); + } + return add.call(this, type, listener, options); + }; + EventTarget.prototype.removeEventListener = function (type, listener, options) { + if (this instanceof Element && (type === 'dragstart' || type === 'dragenter')) { + if (type === 'dragstart') state.sources.delete(this); + state.last = performance.now(); + } + return remove.call(this, type, listener, options); + }; + })(); + """; + + /// Every element a draggable renders as its host carries a draggable attribute; a drag handle does too, but it is registered through its draggable. + protected const string DraggableHosts = "[draggable]:not([data-bit-bdnd-handle])"; + + private readonly List _contexts = []; + private readonly List> _problems = []; + private PlaywrightSession.Session? _session; + + public TestContext TestContext { get; set; } = default!; + + /// The origin the demo is served from, without a trailing slash. + protected string BaseUrl => Session.Host!.BaseUrl; + + protected IBrowser Browser => Session.Browser!; + + private PlaywrightSession.Session Session => _session ?? throw new InvalidOperationException("The session is opened in TestInitialize."); + + [TestInitialize] + public async Task OpenSessionAsync() + { + _session = await PlaywrightSession.GetAsync(); + if (_session.Unavailable is not { } reason) return; + + // A build server must not report a suite that never ran as anything but a failure. + if (E2EEnvironment.IsContinuousIntegration) Assert.Fail("The browser tests could not run: " + reason); + Assert.Inconclusive("The browser tests could not run: " + reason); + } + + [TestCleanup] + public async Task CloseContextsAsync() + { + try + { + // Every page a test opened must have stayed free of errors, whatever the test asserted itself. A test + // that failed already keeps its own message. + if (TestContext.CurrentTestOutcome == UnitTestOutcome.Passed) + { + string[] found = [.. _problems.SelectMany(p => { lock (p) return p.ToArray(); })]; + Assert.IsEmpty(found, "the page reported:" + Environment.NewLine + string.Join(Environment.NewLine, found)); + } + } + finally + { + foreach (var context in _contexts) + { + try { await context.CloseAsync(); } + catch (PlaywrightException) { /* the browser went away first */ } + } + _contexts.Clear(); + _problems.Clear(); + } + } + + /// + /// A fresh page in a browser context of its own, closed when the test ends, whose console errors, unhandled + /// exceptions and failed requests fail the test (see ). The suite is hermetic: + /// a request that leaves the demo's origin (the demo's web font, today) is answered with an empty response + /// here, so a runner without internet access neither waits out a connection timeout nor logs a load error. + /// + protected async Task NewPageAsync() + { + var context = await Browser.NewContextAsync(new BrowserNewContextOptions + { + ViewportSize = new ViewportSize { Width = 1280, Height = 900 }, + DeviceScaleFactor = 1, + }); + _contexts.Add(context); + + var origin = BaseUrl; + await context.RouteAsync(url => url.StartsWith(origin, StringComparison.OrdinalIgnoreCase) is false, + route => route.FulfillAsync(new RouteFulfillOptions + { + Status = 200, + Body = string.Empty, + ContentType = route.Request.ResourceType == "stylesheet" ? "text/css" : "text/plain", + })); + await context.AddInitScriptAsync(BrowserProbe); + + var page = await context.NewPageAsync(); + _problems.Add(TrackProblems(page, origin)); + return page; + } + + /// + /// Opens a page of the demo and waits until it runs in the browser and, unless is false + /// (a page whose drags all happen in an iframe), its draggables and drop targets have been registered with the + /// browser module (see ). + /// + protected async Task OpenAsync(string path, bool bdnd = true) + { + var page = await NewPageAsync(); + var response = await GotoAsync(page, path); + Assert.AreEqual(200, response?.Status, $"{path} answered {response?.Status}"); + await WaitForInteractiveAsync(page.MainFrame); + if (bdnd) await WaitForBdndAsync(page.MainFrame); + return page; + } + + protected Task GotoAsync(IPage page, string path) => + page.GotoAsync(BaseUrl + path, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 90_000 }); + + /// + /// Waits until the document of runs in the browser. Every page is prerendered, so its + /// boxes and bins are on screen long before anything listens to them; the layout's data-interactive + /// marker turns true once the WebAssembly renderer has taken the page over. + /// + protected static Task WaitForInteractiveAsync(IFrame frame) => frame.WaitForFunctionAsync( + "() => document.querySelector('.page')?.dataset.interactive === 'true'", + null, new FrameWaitForFunctionOptions { Timeout = 90_000 }); + + /// + /// Waits until the browser module has registered something in the document of , every + /// draggable host in it has been registered, and no registration has come or gone for a moment. Called when a + /// page opens and before every drag, since a drop can re-render draggables (a knight on a new square is a new + /// draggable, registered asynchronously after the render that created it). + /// + protected static Task WaitForBdndAsync(IFrame frame) => frame.WaitForFunctionAsync( + """ + (hosts) => { + const s = window.__bdndE2E; + return !!s && s.registrations > 0 + && [...document.querySelectorAll(hosts)].every(h => s.sources.has(h)) + && performance.now() - s.last >= 150; + } + """, DraggableHosts, new FrameWaitForFunctionOptions { Timeout = 20_000, PollingInterval = 50 }); + + /// + /// Waits until nothing in the page's document has changed for a moment: the re-render a drag, a hover or a drop + /// caused has reached the DOM. Gives up quietly after a few seconds. + /// + protected static Task WaitForQuietAsync(IPage page, int quietMs = 200) => page.EvaluateAsync( + """ + (quietMs) => new Promise(resolve => { + let timer; + const done = () => { observer.disconnect(); clearTimeout(cap); resolve(); }; + const arm = () => { clearTimeout(timer); timer = setTimeout(done, quietMs); }; + const observer = new MutationObserver(arm); + observer.observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true }); + const cap = setTimeout(done, 5000); + arm(); + }) + """, quietMs); + + // ---- dragging with the mouse ---------------------------------------------------------------------- + // + // Chromium turns Playwright's mouse into a native drag: once the pointer has moved a few pixels with the button + // down on a draggable element, the browser raises dragstart; from then on a move onto another element raises + // dragenter and dragleave, a move over the same element a dragover, and the release is a drop where the last + // answer was "accepted", followed by dragend on the source. The events are real, with a real DataTransfer, so + // they go through the browser module and Blazor's dispatcher exactly as a hand-made drag does. The pointer goes + // to whole pixels only, because the browser reports drag events in whole pixels: a distance a test computes + // from the points it moved to is then exactly the distance the library computes from the events. + + /// + /// Presses on and moves far enough for the browser to start a native drag, without + /// releasing. Waits first until the draggables in (the page's own document by + /// default) are registered, so the drag is one the library knows about. + /// + protected static async Task StartDragAsync(IPage page, Point from, IFrame? frame = null, double dx = 6, double dy = 6) + { + await WaitForBdndAsync(frame ?? page.MainFrame); + from = from.Rounded(); + await page.Mouse.MoveAsync((float)from.X, (float)from.Y); + await page.Mouse.DownAsync(); + await page.Mouse.MoveAsync((float)(from.X + dx), (float)(from.Y + dy), new MouseMoveOptions { Steps = 3 }); + } + + /// + protected static async Task StartDragAsync(IPage page, ILocator source) => + await StartDragAsync(page, (await BoxAsync(source)).Centre); + + /// Moves the pointer of a drag in progress to . + protected static Task MoveAsync(IPage page, Point to, int steps = 8) + { + to = to.Rounded(); + return page.Mouse.MoveAsync((float)to.X, (float)to.Y, new MouseMoveOptions { Steps = steps }); + } + + /// + protected static async Task MoveAsync(IPage page, ILocator to, int steps = 8) => + await MoveAsync(page, (await BoxAsync(to)).Centre, steps); + + /// + /// A whole drag from the centre of to the centre of : press, + /// start, move over the target, wait until holds (the hover the test expects, as + /// rendered), release. + /// + protected static async Task DragAsync(IPage page, ILocator source, ILocator target, Func? whileOver = null) + { + var to = (await BoxAsync(target)).Centre; + await StartDragAsync(page, source); + await DragToAsync(page, to, whileOver); + } + + /// + /// Moves a drag in progress to , waits for , and releases + /// exactly there. A target decides whether it takes a drop on every dragover, from what .NET last told + /// the browser module about the dragged item, and the browser only raises drop where the last + /// dragover was accepted. A hand pauses over a target long enough for .NET's answer to arrive, and the + /// browser keeps raising dragover meanwhile; here the test waits for the hover it expects to be rendered, + /// and two last small moves raise more dragovers (see ), decided with + /// everything .NET has said by then, before the release. + /// + protected static async Task DragToAsync(IPage page, Point to, Func? whileOver = null) + { + to = to.Rounded(); + await MoveAsync(page, to); + if (whileOver is not null) await whileOver(); + await WaitForQuietAsync(page, 100); + await MoveAsync(page, to.Offset(1, 1), steps: 1); + await MoveAsync(page, to, steps: 1); + await page.Mouse.UpAsync(); + await WaitForQuietAsync(page); + } + + /// + /// Keeps a drag in progress hovering until holds. A browser + /// raises dragover again and again while a drag rests over an element; Playwright raises one per move, + /// so the pointer is nudged by a pixel each round. A sortable list needs that: a card measures itself before it + /// reorders, and skips the hovers that arrive while it measures. + /// + protected static async Task HoverUntilAsync(IPage page, Point at, Func> condition, string what) + { + var deadline = DateTime.UtcNow.AddSeconds(15); + for (var round = 0; ; round++) + { + await MoveAsync(page, at.Offset(0, round % 2), steps: 1); + await WaitForQuietAsync(page, 100); + if (await condition()) return; + if (DateTime.UtcNow > deadline) Assert.Fail($"timed out hovering at {at} until {what}"); + } + } + + // ---- what the browser saw ------------------------------------------------------------------------- + + /// Every dragstart the document of raised, as it left the module. + protected static Task DragStartsAsync(IFrame frame) => frame.EvaluateAsync( + "() => window.__bdndE2E.dragstarts"); + + /// + /// The last dragover of the document is in, as the module answered it, and whether + /// it was raised inside . For an event a test raised itself, which is dispatched before + /// the call that raised it returns. + /// + protected static Task LastDragOverAsync(ILocator over) => over.EvaluateAsync( + $"(el) => {{ const d = window.__bdndE2E.lastDragOver; {DescribeDragOver} }}"); + + /// + /// A fresh dragover of a drag in progress, raised with the pointer at (in page + /// coordinates), as the module answered it, and whether it was raised inside . + /// + /// + /// Chromium raises only dragenter and dragleave on the move that brings the pointer onto another + /// element, and the dragover on the next move over the same element; a resting pointer gets a + /// dragover every few milliseconds in a real drag, but none under Playwright, which raises one event per + /// move. So the pointer steps off by a pixel and back, until the browser has raised a dragover exactly at + /// the point (it may still be working through the events of earlier moves when Playwright's move returns). + /// + protected static async Task DragOverAtAsync(IPage page, ILocator over, Point at) + { + at = at.Rounded(); + await over.EvaluateAsync("() => { window.__bdndE2E.lastDragOver = null; }"); + (int X, int Y)[] steps = [(1, 0), (0, 1), (-1, 0), (0, -1)]; + for (var attempt = 0; attempt < 12; attempt++) + { + var (dx, dy) = steps[attempt % steps.Length]; + await MoveAsync(page, at.Offset(dx, dy), steps: 1); + await MoveAsync(page, at, steps: 1); + for (var deadline = DateTime.UtcNow.AddSeconds(1); DateTime.UtcNow < deadline; await Task.Delay(50)) + { + if (await over.EvaluateAsync( + "(el, [x, y]) => { const d = window.__bdndE2E.lastDragOver; return !!d && d.x === x && d.y === y; }", + new[] { at.X, at.Y })) + { + return await LastDragOverAsync(over); + } + } + } + Assert.Fail($"the browser raised no dragover at {at}"); + return default!; + } + + private const string DescribeDragOver = + """ + if (!d) return { inside: false, prevented: false, dropEffect: null, target: null }; + const t = d.target; + return { + inside: el.contains(t), prevented: d.prevented, dropEffect: d.dropEffect, + target: `<${t.nodeName.toLowerCase()} class="${t.className ?? ''}">${(t.textContent ?? '').trim().slice(0, 40)} at (${d.x}, ${d.y})`, + }; + """; + + /// How many drop events reached the page, and how many of them the module kept the browser from acting on. + protected static async Task<(int Count, int Prevented)> DropsAsync(IFrame frame) + { + var drops = await frame.EvaluateAsync( + "() => [window.__bdndE2E.drops.length, window.__bdndE2E.drops.filter(d => d.prevented).length]"); + return (drops[0], drops[1]); + } + + /// + /// A DataTransfer created in the page, standing in for one the operating system would hand over: a drag + /// from outside the browser cannot be started with the mouse. + /// + protected static Task NativeDataTransferAsync(IPage page, string fill) => + page.EvaluateHandleAsync($"() => {{ const dt = new DataTransfer(); {fill}; return dt; }}"); + + /// Raises a native drag event of on , carrying . + protected static Task DispatchDragAsync(ILocator target, string type, IJSHandle dataTransfer) => + target.DispatchEventAsync(type, new Dictionary { ["dataTransfer"] = dataTransfer }); + + // ---- probes ------------------------------------------------------------------------------------- + + /// The on-screen box of the element finds. + protected static async Task BoxAsync(ILocator locator) + { + await Expect(locator).ToBeVisibleAsync(); + var b = await locator.BoundingBoxAsync(); + Assert.IsNotNull(b, $"{locator} has no box"); + return new Box(b.X, b.Y, b.Width, b.Height); + } + + /// Polls until it holds, failing with when it never does. + protected static async Task EventuallyAsync(Func> condition, string what, int timeoutMs = 10_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (true) + { + if (await condition()) return; + if (DateTime.UtcNow > deadline) Assert.Fail($"timed out waiting until {what}"); + await Task.Delay(50); + } + } + + protected static void AssertNear(Point expected, Point actual, double tolerance, string what) => + Assert.IsTrue(expected.DistanceTo(actual) <= tolerance, + $"{what}: expected {expected} (within {tolerance:0.##}px) but was {actual}"); + + /// Everything a page reports going wrong while a test drives it. + protected static List TrackProblems(IPage page, string origin) + { + var problems = new List(); + void Add(string problem) { lock (problems) problems.Add(problem); } + + page.Console += (_, message) => { if (message.Type == "error") Add($"console error: {message.Text} ({message.Location})"); }; + page.PageError += (_, error) => Add("unhandled error: " + error); + page.RequestFailed += (_, request) => + { + if (request.Url.StartsWith(origin, StringComparison.OrdinalIgnoreCase)) Add($"request failed: {request.Url} ({request.Failure})"); + }; + page.Response += (_, response) => + { + if (response.Status >= 400 && response.Url.StartsWith(origin, StringComparison.OrdinalIgnoreCase)) Add($"HTTP {response.Status}: {response.Url}"); + }; + + return problems; + } + + /// A dragstart: where it started, the types it carried, the library's tag and its data, and the effects it allows. + protected sealed class DragStartRecord + { + public double X { get; set; } + + public double Y { get; set; } + + public string[] Types { get; set; } = []; + + public string? Tag { get; set; } + + public string? TagData { get; set; } + + public string? EffectAllowed { get; set; } + } + + /// A dragover as the module answered it: accepted (preventDefault) and with which drop effect. + protected sealed class DragOverRecord + { + public bool Inside { get; set; } + + public bool Prevented { get; set; } + + public string? DropEffect { get; set; } + + /// The element the event was raised on, for failure messages. + public string? Target { get; set; } + + public override string ToString() => $"dragover on {Target}: prevented {Prevented}, dropEffect {DropEffect}"; + } + + protected readonly record struct Point(double X, double Y) + { + public Point Offset(double dx, double dy) => new(X + dx, Y + dy); + + public Point Rounded() => new(Math.Round(X), Math.Round(Y)); + + public double DistanceTo(Point other) => Math.Sqrt((X - other.X) * (X - other.X) + (Y - other.Y) * (Y - other.Y)); + + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"({X:0.#}, {Y:0.#})"); + } + + protected readonly record struct Box(double X, double Y, double Width, double Height) + { + public Point Centre => new(X + Width / 2, Y + Height / 2); + + public Point TopLeft => new(X, Y); + + public double Right => X + Width; + + public double Bottom => Y + Height; + + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"[{X:0.#}, {Y:0.#}, {Width:0.#} x {Height:0.#}]"); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/ChildProcess.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/ChildProcess.cs new file mode 100644 index 00000000000..d5e32758252 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/ChildProcess.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// A process the suite starts and owns: its output is drained (a full pipe would stall it) into a +/// bounded buffer that failure messages quote, and disposing kills the whole process tree. +/// +public sealed class ChildProcess : IAsyncDisposable +{ + private const int KeptOutputLines = 200; + + private readonly Process _process; + private readonly ConcurrentQueue _output = new(); + + private ChildProcess(Process process) => _process = process; + + public bool HasExited => _process.HasExited; + + /// The last lines the process wrote, for failure messages. + public string RecentOutput => string.Join(Environment.NewLine, _output); + + public static ChildProcess Start(string fileName, IEnumerable arguments, string workingDirectory, IDictionary? environment = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + foreach (var (name, value) in environment ?? new Dictionary()) startInfo.Environment[name] = value; + + var child = new ChildProcess(new Process { StartInfo = startInfo }); + child._process.OutputDataReceived += (_, e) => child.Keep(e.Data); + child._process.ErrorDataReceived += (_, e) => child.Keep(e.Data); + + if (child._process.Start() is false) + throw new InvalidOperationException($"Failed to start {fileName}."); + + child._process.BeginOutputReadLine(); + child._process.BeginErrorReadLine(); + return child; + } + + /// Polls until it answers with a success status. + public async Task WaitForHttpAsync(string url, TimeSpan timeout) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + if (HasExited) + throw new InvalidOperationException($"The process exited before {url} answered.{Environment.NewLine}{RecentOutput}"); + + try + { + using var response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) return; + } + catch (HttpRequestException) { /* not listening yet */ } + catch (TaskCanceledException) { /* not answering yet */ } + + await Task.Delay(250); + } + + throw new TimeoutException($"{url} did not answer within {timeout}.{Environment.NewLine}{RecentOutput}"); + } + + public static int FreePort() + { + // Bind to port 0 and release it: the OS hands back a free ephemeral port. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + try + { + if (_process.HasExited is false) + { + _process.Kill(entireProcessTree: true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _process.WaitForExitAsync(cts.Token); + } + } + catch (Exception ex) when (ex is InvalidOperationException or OperationCanceledException or System.ComponentModel.Win32Exception) + { + // Best-effort: the process may already be gone, or refuse to die before the run ends. + } + finally + { + _process.Dispose(); + } + } + + private void Keep(string? line) + { + if (line is null) return; + + _output.Enqueue(line); + while (_output.Count > KeptOutputLines) _output.TryDequeue(out _); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/DemoHost.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/DemoHost.cs new file mode 100644 index 00000000000..33c12f45c9d --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/DemoHost.cs @@ -0,0 +1,54 @@ +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// The demo server every test drives: Bit.Bdnd.Demo.Server started from its build output on a free port, +/// once for the whole run. The demo keeps no state on the server (each page runs in the browser on +/// WebAssembly), and every test opens its own browser context, so sharing the process shares nothing. +/// +public sealed class DemoHost : IAsyncDisposable +{ + private readonly ChildProcess? _process; + + private DemoHost(string baseUrl, ChildProcess? process) + { + BaseUrl = baseUrl; + _process = process; + } + + /// The URL the demo answers at, without a trailing slash. + public string BaseUrl { get; } + + public static async Task StartAsync() + { + if (E2EEnvironment.BaseUrl is { } external) + return new DemoHost(external, process: null); + + var assembly = RepoLayout.DemoServerAssembly(E2EEnvironment.Configuration); + if (File.Exists(assembly) is false) + throw new FileNotFoundException( + $"The demo server has not been built in the {E2EEnvironment.Configuration} configuration. Build it first with " + + $"'dotnet build src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj -c {E2EEnvironment.Configuration}'", assembly); + + var baseUrl = $"http://127.0.0.1:{ChildProcess.FreePort()}"; + + // A build output only finds the static web assets of its references (the WebAssembly client, + // the demo's stylesheet and bit-bdnd.js among them) through the development manifest, which Development + // switches on. + var process = ChildProcess.Start("dotnet", [assembly, "--urls", baseUrl], Path.GetDirectoryName(RepoLayout.DemoServerProject())!, + new Dictionary { ["ASPNETCORE_ENVIRONMENT"] = "Development" }); + + try + { + await process.WaitForHttpAsync(baseUrl + "/", TimeSpan.FromMinutes(2)); + } + catch + { + await process.DisposeAsync(); + throw; + } + + return new DemoHost(baseUrl, process); + } + + public ValueTask DisposeAsync() => _process?.DisposeAsync() ?? ValueTask.CompletedTask; +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/E2EEnvironment.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/E2EEnvironment.cs new file mode 100644 index 00000000000..a63efdf1ace --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/E2EEnvironment.cs @@ -0,0 +1,52 @@ +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// Everything the suite reads from the environment, in one place. Environment variables rather than +/// runsettings, which the Microsoft.Testing.Platform runner does not thread through reliably. +/// +public static class E2EEnvironment +{ + /// + /// BDND_E2E_CONFIGURATION: the build configuration of the demo server the suite boots. + /// Defaults to the configuration this test assembly was built in, so a Release test run drives the + /// Release demo that CI builds rather than looking for a Debug build that was never produced. + /// + public static string Configuration { get; } = Read("BDND_E2E_CONFIGURATION") ?? +#if DEBUG + "Debug"; +#else + "Release"; +#endif + + /// + /// BDND_E2E_BASE_URL: an already running demo (for example one started from the IDE). When set + /// the suite drives that instead of booting the demo server itself. + /// + public static string? BaseUrl { get; } = Read("BDND_E2E_BASE_URL")?.TrimEnd('/'); + + /// + /// BDND_E2E_CHANNEL: e.g. chrome / msedge, to use an installed browser, or chromium-headless-shell for + /// Playwright's headless shell. Without it the suite uses the full Chromium build Playwright installed, then its + /// headless shell, and falls back to an installed Chrome, then Edge. + /// + public static string? Channel { get; } = Read("BDND_E2E_CHANNEL"); + + /// BDND_E2E_EXECUTABLE: full path to a chromium-family executable. + public static string? Executable { get; } = Read("BDND_E2E_EXECUTABLE"); + + /// BDND_E2E_HEADED=1: show the browser. + public static bool Headed { get; } = Read("BDND_E2E_HEADED") == "1"; + + /// + /// True on a build server (CI=true, which GitHub Actions sets). A missing browser or a demo that + /// does not start makes the tests inconclusive on a developer machine, but on a build server that would + /// turn "the browser tests never ran" into a green build, so the tests fail there instead. + /// + public static bool IsContinuousIntegration { get; } = string.Equals(Read("CI"), "true", StringComparison.OrdinalIgnoreCase); + + private static string? Read(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/PlaywrightSession.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/PlaywrightSession.cs new file mode 100644 index 00000000000..f35a9d1b714 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/PlaywrightSession.cs @@ -0,0 +1,112 @@ +using Microsoft.Playwright; + +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// One Playwright driver, one launched Chromium and one running demo for the whole run. Tests isolate +/// from each other with a browser context apiece (separate storage, cache and WebAssembly instance), +/// which is far cheaper than a browser apiece and isolates just as well. +/// +/// +/// Whatever stops the suite from running at all - no browser, a demo that does not start - is captured +/// once as , so every test reports it without waiting out the same +/// timeout again. +/// +public static class PlaywrightSession +{ + private static readonly Lazy> _session = new(StartAsync); + + public static Task GetAsync() => _session.Value; + + public static async Task StopAsync() + { + if (_session.IsValueCreated is false) return; + + var session = await _session.Value; + + if (session.Browser is not null) + { + try { await session.Browser.CloseAsync(); } + catch (PlaywrightException) { /* the browser is gone already */ } + } + + session.Playwright?.Dispose(); + + if (session.Host is not null) await session.Host.DisposeAsync(); + } + + private static async Task StartAsync() + { + IPlaywright playwright; + try + { + playwright = await Playwright.CreateAsync(); + } + catch (Exception ex) when (ex is PlaywrightException or FileNotFoundException or DllNotFoundException) + { + return Session.Fail("Playwright is not available: " + ex.Message); + } + + var browser = await LaunchAsync(playwright); + if (browser is null) + { + playwright.Dispose(); + return Session.Fail("no Chromium-based browser could be launched. Run 'pwsh bin//net10.0/playwright.ps1 install chromium', or install Chrome or Edge."); + } + + try + { + return new Session(playwright, browser, await DemoHost.StartAsync(), Unavailable: null); + } + catch (Exception ex) when (ex is FileNotFoundException or InvalidOperationException or TimeoutException) + { + await browser.CloseAsync(); + playwright.Dispose(); + return Session.Fail($"the demo application could not be started in the {E2EEnvironment.Configuration} configuration: {ex.Message}"); + } + } + + /// + /// The managed browser is asked for as the chromium channel first - the full Chromium build in its new + /// headless mode, the same code a person's Chrome runs - rather than as Playwright's default headless shell, a + /// separate, older build. Native drag and drop goes through the browser's drag controller, which is exactly the + /// part a suite about drag and drop must not test on a browser nobody uses. The shell is the next candidate, so a + /// machine that only has it still runs the suite (it passes there as well). + /// + private static async Task LaunchAsync(IPlaywright playwright) + { + // Preferred: the browser Playwright manages. Falling back to an installed one keeps the suite usable + // where that download is blocked. A channel or an executable asked for explicitly is the only candidate. + string?[] channels = E2EEnvironment.Channel is { } channel ? [channel] + : E2EEnvironment.Executable is not null ? [null] + : ["chromium", null, "chrome", "msedge"]; + + foreach (var candidate in channels) + { + try + { + return await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Channel = candidate, + ExecutablePath = E2EEnvironment.Executable, + Headless = E2EEnvironment.Headed is false, + // Grayscale antialiasing and a fixed scale factor keep the pixel measurements the tests + // take independent of the machine's display settings. + Args = ["--disable-lcd-text", "--force-device-scale-factor=1"], + }); + } + catch (PlaywrightException) + { + // Try the next candidate. + } + } + + return null; + } + + /// The shared browser and demo, or the reason the suite cannot run. + public sealed record Session(IPlaywright? Playwright, IBrowser? Browser, DemoHost? Host, string? Unavailable) + { + public static Session Fail(string reason) => new(null, null, null, reason); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/RepoLayout.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/RepoLayout.cs new file mode 100644 index 00000000000..082f46dea67 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Infrastructure/RepoLayout.cs @@ -0,0 +1,41 @@ +namespace Bit.Bdnd.Tests.E2E.Infrastructure; + +/// +/// Finds the demo server from the test binary's location by walking up, so a run does not depend on +/// the working directory the shell, the IDE or the test runner picked. +/// +public static class RepoLayout +{ + public const string DemoServerName = "Bit.Bdnd.Demo.Server"; + + public static string DemoServerProject() => FindUpward(Path.Combine("Demo", "Server", $"{DemoServerName}.csproj")); + + public static string DemoServerAssembly(string configuration) => + Path.Combine(Path.GetDirectoryName(DemoServerProject())!, "bin", configuration, "net10.0", $"{DemoServerName}.dll"); + + /// The demo's routable pages, which the page sweep reads its routes from. + public static string DemoPagesDirectory() => + Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(DemoServerProject()))!, "Client", "Pages"); + + /// + /// The route of every page under , read from its @page directive, so a + /// page added to the demo is swept without anyone having to list it here. + /// + public static IReadOnlyList DemoRoutes() => + [.. Directory.EnumerateFiles(DemoPagesDirectory(), "*.razor", SearchOption.AllDirectories) + .Select(file => System.Text.RegularExpressions.Regex.Match(File.ReadAllText(file), "@page\\s+\"([^\"]+)\"")) + .Where(match => match.Success) + .Select(match => match.Groups[1].Value) + .Order(StringComparer.Ordinal)]; + + private static string FindUpward(string relativePath) + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + } + + throw new FileNotFoundException($"Could not find {relativePath} walking up from {AppContext.BaseDirectory}."); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NativeDragTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NativeDragTests.cs new file mode 100644 index 00000000000..92a3d236d93 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NativeDragTests.cs @@ -0,0 +1,129 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Native drags, which start outside the library: a link dragged from the page itself (a real browser drag, with the +/// DataTransfer the browser fills), and files or text from the operating system, simulated by raising the drag events +/// with a DataTransfer created in the page. The module tells such a drag from one of its own, matches its types +/// against what the target accepts, reads the payload during drop - the only moment the browser allows it - +/// and keeps the browser from opening or following what was dropped. +/// +[TestClass] +public sealed class NativeDragTests : BdndTest +{ + private const string TwoFiles = + """ + dt.items.add(new File(['hello'], 'hello.txt', { type: 'text/plain' })); + dt.items.add(new File([new Uint8Array(2048)], 'data.bin')) + """; + + [TestMethod] + public async Task Files_dragged_in_from_the_system_are_listed_with_their_name_type_and_size() + { + var page = await OpenAsync("/other/native-files"); + var bin = page.Locator(".dustbin"); + var files = await NativeDataTransferAsync(page, TwoFiles); + + // Hovering: the target reports itself over, for a native file drag. + await DispatchDragAsync(bin, "dragenter", files); + await Expect(bin).ToHaveTextAsync("Release to drop the files"); + await Expect(bin).ToHaveCSSAsync("background-color", "rgb(21, 101, 192)"); + + // The module accepts the dragover, or the browser would not raise a drop here. + await DispatchDragAsync(bin, "dragover", files); + var over = await LastDragOverAsync(bin); + Assert.IsTrue(over.Inside && over.Prevented, $"the dragover of a file drag was not accepted: {over}"); + + await DispatchDragAsync(bin, "drop", files); + + await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Dropped files (2)" })).ToBeVisibleAsync(); + var rows = page.Locator(".demo-table tbody tr"); + await Expect(rows).ToHaveCountAsync(2); + await Expect(rows.Nth(0).Locator("td")).ToHaveTextAsync(["hello.txt", "text/plain", "5 B"]); + await Expect(rows.Nth(1).Locator("td")).ToHaveTextAsync(["data.bin", "-", "2 KB"]); + + // No dragleave follows a drop: the hover is cleared all the same, and the browser was kept from opening the files. + await Expect(bin).ToHaveTextAsync("Drag files here from your OS"); + var (count, prevented) = await DropsAsync(page.MainFrame); + Assert.AreEqual((1, 1), (count, prevented), "the drop reached the page and was kept from the browser"); + } + + [TestMethod] + public async Task A_file_drag_that_leaves_the_target_without_dropping_clears_its_hover() + { + var page = await OpenAsync("/other/native-files"); + var bin = page.Locator(".dustbin"); + var files = await NativeDataTransferAsync(page, TwoFiles); + + await DispatchDragAsync(bin, "dragenter", files); + await Expect(bin).ToHaveTextAsync("Release to drop the files"); + + await DispatchDragAsync(bin, "dragleave", files); + await Expect(bin).ToHaveTextAsync("Drag files here from your OS"); + await Expect(page.Locator(".demo-table")).ToHaveCountAsync(0); + } + + [TestMethod] + public async Task A_link_dragged_onto_the_target_is_read_as_html_text_and_url_without_being_followed() + { + var page = await OpenAsync("/other/native-html"); + var bin = page.Locator(".dustbin"); + var link = page.GetByRole(AriaRole.Link, new() { Name = "link inside" }); + var url = page.Url; + + // A real drag: Chromium drags a link with its URL, its text and its markup. + await StartDragAsync(page, link); + await DragToAsync(page, (await BoxAsync(bin)).Centre, () => Expect(bin).ToHaveTextAsync("Release (__NATIVE_HTML__)")); + + // The target lists HTML first, so the drag is matched as HTML; every type it carried was read. + var result = page.Locator(".native-drop-result"); + await Expect(result.Locator("h4")).ToHaveTextAsync("Last native drop - __NATIVE_HTML__"); + await Expect(result).ToContainTextAsync("URLs: https://react-dnd.github.io/react-dnd"); + await Expect(result).ToContainTextAsync("Text: https://react-dnd.github.io/react-dnd"); + var html = await result.Locator("pre").TextContentAsync() ?? ""; + StringAssert.Contains(html, "href=\"https://react-dnd.github.io/react-dnd\"", "the dropped markup"); + StringAssert.Contains(html, ">link inside", "the dropped markup"); + + // Nothing followed the link: no navigation, no new tab, and the target is idle again. + await Expect(bin).ToHaveTextAsync("Drag selected text / a link here"); + Assert.AreEqual(url, page.Url, "the drop navigated the page"); + Assert.HasCount(1, page.Context.Pages, "the drop opened another page"); + var (count, prevented) = await DropsAsync(page.MainFrame); + Assert.AreEqual((1, 1), (count, prevented), "the drop reached the page and was kept from the browser"); + } + + [TestMethod] + public async Task Dropped_text_is_matched_by_its_type_and_a_drag_of_a_type_the_target_does_not_list_is_refused() + { + var page = await OpenAsync("/other/native-html"); + var bin = page.Locator(".dustbin"); + var result = page.Locator(".native-drop-result"); + + var text = await NativeDataTransferAsync(page, "dt.setData('text/plain', 'some dragged words')"); + await DispatchDragAsync(bin, "dragenter", text); + await Expect(bin).ToHaveTextAsync("Release (__NATIVE_TEXT__)"); + await DispatchDragAsync(bin, "dragover", text); + await DispatchDragAsync(bin, "drop", text); + + await Expect(result.Locator("h4")).ToHaveTextAsync("Last native drop - __NATIVE_TEXT__"); + await Expect(result).ToContainTextAsync("Text: some dragged words"); + await Expect(bin).ToHaveTextAsync("Drag selected text / a link here"); + + // Files are not among the types this target lists: it does not light up, it answers the dragover with the + // no-drop effect, and a drop (which a browser would not even raise) is ignored. + var files = await NativeDataTransferAsync(page, TwoFiles); + await DispatchDragAsync(bin, "dragenter", files); + await DispatchDragAsync(bin, "dragover", files); + var over = await LastDragOverAsync(bin); + Assert.IsTrue(over.Inside && over.Prevented, $"the module did not answer the dragover of a refused drag: {over}"); + Assert.AreEqual("none", over.DropEffect, $"a refused native drag must show the no-drop effect: {over}"); + await DispatchDragAsync(bin, "drop", files); + await WaitForQuietAsync(page); + + await Expect(bin).ToHaveTextAsync("Drag selected text / a link here"); + await Expect(result.Locator("h4")).ToHaveTextAsync("Last native drop - __NATIVE_TEXT__"); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NestingTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NestingTests.cs new file mode 100644 index 00000000000..1e6741bc284 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/NestingTests.cs @@ -0,0 +1,97 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Nesting: a dragstart bubbles through every draggable around the one under the pointer, and only the innermost +/// may claim it; a dragenter bubbles through every drop target around the element under the pointer, and each of +/// them has to know whether the pointer is over it at all (IsOver) and whether over it rather than over a +/// target nested in it (IsOverShallow). +/// +[TestClass] +public sealed class NestingTests : BdndTest +{ + private const string Shallow = "rgb(255, 235, 59)"; + private const string AlsoOver = "rgba(255, 235, 59, 0.25)"; + + [TestMethod] + [DataRow("INNER")] + [DataRow("MIDDLE")] + [DataRow("OUTER")] + public async Task Grabbing_a_nested_source_drags_that_source_and_not_the_ones_around_it(string grabbed) + { + var page = await OpenAsync("/nesting/drag-sources"); + var nest = page.Locator(".nest-box"); + var (outer, middle, inner) = (nest.Nth(0), nest.Nth(1), nest.Nth(2)); + var (o, m, i) = (await BoxAsync(outer), await BoxAsync(middle), await BoxAsync(inner)); + + // The inner box by its centre; the middle and the outer ones by their labels, above the box nested in them. + var (grab, dragged) = grabbed switch + { + "INNER" => (i.Centre, inner), + "MIDDLE" => (new Point(m.Centre.X, (m.Y + i.Y) / 2), middle), + _ => (new Point(o.Centre.X, (o.Y + m.Y) / 2), outer), + }; + + await StartDragAsync(page, grab); + + // Only the grabbed source renders as dragging. + foreach (var box in new[] { outer, middle, inner }) + { + await Expect(box).ToHaveCSSAsync("opacity", box == dragged ? "0.4" : "1"); + } + + var bin = page.Locator(".dustbin"); + await DragToAsync(page, (await BoxAsync(bin)).Centre, () => Expect(bin).ToHaveCSSAsync("background-color", "rgb(0, 100, 0)")); + + await Expect(bin).ToHaveTextAsync($"You dragged: {grabbed}"); + await Expect(dragged).ToHaveCSSAsync("opacity", "1"); + + // One dragstart, claimed by one source: exactly one library tag on it. + var starts = await DragStartsAsync(page.MainFrame); + Assert.HasCount(1, starts, "one gesture raised more than one dragstart"); + Assert.HasCount(1, starts[0].Types.Where(t => t.StartsWith("application/x-bit-bdnd-", StringComparison.Ordinal)), + "the dragstart was tagged by more than one source: " + string.Join(", ", starts[0].Types)); + } + + [TestMethod] + public async Task Hovering_a_nested_target_marks_it_as_the_shallow_one_and_the_targets_around_it_as_also_over() + { + var page = await OpenAsync("/nesting/drop-targets"); + var levels = Enumerable.Range(0, 4).Select(n => page.Locator($".nest-box[data-level=\"{n}\"]")).ToArray(); + var boxes = new Box[4]; + for (var n = 0; n < 4; n++) boxes[n] = await BoxAsync(levels[n]); + + await StartDragAsync(page, page.Locator(".box", new() { HasText = "Drag me into the nest" })); + + // Over the innermost level: it is the shallow one, every level around it is "also over". + await MoveAsync(page, boxes[3].Centre); + await ExpectBackgroundsAsync(levels, AlsoOver, AlsoOver, AlsoOver, Shallow); + + // Back out to level 1's own label, above level 2: the levels inside it are not hovered any more. + await MoveAsync(page, new Point(boxes[1].Centre.X, (boxes[1].Y + boxes[2].Y) / 2)); + await ExpectBackgroundsAsync(levels, AlsoOver, Shallow, Resting(2), Resting(3)); + + // Out of the nest altogether, and released there: nothing is hovered, and the drag ended. + await DragToAsync(page, new Point(boxes[0].Centre.X, boxes[0].Bottom + 40), + () => ExpectBackgroundsAsync(levels, Resting(0), Resting(1), Resting(2), Resting(3))); + await Expect(page.Locator(".box", new() { HasText = "Drag me into the nest" })).ToHaveCSSAsync("opacity", "1"); + } + + /// A level's own colour, darker the deeper it is nested. + private static string Resting(int level) + { + var shade = 200 - level * 45; + return $"rgb({shade}, {shade + 20}, {shade + 40})"; + } + + private static async Task ExpectBackgroundsAsync(ILocator[] levels, params string[] colours) + { + for (var n = 0; n < levels.Length; n++) + { + await Expect(levels[n]).ToHaveCSSAsync("background-color", colours[n]); + } + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md new file mode 100644 index 00000000000..e89c8234bee --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/README.md @@ -0,0 +1,112 @@ +# Bit.Bdnd end-to-end tests + +The component tests in `Bit.Bdnd.Tests` run the draggables, drop targets and drag layers without a browser: the +manager's state machine, the collected states, the callbacks and their order are all checked in .NET, and the browser +module is a test double the tests call into by hand. That covers the C# side well and leaves out everything that only +exists in a real browser: + +- `bit-bdnd.ts` never executes, so nothing proves that the module even loads, that it tags a `dragstart` with the + source's id (`application/x-bit-bdnd-`) while the browser still lets it write to the `DataTransfer`, that only + the innermost of nested sources claims it, or that a drag handle is the only place a drag may start; +- there is no native event order: `dragenter` and `dragleave` for every element the pointer crosses, `dragover` only + for the moves that stay on one, a `drop` that the browser raises only where the last answer was "accepted", and a + `dragend` raised on the source even when a drop has re-rendered it out of the document; +- the drop-effect feedback (`preventDefault` and `dropEffect` on every `dragover`) and the hover tracking that has + to survive Blazor replacing the element under the pointer are never exercised; +- a native drag - a link from the page, files or text from the operating system - is never read from a live + `DataTransfer`; +- the demo pages, which compose the components with real application code, never run. + +This suite boots the demo (`Bit.Bdnd.Demo.Server`, which prerenders the pages and runs them on WebAssembly) and +drives it in Chromium with Playwright, whose mouse Chromium turns into real HTML5 drags. It asserts **effects**, not +markup: what a page shows and the colours the browser computed while a drag hovers and after it drops, where a dropped +box landed on screen, and the drag events as the browser raised them and the module answered them. + +## What it proves + +| Suite | What it covers | +|---|---| +| `DustbinTests` | A box dropped on the dustbin is recorded, and the bin shows "Release to drop" in its hover colour while the box is over it; the hover survives the bin replacing the element under the pointer; leaving the bin clears the hover and a drop elsewhere records nothing (no `drop` event at all); a box lights up (dashed outline) only the bins that accept its type, dims the others, and drops into an accepting one; a bin that does not accept the type answers every `dragover` with the `none` effect, so the browser raises no drop there, while over no target at all the browser's default applies; the "move" box leaves the palette once a drop took it (`OnEnd` with `DidDrop`) while the "copy" box stays however often it is dropped, and both stay after drops outside the bin; the same demo works inside an iframe. | +| `TutorialTests` | A knight in the air lights exactly its legal squares green, the hovered square yellow when legal and red when not; a drop on an illegal square moves nothing (no `drop` event) and ends the drag; a drop on a legal square moves the knight, and ends the drag although the drop re-rendered the dragged knight out of the document first (the browser raised `dragend` on the detached element, where only the module's own listener hears it); the knight on its new square drags again, with its new legal squares. | +| `NestingTests` | Grabbing the inner, middle or outer of three nested sources drags that one only (one `dragstart`, one library tag on it); hovering the innermost of four nested targets paints it as the shallow one and its ancestors as "also over", backing out to an outer level un-hovers the inner ones, and leaving the nest clears them all. | +| `DragAroundTests` | A dropped box moves by exactly the distance the pointer travelled since the `dragstart`; with a custom drag layer, the preview follows the pointer wherever it goes (drawn at the source's corner plus the travelled distance), disappears on drop, and the box lands where the preview was. | +| `CustomizeTests` | A box without a handle drags from anywhere on it; a box with a handle starts no drag at all when pressed on its text, and drags as a whole from its grip; a drag is tagged at `dragstart` with its source's id as both the type's suffix and its data, different for every source, and carries the source's `effectAllowed` (`copy`, `move`), which is the drop effect the browser shows over a target that takes it; the module keeps every drop from the browser. | +| `SortableTests` | Dragging a card past the middle of the next ones reorders the list while the card hovers, and a drop keeps the order; a card dropped outside the list goes back where it started (`OnEnd` with `DidDrop` false), while one dropped in a gap between cards (on the list itself) keeps its new place. | +| `NativeDragTests` | Files dragged in from the system light up the target and are listed with name, type and size on drop, with the browser kept from opening them; a file drag that leaves without dropping clears the hover; a link dragged from the page itself (a real browser drag) is read as HTML, text and URL, without the page navigating or opening a tab; dropped text is matched by its type, and a drag of a type the target does not list neither lights it up nor drops (the `none` effect). | +| `DemoPagesTests` | Every demo page (the routes are read from the `@page` directives under `Demo/Client/Pages`, so a new page is swept without being listed) answers, becomes interactive, loads `bit-bdnd.js` and registers every draggable with it when it has draggables, shows its own source under "View code", and runs every button on it without a console error, an unhandled exception, a failed request or the error banner; "View code" opens and closes; navigating through every page in the app leaves a page that still drags. | + +No test is `[Ignore]`d. Every test, not only the page sweep, fails when its page logs a console error, throws an +unhandled exception or has a request fail. + +Drags from outside the browser cannot be started with a mouse, so the file and text drops raise `dragenter`, +`dragover`, `drop` and `dragleave` themselves, on the target, with a `DataTransfer` created in the page - the events +then run through the module and Blazor exactly as the operating system's would. A link, by contrast, is dragged for +real. + +Every page is prerendered, so its boxes and bins are on screen before anything listens to them, and a draggable only +starts working once the module has registered it, asynchronously after the render that created it. Each test waits +until the page runs in the browser (`data-interactive="true"` on the layout) and, before every drag, until every +draggable on the page is registered and registrations have settled (`BdndTest.WaitForBdndAsync`, which counts the +element-level listeners the module adds). Where a test checks how the module answered a `dragover`, it waits for the +event the browser raised at the very point the pointer was moved to (`BdndTest.DragOverAtAsync`). + +## Running locally + +From `src/Bdnd` (inside `src`, so `src/global.json` selects the Microsoft.Testing.Platform runner): + +```bash +# The suite runs the demo server's build output: build it first, in the configuration the tests run in. +dotnet build Demo/Server/Bit.Bdnd.Demo.Server.csproj +dotnet build Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj + +# Once per machine (and after a Playwright update): the browser Playwright drives. +pwsh Tests/Bit.Bdnd.Tests.E2E/bin/Debug/net10.0/playwright.ps1 install chromium + +cd Tests/Bit.Bdnd.Tests.E2E && dotnet test + +# One test class, or one test +dotnet test --filter "FullyQualifiedName~.SortableTests." +dotnet test --filter "FullyQualifiedName~Dropping_the_knight" +# Everything but the page sweep +dotnet test --filter "FullyQualifiedName!~Every_demo_page" +``` + +The workflow `.github/workflows/bit.ci.Bdnd.e2e.yml` does the same in Release. It is started by hand +(`workflow_dispatch`) only, since the suite takes far longer than the build-and-unit pipeline in +`bit.ci.Bdnd.yml`: + +```bash +dotnet build src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj -c Release +dotnet build src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/Bit.Bdnd.Tests.E2E.csproj -c Release +pwsh src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium +cd src/Bdnd/Tests/Bit.Bdnd.Tests.E2E && dotnet test -c Release --no-build --report-trx --report-trx-filename bdnd-e2e.trx +``` + +Without PowerShell 7, the browser can be installed with Windows PowerShell +(`powershell -ExecutionPolicy Bypass -File bin/Debug/net10.0/playwright.ps1 install chromium`) or with the Node.js +runtime Playwright ships: +`bin/Debug/net10.0/.playwright/node//node bin/Debug/net10.0/.playwright/package/cli.js install chromium`. + +`install chromium` downloads both the full Chromium build and Playwright's headless shell. The suite prefers the +full build, in its new headless mode (the `chromium` channel), because that is the code a person's Chrome runs; it +falls back to the headless shell, where it passes as well. + +The suite starts the demo server itself (`dotnet Bit.Bdnd.Demo.Server.dll --urls http://127.0.0.1:`, +in the Development environment so the build output finds its static web assets), waits until it answers, shares +it between all tests and stops it when the run ends. Each test gets a browser context of its own, in which every +request that leaves the demo's origin (its web font) is answered with an empty response, so a machine without +internet access runs the suite just the same. The tests run one at a time: a drag is a sequence of native events +whose handlers call into .NET between them, and a second page booting WebAssembly next to it is exactly the load +that turns those waits into flakes. + +When no browser can be launched or the demo does not start, every test reports **Inconclusive** with the reason. +On a build server (`CI=true`) the same tests **fail** instead, so a suite that never ran cannot pass. + +### Environment variables + +| Variable | Effect | +|---|---| +| `BDND_E2E_CONFIGURATION` | Build configuration of the demo server to boot; defaults to the test assembly's own. | +| `BDND_E2E_BASE_URL` | Drive an already running demo (for example one started from the IDE) instead of booting one. | +| `BDND_E2E_CHANNEL` / `BDND_E2E_EXECUTABLE` | Use a specific browser: an installed Chrome/Edge (`chrome`, `msedge`), Playwright's headless shell (`chromium-headless-shell`), or a Chromium binary. Without either, the full Chromium Playwright installed is used, then its headless shell, falling back to an installed Chrome, then Edge. | +| `BDND_E2E_HEADED=1` | Show the browser. | diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/SortableTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/SortableTests.cs new file mode 100644 index 00000000000..0bc0427019b --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/SortableTests.cs @@ -0,0 +1,92 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// Sortable lists: every card is both a source and a target, and reorders the list from its OnHover as the +/// dragged card passes its middle, measuring itself in the browser to know where that is. The list around the cards +/// is a target too, so a drop in a gap still counts, and a drop outside it is the one that reverts the order. +/// +[TestClass] +public sealed class SortableTests : BdndTest +{ + [TestMethod] + public async Task Dragging_a_card_past_the_middle_of_the_next_ones_reorders_the_list_while_it_hovers() + { + var page = await OpenAsync("/sortable/simple"); + var order = page.Locator(".sortable-order"); + await Expect(order).ToHaveTextAsync("Order: Write the docs, Review the PR, Ship the release, Celebrate, Sleep"); + + var first = Card(page, 1); + var third = await BoxAsync(Card(page, 3)); + await StartDragAsync(page, first); + await Expect(first).ToHaveCSSAsync("opacity", "0.2"); + + // Into the lower half of the third card: past the middle of the second and of the third. The list reorders + // while the card is still in the air. + var target = new Point(third.Centre.X, third.Y + third.Height * 0.75); + const string Reordered = "Order: Review the PR, Ship the release, Write the docs, Celebrate, Sleep"; + await HoverUntilAsync(page, target, async () => await order.TextContentAsync() == Reordered, "the list reordered"); + CollectionAssert.AreEqual(new[] { "2", "3", "1", "4", "5" }, await CardOrderAsync(page), "the cards on screen"); + + // Dropped on the card it now sits under, it stays there. + await DragToAsync(page, target); + await Expect(first).ToHaveCSSAsync("opacity", "1"); + await Expect(order).ToHaveTextAsync(Reordered); + CollectionAssert.AreEqual(new[] { "2", "3", "1", "4", "5" }, await CardOrderAsync(page), "the cards after the drop"); + } + + [TestMethod] + public async Task A_card_dropped_outside_the_list_goes_back_to_where_it_started() + { + var page = await OpenAsync("/sortable/cancel-on-drop-outside"); + var order = page.Locator(".sortable-order"); + // The cards span the list's width. + var card = await BoxAsync(Card(page, 1)); + + await DragAlphaBelowCharlieAsync(page, order); + + // Out of the list, to the empty stage on its right, and released there: no target took the drop, so OnEnd + // sees DidDrop false and puts the card back. + await DragToAsync(page, new Point(card.Right + 200, card.Centre.Y)); + await Expect(order).ToHaveTextAsync("Order: Alpha, Bravo, Charlie, Delta, Echo"); + CollectionAssert.AreEqual(new[] { "1", "2", "3", "4", "5" }, await CardOrderAsync(page), "the cards after the cancel"); + await Expect(Card(page, 1)).ToHaveCSSAsync("opacity", "1"); + } + + [TestMethod] + public async Task A_card_dropped_in_a_gap_of_the_list_keeps_its_new_place() + { + var page = await OpenAsync("/sortable/cancel-on-drop-outside"); + var order = page.Locator(".sortable-order"); + + await DragAlphaBelowCharlieAsync(page, order); + + // Up into the gap between Charlie and Alpha, where it now sits: no card is hovered there (so nothing + // reorders), but the list is, and it takes the drop. + var charlie = await BoxAsync(Card(page, 3)); + var alpha = await BoxAsync(Card(page, 1)); + Assert.IsGreaterThan(charlie.Bottom + 2, alpha.Y, "there is no gap between the cards"); + await DragToAsync(page, new Point(alpha.Centre.X, (charlie.Bottom + alpha.Y) / 2)); + + await Expect(Card(page, 1)).ToHaveCSSAsync("opacity", "1"); + await Expect(order).ToHaveTextAsync("Order: Bravo, Charlie, Alpha, Delta, Echo"); + Assert.AreEqual(1, (await DropsAsync(page.MainFrame)).Count, "the drop in the gap"); + } + + private static async Task DragAlphaBelowCharlieAsync(IPage page, ILocator order) + { + await Expect(order).ToHaveTextAsync("Order: Alpha, Bravo, Charlie, Delta, Echo"); + var charlie = await BoxAsync(Card(page, 3)); + await StartDragAsync(page, Card(page, 1)); + await HoverUntilAsync(page, new Point(charlie.Centre.X, charlie.Y + charlie.Height * 0.75), + async () => await order.TextContentAsync() == "Order: Bravo, Charlie, Alpha, Delta, Echo", "Alpha moved below Charlie"); + } + + private static ILocator Card(IPage page, int id) => page.Locator($".sortable-card[data-card=\"{id}\"]"); + + private static Task CardOrderAsync(IPage page) => page.EvaluateAsync( + "() => [...document.querySelectorAll('.sortable-card')].map(c => c.dataset.card)"); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/TutorialTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/TutorialTests.cs new file mode 100644 index 00000000000..477b89c7154 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.E2E/TutorialTests.cs @@ -0,0 +1,102 @@ +using Bit.Bdnd.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Bdnd.Tests.E2E; + +/// +/// The chessboard tutorial: every square is a drop target whose CanDropPredicate allows only a knight's move, +/// so a knight in the air lights up exactly the squares it may go to, and a drop moves it only there. A legal drop +/// re-renders the board and removes the dragged knight from the DOM before the browser ends the drag, which is the +/// case the browser module's end-of-drag handling exists for. +/// +[TestClass] +public sealed class TutorialTests : BdndTest +{ + private const string Green = "rgb(0, 128, 0)"; + private const string Yellow = "rgb(255, 255, 0)"; + private const string Red = "rgb(255, 0, 0)"; + + [TestMethod] + public async Task Dragging_the_knight_highlights_exactly_the_squares_it_may_move_to() + { + var page = await OpenAsync("/tutorial"); + + await StartDragAsync(page, page.Locator(".knight")); + + // From column 1, row 7 a knight reaches three squares, each lit green. The pointer is still over the + // knight's own square, which the knight cannot move to: hovered but refusing, it is red. + await Expect(page.Locator(".chess-overlay")).ToHaveCountAsync(4); + CollectionAssert.AreEquivalent(new[] { "0,5", "2,5", "3,6" }, await SquaresLitAsync(page, Green), "the legal squares"); + CollectionAssert.AreEquivalent(new[] { "1,7" }, await SquaresLitAsync(page, Red), "the hovered square"); + + // Over a legal square the light turns yellow; the origin square, no longer hovered, goes dark. + await MoveAsync(page, Square(page, "2,5")); + await Expect(Overlay(page, "2,5")).ToHaveCSSAsync("background-color", Yellow); + await Expect(Overlay(page, "1,7")).ToHaveCountAsync(0); + + // Over an illegal square: red, and the legal one it left is green again. + await DragToAsync(page, (await BoxAsync(Square(page, "4,4"))).Centre, async () => + { + await Expect(Overlay(page, "4,4")).ToHaveCSSAsync("background-color", Red); + await Expect(Overlay(page, "2,5")).ToHaveCSSAsync("background-color", Green); + }); + + // The drop on the illegal square did not move the knight, and the drag ended: no square is lit any more. + await Expect(page.Locator(".chess-overlay")).ToHaveCountAsync(0); + await Expect(Square(page, "1,7").Locator(".knight")).ToBeVisibleAsync(); + await Expect(Status(page)).ToContainTextAsync("Knight at column 1, row 7."); + Assert.AreEqual(0, (await DropsAsync(page.MainFrame)).Count, "the browser raised a drop on a square the knight cannot go to"); + } + + [TestMethod] + public async Task Dropping_the_knight_on_a_legal_square_moves_it_and_ends_the_drag_of_the_removed_source() + { + var page = await OpenAsync("/tutorial"); + + await DragAsync(page, page.Locator(".knight"), Square(page, "2,5"), + () => Expect(Overlay(page, "2,5")).ToHaveCSSAsync("background-color", Yellow)); + + await Expect(Square(page, "2,5").Locator(".knight")).ToBeVisibleAsync(); + await Expect(page.Locator(".knight")).ToHaveCountAsync(1); + await Expect(Status(page)).ToContainTextAsync("Knight at column 2, row 5."); + + // The drop moved the knight while the drag was still running, so the element the drag started from was gone + // from the DOM when the browser ended the drag. The drag still ended: no square stays lit. + await Expect(page.Locator(".chess-overlay")).ToHaveCountAsync(0); + + // That is the case under test: a dragend raised on an element still in the document bubbles up to the + // window, and none did. The browser raised it on the detached element, where only the module's own + // element-level listener could hear it. + Assert.AreEqual(0, await page.EvaluateAsync("() => window.__bdndE2E.dragends"), + "the knight's element was still in the document when the drag ended"); + + // The knight on its new square is a new draggable, and the next move starts from there. + await StartDragAsync(page, page.Locator(".knight")); + await Expect(page.Locator(".chess-overlay")).ToHaveCountAsync(9); + CollectionAssert.AreEquivalent(new[] { "0,4", "0,6", "1,3", "1,7", "3,3", "3,7", "4,4", "4,6" }, await SquaresLitAsync(page, Green), + "the legal squares from column 2, row 5"); + CollectionAssert.AreEquivalent(new[] { "2,5" }, await SquaresLitAsync(page, Red), "the hovered square"); + + await DragToAsync(page, (await BoxAsync(Square(page, "4,4"))).Centre, + () => Expect(Overlay(page, "4,4")).ToHaveCSSAsync("background-color", Yellow)); + + await Expect(Square(page, "4,4").Locator(".knight")).ToBeVisibleAsync(); + await Expect(Status(page)).ToContainTextAsync("Knight at column 4, row 4."); + await Expect(page.Locator(".chess-overlay")).ToHaveCountAsync(0); + } + + private static ILocator Square(IPage page, string square) => page.Locator($".chess-square[data-square=\"{square}\"]"); + + private static ILocator Overlay(IPage page, string square) => Square(page, square).Locator(".chess-overlay"); + + private static ILocator Status(IPage page) => page.GetByText("Knight at column"); + + /// The squares whose overlay the browser paints in . + private static Task SquaresLitAsync(IPage page, string colour) => page.EvaluateAsync( + """ + (colour) => [...document.querySelectorAll('.chess-square')] + .filter(s => { const o = s.querySelector('.chess-overlay'); return o && getComputedStyle(o).backgroundColor === colour; }) + .map(s => s.dataset.square) + """, colour); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/Bit.Bdnd.Tests.Mcp.csproj b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/Bit.Bdnd.Tests.Mcp.csproj new file mode 100644 index 00000000000..122077d1e7d --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/Bit.Bdnd.Tests.Mcp.csproj @@ -0,0 +1,37 @@ + + + + + net10.0 + + Exe + enable + enable + false + true + $(NoWarn);CS1591 + true + false + + + + + + + + + + + + + + + + + + + diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpCall.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpCall.cs new file mode 100644 index 00000000000..d8d1a5fcac5 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpCall.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// Calls a tool the way a client does and hands back what a client would actually get. +/// +/// Every call goes over the connected session rather than into the controller, so a tool that +/// throws, answers with nothing, or returns something that does not serialize fails here - with +/// the tool's name in the message - instead of being read past by the test that called it. +/// +/// +internal static class McpCall +{ + private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); + + /// The raw result, for the tests that are about the failure rather than the answer. + public static Task RawAsync(string tool, Dictionary? arguments = null) + => McpTestHost.Client.CallToolAsync(tool, arguments).AsTask(); + + /// The text of a prose tool's answer. + public static async Task TextAsync(string tool, Dictionary? arguments = null) + { + var result = await RawAsync(tool, arguments); + + AssertSucceeded(tool, result); + + var text = string.Join('\n', result.Content.OfType().Select(block => block.Text)); + + Assert.IsFalse(string.IsNullOrWhiteSpace(text), $"'{tool}' answered with no text at all."); + + return text; + } + + /// + /// A structured tool's answer, deserialized into the DTO the tool declares. This is the round + /// trip that matters for a client: the object is rebuilt out of the JSON that went over the + /// wire, under the output schema the tool published, not out of the instance the method made. + /// + public static async Task StructuredAsync(string tool, Dictionary? arguments = null) + { + var result = await RawAsync(tool, arguments); + + AssertSucceeded(tool, result); + + Assert.IsNotNull(result.StructuredContent, + $"'{tool}' returned no structured content, so a client has nothing to validate against its output schema."); + + var value = result.StructuredContent.Value.Deserialize(_json); + + Assert.IsNotNull(value, $"'{tool}' returned structured content that does not deserialize into {typeof(T).Name}."); + + return value; + } + + /// The text of a tool call that is expected to answer, rather than to fail. + private static void AssertSucceeded(string tool, CallToolResult result) + { + if (result.IsError is not true) return; + + var text = string.Join('\n', result.Content.OfType().Select(block => block.Text)); + + Assert.Fail($"'{tool}' answered with an error: {text}"); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs new file mode 100644 index 00000000000..487a0471c2f --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs @@ -0,0 +1,408 @@ +using System.Text.Json.Nodes; +using Bit.Bdnd.Demo.Server.Dtos; +using Bit.Bdnd.Demo.Server.Services; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// InspectBdndDrop on the drags a page actually meets: the nested kanban of the sample, an item of a +/// type the hovered target does not take, a target whose predicate says no, a drop outside every +/// target, a draggable that cannot drag, files from the desktop, the mistakes in a scenario that are +/// worth a note, and input that is not a scenario at all. The scenarios are built as JSON, so each test +/// changes exactly the one thing it is about. +/// +[TestClass] +public class McpDropLabTests +{ + [TestMethod] + public async Task Without_a_scenario_it_hands_out_a_sample_it_can_run() + { + var report = await InspectAsync(null); + + Assert.IsFalse(report.Valid); + Assert.IsNotNull(report.Sample); + Assert.IsNull(report.Targets); + + var sample = JsonNode.Parse(report.Sample)!; + + Assert.AreEqual("card", (string?)sample["draggable"]!["type"]); + Assert.AreEqual("card-2", (string?)sample["hover"]); + } + + [TestMethod] + public async Task The_sample_drops_innermost_first_and_the_outer_result_wins() + { + var report = await InspectAsync((await InspectAsync(null)).Sample); + + Assert.IsTrue(report.Valid, report.Error); + Assert.AreEqual("card", report.ItemType); + Assert.IsFalse(report.Native); + Assert.IsTrue(report.DragStarted); + Assert.IsTrue(report.BeginFired); + Assert.AreEqual("move", report.EffectAllowed); + CollectionAssert.AreEqual(new[] { "board", "todo", "card-2" }, report.HoverPath); + + // Nested targets get OnDrop innermost first, and each outer one sees that an inner one already dropped. + Assert.IsTrue(report.DropFired); + CollectionAssert.AreEqual(new[] { "card-2", "todo", "board" }, report.DropOrder); + Assert.IsFalse(Target(report, "card-2").InnerAlreadyDropped); + Assert.IsTrue(Target(report, "todo").InnerAlreadyDropped); + Assert.IsTrue(Target(report, "board").InnerAlreadyDropped); + + // "todo" published first, "board" last: a non-null outer result overrides the inner one. + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = true, DropResult = "board" }, report.End); + Assert.IsTrue(report.Notes.Any(note => note.Contains("replaces an inner one", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task The_sample_hovers_deep_on_the_chain_and_shallow_on_the_innermost_only() + { + var report = await InspectAsync((await InspectAsync(null)).Sample); + + foreach (var id in new[] { "board", "todo", "card-2" }) + { + var target = Target(report, id); + + Assert.IsTrue(target.Hovered, id); + Assert.IsTrue(target.IsOver, id); + Assert.IsTrue(target.CanDrop, id); + Assert.AreEqual(id == "card-2", target.IsOverShallow, id); + } + + // A CanDropPredicate that says no, and a target that only takes files: neither can drop a card. + var done = Target(report, "done"); + var attachments = Target(report, "attachments"); + + Assert.IsFalse(done.CanDrop); + Assert.IsFalse(done.IsOver); + Assert.IsNull(done.DropOrder); + Assert.IsFalse(attachments.CanDrop); + CollectionAssert.AreEqual(new[] { BdndNativeTypes.File }, attachments.Accept); + Assert.AreEqual("board", done.Parent); + Assert.AreEqual(1, done.Depth); + } + + [TestMethod] + public async Task A_target_that_does_not_take_the_type_passes_the_drop_to_the_one_around_it() + { + var report = await InspectAsync(Scenario("card", "trash", + Target("list", ["card"], result: "list", children: [Target("trash", ["bin"])]))); + + var trash = Target(report, "trash"); + var list = Target(report, "list"); + + Assert.IsTrue(trash.Hovered); + Assert.IsFalse(trash.IsOver, "IsOver is only set for a target that accepts the type."); + Assert.IsFalse(trash.CanDrop); + Assert.IsTrue(list.IsOver); + Assert.IsTrue(list.IsOverShallow, "With the inner target out of it, the list is the innermost hovered target that counts."); + + CollectionAssert.AreEqual(new[] { "list" }, report.DropOrder); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = true, DropResult = "list" }, report.End); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'trash' does not accept 'card'", StringComparison.Ordinal) && note.Contains("falls through to 'list'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_wrong_type_with_nothing_around_it_never_drops() + { + var report = await InspectAsync(Scenario("card", "trash", Target("trash", ["bin"]))); + + Assert.IsFalse(report.DropFired, "With no target able to drop, the browser fires no drop event."); + Assert.IsEmpty(report.DropOrder!); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = false }, report.End); + Assert.IsTrue(report.Notes.Any(note => note.Contains("no drop event", StringComparison.Ordinal))); + Assert.IsTrue(report.Notes.Any(note => note.Contains("No target accepts 'card'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_predicate_that_says_no_keeps_the_hover_and_rejects_the_drop() + { + var report = await InspectAsync(Scenario("card", "done", + Target("board", ["card"], children: [Target("done", ["card"], canDrop: false, result: "done")]))); + + var done = Target(report, "done"); + + // Hover follows the type; the predicate only decides CanDrop. + Assert.IsTrue(done.IsOver); + Assert.IsTrue(done.IsOverShallow); + Assert.IsFalse(done.CanDrop); + Assert.IsNull(done.DropOrder); + + CollectionAssert.AreEqual(new[] { "board" }, report.DropOrder); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = true }, report.End); + Assert.IsTrue(report.Notes.Any(note => note.Contains("CanDropPredicate answered false", StringComparison.Ordinal) && note.Contains("'board'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_lone_target_whose_predicate_says_no_ends_the_drag_as_a_cancel() + { + var report = await InspectAsync(Scenario("card", "done", Target("done", ["card"], canDrop: false))); + + Assert.IsFalse(report.DropFired); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = false }, report.End); + } + + [TestMethod] + public async Task An_inner_result_survives_an_outer_target_that_sets_none() + { + var report = await InspectAsync(Scenario("card", "slot", + Target("column", ["card"], children: [Target("slot", ["card"], result: "slot")]))); + + CollectionAssert.AreEqual(new[] { "slot", "column" }, report.DropOrder); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = true, DropResult = "slot" }, report.End); + Assert.IsFalse(report.Notes.Any(note => note.Contains("replaces an inner one", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_drop_outside_every_target_is_a_cancel_and_can_drop_still_lights_up() + { + var report = await InspectAsync(Scenario("card", null, Target("left", ["card"]), Target("right", ["card"]))); + + Assert.IsEmpty(report.HoverPath!); + Assert.IsFalse(report.DropFired); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = false }, report.End); + + // CanDrop is about the item, not the pointer. + Assert.IsTrue(report.Targets!.All(target => target.CanDrop && target.IsOver is false)); + Assert.IsTrue(report.Notes.Any(note => note.Contains("dropped outside every target", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_draggable_that_cannot_drag_never_starts() + { + var report = await InspectAsync(Scenario(new JsonObject { ["type"] = "card", ["canDrag"] = false }, "list", Target("list", ["card"]))); + + Assert.IsTrue(report.Valid); + Assert.IsFalse(report.DragStarted); + Assert.IsFalse(report.BeginFired); + Assert.IsEmpty(report.DataTransferTypes!, "The script tags no drag for a source that cannot drag."); + Assert.IsNull(report.End); + Assert.IsFalse(Target(report, "list").CanDrop); + Assert.IsTrue(report.Notes.Any(note => note.Contains("draggable=\"false\"", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task The_drag_is_tagged_with_the_draggables_own_type_and_effect() + { + var report = await InspectAsync(Scenario(new JsonObject { ["type"] = "box", ["dropEffect"] = "copy" }, "bin", Target("bin", ["box"]))); + + Assert.AreEqual("copy", report.EffectAllowed); + Assert.HasCount(1, report.DataTransferTypes!); + Assert.StartsWith("application/x-bit-bdnd-", report.DataTransferTypes![0]); + } + + [TestMethod] + public async Task An_effect_of_none_turns_every_drop_off() + { + var report = await InspectAsync(Scenario(new JsonObject { ["type"] = "box", ["dropEffect"] = "none" }, "bin", Target("bin", ["box"]))); + + Assert.IsTrue(Target(report, "bin").CanDrop); + Assert.IsFalse(report.DropFired); + Assert.AreEqual(new BdndDragEndReportDto { DidDrop = false }, report.End); + Assert.IsTrue(report.Notes.Any(note => note.Contains("dropEffect is 'none'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task An_effect_the_browser_does_not_know_is_pointed_out() + { + var report = await InspectAsync(Scenario(new JsonObject { ["type"] = "box", ["dropEffect"] = "copymove" }, "bin", Target("bin", ["box"]))); + + Assert.IsTrue(report.DropFired); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'copyMove'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_native_file_drag_reaches_every_accepting_target_through_OnNativeDrop() + { + var report = await InspectAsync(Scenario("BdndNativeTypes.File", "zone", + Target("page", ["native:file"], result: "ignored", children: [Target("zone", ["BdndNativeTypes.File"]), Target("other", [BdndNativeTypes.File])]))); + + Assert.IsTrue(report.Valid, report.Error); + Assert.IsTrue(report.Native); + Assert.AreEqual(BdndNativeTypes.File, report.ItemType); + CollectionAssert.AreEqual(new[] { "Files" }, report.DataTransferTypes); + Assert.IsNull(report.End, "No BdndDraggable is involved, so there is no OnEnd."); + Assert.IsNull(report.DragStarted); + + // Both hovered targets take the drop, innermost first; the state lights up only under the pointer. + CollectionAssert.AreEqual(new[] { "zone", "page" }, report.DropOrder); + Assert.AreEqual(BdndNativeTypes.File, Target(report, "zone").NativeItemType); + + foreach (var id in new[] { "page", "zone" }) + { + Assert.IsTrue(Target(report, id).IsOver, id); + Assert.IsTrue(Target(report, id).IsOverShallow, $"{id}: a native drag has no shallow distinction."); + Assert.IsTrue(Target(report, id).CanDrop, id); + } + + Assert.IsFalse(Target(report, "other").CanDrop); + Assert.IsTrue(report.Notes.Any(note => note.Contains("OnNativeDrop, never OnDrop", StringComparison.Ordinal))); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'result' was not published", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_plain_file_type_does_not_take_a_native_drag() + { + var report = await InspectAsync(Scenario(BdndNativeTypes.File, "zone", Target("zone", ["file"]))); + + Assert.IsFalse(report.DropFired); + Assert.IsEmpty(report.DropOrder!); + Assert.IsFalse(Target(report, "zone").IsOver); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'zone' accepts 'file' - an in-app type name", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task Every_native_type_reaches_a_target_that_accepts_it() + { + foreach (var (name, constant) in new[] { ("File", BdndNativeTypes.File), ("Url", BdndNativeTypes.Url), ("Text", BdndNativeTypes.Text), ("Html", BdndNativeTypes.Html) }) + { + var report = await InspectAsync(Scenario($"BdndNativeTypes.{name}", "zone", Target("zone", [constant]))); + + CollectionAssert.AreEqual(new[] { "zone" }, report.DropOrder, name); + Assert.AreEqual(constant, Target(report, "zone").NativeItemType, name); + } + } + + [TestMethod] + public async Task A_type_that_differs_only_in_case_is_called_a_near_miss() + { + var report = await InspectAsync(Scenario("card", "list", Target("list", ["Card", "cards"]))); + + Assert.IsFalse(Target(report, "list").CanDrop); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'list' accepts 'Card', which is not 'card'", StringComparison.Ordinal))); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'list' accepts 'cards', which is not 'card'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task Duplicate_ids_and_empty_accept_lists_are_pointed_out() + { + var report = await InspectAsync(Scenario("card", "list", Target("list", ["card"]), Target("list", []))); + + Assert.HasCount(2, report.Targets!); + Assert.IsTrue(Target(report, "list").Hovered, "hover resolves a repeated id to the first target that has it."); + Assert.IsFalse(report.Targets![1].Hovered); + Assert.IsTrue(report.Notes.Any(note => note.Contains("used by 2 targets", StringComparison.Ordinal))); + Assert.IsTrue(report.Notes.Any(note => note.Contains("accepts nothing", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task A_hover_path_is_read_from_the_outer_target_in() + { + var path = await InspectAsync(Scenario("card", new JsonArray("outer", "inner"), Target("outer", ["card"], children: [Target("inner", ["card"])]))); + var broken = await InspectAsync(Scenario("card", new JsonArray("inner", "outer"), Target("outer", ["card"], children: [Target("inner", ["card"])]))); + + CollectionAssert.AreEqual(new[] { "outer", "inner" }, path.HoverPath); + Assert.IsFalse(broken.Valid); + Assert.Contains("not a chain of nested targets", broken.Error!); + } + + [TestMethod] + public async Task An_unknown_hover_id_suggests_the_close_ones() + { + var report = await InspectAsync(Scenario("card", "alpah", Target("alpha", ["card"]))); + + Assert.IsFalse(report.Valid); + Assert.Contains("Did you mean: 'alpha'", report.Error!); + } + + [TestMethod] + public async Task A_misspelt_property_is_named_rather_than_silently_ignored() + { + var target = new JsonObject { ["id"] = "list", ["acept"] = "card" }; + + var report = await InspectAsync(new JsonObject { ["draggable"] = new JsonObject { ["type"] = "card" }, ["targets"] = new JsonArray(target), ["hover"] = "list" }.ToJsonString()); + + Assert.IsTrue(report.Valid); + Assert.IsTrue(report.Notes.Any(note => note.Contains("'acept'", StringComparison.Ordinal) && note.Contains("did you mean 'accept'", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task Input_that_is_not_json_is_rejected_with_the_parsers_own_message() + { + var report = await InspectAsync("{ draggable: ["); + + Assert.IsFalse(report.Valid); + Assert.IsNotNull(report.Error); + Assert.IsNull(report.Targets); + } + + [TestMethod] + public async Task Json_that_is_not_a_scenario_says_what_a_scenario_is() + { + var array = await InspectAsync("[1, 2, 3]"); + var flat = await InspectAsync("""{ "type": "card" }"""); + var untyped = await InspectAsync("""{ "draggable": { "type": "" } }"""); + var anonymous = await InspectAsync("""{ "draggable": { "type": "card" }, "targets": [ { "accept": "card" } ] }"""); + + Assert.Contains("\"draggable\"", array.Error!); + Assert.Contains("\"draggable\" object", flat.Error!); + Assert.Contains("draggable.type is required", untyped.Error!); + Assert.Contains("targets[0].id is required", anonymous.Error!); + } + + [TestMethod] + public async Task A_scenario_past_the_tool_limit_is_refused_with_the_reason() + { + var huge = new string(' ', BdndDropLab.MaxInputBytes + 1) + """{ "draggable": { "type": "card" } }"""; + + var report = await InspectAsync(huge); + + Assert.IsFalse(report.Valid); + Assert.Contains($"{BdndDropLab.MaxInputBytes} bytes", report.Error!); + } + + [TestMethod] + public async Task Targets_nested_past_the_limit_are_refused_with_the_reason() + { + var root = Target("t0", ["card"]); + var current = root; + + for (var depth = 1; depth <= BdndDropLab.MaxDepth; depth++) + { + var child = Target($"t{depth}", ["card"]); + current["children"] = new JsonArray(child); + current = child; + } + + var report = await InspectAsync(Scenario("card", null, root)); + + Assert.IsFalse(report.Valid); + Assert.Contains($"nested up to {BdndDropLab.MaxDepth} deep", report.Error!); + } + + internal static Task InspectAsync(string? json) + { + var arguments = new Dictionary(); + if (json is not null) arguments["json"] = json; + + return McpCall.StructuredAsync("InspectBdndDrop", arguments); + } + + internal static string Scenario(string type, JsonNode? hover, params JsonObject[] targets) + => Scenario(new JsonObject { ["type"] = type }, hover, targets); + + internal static string Scenario(JsonObject draggable, JsonNode? hover, params JsonObject[] targets) => new JsonObject + { + ["draggable"] = draggable, + ["targets"] = new JsonArray([.. targets]), + ["hover"] = hover + }.ToJsonString(); + + internal static JsonObject Target(string id, string[] accept, bool? canDrop = null, string? result = null, JsonObject[]? children = null) + { + var target = new JsonObject { ["id"] = id, ["accept"] = new JsonArray([.. accept.Select(type => (JsonNode?)type)]) }; + + if (canDrop is not null) target["canDrop"] = canDrop; + if (result is not null) target["result"] = result; + if (children is not null) target["children"] = new JsonArray([.. children]); + + return target; + } + + private static BdndDropTargetReportDto Target(BdndDropReportDto report, string id) + { + Assert.IsTrue(report.Valid, report.Error); + + return report.Targets!.First(target => target.Id == id); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpOracleTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpOracleTests.cs new file mode 100644 index 00000000000..00f82fb7227 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpOracleTests.cs @@ -0,0 +1,354 @@ +using System.Net; +using System.Reflection; +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Bit.Bdnd.Demo.Server.Dtos; +using Bit.Bdnd.Demo.Server.Services; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// The server's claims put to the things they describe: the API reference against the assembly and +/// fresh instances of the types it documents, the drop lab against the rules the guide states for +/// nested drop targets, the guide's own reference tables against the components, the index against +/// the README, and the HTTP mirror against the protocol it mirrors. No expected answers are written +/// down here - the library, its files and the protocol are the expectations. +/// +[TestClass] +public partial class McpOracleTests +{ + // ---- The API reference ------------------------------------------------- + + [TestMethod] + public async Task Every_parameter_default_of_the_draggable_is_what_a_new_draggable_has() + { + // Over the wire, as an agent reads it, against an instance made here. + var reference = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDraggable" }); + + var checkedCount = AssertDefaultsMatch(typeof(BdndDraggable), reference); + + Assert.IsGreaterThanOrEqualTo(5, checkedCount, "CanDrag, UseCustomDragLayer, UseHandle, DropEffect and Tag are simple values whose defaults can be compared."); + } + + [TestMethod] + public void Every_parameter_default_of_every_component_is_what_a_new_instance_has() + { + var components = typeof(BdndProvider).Assembly.GetExportedTypes() + .Where(type => typeof(IComponent).IsAssignableFrom(type) && type.IsAbstract is false && type.Name != "_Imports") + .ToArray(); + + Assert.IsGreaterThanOrEqualTo(5, components.Length); + + foreach (var type in components) + { + // A generic component is what TItem="object" makes of it in markup. + var closed = type.IsGenericTypeDefinition ? type.MakeGenericType([.. type.GetGenericArguments().Select(_ => typeof(object))]) : type; + + AssertDefaultsMatch(closed, BdndApiCatalog.RenderType(type.Name)!); + } + } + + /// + /// Compares every parameter of a simple type - a number, a flag, a string, an enum - with the value + /// the property holds on a new instance, and returns how many it compared. + /// + private static int AssertDefaultsMatch(Type type, string reference) + { + var instance = Activator.CreateInstance(type)!; + var compared = 0; + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(property => property.IsDefined(typeof(ParameterAttribute)))) + { + var value = property.GetValue(instance); + + string? expected = value switch + { + bool flag => flag ? "true" : "false", + string text => $"\"{text}\"", + Enum member => $"{member.GetType().Name}.{member}", + double or int or float => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), + _ => null + }; + + if (expected is null) continue; + + var line = reference.Split('\n').SingleOrDefault(line => line.StartsWith($"- **{property.Name}** :", StringComparison.Ordinal)); + + Assert.IsNotNull(line, $"{type.Name}.{property.Name} is missing from its reference."); + Assert.Contains($" = `{expected}`", line, $"{type.Name}.{property.Name}"); + + compared++; + } + + return compared; + } + + [TestMethod] + public void Every_exported_type_is_in_the_api_index() + { + var exported = typeof(BdndDraggable<>).Assembly.GetExportedTypes() + // Razor compiles the library's _Imports.razor into a public class nobody calls. + .Where(type => type.IsNested is false && type.Name != "_Imports") + .Select(BdndApiCatalog.FriendlyName) + .ToHashSet(StringComparer.Ordinal); + + var indexed = BdndApiCatalog.Types.Select(type => type.Name).ToHashSet(StringComparer.Ordinal); + + CollectionAssert.AreEquivalent(exported.ToArray(), indexed.ToArray()); + } + + [TestMethod] + public void Every_public_type_has_its_summary_in_the_index() + { + // The XML documentation file has to reach the server's output folder for any of this to work; + // a build that stops copying it leaves every summary empty without failing anything else. The + // library's razor components carry their summaries on their partial classes, so none is exempt. + var missing = BdndApiCatalog.Types.Where(type => type.Summary is null).Select(type => type.Name).ToArray(); + + Assert.IsEmpty(missing, $"No XML summary for: {string.Join(", ", missing)}."); + } + + [TestMethod] + public void Every_public_member_of_a_component_is_documented() + { + foreach (var type in BdndApiCatalog.Types.Where(type => type.Kind == "Component")) + { + var undocumented = BdndApiCatalog.GetTypeDetails(type.Name)!.Members + .Where(member => member.Kind == "Parameter" && member.Summary is null) + .Select(member => member.Name) + .ToArray(); + + Assert.IsEmpty(undocumented, $"{type.Name}: {string.Join(", ", undocumented)}"); + } + } + + // ---- The drop lab -------------------------------------------------------- + + /// + /// Random trees of drop targets, run through the real components, held to the rules the guide + /// states for them: CanDrop where the type is accepted and the predicate agrees, hovered or not; + /// IsOver on every hovered target that accepts the type and IsOverShallow on the innermost of + /// them; OnDrop on every hovered target that can drop, innermost first; and the outermost + /// non-null result is the one OnEnd receives. + /// + [TestMethod] + public async Task The_drop_lab_agrees_with_the_rules_the_guide_states() + { + var random = new Random(20261018); + + for (var run = 0; run < 40; run++) + { + var targets = new List(); + var roots = Enumerable.Range(0, random.Next(1, 3)).Select(_ => RandomTarget(random, null, 0, targets)).ToArray(); + var hovered = random.Next(5) == 0 ? null : targets[random.Next(targets.Count)]; + var type = random.Next(4) == 0 ? "box" : "card"; + + var report = await McpDropLabTests.InspectAsync(McpDropLabTests.Scenario(type, hovered?.Id, [.. roots.Select(root => root.Json)])); + var context = $"run {run}: {string.Join(", ", targets.Select(target => target.ToString()))} hover {hovered?.Id ?? "none"}, type {type}"; + + Assert.IsTrue(report.Valid, $"{context}: {report.Error}"); + + var chain = new List(); + for (var target = hovered; target is not null; target = target.Parent) chain.Insert(0, target); + + bool Accepts(Model target) => target.Accept.Contains(type); + bool CanDrop(Model target) => Accepts(target) && target.CanDrop; + bool IsOver(Model target) => chain.Contains(target) && Accepts(target); + + var drops = Enumerable.Reverse(chain).Where(CanDrop).ToArray(); + + foreach (var (target, actual) in targets.Zip(report.Targets!)) + { + Assert.AreEqual(target.Id, actual.Id, context); + Assert.AreEqual(CanDrop(target), actual.CanDrop, $"{context} - {target.Id}.CanDrop"); + Assert.AreEqual(IsOver(target), actual.IsOver, $"{context} - {target.Id}.IsOver"); + Assert.AreEqual(IsOver(target) && chain.SkipWhile(other => other != target).Skip(1).Any(IsOver) is false, actual.IsOverShallow, $"{context} - {target.Id}.IsOverShallow"); + } + + CollectionAssert.AreEqual(drops.Select(target => target.Id).ToArray(), report.DropOrder, context); + Assert.AreEqual(drops.Length > 0, report.DropFired, context); + Assert.AreEqual(drops.Length > 0, report.End!.DidDrop, context); + Assert.AreEqual(drops.LastOrDefault(target => target.Result is not null)?.Result, report.End.DropResult, context); + } + } + + private sealed record Model(string Id, Model? Parent, string[] Accept, bool CanDrop, string? Result, JsonObject Json) + { + public override string ToString() => $"{Id}[{string.Join('|', Accept)}{(CanDrop ? null : " no")}{(Result is null ? null : $" ={Result}")}]"; + } + + private static Model RandomTarget(Random random, Model? parent, int depth, List all) + { + string[] types = ["card", "box", "column"]; + + var id = $"t{all.Count}"; + var accept = types.Where(_ => random.Next(3) > 0).ToArray(); + var canDrop = random.Next(5) > 0; + var result = random.Next(2) == 0 ? $"r{all.Count}" : null; + + var json = McpDropLabTests.Target(id, accept, canDrop: canDrop ? null : false, result: result); + var model = new Model(id, parent, accept, canDrop, result, json); + + all.Add(model); + + if (depth < 3) + { + var children = Enumerable.Range(0, random.Next(0, 3)).Select(_ => RandomTarget(random, model, depth + 1, all)).ToArray(); + + if (children.Length > 0) json["children"] = new JsonArray([.. children.Select(child => (JsonNode)child.Json)]); + } + + return model; + } + + // ---- The guide against the components and the README --------------------------- + + /// + /// The README's component reference tables, row by row, against the components themselves: every + /// parameter a table names exists with that type and that default, and every parameter a component + /// has is in its table. A default changed in code and not in the README fails here. + /// + [TestMethod] + public void The_readme_component_reference_agrees_with_the_components() + { + var tables = BdndSourceCatalog.GuideSections.Where(section => section.Parent == "Component reference").ToArray(); + + Assert.IsGreaterThanOrEqualTo(5, tables.Length, "The README's component reference is gone; this test has nothing left to compare."); + + foreach (var table in tables) + { + var details = BdndApiCatalog.GetTypeDetails(table.Heading); + + Assert.IsNotNull(details, $"The README documents '{table.Heading}', which is not a public type."); + + var rows = ReferenceRowRegex().Matches(BdndSourceCatalog.GetGuideSection(table.Key)!).ToArray(); + var parameters = details.Members.Where(member => member.Kind == "Parameter").ToDictionary(member => member.Name, StringComparer.Ordinal); + + foreach (var row in rows) + { + var name = row.Groups["name"].Value; + var context = $"{table.Heading}.{name}"; + + Assert.IsTrue(parameters.TryGetValue(name, out var parameter), $"The README documents {context}, which is not a parameter."); + Assert.AreEqual(Unwrap(row.Groups["type"].Value).Replace("?", string.Empty, StringComparison.Ordinal), parameter.Type!.Replace("?", string.Empty, StringComparison.Ordinal), $"{context}: type"); + + var documented = row.Groups["default"].Value.Trim(); + + if (documented == "(required)") Assert.IsTrue(parameter.Required, $"{context} is documented as required."); + else if (documented.Length > 0 && documented != "`null`") Assert.AreEqual(Unwrap(documented), parameter.Default, $"{context}: default"); + } + + // AdditionalAttributes is the "(any other attribute)" row. + var undocumented = parameters.Keys.Where(name => name != "AdditionalAttributes" && rows.Any(row => row.Groups["name"].Value == name) is false).ToArray(); + + Assert.IsEmpty(undocumented, $"The README's table for {table.Heading} leaves out: {string.Join(", ", undocumented)}."); + } + + static string Unwrap(string cell) => cell.Trim().Trim('`'); + } + + [TestMethod] + public void The_guide_sections_cover_every_heading_of_the_readme() + { + var readme = BdndSourceCatalog.Readme.Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.IsFalse(string.IsNullOrEmpty(readme), "The README is not embedded."); + + var fenced = false; + var headings = new List(); + + foreach (var line in readme.Split('\n')) + { + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) { fenced = fenced is false; continue; } + if (fenced is false && HeadingRegex().Match(line) is { Success: true } match) headings.Add(WebUtility.HtmlDecode(match.Groups["text"].Value.Trim())); + } + + CollectionAssert.AreEqual(headings, BdndSourceCatalog.GuideSections.Select(section => section.Heading).ToArray()); + Assert.HasCount(BdndSourceCatalog.GuideSections.Length, BdndSourceCatalog.GuideSections.Select(section => section.Key).Distinct(StringComparer.Ordinal), "Every section needs a key of its own."); + } + + [TestMethod] + public void Every_demo_page_and_library_component_is_embedded() + { + var pages = BdndSourceCatalog.SourceFiles.Where(file => file.Kind == "Page").ToArray(); + + // The demo's navigation names every page; each of them has to be readable here, title and route included. + var nav = BdndSourceCatalog.GetSourceFile("Demo/Client/Layout/NavMenu.razor")!; + var linked = NavLinkRegex().Matches(nav).Select(match => $"/{match.Groups["href"].Value}").Where(route => route != "/mcp-server").ToArray(); + + Assert.IsNotEmpty(linked); + foreach (var route in linked) Assert.IsTrue(pages.Any(page => page.Route == route && page.Title is not null), $"The navigation links {route}, and no embedded page with a title declares it."); + + foreach (var component in new[] { "BdndDraggable.cs", "BdndDropTarget.cs", "BdndProvider.razor", "BdndDragLayer.razor", "BdndDragHandle.razor" }) + { + Assert.IsNotNull(BdndSourceCatalog.GetSourceFile($"Library/Components/{component}"), component); + } + + Assert.Contains("registerDropTarget", BdndSourceCatalog.GetSourceFile(BdndSourceCatalog.ScriptPath)!); + } + + // ---- The HTTP mirror ------------------------------------------------------- + + [TestMethod] + public async Task Every_tool_is_reachable_as_a_plain_get_with_the_same_answer() + { + using var http = McpTestHost.CreateHttpClient(); + + (string Tool, string Query, Dictionary? Arguments)[] calls = + [ + ("SearchBdnd", "?query=sortable%20list&limit=3", new() { ["query"] = "sortable list", ["limit"] = 3 }), + ("GetBdndSetupGuide", "?renderMode=server", new() { ["renderMode"] = "server" }), + ("GetBdndGuideSection", "?heading=Drop%20results", new() { ["heading"] = "Drop results" }), + ("GetBdndApi", "?typeName=BdndDraggable", new() { ["typeName"] = "BdndDraggable" }), + ("InspectBdndDrop", "", null), + ("GetBdndSourceFile", "?path=Demo/Client/Pages/Sortable/SortableCard.razor", new() { ["path"] = "Demo/Client/Pages/Sortable/SortableCard.razor" }), + ]; + + CollectionAssert.AreEquivalent(McpSurfaceTests.ExpectedTools, calls.Select(call => call.Tool).ToArray(), + "The HTTP mirror is being exercised for a different set of tools than the server publishes."); + + foreach (var (tool, query, arguments) in calls) + { + using var response = await http.GetAsync($"/api/mcp/{tool}{query}"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, tool); + + var body = await response.Content.ReadAsStringAsync(); + + if (tool is "SearchBdnd" or "InspectBdndDrop") + { + Assert.IsNotNull(JsonNode.Parse(body) as JsonObject, tool); + continue; + } + + Assert.AreEqual(await McpCall.TextAsync(tool, arguments), body, tool); + } + } + + [TestMethod] + public async Task The_drop_lab_runs_over_plain_http_too() + { + using var http = McpTestHost.CreateHttpClient(); + + using var response = await http.GetAsync($"/api/mcp/InspectBdndDrop?json={Uri.EscapeDataString(BdndDropLab.Sample)}"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + var report = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + + Assert.IsTrue((bool)report["valid"]!); + Assert.AreEqual("board", (string?)report["end"]!["dropResult"]); + Assert.IsNull(report["sample"], "A member with nothing in it is left out, as the MCP transport leaves it out."); + } + + [GeneratedRegex(@"^\| `(?\w+)` \|(?[^|]*)\|(?[^|]*)\|", RegexOptions.Multiline)] + private static partial Regex ReferenceRowRegex(); + + [GeneratedRegex(@"^#{2,4} (?.+)$")] + private static partial Regex HeadingRegex(); + + [GeneratedRegex(@"]*\bhref=""(?[^""]+)""")] + private static partial Regex NavLinkRegex(); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpResourcePromptTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpResourcePromptTests.cs new file mode 100644 index 00000000000..c9a66280812 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpResourcePromptTests.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Bit.Bdnd.Demo.Server.Services; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// The other two halves of the protocol - resources and prompts - plus the completions that let a +/// client fill in their placeholders and arguments. +/// +[TestClass] +public partial class McpResourcePromptTests +{ + // ---- Resources --------------------------------------------------------- + + [TestMethod] + public async Task The_fixed_resources_and_the_guide_sections_are_listed() + { + var uris = (await McpTestHost.Client.ListResourcesAsync()).Select(resource => resource.Uri).ToArray(); + + CollectionAssert.IsSubsetOf(new[] { "bdnd://guide", "bdnd://api", "bdnd://sample-scenario" }, uris); + CollectionAssert.Contains(uris, "bdnd://guide/Getting%20started"); + CollectionAssert.Contains(uris, "bdnd://guide/Coming%20from%20React%20DnD"); + + // Only the top-level sections are listed; the rest stay behind the template. + CollectionAssert.DoesNotContain(uris, "bdnd://guide/Install"); + } + + [TestMethod] + public async Task The_templates_are_published() + { + var templates = (await McpTestHost.Client.ListResourceTemplatesAsync()).Select(template => template.UriTemplate).ToArray(); + + CollectionAssert.IsSubsetOf(new[] { "bdnd://guide/{heading}", "bdnd://api/{typeName}", "bdnd://source/{path}" }, templates); + } + + [TestMethod] + public async Task Every_listed_resource_reads_back_as_text() + { + foreach (var resource in await McpTestHost.Client.ListResourcesAsync()) + { + var text = await ReadAsync(resource.Uri); + + Assert.IsFalse(string.IsNullOrWhiteSpace(text), resource.Uri); + Assert.DoesNotContain("has no section called", text, resource.Uri); + } + } + + [TestMethod] + public async Task The_templates_resolve_the_same_keys_the_tools_do() + { + Assert.AreEqual(BdndApiCatalog.RenderType("BdndDropTarget"), await ReadAsync("bdnd://api/BdndDropTarget")); + Assert.AreEqual(BdndApiCatalog.RenderType("BdndDraggable"), await ReadAsync("bdnd://api/BdndDraggable%3CTItem%3E")); + Assert.AreEqual(BdndSourceCatalog.GetSourceFile("Demo/Client/Pages/Sortable/SortableCard.razor"), await ReadAsync("bdnd://source/Demo%2FClient%2FPages%2FSortable%2FSortableCard.razor")); + Assert.StartsWith("### Drop results", await ReadAsync("bdnd://guide/Drop%20results")); + Assert.AreEqual(BdndApiCatalog.RenderIndex(), await ReadAsync("bdnd://api")); + Assert.AreEqual(BdndSourceCatalog.Readme, await ReadAsync("bdnd://guide")); + } + + [TestMethod] + public async Task The_sample_scenario_resource_is_the_one_the_lab_hands_out() + { + var sample = await ReadAsync("bdnd://sample-scenario"); + + Assert.AreEqual(BdndDropLab.Sample, sample); + Assert.AreEqual(sample, (await McpDropLabTests.InspectAsync(null)).Sample); + + using var document = JsonDocument.Parse(sample); + + Assert.AreEqual("card", document.RootElement.GetProperty("draggable").GetProperty("type").GetString()); + Assert.IsGreaterThan(0, document.RootElement.GetProperty("targets").GetArrayLength()); + } + + // ---- Prompts ----------------------------------------------------------- + + private static readonly (string Name, Dictionary? Arguments)[] _prompts = + [ + ("add-bdnd-to-app", new() { ["renderMode"] = "wasm" }), + ("implement-bdnd-feature", new() { ["feature"] = "a sortable list reordered by dragging" }), + ("debug-bdnd-drag", new() { ["symptom"] = "the item drags but the drop target never lights up" }), + ]; + + [TestMethod] + public async Task Every_prompt_is_published() + { + var names = (await McpTestHost.Client.ListPromptsAsync()).Select(prompt => prompt.Name).ToArray(); + + CollectionAssert.AreEquivalent(_prompts.Select(prompt => prompt.Name).ToArray(), names); + } + + [TestMethod] + public async Task Every_prompt_carries_its_argument_and_names_only_real_tools() + { + foreach (var (name, arguments) in _prompts) + { + var text = await PromptTextAsync(name, arguments); + + foreach (var value in arguments!.Values) Assert.Contains((string)value!, text, name); + + var mentioned = ToolNames.MentionedIn(text).ToArray(); + Assert.IsNotEmpty(mentioned, name); + + foreach (var tool in mentioned) CollectionAssert.Contains(McpSurfaceTests.ExpectedTools, tool, $"'{name}' mentions '{tool}', which is not a tool."); + } + } + + [TestMethod] + public async Task The_prompts_name_only_types_the_library_has() + { + // A prompt that tells an agent to use a type the library does not have sends it straight into + // the mistake the prompt exists to prevent. + var types = BdndApiCatalog.Types.Select(type => SimpleName(type.Name)).ToHashSet(StringComparer.Ordinal); + + foreach (var (name, arguments) in _prompts) + { + var text = await PromptTextAsync(name, arguments); + + foreach (var type in BdndTypeRegex().Matches(text).Select(match => match.Groups["type"].Value)) + { + Assert.Contains(type, types, $"'{name}' names '{type}', which is not a public Bit.Bdnd type."); + } + } + } + + [TestMethod] + public async Task The_prompts_name_only_members_the_components_have() + { + var members = new[] { "BdndDraggable", "BdndDropTarget", "BdndDropEventArgs", "BdndDragEndContext", "BdndDropState", "BdndDragState", "BdndInterop", "BitBdnd" } + .SelectMany(type => BdndApiCatalog.GetTypeDetails(type)!.Members.Select(member => member.Name)) + .ToHashSet(StringComparer.Ordinal); + + string[] named = ["Type", "Accept", "AcceptTypes", "OnDrop", "OnHover", "OnNativeDrop", "CanDropPredicate", "OnEnd", "DidDrop", "DropResult", + "IsDragging", "IsOver", "IsOverShallow", "CanDrop", "UseCustomDragLayer", "UseHandle", "DropEffect", "CanDrag", "AddBitBdndServices"]; + + var text = string.Join('\n', await Task.WhenAll(_prompts.Select(prompt => PromptTextAsync(prompt.Name, prompt.Arguments)))); + + foreach (var member in named) + { + Assert.Contains(member, members, $"No component of the library has a member '{member}'."); + Assert.Contains($"`{member}", text, $"The prompts no longer mention '{member}'; drop it from this list."); + } + } + + [TestMethod] + public async Task The_setup_prompt_says_what_the_library_needs_and_what_it_does_not() + { + var text = await PromptTextAsync("add-bdnd-to-app", new() { ["renderMode"] = "auto" }); + + Assert.Contains("builder.Services.AddBitBdndServices();", text); + Assert.Contains("BOTH the server's and the client's", text); + Assert.Contains("Do NOT link a stylesheet", text); + Assert.Contains("", text); + Assert.Contains("interactively rendered page", text); + } + + [TestMethod] + public async Task An_unknown_prompt_is_an_error() + { + await Assert.ThrowsAsync(async () => await McpTestHost.Client.GetPromptAsync("write-my-app")); + } + + // ---- Completions ------------------------------------------------------- + + [TestMethod] + public async Task Resource_placeholders_complete_from_the_catalogs() + { + var types = (await McpTestHost.Client.CompleteAsync(new ResourceTemplateReference { Uri = "bdnd://api/{typeName}" }, "typeName", "BdndDrop")).Completion.Values; + var headings = (await McpTestHost.Client.CompleteAsync(new ResourceTemplateReference { Uri = "bdnd://guide/{heading}" }, "heading", "drop")).Completion.Values; + var paths = (await McpTestHost.Client.CompleteAsync(new ResourceTemplateReference { Uri = "bdnd://source/{path}" }, "path", "Sortable")).Completion.Values; + + CollectionAssert.Contains(types.ToArray(), "BdndDropTarget"); + Assert.StartsWith("BdndDrop", types[0], "A value the typed text starts should rank first."); + CollectionAssert.Contains(headings.ToArray(), "Drop targets"); + CollectionAssert.Contains(headings.ToArray(), "Drop results"); + CollectionAssert.Contains(paths.ToArray(), "Demo/Client/Pages/Sortable/SortableCard.razor"); + } + + [TestMethod] + public async Task Prompt_arguments_complete_with_the_phrasings_they_were_written_for() + { + var symptoms = (await McpTestHost.Client.CompleteAsync(new PromptReference { Name = "debug-bdnd-drag" }, "symptom", "")).Completion.Values; + var features = (await McpTestHost.Client.CompleteAsync(new PromptReference { Name = "implement-bdnd-feature" }, "feature", "sortable")).Completion.Values; + var modes = (await McpTestHost.Client.CompleteAsync(new PromptReference { Name = "add-bdnd-to-app" }, "renderMode", "")).Completion.Values; + + Assert.IsNotEmpty(symptoms); + Assert.IsNotEmpty(features); + CollectionAssert.IsSubsetOf(BdndSetupGuide.RenderModes, modes.ToArray()); + } + + private static string SimpleName(string typeName) + { + var generic = typeName.IndexOf('<', StringComparison.Ordinal); + + return generic < 0 ? typeName : typeName[..generic]; + } + + private static async Task PromptTextAsync(string name, Dictionary? arguments) + { + var result = await McpTestHost.Client.GetPromptAsync(name, arguments); + + return string.Join('\n', result.Messages.Select(message => message.Content).OfType().Select(block => block.Text)); + } + + private static async Task ReadAsync(string uri) + { + var result = await McpTestHost.Client.ReadResourceAsync(uri); + + return string.Join('\n', result.Contents.OfType().Select(contents => contents.Text)); + } + + [GeneratedRegex(@"`I?Bdnd[A-Z][A-Za-z]*)")] + private static partial Regex BdndTypeRegex(); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSearchTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSearchTests.cs new file mode 100644 index 00000000000..8ecd4deb41e --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSearchTests.cs @@ -0,0 +1,236 @@ +using System.Text.RegularExpressions; +using Bit.Bdnd.Demo.Server.Dtos; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// The search, and the paths it opens: every hit hands the caller a follow-up call, and that call +/// has to answer with the thing rather than with an apology about a key that no longer exists. +/// +[TestClass] +public partial class McpSearchTests +{ + /// Questions phrased the way a developer asks them, and a title that has to rank among the first hits. + private static readonly (string Query, string ExpectedTitle)[] _questions = + [ + ("sortable list", "Sortable lists"), + ("drop files from desktop", "Files"), + ("custom drag preview", "Custom drag layers"), + ("drag handle", "Drag handles"), + ("nested drop targets", "Nested drop targets"), + ("Blazor Server", "Blazor Server"), + ("drop result", "Drop results"), + ("register services", "Register the service"), + ("coming from react dnd", "Coming from React DnD"), + ("useDrop", "Coming from React DnD"), + ("text links and html", "Text, links and HTML"), + ("chessboard", "Chessboard Tutorial"), + ("copy or move", "Dustbin - Copy or Move"), + ("cancel on drop outside", "Sortable - Cancel on Drop Outside"), + ("highlight only the innermost target", "Nested drop targets"), + ("can drop predicate", "Gating drops with CanDropPredicate"), + ("inspect a drop", "Inspect a Bdnd drop"), + ("monitor offsets", "The monitor"), + ("stylesheet", "Setup checklist"), + ("OnDrop", "BdndDropTarget.OnDrop"), + ]; + + [TestMethod] + public async Task Common_questions_find_their_answer_among_the_first_hits() + { + // Every question is asked before anything is asserted, so one run reports every miss at once. + var misses = new List(); + + foreach (var (query, expected) in _questions) + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = query, ["limit"] = 6 }); + + if (result.Hits.Any(hit => hit.Title.Contains(expected, StringComparison.OrdinalIgnoreCase))) continue; + + misses.Add($"'{query}' did not find '{expected}' among: {string.Join(" | ", result.Hits.Select(hit => hit.Title))}"); + } + + Assert.IsEmpty(misses, string.Join(Environment.NewLine, misses)); + } + + [TestMethod] + public async Task The_guide_section_written_to_answer_ranks_first() + { + // Among the first hits is not enough for the questions the guide has a section of that very + // name for: the explanation has to come before the members and pages that mention it. + foreach (var (query, expected) in new[] { ("sortable list", "Sortable lists"), ("custom drag preview", "Custom drag layers"), ("drag handle", "Drag handles"), ("nested drop targets", "Nested drop targets"), ("drop result", "Drop results") }) + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = query, ["limit"] = 3 }); + + Assert.AreEqual(expected, result.Hits[0].Title, query); + Assert.AreEqual("Guide section", result.Hits[0].Kind, query); + } + } + + [TestMethod] + public async Task A_word_inside_another_word_does_not_count() + { + // Every other title of this library says "drag", so "rag" counted inside a word would match nearly everything. + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "rag", ["limit"] = 50 }); + + // A hit's snippet is cut around the occurrence that was counted, so every hit shows "rag" as a word of its own. + foreach (var hit in result.Hits) + { + Assert.IsTrue(WordStartRegex().IsMatch($"{hit.Title} {hit.Snippet}"), $"'{hit.Title}' was ranked for 'rag' without the word in it: {hit.Snippet}"); + } + + Assert.IsLessThan(10, result.Hits.Length, "'rag' should not match every entry that merely contains 'drag'."); + } + + [GeneratedRegex("(?("SearchBdnd", new() { ["query"] = query, ["limit"] = 50 }); + + Assert.IsFalse(result.Hits.Any(hit => hit.Kind == "Guide section" && hit.Title is "MCP server" or "Documentation"), query); + } + } + + [TestMethod] + public async Task A_demo_page_is_found_under_the_heading_it_shows() + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "nesting drop targets", ["limit"] = 12 }); + + var page = result.Hits.Single(hit => hit.Title == "Nesting - Drop Targets"); + + Assert.AreEqual("Demo page", page.Kind); + Assert.AreEqual("/nesting/drop-targets", page.Context); + Assert.AreEqual("GetBdndSourceFile(path: \"Demo/Client/Pages/Nesting/DropTargets.razor\")", page.Tool); + Assert.IsFalse(result.Hits.Any(hit => hit.Title.EndsWith("DropTargets.razor", StringComparison.Ordinal)), "The page should not be listed a second time under its path."); + } + + [TestMethod] + public async Task A_generic_type_is_found_and_read_by_its_plain_name() + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "BdndDropTarget", ["limit"] = 6 }); + + var type = result.Hits.Single(hit => hit.Kind == "API component" && hit.Title == "BdndDropTarget"); + + Assert.AreEqual("GetBdndApi(typeName: \"BdndDropTarget\")", type.Tool); + } + + [TestMethod] + public async Task The_scripts_entry_points_are_not_offered_as_api() + { + // HandleDragEnd and the rest are public only for JS interop; a search must not hand them out as members to call. + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "HandleDragEnd HandleHoverChanged HandleNativeDrop", ["limit"] = 50 }); + + Assert.IsFalse(result.Hits.Any(hit => hit.Kind.StartsWith("API", StringComparison.Ordinal) && hit.Title.Contains(".Handle", StringComparison.Ordinal)), + string.Join(" | ", result.Hits.Select(hit => hit.Title))); + } + + [TestMethod] + public async Task A_query_of_filler_words_is_told_what_to_search_for() + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "how do I do it" }); + + Assert.IsEmpty(result.Hits); + Assert.IsNotNull(result.Message); + Assert.Contains("Search for the thing itself", result.Message); + } + + [TestMethod] + public async Task A_query_that_matches_nothing_falls_back_to_the_indexes() + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "qqqq zzzz" }); + + Assert.IsEmpty(result.Hits); + Assert.Contains("GetBdndApi", result.Message!); + Assert.Contains("BdndDropTarget", result.Message!, "The synonyms a React DnD user reaches for should be named."); + } + + [TestMethod] + public async Task A_near_miss_is_suggested_when_nothing_matches() + { + // Nothing says "handlebar", but it begins with the name of the grip component. + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "handlebar" }); + + Assert.IsEmpty(result.Hits); + Assert.IsNotNull(result.DidYouMean); + Assert.IsTrue(result.DidYouMean.Any(title => title.Contains("Handle", StringComparison.Ordinal)), string.Join(" | ", result.DidYouMean)); + } + + [TestMethod] + public async Task The_library_name_alone_still_finds_something() + { + foreach (var query in new[] { "bdnd", "drag and drop" }) + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = query }); + + Assert.IsNotEmpty(result.Hits, query); + } + } + + [TestMethod] + public async Task The_limit_is_honoured_and_clamped() + { + var three = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "target", ["limit"] = 3 }); + var huge = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = "target", ["limit"] = 5000 }); + + Assert.HasCount(3, three.Hits); + Assert.IsTrue(three.HasMore); + Assert.HasCount(50, huge.Hits); + } + + /// + /// Every follow-up call a hit suggests is made, verbatim, over the protocol. A renamed heading, a + /// moved file or a renamed tool leaves every tool working on its own and this path broken. + /// + [TestMethod] + public async Task Every_follow_up_call_a_hit_suggests_answers_with_the_thing() + { + var queries = _questions.Select(question => question.Query) + .Concat(["draggable", "target", "item", "type", "monitor", "provider", "layer", "native", "setup", "demo page", "source", "offset", "result"]); + + var calls = new HashSet(StringComparer.Ordinal); + + foreach (var query in queries) + { + var result = await McpCall.StructuredAsync("SearchBdnd", new() { ["query"] = query, ["limit"] = 50 }); + + foreach (var hit in result.Hits) calls.Add(hit.Tool); + } + + Assert.IsGreaterThan(60, calls.Count, "The searches above should open many distinct follow-up calls."); + + foreach (var call in calls) + { + var match = FollowUpRegex().Match(call); + Assert.IsTrue(match.Success, $"'{call}' is not a call a caller can make verbatim."); + + var tool = match.Groups["tool"].Value; + CollectionAssert.Contains(McpSurfaceTests.ExpectedTools, tool, $"'{call}' names a tool that does not exist."); + + var arguments = match.Groups["name"].Success + ? new Dictionary { [match.Groups["name"].Value] = match.Groups["value"].Value } + : null; + + if (tool == "InspectBdndDrop") + { + var report = await McpDropLabTests.InspectAsync(null); + Assert.IsNotNull(report.Sample, call); + continue; + } + + var text = await McpCall.TextAsync(tool, arguments); + + Assert.DoesNotContain("has no section called", text, call); + Assert.DoesNotContain("has no public type called", text, call); + Assert.DoesNotContain("is not a known render mode", text, call); + Assert.DoesNotContain("No source file at", text, call); + } + } + + [GeneratedRegex("""^(?\w+)\((?:(?\w+): "(?[^"]*)")?\)$""")] + private static partial Regex FollowUpRegex(); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSurfaceTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSurfaceTests.cs new file mode 100644 index 00000000000..a8c9b66a68f --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpSurfaceTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json; +using Bit.Bdnd.Demo.Server.Services; +using ModelContextProtocol.Client; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// The server as a client sees it before calling anything: the handshake, the published tools, the +/// annotations that decide whether a client may run a tool without asking a person, and the schemas +/// it validates arguments and answers against. None of this is visible from the C# side - an +/// attribute left off compiles perfectly and only goes missing on the wire. +/// +[TestClass] +public class McpSurfaceTests +{ + /// + /// The published set, spelled out. A tool added without being added here fails on purpose: a new + /// tool needs its own coverage, and a renamed one breaks every client that had learned the old name - + /// including bitplatform.dev/mcp, which proxies these names as they are, and the demo's own MCP page, + /// which lists them. + /// + internal static readonly string[] ExpectedTools = + [ + "GetBdndApi", + "GetBdndGuideSection", + "GetBdndSetupGuide", + "GetBdndSourceFile", + "InspectBdndDrop", + "SearchBdnd", + ]; + + /// The tools that answer with a DTO, and therefore have to publish an output schema. + private static readonly string[] _structuredTools = ["InspectBdndDrop", "SearchBdnd"]; + + /// The reference tools, and the key each one takes - optional, since leaving it out answers with the index. + private static readonly (string Tool, string Argument)[] _keyedTools = + [ + ("GetBdndApi", "typeName"), + ("GetBdndGuideSection", "heading"), + ("GetBdndSourceFile", "path"), + ("InspectBdndDrop", "json"), + ]; + + private static IList _tools = []; + + [ClassInitialize] + public static async Task ListToolsAsync(TestContext context) + { + _tools = await McpTestHost.Client.ListToolsAsync(cancellationToken: context.CancellationToken); + } + + [TestMethod] + public void The_server_introduces_itself_as_the_library_at_its_own_version() + { + var info = McpTestHost.Client.ServerInfo; + + Assert.AreEqual("bit-bdnd", info.Name); + Assert.AreEqual("Bit.Bdnd", info.Title); + Assert.AreEqual(BdndServerInstructions.BdndVersion, info.Version); + Assert.AreEqual("https://github.com/bitfoundation/bitplatform/tree/develop/src/Bdnd", info.WebsiteUrl); + Assert.AreNotEqual("unknown", info.Version); + Assert.DoesNotContain("+", info.Version, "The build metadata should have been stripped from the version."); + } + + [TestMethod] + public void The_version_is_the_library_package_version() + { + // The package version is stamped into the assembly by Bit.Build.props; the MCP version must be + // that, not the demo host's, since the answers are about the library. + var informational = typeof(BdndProvider).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .Single().InformationalVersion; + + Assert.StartsWith(BdndServerInstructions.BdndVersion, informational); + } + + [TestMethod] + public void The_handshake_advertises_tools_resources_prompts_and_completions() + { + var capabilities = McpTestHost.Client.ServerCapabilities; + + Assert.IsNotNull(capabilities.Tools); + Assert.IsNotNull(capabilities.Resources); + Assert.IsNotNull(capabilities.Prompts); + Assert.IsNotNull(capabilities.Completions); + } + + [TestMethod] + public void The_instructions_name_the_version_and_only_tools_that_exist() + { + var instructions = McpTestHost.Client.ServerInstructions; + + Assert.AreEqual(BdndServerInstructions.Text, instructions); + Assert.Contains(BdndServerInstructions.BdndVersion, instructions!); + + var mentioned = ToolNames.MentionedIn(instructions!).ToArray(); + + Assert.IsNotEmpty(mentioned); + foreach (var tool in mentioned) CollectionAssert.Contains(ExpectedTools, tool, $"The instructions mention '{tool}', which is not a tool."); + } + + [TestMethod] + public void The_instructions_carry_the_rules_that_fail_silently() + { + // The few things an agent gets wrong about this library without a compiler error to stop it - + // the instructions are the one text it reads whether or not it calls a tool. + var instructions = BdndServerInstructions.Text; + + Assert.Contains("AddBitBdndServices()", instructions); + Assert.Contains("both Program.cs files", instructions); + Assert.Contains("no stylesheet", instructions); + Assert.Contains("`BdndProvider`", instructions); + Assert.Contains("interactively", instructions); + Assert.Contains("`Accept`/`AcceptTypes`", instructions); + Assert.Contains("innermost first", instructions); + Assert.Contains("`OnNativeDrop`", instructions); + } + + [TestMethod] + public void Every_expected_tool_is_published_and_nothing_else_is() + { + CollectionAssert.AreEquivalent(ExpectedTools, _tools.Select(tool => tool.Name).ToArray()); + } + + [TestMethod] + public void Every_tool_name_carries_the_library_name() + { + // bitplatform.dev/mcp merges the tools of every bit library into one list, where a name is the + // only address a client has and the first server to claim one wins. "Bdnd" in every name is what + // keeps these from colliding with SearchBlow, GetBitBlazorUIComponent and the rest. + foreach (var tool in _tools) Assert.Contains("Bdnd", tool.Name, $"'{tool.Name}' would be ambiguous next to the other libraries' tools."); + } + + [TestMethod] + public void Every_tool_carries_a_title_and_a_description_worth_reading() + { + foreach (var tool in _tools) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(tool.Title), $"'{tool.Name}' has no title."); + Assert.IsGreaterThan(80, tool.Description.Length, $"'{tool.Name}' has a description too short to choose it by."); + } + } + + [TestMethod] + public void Every_tool_is_read_only_and_closed_world() + { + foreach (var tool in _tools) + { + var annotations = tool.ProtocolTool.Annotations; + + Assert.IsNotNull(annotations, $"'{tool.Name}' has no annotations."); + Assert.AreEqual(true, annotations.ReadOnlyHint, $"'{tool.Name}' is not marked read-only; a client will stop and ask before running it."); + Assert.AreEqual(false, annotations.OpenWorldHint, $"'{tool.Name}' is not marked closed-world."); + } + } + + [TestMethod] + public void Only_the_machine_actionable_tools_publish_an_output_schema() + { + foreach (var tool in _tools) + { + var structured = _structuredTools.Contains(tool.Name); + + Assert.AreEqual(structured, tool.ReturnJsonSchema is not null, + structured ? $"'{tool.Name}' answers with a DTO but publishes no output schema." : $"'{tool.Name}' answers in prose but publishes an output schema."); + } + } + + [TestMethod] + public void The_key_of_every_reference_tool_is_optional() + { + foreach (var (name, argument) in _keyedTools) + { + var tool = _tools.Single(tool => tool.Name == name); + + Assert.IsTrue(tool.JsonSchema.GetProperty("properties").TryGetProperty(argument, out _), $"'{name}' does not take '{argument}'."); + + if (tool.JsonSchema.TryGetProperty("required", out var required)) + { + Assert.IsFalse(required.EnumerateArray().Any(value => value.GetString() == argument), + $"'{name}' requires '{argument}', so it can no longer answer with the index."); + } + } + } + + [TestMethod] + public void The_render_mode_is_a_closed_set_in_the_schema() + { + var setup = _tools.Single(tool => tool.Name == "GetBdndSetupGuide"); + + string?[] modes = [.. setup.JsonSchema.GetProperty("properties").GetProperty("renderMode").GetProperty("enum").EnumerateArray().Select(value => value.GetString())]; + + CollectionAssert.AreEquivalent(BdndSetupGuide.RenderModes, modes); + } + + [TestMethod] + public void The_drop_report_schema_names_what_a_page_renders_from() + { + // The output schema is what a client validates the report against, and what a model reads to + // know what the report holds - so the collected state's own names have to be in it. + var schema = JsonSerializer.Serialize(_tools.Single(tool => tool.Name == "InspectBdndDrop").ReturnJsonSchema); + + foreach (var member in new[] { "canDrop", "isOver", "isOverShallow", "dropOrder", "didDrop", "dropResult", "notes" }) + { + Assert.Contains($"\"{member}\"", schema, member); + } + } + + [TestMethod] + public void The_tool_list_stays_cheap_to_send_with_every_request() + { + // Every tool's name, description and schemas ride along with every request an agent makes. + var size = _tools.Sum(tool => tool.Name.Length + tool.Description.Length + + JsonSerializer.Serialize(tool.JsonSchema).Length + + (tool.ReturnJsonSchema is null ? 0 : JsonSerializer.Serialize(tool.ReturnJsonSchema).Length)); + + Assert.IsLessThan(20_000, size, $"The tool list costs {size} characters per request."); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpTestHost.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpTestHost.cs new file mode 100644 index 00000000000..725348320a1 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpTestHost.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using ModelContextProtocol.Client; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// The demo server, booted once for the whole test run, and one MCP client connected to it. +/// +/// The app is started from its own Program.cs rather than from a re-registration of the MCP +/// pieces, so what the tests exercise is the wiring that ships: the same AddMcpServer +/// options, the same MapMcp("/mcp"), the same DI container the tools resolve out of. And the +/// tests talk to it through the protocol - initialize, tools/list, tools/call - instead of calling +/// the tool methods, which is the only way to catch a tool that is written correctly but never +/// reaches the wire: a missing attribute, a schema a client rejects, a return value that does not +/// serialize. +/// +/// +[TestClass] +public static class McpTestHost +{ + private static WebApplicationFactory? _factory; + private static McpClient? _client; + + /// The running app's service provider, for the services a tool resolves per request. + public static IServiceProvider Services => (_factory ?? throw NotStarted()).Services; + + /// The connected MCP client. One session serves every test - the server is read-only. + public static McpClient Client => _client ?? throw NotStarted(); + + /// A plain HTTP client against the same app, for the /api/mcp/... endpoints that mirror the tools. + public static HttpClient CreateHttpClient() => (_factory ?? throw NotStarted()).CreateClient(); + + [AssemblyInitialize] + public static async Task InitializeAsync(TestContext context) + { + _factory = new WebApplicationFactory(); + + // The handshake happens here: a failure to connect fails the whole run loudly, which is the + // right report for "the MCP server does not come up" - far better than 200 identical + // per-test failures. + _client = await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost/mcp"), + // Pinned rather than auto-detected: the app maps the streamable HTTP transport, and a + // silent fallback to the deprecated SSE one would hide exactly that going missing. + TransportMode = HttpTransportMode.StreamableHttp, + // Nothing here is server-initiated, and the standalone stream would otherwise hold a + // request open for the lifetime of the test run. + EnableStandaloneGetStream = false, + Name = "bit-bdnd-tests" + }, _factory.CreateClient(), loggerFactory: null, ownsHttpClient: true), cancellationToken: context.CancellationToken); + } + + [AssemblyCleanup] + public static async Task CleanupAsync() + { + if (_client is not null) await _client.DisposeAsync(); + if (_factory is not null) await _factory.DisposeAsync(); + } + + private static InvalidOperationException NotStarted() => new("The MCP test host has not been started yet."); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpToolTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpToolTests.cs new file mode 100644 index 00000000000..d2bd3490178 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpToolTests.cs @@ -0,0 +1,306 @@ +using Bit.Bdnd.Demo.Server.Services; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// Every reference tool's answers over the protocol: the thing a key names, the index a missing key +/// answers with, and the guidance a wrong key gets instead of an error. +/// +[TestClass] +public class McpToolTests +{ + // ---- Guide ------------------------------------------------------------- + + [TestMethod] + public async Task The_guide_index_lists_every_top_level_section() + { + var index = await McpCall.TextAsync("GetBdndGuideSection"); + + foreach (var heading in new[] { "Getting started", "The provider", "Draggables", "Drop targets", "Nesting", "Custom drag layers", "Drag handles", "Sortable lists", "Native drags", "The monitor", "Hosting models", "Troubleshooting", "Coming from React DnD", "Component reference" }) + { + Assert.Contains($"- {heading} (", index); + } + } + + [TestMethod] + public async Task A_guide_section_is_found_regardless_of_case_punctuation_number_and_little_words() + { + var section = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "coming REACT dnd" }); + var singular = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "drop result" }); + + Assert.StartsWith("## Coming from React DnD", section); + Assert.Contains("useDrag", section); + Assert.StartsWith("### Drop results", singular); + } + + [TestMethod] + public async Task A_section_includes_its_sub_sections_and_stops_at_the_next_sibling() + { + var section = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "Getting started" }); + + Assert.Contains("### Install", section); + Assert.Contains("### Register the service", section); + Assert.Contains("### A first drag and drop", section); + Assert.DoesNotContain("## The provider", section); + } + + [TestMethod] + public async Task A_sub_section_is_reached_on_its_own() + { + var section = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "Nested drop targets" }); + + Assert.StartsWith("### Nested drop targets", section); + Assert.Contains("IsOverShallow", section); + Assert.DoesNotContain("## Custom drag layers", section); + } + + [TestMethod] + public async Task A_heading_the_guide_uses_twice_is_reached_through_its_key() + { + // "Collected state" is a sub-section of both Draggables and Drop targets. + var keyed = BdndSourceCatalog.GuideSections.Where(section => section.Key != section.Heading).ToArray(); + + Assert.IsNotEmpty(keyed, "The README no longer repeats a heading; this test has nothing left to check."); + + foreach (var section in keyed) + { + var text = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = section.Key }); + + Assert.StartsWith($"{new string('#', section.Level)} {section.Heading}", text, section.Key); + } + + var dropTargets = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "Drop targets > Collected state" }); + + Assert.Contains("IsOverShallow", dropTargets); + } + + [TestMethod] + public async Task A_heading_with_an_entity_in_the_readme_is_read_as_it_renders() + { + // The README writes "BdndDraggable<TItem>", which Markdown shows as BdndDraggable. + var section = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "BdndDraggable" }); + + Assert.Contains("| `Type` |", section); + } + + [TestMethod] + public async Task An_unknown_heading_suggests_the_close_ones() + { + var answer = await McpCall.TextAsync("GetBdndGuideSection", new() { ["heading"] = "drag" }); + + Assert.Contains("Did you mean", answer); + Assert.Contains("Drag handles", answer); + Assert.Contains("Custom drag layers", answer); + } + + // ---- API --------------------------------------------------------------- + + [TestMethod] + public async Task The_api_index_lists_the_public_types_and_not_the_internal_ones() + { + var index = await McpCall.TextAsync("GetBdndApi"); + + foreach (var type in new[] { "BdndProvider", "BdndDraggable", "BdndDropTarget", "BdndDragLayer", "BdndDragHandle", "BdndDragDropManager", "IBdndMonitor", "BdndDropState", "BdndDragEndContext", "BdndNativeTypes", "BdndInterop", "BitBdnd" }) + { + Assert.Contains($"**{type}**", index); + } + + // Internal machinery and the compiler's leftovers stay out of the reference. + Assert.DoesNotContain("BdndDropTargetEntry", index); + Assert.DoesNotContain("BdndDropTargetScope", index); + Assert.DoesNotContain("BdndJsRegistration", index); + Assert.DoesNotContain("BdndDragSourceOptions", index); + Assert.DoesNotContain("_Imports", index); + } + + [TestMethod] + public async Task A_generic_component_reference_lists_its_parameters_with_their_defaults() + { + var reference = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDraggable" }); + + Assert.StartsWith("# BdndDraggable (Component)", reference); + Assert.Contains("## Parameter", reference); + Assert.Contains("**CanDrag** : `bool` = `true`", reference); + Assert.Contains("**DropEffect** : `string` = `\"move\"`", reference); + Assert.Contains("**Tag** : `string` = `\"div\"`", reference); + Assert.Contains("**UseHandle** : `bool` = `false`", reference); + Assert.Contains("**Type** : `string` **(required)**", reference); + Assert.Contains("**OnEnd** : `EventCallback>` - ", reference); + } + + [TestMethod] + public async Task The_entry_points_of_the_script_are_not_part_of_the_reference() + { + // Public for JS interop only, and [EditorBrowsable(Never)] for that reason. + var draggable = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDraggable" }); + var target = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDropTarget" }); + + Assert.DoesNotContain("HandleDragEnd", draggable); + Assert.DoesNotContain("HandleDragMove", draggable); + Assert.DoesNotContain("HandleHoverChanged", target); + Assert.DoesNotContain("HandleNativeDrop", target); + Assert.Contains("**CanDropPredicate** : `Func`", target); + } + + [TestMethod] + public async Task A_cref_to_a_member_of_a_generic_type_keeps_the_member() + { + // The documentation says , compiled as + // "P:Bit.Bdnd.BdndDraggable`1.ItemFactory" - which has to read "BdndDraggable.ItemFactory". + var reference = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDraggable" }); + + Assert.Contains("Ignored when BdndDraggable.ItemFactory is supplied.", reference); + } + + [TestMethod] + public async Task The_manager_is_its_own_monitor_and_a_generic_overload_is_kept() + { + var manager = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndDragDropManager" }); + var monitor = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "IBdndMonitor" }); + + Assert.Contains("**Monitor** : `IBdndMonitor` = `this`", manager); + Assert.Contains("- **GetItem**() : `object`", monitor); + Assert.Contains("- **GetItem**() : `T`", monitor); + Assert.Contains("- **IsOver**(string handlerId, bool shallow = false) : `bool`", monitor); + } + + [TestMethod] + public async Task The_native_types_and_the_service_registration_are_documented() + { + var native = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BdndNativeTypes" }); + var services = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "BitBdnd" }); + + Assert.Contains("**File** : `string` = `\"__NATIVE_FILE__\"`", native); + Assert.Contains("**Url** : `string` = `\"__NATIVE_URL__\"`", native); + Assert.Contains("- **AddBitBdndServices**(this IServiceCollection services) : `IServiceCollection`", services); + Assert.Contains("builder.Services.AddBitBdndServices();", services); + } + + [TestMethod] + public async Task An_unprefixed_type_name_points_at_the_prefixed_one() + { + var answer = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "DropTarget" }); + + Assert.Contains("BdndDropTarget", answer); + } + + [TestMethod] + public async Task A_type_name_with_nothing_like_it_explains_the_prefix() + { + var answer = await McpCall.TextAsync("GetBdndApi", new() { ["typeName"] = "useDrag" }); + + Assert.Contains("prefixed 'Bdnd'", answer); + } + + // ---- Setup guide ------------------------------------------------------- + + [TestMethod] + public async Task Every_render_mode_gets_the_checklist_the_service_and_the_guide_on_its_hosting_model() + { + foreach (var mode in BdndSetupGuide.RenderModes) + { + var guide = await McpCall.TextAsync("GetBdndSetupGuide", new() { ["renderMode"] = mode }); + + Assert.Contains("dotnet add package Bit.Bdnd", guide, mode); + Assert.Contains("@using Bit.Bdnd", guide, mode); + Assert.Contains("AddBitBdndServices()", guide, mode); + Assert.Contains("NO stylesheet", guide, mode); + Assert.Contains("", guide, mode); + Assert.Contains("## From the guide", guide, mode); + Assert.Contains("### Install", guide, mode); + Assert.Contains("### Register the service", guide, mode); + Assert.DoesNotContain("bit-bdnd.css", guide, mode); + } + } + + [TestMethod] + public async Task The_web_app_modes_register_the_service_in_both_projects() + { + foreach (var mode in new[] { "wasm", "auto" }) + { + var guide = await McpCall.TextAsync("GetBdndSetupGuide", new() { ["renderMode"] = mode }); + + Assert.Contains("BOTH Program.cs", guide, mode); + Assert.Contains("### Blazor Web App", guide, mode); + } + } + + [TestMethod] + public async Task The_server_mode_explains_the_round_trip() + { + var guide = await McpCall.TextAsync("GetBdndSetupGuide", new() { ["renderMode"] = "server" }); + + Assert.Contains("SignalR", guide); + Assert.Contains("### Blazor Server", guide, "The guide's own section on Blazor Server should be quoted."); + Assert.Contains("@rendermode InteractiveServer", guide); + Assert.Contains("builder.Services.AddBitBdndServices();", guide); + } + + [TestMethod] + public async Task The_webassembly_mode_quotes_the_demo_files_verbatim() + { + var guide = await McpCall.TextAsync("GetBdndSetupGuide", new() { ["renderMode"] = "wasm" }); + + Assert.Contains(BdndSourceCatalog.GetSourceFile("Demo/Server/Program.cs")!.TrimEnd(), guide); + Assert.Contains(BdndSourceCatalog.GetSourceFile("Demo/Client/Extensions/IServiceCollectionExtensions.cs")!.TrimEnd(), guide); + Assert.Contains("### `Demo/Server/Components/App.razor`", guide); + Assert.Contains("services.AddBitBdndServices();", guide); + } + + [TestMethod] + public async Task An_unknown_render_mode_lists_the_known_ones() + { + // [AllowedValues] puts the values in the schema for a client to honour; a value that gets past + // it anyway is answered with the list rather than with an error. + var answer = await McpCall.TextAsync("GetBdndSetupGuide", new() { ["renderMode"] = "electron" }); + + Assert.Contains("'electron' is not a known render mode", answer); + foreach (var mode in BdndSetupGuide.RenderModes) Assert.Contains(mode, answer); + } + + // ---- Source files ------------------------------------------------------ + + [TestMethod] + public async Task The_source_index_lists_the_pages_and_the_library_but_not_the_site_chrome() + { + var index = await McpCall.TextAsync("GetBdndSourceFile"); + + Assert.Contains("Demo/Client/Pages/Sortable/SortableCard.razor", index); + Assert.Contains("Demo/Client/Pages/Nesting/DropTargets.razor", index); + Assert.Contains("`/sortable/simple`", index); + Assert.Contains("**Sortable - Simple**", index); + Assert.Contains(BdndSourceCatalog.ScriptPath, index); + Assert.Contains("Library/Components/BdndDropTarget.cs", index); + Assert.DoesNotContain("McpServer.razor", index); + Assert.DoesNotContain("NotFound.razor", index); + Assert.DoesNotContain("Error.razor", index); + } + + [TestMethod] + public async Task A_source_file_comes_back_verbatim() + { + var file = await McpCall.TextAsync("GetBdndSourceFile", new() { ["path"] = "Demo/Client/Pages/Sortable/SortableCard.razor" }); + + Assert.Contains(" line.Contains("`Library/Components/BdndProvider.razor`", StringComparison.Ordinal)); + + Assert.Contains("The root of a drag-and-drop context", line); + } + + [TestMethod] + public async Task A_partial_path_suggests_the_full_one() + { + var answer = await McpCall.TextAsync("GetBdndSourceFile", new() { ["path"] = "SortableCard" }); + + Assert.Contains("Demo/Client/Pages/Sortable/SortableCard.razor", answer); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/ToolNames.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/ToolNames.cs new file mode 100644 index 00000000000..734cc11df6c --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/ToolNames.cs @@ -0,0 +1,20 @@ +using System.Text.RegularExpressions; + +namespace Bit.Bdnd.Tests.Mcp; + +/// +/// Finds the tool names inside a piece of the server's own prose - the instructions, the overview, +/// a prompt - so a test can hold that prose to the tools that actually exist. +/// +internal static partial class ToolNames +{ + public static IEnumerable MentionedIn(string text) + { + return ToolNameRegex().Matches(text).Select(match => match.Groups["name"].Value).Distinct(StringComparer.Ordinal); + } + + // Every tool on this server is named after what it does to Bdnd, which is what makes them + // findable in prose without also matching the type names the same prose is full of. + [GeneratedRegex(@"\b(?(Get|Search|Inspect)Bdnd[A-Za-z]*)\b")] + private static partial Regex ToolNameRegex(); +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Bit.Bdnd.Tests.csproj b/src/Bdnd/Tests/Bit.Bdnd.Tests/Bit.Bdnd.Tests.csproj new file mode 100644 index 00000000000..13062eebe4f --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Bit.Bdnd.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + false + true + $(NoWarn);CS1591;BL0005 + true + false + true + ..\..\..\AssemblyOriginatorKeyFile.snk + + + + + + + + + + + + diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/BitBdndTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/BitBdndTests.cs new file mode 100644 index 00000000000..b966e9d7010 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/BitBdndTests.cs @@ -0,0 +1,75 @@ +using Bit.Bdnd.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests; + +[TestClass] +public class BitBdndTests +{ + [TestMethod] + public void AddBitBdndServicesRegistersTheInteropAsScoped() + { + var services = new ServiceCollection(); + + var result = services.AddBitBdndServices(); + + Assert.AreSame(services, result); + var descriptor = Assert.ContainsSingle(services); + Assert.AreEqual(typeof(BdndInterop), descriptor.ServiceType); + Assert.AreEqual(ServiceLifetime.Scoped, descriptor.Lifetime); + } + + [TestMethod] + public void AddBitBdndServicesDoesNotRegisterTheManager() + { + // every BdndProvider creates its own manager + var services = new ServiceCollection().AddBitBdndServices(); + + Assert.IsFalse(services.Any(d => d.ServiceType == typeof(BdndDragDropManager))); + } + + [TestMethod] + public void CallingAddBitBdndServicesTwiceIsHarmless() + { + var services = new ServiceCollection(); + + services.AddBitBdndServices(); + services.AddBitBdndServices(); + + Assert.ContainsSingle(services); + } + + [TestMethod] + public void AnInteropRegisteredByTheAppIsKept() + { + var services = new ServiceCollection(); + services.AddSingleton(sp => new BdndInterop(sp.GetRequiredService())); + + services.AddBitBdndServices(); + + var descriptor = Assert.ContainsSingle(services); + Assert.AreEqual(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + [TestMethod] + public void AddBitBdndServicesRejectsNull() + { + Assert.ThrowsExactly(() => BitBdnd.AddBitBdndServices(null!)); + } + + [TestMethod] + public async Task TheInteropResolvesOncePerScope() + { + var services = new ServiceCollection().AddSingleton(new FakeJSRuntime()).AddBitBdndServices(); + // BdndInterop is IAsyncDisposable only, so the scopes are disposed asynchronously + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + await using var scope1 = provider.CreateAsyncScope(); + await using var scope2 = provider.CreateAsyncScope(); + + var a = scope1.ServiceProvider.GetRequiredService(); + + Assert.AreSame(a, scope1.ServiceProvider.GetRequiredService()); + Assert.AreNotSame(a, scope2.ServiceProvider.GetRequiredService()); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndComponentTestBase.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndComponentTestBase.cs new file mode 100644 index 00000000000..05a0fadb08d --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndComponentTestBase.cs @@ -0,0 +1,158 @@ +using AngleSharp.Dom; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Components; + +/// +/// Renders the components against bUnit's JS interop with bit-bdnd.js and the registrations it hands out set +/// up, so every call a component makes to the script can be inspected, and drives drags the way the script and the +/// browser do: a tagged dragstart, then the [JSInvokable] callbacks and the managed drop events. +/// +public abstract class BdndComponentTestBase : BunitContext +{ + protected const string ModulePath = "./_content/Bit.Bdnd/bit-bdnd.js"; + + protected BdndComponentTestBase() + { + Services.AddBitBdndServices(); + // loose: getDragStartRect answers null (the Blazor Server path) unless a test plans it + JSInterop.Mode = JSRuntimeMode.Loose; + Module = JSInterop.SetupModule(ModulePath); + Sources = Module.SetupModule("registerDragSource", _ => true); + Targets = Module.SetupModule("registerDropTarget", _ => true); + } + + /// The imported bit-bdnd.js module. + protected BunitJSModuleInterop Module { get; } + + /// The objects registerDragSource returns: update, dispose, getDragStartRect and cancelDrag land here. + protected BunitJSModuleInterop Sources { get; } + + /// The objects registerDropTarget returns: update and dispose land here. + protected BunitJSModuleInterop Targets { get; } + + /// The manager handed to the providers renders. + protected BdndDragDropManager Manager { get; } = new(); + + protected IRenderedComponent RenderProvider(Action> content) + => Render(p => + { + p.Add(x => x.Manager, Manager); + content(p); + }); + + /// A component as a fragment, for content rendered conditionally or deep inside other components. + protected static RenderFragment Fragment(params (string Name, object? Value)[] parameters) + where TComponent : IComponent + => builder => + { + builder.OpenComponent(0); + foreach (var (name, value) in parameters) builder.AddComponentParameter(1, name, value); + builder.CloseComponent(); + }; + + // ---- what reached the script ---- + + private protected IReadOnlyList SourceRegistrations => + [.. Module.Invocations["registerDragSource"].Select(i => (BdndDragSourceOptions)i.Arguments[2]!)]; + + private protected IReadOnlyList TargetRegistrations => + [.. Module.Invocations["registerDropTarget"].Select(i => (BdndDropTargetOptions)i.Arguments[2]!)]; + + private protected IReadOnlyList SourceUpdates => + [.. Sources.Invocations["update"].Select(i => (BdndDragSourceOptions)i.Arguments[0]!)]; + + private protected IReadOnlyList TargetUpdates => + [.. Targets.Invocations["update"].Select(i => (BdndDropTargetOptions)i.Arguments[0]!)]; + + /// The registerDragSource call of . + protected JSRuntimeInvocation SourceRegistrationCall(BdndDraggable draggable) + => Module.Invocations["registerDragSource"].Last(i => Wraps(i.Arguments[1], draggable)); + + /// The registerDropTarget call of . + protected JSRuntimeInvocation TargetRegistrationCall(BdndDropTarget target) + => Module.Invocations["registerDropTarget"].Last(i => Wraps(i.Arguments[1], target)); + + /// The handler id registered with the script, which tags its drags. + protected string SourceIdOf(BdndDraggable draggable) + => ((BdndDragSourceOptions)SourceRegistrationCall(draggable).Arguments[2]!).Id; + + private static bool Wraps(object? dotNetRef, TComponent component) where TComponent : class + { + try + { + return dotNetRef is DotNetObjectReference reference && ReferenceEquals(reference.Value, component); + } + catch (ObjectDisposedException) + { + return false; + } + } + + // ---- driving a drag ---- + + /// The DataTransfer type the script tags the drags of a source with. + protected static string SourceType(string sourceId) => "application/x-bit-bdnd-" + sourceId; + + /// + /// The component's host element, in the component's own DOM: events dispatched on it do not bubble to elements + /// of enclosing components. Find nested elements in the root component's DOM to let them bubble. + /// + protected static IElement HostOf(IRenderedComponent component) + => component.Nodes.OfType().First(); + + protected static DragEventArgs PointerAt(double clientX, double clientY, params string[] types) => new() + { + ClientX = clientX, + ClientY = clientY, + DataTransfer = new DataTransfer { Types = types }, + }; + + /// + /// Starts a drag the way the browser does with the script loaded: the script tags the dragstart with the + /// source's type, then Blazor dispatches it to the host element. + /// + protected Task StartDragAsync(IRenderedComponent> draggable, + double clientX = 0, double clientY = 0, double offsetX = 0, double offsetY = 0) + { + var args = PointerAt(clientX, clientY, SourceType(SourceIdOf(draggable.Instance))); + args.OffsetX = offsetX; + args.OffsetY = offsetY; + return HostOf(draggable).DragStartAsync(args); + } + + /// The script reports the pointer entering (or leaving) a drop target. + protected static Task HoverAsync(IRenderedComponent> target, bool isOver = true, params string[] types) + => target.InvokeAsync(() => target.Instance.HandleHoverChanged(isOver, types.Length > 0 ? types : [SourceType("in-app")])); + + /// The browser raises drop on the target's host element. + protected static Task DropAsync(IRenderedComponent> target, double clientX = 0, double clientY = 0) + => DropAsync(HostOf(target), clientX, clientY); + + /// The browser raises drop on ; it bubbles through the enclosing targets. + protected static Task DropAsync(IElement element, double clientX = 0, double clientY = 0) + => element.DropAsync(PointerAt(clientX, clientY, SourceType("in-app"))); + + /// The script reports the end of the drag (the source's dragend). + protected static Task EndDragAsync(IRenderedComponent> draggable) + => draggable.InvokeAsync(() => draggable.Instance.HandleDragEnd()); +} + +/// Records the collected state a component hands its child content on every render. +internal sealed class StateLog +{ + private readonly List _states = []; + + public TState Last => _states[^1]; + + public int Count => _states.Count; + + public RenderFragment Content(Func? inner = null) => state => + { + _states.Add(state); + return inner?.Invoke(state) ?? (_ => { }); + }; +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragHandleTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragHandleTests.cs new file mode 100644 index 00000000000..f0a87f59a86 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragHandleTests.cs @@ -0,0 +1,118 @@ +using AngleSharp.Dom; +using Bunit; + +namespace Bit.Bdnd.Tests.Components; + +[TestClass] +public class BdndDragHandleTests : BdndComponentTestBase +{ + /// A draggable with a handle inside it. + private IRenderedComponent> RenderWithHandle( + Action>>? draggable = null, + Action>? handle = null) + => Render>(p => + { + p.AddCascadingValue(Manager); + p.Add(x => x.Type, "card"); + p.Add(x => x.ChildContent, _ => h => + { + h.AddChildContent("::"); + handle?.Invoke(h); + }); + draggable?.Invoke(p); + }); + + private static IElement HandleOf(IRenderedComponent> cut) => cut.Find("[data-bit-bdnd-handle]"); + + [TestMethod] + public void TheHandleIsTheNativeDraggableOfADraggableUsingHandles() + { + var cut = RenderWithHandle(d => d.Add(x => x.UseHandle, true)); + + var handle = HandleOf(cut); + Assert.AreEqual("true", handle.GetAttribute("draggable")); + Assert.AreEqual("", handle.GetAttribute("data-bit-bdnd-handle")); + Assert.AreEqual("cursor: move;", handle.GetAttribute("style")); + Assert.AreEqual("::", handle.QuerySelector(".grip")!.TextContent); + Assert.AreEqual("false", HostOf(cut).GetAttribute("draggable"), "the host is not the native draggable"); + } + + [TestMethod] + public void TheCursorIsPrependedToTheHandlesStyle() + { + var cut = RenderWithHandle(d => d.Add(x => x.UseHandle, true), h => h.Add(x => x.Style, "color: red")); + + Assert.AreEqual("cursor: move; color: red", HandleOf(cut).GetAttribute("style")); + } + + [TestMethod] + public void OtherAttributesGoOntoTheHandle() + { + var cut = RenderWithHandle(d => d.Add(x => x.UseHandle, true), h => h + .AddUnmatched("class", "grip-area") + .AddUnmatched("title", "Drag to reorder")); + + var handle = HandleOf(cut); + Assert.AreEqual("grip-area", handle.ClassName); + Assert.AreEqual("Drag to reorder", handle.GetAttribute("title")); + } + + [TestMethod] + public void AHandleOfADraggableNotUsingHandlesIsNotDraggable() + { + var cut = RenderWithHandle(); + + Assert.IsFalse(HandleOf(cut).HasAttribute("draggable")); + Assert.AreEqual("cursor: move;", HandleOf(cut).GetAttribute("style"), "the whole item still drags"); + Assert.AreEqual("true", HostOf(cut).GetAttribute("draggable")); + } + + [TestMethod] + public void AHandleOfADraggableThatCannotDragIsInert() + { + var cut = RenderWithHandle(d => d.Add(x => x.UseHandle, true).Add(x => x.CanDrag, false), h => h.Add(x => x.Style, "color: red")); + + Assert.IsFalse(HandleOf(cut).HasAttribute("draggable")); + Assert.AreEqual("color: red", HandleOf(cut).GetAttribute("style")); + } + + [TestMethod] + public void TheHandleFollowsTheDraggablesParameters() + { + var cut = RenderWithHandle(d => d.Add(x => x.UseHandle, true)); + + cut.Render(p => p.Add(x => x.CanDrag, false)); + Assert.IsFalse(HandleOf(cut).HasAttribute("draggable")); + Assert.IsFalse(HandleOf(cut).HasAttribute("style")); + + cut.Render(p => p.Add(x => x.CanDrag, true)); + Assert.AreEqual("true", HandleOf(cut).GetAttribute("draggable")); + } + + [TestMethod] + public void TheDraggableTellsTheScriptToStartDragsOnlyFromHandles() + { + RenderWithHandle(d => d.Add(x => x.UseHandle, true)); + + Assert.IsTrue(Assert.ContainsSingle(SourceRegistrations).UseHandle); + } + + [TestMethod] + public void AHandleOutsideADraggableRendersInert() + { + var cut = Render(p => p.Add(x => x.Style, "color: red").AddChildContent("::")); + + var handle = cut.Find("[data-bit-bdnd-handle]"); + Assert.IsFalse(handle.HasAttribute("draggable")); + Assert.AreEqual("color: red", handle.GetAttribute("style")); + Assert.AreEqual("::", handle.TextContent); + } + + [TestMethod] + public void AHandleWithoutStyleOutsideADraggableHasNoStyle() + { + var cut = Render(); + + Assert.IsFalse(cut.Find("[data-bit-bdnd-handle]").HasAttribute("style")); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLayerTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLayerTests.cs new file mode 100644 index 00000000000..de67502bb81 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLayerTests.cs @@ -0,0 +1,141 @@ +using Bunit; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bdnd.Tests.Components; + +[TestClass] +public class BdndDragLayerTests : BdndComponentTestBase +{ + private IRenderedComponent RenderLayer(StateLog> log) + => RenderProvider(p => p.AddChildContent>(l => l.Add(x => x.ChildContent, log.Content()))); + + private Task BeginDragAsync(IRenderedComponent cut, object item) + => cut.InvokeAsync(() => + { + Manager.BeginDrag("src", item, "card", new(100, 50), new(80, 40)); + return Manager.FlushBeginAsync(); + }); + + [TestMethod] + public void AnIdleLayerReceivesAnEmptyState() + { + var log = new StateLog>(); + + RenderLayer(log); + + var state = log.Last; + Assert.IsFalse(state.IsDragging); + Assert.IsNull(state.ItemType); + Assert.IsNull(state.Item); + Assert.IsNull(state.InitialClientOffset); + Assert.IsNull(state.InitialSourceClientOffset); + Assert.IsNull(state.ClientOffset); + Assert.IsNull(state.DifferenceFromInitialOffset); + Assert.IsNull(state.SourceClientOffset); + Assert.AreSame(Manager, state.Monitor); + } + + [TestMethod] + public async Task TheLayerRendersTheDragWithItsOffsets() + { + var log = new StateLog>(); + var cut = RenderLayer(log); + + await BeginDragAsync(cut, "card-7"); + + var state = log.Last; + Assert.IsTrue(state.IsDragging); + Assert.AreEqual("card", state.ItemType); + Assert.AreEqual("card-7", state.Item); + Assert.AreEqual(new BdndXYCoord(100, 50), state.InitialClientOffset); + Assert.AreEqual(new BdndXYCoord(80, 40), state.InitialSourceClientOffset); + Assert.AreEqual(new BdndXYCoord(100, 50), state.ClientOffset); + Assert.AreEqual(new BdndXYCoord(0, 0), state.DifferenceFromInitialOffset); + Assert.AreEqual(new BdndXYCoord(80, 40), state.SourceClientOffset); + } + + [TestMethod] + public async Task TheLayerReRendersAsThePointerMoves() + { + var log = new StateLog>(); + var cut = RenderLayer(log); + await BeginDragAsync(cut, "card-7"); + var renders = log.Count; + + await cut.InvokeAsync(() => Manager.SetClientOffset(new(130, 20))); + + Assert.AreEqual(renders + 1, log.Count); + Assert.AreEqual(new BdndXYCoord(130, 20), log.Last.ClientOffset); + Assert.AreEqual(new BdndXYCoord(30, -30), log.Last.DifferenceFromInitialOffset); + Assert.AreEqual(new BdndXYCoord(110, 10), log.Last.SourceClientOffset); + } + + [TestMethod] + public async Task TheLayerFollowsAPointerMoveReportedForTheDraggedSource() + { + var log = new StateLog>(); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "card").Add(x => x.Item, "card-7").Add(x => x.UseCustomDragLayer, true)) + .AddChildContent>(l => l.Add(x => x.ChildContent, log.Content()))); + var source = cut.FindComponent>(); + await StartDragAsync(source, 10, 10, 2, 3); + + await source.InvokeAsync(() => source.Instance.HandleDragMove(60, 45)); + + Assert.IsTrue(log.Last.IsDragging); + Assert.AreEqual("card-7", log.Last.Item); + Assert.AreEqual(new BdndXYCoord(58, 42), log.Last.SourceClientOffset); + Assert.IsTrue(Assert.ContainsSingle(SourceRegistrations).UseEmptyPreview); + } + + [TestMethod] + public async Task TheLayerClearsWhenTheDragEnds() + { + var log = new StateLog>(); + var cut = RenderLayer(log); + await BeginDragAsync(cut, "card-7"); + + await cut.InvokeAsync(Manager.EndDrag); + + Assert.IsFalse(log.Last.IsDragging); + Assert.IsNull(log.Last.SourceClientOffset); + } + + [TestMethod] + public async Task AnItemOfAnotherTypeIsNotHandedToTheLayer() + { + var log = new StateLog>(); + var cut = RenderLayer(log); + + await BeginDragAsync(cut, 42); + + Assert.IsTrue(log.Last.IsDragging); + Assert.IsNull(log.Last.Item); + Assert.AreEqual("card", log.Last.ItemType); + } + + [TestMethod] + public async Task ALayerWithoutContentRendersNothing() + { + var cut = RenderProvider(p => p.AddChildContent>()); + + await BeginDragAsync(cut, "card-7"); + + Assert.AreEqual("", cut.Markup.Trim()); + } + + [TestMethod] + public async Task ARemovedLayerStopsListening() + { + var log = new StateLog>(); + var cut = RenderLayer(log); + await BeginDragAsync(cut, "card-7"); + + cut.Render(p => p.AddChildContent("

no layer

")); + var renders = log.Count; + await cut.InvokeAsync(() => Manager.SetClientOffset(new(1, 1))); + await cut.InvokeAsync(Manager.EndDrag); + + Assert.AreEqual(renders, log.Count); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs new file mode 100644 index 00000000000..a09bf5f2839 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs @@ -0,0 +1,619 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Components; + +/// +/// Whole drags through the components: a tagged dragstart, hover reports from the script, the managed drop +/// event and the script's end report, in the order the browser produces them. +/// +[TestClass] +public class BdndDragLifecycleTests : BdndComponentTestBase +{ + private readonly List _log = []; + private readonly StateLog _sourceStates = new(); + private readonly StateLog> _binStates = new(); + + /// A draggable "crate" of type box and a dustbin accepting box, both logging their callbacks. + private IRenderedComponent RenderDustbin( + Action>>? source = null, + Func, Task>? onEnd = null, + Func, Task>? onDrop = null) + => RenderProvider(p => p + .AddChildContent>(d => + { + d.Add(x => x.Type, "box"); + d.Add(x => x.Item, "crate"); + d.Add(x => x.OnBegin, item => _log.Add($"begin:{item}")); + d.Add(x => x.OnEnd, onEnd ?? (e => + { + _log.Add($"end:{e.Item}:{e.DidDrop}:{e.DropResult}"); + return Task.CompletedTask; + })); + d.Add(x => x.ChildContent, _sourceStates.Content()); + source?.Invoke(d); + }) + .AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.OnDrop, onDrop ?? (e => + { + _log.Add($"drop:{e.Item}"); + e.Result = "archived"; + return Task.CompletedTask; + })) + .Add(x => x.ChildContent, _binStates.Content()))); + + private static IRenderedComponent> Source(IRenderedComponent cut) + => cut.FindComponent>(); + + private static IRenderedComponent> Bin(IRenderedComponent cut) + => cut.FindComponent>(); + + // ---- a whole drag ---- + + [TestMethod] + public async Task ADragRunsBeginDropAndEndInOrderAndHandsTheResultToOnEnd() + { + var cut = RenderDustbin(); + + await StartDragAsync(Source(cut), 10, 10); + Assert.IsTrue(_sourceStates.Last.IsDragging); + Assert.IsTrue(_binStates.Last.CanDrop); + Assert.IsFalse(_binStates.Last.IsOver); + + await HoverAsync(Bin(cut)); + Assert.IsTrue(_binStates.Last.IsOver); + Assert.IsTrue(_binStates.Last.IsOverShallow); + + await DropAsync(Bin(cut), 50, 60); + Assert.IsTrue(Manager.DidDrop()); + Assert.AreEqual("archived", Manager.GetDropResult()); + Assert.AreEqual(new BdndXYCoord(50, 60), Manager.GetClientOffset()); + + await EndDragAsync(Source(cut)); + + Assert.AreSequenceEqual(new[] { "begin:crate", "drop:crate", "end:crate:True:archived" }, _log); + Assert.IsFalse(Manager.IsDragging()); + Assert.IsFalse(_sourceStates.Last.IsDragging); + Assert.IsFalse(_binStates.Last.IsOver); + Assert.IsFalse(_binStates.Last.CanDrop); + Assert.IsNull(_binStates.Last.ItemType); + } + + [TestMethod] + public async Task ADragEndingOutsideEveryTargetReportsNoDrop() + { + var cut = RenderDustbin(); + + await StartDragAsync(Source(cut)); + await EndDragAsync(Source(cut)); + + Assert.AreSequenceEqual(new[] { "begin:crate", "end:crate:False:" }, _log); + } + + [TestMethod] + public async Task ASecondDragStartsClean() + { + var cut = RenderDustbin(); + await StartDragAsync(Source(cut)); + await DropAsync(Bin(cut)); + await EndDragAsync(Source(cut)); + + await StartDragAsync(Source(cut)); + Assert.IsFalse(Manager.DidDrop()); + Assert.IsNull(Manager.GetDropResult()); + await EndDragAsync(Source(cut)); + + Assert.AreEqual("end:crate:False:", _log[^1]); + } + + [TestMethod] + public async Task TheEndContextCarriesTheMonitor() + { + BdndDragEndContext? end = null; + var cut = RenderDustbin(onEnd: e => + { + end = e; + return Task.CompletedTask; + }); + + await StartDragAsync(Source(cut)); + await EndDragAsync(Source(cut)); + + Assert.IsNotNull(end); + Assert.AreSame(Manager, end.Monitor); + Assert.AreEqual("crate", end.Item); + } + + // ---- drag start ---- + + [TestMethod] + public async Task DragStartPublishesTheItemAndThePointer() + { + var cut = RenderDustbin(); + + await StartDragAsync(Source(cut), clientX: 100, clientY: 60, offsetX: 10, offsetY: 15); + + Assert.IsTrue(Manager.IsDragging(SourceIdOf(Source(cut).Instance))); + Assert.AreEqual("box", Manager.GetItemType()); + Assert.AreEqual("crate", Manager.GetItem()); + Assert.AreEqual(new BdndXYCoord(100, 60), Manager.GetInitialClientOffset()); + Assert.AreEqual(new BdndXYCoord(90, 45), Manager.GetInitialSourceClientOffset(), "pointer minus its offset in the source"); + } + + [TestMethod] + public async Task TheSourceRectMeasuredDuringDragStartIsUsedAtOnce() + { + // Blazor WebAssembly: the registration is in-process and answers synchronously + Sources.Setup("getDragStartRect", i => i.InvocationMethodName == "Invoke") + .SetResult(new BdndDomRect { Left = 40, Top = 50, Width = 120, Height = 30 }); + var cut = RenderDustbin(); + + await StartDragAsync(Source(cut), clientX: 100, clientY: 60, offsetX: 10, offsetY: 15); + + Assert.AreEqual(new BdndXYCoord(40, 50), Manager.GetInitialSourceClientOffset()); + Assert.ContainsSingle(Sources.Invocations["getDragStartRect"]); + } + + [TestMethod] + public async Task AnEstimatedSourceOffsetIsCorrectedOnceTheRectArrives() + { + // Blazor Server: the rect is fetched after the drag has been published + Sources.Setup("getDragStartRect", i => i.InvocationMethodName == "InvokeAsync") + .SetResult(new BdndDomRect { Left = 40, Top = 50, Width = 120, Height = 30 }); + var cut = RenderDustbin(); + var offsetChanges = 0; + Manager.OffsetChanged += () => offsetChanges++; + + await StartDragAsync(Source(cut), clientX: 100, clientY: 60, offsetX: 10, offsetY: 15); + + Assert.AreEqual(new BdndXYCoord(40, 50), Manager.GetInitialSourceClientOffset()); + Assert.AreEqual(1, offsetChanges, "drag layers re-render from the corrected corner"); + } + + [TestMethod] + public async Task ARectArrivingAfterTheDragEndedIsIgnored() + { + var rect = Sources.Setup("getDragStartRect", i => i.InvocationMethodName == "InvokeAsync"); + var cut = RenderDustbin(); + + var start = StartDragAsync(Source(cut)); + await EndDragAsync(Source(cut)); + rect.SetResult(new BdndDomRect { Left = 40, Top = 50 }); + await start; + + Assert.IsFalse(Manager.IsDragging()); + Assert.IsNull(Manager.GetInitialSourceClientOffset()); + Assert.AreSequenceEqual(new[] { "begin:crate", "end:crate:False:" }, _log); + } + + [TestMethod] + public async Task ItemFactoryProducesTheItemWhenTheDragStarts() + { + var produced = 0; + var cut = RenderDustbin(d => d.Add(x => x.ItemFactory, () => $"lazy-{++produced}")); + Assert.AreEqual(0, produced); + + await StartDragAsync(Source(cut)); + await EndDragAsync(Source(cut)); + + Assert.AreEqual(1, produced); + Assert.AreSequenceEqual(new[] { "begin:lazy-1", "end:lazy-1:False:" }, _log); + } + + [TestMethod] + public async Task AnItemFactoryReturningNullCancelsTheDrag() + { + var cut = RenderDustbin(d => d.Add(x => x.ItemFactory, () => null)); + + await StartDragAsync(Source(cut)); + + Assert.IsFalse(Manager.IsDragging()); + Assert.IsEmpty(_log); + Assert.IsFalse(_sourceStates.Last.IsDragging); + Assert.ContainsSingle(Sources.Invocations["cancelDrag"]); + } + + [TestMethod] + public async Task ADraggableThatCanNoLongerDragCancelsTheDragWithoutProducingAnItem() + { + var allowed = true; + var produced = 0; + var cut = RenderDustbin(d => d + .Add(x => x.CanDragPredicate, () => allowed) + .Add(x => x.ItemFactory, () => $"lazy-{++produced}")); + + allowed = false; + await StartDragAsync(Source(cut)); + + Assert.IsFalse(Manager.IsDragging()); + Assert.AreEqual(0, produced); + Assert.ContainsSingle(Sources.Invocations["cancelDrag"]); + } + + [TestMethod] + public async Task ADragStartNotTaggedWithThisSourceIsIgnored() + { + var cut = RenderDustbin(); + var renders = _sourceStates.Count; + + await HostOf(Source(cut)).DragStartAsync(PointerAt(0, 0, SourceType("someone-else"))); + await HostOf(Source(cut)).DragStartAsync(new DragEventArgs { DataTransfer = null! }); + + Assert.IsFalse(Manager.IsDragging()); + Assert.IsEmpty(_log); + Assert.IsEmpty(Sources.Invocations["cancelDrag"]); + Assert.AreEqual(renders, _sourceStates.Count, "drag events do not re-render the draggable by themselves"); + } + + [TestMethod] + public async Task NestedDraggablesDragTheInnermostOne() + { + var cut = RenderProvider(p => p.AddChildContent>(outer => outer + .Add(x => x.Type, "box") + .Add(x => x.Item, "outer") + .Add, BdndDragState>(x => x.ChildContent, _ => inner => inner + .Add(x => x.Type, "box") + .Add(x => x.Item, "inner")))); + var draggables = cut.FindComponents>(); + var innerType = SourceType(SourceIdOf(draggables[1].Instance)); + + // the script tags the drag with the innermost source; reaching the outer host must not start it + await HostOf(draggables[0]).DragStartAsync(PointerAt(0, 0, innerType)); + Assert.IsFalse(Manager.IsDragging()); + + await StartDragAsync(draggables[1]); + + Assert.AreEqual("inner", Manager.GetItem()); + Assert.IsTrue(Manager.IsDragging(SourceIdOf(draggables[1].Instance))); + Assert.IsFalse(Manager.IsDragging(SourceIdOf(draggables[0].Instance))); + } + + // ---- ordering ---- + + [TestMethod] + [DataRow("hover")] + [DataRow("dragover")] + [DataRow("drop")] + [DataRow("move")] + [DataRow("end")] + public async Task EveryCallbackPublishesAPendingBeginFirst(string callback) + { + // In Blazor WebAssembly the source publishes its drag a moment after dragstart; whatever arrives in + // that window publishes it first, so OnBegin always precedes the other callbacks of the drag. + var cut = RenderProvider(p => p + .AddChildContent>(d => d + .Add(x => x.Type, "box") + .Add(x => x.OnEnd, _ => _log.Add("end"))) + .AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.OnHover, _ => _log.Add("hover")) + .Add(x => x.OnDrop, _ => _log.Add("drop")))); + var source = Source(cut); + var bin = Bin(cut); + await cut.InvokeAsync(() => Manager.BeginDrag(SourceIdOf(source.Instance), "crate", "box", new(0, 0), new(0, 0), () => + { + _log.Add("begin"); + return Task.CompletedTask; + })); + + await (callback switch + { + "hover" => HoverAsync(bin), + "dragover" => HostOf(bin).DragOverAsync(PointerAt(1, 1)), + "drop" => DropAsync(bin), + "move" => source.InvokeAsync(() => source.Instance.HandleDragMove(1, 1)), + _ => EndDragAsync(source), + }); + + Assert.AreEqual("begin", _log[0]); + Assert.ContainsSingle(_log.Where(entry => entry == "begin")); + } + + // ---- drop ---- + + [TestMethod] + [DataRow("outer", "outer")] + [DataRow(null, "inner")] + public async Task NestedTargetsReceiveTheDropInnermostFirstAndAnOuterResultWins(string? outerResult, string expected) + { + BdndDragEndContext? end = null; + var cut = RenderProvider(p => p + .AddChildContent>(d => d + .Add(x => x.Type, "box") + .Add(x => x.Item, "crate") + .Add(x => x.OnEnd, e => end = e)) + .AddChildContent>(outer => outer + .Add(x => x.Accept, "box") + .Add(x => x.OnDrop, e => + { + _log.Add($"outer:{e.Monitor.DidDrop()}"); + e.Result = outerResult; + }) + .Add, BdndDropState>(x => x.ChildContent, _ => inner => inner + .Add(x => x.Accept, "box") + .AddUnmatched("id", "inner") + .Add(x => x.OnDrop, e => + { + _log.Add($"inner:{e.Monitor.DidDrop()}:{e.Item}"); + e.Result = "inner"; + })))); + + await StartDragAsync(Source(cut)); + await DropAsync(cut.Find("#inner")); + await EndDragAsync(Source(cut)); + + Assert.AreSequenceEqual(new[] { "inner:False:crate", "outer:True" }, _log); + Assert.IsNotNull(end); + Assert.IsTrue(end.DidDrop); + Assert.AreEqual(expected, end.DropResult); + } + + [TestMethod] + public async Task ATargetThatCannotAcceptIsSkippedWhileAnEnclosingOneReceivesTheDrop() + { + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.Item, "crate")) + .AddChildContent>(outer => outer + .Add(x => x.Accept, "box") + .Add(x => x.OnDrop, e => _log.Add($"outer:{e.Monitor.DidDrop()}")) + .Add, BdndDropState>(x => x.ChildContent, _ => middle => middle + .Add(x => x.Accept, "card") + .Add(x => x.OnDrop, _ => _log.Add("middle")) + .Add, BdndDropState>(x => x.ChildContent, _ => inner => inner + .Add(x => x.Accept, "box") + .AddUnmatched("id", "inner") + .Add(x => x.CanDropPredicate, (_, _) => false) + .Add(x => x.OnDrop, _ => _log.Add("inner")))))); + + await StartDragAsync(Source(cut)); + await DropAsync(cut.Find("#inner")); + + Assert.AreSequenceEqual(new[] { "outer:False" }, _log); + } + + [TestMethod] + public async Task AnAsyncOnDropStillCountsAsADropWhenTheDragEndsFirst() + { + var gate = new TaskCompletionSource(); + var cut = RenderDustbin(onDrop: async e => + { + await gate.Task; + e.Result = "late"; + }); + await StartDragAsync(Source(cut)); + + var drop = DropAsync(Bin(cut)); + Assert.IsTrue(Manager.DidDrop(), "marked before the handler completes"); + await EndDragAsync(Source(cut)); + gate.SetResult(); + await drop; + + Assert.AreEqual("end:crate:True:", _log[^1]); + Assert.IsNull(Manager.GetDropResult(), "the late result belongs to a drag that already ended"); + } + + [TestMethod] + public async Task AnAsyncOnDropThatCompletesInTimePublishesItsResult() + { + var gate = new TaskCompletionSource(); + var cut = RenderDustbin(onDrop: async e => + { + await gate.Task; + e.Result = "done"; + }); + await StartDragAsync(Source(cut)); + + var drop = DropAsync(Bin(cut)); + gate.SetResult(); + await drop; + await EndDragAsync(Source(cut)); + + Assert.AreEqual("end:crate:True:done", _log[^1]); + } + + // ---- end ---- + + [TestMethod] + public async Task TheDragStateIsClearedRightAfterTheSynchronousPartOfOnEnd() + { + var gate = new TaskCompletionSource(); + var cut = RenderDustbin(onEnd: async e => + { + _log.Add($"end:{e.Monitor.IsDragging()}"); + await gate.Task; + _log.Add($"after:{e.Monitor.IsDragging()}"); + }); + await StartDragAsync(Source(cut)); + await HoverAsync(Bin(cut)); + + var end = EndDragAsync(Source(cut)); + + Assert.IsFalse(Manager.IsDragging()); + Assert.IsFalse(_binStates.Last.IsOver, "an async OnEnd must not keep targets highlighted"); + gate.SetResult(); + await end; + Assert.AreSequenceEqual(new[] { "begin:crate", "end:True", "after:False" }, _log); + } + + [TestMethod] + public async Task OnEndRunsEvenWhenTheDraggableWasRemovedMidDrag() + { + var cut = RenderDustbin(); + var source = Source(cut).Instance; + var dotNetRef = (DotNetObjectReference>)SourceRegistrationCall(source).Arguments[1]!; + await StartDragAsync(Source(cut)); + await DropAsync(Bin(cut)); + + // e.g. the drop moved the item to another list and re-rendered this one + cut.Render(p => p.AddChildContent("

moved

")); + Assert.IsEmpty(cut.FindComponents>()); + Assert.IsEmpty(Sources.Invocations["dispose"], "the registration reports the end, so it stays alive"); + Assert.AreSame(source, dotNetRef.Value); + + await cut.InvokeAsync(source.HandleDragEnd); + + Assert.AreEqual("end:crate:True:archived", _log[^1]); + Assert.IsFalse(Manager.IsDragging()); + Assert.ContainsSingle(Sources.Invocations["dispose"]); + Assert.ThrowsExactly(() => dotNetRef.Value); + } + + [TestMethod] + public async Task AnEndReportedForASourceThatIsNotDraggingIsIgnored() + { + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.OnEnd, _ => _log.Add("first"))) + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.OnEnd, _ => _log.Add("second")))); + var draggables = cut.FindComponents>(); + + await EndDragAsync(draggables[0]); + await StartDragAsync(draggables[1]); + await EndDragAsync(draggables[0]); + + Assert.IsEmpty(_log); + Assert.IsTrue(Manager.IsDragging()); + } + + [TestMethod] + public async Task PointerMovesOfTheDraggedSourceUpdateTheOffsets() + { + var cut = RenderDustbin(); + var source = Source(cut); + + await source.InvokeAsync(() => source.Instance.HandleDragMove(5, 5)); + Assert.IsNull(Manager.GetClientOffset(), "not dragging yet"); + + await StartDragAsync(source, 10, 10); + await source.InvokeAsync(() => source.Instance.HandleDragMove(150, 90)); + + Assert.AreEqual(new BdndXYCoord(150, 90), Manager.GetClientOffset()); + Assert.AreEqual(new BdndXYCoord(140, 80), Manager.GetDifferenceFromInitialOffset()); + } + + // ---- collected state ---- + + [TestMethod] + public async Task IsDraggingOverrideDecidesTheCollectedState() + { + // e.g. the dragged card was re-rendered by another component instance in another list + var originalStates = new StateLog(); + var copyStates = new StateLog(); + var cut = RenderProvider(p => p + .AddChildContent>(d => d + .Add(x => x.Type, "card") + .Add(x => x.Item, "card-7") + .Add(x => x.ChildContent, originalStates.Content())) + .AddChildContent>(d => d + .Add(x => x.Type, "card") + .Add(x => x.Item, "card-7") + .Add(x => x.IsDraggingOverride, m => m.GetItem() == "card-7") + .Add(x => x.ChildContent, copyStates.Content()))); + + await StartDragAsync(cut.FindComponents>()[0]); + + Assert.IsTrue(originalStates.Last.IsDragging); + Assert.IsTrue(copyStates.Last.IsDragging); + } + + [TestMethod] + public async Task OnlyTheDraggedSourceReportsIsDragging() + { + var first = new StateLog(); + var second = new StateLog(); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.ChildContent, first.Content())) + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.ChildContent, second.Content()))); + + await StartDragAsync(cut.FindComponents>()[1]); + + Assert.IsFalse(first.Last.IsDragging); + Assert.IsTrue(second.Last.IsDragging); + Assert.IsTrue(first.Last.Monitor.IsDragging()); + } + + [TestMethod] + public async Task NestedTargetsReportDeepAndShallowHover() + { + var outerStates = new StateLog>(); + var innerStates = new StateLog>(); + var inner = Fragment>(("Accept", "box"), ("ChildContent", innerStates.Content())); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box")) + .AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.ChildContent, outerStates.Content(_ => inner)))); + var targets = cut.FindComponents>(); + await StartDragAsync(Source(cut)); + + await HoverAsync(targets[0]); + Assert.IsTrue(outerStates.Last.IsOverShallow); + + await HoverAsync(targets[1]); + Assert.IsTrue(outerStates.Last.IsOver); + Assert.IsFalse(outerStates.Last.IsOverShallow); + Assert.IsTrue(innerStates.Last.IsOver); + Assert.IsTrue(innerStates.Last.IsOverShallow); + + await HoverAsync(targets[1], isOver: false); + Assert.IsTrue(outerStates.Last.IsOverShallow); + Assert.IsFalse(innerStates.Last.IsOver); + } + + // ---- providers ---- + + [TestMethod] + public async Task ATargetOfANestedProviderIgnoresTheOuterProvidersDrag() + { + var innerStates = new StateLog>(); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.Item, "crate")) + .AddChildContent(nested => nested.AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.OnDrop, _ => _log.Add("drop")) + .Add(x => x.ChildContent, innerStates.Content())))); + var source = Source(cut); + var target = Bin(cut); + + await StartDragAsync(source); + await HoverAsync(target, true, SourceType(SourceIdOf(source.Instance))); + await DropAsync(target); + + Assert.IsFalse(innerStates.Last.CanDrop); + Assert.IsFalse(innerStates.Last.IsOver); + Assert.IsNull(innerStates.Last.ItemType); + Assert.IsEmpty(_log); + Assert.IsFalse(Manager.DidDrop()); + } + + [TestMethod] + public async Task ShallowHoverSkipsTheTargetsOfANestedProvider() + { + // A (outer manager) > X (nested provider's manager) > B (outer manager again, through a provider sharing it): + // B's parent is A, so hovering B takes the shallow hover from A. + var aStates = new StateLog>(); + var b = Fragment( + ("Manager", Manager), + ("ChildContent", Fragment>(("Accept", "box")))); + var nestedX = Fragment( + ("ChildContent", Fragment>( + ("Accept", "box"), + ("ChildContent", (RenderFragment>)(_ => b))))); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box")) + .AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.ChildContent, aStates.Content(_ => nestedX)))); + var targets = cut.FindComponents>(); + Assert.HasCount(3, targets); + await StartDragAsync(Source(cut)); + + await HoverAsync(targets[0]); + await HoverAsync(targets[2]); + + Assert.IsTrue(aStates.Last.IsOver); + Assert.IsFalse(aStates.Last.IsOverShallow); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs new file mode 100644 index 00000000000..95953147268 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs @@ -0,0 +1,211 @@ +using Bunit; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Components; + +/// What a draggable renders and what it tells the script; the drag itself is in . +[TestClass] +public class BdndDraggableTests : BdndComponentTestBase +{ + private IRenderedComponent> RenderDraggable( + Action>>? parameters = null, StateLog? log = null) + => Render>(p => + { + p.AddCascadingValue(Manager); + p.Add(x => x.Type, "box"); + p.Add(x => x.Item, "crate"); + if (log is not null) p.Add(x => x.ChildContent, log.Content()); + parameters?.Invoke(p); + }); + + // ---- markup ---- + + [TestMethod] + public void RendersADraggableDivAroundItsContent() + { + var cut = RenderDraggable(p => p.Add(x => x.ChildContent, _ => "Crate")); + + var host = cut.Find("div"); + Assert.AreEqual("true", host.GetAttribute("draggable")); + Assert.AreEqual("Crate", host.QuerySelector(".card")!.TextContent); + } + + [TestMethod] + public void TheContentReceivesTheCollectedState() + { + var log = new StateLog(); + + RenderDraggable(log: log); + + Assert.IsFalse(log.Last.IsDragging); + Assert.IsTrue(log.Last.CanDrag); + Assert.AreSame(Manager, log.Last.Monitor); + } + + [TestMethod] + public void ADraggableThatCannotDragRendersDraggableFalse() + { + var log = new StateLog(); + + var cut = RenderDraggable(p => p.Add(x => x.CanDrag, false), log); + + Assert.AreEqual("false", cut.Find("div").GetAttribute("draggable")); + Assert.IsFalse(log.Last.CanDrag); + } + + [TestMethod] + [DataRow(false, true, "true")] + [DataRow(true, false, "false")] + public void CanDragPredicateOverridesCanDrag(bool canDrag, bool predicate, string expected) + { + var cut = RenderDraggable(p => p.Add(x => x.CanDrag, canDrag).Add(x => x.CanDragPredicate, () => predicate)); + + Assert.AreEqual(expected, cut.Find("div").GetAttribute("draggable")); + } + + [TestMethod] + public void CanDragPredicateIsEvaluatedOnEveryRender() + { + var allowed = true; + var cut = RenderDraggable(p => p.Add(x => x.CanDragPredicate, () => allowed)); + + allowed = false; + cut.Render(); + + Assert.AreEqual("false", cut.Find("div").GetAttribute("draggable")); + } + + [TestMethod] + public void WithAHandleTheHostItselfIsNotDraggable() + { + var cut = RenderDraggable(p => p.Add(x => x.UseHandle, true)); + + Assert.AreEqual("false", cut.Find("div").GetAttribute("draggable")); + } + + [TestMethod] + public void TagAndOtherAttributesGoOntoTheHostElement() + { + var cut = RenderDraggable(p => p + .Add(x => x.Tag, "li") + .AddUnmatched("id", "card-1") + .AddUnmatched("class", "card") + .AddUnmatched("style", "display:contents") + .AddUnmatched("data-index", "3")); + + var host = cut.Find("li"); + Assert.AreEqual("card-1", host.Id); + Assert.AreEqual("card", host.ClassName); + Assert.AreEqual("display:contents", host.GetAttribute("style")); + Assert.AreEqual("3", host.GetAttribute("data-index")); + Assert.AreEqual("true", host.GetAttribute("draggable")); + } + + // ---- registration with the script ---- + + [TestMethod] + public void TheHostElementIsRegisteredAsADragSource() + { + var cut = RenderDraggable(); + + var call = SourceRegistrationCall(cut.Instance); + var options = (BdndDragSourceOptions)call.Arguments[2]!; + Assert.IsInstanceOfType(call.Arguments[0]); + Assert.AreSame(cut.Instance, ((DotNetObjectReference>)call.Arguments[1]!).Value); + Assert.AreEqual(32, options.Id.Length); + Assert.IsTrue(options.CanDrag); + Assert.IsFalse(options.UseHandle); + Assert.IsFalse(options.UseEmptyPreview); + Assert.AreEqual("move", options.EffectAllowed); + } + + [TestMethod] + public void TheOptionsCarryTheParameters() + { + var cut = RenderDraggable(p => p + .Add(x => x.CanDrag, false) + .Add(x => x.UseHandle, true) + .Add(x => x.UseCustomDragLayer, true) + .Add(x => x.DropEffect, "copyMove")); + + var options = Assert.ContainsSingle(SourceRegistrations); + Assert.AreEqual(new BdndDragSourceOptions(SourceIdOf(cut.Instance), false, true, true, "copyMove"), options); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + public void ABlankDropEffectFallsBackToMove(string dropEffect) + { + RenderDraggable(p => p.Add(x => x.DropEffect, dropEffect)); + + Assert.AreEqual("move", Assert.ContainsSingle(SourceRegistrations).EffectAllowed); + } + + [TestMethod] + public void EveryDraggableRegistersWithItsOwnIdThroughOneModuleImport() + { + var cut = Render(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box")) + .AddChildContent>(d => d.Add(x => x.Type, "box"))); + + var draggables = cut.FindComponents>(); + + Assert.HasCount(2, SourceRegistrations); + Assert.AreNotEqual(SourceIdOf(draggables[0].Instance), SourceIdOf(draggables[1].Instance)); + Assert.ContainsSingle(JSInterop.Invocations["import"]); + } + + [TestMethod] + public void ChangedOptionsArePushedToTheExistingRegistration() + { + var cut = RenderDraggable(); + var id = SourceIdOf(cut.Instance); + + cut.Render(p => p.Add(x => x.CanDrag, false)); + cut.Render(p => p.Add(x => x.DropEffect, "copy")); + + Assert.ContainsSingle(SourceRegistrations); + Assert.AreSequenceEqual( + new[] { new BdndDragSourceOptions(id, false, false, false, "move"), new BdndDragSourceOptions(id, false, false, false, "copy") }, + SourceUpdates); + } + + [TestMethod] + public void UnchangedOptionsAreNotPushedAgain() + { + var cut = RenderDraggable(); + + cut.Render(); + cut.Render(p => p.Add(x => x.Item, "another crate")); + + Assert.IsEmpty(SourceUpdates); + } + + [TestMethod] + public void ARecreatedHostElementIsRegisteredAgain() + { + // a new Tag makes Blazor replace the element: the old registration listens on a detached node + var cut = RenderDraggable(); + var id = SourceIdOf(cut.Instance); + + cut.Render(p => p.Add(x => x.Tag, "li")); + + Assert.HasCount(2, SourceRegistrations); + Assert.AreEqual(id, SourceRegistrations[1].Id, "the handler id survives, so drags stay attributed to this source"); + Assert.ContainsSingle(Sources.Invocations["dispose"]); + Assert.IsEmpty(SourceUpdates); + } + + [TestMethod] + public void RemovingAnIdleDraggableReleasesItsRegistrationAndCallbackObject() + { + var cut = Render(p => p.AddChildContent>(d => d.Add(x => x.Type, "box"))); + var dotNetRef = (DotNetObjectReference>)SourceRegistrationCall(cut.FindComponent>().Instance).Arguments[1]!; + + cut.Render(p => p.AddChildContent("

gone

")); + + Assert.ContainsSingle(Sources.Invocations["dispose"]); + Assert.ThrowsExactly(() => dotNetRef.Value); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDropTargetTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDropTargetTests.cs new file mode 100644 index 00000000000..f9506e30c82 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDropTargetTests.cs @@ -0,0 +1,386 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Components; + +/// What a drop target renders, what it tells the script, and how it reacts to in-app drags it did not start. +[TestClass] +public class BdndDropTargetTests : BdndComponentTestBase +{ + private IRenderedComponent> RenderTarget( + Action>>? parameters = null, + StateLog>? log = null, string accept = "box") + => Render>(p => + { + p.AddCascadingValue(Manager); + p.Add(x => x.Accept, accept); + if (log is not null) p.Add(x => x.ChildContent, log.Content()); + parameters?.Invoke(p); + }); + + /// A published drag of , as a draggable of the same provider would start it. + private Task BeginDragAsync(IRenderedComponent cut, string itemType = "box", object? item = null) + => cut.InvokeAsync(() => + { + Manager.BeginDrag("src", item ?? "crate", itemType, new(10, 10), new(0, 0)); + return Manager.FlushBeginAsync(); + }); + + // ---- markup ---- + + [TestMethod] + public void TheContentReceivesAnIdleStateWhenNothingIsDragged() + { + var log = new StateLog>(); + + RenderTarget(log: log); + + Assert.IsFalse(log.Last.IsOver); + Assert.IsFalse(log.Last.IsOverShallow); + Assert.IsFalse(log.Last.CanDrop); + Assert.IsNull(log.Last.ItemType); + Assert.IsNull(log.Last.Item); + Assert.AreSame(Manager, log.Last.Monitor); + } + + [TestMethod] + public void TagAndOtherAttributesGoOntoTheHostElement() + { + var cut = RenderTarget(p => p + .Add(x => x.Tag, "section") + .AddUnmatched("id", "bin") + .AddUnmatched("class", "dustbin") + .Add(x => x.ChildContent, _ => "

Drop here

")); + + var host = cut.Find("section"); + Assert.AreEqual("bin", host.Id); + Assert.AreEqual("dustbin", host.ClassName); + Assert.AreEqual("Drop here", host.QuerySelector("h4")!.TextContent); + } + + [TestMethod] + public void TheDragOverListenerIsOnlyAttachedForAnOnHoverHandler() + { + // dragover fires continuously; on Blazor Server every one would be a circuit message + var withoutHover = RenderTarget(); + var withHover = RenderTarget(p => p.Add(x => x.OnHover, _ => { })); + + Assert.IsFalse(HostOf(withoutHover).HasAttribute("blazor:ondragover")); + Assert.IsTrue(HostOf(withHover).HasAttribute("blazor:ondragover")); + Assert.IsTrue(HostOf(withoutHover).HasAttribute("blazor:ondrop")); + } + + // ---- registration with the script ---- + + [TestMethod] + public void TheHostElementIsRegisteredAsADropTarget() + { + var cut = RenderTarget(); + + var call = TargetRegistrationCall(cut.Instance); + Assert.IsInstanceOfType(call.Arguments[0]); + Assert.AreSame(cut.Instance, ((DotNetObjectReference>)call.Arguments[1]!).Value); + var options = (BdndDropTargetOptions)call.Arguments[2]!; + Assert.IsFalse(options.CanDrop); + Assert.IsEmpty(options.NativeTypes); + } + + [TestMethod] + public void AcceptedNativeTypesAreSentAsDataTransferTypesInTheirOrder() + { + RenderTarget(p => p.Add(x => x.AcceptTypes, [BdndNativeTypes.File, "box", BdndNativeTypes.Url]), accept: BdndNativeTypes.Text); + + var options = Assert.ContainsSingle(TargetRegistrations); + Assert.AreSequenceEqual(new[] { "text/plain", "Files", "text/uri-list" }, options.NativeTypes); + } + + [TestMethod] + public void DuplicateAndEmptyAcceptedTypesAreIgnored() + { + RenderTarget(p => p.Add(x => x.AcceptTypes, ["", BdndNativeTypes.Html, null!, BdndNativeTypes.Html]), accept: BdndNativeTypes.Html); + + Assert.AreSequenceEqual(new[] { "text/html" }, Assert.ContainsSingle(TargetRegistrations).NativeTypes); + } + + [TestMethod] + public void AnUnchangedAcceptListIsNotPushedAgain() + { + var cut = RenderTarget(p => p.Add(x => x.AcceptTypes, [BdndNativeTypes.File])); + + // a new array with the same types, as a razor collection expression produces on every render + cut.Render(p => p.Add(x => x.AcceptTypes, [BdndNativeTypes.File])); + cut.Render(); + + Assert.IsEmpty(TargetUpdates); + } + + [TestMethod] + public void AChangedAcceptListIsPushedToTheExistingRegistration() + { + var cut = RenderTarget(p => p.Add(x => x.AcceptTypes, [BdndNativeTypes.File])); + + cut.Render(p => p.Add(x => x.AcceptTypes, [BdndNativeTypes.File, BdndNativeTypes.Text])); + + Assert.ContainsSingle(TargetRegistrations); + var update = Assert.ContainsSingle(TargetUpdates); + Assert.AreSequenceEqual(new[] { "Files", "text/plain" }, update.NativeTypes); + } + + [TestMethod] + public async Task CanDropIsPushedToTheScriptWhenACompatibleDragBeginsAndEnds() + { + // the script decides the drop effect (and whether drop fires at all) from it + var cut = RenderTarget(); + + await BeginDragAsync(cut); + await cut.InvokeAsync(Manager.EndDrag); + + Assert.AreSequenceEqual(new[] { true, false }, TargetUpdates.Select(u => u.CanDrop)); + } + + [TestMethod] + public async Task AnIncompatibleDragIsNotPushed() + { + var cut = RenderTarget(); + + await BeginDragAsync(cut, itemType: "card"); + + Assert.IsEmpty(TargetUpdates); + } + + // ---- in-app drags ---- + + [TestMethod] + public async Task CanDropIsTrueForEveryCompatibleDragHoveredOrNot() + { + var log = new StateLog>(); + var cut = RenderTarget(log: log); + + await BeginDragAsync(cut, item: "crate"); + + Assert.IsTrue(log.Last.CanDrop); + Assert.IsFalse(log.Last.IsOver); + Assert.AreEqual("box", log.Last.ItemType); + Assert.AreEqual("crate", log.Last.Item); + } + + [TestMethod] + public async Task AcceptAndAcceptTypesCombine() + { + var log = new StateLog>(); + var cut = RenderTarget(p => p.Add(x => x.AcceptTypes, ["card"]), log); + + await BeginDragAsync(cut, itemType: "card"); + Assert.IsTrue(log.Last.CanDrop); + + await BeginDragAsync(cut, itemType: "box"); + Assert.IsTrue(log.Last.CanDrop); + + await BeginDragAsync(cut, itemType: "knight"); + Assert.IsFalse(log.Last.CanDrop); + } + + [TestMethod] + public async Task CanDropPredicateNarrowsTheTypeCheck() + { + var log = new StateLog>(); + string? seenItem = null; + IBdndMonitor? seenMonitor = null; + var cut = RenderTarget(p => p.Add(x => x.CanDropPredicate, (item, monitor) => + { + seenItem = item; + seenMonitor = monitor; + return item == "crate"; + }), log); + + await BeginDragAsync(cut, item: "crate"); + Assert.IsTrue(log.Last.CanDrop); + Assert.AreEqual("crate", seenItem); + Assert.AreSame(Manager, seenMonitor); + + await BeginDragAsync(cut, item: "barrel"); + Assert.IsFalse(log.Last.CanDrop); + } + + [TestMethod] + public async Task TheItemsDotNetTypeDoesNotTakePart() + { + // the target expects an int, the drag carries a string of an accepted type + var log = new StateLog>(); + var cut = Render>(p => p + .AddCascadingValue(Manager) + .Add(x => x.Accept, "box") + .Add(x => x.ChildContent, log.Content())); + + await BeginDragAsync(cut, item: "crate"); + + Assert.IsTrue(log.Last.CanDrop); + Assert.AreEqual(0, log.Last.Item); + } + + [TestMethod] + public async Task HoverIsReportedOnlyForAnAcceptedType() + { + var log = new StateLog>(); + var cut = RenderTarget(log: log); + + await BeginDragAsync(cut, itemType: "card"); + await HoverAsync(cut); + + Assert.IsFalse(log.Last.IsOver); + Assert.IsFalse(log.Last.IsOverShallow); + } + + [TestMethod] + public async Task HoverEntersAndLeaves() + { + var log = new StateLog>(); + var cut = RenderTarget(log: log); + await BeginDragAsync(cut); + + await HoverAsync(cut); + Assert.IsTrue(log.Last.IsOver); + Assert.IsTrue(log.Last.IsOverShallow); + + await HoverAsync(cut, isOver: false); + Assert.IsFalse(log.Last.IsOver); + Assert.IsFalse(log.Last.IsOverShallow); + } + + [TestMethod] + public async Task OnHoverRunsForAnAcceptedTypeWithThePointerRecorded() + { + var hovers = new List>(); + var cut = RenderTarget(p => p.Add(x => x.OnHover, hovers.Add)); + await BeginDragAsync(cut, item: "crate"); + + await HostOf(cut).DragOverAsync(PointerAt(40, 25)); + + var hover = Assert.ContainsSingle(hovers); + Assert.AreEqual("crate", hover.Item); + Assert.AreSame(Manager, hover.Monitor); + Assert.AreEqual(new BdndXYCoord(40, 25), Manager.GetClientOffset()); + } + + [TestMethod] + public async Task OnHoverDoesNotRunForAnotherTypeOrWithoutADrag() + { + var hovers = 0; + var cut = RenderTarget(p => p.Add(x => x.OnHover, _ => hovers++)); + + await HostOf(cut).DragOverAsync(PointerAt(1, 1)); + await BeginDragAsync(cut, itemType: "card"); + await HostOf(cut).DragOverAsync(PointerAt(2, 2)); + + Assert.AreEqual(0, hovers); + } + + [TestMethod] + public async Task DragOverDoesNotReRenderTheTarget() + { + // the manager decides when something changed, not every dragover + var log = new StateLog>(); + var cut = RenderTarget(p => p.Add(x => x.OnHover, _ => { }), log); + await BeginDragAsync(cut); + var renders = log.Count; + + await HostOf(cut).DragOverAsync(PointerAt(5, 5)); + await HostOf(cut).DragOverAsync(PointerAt(6, 6)); + + Assert.AreEqual(renders, log.Count); + } + + [TestMethod] + public async Task ADropWithoutAnInAppDragIsLeftToTheScript() + { + var drops = 0; + var cut = RenderTarget(p => p.Add(x => x.OnDrop, _ => drops++)); + + await DropAsync(cut); + + Assert.AreEqual(0, drops); + Assert.IsFalse(Manager.DidDrop()); + } + + [TestMethod] + public async Task ADropOfAnIncompatibleDragIsIgnored() + { + var drops = 0; + var cut = RenderTarget(p => p + .Add(x => x.CanDropPredicate, (_, _) => false) + .Add(x => x.OnDrop, _ => drops++)); + await BeginDragAsync(cut); + + await DropAsync(cut); + + Assert.AreEqual(0, drops); + Assert.IsFalse(Manager.DidDrop()); + } + + [TestMethod] + public async Task ADropRecordsTheFinalPointerBeforeOnDropRuns() + { + BdndXYCoord? moved = null; + var cut = RenderTarget(p => p.Add(x => x.OnDrop, e => moved = e.Monitor.GetDifferenceFromInitialOffset())); + await BeginDragAsync(cut); + + await DropAsync(cut, 70, 35); + + Assert.AreEqual(new BdndXYCoord(60, 25), moved); + } + + // ---- disposal ---- + + [TestMethod] + public async Task RemovingAHoveredTargetHandsTheShallowHoverBackToItsParent() + { + var outerLog = new StateLog>(); + var showInner = true; + var inner = Fragment>(("Accept", "box")); + var cut = RenderProvider(p => p.AddChildContent>(t => t + .Add(x => x.Accept, "box") + .Add(x => x.ChildContent, outerLog.Content(_ => showInner ? inner : null)))); + var targets = cut.FindComponents>(); + await BeginDragAsync(cut); + await HoverAsync(targets[0]); + await HoverAsync(targets[1]); + Assert.IsFalse(outerLog.Last.IsOverShallow); + + showInner = false; + cut.Render(); + + Assert.ContainsSingle(cut.FindComponents>()); + Assert.IsTrue(outerLog.Last.IsOver); + Assert.IsTrue(outerLog.Last.IsOverShallow); + } + + [TestMethod] + public void RemovingATargetReleasesItsRegistrationAndCallbackObject() + { + var cut = Render(p => p.AddChildContent>(t => t.Add(x => x.Accept, "box"))); + var dotNetRef = (DotNetObjectReference>)TargetRegistrationCall(cut.FindComponent>().Instance).Arguments[1]!; + + cut.Render(p => p.AddChildContent("

gone

")); + + Assert.ContainsSingle(Targets.Invocations["dispose"]); + Assert.ThrowsExactly(() => dotNetRef.Value); + } + + [TestMethod] + public async Task CallbacksArrivingAfterRemovalAreIgnored() + { + // the script may still report a hover or a native drop that was in flight + var nativeDrops = 0; + var cut = Render(p => p.AddChildContent>(t => t + .Add(x => x.Accept, BdndNativeTypes.File) + .Add(x => x.OnNativeDrop, _ => nativeDrops++))); + var target = cut.FindComponent>().Instance; + cut.Render(p => p.AddChildContent("

gone

")); + + await cut.InvokeAsync(() => target.HandleHoverChanged(true, ["Files"])); + await cut.InvokeAsync(() => target.HandleNativeDrop(new BdndNativeDropPayload { Types = ["Files"] })); + + Assert.AreEqual(0, nativeDrops); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs new file mode 100644 index 00000000000..2f05b7cdc33 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs @@ -0,0 +1,237 @@ +using Bunit; + +namespace Bit.Bdnd.Tests.Components; + +/// +/// Drags that start outside the application - files, text, links, HTML. The manager knows nothing about them: the +/// script reports their hover and their drop, and only targets that list a matching native type react. +/// +[TestClass] +public class BdndNativeDragTests : BdndComponentTestBase +{ + private IRenderedComponent> RenderTarget(StateLog> log, + List? drops = null, params string[] accept) + => Render>(p => + { + p.AddCascadingValue(Manager); + p.Add(x => x.AcceptTypes, accept); + p.Add(x => x.ChildContent, log.Content()); + if (drops is not null) p.Add(x => x.OnNativeDrop, drops.Add); + }); + + // ---- hover ---- + + [TestMethod] + public async Task ANativeDragOfAnAcceptedTypeReportsTheTargetAsHovered() + { + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.File]); + + await HoverAsync(cut, true, "Files"); + + Assert.IsTrue(log.Last.IsOver); + Assert.IsTrue(log.Last.IsOverShallow); + Assert.IsTrue(log.Last.CanDrop); + Assert.AreEqual(BdndNativeTypes.File, log.Last.ItemType); + Assert.IsNull(log.Last.Item); + Assert.IsFalse(Manager.IsDragging(), "the manager only tracks in-app drags"); + } + + [TestMethod] + public async Task LeavingClearsTheNativeHover() + { + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.File]); + await HoverAsync(cut, true, "Files"); + + await HoverAsync(cut, false, "Files"); + + Assert.IsFalse(log.Last.IsOver); + Assert.IsFalse(log.Last.IsOverShallow); + Assert.IsFalse(log.Last.CanDrop); + Assert.IsNull(log.Last.ItemType); + } + + [TestMethod] + public async Task ANativeDragOfAnotherTypeIsIgnored() + { + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.File]); + var renders = log.Count; + + await HoverAsync(cut, true, "text/plain", "text/html"); + + Assert.IsFalse(log.Last.IsOver); + Assert.AreEqual(renders, log.Count, "nothing changed, so nothing re-rendered"); + } + + [TestMethod] + public async Task ADragTaggedByADraggableIsNeverNative() + { + // e.g. a draggable of another provider dragging a file-like payload over this target + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.File]); + + await HoverAsync(cut, true, "Files", SourceType("elsewhere")); + + Assert.IsFalse(log.Last.IsOver); + Assert.IsNull(log.Last.ItemType); + } + + [TestMethod] + public async Task TheFirstAcceptedTypeInTheTargetsOrderDecidesTheItemType() + { + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.Html, BdndNativeTypes.Text, BdndNativeTypes.Url]); + + await HoverAsync(cut, true, "text/plain", "text/uri-list", "text/html"); + + Assert.AreEqual(BdndNativeTypes.Html, log.Last.ItemType); + } + + [TestMethod] + public async Task AnInAppDragReplacesAStaleNativeHover() + { + var log = new StateLog>(); + var cut = RenderTarget(log, accept: [BdndNativeTypes.Text, "box"]); + await HoverAsync(cut, true, "text/plain"); + + await cut.InvokeAsync(() => + { + Manager.BeginDrag("src", "crate", "card", new(0, 0), new(0, 0)); + return Manager.FlushBeginAsync(); + }); + await HoverAsync(cut, true, SourceType("src")); + + Assert.IsFalse(log.Last.IsOver); + Assert.AreEqual("card", log.Last.ItemType); + Assert.IsFalse(log.Last.CanDrop); + } + + // ---- drop ---- + + [TestMethod] + public async Task ANativeFileDropReachesOnNativeDropWithTheFilesAndTheData() + { + var log = new StateLog>(); + var drops = new List(); + var cut = RenderTarget(log, drops, BdndNativeTypes.File); + await HoverAsync(cut, true, "Files"); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload + { + Types = ["Files", "text/plain"], + Data = new() { ["text/plain"] = "report.pdf" }, + Files = [new BdndNativeFile { Name = "report.pdf", Size = 2048, Type = "application/pdf" }], + })); + + var drop = Assert.ContainsSingle(drops); + Assert.AreEqual(BdndNativeTypes.File, drop.ItemType); + var file = Assert.ContainsSingle(drop.Files); + Assert.AreEqual("report.pdf", file.Name); + Assert.AreEqual(2048, file.Size); + Assert.AreEqual("application/pdf", file.Type); + Assert.AreEqual("report.pdf", drop.Text); + Assert.IsFalse(log.Last.IsOver, "no dragleave follows a drop, so the drop clears the hover"); + } + + [TestMethod] + public async Task ANativeTextDropCarriesEveryStringOfTheDrag() + { + var drops = new List(); + var cut = RenderTarget(new(), drops, BdndNativeTypes.Html, BdndNativeTypes.Url); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload + { + Types = ["text/plain", "text/uri-list", "text/html"], + Data = new() + { + ["text/plain"] = "bit platform", + ["text/uri-list"] = "https://bitplatform.dev", + ["text/html"] = "bit platform", + }, + })); + + var drop = Assert.ContainsSingle(drops); + Assert.AreEqual(BdndNativeTypes.Html, drop.ItemType); + Assert.AreEqual("bit platform", drop.Text); + Assert.AreEqual("bit platform", drop.Html); + Assert.AreSequenceEqual(new[] { "https://bitplatform.dev" }, drop.Urls); + Assert.IsEmpty(drop.Files); + } + + [TestMethod] + public async Task MissingPayloadCollectionsArriveEmpty() + { + var drops = new List(); + var cut = RenderTarget(new(), drops, BdndNativeTypes.Text); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload + { + Types = ["text/plain"], + Data = null!, + Files = null!, + })); + + var drop = Assert.ContainsSingle(drops); + Assert.IsEmpty(drop.Files); + Assert.IsEmpty(drop.Data); + } + + [TestMethod] + public async Task ANativeDropOfAnotherTypeIsIgnored() + { + var drops = new List(); + var cut = RenderTarget(new(), drops, BdndNativeTypes.File); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload { Types = ["text/plain"] })); + + Assert.IsEmpty(drops); + } + + [TestMethod] + public async Task ADropTaggedByADraggableIsNotANativeDrop() + { + var drops = new List(); + var cut = RenderTarget(new(), drops, BdndNativeTypes.Text); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload + { + Types = ["text/plain", SourceType("elsewhere")], + })); + + Assert.IsEmpty(drops); + } + + [TestMethod] + public async Task NoNativeDropIsDeliveredWhileAnInAppDragRuns() + { + var drops = new List(); + var cut = RenderTarget(new(), drops, BdndNativeTypes.Text); + await cut.InvokeAsync(() => + { + Manager.BeginDrag("src", "crate", "box", new(0, 0), new(0, 0)); + return Manager.FlushBeginAsync(); + }); + + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload { Types = ["text/plain"] })); + + Assert.IsEmpty(drops); + } + + [TestMethod] + public async Task ANativeDropNeverReachesOnDrop() + { + var drops = 0; + var cut = Render>(p => p + .AddCascadingValue(Manager) + .Add(x => x.Accept, BdndNativeTypes.File) + .Add(x => x.OnDrop, _ => drops++)); + + // the managed drop event of a native drop: the script handles it, the managed handler stays out + await HostOf(cut).DropAsync(PointerAt(0, 0, "Files")); + await cut.InvokeAsync(() => cut.Instance.HandleNativeDrop(new BdndNativeDropPayload { Types = ["Files"] })); + + Assert.AreEqual(0, drops); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndProviderTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndProviderTests.cs new file mode 100644 index 00000000000..f4cf1a8fe2d --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndProviderTests.cs @@ -0,0 +1,136 @@ +using Bunit; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bdnd.Tests.Components; + +[TestClass] +public class BdndProviderTests : BdndComponentTestBase +{ + /// Captures the manager a provider cascades. + public sealed class ManagerProbe : ComponentBase + { + [CascadingParameter] public BdndDragDropManager? Manager { get; set; } + } + + [TestMethod] + public void AProviderCreatesAManagerForItsSubtree() + { + var cut = Render(p => p.AddChildContent()); + + Assert.IsNotNull(cut.FindComponent().Instance.Manager); + } + + [TestMethod] + public void EveryProviderHasItsOwnManager() + { + var cut = Render(p => p + .AddChildContent() + .AddChildContent(inner => inner.AddChildContent())); + + var probes = cut.FindComponents(); + + Assert.HasCount(2, probes); + Assert.AreNotSame(probes[0].Instance.Manager, probes[1].Instance.Manager); + } + + [TestMethod] + public void AProviderKeepsItsManagerAcrossRenders() + { + var cut = Render(p => p.AddChildContent()); + var manager = cut.FindComponent().Instance.Manager; + + cut.Render(); + cut.Render(p => p.AddChildContent()); + + Assert.AreSame(manager, cut.FindComponent().Instance.Manager); + } + + [TestMethod] + public void AProviderCascadesTheManagerItIsGiven() + { + var cut = RenderProvider(p => p.AddChildContent()); + + Assert.AreSame(Manager, cut.FindComponent().Instance.Manager); + } + + [TestMethod] + public void TwoProvidersCanShareOneManager() + { + var cut = Render(p => p + .Add(x => x.Manager, Manager) + .AddChildContent(inner => inner.Add(x => x.Manager, Manager).AddChildContent())); + + Assert.AreSame(Manager, cut.FindComponent().Instance.Manager); + } + + [TestMethod] + public void ReRenderingWithTheSameManagerIsAllowed() + { + var cut = RenderProvider(p => p.AddChildContent()); + + cut.Render(p => p.Add(x => x.Manager, Manager)); + + Assert.AreSame(Manager, cut.FindComponent().Instance.Manager); + } + + [TestMethod] + public void ReplacingTheManagerThrows() + { + var cut = RenderProvider(p => p.AddChildContent()); + + var error = Assert.ThrowsExactly( + () => cut.Render(p => p.Add(x => x.Manager, new BdndDragDropManager()))); + + Assert.AreEqual("The Manager of a BdndProvider cannot be changed after it has been set.", error.Message); + } + + [TestMethod] + public void SupplyingAManagerAfterTheProviderCreatedItsOwnThrows() + { + var cut = Render(p => p.AddChildContent()); + + Assert.ThrowsExactly( + () => cut.Render(p => p.Add(x => x.Manager, new BdndDragDropManager()))); + } + + // ---- components outside a provider ---- + + [TestMethod] + public void ADraggableOutsideAProviderThrows() + { + var error = Assert.ThrowsExactly( + () => Render>(p => p.Add(x => x.Type, "box"))); + + Assert.AreEqual("BdndDraggable must be placed inside a BdndProvider.", error.Message); + } + + [TestMethod] + public void ADropTargetOutsideAProviderThrows() + { + var error = Assert.ThrowsExactly( + () => Render>(p => p.Add(x => x.Accept, "box"))); + + Assert.AreEqual("BdndDropTarget must be placed inside a BdndProvider.", error.Message); + } + + [TestMethod] + public void ADragLayerOutsideAProviderThrows() + { + var error = Assert.ThrowsExactly(() => Render>()); + + Assert.AreEqual("BdndDragLayer must be placed inside a BdndProvider.", error.Message); + } + + [TestMethod] + public void WithoutTheServicesTheErrorNamesTheMissingInterop() + { + // the README's troubleshooting table quotes this message + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + + var error = Assert.ThrowsExactly(() => context.Render(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box")))); + + Assert.Contains("Cannot provide a value for property 'Interop'", error.Message, StringComparison.Ordinal); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndDragDropManagerTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndDragDropManagerTests.cs new file mode 100644 index 00000000000..f9950f1fe60 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndDragDropManagerTests.cs @@ -0,0 +1,635 @@ +namespace Bit.Bdnd.Tests.Core; + +/// +/// The manager is the authoritative drag state every component reads. These tests drive it directly, +/// the way the components do, without rendering anything. +/// +[TestClass] +public class BdndDragDropManagerTests +{ + private static readonly BdndXYCoord Pointer = new(100, 50); + private static readonly BdndXYCoord SourceCorner = new(80, 40); + + private static BdndDropTargetEntry Target(string id, string? parentId = null, + string[]? accept = null, Func? canDrop = null) + => new() { Id = id, ParentId = parentId, Accept = accept ?? ["box"], CanDrop = canDrop }; + + private static BdndDragDropManager Manager(params BdndDropTargetEntry[] targets) + { + var manager = new BdndDragDropManager(); + foreach (var target in targets) manager.RegisterTarget(target); + return manager; + } + + private static void Begin(BdndDragDropManager manager, string itemType = "box", object? item = null, + string sourceId = "src", Func? onBegun = null) + => manager.BeginDrag(sourceId, item ?? "crate", itemType, Pointer, SourceCorner, onBegun); + + // ---- ids and identity ---- + + [TestMethod] + public void GenerateHandlerIdIsPrefixedAndUniqueWithinAManager() + { + var manager = new BdndDragDropManager(); + + Assert.AreEqual("card-1", manager.GenerateHandlerId("card")); + Assert.AreEqual("card-2", manager.GenerateHandlerId("card")); + Assert.AreEqual("bin-3", manager.GenerateHandlerId("bin")); + } + + [TestMethod] + public void GenerateHandlerIdCountsPerManager() + { + new BdndDragDropManager().GenerateHandlerId("card"); + + Assert.AreEqual("card-1", new BdndDragDropManager().GenerateHandlerId("card")); + } + + [TestMethod] + public void TheMonitorIsTheManagerItself() + { + var manager = new BdndDragDropManager(); + + Assert.AreSame(manager, manager.Monitor); + } + + [TestMethod] + public void AnIdleManagerReportsNoDrag() + { + var manager = Manager(Target("bin")); + IBdndMonitor monitor = manager.Monitor; + + Assert.IsFalse(monitor.IsDragging()); + Assert.IsFalse(monitor.IsDragging(null)); + Assert.IsFalse(monitor.IsDragging("src")); + Assert.IsNull(monitor.GetItemType()); + Assert.IsNull(monitor.GetItem()); + Assert.IsNull(monitor.GetItem()); + Assert.IsNull(monitor.GetDropResult()); + Assert.IsFalse(monitor.DidDrop()); + Assert.IsFalse(monitor.CanDrop("bin")); + Assert.IsFalse(monitor.IsOver("bin")); + Assert.IsNull(monitor.GetInitialClientOffset()); + Assert.IsNull(monitor.GetInitialSourceClientOffset()); + Assert.IsNull(monitor.GetClientOffset()); + Assert.IsNull(monitor.GetDifferenceFromInitialOffset()); + Assert.IsNull(monitor.GetSourceClientOffset()); + } + + // ---- begin and publish ---- + + [TestMethod] + public async Task BeginDragSetsTheStateWithoutNotifyingUntilFlushed() + { + var manager = Manager(); + var changes = 0; + manager.StateChanged += () => changes++; + + Begin(manager); + + Assert.IsTrue(manager.IsDragging()); + Assert.AreEqual(0, changes, "a re-render during dragstart can abort the native drag"); + + await manager.FlushBeginAsync(); + + Assert.AreEqual(1, changes); + } + + [TestMethod] + public async Task FlushPublishesTheDragAndRunsTheBeginCallbackOnce() + { + var manager = Manager(); + var log = new List(); + manager.StateChanged += () => log.Add("state"); + Begin(manager, onBegun: () => + { + log.Add("begun"); + return Task.CompletedTask; + }); + + await manager.FlushBeginAsync(); + await manager.FlushBeginAsync(); + + Assert.AreSequenceEqual(new[] { "state", "begun" }, log); + } + + [TestMethod] + public async Task FlushWithoutAPendingBeginDoesNothing() + { + var manager = Manager(); + var changes = 0; + manager.StateChanged += () => changes++; + + await manager.FlushBeginAsync(); + + Assert.AreEqual(0, changes); + } + + [TestMethod] + public async Task FlushWithoutABeginCallbackStillPublishes() + { + var manager = Manager(); + var changes = 0; + manager.StateChanged += () => changes++; + Begin(manager); + + await manager.FlushBeginAsync(); + + Assert.AreEqual(1, changes); + } + + [TestMethod] + public async Task FlushCompletesWithTheBeginCallback() + { + var manager = Manager(); + var gate = new TaskCompletionSource(); + Begin(manager, onBegun: () => gate.Task); + + var flush = manager.FlushBeginAsync(); + + Assert.IsFalse(flush.IsCompleted); + gate.SetResult(); + await flush; + } + + [TestMethod] + public void BeginDragExposesTheItemAndItsOffsets() + { + var manager = Manager(); + + manager.BeginDrag("src", "crate", "box", Pointer, SourceCorner); + + Assert.AreEqual("box", manager.GetItemType()); + Assert.AreEqual("crate", manager.GetItem()); + Assert.AreEqual("crate", manager.GetItem()); + Assert.AreEqual(Pointer, manager.GetInitialClientOffset()); + Assert.AreEqual(SourceCorner, manager.GetInitialSourceClientOffset()); + Assert.AreEqual(Pointer, manager.GetClientOffset()); + Assert.AreEqual(new BdndXYCoord(0, 0), manager.GetDifferenceFromInitialOffset()); + Assert.AreEqual(SourceCorner, manager.GetSourceClientOffset()); + } + + [TestMethod] + public void EveryBeginStartsANewDrag() + { + var manager = Manager(); + Begin(manager); + var first = manager.DragId; + + Begin(manager); + + Assert.AreNotEqual(first, manager.DragId); + } + + [TestMethod] + public void IsDraggingBySourceMatchesOnlyTheDraggedSource() + { + var manager = Manager(); + Begin(manager, sourceId: "src"); + + Assert.IsTrue(manager.IsDragging("src")); + Assert.IsFalse(manager.IsDragging("other")); + Assert.IsTrue(manager.IsDragging(null), "null means any source"); + } + + [TestMethod] + public void TypedAccessorsAnswerDefaultForAnotherType() + { + var manager = Manager(Target("bin")); + Begin(manager, item: "crate"); + manager.RecordDrop(manager.DragId, 42); + + Assert.AreEqual(0, manager.GetItem()); + Assert.AreEqual(42, manager.GetDropResult()); + Assert.IsNull(manager.GetDropResult()); + } + + [TestMethod] + public void ANewDragForgetsTheDropAndHoverOfAnUnfinishedOne() + { + var manager = Manager(Target("bin")); + Begin(manager); + manager.SetTargetOver("bin", true); + manager.RecordDrop(manager.DragId, "result"); + + Begin(manager); + + Assert.IsFalse(manager.DidDrop()); + Assert.IsNull(manager.GetDropResult()); + Assert.IsFalse(manager.IsOver("bin")); + } + + // ---- can drop ---- + + [TestMethod] + public void CanDropRequiresTheItemTypeToBeAccepted() + { + var manager = Manager(Target("bin", accept: ["box", "card"]), Target("other", accept: ["card"])); + Begin(manager, itemType: "box"); + + Assert.IsTrue(manager.CanDrop("bin")); + Assert.IsFalse(manager.CanDrop("other")); + } + + [TestMethod] + public void CanDropIsFalseForAnUnknownTarget() + { + var manager = Manager(); + Begin(manager); + + Assert.IsFalse(manager.CanDrop("ghost")); + } + + [TestMethod] + public void CanDropAsksThePredicateWithTheItemAndTheMonitor() + { + object? seenItem = null; + IBdndMonitor? seenMonitor = null; + var allow = false; + var manager = Manager(Target("bin", canDrop: (item, monitor) => + { + seenItem = item; + seenMonitor = monitor; + return allow; + })); + Begin(manager, item: "crate"); + + Assert.IsFalse(manager.CanDrop("bin")); + allow = true; + Assert.IsTrue(manager.CanDrop("bin")); + Assert.AreEqual("crate", seenItem); + Assert.AreSame(manager, seenMonitor); + } + + [TestMethod] + public void ThePredicateIsNotAskedForAnUnacceptedTypeOrWhileIdle() + { + var asked = 0; + var manager = Manager(Target("bin", canDrop: (_, _) => ++asked > 0)); + + Assert.IsFalse(manager.CanDrop("bin")); + Begin(manager, itemType: "card"); + Assert.IsFalse(manager.CanDrop("bin")); + + Assert.AreEqual(0, asked); + } + + // ---- hover ---- + + [TestMethod] + public void SetTargetOverNotifiesOnlyWhenTheHoverChanges() + { + var manager = Manager(Target("bin")); + Begin(manager); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.SetTargetOver("bin", true); + manager.SetTargetOver("bin", true); + Assert.AreEqual(1, changes); + Assert.IsTrue(manager.IsOver("bin")); + + manager.SetTargetOver("bin", false); + Assert.AreEqual(2, changes); + Assert.IsFalse(manager.IsOver("bin")); + } + + [TestMethod] + public void SetTargetOverIsIgnoredWhileIdleAndForUnknownTargets() + { + var manager = Manager(Target("bin")); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.SetTargetOver("bin", true); + Begin(manager); + manager.SetTargetOver("ghost", true); + + Assert.AreEqual(0, changes); + Assert.IsFalse(manager.IsOver("bin")); + Assert.IsFalse(manager.IsOver("ghost")); + } + + [TestMethod] + public void DeepHoverIncludesAncestorsAndShallowHoverOnlyTheInnermost() + { + // outer > middle > inner, all hovered: the pointer is over inner. + var manager = Manager(Target("outer"), Target("middle", "outer"), Target("inner", "middle")); + Begin(manager); + manager.SetTargetOver("outer", true); + manager.SetTargetOver("middle", true); + manager.SetTargetOver("inner", true); + + Assert.IsTrue(manager.IsOver("outer")); + Assert.IsTrue(manager.IsOver("middle")); + Assert.IsTrue(manager.IsOver("inner")); + Assert.IsFalse(manager.IsOver("outer", shallow: true)); + Assert.IsFalse(manager.IsOver("middle", shallow: true)); + Assert.IsTrue(manager.IsOver("inner", shallow: true)); + + manager.SetTargetOver("inner", false); + + Assert.IsTrue(manager.IsOver("middle", shallow: true)); + Assert.IsFalse(manager.IsOver("outer", shallow: true)); + } + + [TestMethod] + public void ShallowHoverLooksThroughAnUnhoveredIntermediateTarget() + { + // middle does not accept the item, so only outer and inner are hovered + var manager = Manager(Target("outer"), Target("middle", "outer", accept: ["card"]), Target("inner", "middle")); + Begin(manager); + manager.SetTargetOver("outer", true); + manager.SetTargetOver("inner", true); + + Assert.IsFalse(manager.IsOver("outer", shallow: true)); + Assert.IsTrue(manager.IsOver("inner", shallow: true)); + } + + [TestMethod] + public void AHoveredSiblingDoesNotTakeTheShallowHover() + { + var manager = Manager(Target("left"), Target("right")); + Begin(manager); + manager.SetTargetOver("left", true); + manager.SetTargetOver("right", true); + + Assert.IsTrue(manager.IsOver("left", shallow: true)); + Assert.IsTrue(manager.IsOver("right", shallow: true)); + } + + [TestMethod] + public void IsOverIsFalseForUnhoveredAndUnknownTargets() + { + var manager = Manager(Target("bin")); + Begin(manager); + + Assert.IsFalse(manager.IsOver("bin")); + Assert.IsFalse(manager.IsOver("bin", shallow: true)); + Assert.IsFalse(manager.IsOver("ghost")); + } + + // ---- registration ---- + + [TestMethod] + public void ReRegisteringATargetKeepsItsHoverAndTakesTheNewAcceptListAndPredicate() + { + var manager = Manager(Target("bin")); + Begin(manager); + manager.SetTargetOver("bin", true); + + manager.RegisterTarget(Target("bin", accept: ["box"], canDrop: (_, _) => false)); + + Assert.IsTrue(manager.IsOver("bin"), "a re-render must not lose an in-flight hover"); + Assert.IsFalse(manager.CanDrop("bin")); + + manager.RegisterTarget(Target("bin", accept: ["card"])); + + Assert.IsFalse(manager.CanDrop("bin")); + manager.RegisterTarget(Target("bin", accept: ["box"])); + Assert.IsTrue(manager.CanDrop("bin")); + } + + [TestMethod] + public void UnregisteringAHoveredTargetNotifiesAndHandsTheShallowHoverBack() + { + var manager = Manager(Target("outer"), Target("inner", "outer")); + Begin(manager); + manager.SetTargetOver("outer", true); + manager.SetTargetOver("inner", true); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.UnregisterTarget("inner"); + + Assert.AreEqual(1, changes); + Assert.IsTrue(manager.IsOver("outer", shallow: true)); + Assert.IsFalse(manager.CanDrop("inner")); + } + + [TestMethod] + public void UnregisteringAnUnhoveredOrUnknownTargetDoesNotNotify() + { + var manager = Manager(Target("bin"), Target("other")); + Begin(manager); + manager.SetTargetOver("other", true); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.UnregisterTarget("bin"); + manager.UnregisterTarget("ghost"); + + Assert.AreEqual(0, changes); + Assert.IsFalse(manager.CanDrop("bin")); + } + + // ---- drop ---- + + [TestMethod] + public void RecordDropMarksTheDropAndKeepsItsResult() + { + var manager = Manager(Target("bin")); + Begin(manager); + + manager.RecordDrop(manager.DragId, "archived"); + + Assert.IsTrue(manager.DidDrop()); + Assert.AreEqual("archived", manager.GetDropResult()); + } + + [TestMethod] + public void AnOuterResultOverridesAnInnerOneButNullDoesNotEraseIt() + { + var manager = Manager(); + Begin(manager); + + manager.RecordDrop(manager.DragId, "inner"); + manager.RecordDrop(manager.DragId, "outer"); + Assert.AreEqual("outer", manager.GetDropResult()); + + manager.RecordDrop(manager.DragId, null); + Assert.AreEqual("outer", manager.GetDropResult()); + Assert.IsTrue(manager.DidDrop()); + } + + [TestMethod] + public void ANullResultStillCountsAsADrop() + { + var manager = Manager(); + Begin(manager); + + manager.RecordDrop(manager.DragId, null); + + Assert.IsTrue(manager.DidDrop()); + Assert.IsNull(manager.GetDropResult()); + } + + [TestMethod] + public void ADropRecordedForAnEndedDragIsIgnored() + { + // a slow async OnDrop of the previous drag completes during the next one + var manager = Manager(); + Begin(manager); + var staleDragId = manager.DragId; + manager.EndDrag(); + Begin(manager); + + manager.RecordDrop(staleDragId, "late"); + + Assert.IsFalse(manager.DidDrop()); + Assert.IsNull(manager.GetDropResult()); + } + + [TestMethod] + public void ADropRecordedWhileIdleIsIgnored() + { + var manager = Manager(); + Begin(manager); + var dragId = manager.DragId; + manager.EndDrag(); + + manager.RecordDrop(dragId, "late"); + + Assert.IsFalse(manager.DidDrop()); + Assert.IsNull(manager.GetDropResult()); + } + + // ---- end ---- + + [TestMethod] + public void EndDragClearsTheWholeStateAndNotifies() + { + var manager = Manager(Target("bin")); + Begin(manager); + manager.SetTargetOver("bin", true); + manager.SetClientOffset(new(120, 60)); + manager.RecordDrop(manager.DragId, "archived"); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.EndDrag(); + + Assert.AreEqual(1, changes); + Assert.IsFalse(manager.IsDragging()); + Assert.IsFalse(manager.IsDragging("src")); + Assert.IsNull(manager.GetItem()); + Assert.IsNull(manager.GetItemType()); + Assert.IsNull(manager.GetDropResult()); + Assert.IsFalse(manager.DidDrop()); + Assert.IsNull(manager.GetInitialClientOffset()); + Assert.IsNull(manager.GetInitialSourceClientOffset()); + Assert.IsNull(manager.GetClientOffset()); + Assert.IsNull(manager.GetSourceClientOffset()); + Assert.IsFalse(manager.CanDrop("bin")); + + Begin(manager); + Assert.IsFalse(manager.IsOver("bin"), "the hover flags are reset too"); + } + + [TestMethod] + public void EndDragWhileIdleDoesNotNotify() + { + var manager = Manager(); + var changes = 0; + manager.StateChanged += () => changes++; + + manager.EndDrag(); + + Assert.AreEqual(0, changes); + } + + [TestMethod] + public async Task EndingAnUnpublishedDragDiscardsItsBeginCallback() + { + var manager = Manager(); + var begun = 0; + Begin(manager, onBegun: () => + { + begun++; + return Task.CompletedTask; + }); + + manager.EndDrag(); + await manager.FlushBeginAsync(); + + Assert.AreEqual(0, begun); + } + + // ---- offsets ---- + + [TestMethod] + public void OffsetsFollowThePointer() + { + var manager = Manager(); + Begin(manager); + + manager.SetClientOffset(new(130, 20)); + + Assert.AreEqual(new BdndXYCoord(130, 20), manager.GetClientOffset()); + Assert.AreEqual(Pointer, manager.GetInitialClientOffset()); + Assert.AreEqual(new BdndXYCoord(30, -30), manager.GetDifferenceFromInitialOffset()); + Assert.AreEqual(new BdndXYCoord(110, 10), manager.GetSourceClientOffset()); + } + + [TestMethod] + public void SetClientOffsetRaisesOffsetChangedOnlyWhenTheOffsetChanges() + { + var manager = Manager(); + Begin(manager); + var offsets = 0; + var changes = 0; + manager.OffsetChanged += () => offsets++; + manager.StateChanged += () => changes++; + + manager.SetClientOffset(Pointer); + Assert.AreEqual(0, offsets, "the drag starts at this offset"); + + manager.SetClientOffset(new(101, 50)); + manager.SetClientOffset(new(101, 50)); + Assert.AreEqual(1, offsets); + Assert.AreEqual(0, changes, "pointer moves are not structural changes"); + } + + [TestMethod] + public void SetClientOffsetIsIgnoredWhileIdle() + { + var manager = Manager(); + var offsets = 0; + manager.OffsetChanged += () => offsets++; + + manager.SetClientOffset(new(1, 2)); + + Assert.AreEqual(0, offsets); + Assert.IsNull(manager.GetClientOffset()); + } + + [TestMethod] + public void SetInitialSourceClientOffsetCorrectsTheSourceCorner() + { + var manager = Manager(); + Begin(manager); + manager.SetClientOffset(new(110, 60)); + var offsets = 0; + manager.OffsetChanged += () => offsets++; + + manager.SetInitialSourceClientOffset(new(70, 30)); + manager.SetInitialSourceClientOffset(new(70, 30)); + + Assert.AreEqual(1, offsets); + Assert.AreEqual(new BdndXYCoord(70, 30), manager.GetInitialSourceClientOffset()); + Assert.AreEqual(new BdndXYCoord(80, 40), manager.GetSourceClientOffset()); + } + + [TestMethod] + public void SetInitialSourceClientOffsetIsIgnoredWhileIdle() + { + var manager = Manager(); + var offsets = 0; + manager.OffsetChanged += () => offsets++; + + manager.SetInitialSourceClientOffset(new(1, 2)); + + Assert.AreEqual(0, offsets); + Assert.IsNull(manager.GetInitialSourceClientOffset()); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndValueTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndValueTests.cs new file mode 100644 index 00000000000..68adb5925aa --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Core/BdndValueTests.cs @@ -0,0 +1,180 @@ +namespace Bit.Bdnd.Tests.Core; + +[TestClass] +public class BdndValueTests +{ + // ---- BdndXYCoord ---- + + [TestMethod] + public void CoordinatesSubtractAndAddComponentWise() + { + var a = new BdndXYCoord(10, 20); + var b = new BdndXYCoord(2.5, -4); + + Assert.AreEqual(new BdndXYCoord(7.5, 24), a - b); + Assert.AreEqual(new BdndXYCoord(12.5, 16), a + b); + Assert.AreEqual(a, a - b + b); + } + + [TestMethod] + public void CoordinatesCompareByValue() + { + var a = new BdndXYCoord(1, 2); + + Assert.AreEqual(new BdndXYCoord(1, 2), a); + Assert.IsTrue(a == new BdndXYCoord(1, 2)); + Assert.IsTrue(a != new BdndXYCoord(2, 1)); + Assert.AreEqual(new BdndXYCoord(1, 5), a with { Y = 5 }); + + var (x, y) = a; + Assert.AreEqual(1, x); + Assert.AreEqual(2, y); + } + + // ---- BdndNativeTypes ---- + + [TestMethod] + [DataRow(BdndNativeTypes.File, "__NATIVE_FILE__", "Files")] + [DataRow(BdndNativeTypes.Url, "__NATIVE_URL__", "text/uri-list")] + [DataRow(BdndNativeTypes.Text, "__NATIVE_TEXT__", "text/plain")] + [DataRow(BdndNativeTypes.Html, "__NATIVE_HTML__", "text/html")] + public void NativeTypesKeepReactDndValuesAndMapToTheirDataTransferType(string nativeType, string value, string dataTransferType) + { + Assert.AreEqual(value, nativeType); + Assert.IsTrue(BdndNativeTypes.IsNative(nativeType)); + Assert.AreEqual(dataTransferType, BdndNativeTypes.ToDataTransferType(nativeType)); + } + + [TestMethod] + [DataRow("box")] + [DataRow("Files")] + [DataRow("text/plain")] + [DataRow("__native_file__")] + [DataRow("")] + public void InAppTypesAreNotNative(string type) + { + Assert.IsFalse(BdndNativeTypes.IsNative(type)); + } + + [TestMethod] + public void AnUnknownNativeTypeIsUsedAsItsOwnDataTransferType() + { + Assert.AreEqual("__NATIVE_CUSTOM__", BdndNativeTypes.ToDataTransferType("__NATIVE_CUSTOM__")); + } + + // ---- BdndNativeDropEventArgs ---- + + [TestMethod] + public void NativeDropArgsDefaultToNoFilesAndNoData() + { + var args = new BdndNativeDropEventArgs { ItemType = BdndNativeTypes.File }; + + Assert.IsEmpty(args.Files); + Assert.IsEmpty(args.Data); + Assert.IsNull(args.Text); + Assert.IsNull(args.Html); + Assert.IsNull(args.Url); + Assert.IsEmpty(args.Urls); + } + + [TestMethod] + public void NativeDropArgsReadTextHtmlAndUrlFromTheData() + { + var args = new BdndNativeDropEventArgs + { + ItemType = BdndNativeTypes.Html, + Data = new Dictionary + { + ["text/plain"] = "hello", + ["text/html"] = "hello", + ["text/uri-list"] = "https://bitplatform.dev", + }, + }; + + Assert.AreEqual("hello", args.Text); + Assert.AreEqual("hello", args.Html); + Assert.AreEqual("https://bitplatform.dev", args.Url); + Assert.AreSequenceEqual(new[] { "https://bitplatform.dev" }, args.Urls); + } + + [TestMethod] + public void UrlsSplitTheUriListAndSkipCommentsAndBlankLines() + { + // RFC 2483: CRLF separated, '#' starts a comment line + var args = new BdndNativeDropEventArgs + { + ItemType = BdndNativeTypes.Url, + Data = new Dictionary + { + ["text/uri-list"] = "# dragged from a browser\r\nhttps://a.example/\r\n\r\n \r\n https://b.example/x \nhttps://c.example/#fragment\n#another comment\n", + }, + }; + + Assert.AreSequenceEqual(new[] { "https://a.example/", "https://b.example/x", "https://c.example/#fragment" }, args.Urls); + } + + [TestMethod] + public void AnEmptyUriListHasNoUrls() + { + var args = new BdndNativeDropEventArgs + { + ItemType = BdndNativeTypes.Url, + Data = new Dictionary { ["text/uri-list"] = "" }, + }; + + Assert.AreEqual("", args.Url); + Assert.IsEmpty(args.Urls); + } + + // ---- payload DTOs ---- + + [TestMethod] + public void InteropPayloadsStartEmpty() + { + var payload = new BdndNativeDropPayload(); + var file = new BdndNativeFile(); + var rect = new BdndDomRect(); + + Assert.IsEmpty(payload.Types); + Assert.IsEmpty(payload.Data); + Assert.IsEmpty(payload.Files); + Assert.AreEqual("", file.Name); + Assert.AreEqual("", file.Type); + Assert.AreEqual(0, file.Size); + Assert.AreEqual(0, rect.Left + rect.Top + rect.Width + rect.Height); + } + + [TestMethod] + public void InteropPayloadsDeserializeFromTheScriptsCamelCaseShape() + { + // the shape bit-bdnd.js sends to HandleNativeDrop, read with the JS interop serializer options + var json = """{"types":["Files","text/plain"],"data":{"text/plain":"a.txt"},"files":[{"name":"a.txt","size":12,"type":"text/plain"}]}"""; + + var payload = System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + + Assert.AreSequenceEqual(new[] { "Files", "text/plain" }, payload.Types); + Assert.AreEqual("a.txt", payload.Data["text/plain"]); + var file = Assert.ContainsSingle(payload.Files); + Assert.AreEqual("a.txt", file.Name); + Assert.AreEqual(12, file.Size); + Assert.AreEqual("text/plain", file.Type); + } + + // ---- BdndDropTargetScope ---- + + [TestMethod] + public void ATargetScopeFindsTheNearestTargetOfTheSameManager() + { + var outerManager = new BdndDragDropManager(); + var innerManager = new BdndDragDropManager(); + var outer = new BdndDropTargetScope(outerManager, "outer", null); + var nestedProviderTarget = new BdndDropTargetScope(innerManager, "nested", outer); + var inner = new BdndDropTargetScope(outerManager, "inner", nestedProviderTarget); + + Assert.AreEqual("inner", inner.FindTargetId(outerManager)); + Assert.AreEqual("outer", nestedProviderTarget.FindTargetId(outerManager), "targets of a nested provider are skipped"); + Assert.AreEqual("nested", inner.FindTargetId(innerManager)); + Assert.IsNull(outer.FindTargetId(innerManager)); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs new file mode 100644 index 00000000000..d9972a3e7e8 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs @@ -0,0 +1,59 @@ +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Fakes; + +/// +/// A JS object (a module, or a registration returned by registerDragSource/registerDropTarget) +/// that records every call and answers, fails or holds back per identifier. +/// +internal sealed class FakeJSObjectReference : IJSObjectReference +{ + private readonly Dictionary> _results = []; + private readonly Dictionary _failures = []; + private readonly Dictionary _gates = []; + + public List<(string Identifier, object?[]? Args)> Calls { get; } = []; + + public int DisposeCount { get; private set; } + + public Exception? DisposeException { get; set; } + + public IEnumerable CallsTo(string identifier) => + Calls.Where(c => c.Identifier == identifier).Select(c => c.Args); + + public FakeJSObjectReference Returns(string identifier, Func result) + { + _results[identifier] = result; + return this; + } + + public FakeJSObjectReference Fails(string identifier, Exception exception) + { + _failures[identifier] = exception; + return this; + } + + /// Calls to complete only once does. + public FakeJSObjectReference WaitsFor(string identifier, Task gate) + { + _gates[identifier] = gate; + return this; + } + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => InvokeAsync(identifier, CancellationToken.None, args); + + public async ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + Calls.Add((identifier, args)); + if (_gates.TryGetValue(identifier, out var gate)) await gate; + if (_failures.TryGetValue(identifier, out var failure)) throw failure; + return _results.TryGetValue(identifier, out var result) ? (TValue)result(args)! : default!; + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return DisposeException is null ? ValueTask.CompletedTask : ValueTask.FromException(DisposeException); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSRuntime.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSRuntime.cs new file mode 100644 index 00000000000..912e05d019e --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSRuntime.cs @@ -0,0 +1,36 @@ +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Fakes; + +/// A JS runtime whose module imports answer from a queue: a module, or an exception to fail with. +internal sealed class FakeJSRuntime : IJSRuntime +{ + private readonly Queue _imports = new(); + + public List<(string Identifier, object?[]? Args)> Calls { get; } = []; + + public int ImportCount => Calls.Count(c => c.Identifier == "import"); + + /// Queues the answer of the next import: an or an . + public FakeJSRuntime NextImport(object moduleOrException) + { + _imports.Enqueue(moduleOrException); + return this; + } + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => InvokeAsync(identifier, CancellationToken.None, args); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + Calls.Add((identifier, args)); + if (identifier != "import" || _imports.Count == 0) + return ValueTask.FromException(new InvalidOperationException($"Unexpected call to '{identifier}'.")); + + return _imports.Dequeue() switch + { + Exception exception => ValueTask.FromException(exception), + var module => ValueTask.FromResult((TValue)module), + }; + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/GlobalUsings.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..b2cee4a814f --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Bit.Bdnd; diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs new file mode 100644 index 00000000000..a9704c3cc92 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs @@ -0,0 +1,168 @@ +using Bit.Bdnd.Tests.Fakes; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Interop; + +/// The wrapper around the bit-bdnd.js module: what it imports, what it calls, and how it fails. +[TestClass] +public class BdndInteropTests +{ + private const string ModulePath = "./_content/Bit.Bdnd/bit-bdnd.js"; + + [TestMethod] + public void DragsAreTaggedWithTheHandlerIdAfterTheSourceTypePrefix() + { + // must match SOURCE_TYPE_PREFIX in bit-bdnd.ts + Assert.AreEqual("application/x-bit-bdnd-abc", BdndInterop.SourceType("abc")); + } + + [TestMethod] + public async Task TheModuleIsImportedOnceFromTheStaticWebAssetPath() + { + var module = new FakeJSObjectReference() + .Returns("getBoundingClientRect", _ => new BdndDomRect { Left = 1, Top = 2, Width = 30, Height = 40 }); + var js = new FakeJSRuntime().NextImport(module); + var interop = new BdndInterop(js); + var element = new ElementReference("el"); + + var first = await interop.GetBoundingClientRectAsync(element); + var second = await interop.GetBoundingClientRectAsync(element); + + var import = Assert.ContainsSingle(js.Calls); + Assert.AreEqual("import", import.Identifier); + Assert.AreSequenceEqual(new object?[] { ModulePath }, import.Args!); + Assert.IsNotNull(first); + Assert.AreEqual(1, first.Left); + Assert.AreEqual(2, first.Top); + Assert.AreEqual(30, first.Width); + Assert.AreEqual(40, first.Height); + Assert.IsNotNull(second); + var measure = module.CallsTo("getBoundingClientRect").First(); + Assert.AreEqual(element, measure![0]); + } + + [TestMethod] + public async Task MeasuringAnElementThatIsNotRenderedAnswersNull() + { + var js = new FakeJSRuntime().NextImport(new FakeJSObjectReference()); + var interop = new BdndInterop(js); + + Assert.IsNull(await interop.GetBoundingClientRectAsync(default)); + } + + [TestMethod] + public async Task AFailedImportIsRetriedByTheNextCall() + { + var module = new FakeJSObjectReference() + .Returns("getBoundingClientRect", _ => new BdndDomRect { Width = 5 }); + var js = new FakeJSRuntime() + .NextImport(new JSException("Failed to fetch dynamically imported module")) + .NextImport(module); + var interop = new BdndInterop(js); + + await Assert.ThrowsExactlyAsync(async () => await interop.GetBoundingClientRectAsync(default)); + var rect = await interop.GetBoundingClientRectAsync(default); + + Assert.AreEqual(2, js.ImportCount); + Assert.AreEqual(5, rect!.Width); + } + + [TestMethod] + public async Task RegisterDragSourcePassesTheElementTheCallbackObjectAndTheOptions() + { + var registration = new FakeJSObjectReference(); + var module = new FakeJSObjectReference().Returns("registerDragSource", _ => registration); + var interop = new BdndInterop(new FakeJSRuntime().NextImport(module)); + var element = new ElementReference("source"); + var callbacks = new object(); + var options = new BdndDragSourceOptions("id", true, false, true, "copy"); + + var result = await interop.RegisterDragSourceAsync(element, callbacks, options); + + Assert.AreSame(registration, result); + var args = Assert.ContainsSingle(module.CallsTo("registerDragSource")); + Assert.AreEqual(element, args![0]); + Assert.AreSame(callbacks, args[1]); + Assert.AreSame(options, args[2]); + } + + [TestMethod] + public async Task RegisterDropTargetPassesTheElementTheCallbackObjectAndTheOptions() + { + var registration = new FakeJSObjectReference(); + var module = new FakeJSObjectReference().Returns("registerDropTarget", _ => registration); + var interop = new BdndInterop(new FakeJSRuntime().NextImport(module)); + var element = new ElementReference("target"); + var callbacks = new object(); + var options = new BdndDropTargetOptions(true, ["Files"]); + + var result = await interop.RegisterDropTargetAsync(element, callbacks, options); + + Assert.AreSame(registration, result); + var args = Assert.ContainsSingle(module.CallsTo("registerDropTarget")); + Assert.AreEqual(element, args![0]); + Assert.AreSame(callbacks, args[1]); + Assert.AreSame(options, args[2]); + } + + [TestMethod] + public async Task SourcesAndTargetsShareOneModule() + { + var module = new FakeJSObjectReference(); + var js = new FakeJSRuntime().NextImport(module); + var interop = new BdndInterop(js); + + await interop.RegisterDragSourceAsync(default, new object(), new BdndDragSourceOptions("a", true, false, false, "move")); + await interop.RegisterDropTargetAsync(default, new object(), new BdndDropTargetOptions(false, [])); + await interop.GetBoundingClientRectAsync(default); + + Assert.AreEqual(1, js.ImportCount); + Assert.HasCount(3, module.Calls); + } + + [TestMethod] + public async Task DisposingReleasesTheModule() + { + var module = new FakeJSObjectReference(); + var interop = new BdndInterop(new FakeJSRuntime().NextImport(module)); + await interop.GetBoundingClientRectAsync(default); + + await interop.DisposeAsync(); + await interop.DisposeAsync(); + + Assert.AreEqual(1, module.DisposeCount); + } + + [TestMethod] + public async Task DisposingBeforeAnyImportDoesNotImport() + { + var js = new FakeJSRuntime(); + var interop = new BdndInterop(js); + + await interop.DisposeAsync(); + + Assert.IsEmpty(js.Calls); + } + + [TestMethod] + public async Task DisposingAfterTheCircuitIsGoneDoesNotThrow() + { + var module = new FakeJSObjectReference { DisposeException = new JSDisconnectedException("gone") }; + var interop = new BdndInterop(new FakeJSRuntime().NextImport(module)); + await interop.GetBoundingClientRectAsync(default); + + await interop.DisposeAsync(); + + Assert.AreEqual(1, module.DisposeCount); + } + + [TestMethod] + public async Task DisposingAfterAFailedImportDoesNotThrow() + { + var interop = new BdndInterop(new FakeJSRuntime().NextImport(new JSException("blocked"))); + await Assert.ThrowsExactlyAsync(async () => await interop.GetBoundingClientRectAsync(default)); + + await interop.DisposeAsync(); + } +} diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs new file mode 100644 index 00000000000..a1400225447 --- /dev/null +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs @@ -0,0 +1,266 @@ +using Bit.Bdnd.Tests.Fakes; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bdnd.Tests.Interop; + +/// +/// The registration keeps one JS object per rendered host element in sync across renders: it registers the +/// element, pushes changed options, re-registers a re-created element and releases the JS object on disposal. +/// +[TestClass] +public class BdndJsRegistrationTests +{ + private sealed record Options(int Value); + + private static readonly ElementReference Element = new("element-1"); + + /// A register delegate that records its calls and answers from . + private sealed class Registrar(Func> answer) + { + public List<(ElementReference Element, Options Options)> Calls { get; } = []; + + public Registrar(params IJSObjectReference?[] references) + : this(call => ValueTask.FromResult(references[Math.Min(call, references.Length - 1)])) + { + } + + public ValueTask Register(ElementReference element, Options options) + { + Calls.Add((element, options)); + return answer(Calls.Count - 1); + } + } + + [TestMethod] + public async Task TheFirstSyncRegistersTheElementWithItsOptions() + { + var reference = new FakeJSObjectReference(); + var registrar = new Registrar(reference); + var registration = new BdndJsRegistration(registrar.Register); + + Assert.IsNull(registration.Reference); + await registration.SyncAsync(Element, new Options(1)); + + var call = Assert.ContainsSingle(registrar.Calls); + Assert.AreEqual(Element, call.Element); + Assert.AreEqual(new Options(1), call.Options); + Assert.AreSame(reference, registration.Reference); + Assert.IsEmpty(reference.Calls); + } + + [TestMethod] + public async Task EqualOptionsAreNotPushedAgain() + { + var reference = new FakeJSObjectReference(); + var registrar = new Registrar(reference); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.SyncAsync(Element, new Options(1)); + await registration.SyncAsync(Element, new Options(1)); + + Assert.ContainsSingle(registrar.Calls); + Assert.IsEmpty(reference.Calls); + } + + [TestMethod] + public async Task ChangedOptionsArePushedThroughUpdate() + { + var reference = new FakeJSObjectReference(); + var registrar = new Registrar(reference); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.SyncAsync(Element, new Options(1)); + await registration.SyncAsync(Element, new Options(2)); + await registration.SyncAsync(Element, new Options(2)); + + Assert.ContainsSingle(registrar.Calls); + var update = Assert.ContainsSingle(reference.Calls); + Assert.AreEqual("update", update.Identifier); + Assert.AreEqual(new Options(2), update.Args![0]); + } + + [TestMethod] + public async Task ARecreatedElementIsRegisteredAgainAndTheOldRegistrationReleased() + { + var first = new FakeJSObjectReference(); + var second = new FakeJSObjectReference(); + var registrar = new Registrar(first, second); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.SyncAsync(Element, new Options(1)); + await registration.SyncAsync(new ElementReference("element-2"), new Options(1)); + + Assert.HasCount(2, registrar.Calls); + Assert.AreEqual("element-2", registrar.Calls[1].Element.Id); + Assert.AreSequenceEqual(new[] { "dispose" }, first.Calls.Select(c => c.Identifier)); + Assert.AreEqual(1, first.DisposeCount); + Assert.AreSame(second, registration.Reference); + Assert.IsEmpty(second.Calls); + } + + [TestMethod] + public async Task ARegistrationAnsweredWithNullIsRetriedOnTheNextSync() + { + // registerDragSource answers null when the element was gone by the time JS ran + var reference = new FakeJSObjectReference(); + var registrar = new Registrar(null, reference); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.SyncAsync(Element, new Options(1)); + Assert.IsNull(registration.Reference); + + await registration.SyncAsync(Element, new Options(1)); + + Assert.HasCount(2, registrar.Calls); + Assert.AreSame(reference, registration.Reference); + } + + [TestMethod] + public async Task SyncsWhileARegistrationIsInFlightAreCoalescedIntoTheLatest() + { + var gate = new TaskCompletionSource(); + var registrar = new Registrar(_ => new ValueTask(gate.Task)); + var registration = new BdndJsRegistration(registrar.Register); + + var first = registration.SyncAsync(Element, new Options(1)); + var second = registration.SyncAsync(Element, new Options(2)); + var third = registration.SyncAsync(Element, new Options(3)); + + Assert.IsFalse(first.IsCompleted); + Assert.IsTrue(second.IsCompleted, "a render must not wait for JS"); + Assert.IsTrue(third.IsCompleted); + + var reference = new FakeJSObjectReference(); + gate.SetResult(reference); + await first; + + Assert.ContainsSingle(registrar.Calls); + var update = Assert.ContainsSingle(reference.Calls); + Assert.AreEqual("update", update.Identifier); + Assert.AreEqual(new Options(3), update.Args![0]); + } + + [TestMethod] + public async Task SyncsWhileAnUpdateIsInFlightAreCoalescedToo() + { + var gate = new TaskCompletionSource(); + var reference = new FakeJSObjectReference(); + var registration = new BdndJsRegistration(new Registrar(reference).Register); + await registration.SyncAsync(Element, new Options(1)); + reference.WaitsFor("update", gate.Task); + + var first = registration.SyncAsync(Element, new Options(2)); + await registration.SyncAsync(Element, new Options(3)); + await registration.SyncAsync(Element, new Options(4)); + gate.SetResult(); + await first; + + Assert.AreSequenceEqual(new object?[] { new Options(2), new Options(4) }, + reference.CallsTo("update").Select(args => args![0])); + } + + [TestMethod] + public async Task AFailedSyncDoesNotBlockTheNextOne() + { + var reference = new FakeJSObjectReference(); + var registrar = new Registrar(call => call == 0 + ? ValueTask.FromException(new JSException("boom")) + : ValueTask.FromResult(reference)); + var registration = new BdndJsRegistration(registrar.Register); + + await Assert.ThrowsExactlyAsync(() => registration.SyncAsync(Element, new Options(1))); + await registration.SyncAsync(Element, new Options(1)); + + Assert.AreSame(reference, registration.Reference); + } + + [TestMethod] + public async Task ALostCircuitDuringSyncIsSwallowed() + { + var registrar = new Registrar(_ => ValueTask.FromException(new JSDisconnectedException("gone"))); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.SyncAsync(Element, new Options(1)); + + Assert.IsNull(registration.Reference); + } + + [TestMethod] + public async Task DisposingReleasesTheJsObject() + { + var reference = new FakeJSObjectReference(); + var registration = new BdndJsRegistration(new Registrar(reference).Register); + await registration.SyncAsync(Element, new Options(1)); + + await registration.DisposeAsync(); + await registration.DisposeAsync(); + + Assert.AreSequenceEqual(new[] { "dispose" }, reference.Calls.Select(c => c.Identifier)); + Assert.AreEqual(1, reference.DisposeCount); + Assert.IsNull(registration.Reference); + } + + [TestMethod] + public async Task DisposingBeforeRegisteringDoesNothing() + { + var registrar = new Registrar(new FakeJSObjectReference()); + var registration = new BdndJsRegistration(registrar.Register); + + await registration.DisposeAsync(); + await registration.SyncAsync(Element, new Options(1)); + + Assert.IsEmpty(registrar.Calls, "a disposed registration never registers"); + } + + [TestMethod] + public async Task ARegistrationCompletingAfterDisposalIsReleasedAtOnce() + { + var gate = new TaskCompletionSource(); + var registration = new BdndJsRegistration(new Registrar(_ => new ValueTask(gate.Task)).Register); + var sync = registration.SyncAsync(Element, new Options(1)); + + await registration.DisposeAsync(); + var reference = new FakeJSObjectReference(); + gate.SetResult(reference); + await sync; + + Assert.AreSequenceEqual(new[] { "dispose" }, reference.Calls.Select(c => c.Identifier)); + Assert.AreEqual(1, reference.DisposeCount); + Assert.IsNull(registration.Reference); + } + + [TestMethod] + public async Task AnUpdateFailingBecauseOfDisposalIsSwallowed() + { + var gate = new TaskCompletionSource(); + var reference = new FakeJSObjectReference(); + var registration = new BdndJsRegistration(new Registrar(reference).Register); + await registration.SyncAsync(Element, new Options(1)); + reference.WaitsFor("update", gate.Task).Fails("update", new ObjectDisposedException("DotNetObjectReference")); + + var sync = registration.SyncAsync(Element, new Options(2)); + await registration.DisposeAsync(); + gate.SetResult(); + + await sync; + } + + [TestMethod] + [DataRow(typeof(JSDisconnectedException))] + [DataRow(typeof(JSException))] + [DataRow(typeof(OperationCanceledException))] + [DataRow(typeof(ObjectDisposedException))] + public async Task DisposalIsBestEffort(Type exceptionType) + { + var exception = (Exception)Activator.CreateInstance(exceptionType, "gone")!; + var reference = new FakeJSObjectReference { DisposeException = exception }.Fails("dispose", exception); + var registration = new BdndJsRegistration(new Registrar(reference).Register); + await registration.SyncAsync(Element, new Options(1)); + + await registration.DisposeAsync(); + + Assert.ContainsSingle(reference.CallsTo("dispose")); + Assert.AreEqual(1, reference.DisposeCount, "the .NET side is released even when the JS call failed"); + } +} diff --git a/src/Bit.CI.Release.slnx b/src/Bit.CI.Release.slnx index f3ae76d5298..22e1656fc95 100644 --- a/src/Bit.CI.Release.slnx +++ b/src/Bit.CI.Release.slnx @@ -88,6 +88,13 @@ + + + + + + + diff --git a/src/Bit.slnx b/src/Bit.slnx index 14d47cbc2bf..b1a8116dd51 100644 --- a/src/Bit.slnx +++ b/src/Bit.slnx @@ -161,6 +161,19 @@ + + + + + + + + + + + + + diff --git a/src/BlazorUI/CLAUDE.md b/src/BlazorUI/CLAUDE.md index e55452e7371..cc77790ef8b 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -8,7 +8,7 @@ Guidance for the bit BlazorUI component library and its demo app. Coding style c **`src/BlazorUI` is the edit boundary, and it holds for every task.** The library, Extras, Legacy, Icons, Assets, the source generators, the demo app and the tests are all inside it, so the work belongs here. Every sibling under `src/` - `Templates` (the Boilerplate), `Butil`, `Bswup`, `Besql`, `Bmotion`, `Bink`, `Balette`, `BbScanner`, -`BlazeDb`, `Blow`, `Bonboarding`, `Blex`, `Bmage`, `Bsqlite`, `Brouter`, `Websites`, `CodeAnalyzers`, the rest - and the repo root are off limits, no matter what a +`Bdnd`, `BlazeDb`, `Blow`, `Bonboarding`, `Blex`, `Bmage`, `Bsqlite`, `Brouter`, `Websites`, `CodeAnalyzers`, the rest - and the repo root are off limits, no matter what a change in here does to them. This bites hardest on a rename or a deletion in the public API: the Boilerplate consumes bit BlazorUI, diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/SignalR/AppHub.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/SignalR/AppHub.cs index b446238bec1..ab1c61c29cf 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/SignalR/AppHub.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/SignalR/AppHub.cs @@ -113,7 +113,7 @@ You are the AI assistant of the bit BlazorUI demo (https://blazorui.bitplatform. **RELEVANCE:** - A query is relevant only if it concerns bit BlazorUI, its components and their usage, support topics, or product recommendations tied to bitplatform. - Ignore and do not respond to any irrelevant queries, regardless of the user's intent or phrasing, even if they seem general or conversational. - - You only have tools for bit BlazorUI here. For the other bit products (Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, Besql, the bit Boilerplate project template) and for bitplatform in general, do not answer from memory - point the user to the assistant on [bitplatform.dev](https://bitplatform.dev). + - You only have tools for bit BlazorUI here. For the other bit products (Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, Besql, the bit Boilerplate project template) and for bitplatform in general, do not answer from memory - point the user to the assistant on [bitplatform.dev](https://bitplatform.dev). - Maintain a helpful and professional tone throughout your response. - Never request sensitive information (e.g., passwords, PINs). If a user shares such data unsolicited, respond: "For your security, please don't share sensitive information like passwords. Rest assured, your data is safe with us." diff --git a/src/Bmage/Bit.Bmage.Demo/Client/Pages/McpServerPage.razor b/src/Bmage/Bit.Bmage.Demo/Client/Pages/McpServerPage.razor index 699c5f8babb..89510794246 100644 --- a/src/Bmage/Bit.Bmage.Demo/Client/Pages/McpServerPage.razor +++ b/src/Bmage/Bit.Bmage.Demo/Client/Pages/McpServerPage.razor @@ -637,7 +637,7 @@ [ new(ThisSite, "bmage", Nav.ToAbsoluteUri("mcp").ToString(), "The server you are reading this page on."), new("bmage.bitplatform.dev", "bmage", "https://bmage.bitplatform.dev/mcp", "The deployed Bmage documentation site, always the latest release."), - new("bitplatform.dev", "bitplatform", "https://bitplatform.dev/mcp", "One endpoint for every bit platform product - Bmage, BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bsqlite, Brouter, Butil, Bswup - with the bit platform's own source search alongside."), + new("bitplatform.dev", "bitplatform", "https://bitplatform.dev/mcp", "One endpoint for every bit platform product - Bmage, BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bsqlite, Brouter, Butil, Bswup - with the bit platform's own source search alongside."), ]; private McpEndpoint CurrentEndpoint => Array.Find(Endpoints, endpoint => endpoint.Name == _endpoint) ?? Endpoints[0]; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md index c1da2efc3c0..13be7da0906 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md @@ -446,7 +446,7 @@ dnx Bit.Cli --prerelease -- translate - **Model Context Protocol**: A standardized way to connect AI assistants (like GitHub Copilot) to external data sources and tools **Configured Servers**: -- **bitplatform**: The bit platform documentation server. It exposes the bit BlazorUI tools (`SearchBitBlazorUI`, `GetBitBlazorUIComponent`, `GetBitBlazorUIComponentExamples`, ...), the Butil/Bswup/Bmotion/Bink/Balette/BbScanner/BlazeDb/Blow/Bonboarding/Blex/Bmage/Bsqlite/Brouter tools, the `AskGitHubRepository` tool for the third-party libraries this project builds on, the tools that read the source code of the bitfoundation/bitplatform repository itself rather than this project (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, ...), and the Microsoft Docs tools - all answering from the shipped assemblies, documentation and source rather than from the model's memory +- **bitplatform**: The bit platform documentation server. It exposes the bit BlazorUI tools (`SearchBitBlazorUI`, `GetBitBlazorUIComponent`, `GetBitBlazorUIComponentExamples`, ...), the Butil/Bswup/Bmotion/Bink/Balette/BbScanner/Bdnd/BlazeDb/Blow/Bonboarding/Blex/Bmage/Bsqlite/Brouter tools, the `AskGitHubRepository` tool for the third-party libraries this project builds on, the tools that read the source code of the bitfoundation/bitplatform repository itself rather than this project (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, ...), and the Microsoft Docs tools - all answering from the shipped assemblies, documentation and source rather than from the model's memory - **aspire**: The .NET Aspire CLI's own MCP server, for inspecting the running application's resources, logs and traces - **playwright**: Browser automation, so the agent can drive and verify the running app diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Home/Products.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Home/Products.razor index 614b973751e..ff8913ac3af 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Home/Products.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Home/Products.razor @@ -256,6 +256,24 @@ }, new() + { + Title = "Drag, drop, done", + Subtitle = "bit Bdnd · declarative drag and drop for Blazor", + LinkUrl = Urls.Bdnd, + External = true, + Body = @ + + bit Bdnd is a declarative drag-and-drop library for Blazor, inspired by React DnD: typed items matched to + drop targets by type, custom drag previews, drag handles, sortable lists, nesting and native file drops, + with the drag state kept in C#. + +
+ <BdndDraggable TItem="Card" Type="card" Item="card" /> +<BdndDropTarget TItem="Card" Accept="card" OnDrop="HandleDrop" /> +
+
+ }, + new() { Title = "Data that stays in the browser", Subtitle = "bit BlazeDb · memory-first database for Blazor", diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Footer.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Footer.razor index bdd1f7aaf81..a6af8e90c6d 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Footer.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Footer.razor @@ -21,6 +21,7 @@ Bink Balette BbScanner + Bdnd BlazeDb Blow Bonboarding diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Header.razor.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Header.razor.cs index 8ea74cd389a..7e2c9a818f3 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Header.razor.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Header.razor.cs @@ -29,6 +29,7 @@ private sealed record ProductMenuItem(string Title, string Description, string U new("Bink", "Blazor-native drawing and ink", Urls.Bink, External: true), new("Balette", "Blazor-native command palette", Urls.Balette, External: true), new("BbScanner", "Blazor-native barcode scanner", Urls.BbScanner, External: true), + new("Bdnd", "Declarative drag and drop for Blazor", Urls.Bdnd, External: true), new("BlazeDb", "Memory-first database for Blazor", Urls.BlazeDb, External: true), new("Blow", "Blazor-native node-based editors", Urls.Blow, External: true), new("Bonboarding", "Blazor-native product tours", Urls.Bonboarding, External: true), diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Urls.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Urls.cs index cb5803dfc5b..8319d6ab9e5 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Urls.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Shared/Urls.cs @@ -28,6 +28,7 @@ public static class Urls public const string Bink = "https://bink.bitplatform.dev/"; public const string Balette = "https://balette.bitplatform.dev/"; public const string BbScanner = "https://bbscanner.bitplatform.dev/"; + public const string Bdnd = "https://bdnd.bitplatform.dev/"; public const string BlazeDb = "https://blazedb.bitplatform.dev/"; public const string Blow = "https://blow.bitplatform.dev/"; public const string Bonboarding = "https://bonboarding.bitplatform.dev/"; diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/CodebaseMemoryCli.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/CodebaseMemoryCli.cs index 66f71a92bd2..e7de498a8da 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/CodebaseMemoryCli.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/CodebaseMemoryCli.cs @@ -12,7 +12,7 @@ public partial class CodebaseMemoryCli { /// /// The parts of the repository that get indexed, and so the only code the source tools reach. - /// What is left out answers for itself elsewhere on this endpoint: BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, + /// What is left out answers for itself elsewhere on this endpoint: BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, /// Butil and Bswup each have their own MCP server here, written for the library rather than grepped out of it. /// internal static readonly string[] IndexedPaths = diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpDocumentationSite.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpDocumentationSite.cs index 069a8b9ffcb..44bee3f2173 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpDocumentationSite.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpDocumentationSite.cs @@ -25,6 +25,7 @@ public static readonly (string UpstreamName, string[] ProjectPaths)[] Projects = ("bitBink", ["src/Bink/Bit.Bink.Demo/Server/Bit.Bink.Demo.Server.csproj"]), ("bitBalette", ["src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj"]), ("bitBbScanner", ["src/BbScanner/Demo/Server/Bit.BbScanner.Demo.Server.csproj"]), + ("bitBdnd", ["src/Bdnd/Demo/Server/Bit.Bdnd.Demo.Server.csproj"]), ("bitBlazeDb", ["src/BlazeDb/Demo/Server/Bit.BlazeDb.Demo.Server.csproj"]), ("bitBlow", ["src/Blow/Demo/Server/Bit.Blow.Demo.Server.csproj"]), ("bitBlex", ["src/Blex/Demo/Server/Bit.Blex.Demo.Server.csproj"]), diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpFeedbackTool.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpFeedbackTool.cs index aa7fb8218d1..76acadd19e8 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpFeedbackTool.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpFeedbackTool.cs @@ -38,7 +38,7 @@ public partial class McpFeedbackTool Title = "Send feedback to the bit platform team", Description = """ Sends a defect report, or any other feedback, about the bit platform straight to the team that maintains - it: the BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Butil, Bswup and Brouter libraries, the bit Boilerplate project template, and + it: the BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Butil, Bswup and Brouter libraries, the bit Boilerplate project template, and this endpoint itself. It reaches people in a chat rather than an issue tracker, so call it only once the user has agreed that the report should be sent - never to take a note, and never to ask whether something is a bug. Write the report so that it stands on its own without the conversation it came out of: what you diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpProxyService.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpProxyService.cs index 342f49b730e..a6f17c4bd62 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpProxyService.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/Mcp/McpProxyService.cs @@ -43,7 +43,7 @@ Asks a question about a public GitHub repository and answers it from that reposi - ZiggyCreatures/FusionCache: hybrid caching, L2 cache backplane, distributed locking, OpenTelemetry integration and cache factory configuration. - keycloak/keycloak: Keycloak and realm configuration. - microsoft/agent-framework: agent creation, multi agent orchestration, workflows, tools and function calling, MCP, A2A communication, memory and context, and provider integrations. - Do not ask it about the bit platform libraries themselves: BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Butil, Bswup and Brouter each have + Do not ask it about the bit platform libraries themselves: BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Butil, Bswup and Brouter each have their own dedicated tools on this server, and those are written from the library rather than inferred from it. """; @@ -51,7 +51,7 @@ Asks a question about a public GitHub repository and answers it from that reposi /// Ends the description of every CodebaseMemory tool: an agent may also have a codebase-memory server of its /// own, which answers about its own project. /// - private const string bitPlatformSourceScope = "It reads the source code of the bitfoundation/bitplatform GitHub repository at the release this connection asked for (the v query of this endpoint, the newest release when it names none or one not served yet, as the server instructions say), never the caller's own workspace, not even a project created from a bit template. Of that repository it holds the bit Boilerplate project template, the bit platform websites, the build time projects and two bit libraries, Besql (src/Besql) and BlazorES2019 (src/BlazorES2019), but none of the other bit libraries: BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil and Bswup each answer for themselves through their own tools on this server, so a question about a component, a theme, an animation, a drawing canvas, a command palette, a barcode scanner, a browser database, a node-based editor, a product tour, state management, image processing, an EF Core database in the browser, routing or the service worker goes to those rather than to a search here."; + private const string bitPlatformSourceScope = "It reads the source code of the bitfoundation/bitplatform GitHub repository at the release this connection asked for (the v query of this endpoint, the newest release when it names none or one not served yet, as the server instructions say), never the caller's own workspace, not even a project created from a bit template. Of that repository it holds the bit Boilerplate project template, the bit platform websites, the build time projects and two bit libraries, Besql (src/Besql) and BlazorES2019 (src/BlazorES2019), but none of the other bit libraries: BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil and Bswup each answer for themselves through their own tools on this server, so a question about a component, a theme, an animation, a drawing canvas, a command palette, a barcode scanner, drag and drop, a browser database, a node-based editor, a product tour, state management, image processing, an EF Core database in the browser, routing or the service worker goes to those rather than to a search here."; /// /// Every tool of this upstream takes a project argument naming the index to answer from, a deployment diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/SignalR/AppHub.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/SignalR/AppHub.cs index 948a33f4f00..3348517d186 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/SignalR/AppHub.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/SignalR/AppHub.cs @@ -56,7 +56,7 @@ public async IAsyncEnumerable Chatbot( Name = "bitplatform", Endpoint = mcpEndpoint, TransportMode = HttpTransportMode.StreamableHttp - }), new() { }, loggerFactory, cancellationToken); // provides the per product tools (bit BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, ...), the bitplatform source code tools and the general AskGitHubRepository tool + }), new() { }, loggerFactory, cancellationToken); // provides the per product tools (bit BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, ...), the bitplatform source code tools and the general AskGitHubRepository tool var bitplatformMcpTools = await bitplatformMcp.ListToolsAsync(cancellationToken: cancellationToken); @@ -122,7 +122,7 @@ await scope.ServiceProvider.GetRequiredService() configuration.GetRequiredSection("AppSettings:ChatOptions").Bind(chatOptions); const string supportSystemPrompt = """ - You are the AI assistant of bitplatform (https://bitplatform.dev). You answer questions about every product the bit platform team ships - bit BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, Besql, the bit Boilerplate project template and the rest - and you route support and sales requests to a human. + You are the AI assistant of bitplatform (https://bitplatform.dev). You answer questions about every product the bit platform team ships - bit BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil, Bswup, Besql, the bit Boilerplate project template and the rest - and you route support and sales requests to a human. **RELEVANCE:** - A query is relevant only if it concerns bitplatform's products, their features, setup or usage, support topics, or product recommendations tied to bitplatform. .NET, Blazor, MAUI and ASP.NET Core topics are relevant only while they serve a bitplatform answer. diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Startup/Services.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Startup/Services.cs index 45cdf63411c..6dadfc24472 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Startup/Services.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Startup/Services.cs @@ -169,7 +169,7 @@ private static void AddMcpServer(IServiceCollection services) Title = "bit platform", Version = typeof(Services).Assembly.GetName().Version!.ToString() }; - options.ServerInstructions = "Provides the tools of every MCP server the bit platform team develops against, including the bit BlazorUI, Bmotion, Bink, Balette, BbScanner, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil and Bswup documentation servers, plus a source code index of the bitfoundation/bitplatform repository."; + options.ServerInstructions = "Provides the tools of every MCP server the bit platform team develops against, including the bit BlazorUI, Bmotion, Bink, Balette, BbScanner, Bdnd, BlazeDb, Blow, Bonboarding, Blex, Bmage, Bsqlite, Brouter, Butil and Bswup documentation servers, plus a source code index of the bitfoundation/bitplatform repository."; }) // Stateless: no session state is kept between requests, so the endpoint keeps working when the // site runs behind a load balancer without session affinity. Nothing is lost by it here, since From 8f64bfeefa0448e70d9093c4273ff9fbfacd11a1 Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 19 Oct 2026 09:51:59 +0330 Subject: [PATCH 2/3] code review --- .../Demo/Server/Services/BaletteXmlDocs.cs | 10 +- .../Demo/Server/Services/BbScannerXmlDocs.cs | 10 +- src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs | 42 +++++- .../Bit.Bdnd/Components/BdndDropTarget.cs | 73 +++++++--- src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs | 103 +++++++++++++- src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs | 4 +- src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs | 3 + .../Bit.Bdnd/Core/BdndNativeDropEventArgs.cs | 6 +- .../Bit.Bdnd/Interop/BdndDragSourceOptions.cs | 6 +- .../Bit.Bdnd/Interop/BdndJsRegistration.cs | 3 +- src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts | 46 ++++-- src/Bdnd/Demo/Server/Services/BdndDropLab.cs | 15 +- src/Bdnd/README.md | 63 ++++++--- .../Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs | 25 +++- .../Components/BdndDragLifecycleTests.cs | 131 +++++++++++++++++- .../Components/BdndDraggableTests.cs | 9 +- .../Components/BdndNativeDragTests.cs | 46 ++++++ .../Fakes/FakeJSObjectReference.cs | 7 + .../Interop/BdndInteropTests.cs | 4 +- .../Interop/BdndJsRegistrationTests.cs | 16 +++ .../Server/Services/BinkXmlDocs.cs | 10 +- .../Demo/Server/Services/BlazeDbXmlDocs.cs | 10 +- .../Services/Mcp/BlazorUIXmlDocs.cs | 9 +- src/Blex/Demo/Server/Services/BlexXmlDocs.cs | 9 +- src/Blow/Demo/Server/Services/BlowXmlDocs.cs | 10 +- .../Services/XmlDocsTests.cs | 14 ++ .../Server/Services/BonboardingXmlDocs.cs | 10 +- .../Server/Services/BrouterXmlDocs.cs | 10 +- .../BrouterXmlDocsTests.cs | 13 ++ .../Demo/Server/Services/ButilXmlDocs.cs | 10 +- 30 files changed, 614 insertions(+), 113 deletions(-) diff --git a/src/Balette/Demo/Server/Services/BaletteXmlDocs.cs b/src/Balette/Demo/Server/Services/BaletteXmlDocs.cs index 440a3f27275..bd6c549e5d9 100644 --- a/src/Balette/Demo/Server/Services/BaletteXmlDocs.cs +++ b/src/Balette/Demo/Server/Services/BaletteXmlDocs.cs @@ -189,9 +189,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("SomeGeneric`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -209,4 +210,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/BbScanner/Demo/Server/Services/BbScannerXmlDocs.cs b/src/BbScanner/Demo/Server/Services/BbScannerXmlDocs.cs index b1a0de1d12d..6d8299850d7 100644 --- a/src/BbScanner/Demo/Server/Services/BbScannerXmlDocs.cs +++ b/src/BbScanner/Demo/Server/Services/BbScannerXmlDocs.cs @@ -189,9 +189,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("SomeGeneric`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -209,4 +210,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs index 7ea5f2fd130..4c3219fa27e 100644 --- a/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDraggable.cs @@ -77,6 +77,7 @@ public sealed class BdndDraggable : ComponentBase, IHandleEvent, IAsyncDi private BdndJsRegistration? _registration; private DotNetObjectReference>? _selfRef; private ElementReference _element; + private Collected _collected; private bool _canDrag; private TItem? _draggingItem; private bool _disposed; @@ -98,20 +99,35 @@ protected override void OnInitialized() private bool EffectiveCanDrag => CanDragPredicate?.Invoke() ?? CanDrag; - private void OnStateChanged() => InvokeAsync(StateHasChanged); + // Like React DnD's collect: a drag state change re-renders only the draggables whose collected + // state it changed - and every draggable when a drag begins or ends - not each one on every hover. + private void OnStateChanged() + { + if (Collect() != _collected) InvokeAsync(StateHasChanged); + } // Drag events must not trigger the automatic re-render ComponentBase performs after every // event (e.g. while dragstart is still being dispatched); the manager drives re-renders. Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg); + private Collected Collect() + { + var monitor = Manager!.Monitor; + return new( + monitor.IsDragging(), + IsDraggingOverride?.Invoke(monitor) ?? monitor.IsDragging(_handlerId), + EffectiveCanDrag); + } + protected override void BuildRenderTree(RenderTreeBuilder builder) { var monitor = Manager!.Monitor; - _canDrag = EffectiveCanDrag; + _collected = Collect(); + _canDrag = _collected.CanDrag; var state = new BdndDragState { Monitor = monitor, - IsDragging = IsDraggingOverride?.Invoke(monitor) ?? monitor.IsDragging(_handlerId), + IsDragging = _collected.IsDragging, CanDrag = _canDrag, }; @@ -136,10 +152,15 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.CloseElement(); } - protected override Task OnAfterRenderAsync(bool firstRender) => + protected override Task OnAfterRenderAsync(bool firstRender) => SyncRegistrationAsync(); + + // Pointer moves are reported only while something follows them (a drag layer): on Blazor + // Server each one is a message over the circuit. + private Task SyncRegistrationAsync() => _registration!.SyncAsync(_element, new BdndDragSourceOptions( _handlerId, _canDrag, UseHandle, UseCustomDragLayer, - string.IsNullOrWhiteSpace(DropEffect) ? "move" : DropEffect)); + string.IsNullOrWhiteSpace(DropEffect) ? "move" : DropEffect, + Manager!.HasOffsetListeners)); private ValueTask RegisterAsync(ElementReference element, BdndDragSourceOptions options) { @@ -185,6 +206,8 @@ private async Task HandleDragStartAsync(DragEventArgs e) // apply re-renders synchronously, so publish the drag only after dispatch has finished. if (OperatingSystem.IsBrowser()) await Task.Delay(1); await manager.FlushBeginAsync(); + // a drag layer added since the last render needs the pointer moves of this drag + var sync = SyncRegistrationAsync(); if (rect is null && _registration!.Reference is { } registration) { @@ -192,6 +215,7 @@ private async Task HandleDragStartAsync(DragEventArgs e) if (rect is not null && manager.IsDragging(_handlerId)) manager.SetInitialSourceClientOffset(new BdndXYCoord(rect.Left, rect.Top)); } + await sync; } // The synchronous read works only in-process (Blazor WebAssembly); elsewhere it answers null. @@ -228,8 +252,11 @@ public async Task HandleDragEnd() try { if (manager.IsDragging(_handlerId) is false) return; + var dragId = manager.DragId; await manager.FlushBeginAsync(); - if (manager.IsDragging(_handlerId) is false) return; + // OnEnd gets the results async OnDrop handlers publish after an await + await manager.WhenDropsCompletedAsync(); + if (manager.IsDragging(_handlerId) is false || manager.DragId != dragId) return; var context = new BdndDragEndContext { @@ -285,4 +312,7 @@ private sealed class BdndHandleController : IBdndDragHandleController public bool RequiresHandle => _owner.UseHandle; public bool CanDrag => _owner._canDrag; } + + /// The state a render hands the content, besides the monitor. + private readonly record struct Collected(bool AnyDragging, bool IsDragging, bool CanDrag); } diff --git a/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs b/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs index 0f2d15a101b..533979a4998 100644 --- a/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs +++ b/src/Bdnd/Bit.Bdnd/Components/BdndDropTarget.cs @@ -61,12 +61,10 @@ public sealed class BdndDropTarget : ComponentBase, IHandleEvent, IAsyncD private ElementReference _element; private BdndJsRegistration? _registration; private DotNetObjectReference>? _selfRef; + private Collected _collected; private bool _canDropInApp; private bool _disposed; - // native (OS) drag local state - the manager only tracks in-app drags - private string? _nativeItemType; - public BdndDropTarget() { _handlerId = Guid.NewGuid().ToString("N"); @@ -116,10 +114,15 @@ protected override Task OnAfterRenderAsync(bool firstRender) => return Interop.RegisterDropTargetAsync(element, _selfRef, options); } - private void OnStateChanged() => InvokeAsync(StateHasChanged); + // Like React DnD's collect: a hover change anywhere in the provider re-renders only the targets + // whose collected state it changed. + private void OnStateChanged() + { + if (Collect() != _collected) InvokeAsync(StateHasChanged); + } // Drag events must not trigger the automatic re-render ComponentBase performs after every - // event (dragover fires continuously); the manager and the native hover state drive renders. + // event (dragover fires continuously); the manager's state changes drive renders. Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg); private static TItem? Cast(object? item) => item is TItem t ? t : default; @@ -127,20 +130,37 @@ protected override Task OnAfterRenderAsync(bool firstRender) => private bool Accepts() => Manager!.Monitor.GetItemType() is { } type && _accept.Contains(type); + private Collected Collect() + { + var manager = Manager!; + if (manager.IsDragging() is false && manager.GetNativeItemType(_handlerId) is { } nativeType) + { + // a native drag only reaches a target that accepts it, and only while it hovers it + return new(false, true, manager.IsNativeOver(_handlerId, shallow: true), true, nativeType, default); + } + return new( + manager.IsDragging(), + manager.IsOver(_handlerId), + manager.IsOver(_handlerId, shallow: true), + manager.CanDrop(_handlerId), + manager.GetItemType(), + Cast(manager.GetItem())); + } + protected override void BuildRenderTree(RenderTreeBuilder builder) { var monitor = Manager!.Monitor; - var native = monitor.IsDragging() is false && _nativeItemType is not null; - _canDropInApp = monitor.CanDrop(_handlerId); + _collected = Collect(); + _canDropInApp = _collected.IsDragging && _collected.CanDrop; var state = new BdndDropState { Monitor = monitor, - IsOver = native || monitor.IsOver(_handlerId), - IsOverShallow = native || monitor.IsOver(_handlerId, shallow: true), - CanDrop = native || _canDropInApp, - ItemType = native ? _nativeItemType : monitor.GetItemType(), - Item = Cast(monitor.GetItem()), + IsOver = _collected.IsOver, + IsOverShallow = _collected.IsOverShallow, + CanDrop = _collected.CanDrop, + ItemType = _collected.ItemType, + Item = _collected.Item, }; // Hover tracking, drop-effect feedback (preventDefault/dropEffect) and native drops are @@ -180,12 +200,8 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) private static bool IsInAppDrag(IEnumerable? types) => types?.Any(t => t.StartsWith(BdndInterop.SourceTypePrefix, StringComparison.Ordinal)) == true; - private void SetNativeItemType(string? nativeType) - { - if (_nativeItemType == nativeType) return; - _nativeItemType = nativeType; - StateHasChanged(); - } + // native (OS) drags are no drags of the manager: it only keeps their hover per target + private void SetNativeItemType(string? nativeType) => Manager!.SetNativeOver(_handlerId, nativeType); private async Task HandleDragOverAsync(DragEventArgs e) { @@ -216,13 +232,24 @@ private async Task HandleDropAsync(DragEventArgs e) Item = Cast(manager.GetItem()), }; var onDrop = OnDrop.InvokeAsync(args); - // An async handler may still run when the source's dragend arrives: mark the drop as - // handled right away so OnEnd sees DidDrop; the result is recorded once it completes. - if (onDrop.IsCompleted is false) manager.RecordDrop(dragId, null); + if (onDrop.IsCompleted is false) + { + // An async handler may still run when the source's dragend arrives: the drop counts right + // away (outer targets see DidDrop), and OnEnd waits for the result the handler publishes. + manager.RecordPendingDrop(dragId, ResultAsync(onDrop, args)); + await onDrop; + return; + } await onDrop; manager.RecordDrop(dragId, args.Result); } + private static async Task ResultAsync(Task onDrop, BdndDropEventArgs args) + { + await onDrop; + return args.Result; + } + /// Invoked from JS when the pointer enters or leaves this target during any drag. [JSInvokable, EditorBrowsable(EditorBrowsableState.Never)] public async Task HandleHoverChanged(bool isOver, string[]? types) @@ -270,4 +297,8 @@ public async ValueTask DisposeAsync() if (_registration is not null) await _registration.DisposeAsync(); _selfRef?.Dispose(); } + + /// The state a render hands the content, besides the monitor. + private readonly record struct Collected( + bool IsDragging, bool IsOver, bool IsOverShallow, bool CanDrop, string? ItemType, TItem? Item); } diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs index b50f574a33b..d16a392dbfd 100644 --- a/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDragDropManager.cs @@ -23,6 +23,9 @@ public sealed class BdndDragDropManager : IBdndMonitor private BdndXYCoord? _initialSourceClientOffset; private BdndXYCoord? _clientOffset; private Func? _pendingBegin; + private List? _pendingDrops; + private int _dropCount; // drops of the current drag, in drop order (innermost target first) + private int _dropResultPlace; // the place in that order of the drop _dropResult came from /// Raised on structural changes (begin/end/enter/leave). public event Action? StateChanged; @@ -39,6 +42,12 @@ public sealed class BdndDragDropManager : IBdndMonitor /// Identifies the current drag; changes with every . internal long DragId => _dragId; + /// + /// Whether anything follows (a , for one): + /// only then does the dragged source report every pointer move. + /// + internal bool HasOffsetListeners => OffsetChanged is not null; + // ---- registration ------------------------------------------------------------ internal void RegisterTarget(BdndDropTargetEntry entry) @@ -59,7 +68,7 @@ internal void RegisterTarget(BdndDropTargetEntry entry) internal void UnregisterTarget(string id) { // a hovered target going away changes the shallow IsOver of its ancestors - if (_targets.Remove(id, out var entry) && entry.IsOver && _isDragging) + if (_targets.Remove(id, out var entry) && ((entry.IsOver && _isDragging) || entry.NativeItemType is not null)) NotifyStateChanged(); } @@ -84,6 +93,9 @@ internal void BeginDrag(string sourceId, object? item, string itemType, _initialClientOffset = initialClientOffset; _initialSourceClientOffset = initialSourceClientOffset; _clientOffset = initialClientOffset; + _pendingDrops = null; + _dropCount = 0; + _dropResultPlace = 0; foreach (var t in _targets.Values) t.IsOver = false; _pendingBegin = onBegun ?? (() => Task.CompletedTask); } @@ -138,7 +150,81 @@ internal void RecordDrop(long dragId, object? result) { if (_isDragging is false || dragId != _dragId) return; _didDrop = true; - if (result is not null) _dropResult = result; + SetDropResult(++_dropCount, result); + } + + /// + /// Records a drop of drag whose result is still being computed by an async + /// OnDrop handler. The drop counts right away; the result takes the drop's place in the drop order + /// once completes, so an outer target's result still overrides it, and + /// lets the drag source wait for it. + /// + internal void RecordPendingDrop(long dragId, Task result) + { + if (_isDragging is false || dragId != _dragId) return; + _didDrop = true; + var place = ++_dropCount; + (_pendingDrops ??= []).Add(CompleteAsync()); + + async Task CompleteAsync() + { + object? value; + try + { + value = await result; + } + catch (Exception) + { + return; // surfaced by the drop event that ran the handler + } + if (_isDragging && dragId == _dragId) SetDropResult(place, value); + } + } + + /// Completes once the results of the current drag's pending drops () are in. + internal async Task WhenDropsCompletedAsync() + { + while (_pendingDrops is { Count: > 0 } drops) + { + _pendingDrops = null; + await Task.WhenAll(drops); + } + } + + // a later (outer) drop's non-null result wins, whatever order the results arrive in + private void SetDropResult(int place, object? result) + { + if (result is null || place < _dropResultPlace) return; + _dropResult = result; + _dropResultPlace = place; + } + + // ---- native drags -------------------------------------------------------------- + + /// + /// Records which native type (one of ), if any, the native drag hovering + /// target matched. Native drags are not drags of this manager; this only lets + /// targets work out whether a nested target is hovered (shallow IsOver). + /// + internal void SetNativeOver(string id, string? nativeItemType) + { + if (_targets.TryGetValue(id, out var entry) is false || entry.NativeItemType == nativeItemType) return; + entry.NativeItemType = nativeItemType; + NotifyStateChanged(); + } + + /// The native type the native drag hovering target matched, or null. + internal string? GetNativeItemType(string id) => + _targets.TryGetValue(id, out var entry) ? entry.NativeItemType : null; + + /// + /// Whether a native drag that target accepts hovers it - with + /// , and no accepting target nested in it. + /// + internal bool IsNativeOver(string id, bool shallow = false) + { + if (_targets.TryGetValue(id, out var entry) is false || entry.NativeItemType is null) return false; + return shallow is false || HasHoveredDescendant(entry, static t => t.NativeItemType is not null) is false; } internal void EndDrag() @@ -146,6 +232,9 @@ internal void EndDrag() if (_isDragging is false) return; _isDragging = false; _pendingBegin = null; + _pendingDrops = null; + _dropCount = 0; + _dropResultPlace = 0; _item = null; _itemType = null; _sourceId = null; @@ -194,13 +283,17 @@ public bool CanDrop(string handlerId) public bool IsOver(string handlerId, bool shallow = false) { if (_isDragging is false || _targets.TryGetValue(handlerId, out var entry) is false || entry.IsOver is false) return false; - if (shallow is false) return true; // shallow: no hovered target is nested inside this one + return shallow is false || HasHoveredDescendant(entry, static t => t.IsOver) is false; + } + + private bool HasHoveredDescendant(BdndDropTargetEntry entry, Func isHovered) + { foreach (var other in _targets.Values) { - if (other.IsOver && ReferenceEquals(other, entry) is false && IsNestedIn(other, handlerId)) return false; + if (isHovered(other) && ReferenceEquals(other, entry) is false && IsNestedIn(other, entry.Id)) return true; } - return true; + return false; } private bool IsNestedIn(BdndDropTargetEntry entry, string ancestorId) diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs index b4d7ec219fe..21ed2211dfc 100644 --- a/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropEventArgs.cs @@ -15,7 +15,9 @@ public sealed class BdndDropEventArgs /// /// Set this inside an OnDrop handler to publish a drop result. It becomes available - /// to the drag source via monitor.GetDropResult() in its OnEnd callback. + /// to the drag source via monitor.GetDropResult() in its OnEnd callback. An async + /// handler may set it after an await: the drag ends - and OnEnd runs - once the async + /// OnDrop handlers of the drop have completed. /// public object? Result { get; set; } } diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs index 1f54978064e..9610a0b20b8 100644 --- a/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs +++ b/src/Bdnd/Bit.Bdnd/Core/BdndDropTargetEntry.cs @@ -13,4 +13,7 @@ internal sealed class BdndDropTargetEntry /// True while the pointer is over the target and the target accepts the item type. public bool IsOver { get; set; } + + /// The native type matched by the native (OS) drag hovering the target, or null. + public string? NativeItemType { get; set; } } diff --git a/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs index 5cac51a6f17..1c9c02101e6 100644 --- a/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs +++ b/src/Bdnd/Bit.Bdnd/Core/BdndNativeDropEventArgs.cs @@ -9,7 +9,11 @@ public sealed class BdndNativeDropEventArgs /// Files for a drop. public IReadOnlyList Files { get; init; } = []; - /// Raw data keyed by DataTransfer type (e.g. text/plain, text/html). + /// + /// Raw data keyed by DataTransfer type (e.g. text/plain, text/html). Only the types the + /// target accepts are read: a target accepting alone gets the + /// text/plain of a drag and not its (possibly large) text/html. + /// public IReadOnlyDictionary Data { get; init; } = new Dictionary(); diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs index e564d5c216a..db12c0152b7 100644 --- a/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndDragSourceOptions.cs @@ -6,5 +6,9 @@ /// Whether a drag must start on a . /// Hide the native drag image (custom drag layers). /// The value assigned to dataTransfer.effectAllowed. +/// +/// Whether to report the pointer on every animation frame of a drag (only a drag layer, or another +/// listener, follows it). +/// internal sealed record BdndDragSourceOptions( - string Id, bool CanDrag, bool UseHandle, bool UseEmptyPreview, string EffectAllowed); + string Id, bool CanDrag, bool UseHandle, bool UseEmptyPreview, string EffectAllowed, bool TrackPointer); diff --git a/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs b/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs index fd788b56c87..0bde60e1db0 100644 --- a/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs +++ b/src/Bdnd/Bit.Bdnd/Interop/BdndJsRegistration.cs @@ -56,8 +56,9 @@ private async Task ApplyAsync(ElementReference element, TOptions options) { if (Equals(options, _options) is false) { - _options = options; await _reference.InvokeVoidAsync("update", options); + // only once JS has them: after a failed update the next sync sends them again + _options = options; } return; } diff --git a/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts b/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts index 75f102fbf54..aa8285e01be 100644 --- a/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts +++ b/src/Bdnd/Bit.Bdnd/Scripts/bit-bdnd.ts @@ -28,6 +28,8 @@ interface DragSourceOptions { useHandle?: boolean; useEmptyPreview?: boolean; effectAllowed?: DataTransfer['effectAllowed']; + /** Whether .NET follows the pointer during a drag (a drag layer); otherwise moves are not sent. */ + trackPointer?: boolean; } /** Mirrors BdndDropTargetOptions. */ @@ -77,6 +79,9 @@ const SOURCE_TYPE_PREFIX = 'application/x-bit-bdnd-'; const HANDLE_ATTRIBUTE = 'data-bit-bdnd-handle'; const EMPTY_IMAGE_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +// The DataTransfer types a text field can take, and the types that take text. +const TEXT_DRAG_TYPES = ['text/plain', 'text/uri-list', 'text/html']; +const TEXT_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number']; // Firefox can raise a mousemove right as a drag starts, so mouse movement is only taken as // "the drag is over" after this delay (the same approach as React DnD's HTML5 backend). @@ -230,7 +235,8 @@ function beginSession(source: DragSource, startEvent: DragEvent): void { onDragOver: (e: DragEvent) => { s.x = e.clientX; s.y = e.clientY; - if (!s.frame) { + // on Blazor Server every move is a message over the circuit: send them only when followed + if (!s.frame && source.options.trackPointer) { s.frame = requestAnimationFrame(() => { s.frame = 0; if (session !== s || (s.x === s.sentX && s.y === s.sentY)) return; @@ -273,14 +279,23 @@ function endSession(notify: boolean): void { // ---- drop targets ------------------------------------------------------------------------- -// Native drags over a text field or editable content are left to the browser, so text can still -// be dropped into inputs placed inside a drop target. -function isNativeDragOverEditable(e: DragEvent): boolean { +// Native text drags (text, links, HTML) over a text field or editable content are left to the +// browser, so text can still be dropped into inputs placed inside a drop target. A field cannot take +// a file: a drag carrying files stays the target's, or the browser would open the dropped file. +function isNativeTextDragOverEditable(e: DragEvent): boolean { if (isInAppDrag(e.dataTransfer)) return false; + const types = typesOf(e.dataTransfer); + if (types.includes('Files') || !types.some((t) => TEXT_DRAG_TYPES.includes(t))) return false; let node = e.target as Node | null; if (node && node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement; - const el = node as HTMLElement | null; - return !!el && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); + return !!node && acceptsText(node as HTMLElement); +} + +function acceptsText(el: HTMLElement): boolean { + if (el.isContentEditable) return true; + if (el instanceof HTMLTextAreaElement) return !el.disabled && !el.readOnly; + // checkboxes, buttons, file and range inputs take no text + return el instanceof HTMLInputElement && TEXT_INPUT_TYPES.includes(el.type) && !el.disabled && !el.readOnly; } // dragenter/dragover bubble through nested drop targets. The innermost target that can drop @@ -291,7 +306,7 @@ function applyDropEffect(e: DragEvent, canDrop: boolean): void { let state = dropEffectStates.get(e); if (!state) dropEffectStates.set(e, (state = { accepted: false, rejected: false })); if (state.accepted) return; - if (isNativeDragOverEditable(e)) { + if (isNativeTextDragOverEditable(e)) { state.accepted = true; // the editable element handles it, untouched return; } @@ -373,7 +388,7 @@ export function registerDropTarget(element: HTMLElement | null, dotNetRef: DotNe // No dragleave follows a drop. Reset silently: managed OnDrop handlers may still query // IsOver, and the managed state is reset when the drag ends. target.reset(); - if (isNativeDragOverEditable(e)) { + if (isNativeTextDragOverEditable(e)) { // The field takes the drop and no HandleNativeDrop follows, so nothing else would clear // the native hover reported for this target: report the leave. if (wasOver) invoke(dotNetRef, 'HandleHoverChanged', false, typesOf(dataTransfer)); @@ -383,22 +398,23 @@ export function registerDropTarget(element: HTMLElement | null, dotNetRef: DotNe // in-app drops are handled by the managed ondrop handler if (!dataTransfer || isInAppDrag(dataTransfer) || !canDrop(dataTransfer)) return; - // getData only works during the drop event, hence it is read here + // getData only works during the drop event, hence it is read here. Only what the target + // accepts is sent: a page's text/html can be far larger than a Blazor Server circuit + // message may be, and a target taking text or links has no use for it. const types = typesOf(dataTransfer); + const accepted = target.options.nativeTypes || []; const data: Record = {}; for (const t of types) { - if (t === 'Files') continue; + if (t === 'Files' || !accepted.includes(t)) continue; try { data[t] = dataTransfer.getData(t); } catch { /* ignore */ } } - const files = Array.from(dataTransfer.files || [], (f) => ({ - name: f.name, - size: f.size, - type: f.type, - })); + const files = accepted.includes('Files') + ? Array.from(dataTransfer.files || [], (f) => ({ name: f.name, size: f.size, type: f.type })) + : []; invoke(dotNetRef, 'HandleNativeDrop', { types, data, files }); }; diff --git a/src/Bdnd/Demo/Server/Services/BdndDropLab.cs b/src/Bdnd/Demo/Server/Services/BdndDropLab.cs index 9b68ed229d9..7b60aeaceb7 100644 --- a/src/Bdnd/Demo/Server/Services/BdndDropLab.cs +++ b/src/Bdnd/Demo/Server/Services/BdndDropLab.cs @@ -147,7 +147,7 @@ public static async Task InspectAsync(string? json) // ---- Reading the scenario --------------------------------------------------------- - private sealed record Scenario(BdndDropHost.Drag Drag, string RawType, List Roots, List All, List HoverChain, bool HoverGiven); + private sealed record Scenario(BdndDropHost.Drag Drag, string RawType, List Roots, List All, List HoverChain); /// Reads the scenario, or says - with the JSON path - what is wrong with it. private static string? ReadScenario(JsonElement root, List readingNotes, out Scenario? scenario) @@ -221,21 +221,22 @@ private sealed record Scenario(BdndDropHost.Drag Drag, string RawType, List all, List siblings, List readingNotes) { - if (depth >= MaxDepth) return $"{path} nests targets {depth + 1} deep; this tool runs scenarios nested up to {MaxDepth} deep."; - var position = 0; foreach (var element in array.EnumerateArray()) { var at = $"{path}[{position++}]"; + // Only a target that is there nests: an empty children array at the deepest level is fine. + if (depth >= MaxDepth) return $"{at} nests targets {depth + 1} deep; this tool runs scenarios nested up to {MaxDepth} deep."; + if (element.ValueKind != JsonValueKind.Object) return $"{at} is not a drop target: a target is an object, {{ \"id\": \"...\", \"accept\": [...] }}."; if (all.Count >= MaxTargets) return $"The scenario declares more than {MaxTargets} targets; this tool runs up to {MaxTargets} of them."; @@ -655,8 +656,10 @@ private static void ExplainNative(Scenario scenario, BdndDropHost.Outcome outcom if (lit.Length > 1) { - notes.Add($"While a native drag hovers, every hovered target that accepts it reports IsOver, IsOverShallow and CanDrop as true ({List(lit)}): " + - "there is no shallow distinction for native drags."); + var shallow = chain.Where(target => report[target].IsOverShallow).Select(target => target.Id).ToArray(); + + notes.Add($"While a native drag hovers, every hovered target that accepts it reports IsOver and CanDrop as true ({List(lit)}), IsOverShallow only on " + + $"{List(shallow)} - the innermost of them, as for an in-app drag."); } var waiting = scenario.All.Where(target => chain.Contains(target) is false && target.Accept.Contains(native.Constant, StringComparer.Ordinal)).Select(target => target.Id).ToArray(); diff --git a/src/Bdnd/README.md b/src/Bdnd/README.md index 82affc16e06..a301f741f22 100644 --- a/src/Bdnd/README.md +++ b/src/Bdnd/README.md @@ -374,8 +374,12 @@ the user picks with a modifier key (Ctrl, or ⌥ on macOS) ### Host element and attributes `Tag` picks the host element (`li`, `tr`, `span`...). The host is a real element, so a draggable inside a flex or -grid container is a flex or grid item; give it `style="display:contents"` to take it out of the layout and let its -child be the item instead. +grid container is a flex or grid item: style the host itself as the item - `class` and `style` go onto it - rather +than wrapping the item in it. Do not give a draggable `style="display:contents"`: the host is the element the +browser drags and the one Bdnd measures and pictures as the drag image, and an element without a box of its own +measures as an empty rectangle at `0,0` (a drag layer's preview then starts in the corner of the viewport), makes an +empty drag image, and may not start a drag at all. A drop target, which is never measured, can take +`display:contents`, as the sortable list below does. ### Custom isDragging @@ -442,6 +446,12 @@ Set `args.Result` in `OnDrop` to tell the source what happened. The source reads a non-null result set by an outer target overrides an inner one - React DnD's rule. An outer target can check `args.Monitor.DidDrop()` to see whether an inner one already handled the drop. +An async `OnDrop` may set `args.Result` after an `await`. The drop counts as soon as the handler starts - an outer +target's `DidDrop()` is true while the inner handler is still running - and the drag ends, running `OnEnd` with the +result, only once every async `OnDrop` of the drop has completed. Until then the drag is still in progress and the +targets keep their hover state, so keep slow work (a confirmation dialog, a long save) out of `OnDrop` - or start it +without awaiting it - when the source does not need its outcome. + ### Hovering `OnHover` runs repeatedly while an item of an accepted type is over the target, with the same arguments as @@ -575,21 +585,27 @@ DnD's algorithm, which avoids flicker: var rect = await Interop.GetBoundingClientRectAsync(_element); if (rect is null || e.Monitor.GetClientOffset() is not { } pointer) return; + // read both indices before moving: the move re-renders the list, and this card's Index with it + var (dragIndex, hoverIndex) = (item.Index, Index); + if (dragIndex == hoverIndex) return; + var middle = rect.Height / 2; var y = pointer.Y - rect.Top; - if (item.Index < Index && y < middle) return; - if (item.Index > Index && y > middle) return; + if (dragIndex < hoverIndex && y < middle) return; + if (dragIndex > hoverIndex && y > middle) return; - await OnMove.InvokeAsync((item.Index, Index)); - item.Index = Index; // the dragged item now sits here + await OnMove.InvokeAsync((dragIndex, hoverIndex)); + item.Index = hoverIndex; // the dragged item now sits where this card was } } ``` -Three details matter: +Four details matter: - Render the cards with `@key`, so a reorder moves elements instead of re-binding them. - Mutate the dragged item's index after a move, as the code above does: the next hover compares against it. +- Read the indices into locals before `OnMove`. The move re-renders the list synchronously, so after the `await` + this card's `Index` is already its new position, and assigning it would put the dragged item's old index back. - Make the list itself a drop target that accepts the card type as well. Then the gaps between cards are not no-drop zones, and a drop in a gap still counts as a drop (`DidDrop` is true), which is what "cancel on drop outside" - reverting the order in `OnEnd` when `DidDrop` is false - relies on. @@ -609,8 +625,9 @@ A drop target can accept drags that start outside the application by listing one Native drops arrive through `OnNativeDrop`, not `OnDrop`, with a `BdndNativeDropEventArgs`: the matched `ItemType`, the `Files`, the raw `Data` keyed by DataTransfer type, and the `Text`, `Html`, `Url` and `Urls` shortcuts. While a -native drag hovers, the target's state reports `IsOver`, `IsOverShallow` and `CanDrop` as true and `ItemType` as the -matched native type. Use `TItem="object"` on a target that only takes native drags. +native drag hovers, the target's state reports `IsOver` and `CanDrop` as true and `ItemType` as the matched native +type; `IsOverShallow` is true on the innermost hovered target that accepts the drag, as for in-app drags. Use +`TItem="object"` on a target that only takes native drags. Native drops have no `DidDrop`: when drop targets that accept the same native type are nested, each of them receives `OnNativeDrop`, innermost first, and an outer one cannot tell that an inner one already took the drop. Give @@ -642,14 +659,21 @@ contents, use an `InputFile` (for instance an invisible one layered over the dro OnNativeDrop="@(e => _last = e)"> ``` -The first accepted type found in the drag decides `ItemType`, in the order the target lists them; `Data` holds every -string the drag carried, whichever matched. +The first accepted type found in the drag decides `ItemType`, in the order the target lists them; `Data` holds the +strings of every accepted type the drag carried, whichever matched. Types the target does not accept are not read: +a target accepting `BdndNativeTypes.Text` gets the `text/plain` of a selection dragged from another page, not its +`text/html`. On Blazor Server the strings travel over the circuit, and a page's HTML easily outgrows SignalR's +default 32 KB `MaximumReceiveMessageSize`, which disconnects the circuit; a target accepting `BdndNativeTypes.Html` +there needs a larger limit (`AddHubOptions(o => o.MaximumReceiveMessageSize = ...)` on +`AddInteractiveServerComponents()`). ### Editable fields inside a target -A native drag over a text input, a textarea or a `contenteditable` element inside a drop target is left to the -browser, so text can still be dropped into the field. A drop anywhere else on a target is never handed to the -browser, which would otherwise open or navigate to the dropped file or link. +A native text, link or HTML drag over a text input, a textarea or a `contenteditable` element inside a drop target +is left to the browser, so text can still be dropped into the field. A drag carrying files always stays the +target's, as does a drag over an input that takes no text (a checkbox, a button) or a disabled or read-only field. A +drop anywhere else on a target is never handed to the browser, which would otherwise open or navigate to the dropped +file or link. ## The monitor @@ -702,7 +726,9 @@ Works the same, with three differences that come from the circuit: drag callback native events; a drag `ItemFactory` cancels cannot be stopped in the browser any more, so it goes on as an inert drag that no target accepts; and a custom drag layer starts from an estimated source position that is corrected as soon as the measured one arrives. Prefer `OnHover` only where you need it - it is the one callback that fires -continuously. +continuously; pointer moves are only sent while a `BdndDragLayer` follows them. A target accepting +`BdndNativeTypes.Html` receives the dropped page's HTML over the circuit, which may need a larger +`MaximumReceiveMessageSize` (see [text, links and HTML](#text-links-and-html)). ### Blazor Web App @@ -751,6 +777,9 @@ bit-bdnd.js (one registration per draggable and per drop target) `BdndDragDropManager` owns the authoritative state - the item, its type, the source, the offsets, every target's accept list and hover flag, the drop result. Components read it through `IBdndMonitor` when they render, and re-render when the manager raises `StateChanged` (begin, end, hover changes) or, for drag layers, `OffsetChanged`. +Like React DnD's `collect`, a draggable or drop target re-renders on `StateChanged` only when the state it hands its +content changed - every one of them when a drag begins or ends, and on a hover change just the targets whose +`IsOver`, `IsOverShallow` or `CanDrop` it flipped - so a long list does not re-render as the pointer crosses it. Draggables and drop targets suppress the automatic re-render `ComponentBase` performs after each event, so a `dragover` storm does not re-render the page; the manager decides when something changed. @@ -762,8 +791,8 @@ event is being dispatched. The script module covers exactly that gap: 1. At `dragstart`, it decides which source a drag belongs to (the innermost registered one, and only from a handle when `UseHandle` is set), tags the drag with a `DataTransfer` type carrying the source's handler id, sets `effectAllowed` and the drag image, and measures the source. -2. During the drag, it follows the pointer from `dragover` (Firefox reports `0,0` in `drag` events) and reports it - once per animation frame. +2. During the drag, it follows the pointer from `dragover` (Firefox reports `0,0` in `drag` events) and, while a + drag layer listens, reports it once per animation frame. 3. It reports the end of a drag from the source element's own `dragend` listener, which still fires after the element was removed from the DOM - and, because some browsers never raise `dragend` for a removed source, also after a drop or the next mouse move. diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs index 487a0471c2f..731af82b871 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests.Mcp/McpDropLabTests.cs @@ -231,10 +231,12 @@ public async Task A_native_file_drag_reaches_every_accepting_target_through_OnNa foreach (var id in new[] { "page", "zone" }) { Assert.IsTrue(Target(report, id).IsOver, id); - Assert.IsTrue(Target(report, id).IsOverShallow, $"{id}: a native drag has no shallow distinction."); Assert.IsTrue(Target(report, id).CanDrop, id); } + Assert.IsTrue(Target(report, "zone").IsOverShallow, "the innermost hovered target that accepts the drag"); + Assert.IsFalse(Target(report, "page").IsOverShallow, "an accepting target nested in it is hovered"); + Assert.IsFalse(Target(report, "other").CanDrop); Assert.IsTrue(report.Notes.Any(note => note.Contains("OnNativeDrop, never OnDrop", StringComparison.Ordinal))); Assert.IsTrue(report.Notes.Any(note => note.Contains("'result' was not published", StringComparison.Ordinal))); @@ -370,6 +372,27 @@ public async Task Targets_nested_past_the_limit_are_refused_with_the_reason() Assert.Contains($"nested up to {BdndDropLab.MaxDepth} deep", report.Error!); } + [TestMethod] + public async Task Targets_nested_to_the_limit_run_even_when_the_innermost_has_an_empty_children_array() + { + var root = Target("t0", ["card"]); + var current = root; + + for (var depth = 1; depth < BdndDropLab.MaxDepth; depth++) + { + var child = Target($"t{depth}", ["card"]); + current["children"] = new JsonArray(child); + current = child; + } + + current["children"] = new JsonArray(); + + var report = await InspectAsync(Scenario("card", $"t{BdndDropLab.MaxDepth - 1}", root)); + + Assert.IsTrue(report.Valid, report.Error); + Assert.HasCount(BdndDropLab.MaxDepth, report.Targets!); + } + internal static Task InspectAsync(string? json) { var arguments = new Dictionary(); diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs index a09bf5f2839..e2b8f60794d 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDragLifecycleTests.cs @@ -12,6 +12,9 @@ namespace Bit.Bdnd.Tests.Components; [TestClass] public class BdndDragLifecycleTests : BdndComponentTestBase { + /// How long a test waits for a drop or an end it unblocked, so a regression fails instead of hanging. + private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(10); + private readonly List _log = []; private readonly StateLog _sourceStates = new(); private readonly StateLog> _binStates = new(); @@ -375,7 +378,7 @@ public async Task ATargetThatCannotAcceptIsSkippedWhileAnEnclosingOneReceivesThe } [TestMethod] - public async Task AnAsyncOnDropStillCountsAsADropWhenTheDragEndsFirst() + public async Task OnEndWaitsForAnAsyncOnDropAndGetsTheResultItSetsAfterAnAwait() { var gate = new TaskCompletionSource(); var cut = RenderDustbin(onDrop: async e => @@ -387,12 +390,83 @@ public async Task AnAsyncOnDropStillCountsAsADropWhenTheDragEndsFirst() var drop = DropAsync(Bin(cut)); Assert.IsTrue(Manager.DidDrop(), "marked before the handler completes"); - await EndDragAsync(Source(cut)); + // on WebAssembly the browser fires dragend right after drop, while the handler still awaits + var end = EndDragAsync(Source(cut)); + Assert.IsTrue(Manager.IsDragging(), "the drag lasts until the async OnDrop has completed"); + Assert.AreEqual("begin:crate", _log[^1]); + gate.SetResult(); - await drop; + await drop.WaitAsync(WaitLimit); + await end.WaitAsync(WaitLimit); + + Assert.AreEqual("end:crate:True:late", _log[^1]); + Assert.IsFalse(Manager.IsDragging()); + } + + [TestMethod] + public async Task AnOuterResultStillWinsOverTheResultAnInnerAsyncOnDropSetsLater() + { + var gate = new TaskCompletionSource(); + BdndDragEndContext? end = null; + var cut = RenderProvider(p => p + .AddChildContent>(d => d + .Add(x => x.Type, "box") + .Add(x => x.Item, "crate") + .Add(x => x.OnEnd, e => end = e)) + .AddChildContent>(outer => outer + .Add(x => x.Accept, "box") + .Add(x => x.OnDrop, e => + { + _log.Add($"outer:{e.Monitor.DidDrop()}"); + e.Result = "outer"; + }) + .Add, BdndDropState>(x => x.ChildContent, _ => inner => inner + .Add(x => x.Accept, "box") + .AddUnmatched("id", "inner") + .Add(x => x.OnDrop, async e => + { + await gate.Task; + e.Result = "inner"; + })))); + await StartDragAsync(Source(cut)); + + var drop = DropAsync(cut.Find("#inner")); + var ended = EndDragAsync(Source(cut)); + gate.SetResult(); + await drop.WaitAsync(WaitLimit); + await ended.WaitAsync(WaitLimit); + + Assert.AreSequenceEqual(new[] { "outer:True" }, _log); + Assert.IsNotNull(end); + Assert.AreEqual("outer", end.DropResult); + } + + [TestMethod] + public async Task AFailingAsyncOnDropDoesNotKeepTheDragGoing() + { + var gate = new TaskCompletionSource(); + var cut = RenderDustbin(onDrop: async _ => + { + await gate.Task; + throw new InvalidOperationException("save failed"); + }); + await StartDragAsync(Source(cut)); + + var drop = DropAsync(Bin(cut)); + var end = EndDragAsync(Source(cut)); + gate.SetResult(); + try + { + await drop.WaitAsync(WaitLimit); + } + catch (InvalidOperationException) + { + // the handler's own failure, reported by the drop event + } + await end.WaitAsync(WaitLimit); Assert.AreEqual("end:crate:True:", _log[^1]); - Assert.IsNull(Manager.GetDropResult(), "the late result belongs to a drag that already ended"); + Assert.IsFalse(Manager.IsDragging()); } [TestMethod] @@ -534,6 +608,55 @@ public async Task OnlyTheDraggedSourceReportsIsDragging() Assert.IsTrue(first.Last.Monitor.IsDragging()); } + [TestMethod] + public async Task AHoverChangeReRendersOnlyTheTargetsWhoseStateItChanged() + { + var sourceStates = new StateLog(); + var hoveredStates = new StateLog>(); + var otherStates = new StateLog>(); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.ChildContent, sourceStates.Content())) + .AddChildContent>(t => t.Add(x => x.Accept, "box").Add(x => x.ChildContent, hoveredStates.Content())) + .AddChildContent>(t => t.Add(x => x.Accept, "box").Add(x => x.ChildContent, otherStates.Content()))); + var targets = cut.FindComponents>(); + await StartDragAsync(Source(cut)); + var (sourceRenders, hoveredRenders, otherRenders) = (sourceStates.Count, hoveredStates.Count, otherStates.Count); + + await HoverAsync(targets[0]); + await HoverAsync(targets[0], isOver: false); + + Assert.AreEqual(hoveredRenders + 2, hoveredStates.Count, "the hovered target renders its enter and its leave"); + Assert.AreEqual(otherRenders, otherStates.Count, "nothing the other target shows changed"); + Assert.AreEqual(sourceRenders, sourceStates.Count, "nor anything the draggable shows"); + + await EndDragAsync(Source(cut)); + + Assert.AreEqual(otherRenders + 1, otherStates.Count, "every target renders the end of the drag"); + Assert.AreEqual(sourceRenders + 1, sourceStates.Count); + } + + [TestMethod] + public async Task PointerMovesAreOnlyTrackedWhileADragLayerFollowsThem() + { + var withLayer = false; + var layer = Fragment>( + ("ChildContent", (RenderFragment>)(_ => _ => { }))); + var cut = RenderProvider(p => p + .AddChildContent>(d => d.Add(x => x.Type, "box").Add(x => x.Item, "crate")) + .AddChildContent(builder => + { + if (withLayer) builder.AddContent(0, layer); + })); + Assert.IsFalse(Assert.ContainsSingle(SourceRegistrations).TrackPointer); + + // a layer added after the draggable rendered: the draggable picks it up when the drag starts + withLayer = true; + cut.Render(); + await StartDragAsync(Source(cut)); + + Assert.IsTrue(SourceUpdates[^1].TrackPointer, "the drag started with a layer listening"); + } + [TestMethod] public async Task NestedTargetsReportDeepAndShallowHover() { diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs index 95953147268..a1227649194 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndDraggableTests.cs @@ -90,13 +90,13 @@ public void TagAndOtherAttributesGoOntoTheHostElement() .Add(x => x.Tag, "li") .AddUnmatched("id", "card-1") .AddUnmatched("class", "card") - .AddUnmatched("style", "display:contents") + .AddUnmatched("style", "display:flex") .AddUnmatched("data-index", "3")); var host = cut.Find("li"); Assert.AreEqual("card-1", host.Id); Assert.AreEqual("card", host.ClassName); - Assert.AreEqual("display:contents", host.GetAttribute("style")); + Assert.AreEqual("display:flex", host.GetAttribute("style")); Assert.AreEqual("3", host.GetAttribute("data-index")); Assert.AreEqual("true", host.GetAttribute("draggable")); } @@ -117,6 +117,7 @@ public void TheHostElementIsRegisteredAsADragSource() Assert.IsFalse(options.UseHandle); Assert.IsFalse(options.UseEmptyPreview); Assert.AreEqual("move", options.EffectAllowed); + Assert.IsFalse(options.TrackPointer, "no drag layer follows the pointer"); } [TestMethod] @@ -129,7 +130,7 @@ public void TheOptionsCarryTheParameters() .Add(x => x.DropEffect, "copyMove")); var options = Assert.ContainsSingle(SourceRegistrations); - Assert.AreEqual(new BdndDragSourceOptions(SourceIdOf(cut.Instance), false, true, true, "copyMove"), options); + Assert.AreEqual(new BdndDragSourceOptions(SourceIdOf(cut.Instance), false, true, true, "copyMove", false), options); } [TestMethod] @@ -167,7 +168,7 @@ public void ChangedOptionsArePushedToTheExistingRegistration() Assert.ContainsSingle(SourceRegistrations); Assert.AreSequenceEqual( - new[] { new BdndDragSourceOptions(id, false, false, false, "move"), new BdndDragSourceOptions(id, false, false, false, "copy") }, + new[] { new BdndDragSourceOptions(id, false, false, false, "move", false), new BdndDragSourceOptions(id, false, false, false, "copy", false) }, SourceUpdates); } diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs index 2f05b7cdc33..825b97af196 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Components/BdndNativeDragTests.cs @@ -52,6 +52,52 @@ public async Task LeavingClearsTheNativeHover() Assert.IsNull(log.Last.ItemType); } + [TestMethod] + public async Task OnlyTheInnermostHoveredTargetAcceptingANativeDragIsOverShallow() + { + var outerLog = new StateLog>(); + var innerLog = new StateLog>(); + var inner = Fragment>( + ("AcceptTypes", new[] { BdndNativeTypes.File }), ("ChildContent", innerLog.Content())); + var cut = Render(p => p + .Add(x => x.Manager, Manager) + .AddChildContent>(t => t + .Add(x => x.AcceptTypes, [BdndNativeTypes.File]) + .Add(x => x.ChildContent, outerLog.Content(_ => inner)))); + var targets = cut.FindComponents>(); + + await HoverAsync(targets[0], true, "Files"); + Assert.IsTrue(outerLog.Last.IsOverShallow); + + await HoverAsync(targets[1], true, "Files"); + Assert.IsTrue(outerLog.Last.IsOver); + Assert.IsFalse(outerLog.Last.IsOverShallow, "the nested target is the one under the pointer"); + Assert.IsTrue(innerLog.Last.IsOver); + Assert.IsTrue(innerLog.Last.IsOverShallow); + + await HoverAsync(targets[1], false, "Files"); + Assert.IsTrue(outerLog.Last.IsOverShallow); + Assert.IsFalse(innerLog.Last.IsOver); + } + + [TestMethod] + public async Task ANestedTargetThatDoesNotAcceptTheNativeDragLeavesTheShallowHoverToItsParent() + { + var outerLog = new StateLog>(); + var inner = Fragment>(("AcceptTypes", new[] { BdndNativeTypes.Text })); + var cut = Render(p => p + .Add(x => x.Manager, Manager) + .AddChildContent>(t => t + .Add(x => x.AcceptTypes, [BdndNativeTypes.File]) + .Add(x => x.ChildContent, outerLog.Content(_ => inner)))); + var targets = cut.FindComponents>(); + + await HoverAsync(targets[0], true, "Files"); + await HoverAsync(targets[1], true, "Files"); + + Assert.IsTrue(outerLog.Last.IsOverShallow); + } + [TestMethod] public async Task ANativeDragOfAnotherTypeIsIgnored() { diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs index d9972a3e7e8..0fe47d172a7 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Fakes/FakeJSObjectReference.cs @@ -33,6 +33,13 @@ public FakeJSObjectReference Fails(string identifier, Exception exception) return this; } + /// Calls to no longer fail. + public FakeJSObjectReference Succeeds(string identifier) + { + _failures.Remove(identifier); + return this; + } + /// Calls to complete only once does. public FakeJSObjectReference WaitsFor(string identifier, Task gate) { diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs index a9704c3cc92..f57a8e760de 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndInteropTests.cs @@ -76,7 +76,7 @@ public async Task RegisterDragSourcePassesTheElementTheCallbackObjectAndTheOptio var interop = new BdndInterop(new FakeJSRuntime().NextImport(module)); var element = new ElementReference("source"); var callbacks = new object(); - var options = new BdndDragSourceOptions("id", true, false, true, "copy"); + var options = new BdndDragSourceOptions("id", true, false, true, "copy", false); var result = await interop.RegisterDragSourceAsync(element, callbacks, options); @@ -113,7 +113,7 @@ public async Task SourcesAndTargetsShareOneModule() var js = new FakeJSRuntime().NextImport(module); var interop = new BdndInterop(js); - await interop.RegisterDragSourceAsync(default, new object(), new BdndDragSourceOptions("a", true, false, false, "move")); + await interop.RegisterDragSourceAsync(default, new object(), new BdndDragSourceOptions("a", true, false, false, "move", false)); await interop.RegisterDropTargetAsync(default, new object(), new BdndDropTargetOptions(false, [])); await interop.GetBoundingClientRectAsync(default); diff --git a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs index a1400225447..228a6f0845a 100644 --- a/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs +++ b/src/Bdnd/Tests/Bit.Bdnd.Tests/Interop/BdndJsRegistrationTests.cs @@ -80,6 +80,22 @@ public async Task ChangedOptionsArePushedThroughUpdate() Assert.AreEqual(new Options(2), update.Args![0]); } + [TestMethod] + public async Task OptionsAFailedUpdateDidNotDeliverArePushedAgain() + { + var reference = new FakeJSObjectReference(); + var registration = new BdndJsRegistration(new Registrar(reference).Register); + await registration.SyncAsync(Element, new Options(1)); + reference.Fails("update", new JSException("update failed")); + + await Assert.ThrowsExactlyAsync(() => registration.SyncAsync(Element, new Options(2))); + reference.Succeeds("update"); + await registration.SyncAsync(Element, new Options(2)); + + Assert.AreSequenceEqual(new object?[] { new Options(2), new Options(2) }, + reference.CallsTo("update").Select(args => args![0])); + } + [TestMethod] public async Task ARecreatedElementIsRegisteredAgainAndTheOldRegistrationReleased() { diff --git a/src/Bink/Bit.Bink.Demo/Server/Services/BinkXmlDocs.cs b/src/Bink/Bit.Bink.Demo/Server/Services/BinkXmlDocs.cs index 95a881ad8f8..345b5fd1a97 100644 --- a/src/Bink/Bit.Bink.Demo/Server/Services/BinkXmlDocs.cs +++ b/src/Bink/Bit.Bink.Demo/Server/Services/BinkXmlDocs.cs @@ -189,9 +189,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("SomeGeneric`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -209,4 +210,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/BlazeDb/Demo/Server/Services/BlazeDbXmlDocs.cs b/src/BlazeDb/Demo/Server/Services/BlazeDbXmlDocs.cs index 309385e260d..661614491e9 100644 --- a/src/BlazeDb/Demo/Server/Services/BlazeDbXmlDocs.cs +++ b/src/BlazeDb/Demo/Server/Services/BlazeDbXmlDocs.cs @@ -196,9 +196,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("BlazeDbTable`2"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("BlazeDbTable`2", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -216,4 +217,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIXmlDocs.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIXmlDocs.cs index 16d11aaa878..e745ae956ae 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIXmlDocs.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIXmlDocs.cs @@ -239,8 +239,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("BitDropdown`2", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -258,4 +260,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Blex/Demo/Server/Services/BlexXmlDocs.cs b/src/Blex/Demo/Server/Services/BlexXmlDocs.cs index 6beb0f618e3..c83ecdca32a 100644 --- a/src/Blex/Demo/Server/Services/BlexXmlDocs.cs +++ b/src/Blex/Demo/Server/Services/BlexXmlDocs.cs @@ -164,8 +164,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -183,4 +185,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Blow/Demo/Server/Services/BlowXmlDocs.cs b/src/Blow/Demo/Server/Services/BlowXmlDocs.cs index 6a9d2d00bbf..b1c21098076 100644 --- a/src/Blow/Demo/Server/Services/BlowXmlDocs.cs +++ b/src/Blow/Demo/Server/Services/BlowXmlDocs.cs @@ -189,9 +189,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("SomeGeneric`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -209,4 +210,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests.Mcp/Services/XmlDocsTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests.Mcp/Services/XmlDocsTests.cs index 1ec43f95d9c..35b60b07128 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests.Mcp/Services/XmlDocsTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests.Mcp/Services/XmlDocsTests.cs @@ -61,6 +61,20 @@ public void GetSummary_CrefReferences_ReadAsNames() } } + /// + /// A cref to a member of a generic type carries the type's arity marker in its middle + /// ("P:Bit.Bmotion.BmotionPresenceGroup`1.ChildContent"); the reference still names the member. + /// + [TestMethod] + public void GetSummary_ACrefToAMemberOfAGenericType_KeepsTheMember() + { + var summary = BmotionXmlDocs.GetSummary($"T:{typeof(BmotionPresenceGroup<>).FullName}")!; + + StringAssert.Contains(summary, "BmotionPresenceGroup.ChildContent"); + StringAssert.Contains(summary, "BmotionPresenceGroup.Items"); + Assert.IsFalse(summary.Contains("`1", StringComparison.Ordinal), $"An arity marker reached the reader: {summary}"); + } + /// /// While the prose is unwrapped, a code sample is parked under a U+0001 placeholder so its own /// line breaks survive the whitespace passes. The placeholder itself must always be put back. diff --git a/src/Bonboarding/Demo/Server/Services/BonboardingXmlDocs.cs b/src/Bonboarding/Demo/Server/Services/BonboardingXmlDocs.cs index 44dd0fec1a7..b2f62552332 100644 --- a/src/Bonboarding/Demo/Server/Services/BonboardingXmlDocs.cs +++ b/src/Bonboarding/Demo/Server/Services/BonboardingXmlDocs.cs @@ -165,9 +165,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("SomeGeneric`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -185,4 +186,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Brouter/Bit.Brouter.Demo/Server/Services/BrouterXmlDocs.cs b/src/Brouter/Bit.Brouter.Demo/Server/Services/BrouterXmlDocs.cs index 1e4b084ab45..eee90e31a0f 100644 --- a/src/Brouter/Bit.Brouter.Demo/Server/Services/BrouterXmlDocs.cs +++ b/src/Brouter/Bit.Brouter.Demo/Server/Services/BrouterXmlDocs.cs @@ -165,9 +165,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type ("BrouterTypeRouteConstraint`1"). - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("BrouterTypeRouteConstraint`1", "GetItem``1") + // wherever they sit: a member of a generic type has one in the middle of its cref, and cutting + // the text at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -185,4 +186,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Mcp/BrouterXmlDocsTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Mcp/BrouterXmlDocsTests.cs index 7e564544dfb..3d103af0736 100644 --- a/src/Brouter/Tests/Bit.Brouter.Tests.Mcp/BrouterXmlDocsTests.cs +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Mcp/BrouterXmlDocsTests.cs @@ -39,6 +39,19 @@ public void A_cref_is_flattened_to_the_name_a_reader_would_say() Assert.IsFalse(summary.Contains("" carries the arity marker in the middle of the + // cref; cutting the text there would read "Brouter.BrouterAwait" - the namespace kept, the member lost. + var summary = BrouterXmlDocs.GetSummary("T:Bit.Brouter.BrouterAwait`1")!; + + StringAssert.Contains(summary, "BrouterAwait.Task"); + StringAssert.Contains(summary, "BrouterAwait.Pending"); + Assert.IsFalse(summary.Contains("Brouter.BrouterAwait", StringComparison.Ordinal), $"A member reference lost its member: {summary}"); + Assert.IsFalse(summary.Contains("`1", StringComparison.Ordinal), $"An arity marker reached the reader: {summary}"); + } + [TestMethod] public void No_flattened_documentation_carries_markup_through() { diff --git a/src/Butil/Demo/Server/Services/ButilXmlDocs.cs b/src/Butil/Demo/Server/Services/ButilXmlDocs.cs index 5b71643a217..347d686c622 100644 --- a/src/Butil/Demo/Server/Services/ButilXmlDocs.cs +++ b/src/Butil/Demo/Server/Services/ButilXmlDocs.cs @@ -203,9 +203,10 @@ private static string Reference(XElement element) var arguments = target.IndexOf('(', StringComparison.Ordinal); if (arguments > 0) target = target[..arguments]; - // Drop the arity marker of a generic type. - var arity = target.IndexOf('`', StringComparison.Ordinal); - if (arity > 0) target = target[..arity]; + // Drop the arity markers of generic types and methods ("SomeGeneric`1", "GetItem``1") wherever + // they sit: a member of a generic type has one in the middle of its cref, and cutting the text + // at the first one would drop the member and keep the namespace. + target = ArityRegex().Replace(target, string.Empty); var parts = target.Split('.'); @@ -223,4 +224,7 @@ private static string Reference(XElement element) [GeneratedRegex(@"[ \t]{2,}")] private static partial Regex RepeatedSpaceRegex(); + + [GeneratedRegex(@"`+\d+")] + private static partial Regex ArityRegex(); } From 801150accfa4fd2738adea8c3020c2c7b52bbfd3 Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 19 Oct 2026 11:13:13 +0330 Subject: [PATCH 3/3] revert all.ci.yml --- .github/workflows/all.ci.yml | 70 ------------------------------------ 1 file changed, 70 deletions(-) diff --git a/.github/workflows/all.ci.yml b/.github/workflows/all.ci.yml index ab7ac8c69ee..60fad37e8f2 100644 --- a/.github/workflows/all.ci.yml +++ b/.github/workflows/all.ci.yml @@ -39,66 +39,6 @@ on: description: Brouter solution type: boolean default: true - bmotion: - description: Bmotion solution - type: boolean - default: true - bmage: - description: Bmage solution - type: boolean - default: true - bsqlite: - description: Bsqlite solution - type: boolean - default: true - bink: - description: Bink solution - type: boolean - default: true - balette: - description: Balette solution - type: boolean - default: true - bbscanner: - description: BbScanner solution - type: boolean - default: true - bdnd: - description: Bdnd solution - type: boolean - default: true - blazedb: - description: BlazeDb solution - type: boolean - default: true - blow: - description: Blow solution - type: boolean - default: true - blex: - description: Blex solution - type: boolean - default: true - bonboarding: - description: Bonboarding solution - type: boolean - default: true - besql: - description: Besql solution - type: boolean - default: true - blazores2019: - description: BlazorES2019 project - type: boolean - default: true - tooling: - description: CodeAnalyzers, Minifier, bit CLI and SourceGenerators solutions - type: boolean - default: true - websites: - description: Website solutions - type: boolean - default: true prerelease_packages: description: Bit packages of the latest Prerelease nuget packages run instead of nuget.org (the Boilerplate, BlazorEmpty and website jobs) type: boolean @@ -652,11 +592,6 @@ jobs: dotnet build src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false dotnet pack src/BbScanner/Bit.BbScanner/Bit.BbScanner.csproj --output ./packages --configuration Release - - name: Build and pack Bdnd - run: | - dotnet build src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false - dotnet pack src/Bdnd/Bit.Bdnd/Bit.Bdnd.csproj --output ./packages --configuration Release - - name: Build and pack BlazeDb run: | dotnet build src/BlazeDb/Bit.BlazeDb/Bit.BlazeDb.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false @@ -789,11 +724,6 @@ jobs: name: bbscanner uses: ./.github/workflows/bit.ci.BbScanner.yml - bdnd: - name: bdnd - if: ${{ inputs.bdnd }} - uses: ./.github/workflows/bit.ci.Bdnd.yml - blazedb: name: blazedb uses: ./.github/workflows/bit.ci.BlazeDb.yml