{
  "name": "RichTextEditor",
  "category": "Forms",
  "subcategory": "Specialized",
  "description": "WYSIWYG editor wrapping TipTap with Lumeo styling.",
  "thumbnail": "/preview-cards/rich-text-editor.png",
  "hasDocsPage": true,
  "nugetPackage": "Lumeo.Editor",
  "files": [
    "UI/RichTextEditor/AiAction.cs",
    "UI/RichTextEditor/AiActionMenu.razor",
    "UI/RichTextEditor/BubbleMenu.razor",
    "UI/RichTextEditor/EditorImageRequest.cs",
    "UI/RichTextEditor/EditorToolbar.razor",
    "UI/RichTextEditor/EditorToolbarPreset.cs",
    "UI/RichTextEditor/EditorTrigger.cs",
    "UI/RichTextEditor/RichTextEditor.razor",
    "UI/RichTextEditor/SlashCommands.cs",
    "UI/RichTextEditor/TriggerDropdown.razor",
    "UI/RichTextEditor/WordImporter.cs",
    "UI/RichTextEditor/WordImportPayload.cs",
    "UI/RichTextEditor/WordImportSizeException.cs"
  ],
  "dependencies": [
    "button",
    "dialog",
    "icon",
    "input"
  ],
  "packageDependencies": [],
  "cssVars": [
    "--color-accent",
    "--color-accent-foreground",
    "--color-border",
    "--color-card",
    "--color-destructive",
    "--color-foreground",
    "--color-muted",
    "--color-muted-foreground",
    "--color-popover",
    "--color-popover-foreground",
    "--color-ring",
    "--radius"
  ],
  "api": {
    "name": "RichTextEditor",
    "category": "Forms",
    "subcategory": "Specialized",
    "description": "WYSIWYG editor wrapping TipTap with Lumeo styling.",
    "nugetPackage": "Lumeo.Editor",
    "files": [
      "UI/RichTextEditor/AiAction.cs",
      "UI/RichTextEditor/AiActionMenu.razor",
      "UI/RichTextEditor/BubbleMenu.razor",
      "UI/RichTextEditor/EditorImageRequest.cs",
      "UI/RichTextEditor/EditorToolbar.razor",
      "UI/RichTextEditor/EditorToolbarPreset.cs",
      "UI/RichTextEditor/EditorTrigger.cs",
      "UI/RichTextEditor/RichTextEditor.razor",
      "UI/RichTextEditor/SlashCommands.cs",
      "UI/RichTextEditor/TriggerDropdown.razor",
      "UI/RichTextEditor/WordImporter.cs",
      "UI/RichTextEditor/WordImportPayload.cs",
      "UI/RichTextEditor/WordImportSizeException.cs"
    ],
    "namespace": "Lumeo",
    "inheritsFrom": null,
    "implements": [
      "IAsyncDisposable"
    ],
    "parameters": [
      {
        "name": "FormField",
        "type": "FormField.FormFieldContext?",
        "default": null,
        "description": null,
        "isCascading": true,
        "captureUnmatched": false
      },
      {
        "name": "Value",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ValueChanged",
        "type": "EventCallback<string?>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Placeholder",
        "type": "string",
        "default": "\"Start writing…\"",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MinHeight",
        "type": "int",
        "default": "180",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MaxHeight",
        "type": "int?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Disabled",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ReadOnly",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Required",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Invalid",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ErrorText",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "HelperText",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Label",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Name",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ToolbarMode",
        "type": "RichTextToolbarMode?",
        "default": null,
        "description": "Legacy toolbar mode; superseded by Toolbar.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Toolbar",
        "type": "EditorToolbarPreset",
        "default": "EditorToolbarPreset.Standard",
        "description": "Toolbar density preset. Defaults to Standard.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Triggers",
        "type": "IReadOnlyList<EditorTrigger>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableSlashCommand",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableTables",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableCodeBlock",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableImages",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableTaskList",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableMarkdownShortcuts",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableAiActions",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableBubbleMenu",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableDragHandle",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MaxLength",
        "type": "int?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AllowWordImport",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MaxWordImportBytes",
        "type": "long",
        "default": "8L * 1024L * 1024L",
        "description": "Maximum size, in bytes, of a Word document accepted by the in-browser import path. Defaults to 8 MB. Larger files are refused before being read into memory and surfaced to the user via WordImportError. Set explicitly when consumers expect large documents and the deployment can afford the memory.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnImageRequested",
        "type": "Func<EditorImageRequest, ValueTask<string?>>?",
        "default": null,
        "description": "Resolves a picked image into a URL (or data URI). Receives file metadata from JS; consumer is expected to upload the file out-of-band (e.g. via a paired InputFile) and return a final URL. If null, images fall back to base64.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnAiAction",
        "type": "Func<string, string, ValueTask<string?>>?",
        "default": null,
        "description": "Selection-driven AI actions. Receives the action id and selected plain text; returns replacement HTML to be inserted in place of the selection.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnWordImportRequested",
        "type": "Func<WordImportPayload, ValueTask<string>>?",
        "default": null,
        "description": "Optional override for Word import. Receives the picked file payload (filename + bytes) and returns HTML to replace the editor content. When NOT set, the file is converted in-browser via WordImporter (Mammoth runs inside the WASM bundle), so the demo works on static hosting with no server. Set this when you want server-side conversion (better fidelity for complex docs).",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnInitError",
        "type": "EventCallback<string>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Class",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AdditionalAttributes",
        "type": "Dictionary<string, object>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": true
      }
    ],
    "events": [
      {
        "name": "ValueChanged",
        "type": "EventCallback<string?>",
        "description": null
      },
      {
        "name": "OnInitError",
        "type": "EventCallback<string>",
        "description": null
      }
    ],
    "enums": [
      {
        "name": "RichTextToolbarMode",
        "values": [
          "None",
          "Basic",
          "Full"
        ],
        "description": "Legacy toolbar mode — kept so existing call sites compile."
      }
    ],
    "records": [],
    "cssVars": [
      "--color-accent",
      "--color-accent-foreground",
      "--color-border",
      "--color-card",
      "--color-destructive",
      "--color-foreground",
      "--color-muted",
      "--color-muted-foreground",
      "--color-popover",
      "--color-popover-foreground",
      "--color-ring",
      "--radius"
    ],
    "examples": [],
    "subComponents": {
      "AiActionMenu": {
        "componentName": "AiActionMenu",
        "fileName": "AiActionMenu.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Actions",
            "type": "IReadOnlyList<AiAction>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnAction",
            "type": "EventCallback<AiAction>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Class",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AdditionalAttributes",
            "type": "Dictionary<string, object>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": true
          }
        ],
        "events": [
          {
            "name": "OnAction",
            "type": "EventCallback<AiAction>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "BubbleMenu": {
        "componentName": "BubbleMenu",
        "fileName": "BubbleMenu.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "EnableAiActions",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnCommand",
            "type": "EventCallback<string>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnAiMenuToggled",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Class",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AdditionalAttributes",
            "type": "Dictionary<string, object>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": true
          }
        ],
        "events": [
          {
            "name": "OnCommand",
            "type": "EventCallback<string>",
            "description": null
          },
          {
            "name": "OnAiMenuToggled",
            "type": "EventCallback",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "EditorToolbar": {
        "componentName": "EditorToolbar",
        "fileName": "EditorToolbar.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Preset",
            "type": "EditorToolbarPreset",
            "default": "EditorToolbarPreset.Standard",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Active",
            "type": "Lumeo.Services.Interop.RichTextActiveState?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Disabled",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AllowWordImport",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "EnableAiActions",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnCommand",
            "type": "EventCallback<(string command, object? arg)>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Class",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AdditionalAttributes",
            "type": "Dictionary<string, object>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": true
          }
        ],
        "events": [
          {
            "name": "OnCommand",
            "type": "EventCallback<(string command, object? arg)>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "TriggerDropdown": {
        "componentName": "TriggerDropdown",
        "fileName": "TriggerDropdown.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Id",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Items",
            "type": "IReadOnlyList<TriggerItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SelectedIndex",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ItemTemplate",
            "type": "RenderFragment<TriggerItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnPick",
            "type": "EventCallback<TriggerItem>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnHover",
            "type": "EventCallback<int>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AriaLabel",
            "type": "string",
            "default": "\"Suggestions\"",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Class",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AdditionalAttributes",
            "type": "Dictionary<string, object>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": true
          }
        ],
        "events": [
          {
            "name": "OnPick",
            "type": "EventCallback<TriggerItem>",
            "description": null
          },
          {
            "name": "OnHover",
            "type": "EventCallback<int>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      }
    },
    "parseFailed": false,
    "parseError": null
  },
  "$schema": "https://lumeo.nativ.sh/registry-component-schema.json",
  "key": "rich-text-editor",
  "docsUrl": "https://lumeo.nativ.sh/components/rich-text-editor",
  "sourceUrl": [
    {
      "file": "UI/RichTextEditor/AiAction.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/AiAction.cs"
    },
    {
      "file": "UI/RichTextEditor/AiActionMenu.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/AiActionMenu.razor"
    },
    {
      "file": "UI/RichTextEditor/BubbleMenu.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/BubbleMenu.razor"
    },
    {
      "file": "UI/RichTextEditor/EditorImageRequest.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/EditorImageRequest.cs"
    },
    {
      "file": "UI/RichTextEditor/EditorToolbar.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/EditorToolbar.razor"
    },
    {
      "file": "UI/RichTextEditor/EditorToolbarPreset.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/EditorToolbarPreset.cs"
    },
    {
      "file": "UI/RichTextEditor/EditorTrigger.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/EditorTrigger.cs"
    },
    {
      "file": "UI/RichTextEditor/RichTextEditor.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/RichTextEditor.razor"
    },
    {
      "file": "UI/RichTextEditor/SlashCommands.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/SlashCommands.cs"
    },
    {
      "file": "UI/RichTextEditor/TriggerDropdown.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/TriggerDropdown.razor"
    },
    {
      "file": "UI/RichTextEditor/WordImporter.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/WordImporter.cs"
    },
    {
      "file": "UI/RichTextEditor/WordImportPayload.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/WordImportPayload.cs"
    },
    {
      "file": "UI/RichTextEditor/WordImportSizeException.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.Editor/UI/RichTextEditor/WordImportSizeException.cs"
    }
  ],
  "source": [
    {
      "path": "UI/RichTextEditor/AiAction.cs",
      "content": "using Lumeo.Services.Localization;\n\nnamespace Lumeo;\n\n/// <summary>\n/// A single AI action that operates on the current editor selection.\n/// </summary>\npublic sealed record AiAction(string Id, string Label, string? IconName = null);\n\n/// <summary>Built-in AI actions surfaced by the bubble menu / AiActionMenu.</summary>\npublic static class AiActions\n{\n    // EN fallback IDs — kept so existing code that references AiActions.Improve etc.\n    // still works. The labels here are the English defaults; use LocalizedDefault to\n    // get locale-aware labels at render time.\n    public static AiAction Improve { get; } = new(\"improve\", \"Improve writing\", \"Sparkles\");\n    public static AiAction Shorten { get; } = new(\"shorten\", \"Make shorter\", \"Minimize2\");\n    public static AiAction Expand { get; } = new(\"expand\", \"Make longer\", \"Maximize2\");\n    public static AiAction Translate { get; } = new(\"translate\", \"Translate\", \"Languages\");\n    public static AiAction Summarize { get; } = new(\"summarize\", \"Summarize\", \"FileText\");\n    public static AiAction FixGrammar { get; } = new(\"fix-grammar\", \"Fix grammar\", \"SpellCheck\");\n\n    /// <summary>Default action set with EN labels. Prefer <see cref=\"LocalizedDefault\"/> when a\n    /// localizer is available so labels reflect the active locale.</summary>\n    public static IReadOnlyList<AiAction> Default { get; } = new[]\n    {\n        Improve, Shorten, Expand, FixGrammar, Summarize, Translate,\n    };\n\n    /// <summary>Returns the default action set with labels resolved through the active localizer.</summary>\n    public static IReadOnlyList<AiAction> LocalizedDefault(ILumeoLocalizer L) => new[]\n    {\n        new AiAction(\"improve\",    L[\"Editor.AiImproveWriting\"], \"Sparkles\"),\n        new AiAction(\"shorten\",    L[\"Editor.AiMakeShorter\"],   \"Minimize2\"),\n        new AiAction(\"expand\",     L[\"Editor.AiMakeLonger\"],    \"Maximize2\"),\n        new AiAction(\"fix-grammar\",L[\"Editor.AiFixGrammar\"],    \"SpellCheck\"),\n        new AiAction(\"summarize\",  L[\"Editor.AiSummarize\"],     \"FileText\"),\n        new AiAction(\"translate\",  L[\"Editor.AiTranslate\"],     \"Languages\"),\n    };\n}\n"
    },
    {
      "path": "UI/RichTextEditor/AiActionMenu.razor",
      "content": "@namespace Lumeo\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@*\n    Floating list of AI actions (Improve / Shorten / Expand / ...). Wired to\n    the editor's selection — picking an item fires OnAction with the action\n    record and the editor's currently selected text; consumer returns the\n    replacement HTML, which the editor inserts via executeCommand(\"replaceSelection\").\n*@\n\n<div class=\"@RootClass\" role=\"menu\" aria-label=\"@L[\"Editor.AiActions\"]\" @attributes=\"AdditionalAttributes\">\n    @foreach (var action in EffectiveActions)\n    {\n        var a = action;\n        <button type=\"button\"\n                role=\"menuitem\"\n                class=\"flex w-full items-center gap-2 px-2.5 py-1.5 rounded-md text-sm hover:bg-accent hover:text-accent-foreground\"\n                @onmousedown:preventDefault=\"true\"\n                @onclick=\"() => OnAction.InvokeAsync(a)\">\n            @if (!string.IsNullOrEmpty(a.IconName))\n            {\n                <span class=\"text-muted-foreground\"><Icon Name=\"@a.IconName\" Size=\"Icon.IconSize.Sm\" /></span>\n            }\n            <span class=\"flex-1 text-left\">@a.Label</span>\n        </button>\n    }\n</div>\n\n@code {\n    [Parameter] public IReadOnlyList<AiAction>? Actions { get; set; }\n    [Parameter] public EventCallback<AiAction> OnAction { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private IReadOnlyList<AiAction> EffectiveActions => Actions ?? AiActions.LocalizedDefault(L);\n\n    private string RootClass =>\n        $\"min-w-[200px] rounded-lg border border-border/60 bg-popover text-popover-foreground shadow-lg p-1 {Class}\".Trim();\n}\n"
    },
    {
      "path": "UI/RichTextEditor/BubbleMenu.razor",
      "content": "@namespace Lumeo\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@*\n    Static (non-floating) bubble-menu primitive consumers can drop into a\n    custom toolbar. The live floating bubble menu is rendered in JS by the\n    editor itself when EnableBubbleMenu=true on RichTextEditor. This component\n    exists so consumers can also build their own selection menu out of band.\n*@\n\n<div class=\"@RootClass\" role=\"toolbar\" aria-label=\"@L[\"Editor.FormatSelection\"]\" @attributes=\"AdditionalAttributes\">\n    <button type=\"button\" class=\"@BtnClass\" title=\"@L[\"Editor.Bold\"]\" aria-label=\"@L[\"Editor.Bold\"]\"\n            @onmousedown:preventDefault=\"true\" @onclick=\"@(() => OnCommand.InvokeAsync(\"bold\"))\">\n        <Icon Name=\"Bold\" Size=\"Icon.IconSize.Sm\" />\n    </button>\n    <button type=\"button\" class=\"@BtnClass\" title=\"@L[\"Editor.Italic\"]\" aria-label=\"@L[\"Editor.Italic\"]\"\n            @onmousedown:preventDefault=\"true\" @onclick=\"@(() => OnCommand.InvokeAsync(\"italic\"))\">\n        <Icon Name=\"Italic\" Size=\"Icon.IconSize.Sm\" />\n    </button>\n    <button type=\"button\" class=\"@BtnClass\" title=\"@L[\"Editor.Underline\"]\" aria-label=\"@L[\"Editor.Underline\"]\"\n            @onmousedown:preventDefault=\"true\" @onclick=\"@(() => OnCommand.InvokeAsync(\"underline\"))\">\n        <Icon Name=\"Underline\" Size=\"Icon.IconSize.Sm\" />\n    </button>\n    <button type=\"button\" class=\"@BtnClass\" title=\"@L[\"Editor.Link\"]\" aria-label=\"@L[\"Editor.Link\"]\"\n            @onmousedown:preventDefault=\"true\" @onclick=\"@(() => OnCommand.InvokeAsync(\"link\"))\">\n        <Icon Name=\"Link\" Size=\"Icon.IconSize.Sm\" />\n    </button>\n    @if (EnableAiActions)\n    {\n        <button type=\"button\" class=\"@BtnClass\" title=\"@L[\"Editor.AiActions\"]\" aria-label=\"@L[\"Editor.AiActions\"]\"\n                @onmousedown:preventDefault=\"true\" @onclick=\"@(() => OnAiMenuToggled.InvokeAsync())\">\n            <Icon Name=\"Sparkles\" Size=\"Icon.IconSize.Sm\" />\n        </button>\n    }\n</div>\n\n@code {\n    [Parameter] public bool EnableAiActions { get; set; }\n    [Parameter] public EventCallback<string> OnCommand { get; set; }\n    [Parameter] public EventCallback OnAiMenuToggled { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string RootClass =>\n        $\"inline-flex items-center gap-0.5 rounded-lg border border-border/60 bg-popover text-popover-foreground shadow-md p-1 {Class}\".Trim();\n\n    private const string BtnClass =\n        \"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\";\n}\n"
    },
    {
      "path": "UI/RichTextEditor/EditorImageRequest.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Metadata about an image the user dropped, pasted, or picked through the\n/// editor's image button. Returned to consumers via\n/// <see cref=\"RichTextEditor.OnImageRequested\"/>; consumer is responsible for\n/// uploading the actual bytes (typically via a paired <c>InputFile</c>) and\n/// returning a final URL.\n/// </summary>\npublic sealed record EditorImageRequest(string FileName, string MimeType, long Size);\n"
    },
    {
      "path": "UI/RichTextEditor/EditorToolbar.razor",
      "content": "@namespace Lumeo\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@*\n    Preset-driven toolbar rendered above the editor surface. Each button\n    invokes OnCommand which the parent RichTextEditor forwards to the JS\n    editor via executeCommand.\n*@\n\n<div class=\"@RootClass\" role=\"toolbar\" aria-label=\"@L[\"Editor.ToolbarAriaLabel\"]\" @attributes=\"AdditionalAttributes\">\n    @if (Preset == EditorToolbarPreset.Full)\n    {\n        @Btn(L[\"Editor.Paragraph\"], \"Pilcrow\", \"setParagraph\", Active?.Paragraph == true)\n        @Btn(L[\"Editor.Heading1\"], \"Heading1\", \"setHeading\", Active?.Heading1 == true, 1)\n        @Btn(L[\"Editor.Heading2\"], \"Heading2\", \"setHeading\", Active?.Heading2 == true, 2)\n        @Btn(L[\"Editor.Heading3\"], \"Heading3\", \"setHeading\", Active?.Heading3 == true, 3)\n        @Sep()\n    }\n    else if (Preset == EditorToolbarPreset.Standard)\n    {\n        @Btn(L[\"Editor.Heading1\"], \"Heading1\", \"setHeading\", Active?.Heading1 == true, 1)\n        @Btn(L[\"Editor.Heading2\"], \"Heading2\", \"setHeading\", Active?.Heading2 == true, 2)\n        @Sep()\n    }\n\n    @Btn(L[\"Editor.Bold\"], \"Bold\", \"bold\", Active?.Bold == true)\n    @Btn(L[\"Editor.Italic\"], \"Italic\", \"italic\", Active?.Italic == true)\n\n    @if (Preset != EditorToolbarPreset.Minimal)\n    {\n        @Btn(L[\"Editor.Underline\"], \"Underline\", \"underline\", Active?.Underline == true)\n        @Btn(L[\"Editor.Strikethrough\"], \"Strikethrough\", \"strike\", Active?.Strike == true)\n    }\n\n    @if (Preset == EditorToolbarPreset.Full)\n    {\n        @Btn(L[\"Editor.InlineCode\"], \"Code\", \"code\", Active?.Code == true)\n    }\n\n    @if (Preset != EditorToolbarPreset.Minimal)\n    {\n        @Sep()\n        @Btn(L[\"Editor.BulletList\"], \"List\", \"bulletList\", Active?.BulletList == true)\n        @Btn(L[\"Editor.NumberedList\"], \"ListOrdered\", \"orderedList\", Active?.OrderedList == true)\n\n        @if (Preset == EditorToolbarPreset.Full)\n        {\n            @Btn(L[\"Editor.TaskList\"], \"ListChecks\", \"taskList\", false)\n            @Sep()\n            @Btn(L[\"Editor.Quote\"], \"Quote\", \"blockquote\", Active?.Blockquote == true)\n            @Btn(L[\"Editor.CodeBlock\"], \"SquareCode\", \"codeBlock\", Active?.CodeBlock == true)\n            @Btn(L[\"Editor.HorizontalRule\"], \"Minus\", \"hr\", false)\n        }\n    }\n\n    @Sep()\n    @Btn(L[\"Editor.Link\"], \"Link\", \"link\", Active?.Link == true)\n    @if (Preset != EditorToolbarPreset.Minimal)\n    {\n        @Btn(L[\"Editor.Unlink\"], \"Unlink\", \"unlink\", false, disabled: Active?.Link != true)\n    }\n\n    @if (Preset == EditorToolbarPreset.Full)\n    {\n        @Sep()\n        @Btn(L[\"Editor.InsertTable\"], \"Table\", \"insertTable\", false)\n        @Btn(L[\"Editor.InsertImage\"], \"Image\", \"pickImage\", false)\n        @if (AllowWordImport)\n        {\n            @Btn(L[\"Editor.ImportDocx\"], \"FileText\", \"pickWord\", false)\n        }\n        @if (EnableAiActions)\n        {\n            @Btn(L[\"Editor.AiActions\"], \"Sparkles\", \"ai-menu\", false)\n        }\n    }\n\n    @Sep()\n    @Btn(L[\"Editor.Undo\"], \"Undo\", \"undo\", false, disabled: Active?.CanUndo == false)\n    @Btn(L[\"Editor.Redo\"], \"Redo\", \"redo\", false, disabled: Active?.CanRedo == false)\n\n    @if (Preset == EditorToolbarPreset.Full)\n    {\n        @Btn(L[\"Editor.ClearFormatting\"], \"RemoveFormatting\", \"clearFormat\", false)\n    }\n</div>\n\n@code {\n    [Parameter] public EditorToolbarPreset Preset { get; set; } = EditorToolbarPreset.Standard;\n    [Parameter] public Lumeo.Services.Interop.RichTextActiveState? Active { get; set; }\n    [Parameter] public bool Disabled { get; set; }\n    [Parameter] public bool AllowWordImport { get; set; }\n    [Parameter] public bool EnableAiActions { get; set; }\n    [Parameter] public EventCallback<(string command, object? arg)> OnCommand { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string RootClass =>\n        $\"flex flex-wrap items-center gap-0.5 border-b border-border/40 bg-muted/30 px-2 py-1.5 {Class}\".Trim();\n\n    private RenderFragment Sep() => builder =>\n    {\n        builder.OpenElement(0, \"span\");\n        builder.AddAttribute(1, \"aria-hidden\", \"true\");\n        builder.AddAttribute(2, \"class\", \"mx-0.5 h-5 w-px bg-border\");\n        builder.CloseElement();\n    };\n\n    private RenderFragment Btn(string label, string icon, string command, bool active, object? arg = null, bool disabled = false)\n        => builder =>\n        {\n            var isDisabled = disabled || Disabled;\n            var cls = active\n                ? \"inline-flex h-7 w-7 items-center justify-center rounded-md bg-accent text-accent-foreground transition-colors\"\n                : \"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\";\n            if (isDisabled) cls += \" opacity-50 cursor-not-allowed\";\n\n            builder.OpenElement(0, \"button\");\n            builder.AddAttribute(1, \"type\", \"button\");\n            builder.AddAttribute(2, \"class\", cls);\n            builder.AddAttribute(3, \"title\", label);\n            builder.AddAttribute(4, \"aria-label\", label);\n            builder.AddAttribute(5, \"aria-pressed\", active ? \"true\" : \"false\");\n            if (isDisabled)\n            {\n                builder.AddAttribute(6, \"disabled\", true);\n            }\n            else\n            {\n                builder.AddAttribute(7, \"onclick\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create(\n                        this, async () => await OnCommand.InvokeAsync((command, arg))));\n                builder.AddAttribute(8, \"onmousedown\", \"event.preventDefault()\");\n            }\n            builder.OpenComponent<Icon>(9);\n            builder.AddAttribute(10, \"Name\", icon);\n            builder.AddAttribute(11, \"Size\", Icon.IconSize.Sm);\n            builder.CloseComponent();\n            builder.CloseElement();\n        };\n}\n"
    },
    {
      "path": "UI/RichTextEditor/EditorToolbarPreset.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Toolbar density presets for <see cref=\"RichTextEditor\"/>.\n/// </summary>\npublic enum EditorToolbarPreset\n{\n    /// <summary>No toolbar at all (consumer-driven via context menu / keyboard).</summary>\n    None,\n    /// <summary>Bold, Italic, Link only.</summary>\n    Minimal,\n    /// <summary>Headings, basic marks, lists, link, undo/redo. Default.</summary>\n    Standard,\n    /// <summary>Standard plus tables, image, code block, AI menu, Word import.</summary>\n    Full,\n}\n"
    },
    {
      "path": "UI/RichTextEditor/EditorTrigger.cs",
      "content": "using Microsoft.AspNetCore.Components;\n\nnamespace Lumeo;\n\n/// <summary>\n/// Registers a typed trigger character (`@`, `#`, `$`, `/`, ...) on the\n/// <see cref=\"RichTextEditor\"/>. When the user types the character, the\n/// editor calls <see cref=\"ItemSource\"/> with the partial query and renders\n/// a floating result list.\n/// </summary>\npublic sealed record EditorTrigger(\n    char Char,\n    Func<string, ValueTask<IReadOnlyList<TriggerItem>>> ItemSource,\n    RenderFragment<TriggerItem>? ItemTemplate = null,\n    string? ChipClass = null);\n\n/// <summary>\n/// A single row shown in the trigger or slash-command dropdown. Lightweight\n/// enough that result lists can be returned from arbitrary backends without\n/// allocating UI types.\n/// </summary>\npublic sealed record TriggerItem(\n    string Id,\n    string Label,\n    string? Subtitle = null,\n    string? IconName = null,\n    object? Payload = null);\n"
    },
    {
      "path": "UI/RichTextEditor/RichTextEditor.razor",
      "content": "@namespace Lumeo\n@inject Services.ComponentInteropService Interop\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n@implements IAsyncDisposable\n\n@if (_initError is not null)\n{\n    <div class=\"rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive\">Editor failed to initialize: @_initError</div>\n}\nelse\n{\n\n@if (Label is not null && !InsideFormField)\n{\n    <label class=\"block text-sm font-medium text-foreground mb-1.5\">\n        @Label\n        @if (Required)\n        {\n            <span class=\"text-destructive ml-0.5\">*</span>\n        }\n    </label>\n}\n\n<div class=\"@RootClass\" @attributes=\"AdditionalAttributes\" aria-required=\"@(Required ? \"true\" : \"false\")\" aria-invalid=\"@(EffectiveInvalid ? \"true\" : \"false\")\">\n    @if (EffectiveToolbar != EditorToolbarPreset.None)\n    {\n        <EditorToolbar Preset=\"EffectiveToolbar\"\n                       Active=\"_active\"\n                       Disabled=\"Disabled || ReadOnly\"\n                       AllowWordImport=\"AllowWordImport\"\n                       EnableAiActions=\"EnableAiActions\"\n                       OnCommand=\"HandleToolbarCommand\" />\n    }\n\n    <div @ref=\"_contentRef\" style=\"@ContentStyle\" class=\"lumeo-rte-content bg-card\"></div>\n</div>\n\n@if (!InsideFormField)\n{\n    @if (_wordImportError is not null)\n    {\n        <p class=\"text-xs text-destructive mt-1.5\" role=\"alert\">@_wordImportError</p>\n    }\n    else if (EffectiveInvalid && ErrorText is not null)\n    {\n        <p class=\"text-xs text-destructive mt-1.5\">@ErrorText</p>\n    }\n    else if (HelperText is not null)\n    {\n        <p class=\"text-xs text-muted-foreground mt-1.5\">@HelperText</p>\n    }\n}\n\n<Dialog @bind-Open=\"_linkDialogOpen\">\n    <DialogContent Class=\"max-w-md\">\n        <DialogHeader>\n            <DialogTitle>@L[\"Editor.InsertLinkTitle\"]</DialogTitle>\n            <DialogDescription>@L[\"Editor.InsertLinkDescription\"]</DialogDescription>\n        </DialogHeader>\n        <div class=\"py-2\">\n            <Input @bind-Value=\"_linkDialogUrl\"\n                   Placeholder=\"https://\"\n                   @onkeydown=\"HandleLinkDialogKey\" />\n        </div>\n        <DialogFooter>\n            <Button Variant=\"Button.ButtonVariant.Outline\" OnClick=\"CancelLinkDialog\">@L[\"Editor.Cancel\"]</Button>\n            <Button Variant=\"Button.ButtonVariant.Default\" OnClick=\"ApplyLinkDialog\" Disabled=\"@string.IsNullOrWhiteSpace(_linkDialogUrl)\">\n                @L[\"Editor.Apply\"]\n            </Button>\n        </DialogFooter>\n    </DialogContent>\n</Dialog>\n\n} @* end @if (_initError is not null) else *@\n\n@code {\n    [CascadingParameter] private FormField.FormFieldContext? FormField { get; set; }\n\n    // ---- Backwards-compatible params ----\n    [Parameter] public string? Value { get; set; }\n    [Parameter] public EventCallback<string?> ValueChanged { get; set; }\n    [Parameter] public string Placeholder { get; set; } = \"Start writing…\";\n    [Parameter] public int MinHeight { get; set; } = 180;\n    [Parameter] public int? MaxHeight { get; set; }\n    [Parameter] public bool Disabled { get; set; }\n    [Parameter] public bool ReadOnly { get; set; }\n    [Parameter] public bool Required { get; set; }\n    [Parameter] public bool Invalid { get; set; }\n    [Parameter] public string? ErrorText { get; set; }\n    [Parameter] public string? HelperText { get; set; }\n    [Parameter] public string? Label { get; set; }\n    [Parameter] public string? Name { get; set; }\n\n    /// <summary>Legacy toolbar mode; superseded by <see cref=\"Toolbar\"/>.</summary>\n    [Parameter] public RichTextToolbarMode? ToolbarMode { get; set; }\n\n    /// <summary>Toolbar density preset. Defaults to <see cref=\"EditorToolbarPreset.Standard\"/>.</summary>\n    [Parameter] public EditorToolbarPreset Toolbar { get; set; } = EditorToolbarPreset.Standard;\n\n    // ---- New: rich features ----\n    [Parameter] public IReadOnlyList<EditorTrigger>? Triggers { get; set; }\n    [Parameter] public bool EnableSlashCommand { get; set; } = true;\n    [Parameter] public bool EnableTables { get; set; } = true;\n    [Parameter] public bool EnableCodeBlock { get; set; } = true;\n    [Parameter] public bool EnableImages { get; set; } = true;\n    [Parameter] public bool EnableTaskList { get; set; } = true;\n    [Parameter] public bool EnableMarkdownShortcuts { get; set; } = true;\n    [Parameter] public bool EnableAiActions { get; set; }\n    [Parameter] public bool EnableBubbleMenu { get; set; } = true;\n    [Parameter] public bool EnableDragHandle { get; set; } = true;\n    [Parameter] public int? MaxLength { get; set; }\n    [Parameter] public bool AllowWordImport { get; set; }\n\n    /// <summary>\n    /// Maximum size, in bytes, of a Word document accepted by the in-browser import path.\n    /// Defaults to 8 MB. Larger files are refused before being read into memory and surfaced\n    /// to the user via <see cref=\"WordImportError\"/>. Set explicitly when consumers expect\n    /// large documents and the deployment can afford the memory.\n    /// </summary>\n    [Parameter] public long MaxWordImportBytes { get; set; } = 8L * 1024L * 1024L;\n\n    /// <summary>Resolves a picked image into a URL (or data URI). Receives file metadata\n    /// from JS; consumer is expected to upload the file out-of-band (e.g. via a paired\n    /// <c>InputFile</c>) and return a final URL. If null, images fall back to base64.</summary>\n    [Parameter] public Func<EditorImageRequest, ValueTask<string?>>? OnImageRequested { get; set; }\n\n    /// <summary>Selection-driven AI actions. Receives the action id and selected plain text;\n    /// returns replacement HTML to be inserted in place of the selection.</summary>\n    [Parameter] public Func<string, string, ValueTask<string?>>? OnAiAction { get; set; }\n\n    /// <summary>Optional override for Word import. Receives the picked file payload\n    /// (filename + bytes) and returns HTML to replace the editor content. When NOT set,\n    /// the file is converted in-browser via <see cref=\"WordImporter\"/> (Mammoth runs\n    /// inside the WASM bundle), so the demo works on static hosting with no server.\n    /// Set this when you want server-side conversion (better fidelity for complex docs).</summary>\n    [Parameter] public Func<WordImportPayload, ValueTask<string>>? OnWordImportRequested { get; set; }\n\n    [Parameter] public EventCallback<string> OnInitError { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private bool EffectiveInvalid => Invalid || FormField?.HasError == true;\n    private bool InsideFormField => FormField is not null;\n\n    private EditorToolbarPreset EffectiveToolbar => ToolbarMode switch\n    {\n        RichTextToolbarMode.None => EditorToolbarPreset.None,\n        RichTextToolbarMode.Basic => EditorToolbarPreset.Minimal,\n        RichTextToolbarMode.Full => EditorToolbarPreset.Full,\n        _ => Toolbar,\n    };\n\n    private Microsoft.AspNetCore.Components.ElementReference _contentRef;\n    private DotNetObjectReference<RichTextEditor>? _selfRef;\n    private string? _instanceId;\n    private string? _lastSetValue;\n    private bool _lastDisabled;\n    private bool _lastReadOnly;\n    private Services.Interop.RichTextActiveState? _active;\n    private bool _initialized;\n    private string? _initError;\n\n    // Link dialog state (replaces the native browser prompt for the toolbar\n    // and bubble menu's Link button).\n    private bool _linkDialogOpen;\n    private string _linkDialogUrl = string.Empty;\n\n    // Surface the most-recent Word import failure (size cap, decode error, etc.)\n    // inline below the editor — same slot the EffectiveInvalid path uses.\n    private string? _wordImportError;\n\n    private void OpenLinkDialog(string? initial)\n    {\n        _linkDialogUrl = string.IsNullOrWhiteSpace(initial) ? \"https://\" : initial;\n        _linkDialogOpen = true;\n        StateHasChanged();\n    }\n\n    private void CancelLinkDialog()\n    {\n        _linkDialogOpen = false;\n    }\n\n    private async Task ApplyLinkDialog()\n    {\n        var url = _linkDialogUrl?.Trim();\n        _linkDialogOpen = false;\n        if (string.IsNullOrWhiteSpace(url) || _instanceId is null) return;\n        await Interop.RichTextCommandAsync(_instanceId, \"link\", new object?[] { url });\n    }\n\n    private async Task HandleLinkDialogKey(KeyboardEventArgs e)\n    {\n        if (e.Key == \"Enter\") await ApplyLinkDialog();\n        else if (e.Key == \"Escape\") CancelLinkDialog();\n    }\n\n    [JSInvokable(\"OnLinkRequested\")]\n    public Task OnLinkRequestedJs(string? currentUrl)\n    {\n        OpenLinkDialog(currentUrl);\n        return Task.CompletedTask;\n    }\n\n    private string RootClass =>\n        $\"rounded-lg border {(EffectiveInvalid ? \"border-destructive focus-within:ring-destructive/20\" : \"border-border focus-within:border-ring/60 focus-within:ring-ring/40\")} bg-card overflow-hidden transition-colors focus-within:ring-[1.5px] {Class}\".Trim();\n\n    private string ContentStyle\n    {\n        get\n        {\n            var parts = new List<string> { $\"min-height: {MinHeight}px\" };\n            if (MaxHeight is int max)\n            {\n                parts.Add($\"max-height: {max}px\");\n                parts.Add(\"overflow-y: auto\");\n            }\n            return string.Join(\"; \", parts) + \";\";\n        }\n    }\n\n    protected override async Task OnAfterRenderAsync(bool firstRender)\n    {\n        if (firstRender)\n        {\n            _selfRef = DotNetObjectReference.Create(this);\n            _lastSetValue = Value;\n            _lastDisabled = Disabled;\n            _lastReadOnly = ReadOnly;\n\n            // Build the \"trigger\" descriptor list passed into JS — only the\n            // metadata the JS needs (char + label). Item resolution happens via\n            // the OnTriggerQuery JSInvokable below, which fans queries out to\n            // the matching EditorTrigger registered server-side.\n            var triggerMeta = (Triggers ?? Array.Empty<EditorTrigger>())\n                .Select(t => new { @char = t.Char.ToString(), label = $\"{t.Char} suggestions\" })\n                .ToArray();\n\n            var options = new\n            {\n                content = Value ?? string.Empty,\n                initialHtml = Value ?? string.Empty,\n                placeholder = Placeholder,\n                disabled = Disabled,\n                readOnly = ReadOnly,\n                editable = !Disabled && !ReadOnly,\n                mentionTriggers = triggerMeta,\n                enableSlashCommand = EnableSlashCommand,\n                enableTables = EnableTables,\n                enableCodeBlock = EnableCodeBlock,\n                enableImages = EnableImages,\n                enableTaskList = EnableTaskList,\n                enableMarkdownShortcuts = EnableMarkdownShortcuts,\n                enableAiActions = EnableAiActions,\n                enableBubbleMenu = EnableBubbleMenu,\n                enableDragHandle = EnableDragHandle,\n                maxLength = MaxLength,\n                maxWordImportBytes = MaxWordImportBytes,\n            };\n\n            try\n            {\n                _instanceId = await Interop.RichTextInitAsync(_contentRef, _selfRef, options);\n            }\n            catch (JSException ex)\n            {\n                _initError = ex.Message;\n                Console.Error.WriteLine($\"[Lumeo RichTextEditor] Init failed: {ex.Message}\");\n                if (OnInitError.HasDelegate)\n                    await OnInitError.InvokeAsync(ex.Message);\n                StateHasChanged();\n                return;\n            }\n            _initialized = !string.IsNullOrEmpty(_instanceId);\n            if (_initialized)\n            {\n                await RefreshActiveAsync();\n            }\n        }\n    }\n\n    protected override async Task OnParametersSetAsync()\n    {\n        if (!_initialized || _instanceId is null) return;\n\n        if (!string.Equals(Value, _lastSetValue, StringComparison.Ordinal))\n        {\n            _lastSetValue = Value;\n            await Interop.RichTextSetContentAsync(_instanceId, Value);\n        }\n\n        if (Disabled != _lastDisabled || ReadOnly != _lastReadOnly)\n        {\n            _lastDisabled = Disabled;\n            _lastReadOnly = ReadOnly;\n            await Interop.RichTextSetDisabledAsync(_instanceId, Disabled || ReadOnly);\n        }\n    }\n\n    // ---------------- JS callbacks ----------------\n\n    [JSInvokable]\n    public async Task OnContentUpdate(string html)\n    {\n        _lastSetValue = html;\n        Value = html;\n        await ValueChanged.InvokeAsync(html);\n        await RefreshActiveAsync();\n    }\n\n    /// <summary>Mirror of <see cref=\"OnContentUpdate\"/> kept for the new JS naming.</summary>\n    [JSInvokable]\n    public Task OnUpdate(string html) => OnContentUpdate(html);\n\n    [JSInvokable]\n    public Task OnSelectionUpdate() => RefreshActiveAsync();\n\n    [JSInvokable]\n    public async Task<IReadOnlyList<TriggerItem>> OnTriggerQuery(string triggerChar, string query)\n    {\n        // Slash command default — only kicks in if no consumer trigger overrides '/'.\n        if (triggerChar == \"/\" && (Triggers is null || !Triggers.Any(t => t.Char == '/')))\n        {\n            return EnableSlashCommand\n                ? await SlashCommands.Filter(query, L)\n                : Array.Empty<TriggerItem>();\n        }\n\n        var trigger = Triggers?.FirstOrDefault(t => t.Char.ToString() == triggerChar);\n        if (trigger is null) return Array.Empty<TriggerItem>();\n        try\n        {\n            return await trigger.ItemSource(query) ?? Array.Empty<TriggerItem>();\n        }\n        catch\n        {\n            return Array.Empty<TriggerItem>();\n        }\n    }\n\n    [JSInvokable(\"OnImageRequested\")]\n    public async Task<string?> OnImageRequestedJs(string fileName, string mimeType, long size)\n    {\n        if (OnImageRequested is null) return null;\n        try\n        {\n            return await OnImageRequested(new EditorImageRequest(fileName, mimeType, size));\n        }\n        catch\n        {\n            return null;\n        }\n    }\n\n    [JSInvokable(\"OnAiAction\")]\n    public async Task<string?> OnAiActionJs(string actionId, string selectedText)\n    {\n        if (OnAiAction is null) return null;\n        try\n        {\n            return await OnAiAction(actionId, selectedText);\n        }\n        catch\n        {\n            return null;\n        }\n    }\n\n    [JSInvokable(\"OnWordImportRequested\")]\n    public async Task OnWordImportRequestedJs(string fileName, string base64Content)\n    {\n        try\n        {\n            // Pre-allocation size guard — decoded length is computed from the base64\n            // string length, so an oversized payload is rejected BEFORE the .NET\n            // byte[] allocation that would normally double peak memory usage.\n            var bytes = WordImporter.DecodeBase64WithLimit(base64Content, MaxWordImportBytes);\n            var payload = new WordImportPayload(fileName, bytes);\n\n            string html;\n            if (OnWordImportRequested is not null)\n            {\n                html = await OnWordImportRequested(payload);\n            }\n            else\n            {\n                using var stream = new MemoryStream(bytes);\n                var result = await WordImporter.ToHtmlAsync(stream);\n                html = result.Html;\n            }\n\n            if (!string.IsNullOrEmpty(html))\n            {\n                _wordImportError = null;\n                await SetHtmlAsync(html);\n            }\n        }\n        catch (WordImportSizeException sizeEx)\n        {\n            _wordImportError = $\"Word document too large ({FormatBytes(sizeEx.ActualBytes)}). Limit is {FormatBytes(sizeEx.LimitBytes)}.\";\n            StateHasChanged();\n        }\n        catch (Exception ex)\n        {\n            _wordImportError = \"Word import failed. Try saving the document again or pick a different file.\";\n            Console.Error.WriteLine($\"Word import failed: {ex.Message}\");\n            StateHasChanged();\n        }\n    }\n\n    [JSInvokable(\"OnWordImportFailed\")]\n    public Task OnWordImportFailedJs(string reason, long size, long limit)\n    {\n        _wordImportError = reason switch\n        {\n            \"file_too_large\" => $\"Word document too large ({FormatBytes(size)}). Limit is {FormatBytes(limit)}.\",\n            _ => \"Word import failed. Try saving the document again or pick a different file.\",\n        };\n        StateHasChanged();\n        return Task.CompletedTask;\n    }\n\n    private static string FormatBytes(long bytes)\n    {\n        if (bytes < 1024) return $\"{bytes} B\";\n        if (bytes < 1024 * 1024) return $\"{bytes / 1024.0:0.#} KB\";\n        return $\"{bytes / (1024.0 * 1024.0):0.#} MB\";\n    }\n\n    // ---------------- Toolbar dispatch ----------------\n\n    private async Task HandleToolbarCommand((string command, object? arg) cmd)\n    {\n        if (_instanceId is null || Disabled || ReadOnly) return;\n\n        switch (cmd.command)\n        {\n            case \"link\":\n            {\n                OpenLinkDialog(null);\n                break;\n            }\n            case \"ai-menu\":\n                // Reserved for a future popover. For now, fire an \"improve\" against\n                // current selection if the consumer wired OnAiAction.\n                if (OnAiAction is not null)\n                {\n                    await Interop.RichTextCommandAsync(_instanceId, \"noop\", null);\n                }\n                break;\n            default:\n                var args = cmd.arg is null ? null : new object?[] { cmd.arg };\n                await Interop.RichTextCommandAsync(_instanceId, cmd.command, args);\n                break;\n        }\n\n        await RefreshActiveAsync();\n    }\n\n    private async Task RefreshActiveAsync()\n    {\n        if (_instanceId is null) return;\n        _active = await Interop.RichTextGetActiveAsync(_instanceId);\n        StateHasChanged();\n    }\n\n    // ---------------- Public API for consumers ----------------\n\n    /// <summary>Replaces the editor content with the given HTML.</summary>\n    public async ValueTask SetHtmlAsync(string? html)\n    {\n        if (_instanceId is null) return;\n        _lastSetValue = html;\n        Value = html;\n        await Interop.RichTextSetContentAsync(_instanceId, html);\n        await ValueChanged.InvokeAsync(html);\n    }\n\n    public async ValueTask DisposeAsync()\n    {\n        if (_instanceId is not null)\n        {\n            try\n            {\n                await Interop.RichTextDestroyAsync(_instanceId);\n            }\n            catch (JSDisconnectedException) { /* ignore — circuit already gone */ }\n            _instanceId = null;\n        }\n        _selfRef?.Dispose();\n        _selfRef = null;\n    }\n\n    /// <summary>Legacy toolbar mode — kept so existing call sites compile.</summary>\n    public enum RichTextToolbarMode\n    {\n        None,\n        Basic,\n        Full,\n    }\n}\n"
    },
    {
      "path": "UI/RichTextEditor/SlashCommands.cs",
      "content": "using Lumeo.Services.Localization;\n\nnamespace Lumeo;\n\n/// <summary>\n/// Built-in slash-command catalogue used when\n/// <see cref=\"RichTextEditor.EnableSlashCommand\"/> is true and the consumer\n/// hasn't supplied their own `/` trigger.\n/// </summary>\npublic static class SlashCommands\n{\n    /// <summary>Default Notion-style slash command set with EN labels. Prefer\n    /// <see cref=\"LocalizedDefault\"/> when a localizer is available.</summary>\n    public static IReadOnlyList<TriggerItem> Default { get; } = new TriggerItem[]\n    {\n        new(\"h1\",      \"Heading 1\",     \"Big section heading\",            \"Heading1\"),\n        new(\"h2\",      \"Heading 2\",     \"Medium section heading\",         \"Heading2\"),\n        new(\"h3\",      \"Heading 3\",     \"Small section heading\",          \"Heading3\"),\n        new(\"bullet\",  \"Bullet list\",   \"Simple list\",                    \"List\"),\n        new(\"ordered\", \"Numbered list\", \"Ordered list\",                   \"ListOrdered\"),\n        new(\"task\",    \"Task list\",     \"Check items off as you go\",      \"ListChecks\"),\n        new(\"quote\",   \"Quote\",         \"Block quote\",                    \"Quote\"),\n        new(\"code\",    \"Code block\",    \"Syntax-highlighted code\",        \"SquareCode\"),\n        new(\"table\",   \"Table\",         \"Insert a 3×3 table\",             \"Table\"),\n        new(\"image\",   \"Image\",         \"Upload from your device\",        \"Image\"),\n        new(\"divider\", \"Divider\",       \"Horizontal rule\",                \"Minus\"),\n    };\n\n    /// <summary>Returns the default slash command set with labels resolved through the active localizer.</summary>\n    public static IReadOnlyList<TriggerItem> LocalizedDefault(ILumeoLocalizer L) => new TriggerItem[]\n    {\n        new(\"h1\",      L[\"Editor.SlashHeading1\"],     L[\"Editor.SlashHeading1Sub\"],     \"Heading1\"),\n        new(\"h2\",      L[\"Editor.SlashHeading2\"],     L[\"Editor.SlashHeading2Sub\"],     \"Heading2\"),\n        new(\"h3\",      L[\"Editor.SlashHeading3\"],     L[\"Editor.SlashHeading3Sub\"],     \"Heading3\"),\n        new(\"bullet\",  L[\"Editor.SlashBulletList\"],   L[\"Editor.SlashBulletListSub\"],   \"List\"),\n        new(\"ordered\", L[\"Editor.SlashNumberedList\"], L[\"Editor.SlashNumberedListSub\"], \"ListOrdered\"),\n        new(\"task\",    L[\"Editor.SlashTaskList\"],     L[\"Editor.SlashTaskListSub\"],     \"ListChecks\"),\n        new(\"quote\",   L[\"Editor.SlashQuote\"],        L[\"Editor.SlashQuoteSub\"],        \"Quote\"),\n        new(\"code\",    L[\"Editor.SlashCodeBlock\"],    L[\"Editor.SlashCodeBlockSub\"],    \"SquareCode\"),\n        new(\"table\",   L[\"Editor.SlashTable\"],        L[\"Editor.SlashTableSub\"],        \"Table\"),\n        new(\"image\",   L[\"Editor.SlashImage\"],        L[\"Editor.SlashImageSub\"],        \"Image\"),\n        new(\"divider\", L[\"Editor.SlashDivider\"],      L[\"Editor.SlashDividerSub\"],      \"Minus\"),\n    };\n\n    /// <summary>\n    /// Filters <see cref=\"Default\"/> by a free-text query (case-insensitive\n    /// substring match on label or id). Suitable for use as the `ItemSource`\n    /// of an <see cref=\"EditorTrigger\"/>.\n    /// </summary>\n    public static ValueTask<IReadOnlyList<TriggerItem>> Filter(string query)\n    {\n        if (string.IsNullOrWhiteSpace(query))\n            return ValueTask.FromResult(Default);\n\n        var q = query.Trim();\n        var filtered = Default\n            .Where(i =>\n                i.Label.Contains(q, StringComparison.OrdinalIgnoreCase) ||\n                i.Id.Contains(q, StringComparison.OrdinalIgnoreCase))\n            .ToList();\n        return ValueTask.FromResult<IReadOnlyList<TriggerItem>>(filtered);\n    }\n\n    /// <summary>\n    /// Filters <see cref=\"LocalizedDefault\"/> by a free-text query using localized labels.\n    /// </summary>\n    public static ValueTask<IReadOnlyList<TriggerItem>> Filter(string query, ILumeoLocalizer L)\n    {\n        var localized = LocalizedDefault(L);\n        if (string.IsNullOrWhiteSpace(query))\n            return ValueTask.FromResult(localized);\n\n        var q = query.Trim();\n        var filtered = localized\n            .Where(i =>\n                i.Label.Contains(q, StringComparison.OrdinalIgnoreCase) ||\n                i.Id.Contains(q, StringComparison.OrdinalIgnoreCase))\n            .ToList();\n        return ValueTask.FromResult<IReadOnlyList<TriggerItem>>(filtered);\n    }\n}\n"
    },
    {
      "path": "UI/RichTextEditor/TriggerDropdown.razor",
      "content": "@namespace Lumeo\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@*\n    Floating list rendered above/below a trigger caret. Driven by Items + a\n    selected index. Keyboard navigation (Up/Down/Enter/Escape) is handled by\n    the parent (the JS suggestion plugin already does this in the live editor;\n    this Razor primitive exists for consumers who want to render their own\n    trigger UI server-side, e.g. in a Mention input).\n*@\n\n<div class=\"@RootClass\" role=\"listbox\" aria-label=\"@AriaLabel\" id=\"@Id\" @attributes=\"AdditionalAttributes\">\n    @if (Items is null || Items.Count == 0)\n    {\n        <div class=\"px-2.5 py-2 text-xs text-muted-foreground\">@L[\"Editor.NoResults\"]</div>\n    }\n    else\n    {\n        @for (var i = 0; i < Items.Count; i++)\n        {\n            var idx = i;\n            var item = Items[idx];\n            var selected = idx == SelectedIndex;\n            var rowClass = selected\n                ? \"flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer bg-accent text-accent-foreground\"\n                : \"flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer hover:bg-accent/60\";\n\n            <div role=\"option\"\n                 aria-selected=\"@(selected ? \"true\" : \"false\")\"\n                 class=\"@rowClass\"\n                 @onmousedown:preventDefault=\"true\"\n                 @onclick=\"() => OnPick.InvokeAsync(item)\"\n                 @onmouseenter=\"() => OnHover.InvokeAsync(idx)\">\n                @if (ItemTemplate is not null)\n                {\n                    @ItemTemplate(item)\n                }\n                else\n                {\n                    @if (!string.IsNullOrEmpty(item.IconName))\n                    {\n                        <span class=\"mt-0.5 text-muted-foreground\">\n                            <Icon Name=\"@item.IconName\" Size=\"Icon.IconSize.Sm\" />\n                        </span>\n                    }\n                    <div class=\"min-w-0 flex-1\">\n                        <div class=\"text-sm font-medium leading-tight\">@item.Label</div>\n                        @if (!string.IsNullOrEmpty(item.Subtitle))\n                        {\n                            <div class=\"text-xs text-muted-foreground leading-tight\">@item.Subtitle</div>\n                        }\n                    </div>\n                }\n            </div>\n        }\n    }\n</div>\n\n@code {\n    [Parameter] public string? Id { get; set; }\n    [Parameter] public IReadOnlyList<TriggerItem>? Items { get; set; }\n    [Parameter] public int SelectedIndex { get; set; }\n    [Parameter] public RenderFragment<TriggerItem>? ItemTemplate { get; set; }\n    [Parameter] public EventCallback<TriggerItem> OnPick { get; set; }\n    [Parameter] public EventCallback<int> OnHover { get; set; }\n    [Parameter] public string AriaLabel { get; set; } = \"Suggestions\";\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string RootClass =>\n        $\"min-w-[200px] max-w-[340px] max-h-[280px] overflow-y-auto rounded-lg border border-border/60 bg-popover text-popover-foreground shadow-lg p-1 {Class}\".Trim();\n}\n"
    },
    {
      "path": "UI/RichTextEditor/WordImporter.cs",
      "content": "namespace Lumeo;\n\n/// <summary>Result of a Word document import.</summary>\npublic sealed record WordImportResult(string Html, IReadOnlyList<string> Warnings);\n\n/// <summary>Options for <see cref=\"WordImporter.ToHtmlAsync\"/>.</summary>\npublic sealed class WordImportOptions\n{\n    /// <summary>Custom style map (Mammoth syntax). When null, <see cref=\"WordImporter.DefaultStyleMap\"/> is used.</summary>\n    public string? StyleMap { get; init; }\n\n    /// <summary>\n    /// Optional callback to handle embedded images.\n    /// Receives (contentType, imageStream) and returns the URL to use as the img src.\n    /// When null, images are inlined as base64 data URIs (Mammoth default).\n    /// </summary>\n    public Func<string, Stream, ValueTask<string>>? ConvertImage { get; init; }\n\n    /// <summary>\n    /// When true (default) and <see cref=\"StyleMap\"/> is also set,\n    /// the default style map is prepended so custom rules take precedence.\n    /// </summary>\n    public bool IncludeDefaultStyleMap { get; init; } = true;\n}\n\n/// <summary>\n/// Server-side helper that converts a .docx stream to clean HTML using Mammoth.NET.\n/// Call from an HTTP endpoint; consumers wire the result into the editor via\n/// <c>WordImportRequest.SetHtml</c>.\n/// </summary>\npublic static class WordImporter\n{\n    /// <summary>\n    /// Decodes a base64 string into a byte array, but rejects payloads whose\n    /// decoded length would exceed <paramref name=\"maxBytes\"/> BEFORE calling\n    /// <see cref=\"Convert.FromBase64String\"/> — so an attacker can't force a\n    /// large allocation by submitting an oversized base64 blob.\n    /// </summary>\n    /// <exception cref=\"WordImportSizeException\">When the decoded payload would exceed the limit.</exception>\n    public static byte[] DecodeBase64WithLimit(string base64, long maxBytes)\n    {\n        if (base64 is null) throw new ArgumentNullException(nameof(base64));\n        if (maxBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxBytes));\n\n        // Compute decoded length without allocating: every 4 base64 chars = 3 bytes,\n        // minus 1 byte per '=' padding char. Whitespace would skew this; we strip it\n        // up-front since Convert.FromBase64String tolerates it but we need a true count.\n        var sanitized = StripBase64Whitespace(base64);\n        var paddingChars = 0;\n        for (var i = sanitized.Length - 1; i >= 0 && sanitized[i] == '='; i--) paddingChars++;\n        var decodedLength = ((long)(sanitized.Length - paddingChars) * 3L) / 4L;\n\n        if (decodedLength > maxBytes)\n        {\n            throw new WordImportSizeException(decodedLength, maxBytes);\n        }\n\n        return Convert.FromBase64String(sanitized);\n    }\n\n    private static string StripBase64Whitespace(string s)\n    {\n        // Fast path: no whitespace.\n        var hasWs = false;\n        for (var i = 0; i < s.Length; i++)\n        {\n            var c = s[i];\n            if (c == ' ' || c == '\\r' || c == '\\n' || c == '\\t') { hasWs = true; break; }\n        }\n        if (!hasWs) return s;\n\n        var sb = new System.Text.StringBuilder(s.Length);\n        foreach (var c in s)\n        {\n            if (c != ' ' && c != '\\r' && c != '\\n' && c != '\\t') sb.Append(c);\n        }\n        return sb.ToString();\n    }\n\n    /// <summary>\n    /// Default style map covering English and German Word built-in styles\n    /// (Title/Titel, Heading 1-6/Überschrift 1-6, Body Text/Textkörper,\n    /// List Paragraph/Listenabsatz, Quote/Zitat, Caption/Beschriftung,\n    /// Strong/Emphasis runs).\n    /// </summary>\n    public const string DefaultStyleMap = \"\"\"\n        p[style-name='Title']          => h1.doc-title:fresh\n        p[style-name='Titel']          => h1.doc-title:fresh\n        p[style-name='Subtitle']       => h2.doc-subtitle:fresh\n        p[style-name='Untertitel']     => h2.doc-subtitle:fresh\n        p[style-name='Heading 1']      => h1:fresh\n        p[style-name='Überschrift 1']  => h1:fresh\n        p[style-name='Heading 2']      => h2:fresh\n        p[style-name='Überschrift 2']  => h2:fresh\n        p[style-name='Heading 3']      => h3:fresh\n        p[style-name='Überschrift 3']  => h3:fresh\n        p[style-name='Heading 4']      => h4:fresh\n        p[style-name='Überschrift 4']  => h4:fresh\n        p[style-name='Heading 5']      => h5:fresh\n        p[style-name='Überschrift 5']  => h5:fresh\n        p[style-name='Heading 6']      => h6:fresh\n        p[style-name='Überschrift 6']  => h6:fresh\n        p[style-name='Body Text']      => p:fresh\n        p[style-name='Textkörper']     => p:fresh\n        p[style-name='List Paragraph'] => li:fresh\n        p[style-name='Listenabsatz']   => li:fresh\n        p[style-name='Quote']          => blockquote:fresh\n        p[style-name='Zitat']          => blockquote:fresh\n        p[style-name='Caption']        => p.caption:fresh\n        p[style-name='Beschriftung']   => p.caption:fresh\n        r[style-name='Strong']         => strong\n        r[style-name='Emphasis']       => em\n        \"\"\";\n\n    /// <summary>\n    /// Converts a .docx stream to HTML.\n    /// Runs Mammoth on a thread-pool thread so the calling thread is not blocked.\n    /// </summary>\n    /// <param name=\"docxStream\">Readable stream containing the .docx bytes.</param>\n    /// <param name=\"options\">Conversion options; pass null to use all defaults.</param>\n    public static async ValueTask<WordImportResult> ToHtmlAsync(\n        Stream docxStream,\n        WordImportOptions? options = null)\n    {\n        options ??= new WordImportOptions();\n\n        // Build the effective style map: default + custom (custom wins because\n        // Mammoth gives highest precedence to the last-added style map).\n        var styleMap = options switch\n        {\n            { StyleMap: not null, IncludeDefaultStyleMap: true } => DefaultStyleMap + \"\\n\" + options.StyleMap,\n            { StyleMap: not null, IncludeDefaultStyleMap: false } => options.StyleMap,\n            _ => DefaultStyleMap\n        };\n\n        // Capture the image callback so we can reference it inside Task.Run.\n        var imageCallback = options.ConvertImage;\n\n        var result = await Task.Run(() =>\n        {\n            var converter = new Mammoth.DocumentConverter()\n                .AddStyleMap(styleMap);\n\n            if (imageCallback is not null)\n            {\n                // Mammoth's ImageConverter is sync-only. We bridge async → sync by\n                // buffering the image stream (already fully loaded in memory by Mammoth)\n                // and blocking on the async callback. This runs on a thread-pool thread\n                // so blocking here is safe.\n                converter = converter.ImageConverter(image =>\n                {\n                    using var stream = image.GetStream();\n                    using var buffer = new MemoryStream();\n                    stream.CopyTo(buffer);\n                    buffer.Position = 0;\n\n                    var url = imageCallback(image.ContentType, buffer)\n                        .AsTask()\n                        .GetAwaiter()\n                        .GetResult();\n\n                    return new Dictionary<string, string> { { \"src\", url } };\n                });\n            }\n\n            return converter.ConvertToHtml(docxStream);\n        });\n\n        return new WordImportResult(result.Value, result.Warnings.ToArray());\n    }\n}\n"
    },
    {
      "path": "UI/RichTextEditor/WordImportPayload.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Picked .docx file payload handed to <see cref=\"RichTextEditor.OnWordImportRequested\"/>.\n/// </summary>\npublic sealed record WordImportPayload(string FileName, byte[] Content);\n"
    },
    {
      "path": "UI/RichTextEditor/WordImportSizeException.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Thrown when a Word document upload exceeds the configured size limit.\n/// Lightweight, reusable across the JS-side guard and the .NET-side decode guard.\n/// </summary>\npublic sealed class WordImportSizeException : Exception\n{\n    public long ActualBytes { get; }\n    public long LimitBytes { get; }\n\n    public WordImportSizeException(long actualBytes, long limitBytes)\n        : base($\"Word document is {actualBytes:N0} bytes which exceeds the {limitBytes:N0}-byte import limit.\")\n    {\n        ActualBytes = actualBytes;\n        LimitBytes = limitBytes;\n    }\n}\n"
    }
  ],
  "slots": [
    {
      "name": "ItemTemplate",
      "owner": "TriggerDropdown",
      "type": "RenderFragment<TriggerItem>?",
      "description": null
    }
  ],
  "serviceDependencies": [
    {
      "service": "ILumeoLocalizer",
      "namespace": "Lumeo.Services.Localization"
    },
    {
      "service": "ComponentInteropService",
      "namespace": "Services"
    }
  ],
  "cascadingDependencies": [
    {
      "name": "FormField",
      "type": "FormField.FormFieldContext?",
      "required": false
    }
  ],
  "relatedComponents": [
    {
      "name": "AiActionMenu",
      "reason": "sub-component"
    },
    {
      "name": "BubbleMenu",
      "reason": "sub-component"
    },
    {
      "name": "EditorToolbar",
      "reason": "sub-component"
    },
    {
      "name": "TriggerDropdown",
      "reason": "sub-component"
    },
    {
      "name": "Icon",
      "reason": "used internally"
    },
    {
      "name": "Dialog",
      "reason": "used internally"
    },
    {
      "name": "Input",
      "reason": "used internally"
    },
    {
      "name": "Button",
      "reason": "used internally"
    }
  ],
  "keyboardInteractions": [
    {
      "key": "Enter",
      "action": "HandleLinkDialogKey handles Enter in RichTextEditor.razor"
    },
    {
      "key": "Escape",
      "action": "HandleLinkDialogKey handles Escape in RichTextEditor.razor"
    }
  ],
  "tests": [
    "tests/Lumeo.Tests/Components/RichTextEditor/RichTextEditorTests.cs"
  ],
  "mdSummary": "# RichTextEditor\n\nWYSIWYG editor wrapping TipTap with Lumeo styling.\n\n**Package:** `Lumeo.Editor` · **Category:** Forms/Specialized\n**Docs:** https://lumeo.nativ.sh/components/rich-text-editor\n\n## Parameters\n| Owner | Name | Type | Default | Description |\n|---|---|---|---|---|\n| RichTextEditor | FormField | `FormField.FormFieldContext?` | `` |  |\n| RichTextEditor | Value | `string?` | `` |  |\n| RichTextEditor | ValueChanged | `EventCallback<string?>` | `` |  |\n| RichTextEditor | Placeholder | `string` | `\"Start writing…\"` |  |\n| RichTextEditor | MinHeight | `int` | `180` |  |\n| RichTextEditor | MaxHeight | `int?` | `` |  |\n| RichTextEditor | Disabled | `bool` | `` |  |\n| RichTextEditor | ReadOnly | `bool` | `` |  |\n| RichTextEditor | Required | `bool` | `` |  |\n| RichTextEditor | Invalid | `bool` | `` |  |\n| RichTextEditor | ErrorText | `string?` | `` |  |\n| RichTextEditor | HelperText | `string?` | `` |  |\n| RichTextEditor | Label | `string?` | `` |  |\n| RichTextEditor | Name | `string?` | `` |  |\n| RichTextEditor | ToolbarMode | `RichTextToolbarMode?` | `` | Legacy toolbar mode; superseded by Toolbar. |\n| RichTextEditor | Toolbar | `EditorToolbarPreset` | `EditorToolbarPreset.Standard` | Toolbar density preset. Defaults to Standard. |\n| RichTextEditor | Triggers | `IReadOnlyList<EditorTrigger>?` | `` |  |\n| RichTextEditor | EnableSlashCommand | `bool` | `true` |  |\n| RichTextEditor | EnableTables | `bool` | `true` |  |\n| RichTextEditor | EnableCodeBlock | `bool` | `true` |  |\n| RichTextEditor | EnableImages | `bool` | `true` |  |\n| RichTextEditor | EnableTaskList | `bool` | `true` |  |\n| RichTextEditor | EnableMarkdownShortcuts | `bool` | `true` |  |\n| RichTextEditor | EnableAiActions | `bool` | `` |  |\n| RichTextEditor | EnableBubbleMenu | `bool` | `true` |  |\n| RichTextEditor | EnableDragHandle | `bool` | `true` |  |\n| RichTextEditor | MaxLength | `int?` | `` |  |\n| RichTextEditor | AllowWordImport | `bool` | `` |  |\n| RichTextEditor | MaxWordImportBytes | `long` | `8L * 1024L * 1024L` | Maximum size, in bytes, of a Word document accepted by the in-browser import path. Defaults to 8 MB. Larger files are refused before being read into memory and surfaced to the user via WordImportError. Set explicitly when consumers expect large documents and the deployment can afford the memory. |\n| RichTextEditor | OnImageRequested | `Func<EditorImageRequest, ValueTask<string?>>?` | `` | Resolves a picked image into a URL (or data URI). Receives file metadata from JS; consumer is expected to upload the file out-of-band (e.g. via a paired InputFile) and return a final URL. If null, images fall back to base64. |\n| RichTextEditor | OnAiAction | `Func<string, string, ValueTask<string?>>?` | `` | Selection-driven AI actions. Receives the action id and selected plain text; returns replacement HTML to be inserted in place of the selection. |\n| RichTextEditor | OnWordImportRequested | `Func<WordImportPayload, ValueTask<string>>?` | `` | Optional override for Word import. Receives the picked file payload (filename + bytes) and returns HTML to replace the editor content. When NOT set, the file is converted in-browser via WordImporter (Mammoth runs inside the WASM bundle), so the demo works on static hosting with no server. Set this when you want server-side conversion (better fidelity for complex docs). |\n| RichTextEditor | OnInitError | `EventCallback<string>` | `` |  |\n| RichTextEditor | Class | `string?` | `` |  |\n| RichTextEditor | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| AiActionMenu | Actions | `IReadOnlyList<AiAction>?` | `` |  |\n| AiActionMenu | OnAction | `EventCallback<AiAction>` | `` |  |\n| AiActionMenu | Class | `string?` | `` |  |\n| AiActionMenu | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| BubbleMenu | EnableAiActions | `bool` | `` |  |\n| BubbleMenu | OnCommand | `EventCallback<string>` | `` |  |\n| BubbleMenu | OnAiMenuToggled | `EventCallback` | `` |  |\n| BubbleMenu | Class | `string?` | `` |  |\n| BubbleMenu | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| EditorToolbar | Preset | `EditorToolbarPreset` | `EditorToolbarPreset.Standard` |  |\n| EditorToolbar | Active | `Lumeo.Services.Interop.RichTextActiveState?` | `` |  |\n| EditorToolbar | Disabled | `bool` | `` |  |\n| EditorToolbar | AllowWordImport | `bool` | `` |  |\n| EditorToolbar | EnableAiActions | `bool` | `` |  |\n| EditorToolbar | OnCommand | `EventCallback<(string command, object? arg)>` | `` |  |\n| EditorToolbar | Class | `string?` | `` |  |\n| EditorToolbar | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| TriggerDropdown | Id | `string?` | `` |  |\n| TriggerDropdown | Items | `IReadOnlyList<TriggerItem>?` | `` |  |\n| TriggerDropdown | SelectedIndex | `int` | `` |  |\n| TriggerDropdown | ItemTemplate | `RenderFragment<TriggerItem>?` | `` |  |\n| TriggerDropdown | OnPick | `EventCallback<TriggerItem>` | `` |  |\n| TriggerDropdown | OnHover | `EventCallback<int>` | `` |  |\n| TriggerDropdown | AriaLabel | `string` | `\"Suggestions\"` |  |\n| TriggerDropdown | Class | `string?` | `` |  |\n| TriggerDropdown | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n\n## Slots\n- `ItemTemplate` (TriggerDropdown) — RenderFragment slot\n\n## Events\n- `ValueChanged` (`EventCallback<string?>`)\n- `OnInitError` (`EventCallback<string>`)\n\n## Sub-components\n- `AiActionMenu`, `BubbleMenu`, `EditorToolbar`, `TriggerDropdown`\n\n## Cascading values it expects\n- `FormField` (`FormField.FormFieldContext?`)\n\n## Services it injects\n- `ILumeoLocalizer` (Lumeo.Services.Localization)\n- `ComponentInteropService` (Services)\n\n## Keyboard\n- `Enter` — HandleLinkDialogKey handles Enter in RichTextEditor.razor\n- `Escape` — HandleLinkDialogKey handles Escape in RichTextEditor.razor\n\n## Example\n```razor\n<RichTextEditor />\n```\n\n## CSS variables\n`--color-accent`, `--color-accent-foreground`, `--color-border`, `--color-card`, `--color-destructive`, `--color-foreground`, `--color-muted`, `--color-muted-foreground`, `--color-popover`, `--color-popover-foreground`, `--color-ring`, `--radius`\n\n## Files\n- `UI/RichTextEditor/AiAction.cs`\n- `UI/RichTextEditor/AiActionMenu.razor`\n- `UI/RichTextEditor/BubbleMenu.razor`\n- `UI/RichTextEditor/EditorImageRequest.cs`\n- `UI/RichTextEditor/EditorToolbar.razor`\n- `UI/RichTextEditor/EditorToolbarPreset.cs`\n- `UI/RichTextEditor/EditorTrigger.cs`\n- `UI/RichTextEditor/RichTextEditor.razor`\n- `UI/RichTextEditor/SlashCommands.cs`\n- `UI/RichTextEditor/TriggerDropdown.razor`\n- `UI/RichTextEditor/WordImporter.cs`\n- `UI/RichTextEditor/WordImportPayload.cs`\n- `UI/RichTextEditor/WordImportSizeException.cs`\n"
}