Skip to content
Brok UI

Loading…

No results

Editor

Open source

A lightweight contenteditable rich-text editor surface with a toolbar slot, marks (bold/italic/underline/strike/code/link), block types (headings, blockquote, code, lists, to-do lists, callouts, toggles), typed / and @ triggers at the caret, mentions, images by URL or upload, sanitised saved HTML for x-model, wire:model or a hidden form field, and a reactive selection state other editor parts bind to.

Version
v1.7.1
Stability
stable
License
MIT
Related
Editor Toolbar
Editor Media
Block Handle

Preview

Adding page…

Draft Last edited just now
previews.components.editor.default.blade.php Blade
<x-ui.editor class="mx-auto max-w-2xl" placeholder="Write something…">
    <x-slot:toolbar>
        <x-ui.editor.toolbar />
    </x-slot:toolbar>

    <x-slot:tools>
        <x-ui.editor.toolbar variant="floating" />
        <x-ui.editor.block-handle />
        <x-ui.editor.drop-indicator />
    </x-slot:tools>

    <h1>The quick brown fox</h1>
    <p>A lightweight rich-text surface with <strong>bold</strong>, <em>italic</em>, an
        <a href="#">inline link</a> and <code>inline code</code>.</p>
    <blockquote>Compose the toolbar, menus and inline controls around it.</blockquote>
    <ul><li>Bulleted item</li><li>Another item</li></ul>
    <ol><li>Numbered step</li><li>Second step</li></ol>
    <pre>const editor = useEditor();</pre>

    <x-slot:footer>
        <span>{{ __('Draft') }}</span>
        <span>{{ __('Last edited just now') }}</span>
    </x-slot:footer>
</x-ui.editor>

Installation

terminal
php artisan ui:add editor

Note

This component ships an Alpine behavior module at resources/js/ui/editor.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './editor.js';

Registry contract

php artisan ui:add editor writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/editor.blade.php
  • js resources/js/ui/editor.js
  • js resources/js/ui/editor-blocks.js
  • js resources/js/ui/editor-gutter.js
  • js resources/js/ui/editor-embeds.js
  • js resources/js/ui/editor-triggers.js
  • js resources/js/ui/editor-images.js
  • js resources/js/ui/editor-menu-trigger.js
  • js resources/js/ui/editor-pages.js
  • js resources/js/ui/editor-markdown.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

editor.md
# Brok UI: Editor (`editor`)

A lightweight contenteditable rich-text editor surface with a toolbar slot, marks (bold/italic/underline/strike/code/link), block types (headings, blockquote, code, lists, to-do lists, callouts, toggles), typed / and @ triggers at the caret, mentions, images by URL or upload, sanitised saved HTML for x-model, wire:model or a hidden form field, and a reactive selection state other editor parts bind to.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add editor
```

## Usage

```blade
<x-ui.editor class="mx-auto max-w-2xl" placeholder="Write something…">
    <x-slot:toolbar>
        <x-ui.editor.toolbar />
    </x-slot:toolbar>

    <x-slot:tools>
        <x-ui.editor.toolbar variant="floating" />
        <x-ui.editor.block-handle />
        <x-ui.editor.drop-indicator />
    </x-slot:tools>

    <h1>The quick brown fox</h1>
    <p>A lightweight rich-text surface with <strong>bold</strong>, <em>italic</em>, an
        <a href="#">inline link</a> and <code>inline code</code>.</p>
    <blockquote>Compose the toolbar, menus and inline controls around it.</blockquote>
    <ul><li>Bulleted item</li><li>Another item</li></ul>
    <ol><li>Numbered step</li><li>Second step</li></ol>
    <pre>const editor = useEditor();</pre>

    <x-slot:footer>
        <span>{{ __('Draft') }}</span>
        <span>{{ __('Last edited just now') }}</span>
    </x-slot:footer>
</x-ui.editor>
```

## Props

- `placeholder` (string, default `Write something…`) — Text shown in the placeholder overlay while the document is empty.
- `ariaLabel` (string, default `Rich text editor`) — Accessible name for the contenteditable region.
- `name` (string|null, default `null`) — Form field name. When set, a hidden input carries the saved HTML so a plain form post submits the document.
- `slashTrigger` (boolean, default `true`) — Typing / at the start of a block or after a space opens a slash menu placed in this editor, at the caret. Set slash-trigger="false" to turn it off.
- `mentionTrigger` (boolean, default `true`) — Typing a mention menu's trigger (@ by default) at a word start opens that menu at the caret. Set mention-trigger="false" to turn it off.
- `upload` (boolean, default `false`) — Show an Upload button in the image form and accept dropped or pasted image files. The editor dispatches editor-upload { file, resolve, reject }; the host uploads the file and calls resolve(url) or resolve({ url, alt, width, height }), or reject(message).
- `uploadMethod` (string|null, default `null`) — Livewire upload instead of the event: the editor runs $wire.upload(uploadProperty, file) and then calls this component method, which returns the stored image URL (or { url, alt, width, height }). Turns uploads on.
- `uploadProperty` (string, default `upload`) — The Livewire property (WithFileUploads) that receives the file before uploadMethod runs.
- `allowDataUrls` (boolean, default `false`) — Keep raster data: image URLs (png, gif, jpeg, webp, avif) in images and saved HTML. Off, an image source must be http(s) or relative; javascript:, blob: and other schemes are always refused.
- `blockPlaceholder` (string|false|null, default `null`) — The hint on an empty line with the caret. null shows a translated "Type / for blocks" when a slash menu sits in the editor (and nothing otherwise), false turns it off, a string replaces it. An empty heading shows "Heading".

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Building a lightweight, contenteditable rich-text surface driven by a shared command API that other editor parts (toolbar, menus, colour and link controls) dispatch into.
- Composing a custom editor toolbar and menu set from the other editor-* parts, all listening on the same bubbling events.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Use rich editing only when structure, formatting, or collaboration justifies the complexity.
- A full block-based document engine with persistence, collaboration, or a structured JSON document model is needed; this is a contenteditable surface, not a document engine.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:editor>` tag (or `<x-ui.editor>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/editor
- Registry JSON (files, props, contract): https://brokui.dev/r/open/editor.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

blocks.blade.php Blade
{{-- Structured blocks: a to-do list, callouts in every variant and a toggle.
     The hidden `body` input carries the saved HTML for a form post. --}}
<x-ui.editor class="mx-auto max-w-2xl" name="body" placeholder="{{ __('Write something…') }}">
    <x-slot:toolbar>
        <x-ui.editor.toolbar />
    </x-slot:toolbar>

    <x-slot:tools>
        <x-ui.editor.block-handle />
        <x-ui.editor.drop-indicator />
    </x-slot:tools>

    <h2>{{ __('Launch checklist') }}</h2>
    <ul data-type="todo">
        <li data-checked="true">{{ __('Write the release notes') }}</li>
        <li data-checked="false">{{ __('Tell the support team') }}</li>
        <li data-checked="false">{{ __('Publish the changelog') }}</li>
    </ul>
    <div data-type="callout" data-variant="info"><p>{{ __('The release goes out on Tuesday morning.') }}</p></div>
    <div data-type="callout" data-variant="warning"><p>{{ __('Freeze the database migrations a day before.') }}</p></div>
    <div data-type="callout" data-variant="success"><p>{{ __('All checks passed on the release branch.') }}</p></div>
    <div data-type="callout" data-variant="note"><p>{{ __('Keep this page up to date after the launch.') }}</p></div>
    <details data-type="toggle" open>
        <summary>{{ __('Rollback plan') }}</summary>
        <div><p>{{ __('Revert the deploy and restore the last backup.') }}</p></div>
    </details>
</x-ui.editor>
pages.blade.php Blade
{{-- Sub-pages and page links: the slash menu's Page command asks the host to
     create a page inside this one (editor-page-create), Link to page asks the
     host to pick one (editor-page-link). The host answers with resolve({ id,
     title, href }) or reject(message); this example answers at once. --}}
<div
    class="mx-auto w-full max-w-2xl"
    x-data="{ created: 0, refuse: false }"
    x-on:editor-page-create="created++; refuse ? $event.detail.reject(@js(__('You cannot add pages here.'))) : $event.detail.resolve({ id: 'draft-' + created, title: @js(__('Untitled')), href: '#draft-' + created })"
    x-on:editor-page-link="$event.detail.resolve({ id: 'roadmap', title: @js(__('Roadmap')), href: '#roadmap' })"
>
    <x-ui.editor name="page" :aria-label="__('Page content')">
        <x-slot:tools>
            <x-ui.editor.slash-menu floating pages />
        </x-slot:tools>

        <h2>{{ __('Team handbook') }}</h2>
        <p>{{ __('Type / and choose Page to add a page inside this one, or Link to page to point at another page.') }}</p>
        <div data-type="page" data-page="sub" data-id="onboarding"><a href="#onboarding">{{ __('Onboarding') }}</a></div>
        <div data-type="page" data-page="link" data-id="time-off"><a href="#time-off">{{ __('Time off') }}</a></div>
        <p><br></p>
    </x-ui.editor>
</div>
template.blade.php Blade
{{-- A page template: empty paragraphs, list items and to-dos under each
     heading are real lines the pointer can reach; the line with the caret
     shows a hint while it is empty. --}}
<div class="mx-auto w-full max-w-2xl">
    <x-ui.editor name="meeting" :aria-label="__('Meeting notes')">
        <x-slot:tools>
            <x-ui.editor.slash-menu floating />
        </x-slot:tools>

        <h2>{{ __('Agenda') }}</h2>
        <p></p>
        <h2>{{ __('Attendees') }}</h2>
        <ul><li></li></ul>
        <h2>{{ __('Action items') }}</h2>
        <ul data-type="todo"><li data-checked="false"></li></ul>
        <h3></h3>
    </x-ui.editor>
</div>
triggers.blade.php Blade
{{-- Typed triggers: type / at the start of a line for blocks, or @ to mention a
     person, page or task. The slash menu's Image command asks for a URL or an
     upload; this example answers editor-upload with a static image from the
     docs site (a real host uploads the file and resolves with its URL). --}}
@php
    $mentionItems = [
        ['id' => 'ada', 'label' => 'Ada Lovelace', 'type' => 'person', 'description' => __('Engineering')],
        ['id' => 'grace', 'label' => 'Grace Hopper', 'type' => 'person', 'description' => __('Operations')],
        ['id' => 'launch-plan', 'label' => __('Launch plan'), 'type' => 'page', 'href' => '/docs/components/editor'],
        ['id' => '42', 'label' => __('Write the release notes'), 'type' => 'task', 'description' => __('Due Friday')],
        ['id' => '43', 'label' => __('Tell the support team'), 'type' => 'task'],
        ['id' => 'atlas', 'label' => 'Atlas', 'type' => 'project'],
    ];
@endphp
<div class="mx-auto w-full max-w-2xl" x-data x-on:editor-upload="$event.detail.resolve({ url: '/og-default.png' })">
    <x-ui.editor name="notes" upload placeholder="{{ __('Type / for blocks or @ to mention…') }}">
        <x-slot:tools>
            <x-ui.editor.slash-menu floating />
            <x-ui.editor.mention-menu floating :items="$mentionItems" />
        </x-slot:tools>

        <h2>{{ __('Release notes') }}</h2>
        <p>{{ __('Type a slash at the start of the next line to add a block, or an at sign to mention someone.') }}</p>
        <p><br></p>

        <x-slot:footer>
            <span>{{ __('Type / for blocks, @ to mention') }}</span>
        </x-slot:footer>
    </x-ui.editor>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
placeholder string Write something… Text shown in the placeholder overlay while the document is empty.
ariaLabel string Rich text editor Accessible name for the contenteditable region.
name string | null null Form field name. When set, a hidden input carries the saved HTML so a plain form post submits the document.
slashTrigger boolean true Typing / at the start of a block or after a space opens a slash menu placed in this editor, at the caret. Set slash-trigger="false" to turn it off.
mentionTrigger boolean true Typing a mention menu's trigger (@ by default) at a word start opens that menu at the caret. Set mention-trigger="false" to turn it off.
upload boolean false Show an Upload button in the image form and accept dropped or pasted image files. The editor dispatches editor-upload { file, resolve, reject }; the host uploads the file and calls resolve(url) or resolve({ url, alt, width, height }), or reject(message).
uploadMethod string | null null Livewire upload instead of the event: the editor runs $wire.upload(uploadProperty, file) and then calls this component method, which returns the stored image URL (or { url, alt, width, height }). Turns uploads on.
uploadProperty string upload The Livewire property (WithFileUploads) that receives the file before uploadMethod runs.
allowDataUrls boolean false Keep raster data: image URLs (png, gif, jpeg, webp, avif) in images and saved HTML. Off, an image source must be http(s) or relative; javascript:, blob: and other schemes are always refused.
blockPlaceholder string | false | null null The hint on an empty line with the caret. null shows a translated "Type / for blocks" when a slash menu sits in the editor (and nothing otherwise), false turns it off, a string replaces it. An empty heading shows "Heading".

Slots

  • default — Initial document HTML. It is sanitised on load; a bound x-model or wire:model value replaces it once the model is set.
  • toolbar — Fixed formatting toolbar above the document, usually <x-ui.editor.toolbar>.
  • tools — Overlays beside the document: floating toolbar, block handle, drop indicator, menus.
  • footer — Optional status or word-count row.

Data slots

Stable hooks for CSS overrides and browser tests.

editor editor-content editor-footer editor-image-alt editor-image-error editor-image-form editor-image-status editor-image-url editor-media editor-page-error editor-page-status editor-toolbar-region editor-value

Behavior

  • Every other editor part communicates by dispatching bubbling editor-exec, editor-block, editor-insert, editor-callout or editor-block-action events that this root listens for and applies. editor-insert { html } inserts trusted markup; editor-insert { text } inserts an untrusted string as literal characters. The link command drops any URL whose scheme is not http, https, mailto or tel (javascript:, data:, even with hidden whitespace).
  • editor-block { type } accepts paragraph, h1, h2, h3, blockquote, code, bulleted, numbered, todo, callout and toggle. Saved HTML: to-do <ul data-type="todo"><li data-checked="true|false">, callout <div data-type="callout" data-variant="info|warning|success|note" role="note">, toggle <details data-type="toggle" [open]><summary>title</summary><div>body blocks</div></details>. A legacy <div class="callout"> loads as an info callout.
  • In the document every to-do item has a checkbox (role=checkbox, aria-checked) and every toggle a chevron button (aria-expanded); both are editor-only and never saved. Enter in a to-do adds an unchecked item, Enter on an empty item leaves the list, Backspace at the start of an item joins it to the one before. Enter in a toggle title opens it and moves into the body; Enter on an empty last line leaves a callout or toggle. Mod+Enter checks a to-do or opens a toggle. editor-callout { variant } restyles the current callout.
  • Every change emits editor-change { html } with the saved HTML, and updates x-model / wire:model (x-modelable) and the hidden name input. Saved and loaded HTML is sanitised: script-capable elements, on* attributes and unsafe URLs are removed, and data-type, data-checked and data-variant keep only their allowed values. Sanitise stored HTML on the server as well.
  • ui.css styles the to-do, callout and toggle markup anywhere on the page, so saved HTML rendered read-only (inside a prose wrapper, for example) looks the same; to-do boxes are drawn with CSS, and the toggle stays a native details element.
  • Shows a placeholder overlay only while the document is empty, and opens a start gutter, data-gutter, automatically once a block-handle child is present.
  • Typed triggers: with a <x-ui.editor.slash-menu> or <x-ui.editor.mention-menu> in the tools slot, typing / or the mention trigger at the start of a block or after whitespace opens that menu; a floating menu shows at the caret (placed with the dropdown's overlay engine, flipped at viewport edges, aligned to the inline start in RTL) and an inline menu follows the query. The text between the trigger and the caret filters the list live. ArrowUp/ArrowDown move the active option, Enter or Tab choose it and remove the trigger and query text, Escape closes and keeps the text; moving the caret out of the trigger range, deleting the trigger, clicking outside or leaving the editor closes it; with no results Enter does nothing. Nothing opens inside a code block or inline code. editor-trigger { char } opens a menu from a control.
  • Mentions: <a data-type="mention" data-mention-type="task" data-id="42" href="/tasks/42">@Label</a>, a <span> without an href, contenteditable="false" in the editor. The sanitiser keeps only data-type, data-mention-type and data-id (conservative patterns) and an http(s), mailto, tel or relative href, and flattens the content to text. ui.css styles mentions as chips anywhere.
  • Images: the slash menu's Image command dispatches editor-image, which opens the image form (image URL, alt text, Upload when a hook is set). An unsafe or empty URL is refused with a message and nothing is inserted; Cancel or Escape inserts nothing. The image is <figure data-slot="editor-media" data-type="image"><img src alt loading="lazy" [width] [height]></figure>; the sanitiser keeps only a safe src, alt, width, height and loading on every image and removes an image without a safe src. Uploads show an uploading status and the host's reject message or a translated error; dropped and pasted image files use the same hook. Livewire: use WithFileUploads, a public $upload property and a method that stores it and returns the URL, then pass upload-method.
  • Empty lines: an empty paragraph, heading, list item, to-do or toggle title (a template's <p></p> or <li></li>) keeps one line of height (ui.css), so a click puts the caret in it; the line with the caret shows the block hint as a muted data-placeholder pseudo-element while it is empty. The hint is never part of the saved HTML and clears when the editor loses focus.
  • Pages: with <x-ui.editor.slash-menu pages>, the Page and Link to page commands dispatch editor-page { kind: 'sub' | 'link' }; the editor asks the host with a bubbling editor-page-create or editor-page-link { resolve, reject }. resolve({ id, title, href }) inserts <div data-type="page" data-page="sub|link" data-id="42"><a href="/pages/42">Title</a></div> on the caret's empty line; reject(message) shows the message in an alert, reject() cancels quietly. The sanitiser keeps only data-type, data-page (sub or link), a conservative data-id and one safe link or span with plain text; ui.css draws the block as a row with a page icon anywhere.
  • Markdown shortcuts: at the start of a paragraph, "# ", "## " and "### " make a heading, "- " or "* " a bulleted list, "1. " a numbered list (another number sets its start), "[] " or "[ ] " a to-do ("[x] " a checked one), "> " a quote, "```" and a space or Enter a code block, and "---" a divider. Text after the caret stays in the new block. Mod+Z (or the browser undo) right after a conversion restores the paragraph with the typed prefix; any other key or click makes it final.
  • Wide tables scroll sideways inside the editor (the table is its own horizontal scroll container, max-width 100%), so a phone-width page never scrolls sideways; the caret moving through the cells scrolls the table with it, so no extra tab stop is added inside the text box.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Building a lightweight, contenteditable rich-text surface driven by a shared command API that other editor parts (toolbar, menus, colour and link controls) dispatch into.
  • Composing a custom editor toolbar and menu set from the other editor-* parts, all listening on the same bubbling events.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • Use rich editing only when structure, formatting, or collaboration justifies the complexity.
  • A full block-based document engine with persistence, collaboration, or a structured JSON document model is needed; this is a contenteditable surface, not a document engine.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root editor-toolbar-region editor-content editor-image-form editor-value editor-footer
Theming hooks
editor editor-content editor-footer editor-image-alt editor-image-error editor-image-form editor-image-status editor-image-url editor-media editor-page-error editor-page-status editor-toolbar-region editor-value

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Enter Escape ArrowUp ArrowDown
Focus
managed
  • The editable region carries role="textbox", aria-multiline="true", and an aria-label from ariaLabel, so it is announced as a multiline text input.
  • To-do checkboxes are named by the item text (or "Mark as done") and expose aria-checked; toggle buttons expose aria-expanded. Both are 24px pointer targets; from the keyboard, Mod+Enter checks the to-do or opens the toggle at the caret.
  • The toggle chevron sits on the inline start and mirrors under dir="rtl"; its rotation is removed under prefers-reduced-motion.
  • While a trigger menu is open, focus stays in the editable region, which carries aria-controls (the listbox), aria-autocomplete="list", aria-haspopup="listbox" and aria-activedescendant (the active option). aria-expanded is not set: it is not allowed on role=textbox. The menu announces the result count once typing settles, through a polite status region.
  • The image form is a labelled group: labelled URL and alt-text fields, the error as role=alert tied to the URL field (aria-invalid, aria-describedby), the upload status as role=status; Escape or Cancel closes it and returns focus to the caret.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <x-ui.editor class="mx-auto max-w-2xl" placeholder="Write something…">
        <x-slot:toolbar>
            <x-ui.editor.toolbar />
        </x-slot:toolbar>
    
        <x-slot:tools>
            <x-ui.editor.toolbar variant="floating" />
            <x-ui.editor.block-handle />
            <x-ui.editor.drop-indicator />
        </x-slot:tools>
    
        <h1>The quick brown fox</h1>
        <p>A lightweight rich-text surface with <strong>bold</strong>, <em>italic</em>, an
            <a href="#">inline link</a> and <code>inline code</code>.</p>
        <blockquote>Compose the toolbar, menus and inline controls around it.</blockquote>
        <ul><li>Bulleted item</li><li>Another item</li></ul>
        <ol><li>Numbered step</li><li>Second step</li></ol>
        <pre>const editor = useEditor();</pre>
    
        <x-slot:footer>
            <span>{{ __('Draft') }}</span>
            <span>{{ __('Last edited just now') }}</span>
        </x-slot:footer>
    </x-ui.editor>
</div>

Validation

Validation support: custom. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:editor
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/editor.blade.php Blade
@props([
    'placeholder' => 'Write something…',
    'ariaLabel' => 'Rich text editor',
    // Form field name: renders a hidden input carrying the saved HTML.
    'name' => null,
    // Typed triggers: `/` opens a slash menu and a mention menu's trigger
    // (`@`) opens it at the caret, when such a menu sits in this editor.
    'slashTrigger' => true,
    'mentionTrigger' => true,
    // Image uploads: `upload` dispatches editor-upload { file, resolve, reject };
    // `upload-method` uses Livewire ($wire.upload(uploadProperty, file), then
    // $wire[uploadMethod]() returns the URL). Either shows an Upload button
    // and takes dropped or pasted image files.
    'upload' => false,
    'uploadMethod' => null,
    'uploadProperty' => 'upload',
    // Keep raster data: image URLs (off: only http(s) and relative sources).
    'allowDataUrls' => false,
    // The hint on an empty line with the caret: null shows "Type / for
    // blocks" when a slash menu is present, false turns it off, a string
    // replaces it. An empty heading shows "Heading".
    'blockPlaceholder' => null,
])

@php
    $flag = fn ($value, $default) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default;
    $uploads = $flag($upload, false) || filled($uploadMethod);
    $uid = 'editor-'.\Illuminate\Support\Str::random(6);
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $field = $styles['input']['base'].' '.$styles['input']['sizes']['md'];
    $editorConfig = [
        'strings' => [
            'markDone' => __('Mark as done'),
            'toggle' => __('Toggle section'),
            'imageUrlRequired' => __('Enter an image URL.'),
            'imageUrlInvalid' => __('Use an http, https or relative image URL.'),
            'imageUploadFailed' => __('The image could not be uploaded.'),
            'imageNotImage' => __('Choose an image file.'),
            'pageFailed' => __('The page could not be added.'),
            'slashHint' => __('Type / for blocks'),
            'headingHint' => __('Heading'),
        ],
        'triggers' => ['slash' => $flag($slashTrigger, true), 'mention' => $flag($mentionTrigger, true)],
        'upload' => ['enabled' => $uploads, 'method' => filled($uploadMethod) ? (string) $uploadMethod : null, 'property' => (string) $uploadProperty],
        'allowDataUrls' => $flag($allowDataUrls, false),
        'blockPlaceholder' => is_string($blockPlaceholder) && ! in_array(strtolower($blockPlaceholder), ['false', '0', ''], true)
            ? __($blockPlaceholder)
            : ($blockPlaceholder === null || $blockPlaceholder === true ? null : false),
    ];
@endphp

{{--
    Editor: a lightweight contenteditable rich-text surface. The `uiEditor()`
    behaviour layer (editor.js) exposes a reactive selection state and a small
    command API that every other editor part (toolbar, floating toolbar, slash /
    turn-into menus, link / colour controls) binds to by dispatching scoped,
    bubbling events:

        $dispatch('editor-exec',   { command: 'bold' })           // marks + createLink
        $dispatch('editor-block',  { type: 'h1' })                // block type, also 'todo' | 'callout' | 'toggle'
        $dispatch('editor-callout', { variant: 'warning' })       // current callout: info | warning | success | note
        $dispatch('editor-insert', { html: '<hr>' })              // trusted markup only
        $dispatch('editor-insert', { text: title })               // untrusted text, inserted literally
        $dispatch('editor-block-action', { action: 'delete' })    // current block: insert-after | duplicate | delete | move-up | move-down
        $dispatch('editor-trigger', { char: '/' })                // type a menu trigger at the caret and open that menu
        $dispatch('editor-image')                                 // open the image form (URL, alt text, upload)

    Buttons that trigger these MUST use @mousedown.prevent so clicking them never
    blurs the editable region (which would drop the current selection).

    Saved HTML (editor-change { html }, x-model / wire:model through
    x-modelable, and the hidden `name` input) is sanitised and carries no
    editor-only UI. Structured blocks:
        <ul data-type="todo"><li data-checked="true|false">…</li></ul>
        <div data-type="callout" data-variant="info|warning|success|note" role="note"><p>…</p></div>
        <details data-type="toggle" open><summary>…</summary><div><p>…</p></div></details>
    ui.css styles them anywhere, so saved HTML renders the same read-only.
    Mod+Enter checks a to-do item or opens / closes a toggle.

    Pages (editor-pages.js): the slash menu's Page / Link to page commands
    dispatch editor-page { kind: 'sub' | 'link' }; the editor asks the host
    with editor-page-create / editor-page-link { resolve, reject } and inserts
        <div data-type="page" data-page="sub|link" data-id="42"><a href="/pages/42">Title</a></div>
    from resolve({ id, title, href }); reject(message) shows the message.

    Mentions and images (editor-embeds.js):
        <a data-type="mention" data-mention-type="task" data-id="42" href="/tasks/42">@Label</a>
        (<span data-type="mention" …> without an href)
        <figure data-slot="editor-media" data-type="image"><img src="…" alt="…" loading="lazy"></figure>

    Typed triggers: with a <x-ui.editor.slash-menu> or <x-ui.editor.mention-menu>
    in the tools slot, typing `/` or `@` at a word start opens it at the caret
    (a `floating` menu) or drives the inline list. slash-trigger="false" /
    mention-trigger="false" turn them off.

    Uploads: `upload` makes the editor dispatch editor-upload
    { file, resolve, reject } (call resolve(url) or reject(message)):
        <div x-on:editor-upload="send($event.detail)"> … <x-ui.editor upload /> … </div>
    `upload-method` uses Livewire file uploads instead:
        use WithFileUploads;  public $upload;
        public function storeEditorImage(): string {
            $this->validate(['upload' => 'image|max:4096']);
            return Storage::url($this->upload->store('editor', 'public'));
        }
        <x-ui.editor wire:model="body" upload-method="storeEditorImage" />

    Slots:
        $toolbar — fixed formatting toolbar, rendered above the editable area
        $tools   — overlays that float over the document (floating toolbar,
                   block handle, drop indicator): rendered in the positioned
                   wrapper beside the editable region, never inside it
        $slot    — initial document content
        $footer  — optional status / word-count row
--}}
{{-- overflow-x-clip, not overflow-hidden: a menu from the toolbar or the
     block handle may hang below the card, and a clipped box is never a
     scroll container that focus could scroll (nor a trap for the sticky
     toolbar). --}}
<div
    data-slot="editor"
    x-data="uiEditor({{ \Illuminate\Support\Js::from($editorConfig) }})"
    x-modelable="editorModel"
    x-on:editor-exec="exec($event.detail.command, $event.detail.value ?? null)"
    x-on:editor-block="setBlock($event.detail.type)"
    x-on:editor-insert="insert($event.detail.html, $event.detail.text ?? null)"
    x-on:editor-callout="setCalloutVariant($event.detail.variant)"
    x-on:editor-block-action="blockAction($event.detail.action)"
    x-on:editor-block-drag="beginBlockDrag($event.detail.event)"
    x-on:editor-trigger="openTrigger($event.detail.char)"
    x-on:editor-trigger-commit="commitTrigger()"
    x-on:editor-image="openImageForm()"
    x-on:editor-page="requestPage($event.detail.kind)"
    x-on:pointerdown="unsettle()"
    x-on:keydown="unsettle()"
    :data-dragging="dragging ? 'true' : null"
    {{ $attributes->merge(['class' => 'group/editor flex w-full flex-col overflow-x-clip rounded-lg border border-border bg-card text-card-foreground shadow-sm focus-within:ring-2 focus-within:ring-ring/40']) }}
>
    @isset($toolbar)
        <div data-slot="editor-toolbar-region" class="sticky top-0 z-10 rounded-t-[calc(var(--radius-lg)-1px)] *:rounded-t-[inherit] border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80">
            {{ $toolbar }}
        </div>
    @endisset

    {{-- The positioned wrapper: the block handle and drop indicator place
         themselves against it. A <x-ui.block-handle> inside stamps
         data-gutter on the root, which opens a start gutter for the handle. --}}
    <div class="relative" x-on:mousemove="onHover($event)" x-on:mouseleave="hideHandle()">
        {{-- Placeholder overlay: shown only while the document is empty. --}}
        <div
            x-show="empty"
            x-cloak
            aria-hidden="true"
            class="pointer-events-none absolute inset-x-0 top-0 px-4 py-3 text-sm text-muted-foreground group-data-[gutter]/editor:ps-16"
        >{{ __($placeholder) }}</div>

        {{-- wire:ignore: the document belongs to the script; a Livewire
             render reaches it only through wire:model. --}}
        <div
            x-ref="content"
            wire:ignore
            contenteditable="true"
            role="textbox"
            aria-multiline="true"
            aria-label="{{ __($ariaLabel) }}"
            data-slot="editor-content"
            spellcheck="true"
            @beforeinput="markdownBeforeInput($event)"
            @input="onInput($event)"
            @keydown="onKeydown($event)"
            @paste="onPaste($event)"
            @mousedown="onContentPointer($event)"
            @click="onContentPointer($event)"
            @keyup="onSelection()"
            @mouseup="onSelection()"
            @focus="onSelection()"
            @blur="onSelection(); triggerBlur($event)"
            @dragover="onDragOver($event)"
            @drop="onDrop($event)"
            class="min-h-[12rem] w-full px-4 py-3 text-sm leading-relaxed text-foreground focus:outline-none group-data-[gutter]/editor:ps-16
                [&>[data-dragging]]:opacity-40 [&>*]:transition-opacity [&>*]:duration-150
                [&_h1]:mb-2 [&_h1]:mt-4 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:tracking-tight
                [&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:tracking-tight
                [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:text-xl [&_h3]:font-semibold
                [&_p]:my-2
                [&_blockquote]:my-3 [&_blockquote]:border-s-2 [&_blockquote]:border-border [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground
                [&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1
                [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-sm [&_pre]:text-foreground
                [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-sm
                [&_a]:text-link [&_a]:underline [&_a]:underline-offset-2
                [&_hr]:my-4 [&_hr]:border-border
                [&_table]:my-3 [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto [&_table]:overscroll-x-contain [&_table]:border-collapse
                [&_th]:min-w-24 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-start [&_th]:align-top [&_th]:font-semibold
                [&_td]:min-w-24 [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:align-top"
        >{{ $slot }}</div>

        @isset($tools)
            {{ $tools }}
        @endisset
    </div>

    {{-- Image form: opened by the slash menu's Image command (editor-image). --}}
    <template x-if="imageForm.open">
        <div
            data-slot="editor-image-form"
            role="group"
            aria-labelledby="{{ $uid }}-image-title"
            x-on:keydown.escape.prevent.stop="closeImageForm()"
            class="flex flex-col gap-3 border-t border-border px-4 py-4"
        >
            <p id="{{ $uid }}-image-title" class="text-sm font-medium text-foreground">{{ __('Insert image') }}</p>
            <div class="grid gap-3 sm:grid-cols-2">
                <div class="flex min-w-0 flex-col gap-1">
                    <label for="{{ $uid }}-image-url" class="text-sm text-foreground">{{ __('Image URL') }}</label>
                    <input
                        id="{{ $uid }}-image-url"
                        type="text"
                        inputmode="url"
                        data-slot="editor-image-url"
                        dir="ltr"
                        x-model="imageForm.src"
                        placeholder="https://"
                        autocomplete="off"
                        spellcheck="false"
                        aria-describedby="{{ $uid }}-image-error"
                        x-bind:aria-invalid="imageForm.error ? 'true' : null"
                        x-on:keydown.enter.prevent="insertImageFromForm()"
                        x-bind:class="{ '{{ $styles['input']['states']['default'] }}': !imageForm.error, '{{ $styles['input']['states']['invalid'] }}': !!imageForm.error }"
                        class="{{ $field }}"
                    />
                </div>
                <div class="flex min-w-0 flex-col gap-1">
                    <label for="{{ $uid }}-image-alt" class="text-sm text-foreground">{{ __('Alt text') }}</label>
                    <input
                        id="{{ $uid }}-image-alt"
                        type="text"
                        data-slot="editor-image-alt"
                        x-model="imageForm.alt"
                        autocomplete="off"
                        aria-describedby="{{ $uid }}-image-alt-hint"
                        x-on:keydown.enter.prevent="insertImageFromForm()"
                        class="{{ $field }} {{ $styles['input']['states']['default'] }}"
                    />
                    <p id="{{ $uid }}-image-alt-hint" class="text-sm text-muted-foreground">{{ __('Describe the image for people who cannot see it.') }}</p>
                </div>
            </div>
            <p id="{{ $uid }}-image-error" role="alert" data-slot="editor-image-error" x-show="imageForm.error" x-text="imageForm.error" class="text-sm text-destructive"></p>
            <p role="status" data-slot="editor-image-status" x-show="imageForm.uploading" class="text-sm text-muted-foreground">{{ __('Uploading image…') }}</p>
            <div class="flex flex-wrap items-center justify-end gap-2">
                @if ($uploads)
                    <input type="file" accept="image/*" hidden x-ref="imageFile" x-on:change="uploadFromInput($event)" />
                    <x-ui.button variant="outline" size="sm" data-image-action="upload" x-bind:disabled="imageForm.uploading" x-on:click="$refs.imageFile.click()">{{ __('Upload') }}</x-ui.button>
                @endif
                <x-ui.button variant="ghost" size="sm" data-image-action="cancel" x-on:click="closeImageForm()">{{ __('Cancel') }}</x-ui.button>
                <x-ui.button size="sm" data-image-action="insert" x-bind:disabled="imageForm.uploading" x-on:click="insertImageFromForm()">{{ __('Insert') }}</x-ui.button>
            </div>
        </div>
    </template>

    {{-- Page requests (editor-pages.js): the host's answer is pending or refused. --}}
    <p role="status" data-slot="editor-page-status" x-show="pageState.busy" x-cloak class="border-t border-border px-4 py-2 text-sm text-muted-foreground">{{ __('Adding page…') }}</p>
    <div x-show="pageState.error" x-cloak class="flex items-center justify-between gap-2 border-t border-border px-4 py-2">
        <p role="alert" data-slot="editor-page-error" x-text="pageState.error" class="text-sm text-destructive"></p>
        <x-ui.button variant="ghost" size="sm" data-page-action="dismiss" x-on:click="dismissPageError()">{{ __('Dismiss') }}</x-ui.button>
    </div>

    @if ($name !== null && $name !== '')
        <input type="hidden" name="{{ $name }}" x-bind:value="editorHtml" data-slot="editor-value" wire:ignore />
    @endif

    @isset($footer)
        <div data-slot="editor-footer" class="flex items-center justify-between gap-2 border-t border-border px-4 py-2 text-sm text-muted-foreground">
            {{ $footer }}
        </div>
    @endisset
</div>
resources/js/ui/editor.js JS
/**
 * Editor behaviour layer (uiEditor).
 *
 * A pragmatic contenteditable controller built on the browser's native editing
 * commands. It is intentionally lightweight — not a full document model — but it
 * gives every surrounding editor part a single, reactive contract to bind to:
 *
 *   exec(command, value)   marks + links — 'bold' | 'italic' | 'underline' |
 *                          'strikethrough' | 'code' | 'link' | 'unlink'
 *   setBlock(type)         block type — 'paragraph' | 'h1' | 'h2' | 'h3' |
 *                          'blockquote' | 'code' | 'bulleted' | 'numbered'
 *   insert(html)           insert arbitrary trusted HTML at the caret
 *   isActive(mark)         is a mark currently applied at the selection
 *   isBlock(type)          is the caret inside that block type
 *
 *   setBlock also takes 'todo' | 'callout' | 'toggle' (editor-blocks.js), and
 *   setCalloutVariant(variant) restyles the current callout.
 *
 *   blockAction(action)    on the current block (the hovered one, else the
 *                          caret's) — 'insert-after' | 'duplicate' | 'delete' |
 *                          'move-up' | 'move-down'
 *
 * The reactive `active` map and `blockType` string let toolbars reflect state
 * (aria-pressed, highlighted buttons) without polling. Selection changes emit a
 * bubbling `editor-selection` event carrying the caret rect so a floating
 * toolbar can position itself; `floating` is true only once a non-collapsed
 * selection has settled (pointer up, keys released, ~150 ms), so the bubble
 * never sits under a drag or over text being typed.
 *
 * The last selection inside the editor is remembered and restored before every
 * command, so a menu that took focus (a dropdown item, a colour swatch) still
 * formats the text the user had selected, and focus comes back to the text.
 *
 * Blocks: `handle` tracks the hovered top-level block (its offset box) so a
 * gutter control can sit beside it; `beginBlockDrag()` runs a pointer drag that
 * reorders blocks, publishing the drop gap through `editor-drop-indicator`.
 *
 * Saved HTML: html() serialises the document without editor-only UI, and
 * every change emits `editor-change { html }` with it, updates the modelable
 * `editorModel` (x-model / wire:model) and the hidden `name` input.
 *
 * Markdown shortcuts (editor-markdown.js): `## `, `- `, `1. `, `[] `, `> `,
 * ``` and `---` at the start of a paragraph turn it into that block; Mod+Z
 * right after reverts to the typed text.
 *
 * Typed triggers (editor-triggers.js): `/` and the mention menu's trigger
 * open those menus at the caret; `editor-trigger { char }` opens one from a
 * control. Pages (editor-pages.js): `editor-page { kind }` asks the host for
 * a sub-page or a page to link. Images (editor-images.js): `editor-image` opens the image form;
 * uploads go through `editor-upload { file, resolve, reject }` or Livewire.
 *
 * Teardown removes the document-level selectionchange listener and any drag
 * listeners so unmounting mid-edit can never leak.
 */
import { editorBlockMethods, isBlankLine, parseClean, safeHref, sanitize, serialize } from './editor-blocks.js';
import { editorGutterMethods } from './editor-gutter.js';
import { closedForm, editorImageMethods } from './editor-images.js';
import { editorMarkdownMethods } from './editor-markdown.js';
import { editorPageMethods } from './editor-pages.js';
import { editorTriggerMethods } from './editor-triggers.js';

// Blocks that show the empty-line hint while the caret is in them.
const HINT_BLOCKS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'LI', 'SUMMARY']);

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiEditor', (config = {}) => ({
        ...editorBlockMethods,
        ...editorGutterMethods,
        ...editorTriggerMethods,
        ...editorImageMethods,
        ...editorPageMethods,
        ...editorMarkdownMethods,
        imageForm: closedForm(),
        // A page request in flight (busy) or the host's refusal (error).
        pageState: { busy: false, error: '' },
        active: { bold: false, italic: false, underline: false, strikethrough: false, code: false, link: false },
        blockType: 'paragraph',
        empty: true,
        // Latest selection geometry — descendants (e.g. the floating toolbar)
        // share this Alpine scope and read it to position themselves.
        selection: { collapsed: true, rect: null },
        // True once a non-collapsed selection has settled: the floating toolbar
        // shows on this, not on the raw selection, so it never appears under a
        // drag in progress or while keys are still moving the caret.
        floating: false,
        // The hovered top-level block's offset box (relative to the content's
        // positioned wrapper) for a gutter handle: { visible, top, height, index }.
        handle: { visible: false, top: 0, height: 0, index: -1 },
        dragging: false,
        // Saved HTML of the document, and a counter that bumps on every change
        // so template reads of the document (calloutVariant()) stay reactive.
        editorHtml: '',
        docRevision: 0,
        _touched: false,
        _strings: config.strings || {},
        // Which typed triggers are on, the upload hook, and the sanitiser's
        // data: URL policy (allow-data-urls).
        _triggers: config.triggers || {},
        _upload: config.upload || {},
        _clean: { allowData: !!config.allowDataUrls },
        // The empty-line hint: null picks "Type / for blocks" when a slash
        // menu is present, false turns it off, a string replaces it.
        _blockHint: config.blockPlaceholder ?? null,
        _savedRange: null,
        _settleTimer: null,
        _hoverRaf: 0,
        _contentEl: null,

        init() {
            this._contentEl = this.$refs.content;
            // Keep state in sync when the selection moves via keyboard shortcuts,
            // browser menus, or anywhere outside our own @keyup/@mouseup hooks.
            this._onSelectionChange = () => {
                if (this.selectionInside()) {
                    this.rememberSelection();
                    this.syncState();
                    this.syncTrigger();
                }
                this.refreshBlockHint();
            };
            document.addEventListener('selectionchange', this._onSelectionChange);
            const root = this.content();
            if (root) {
                sanitize(root, { ...this._clean, live: true });
                this.decorate();
                this.editorHtml = serialize(root, this._clean);
            }
            this.$nextTick(() => this.refreshEmpty());
        },

        /** Modelable value (`x-modelable="editorModel"`): the saved HTML. */
        get editorModel() {
            return this.editorHtml;
        },
        set editorModel(next) {
            const value = next === null || next === undefined ? '' : String(next);
            // An empty model before the first edit keeps the slot content.
            if (value === this.editorHtml || (value === '' && !this._touched)) return;
            this.load(value);
        },

        /** Replace the document with sanitised HTML (from the model). */
        load(html) {
            const root = this.content();
            if (!root) return;
            const nodes = parseClean(html, this._clean);
            root.replaceChildren(...(nodes.length ? nodes : [document.createElement('p')]));
            this.decorate();
            this.editorHtml = serialize(root, this._clean);
            this.refreshEmpty();
        },

        /** The saved HTML: sanitised, without editor-only affordances. */
        html() {
            const root = this.content();
            return root ? serialize(root, this._clean) : '';
        },

        emitChange() {
            this.editorHtml = this.html();
            this._touched = true;
            this.docRevision++;
            this.$dispatch('editor-change', { html: this.editorHtml });
        },

        // After a structural edit: affordances, derived state, then the change.
        structureChanged() {
            this.decorate();
            this.rememberSelection();
            this.syncState();
            this.refreshEmpty();
            this.emitChange();
        },

        onKeydown(event) {
            if (this.triggerKeydown(event)) return;
            if (this.markdownKeydown(event)) return;
            this.handleBlockKey(event);
        },

        // A paste with markup goes through the strict sanitiser before it
        // lands; plain text keeps the native paste.
        onPaste(event) {
            if (this.pasteImages(event)) return;
            const html = event.clipboardData?.getData('text/html');
            if (!html) return;
            event.preventDefault();
            const holder = document.createElement('div');
            holder.append(...parseClean(html, this._clean));
            document.execCommand('insertHTML', false, holder.innerHTML);
            this.structureChanged();
        },

        destroy() {
            document.removeEventListener('selectionchange', this._onSelectionChange);
            clearTimeout(this._settleTimer);
            cancelAnimationFrame(this._hoverRaf);
            this.endBlockDrag();
            this.closeTrigger();
            this._imageTicket++;
        },

        /* ----------------------------------------------------- selection memory */

        rememberSelection() {
            const sel = window.getSelection();
            if (sel && sel.rangeCount > 0) {
                this._savedRange = sel.getRangeAt(0).cloneRange();
            }
        },

        // Put the remembered range back (a menu or swatch may have moved the
        // DOM selection) and make sure the editable region is the active element,
        // so the native command lands on the text the user chose.
        restoreSelection() {
            const root = this.content();
            if (!root) return;
            if (document.activeElement !== root) root.focus({ preventScroll: true });
            const range = this._savedRange;
            if (range && root.contains(range.commonAncestorContainer)) {
                const sel = window.getSelection();
                sel.removeAllRanges();
                sel.addRange(range);
            }
        },

        // Menus hand focus back to their trigger a tick after a choice; the
        // editor takes it back on the next macrotask so typing continues in the
        // document, not on the toolbar.
        refocusLater() {
            setTimeout(() => this.restoreSelection(), 0);
        },

        /**
         * The editable region. Kept from init: a method run by a control that
         * has since been removed (a closed form's button) resolves $refs from
         * that detached element and would find nothing.
         */
        content() {
            return this._contentEl || this.$refs.content;
        },

        /** True when the current selection lives inside this editor. */
        selectionInside() {
            const sel = window.getSelection();
            const node = sel && sel.anchorNode;
            const root = this.content();
            return !!(node && root && root.contains(node));
        },

        focusContent() {
            this.restoreSelection();
        },

        /* --------------------------------------------------------------- marks */

        exec(command, value = null) {
            this.focusContent();
            switch (command) {
                case 'bold':
                case 'italic':
                case 'underline':
                    document.execCommand(command, false);
                    break;
                case 'strikethrough':
                    document.execCommand('strikeThrough', false);
                    break;
                case 'code':
                    this.wrapInlineCode();
                    break;
                case 'link': {
                    const href = safeHref(value || window.prompt(this.$refs.content?.dataset.linkPrompt || 'Link URL'));
                    if (href) {
                        document.execCommand('createLink', false, href);
                    }
                    break;
                }
                case 'unlink':
                    document.execCommand('unlink', false);
                    break;
                default:
                    // Fall through to the native command for anything else (e.g.
                    // 'removeFormat', 'foreColor', 'hiliteColor', alignment).
                    document.execCommand(command, false, value ?? undefined);
            }
            this.rememberSelection();
            this.syncState();
            this.refreshEmpty();
            this.refocusLater();
        },

        /** Toggle an inline <code> wrap around the current selection. */
        wrapInlineCode() {
            const sel = window.getSelection();
            if (!sel || sel.rangeCount === 0) {
                return;
            }
            // Inside an existing <code>: unwrap by replacing with its text.
            const existing = this.closest('CODE');
            if (existing) {
                const text = existing.textContent;
                existing.replaceWith(document.createTextNode(text));
                return;
            }
            const text = sel.toString();
            if (!text) {
                return;
            }
            const code = document.createElement('code');
            code.textContent = text;
            const range = sel.getRangeAt(0);
            range.deleteContents();
            range.insertNode(code);
            // Place the caret after the inserted node.
            range.setStartAfter(code);
            range.collapse(true);
            sel.removeAllRanges();
            sel.addRange(range);
        },

        /* -------------------------------------------------------------- blocks */

        setBlock(type) {
            this.focusContent();
            if (this.applyBlock(type)) {
                this.structureChanged();
                this.refocusLater();
                return;
            }
            switch (type) {
                case 'paragraph':
                    document.execCommand('formatBlock', false, '<p>');
                    break;
                case 'h1':
                case 'h2':
                case 'h3':
                    document.execCommand('formatBlock', false, `<${type}>`);
                    break;
                case 'blockquote':
                    document.execCommand('formatBlock', false, '<blockquote>');
                    break;
                case 'code':
                    document.execCommand('formatBlock', false, '<pre>');
                    break;
                case 'bulleted':
                    document.execCommand('insertUnorderedList', false);
                    break;
                case 'numbered':
                    document.execCommand('insertOrderedList', false);
                    break;
            }
            this.structureChanged();
            this.refocusLater();
        },

        /* -------------------------------------------------------------- insert */

        // `html` is trusted markup from the editor parts; untrusted strings go
        // through `text`, which the browser inserts as characters.
        insert(html, text = null) {
            this.focusContent();
            if (text != null) document.execCommand('insertText', false, String(text));
            else if (!this.insertInline(html)) {
                document.execCommand('insertHTML', false, html);
                sanitize(this.content(), { ...this._clean, live: true });
            }
            this.structureChanged();
            this.refocusLater();
        },

        // Inline-only trusted markup (a mention) goes in through the Range API,
        // so the browser never moves it out of the block the caret is in.
        insertInline(html) {
            const template = document.createElement('template');
            template.innerHTML = String(html ?? '');
            const fragment = template.content;
            const inline = /^(A|SPAN|B|STRONG|I|EM|U|S|CODE|MARK|SUB|SUP|SMALL|KBD|ABBR|TIME|BR)$/;
            const sel = window.getSelection();
            if (!fragment.lastChild || [...fragment.querySelectorAll('*')].some((node) => !inline.test(node.tagName))) return false;
            if (!sel || sel.rangeCount === 0 || !this.selectionInside()) return false;
            const range = sel.getRangeAt(0);
            const last = fragment.lastChild;
            range.deleteContents();
            range.insertNode(fragment);
            sanitize(this.content(), { ...this._clean, live: true });
            range.setStartAfter(last.isConnected ? last : range.endContainer);
            range.collapse(true);
            sel.removeAllRanges();
            sel.addRange(range);
            return true;
        },

        /* -------------------------------------------------------------- blocks */

        /** Top-level blocks of the document (direct element children). */
        blocks() {
            const root = this.content();
            return root ? [...root.children] : [];
        },

        /** The top-level block containing a node, or null. */
        blockOf(node) {
            const root = this.content();
            while (node && node !== root) {
                if (node.parentNode === root) return node.nodeType === 1 ? node : null;
                node = node.parentNode;
            }
            return null;
        },

        /** The block a gutter action targets: the hovered one, else the caret's. */
        currentBlock() {
            const hovered = this.handle.index >= 0 ? this.blocks()[this.handle.index] : null;
            return hovered || this.blockOf(window.getSelection()?.anchorNode || null);
        },

        placeCaret(block, atEnd = false) {
            if (!block) return;
            const range = document.createRange();
            range.selectNodeContents(block);
            // Keep the caret before a trailing affordance (a to-do checkbox).
            if (atEnd && block.lastChild?.nodeType === 1 && block.lastChild.hasAttribute('data-editor-ui')) range.setEndBefore(block.lastChild);
            range.collapse(!atEnd);
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);
            this.content()?.focus({ preventScroll: true });
            this.rememberSelection();
            this.syncState();
        },

        blockAction(action) {
            const block = this.currentBlock();
            const root = this.content();
            if (!root) return;
            switch (action) {
                case 'insert-after': {
                    const p = document.createElement('p');
                    p.innerHTML = '<br>';
                    if (block) block.after(p);
                    else root.appendChild(p);
                    this.placeCaret(p);
                    break;
                }
                case 'duplicate': {
                    if (!block) return;
                    const copy = block.cloneNode(true);
                    block.after(copy);
                    this.placeCaret(copy, true);
                    break;
                }
                case 'delete': {
                    if (!block) return;
                    const next = block.nextElementSibling || block.previousElementSibling;
                    block.remove();
                    if (root.children.length === 0) {
                        const p = document.createElement('p');
                        p.innerHTML = '<br>';
                        root.appendChild(p);
                        this.placeCaret(p);
                    } else {
                        this.placeCaret(next, true);
                    }
                    break;
                }
                case 'move-up': {
                    const prev = block?.previousElementSibling;
                    if (prev) prev.before(block);
                    this.placeCaret(block, true);
                    break;
                }
                case 'move-down': {
                    const next = block?.nextElementSibling;
                    if (next) next.after(block);
                    this.placeCaret(block, true);
                    break;
                }
                default:
                    return;
            }
            this.handle.visible = false;
            this.handle.index = -1;
            this.structureChanged();
        },

        /* --------------------------------------------------------------- state */

        isActive(mark) {
            return !!this.active[mark];
        },

        isBlock(type) {
            return this.blockType === type;
        },

        /** Nearest ancestor element matching tagName, bounded by the editor. */
        closest(tagName) {
            const root = this.content();
            let node = window.getSelection()?.anchorNode || null;
            while (node && node !== root) {
                if (node.nodeType === 1 && node.tagName === tagName) {
                    return node;
                }
                node = node.parentNode;
            }
            return null;
        },

        syncState() {
            if (!this.selectionInside()) {
                return;
            }
            try {
                this.active = {
                    bold: document.queryCommandState('bold'),
                    italic: document.queryCommandState('italic'),
                    underline: document.queryCommandState('underline'),
                    strikethrough: document.queryCommandState('strikeThrough'),
                    code: !!this.closest('CODE'),
                    link: !!this.closest('A'),
                };
            } catch (e) {
                /* queryCommandState can throw in detached states — ignore. */
            }
            this.blockType = this.detectBlock();
        },

        detectBlock() {
            const root = this.content();
            let node = window.getSelection()?.anchorNode || null;
            while (node && node !== root) {
                if (node.nodeType === 1) {
                    const structured = { UL: 'todo', DIV: 'callout', DETAILS: 'toggle' }[node.tagName];
                    if (structured && node.getAttribute('data-type') === structured) return structured;
                    switch (node.tagName) {
                        case 'H1': return 'h1';
                        case 'H2': return 'h2';
                        case 'H3': return 'h3';
                        case 'BLOCKQUOTE': return 'blockquote';
                        case 'PRE': return 'code';
                        case 'UL': return 'bulleted';
                        case 'OL': return 'numbered';
                    }
                }
                node = node.parentNode;
            }
            return 'paragraph';
        },

        /** Re-evaluate the empty state (drives the placeholder overlay). */
        refreshEmpty() {
            const root = this.content();
            this.empty = !root || (root.textContent.trim() === '' && !root.querySelector('hr, img, table, [data-type]'));
            this.refreshBlockHint();
        },

        /**
         * The hint on the empty block with the caret (data-placeholder, drawn
         * by ui.css, stripped from the saved HTML). Only while the editor has
         * focus and the whole-document placeholder is not showing.
         */
        refreshBlockHint() {
            const root = this.content();
            if (!root) return;
            const sel = window.getSelection();
            let block = null;
            if (!this.empty && document.activeElement === root && sel?.isCollapsed && this.selectionInside()) {
                for (let node = sel.anchorNode; node && node !== root; node = node.parentNode) {
                    if (node.nodeType === 1 && HINT_BLOCKS.has(node.tagName)) {
                        block = node;
                        break;
                    }
                }
            }
            const hint = block ? this.blockHint(block) : null;
            root.querySelectorAll('[data-placeholder]').forEach((node) => {
                if (node !== block || !hint) node.removeAttribute('data-placeholder');
            });
            if (block && hint && block.getAttribute('data-placeholder') !== hint) block.setAttribute('data-placeholder', hint);
        },

        /** The hint for an empty block, or null when it has content or no hint applies. */
        blockHint(block) {
            if (!isBlankLine(block)) return null;
            const strings = this._strings || {};
            if (/^H[1-4]$/.test(block.tagName)) return strings.headingHint || null;
            if (this._blockHint === false) return null;
            if (typeof this._blockHint === 'string' && this._blockHint !== '') return this._blockHint;
            return this.triggerMenus().some((menu) => menu.kind === 'slash') ? strings.slashHint || null : null;
        },

        /* ------------------------------------------------------------- events */

        onInput(event) {
            // A markdown prefix at the start of a paragraph (editor-markdown.js).
            if (this.applyMarkdownShortcut(event)) return;
            this._markdownUndo = null;
            // A drop can carry foreign markup (pastes are cleaned in onPaste).
            if (/^insertFrom(Paste|Drop)/.test(event?.inputType || '')) sanitize(this.content(), { ...this._clean, live: true });
            this.decorate();
            this.refreshEmpty();
            this.emitChange();
            this.detectTrigger(event);
            this.syncTrigger();
        },

        onSelection() {
            this.syncState();
            this.refreshBlockHint();
            const sel = window.getSelection();
            let rect = null;
            let collapsed = true;
            if (sel && sel.rangeCount > 0 && this.selectionInside()) {
                collapsed = sel.isCollapsed;
                const r = sel.getRangeAt(0).getBoundingClientRect();
                if (r && (r.width || r.height)) {
                    rect = { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
                }
                this.rememberSelection();
            }
            this.selection = { collapsed, rect };
            this.settleFloating();
            this.$dispatch('editor-selection', { collapsed, rect, active: this.active, blockType: this.blockType });
        },

        // The bubble hides the moment the pointer goes down or a key is pressed
        // (unsettle) and returns a beat after the selection stops changing.
        unsettle() {
            clearTimeout(this._settleTimer);
            this.floating = false;
        },

        settleFloating() {
            clearTimeout(this._settleTimer);
            if (this.selection.collapsed || !this.selection.rect) {
                this.floating = false;
                return;
            }
            this._settleTimer = setTimeout(() => {
                this.floating = !this.selection.collapsed && !!this.selection.rect && this.selectionInside();
            }, 150);
        },
    }));
});
resources/js/ui/editor-blocks.js JS
/**
 * Editor blocks and the saved-HTML contract (imported by editor.js).
 *
 * Three structured blocks live beside the native ones:
 *
 *   to-do    <ul data-type="todo"><li data-checked="false">Text</li></ul>
 *   callout  <div data-type="callout" data-variant="info" role="note"><p>Text</p></div>
 *            (variant: info | warning | success | note)
 *   toggle   <details data-type="toggle" open><summary>Title</summary><div><p>Body</p></div></details>
 *
 * Mentions and images follow the contract in editor-embeds.js.
 *
 * The saved HTML carries only those attributes. Inside the editor each block
 * gets editor-only affordances (a checkbox on every to-do item, a chevron in
 * every toggle summary), all marked `data-editor-ui`, `contenteditable=false`
 * and built with createElement; serialize() strips them again. The visuals
 * (check box, callout icon, chevron) are CSS in ui.css, so saved HTML
 * rendered read-only outside the editor looks the same.
 *
 * sanitize() is strict for markup that arrives as a string (a model value,
 * a paste) and for everything the editor emits: it drops script-capable and
 * form elements, event-handler attributes and unsafe URLs, and holds
 * data-type / data-checked / data-variant to their allowed values. The live
 * document (the server-rendered slot, trusted inserts, a drop) gets the
 * `live` pass: the same attribute rules and block normalising, but embedded
 * widgets (templates, controls, contenteditable islands) are left alone.
 * It is a client-side guard, not a replacement for server-side sanitising of
 * stored HTML.
 */

import { cleanEmbed, safeHref, safeImageSrc } from './editor-embeds.js';

export const CALLOUT_VARIANTS = ['info', 'warning', 'success', 'note'];

export { safeHref };

// Elements that can run script or load documents; strict mode also drops
// form controls and templates.
const LIVE_DROP_TAGS = new Set(['script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'base']);
const DROP_TAGS = new Set([
    'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'link', 'meta', 'base',
    'form', 'input', 'textarea', 'select', 'option', 'template', 'noscript', 'foreignobject',
    'animate', 'animatemotion', 'animatetransform', 'set',
]);
const URL_ATTRIBUTES = new Set(['href', 'xlink:href']);
const SRC_ATTRIBUTES = new Set(['src', 'poster']);

const el = (node) => node && node.nodeType === 1;
export const isTodoList = (node) => el(node) && node.tagName === 'UL' && node.getAttribute('data-type') === 'todo';
export const isTodoItem = (node) => el(node) && node.tagName === 'LI' && isTodoList(node.parentNode);
export const isCallout = (node) => el(node) && node.tagName === 'DIV' && node.getAttribute('data-type') === 'callout';
export const isToggle = (node) => el(node) && node.tagName === 'DETAILS';
const isToggleBody = (node) => el(node) && node.tagName === 'DIV' && isToggle(node.parentNode) && node.parentNode.getAttribute('data-type') === 'toggle';
const isContainer = (node) => isCallout(node) || isToggleBody(node);

function cleanAttributes(node, { live, allowData }) {
    for (const { name, value } of [...node.attributes]) {
        const lower = name.toLowerCase();
        if (lower.startsWith('on') || lower === 'srcdoc' || lower === 'formaction' || lower === 'action' || (!live && lower === 'contenteditable')) {
            node.removeAttribute(name);
        } else if (URL_ATTRIBUTES.has(lower) && safeHref(value) === null) {
            node.removeAttribute(name);
        } else if (SRC_ATTRIBUTES.has(lower) && safeImageSrc(value, allowData) === null) {
            node.removeAttribute(name);
        } else if (lower === 'style' && /url\s*\(|expression\s*\(|javascript:/i.test(value)) {
            node.removeAttribute(name);
        }
    }
}

// Hold the block attributes to their contract. A <ul> may only be a to-do
// list, data-checked lives only on to-do items (true|false), data-variant on
// a callout is one of CALLOUT_VARIANTS, and a <details> is always a toggle
// with a summary and one body <div>.
function normalizeBlock(node, doc) {
    const tag = node.tagName;
    if (tag === 'UL' && node.hasAttribute('data-type') && node.getAttribute('data-type') !== 'todo') {
        node.removeAttribute('data-type');
    }
    if (node.hasAttribute('data-checked') && !isTodoItem(node)) node.removeAttribute('data-checked');
    if (isTodoItem(node) && node.getAttribute('data-checked') !== 'true') node.setAttribute('data-checked', 'false');
    if (tag === 'DIV' && node.classList.contains('callout') && !node.hasAttribute('data-type')) {
        // A callout saved before variants existed: upgrade it to the default.
        node.classList.remove('callout');
        if (node.classList.length === 0) node.removeAttribute('class');
        node.setAttribute('data-type', 'callout');
    }
    if (isCallout(node)) {
        if (!CALLOUT_VARIANTS.includes(node.getAttribute('data-variant'))) node.setAttribute('data-variant', 'info');
        node.setAttribute('role', 'note');
    }
    if (tag === 'DETAILS') {
        node.setAttribute('data-type', 'toggle');
        let summary = [...node.children].find((child) => child.tagName === 'SUMMARY');
        if (!summary) summary = doc.createElement('summary');
        node.prepend(summary);
        [...node.childNodes].forEach((child) => {
            if (child.nodeType === 3 && child.textContent.trim() === '') child.remove();
        });
        const rest = [...node.childNodes].filter((child) => child !== summary);
        const body = rest.length === 1 && rest[0].nodeType === 1 && rest[0].tagName === 'DIV' ? rest[0] : doc.createElement('div');
        if (body.parentNode !== node) {
            rest.forEach((child) => body.appendChild(child));
            node.appendChild(body);
        }
        if (body.childNodes.length === 0) body.appendChild(paragraph(doc));
    }
}

/**
 * Clean a subtree in place (children of `root`, not root). `allowData`
 * keeps raster data: image URLs (the editor's allow-data-urls prop).
 */
export function sanitize(root, { live = false, allowData = false } = {}) {
    const doc = root.ownerDocument;
    const options = { live, allowData };
    const drop = live ? LIVE_DROP_TAGS : DROP_TAGS;
    const walk = (parent) => {
        for (const child of [...parent.childNodes]) {
            if (child.nodeType === 8 && !live) {
                child.remove();
                continue;
            }
            if (child.nodeType !== 1) continue;
            if (drop.has(child.localName.toLowerCase()) || (!live && child.hasAttribute('data-editor-ui'))) {
                child.remove();
                continue;
            }
            if (live && child.hasAttribute('data-editor-ui')) continue;
            cleanAttributes(child, options);
            const embed = cleanEmbed(child, options);
            if (embed === 'drop') child.remove();
            if (embed) continue;
            walk(child);
            normalizeBlock(child, doc);
        }
    };
    walk(root);
    return root;
}

/**
 * Parse an HTML string into sanitised nodes without running it: DOMParser
 * documents are inert (no scripts, no image loads, no handlers).
 */
export function parseClean(html, options = {}) {
    const parsed = new DOMParser().parseFromString(`<body>${String(html ?? '')}</body>`, 'text/html');
    return [...sanitize(parsed.body, { ...options, live: false }).childNodes];
}

/** The saved HTML of an editor content element: no editor UI, sanitised. */
export function serialize(root, options = {}) {
    const inert = document.implementation.createHTMLDocument('');
    const copy = inert.importNode(root, true);
    copy.querySelectorAll('[data-dragging]').forEach((node) => node.removeAttribute('data-dragging'));
    copy.querySelectorAll('[data-placeholder]').forEach((node) => node.removeAttribute('data-placeholder'));
    return sanitize(copy, { ...options, live: false }).innerHTML;
}

function paragraph(doc = document) {
    const p = doc.createElement('p');
    p.appendChild(doc.createElement('br'));
    return p;
}

// Move a block's inline content (never its editor UI) into another element.
function moveContent(from, to) {
    [...from.childNodes].forEach((child) => {
        if (el(child) && child.hasAttribute('data-editor-ui')) child.remove();
        else to.appendChild(child);
    });
    if (to.textContent === '' && !to.querySelector('br, img')) to.appendChild(document.createElement('br'));
    return to;
}

/** A block with no text and nothing but a <br> or editor UI in it. */
export const isBlankLine = (block) => ![...block.childNodes].some((child) => (child.nodeType === 3 && child.textContent !== '')
    || (child.nodeType === 1 && child.tagName !== 'BR' && !child.hasAttribute('data-editor-ui')));

const isEmpty = (node) => node.textContent.trim() === '' && !node.querySelector('img, hr, table');
const INLINE_BLOCKS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'DIV', 'BLOCKQUOTE', 'PRE']);

export function createTodoItem(checked = false) {
    const li = document.createElement('li');
    li.setAttribute('data-checked', checked ? 'true' : 'false');
    return li;
}

function createTodoList() {
    const ul = document.createElement('ul');
    ul.setAttribute('data-type', 'todo');
    return ul;
}

export function createCallout(variant = 'info') {
    const callout = document.createElement('div');
    callout.setAttribute('data-type', 'callout');
    callout.setAttribute('data-variant', CALLOUT_VARIANTS.includes(variant) ? variant : 'info');
    callout.setAttribute('role', 'note');
    return callout;
}

export function createToggle() {
    const details = document.createElement('details');
    details.setAttribute('data-type', 'toggle');
    details.open = true;
    details.appendChild(document.createElement('summary'));
    return details;
}

/**
 * Methods mixed into the uiEditor Alpine component. `this` is the editor:
 * content(), placeCaret(), currentBlock(), emitChange() come from editor.js.
 */
export const editorBlockMethods = {
    /* ---------------------------------------------------------- affordances */

    // Give every to-do item its checkbox and every toggle its chevron button,
    // with state and names in sync; drop any affordance left in a wrong place.
    decorate() {
        const root = this.content();
        if (!root) return;
        const strings = this._strings || {};
        root.querySelectorAll('[data-editor-ui]').forEach((ui) => {
            const kind = ui.getAttribute('data-editor-ui');
            const host = ui.parentNode;
            const valid = kind === 'todo-check' ? isTodoItem(host) : host?.tagName === 'SUMMARY' && isToggle(host.parentNode);
            if (!valid || host.querySelectorAll(`:scope > [data-editor-ui="${kind}"]`)[0] !== ui) ui.remove();
        });
        root.querySelectorAll('ul[data-type="todo"] > li').forEach((li) => {
            const box = li.querySelector(':scope > [data-editor-ui="todo-check"]') || this.affordance(li, 'todo-check', 'checkbox');
            const checked = li.getAttribute('data-checked') === 'true';
            if (li.getAttribute('data-checked') !== (checked ? 'true' : 'false')) li.setAttribute('data-checked', 'false');
            box.setAttribute('aria-checked', checked ? 'true' : 'false');
            const text = li.textContent.trim().slice(0, 120);
            box.setAttribute('aria-label', text || strings.markDone || 'Mark as done');
        });
        root.querySelectorAll('details').forEach((details) => {
            const summary = details.querySelector(':scope > summary');
            if (!summary) return;
            const button = summary.querySelector(':scope > [data-editor-ui="toggle-button"]') || this.affordance(summary, 'toggle-button', 'button');
            button.setAttribute('aria-expanded', details.open ? 'true' : 'false');
            button.setAttribute('aria-label', strings.toggle || 'Toggle section');
        });
    },

    affordance(host, kind, role) {
        const ui = document.createElement('span');
        ui.setAttribute('data-editor-ui', kind);
        ui.setAttribute('contenteditable', 'false');
        ui.setAttribute('role', role);
        ui.setAttribute('tabindex', '-1');
        host.appendChild(ui);
        return ui;
    },

    // Clicks on an affordance act on their block; a click on summary text
    // only places the caret (the native toggle would fight the editing).
    onContentPointer(event) {
        // A click makes the last markdown conversion final (editor-markdown.js).
        this._markdownUndo = null;
        const ui = event.target.closest?.('[data-editor-ui]');
        if (event.type === 'mousedown') {
            if (ui) event.preventDefault();
            return;
        }
        if (ui) {
            event.preventDefault();
            if (ui.getAttribute('data-editor-ui') === 'todo-check') this.toggleTodo(ui.parentNode);
            else this.toggleOpen(ui.closest('details'));
            return;
        }
        if (event.target.closest?.('summary')) event.preventDefault();
        // An empty line (a template's <p></p> or <li></li>): some engines leave
        // the caret where it was or type outside a line with no <br>, so a
        // click gives the line its <br> and puts the caret in it.
        const line = event.target.closest?.('p, h1, h2, h3, h4, li, summary');
        if (!line || !this.content()?.contains(line) || !isBlankLine(line)) return;
        const hadBreak = !!line.querySelector(':scope > br');
        if (!hadBreak) line.prepend(document.createElement('br'));
        if (!hadBreak || !line.contains(window.getSelection()?.anchorNode ?? null)) this.placeCaret(line);
    },

    toggleTodo(li) {
        if (!isTodoItem(li)) return;
        li.setAttribute('data-checked', li.getAttribute('data-checked') === 'true' ? 'false' : 'true');
        this.structureChanged();
    },

    toggleOpen(details) {
        if (!isToggle(details)) return;
        details.open = !details.open;
        this.structureChanged();
    },

    /* ------------------------------------------------------------ callouts */

    /** The callout a variant change targets: the hovered block, else the caret's. */
    targetCallout() {
        const block = this.currentBlock();
        if (isCallout(block)) return block;
        return this.closestMatch(isCallout);
    },

    /** Variant of the targeted callout, or null (reactive through docRevision). */
    calloutVariant() {
        void this.docRevision;
        void this.blockType;
        void this.handle.index;
        return this.targetCallout()?.getAttribute('data-variant') ?? null;
    },

    setCalloutVariant(variant) {
        const callout = this.targetCallout();
        if (!callout || !CALLOUT_VARIANTS.includes(variant)) return;
        callout.setAttribute('data-variant', variant);
        this.structureChanged();
        this.refocusLater();
    },

    /* ---------------------------------------------------------- structure */

    closestMatch(test, from = window.getSelection()?.anchorNode || null) {
        const root = this.content();
        let node = from;
        while (node && node !== root) {
            if (test(node)) return node;
            node = node.parentNode;
        }
        return null;
    },

    // The innermost block holding the caret: a direct child of the document
    // or of a container (callout, toggle body). A list counts as one block.
    leafBlock() {
        return this.closestMatch((node) => el(node) && (node.parentNode === this.content() || isContainer(node.parentNode)));
    },

    /**
     * Apply a structured block type, or prepare a structured block for a
     * native one. Returns true when the change is complete.
     */
    applyBlock(type) {
        const item = this.closestMatch(isTodoItem);
        const summary = this.closestMatch((node) => el(node) && node.tagName === 'SUMMARY' && isToggle(node.parentNode));
        if (type === 'todo') return item ? true : this.toTodo();
        if (type === 'callout') return this.closestMatch(isCallout) ? true : this.wrapBlock(createCallout('info'));
        if (type === 'toggle') return this.closestMatch(isToggle) ? true : this.toToggle();
        if (item && (type === 'bulleted' || type === 'numbered')) {
            const list = item.parentNode;
            const next = document.createElement(type === 'bulleted' ? 'ul' : 'ol');
            [...list.children].forEach((li) => {
                li.removeAttribute('data-checked');
                li.querySelectorAll(':scope > [data-editor-ui]').forEach((ui) => ui.remove());
                next.appendChild(li);
            });
            list.replaceWith(next);
            this.placeCaret(item, true);
            return true;
        }
        if (item) this.placeCaret(this.outdentTodo(item), true);
        else if (summary) this.placeCaret(this.unwrapToggle(summary.parentNode), true);
        return false;
    },

    toTodo() {
        const block = this.leafBlock();
        const list = createTodoList();
        let focus = null;
        if (block && (block.tagName === 'UL' || block.tagName === 'OL')) {
            [...block.children].forEach((li) => list.appendChild(moveContent(li, createTodoItem())));
            block.replaceWith(list);
            focus = list.lastElementChild;
        } else if (block && isToggle(block)) {
            const p = this.unwrapToggle(block);
            p.replaceWith(list);
            focus = list.appendChild(moveContent(p, createTodoItem()));
        } else if (block && INLINE_BLOCKS.has(block.tagName)) {
            block.replaceWith(list);
            focus = list.appendChild(moveContent(block, createTodoItem()));
        } else {
            focus = list.appendChild(moveContent(document.createElement('li'), createTodoItem()));
            this.placeBlock(list, block);
        }
        this.placeCaret(focus || list.appendChild(createTodoItem()), true);
        return true;
    },

    toToggle() {
        const block = this.leafBlock();
        const details = createToggle();
        const summary = details.firstChild;
        const body = document.createElement('div');
        if (block && /^(P|H[1-6])$/.test(block.tagName)) {
            block.replaceWith(details);
            moveContent(block, summary);
            body.appendChild(paragraph());
        } else {
            summary.appendChild(document.createElement('br'));
            this.placeBlock(details, block);
            if (block) body.appendChild(block);
            else body.appendChild(paragraph());
        }
        details.appendChild(body);
        this.placeCaret(summary, true);
        return true;
    },

    wrapBlock(wrapper) {
        const block = this.leafBlock();
        this.placeBlock(wrapper, block);
        wrapper.appendChild(block || paragraph());
        this.placeCaret(wrapper.firstElementChild, true);
        return true;
    },

    // Put a new block where `block` is (before it), or after the caret's
    // top-level block, or at the end of the document.
    placeBlock(node, block) {
        const root = this.content();
        if (block) block.before(node);
        else {
            const anchor = this.blockOf(window.getSelection()?.anchorNode || null);
            if (anchor) anchor.after(node);
            else root.appendChild(node);
        }
    },

    // Turn one to-do item into a paragraph, splitting the list around it.
    outdentTodo(li) {
        const list = li.parentNode;
        const p = moveContent(li, document.createElement('p'));
        const after = createTodoList();
        let next = li.nextElementSibling;
        while (next) {
            const move = next;
            next = next.nextElementSibling;
            after.appendChild(move);
        }
        li.remove();
        list.after(p);
        if (after.children.length) p.after(after);
        if (!list.children.length) list.remove();
        return p;
    },

    // Turn a toggle back into text: the summary becomes a paragraph and the
    // body blocks follow it.
    unwrapToggle(details) {
        const summary = details.querySelector(':scope > summary');
        const p = moveContent(summary || document.createElement('summary'), document.createElement('p'));
        const body = details.querySelector(':scope > div');
        details.before(p);
        let anchor = p;
        [...(body?.childNodes || [])].forEach((child) => {
            anchor.after(child);
            anchor = child;
        });
        details.remove();
        return p;
    },

    unwrapContainer(container) {
        const outer = isToggleBody(container) ? container.parentNode : container;
        const first = container.firstElementChild;
        [...container.childNodes].forEach((child) => outer.before(child));
        outer.remove();
        return first;
    },

    /* ------------------------------------------------------------- keyboard */

    // Enter, Mod+Enter and Backspace inside the structured blocks. Returns
    // true when the key was handled here.
    handleBlockKey(event) {
        if (event.isComposing || event.keyCode === 229 || event.altKey) return false;
        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0) return false;
        const item = this.closestMatch(isTodoItem);
        const summary = this.closestMatch((node) => el(node) && node.tagName === 'SUMMARY' && isToggle(node.parentNode));
        const mod = event.metaKey || event.ctrlKey;

        if (event.key === 'Enter' && mod) {
            if (item) this.toggleTodo(item);
            else if (summary) this.toggleOpen(summary.parentNode);
            else return false;
            event.preventDefault();
            return true;
        }
        if (event.key === 'Enter' && !event.shiftKey) {
            if (item) return this.enterTodo(event, item, sel);
            if (summary) {
                event.preventDefault();
                const details = summary.parentNode;
                details.open = true;
                const body = details.querySelector(':scope > div') || details.appendChild(document.createElement('div'));
                if (!body.firstElementChild) body.appendChild(paragraph());
                this.placeCaret(body.firstElementChild, false);
                this.structureChanged();
                return true;
            }
            const block = this.leafBlock();
            if (block && isContainer(block.parentNode) && block === block.parentNode.lastElementChild && block.tagName === 'P' && isEmpty(block)) {
                event.preventDefault();
                const container = block.parentNode;
                if (container.children.length === 1) container.appendChild(paragraph());
                (isToggleBody(container) ? container.parentNode : container).after(block);
                this.placeCaret(block, false);
                this.structureChanged();
                return true;
            }
            return false;
        }
        if (event.key === 'Backspace' && sel.isCollapsed) {
            const host = item || summary || this.leafBlock();
            if (!host || !this.caretAtStart(host, sel)) return false;
            if (item) return this.backspaceTodo(event, item);
            if (summary) {
                event.preventDefault();
                this.placeCaret(this.unwrapToggle(summary.parentNode), false);
                this.structureChanged();
                return true;
            }
            const container = host.parentNode;
            if (!isContainer(container) || container.firstElementChild !== host) return false;
            event.preventDefault();
            if (isCallout(container)) this.placeCaret(this.unwrapContainer(container), false);
            else {
                const title = container.parentNode.querySelector(':scope > summary');
                if (isEmpty(host) && container.children.length > 1) host.remove();
                this.placeCaret(title, true);
            }
            this.structureChanged();
            return true;
        }
        return false;
    },

    caretAtStart(host, sel) {
        const range = document.createRange();
        range.selectNodeContents(host);
        range.setEnd(sel.anchorNode, sel.anchorOffset);
        return range.toString() === '';
    },

    enterTodo(event, item, sel) {
        event.preventDefault();
        if (isEmpty(item)) {
            // An empty item leaves the list, like the native lists.
            this.placeCaret(this.outdentTodo(item), false);
            this.structureChanged();
            return true;
        }
        const range = sel.getRangeAt(0);
        range.deleteContents();
        const tail = document.createRange();
        tail.setStart(range.startContainer, range.startOffset);
        tail.setEnd(item, item.childNodes.length);
        const next = createTodoItem(false);
        next.appendChild(tail.extractContents());
        next.querySelectorAll('[data-editor-ui]').forEach((ui) => ui.remove());
        if (isEmpty(next)) next.replaceChildren(document.createElement('br'));
        if (item.textContent === '' && !item.querySelector('br')) item.appendChild(document.createElement('br'));
        item.after(next);
        this.placeCaret(next, false);
        this.structureChanged();
        return true;
    },

    backspaceTodo(event, item) {
        event.preventDefault();
        const prev = item.previousElementSibling;
        if (!prev) {
            this.placeCaret(this.outdentTodo(item), false);
        } else if (isEmpty(item)) {
            item.remove();
            this.placeCaret(prev, true);
        } else {
            // Join the item's text onto the end of the previous one.
            prev.querySelectorAll(':scope > br').forEach((br) => br.remove());
            const marker = document.createTextNode('');
            prev.appendChild(marker);
            [...item.childNodes].forEach((child) => {
                if (!(el(child) && child.hasAttribute('data-editor-ui'))) prev.appendChild(child);
            });
            item.remove();
            const range = document.createRange();
            range.setStartAfter(marker);
            range.collapse(true);
            window.getSelection().removeAllRanges();
            window.getSelection().addRange(range);
            this.rememberSelection();
        }
        this.structureChanged();
        return true;
    },
};
resources/js/ui/editor-gutter.js JS
/**
 * Editor gutter (imported by editor.js): the hovered-block handle and the
 * pointer-driven block reorder. Methods are mixed into the uiEditor Alpine
 * component; `this.handle`, `this.dragging` and `blocks()`/`content()` come
 * from editor.js. endBlockDrag() runs in the editor's destroy(), so an
 * unmount mid-drag removes every window listener.
 */
export const editorGutterMethods = {
    /* ---------------------------------------------------- gutter handle */

    // Track the hovered block (one layout read per frame) so a gutter
    // handle can sit beside it.
    onHover(event) {
        if (this.dragging) return;
        cancelAnimationFrame(this._hoverRaf);
        this._hoverRaf = requestAnimationFrame(() => {
            const blocks = this.blocks();
            const y = event.clientY;
            let index = -1;
            for (let i = 0; i < blocks.length; i++) {
                const box = blocks[i].getBoundingClientRect();
                if (y >= box.top - 4 && y <= box.bottom + 4) {
                    index = i;
                    break;
                }
            }
            if (index === -1) return;
            this.showHandle(index);
        });
    },

    showHandle(index) {
        const block = this.blocks()[index];
        const wrapper = this.content()?.parentElement;
        if (!block || !wrapper) return;
        const box = block.getBoundingClientRect();
        const base = wrapper.getBoundingClientRect();
        this.handle = { visible: true, top: box.top - base.top, height: box.height, index };
    },

    hideHandle() {
        if (this.dragging) return;
        this.handle.visible = false;
    },

    /* ------------------------------------------------------- drag reorder */

    // Pointer-driven block reorder (works with touch, no HTML5 drag ghost).
    // The dragged block dims, the nearest gap is published for a drop
    // indicator, and pointerup moves the block there.
    beginBlockDrag(event) {
        const block = this.currentBlock();
        if (!block) return;
        const startY = event.clientY;
        this._drag = { block, gap: null, moved: false };

        this._onDragMove = (e) => {
            if (!this._drag.moved && Math.abs(e.clientY - startY) < 4) return;
            if (!this._drag.moved) {
                this._drag.moved = true;
                this.dragging = true;
                block.setAttribute('data-dragging', 'true');
            }
            e.preventDefault();
            const gap = this.nearestGap(e.clientY);
            this._drag.gap = gap;
            this.$dispatch('editor-drop-indicator', { editor: this.$root, rect: gap ? gap.rect : null });
        };
        this._onDragUp = () => {
            const drag = this._drag;
            this.endBlockDrag();
            if (!drag?.moved) return;
            const gap = drag.gap;
            if (gap && gap.before !== drag.block && gap.before?.previousElementSibling !== drag.block) {
                if (gap.before) gap.before.before(drag.block);
                else this.content().appendChild(drag.block);
                this.placeCaret(drag.block, true);
                this.emitChange();
            }
        };
        window.addEventListener('pointermove', this._onDragMove);
        window.addEventListener('pointerup', this._onDragUp);
        window.addEventListener('pointercancel', this._onDragUp);
    },

    endBlockDrag() {
        if (this._onDragMove) {
            window.removeEventListener('pointermove', this._onDragMove);
            window.removeEventListener('pointerup', this._onDragUp);
            window.removeEventListener('pointercancel', this._onDragUp);
        }
        this._onDragMove = this._onDragUp = null;
        this._drag?.block?.removeAttribute('data-dragging');
        this._drag = null;
        if (this.dragging) {
            this.dragging = false;
            this.$dispatch('editor-drop-indicator', { editor: this.$root, rect: null });
        }
    },

    // The gap (between two blocks, or after the last) nearest a viewport y:
    // `before` is the block the dragged one would be inserted before (null
    // = append), `rect` the line's box relative to the content wrapper.
    nearestGap(clientY) {
        const blocks = this.blocks().filter((b) => b !== this._drag?.block);
        const wrapper = this.content()?.parentElement;
        if (!wrapper || blocks.length === 0) return null;
        const base = wrapper.getBoundingClientRect();
        let best = null;
        let bestDist = Infinity;
        const consider = (y, before, left, width) => {
            const d = Math.abs(clientY - y);
            if (d < bestDist) {
                bestDist = d;
                best = { before, rect: { top: y - base.top, left: left - base.left, width } };
            }
        };
        for (const b of blocks) {
            const box = b.getBoundingClientRect();
            consider(box.top - 2, b, box.left, box.width);
        }
        const last = blocks[blocks.length - 1].getBoundingClientRect();
        consider(last.bottom + 2, null, last.left, last.width);
        return best;
    },
};
resources/js/ui/editor-embeds.js JS
/**
 * Editor embeds: URLs, mentions and images in the saved-HTML contract
 * (imported by editor-blocks.js, editor-images.js and the mention menu).
 *
 *   mention  <a data-type="mention" data-mention-type="task" data-id="42" href="/tasks/42">@Label</a>
 *            (<span> instead of <a> when there is no href)
 *   image    <figure data-slot="editor-media" data-type="image">
 *              <img src="https://…" alt="…" loading="lazy" width="…" height="…">
 *            </figure>
 *   page     <div data-type="page" data-page="sub|link" data-id="42"><a href="/pages/42">Title</a></div>
 *            (sub: a page created inside this one; link: another page;
 *            a <span> instead of <a> when there is no safe href)
 *
 * Both are built with createElement / textContent only, so a label, an id
 * or a URL from the host is always text or an attribute value, never markup.
 * cleanEmbed() holds them to the contract inside sanitize(): a mention keeps
 * data-type, data-mention-type and data-id (conservative patterns) and a
 * safe href, and its content becomes plain text; an image keeps a safe src,
 * alt, width, height and loading, and an image without a safe src is removed.
 * Inside the editor both are contenteditable="false" islands.
 */

// Link schemes a link may use; anything else (javascript:, data:, vbscript:)
// is dropped. Browsers ignore control characters and spaces inside a scheme
// ("java\tscript:"), so they are removed before the check.
const LINK_SCHEMES = ['http', 'https', 'mailto', 'tel'];

// eslint-disable-next-line no-control-regex
const stripControl = (value) => String(value).replace(/[\u0000- ]/g, '');

function schemeOf(value) {
    const match = stripControl(value).match(/^([a-z][a-z0-9+.-]*):/i);
    return match ? match[1].toLowerCase() : null;
}

/** A link href: http(s), mailto, tel or relative; null otherwise. */
export function safeHref(value) {
    const href = String(value ?? '').trim();
    if (href === '') return null;
    const scheme = schemeOf(href);
    return !scheme || LINK_SCHEMES.includes(scheme) ? href : null;
}

/**
 * An image or media src: http(s) or relative. A raster data: URL only when
 * the editor allows data URLs (`allow-data-urls`); never javascript:, blob:
 * or any other scheme. Returns the trimmed value or null.
 */
export function safeImageSrc(value, allowData = false) {
    const src = String(value ?? '').trim();
    if (src === '') return null;
    const scheme = schemeOf(src);
    if (!scheme || scheme === 'http' || scheme === 'https') return src;
    return allowData && /^data:image\/(png|gif|jpe?g|webp|avif);/i.test(stripControl(src)) ? src : null;
}

export const MENTION_TYPE_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/i;
export const MENTION_ID_PATTERN = /^[\w.:@-]{1,128}$/;
const MENTION_ATTRIBUTES = new Set(['data-type', 'data-mention-type', 'data-id', 'href', 'contenteditable']);
const IMAGE_ATTRIBUTES = new Set(['src', 'alt', 'width', 'height', 'loading']);
const DIMENSION = /^\d{1,5}$/;

export const PAGE_KINDS = ['sub', 'link'];

const isMention = (node) => node.getAttribute('data-type') === 'mention';
const isPageBlock = (node) => node.tagName === 'DIV' && node.getAttribute('data-type') === 'page';
const isImageFigure = (node) => node.tagName === 'FIGURE' && node.getAttribute('data-type') === 'image';

/**
 * Build a mention element. `item` is { id, type, label, href }; `prefix` is
 * the trigger character shown before the label.
 */
export function createMention(item, prefix = '@', doc = document) {
    const href = safeHref(item.href);
    const mention = doc.createElement(href ? 'a' : 'span');
    mention.setAttribute('data-type', 'mention');
    const type = String(item.type ?? 'person');
    if (MENTION_TYPE_PATTERN.test(type)) mention.setAttribute('data-mention-type', type);
    const id = String(item.id ?? '');
    if (MENTION_ID_PATTERN.test(id)) mention.setAttribute('data-id', id);
    if (href) mention.setAttribute('href', href);
    mention.setAttribute('contenteditable', 'false');
    mention.textContent = `${prefix}${item.label ?? ''}`;
    return mention;
}

/** Build an image figure, or null when the src is not safe. */
export function createImageFigure({ src, alt = '', width = null, height = null }, allowData = false, doc = document) {
    const safe = safeImageSrc(src, allowData);
    if (!safe) return null;
    const figure = doc.createElement('figure');
    figure.setAttribute('data-slot', 'editor-media');
    figure.setAttribute('data-type', 'image');
    figure.setAttribute('contenteditable', 'false');
    const img = doc.createElement('img');
    img.setAttribute('src', safe);
    img.setAttribute('alt', String(alt ?? ''));
    img.setAttribute('loading', 'lazy');
    if (DIMENSION.test(String(width ?? ''))) img.setAttribute('width', String(width));
    if (DIMENSION.test(String(height ?? ''))) img.setAttribute('height', String(height));
    figure.appendChild(img);
    return figure;
}

/**
 * Build a page block (a sub-page or a link to a page), or null without a
 * title. `page` is { id, title, href }; `kind` is 'sub' or 'link'.
 */
export function createPageBlock(page, kind = 'link', doc = document) {
    const title = String(page?.title ?? page?.label ?? '').trim();
    if (title === '') return null;
    const block = doc.createElement('div');
    block.setAttribute('data-type', 'page');
    block.setAttribute('data-page', PAGE_KINDS.includes(kind) ? kind : 'link');
    const id = String(page?.id ?? '');
    if (MENTION_ID_PATTERN.test(id)) block.setAttribute('data-id', id);
    block.setAttribute('contenteditable', 'false');
    const href = safeHref(page?.href);
    const label = doc.createElement(href ? 'a' : 'span');
    if (href) label.setAttribute('href', href);
    label.textContent = title;
    block.appendChild(label);
    return block;
}

/** The visible text of an element: no script, style or editor UI text. */
function plainText(node) {
    if (!node) return '';
    const copy = node.cloneNode(true);
    copy.querySelectorAll('script, style, template, noscript, [data-editor-ui]').forEach((child) => child.remove());
    return copy.textContent.replace(/\s+/g, ' ').trim();
}

function keepOnly(node, allowed) {
    for (const { name } of [...node.attributes]) {
        if (!allowed.has(name.toLowerCase())) node.removeAttribute(name);
    }
}

/**
 * Hold a mention, an image or an image figure to the contract. Called by
 * sanitize() before it walks the node's children. Returns 'drop' (remove the
 * node), 'leaf' (cleaned, do not walk its children) or null (walk on).
 */
export function cleanEmbed(node, { live = false, allowData = false } = {}) {
    const tag = node.tagName;
    if (isMention(node)) {
        if (tag !== 'A' && tag !== 'SPAN') {
            node.removeAttribute('data-type');
            return null;
        }
        keepOnly(node, MENTION_ATTRIBUTES);
        if (!MENTION_TYPE_PATTERN.test(node.getAttribute('data-mention-type') ?? '')) node.removeAttribute('data-mention-type');
        if (!MENTION_ID_PATTERN.test(node.getAttribute('data-id') ?? '')) node.removeAttribute('data-id');
        if (tag === 'SPAN' || safeHref(node.getAttribute('href')) === null) node.removeAttribute('href');
        if (live) node.setAttribute('contenteditable', 'false');
        else node.removeAttribute('contenteditable');
        // Collapse any child markup into one plain text node.
        const text = node.textContent;
        node.textContent = text;
        return 'leaf';
    }
    if (isPageBlock(node)) {
        // One link (or span) with plain text; the rest of the content goes.
        const link = node.querySelector('a[href]');
        const title = plainText(link) || plainText(node);
        if (title === '') return 'drop';
        const href = link ? safeHref(link.getAttribute('href')) : null;
        const kind = node.getAttribute('data-page');
        const id = node.getAttribute('data-id') ?? '';
        keepOnly(node, new Set(['data-type', 'data-page', 'data-id']));
        node.setAttribute('data-page', PAGE_KINDS.includes(kind) ? kind : 'link');
        if (!MENTION_ID_PATTERN.test(id)) node.removeAttribute('data-id');
        if (live) node.setAttribute('contenteditable', 'false');
        const label = node.ownerDocument.createElement(href ? 'a' : 'span');
        if (href) label.setAttribute('href', href);
        label.textContent = title;
        node.replaceChildren(label);
        return 'leaf';
    }
    if (tag === 'IMG') {
        if (!safeImageSrc(node.getAttribute('src'), allowData)) return 'drop';
        if (!live) {
            keepOnly(node, IMAGE_ATTRIBUTES);
            ['width', 'height'].forEach((name) => {
                if (node.hasAttribute(name) && !DIMENSION.test(node.getAttribute(name))) node.removeAttribute(name);
            });
            if (node.hasAttribute('loading') && !['lazy', 'eager'].includes(node.getAttribute('loading'))) node.setAttribute('loading', 'lazy');
        }
        return null;
    }
    if (isImageFigure(node)) {
        const images = [...node.querySelectorAll('img')];
        if (!images.some((img) => safeImageSrc(img.getAttribute('src'), allowData))) return 'drop';
        if (live) node.setAttribute('contenteditable', 'false');
        else {
            keepOnly(node, new Set(['data-slot', 'data-type']));
            if (node.getAttribute('data-slot') !== 'editor-media') node.removeAttribute('data-slot');
        }
    }
    return null;
}
resources/js/ui/editor-triggers.js JS
/**
 * Typed triggers at the caret (mixed into uiEditor by editor.js).
 *
 * Typing a menu's trigger character (`/` for <x-ui.editor.slash-menu>, the
 * mention menu's `trigger`, `@` by default) at the start of a block or after
 * whitespace opens that menu for the text after it. The menu stays open while
 * the caret sits after the trigger in the same block; the text between the
 * trigger and the caret is the menu's query. Focus never leaves the editable
 * region: it becomes the combobox input (aria-controls, aria-autocomplete,
 * aria-activedescendant on the listbox option), and the keys go to the menu:
 *
 *   ArrowDown / ArrowUp   move the active option
 *   Enter / Tab           choose it (the trigger and query are removed first)
 *   Escape                close, keep the typed text
 *
 * Moving the caret out of the trigger range, deleting the trigger, clicking
 * outside or leaving the editor closes the menu. Nothing opens inside code
 * (a code block or inline code), a mention, or an editor-only control.
 *
 * A menu takes part when it sits inside this editor and exposes
 * triggerChar() / triggerOpen() / triggerUpdate() / triggerClose() / move() /
 * choose() / hasResults() (editor-menu-trigger.js). A floating menu shows at
 * the caret; an inline menu keeps its place and follows the query. The
 * `slash-trigger` and `mention-trigger` props turn each kind off.
 */
const BLOCK_TAGS = new Set(['P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SUMMARY', 'BLOCKQUOTE', 'PRE', 'DIV', 'TD', 'TH', 'FIGCAPTION', 'DT', 'DD']);
const SKIP = 'pre, code, [data-type="mention"], [data-editor-ui]';

// Session state per editable element, kept out of Alpine's reactive data:
// it holds DOM nodes and another component's data proxy.
const states = new WeakMap();
function stateOf(editor) {
    const key = editor.content();
    if (!key) return { session: null, skip: false, outside: null };
    if (!states.has(key)) states.set(key, { session: null, skip: false, outside: null });
    return states.get(key);
}

export const editorTriggerMethods = {
    /** The trigger-capable menus inside this editor: { el, kind, data, char }. */
    triggerMenus() {
        const root = this.content()?.closest('[data-slot="editor"]');
        if (!root || !window.Alpine) return [];
        const enabled = this._triggers || {};
        return [...root.querySelectorAll('[data-slot="slash-menu"], [data-slot="mention-menu"]')]
            .filter((el) => el.closest('[data-slot="editor"]') === root)
            .map((el) => {
                const kind = el.getAttribute('data-slot') === 'slash-menu' ? 'slash' : 'mention';
                const data = window.Alpine.$data(el);
                const char = typeof data?.triggerChar === 'function' ? data.triggerChar() : null;
                return { el, kind, data, char };
            })
            .filter((menu) => menu.char && enabled[menu.kind] !== false);
    },

    /** The nearest block-level element holding a node (or the editable root). */
    triggerBlock(node) {
        const root = this.content();
        for (let n = node; n && n !== root; n = n.parentNode) {
            if (n.nodeType === 1 && BLOCK_TAGS.has(n.tagName)) return n;
        }
        return root;
    },

    // On input: a trigger character just typed at a word start opens its menu.
    detectTrigger(event) {
        if (stateOf(this).skip || event?.inputType !== 'insertText' || typeof event.data !== 'string' || event.data.length !== 1) return;
        const menu = this.triggerMenus().find((candidate) => candidate.char === event.data);
        const sel = window.getSelection();
        if (!menu || !sel || !sel.isCollapsed || sel.rangeCount === 0) return;
        const node = sel.anchorNode;
        const offset = sel.anchorOffset;
        if (!node || node.nodeType !== 3 || offset < 1 || node.data[offset - 1] !== menu.char) return;
        const skip = node.parentElement?.closest(SKIP);
        if (skip && this.content().contains(skip)) return;
        const block = this.triggerBlock(node);
        const before = document.createRange();
        before.setStart(block, 0);
        before.setEnd(node, offset - 1);
        const text = before.toString();
        if (text !== '' && !/\s$/.test(text)) return;
        this.openTriggerSession(menu, node, offset - 1, block);
    },

    /**
     * Open a menu from a control (editor-trigger { char }): type the trigger
     * at the caret, after a space when the caret follows a word.
     */
    openTrigger(char) {
        const menu = this.triggerMenus().find((candidate) => candidate.char === char);
        if (!menu) return;
        const root = this.content();
        if (this._savedRange && root.contains(this._savedRange.commonAncestorContainer)) this.restoreSelection();
        else this.placeCaret(root.lastElementChild || root, true);
        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0) return;
        const skip = sel.anchorNode?.nodeType === 1 ? sel.anchorNode.closest(SKIP) : sel.anchorNode?.parentElement?.closest(SKIP);
        if (skip && root.contains(skip)) return;
        const probe = document.createRange();
        probe.setStart(this.triggerBlock(sel.anchorNode), 0);
        probe.setEnd(sel.anchorNode, sel.anchorOffset);
        const lead = probe.toString() === '' || /\s$/.test(probe.toString()) ? '' : ' ';
        stateOf(this).skip = true;
        document.execCommand('insertText', false, `${lead}${char}`);
        stateOf(this).skip = false;
        const node = sel.anchorNode;
        const offset = sel.anchorOffset;
        if (node?.nodeType === 3 && node.data[offset - 1] === char) this.openTriggerSession(menu, node, offset - 1, this.triggerBlock(node));
    },

    openTriggerSession(menu, node, start, block) {
        this.closeTrigger();
        const host = this.content();
        const state = stateOf(this);
        state.session = { menu, node, start, block, char: menu.char };
        if (menu.data.listId) host.setAttribute('aria-controls', menu.data.listId);
        host.setAttribute('aria-autocomplete', 'list');
        host.setAttribute('aria-haspopup', 'listbox');
        state.outside = (event) => {
            if (!menu.el.contains(event.target) && !host.contains(event.target)) this.closeTrigger();
        };
        document.addEventListener('pointerdown', state.outside, true);
        menu.data.triggerOpen({ query: '', host, anchor: () => this.triggerRange()?.getBoundingClientRect() ?? null });
    },

    /** The live range from the trigger character to the caret, or null. */
    triggerRange() {
        const s = stateOf(this).session;
        const sel = window.getSelection();
        if (!s || !sel || sel.rangeCount === 0 || !sel.isCollapsed || !s.node.isConnected) return null;
        if (s.node.data[s.start] !== s.char) return null;
        const caret = sel.getRangeAt(0);
        const range = document.createRange();
        range.setStart(s.node, s.start);
        if (range.comparePoint(caret.startContainer, caret.startOffset) < 0) return null;
        range.setEnd(caret.startContainer, caret.startOffset);
        return this.triggerBlock(caret.startContainer) === s.block ? range : null;
    },

    /** The query (text after the trigger), or null when the session is over. */
    triggerQuery() {
        const text = this.triggerRange()?.toString() ?? '';
        if (text === '') return null;
        const query = text.slice(1).replace(/\u00a0/g, ' ');
        if (/[\r\n]/.test(query) || /^\s/.test(query) || /\s\s/.test(query) || query.length > 64) return null;
        return query;
    },

    // After input or a caret move: follow the query, or close.
    syncTrigger() {
        const s = stateOf(this).session;
        if (!s) return;
        const query = this.triggerQuery();
        if (query === null) this.closeTrigger();
        else s.menu.data.triggerUpdate({ query });
    },

    closeTrigger() {
        const state = stateOf(this);
        const s = state.session;
        if (!s) return;
        state.session = null;
        document.removeEventListener('pointerdown', state.outside, true);
        state.outside = null;
        const host = this.content();
        ['aria-controls', 'aria-autocomplete', 'aria-haspopup', 'aria-activedescendant'].forEach((name) => host?.removeAttribute(name));
        s.menu.data.triggerClose();
    },

    // Keys while a menu is open. Returns true when the key was handled.
    triggerKeydown(event) {
        const s = stateOf(this).session;
        if (!s || event.isComposing || event.keyCode === 229) return false;
        const plain = !event.altKey && !event.ctrlKey && !event.metaKey;
        if (!plain) return false;
        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            s.menu.data.move(event.key === 'ArrowDown' ? 1 : -1);
            return true;
        }
        if ((event.key === 'Enter' || event.key === 'Tab') && !event.shiftKey) {
            // With no results Enter does nothing: no choice, no new line.
            event.preventDefault();
            if (s.menu.data.hasResults()) s.menu.data.choose();
            return true;
        }
        if (event.key === 'Escape') {
            event.preventDefault();
            event.stopPropagation();
            this.closeTrigger();
            return true;
        }
        return false;
    },

    // The editable lost focus: close unless focus went into the open menu.
    triggerBlur(event) {
        const s = stateOf(this).session;
        if (s && !(event?.relatedTarget && s.menu.el.contains(event.relatedTarget))) this.closeTrigger();
    },

    /**
     * A menu chose an option (editor-trigger-commit): remove the trigger and
     * query, leave the caret where they were, then the menu's own
     * editor-block / editor-insert / editor-image event applies the choice.
     */
    commitTrigger() {
        const range = this.triggerRange();
        const block = stateOf(this).session?.block;
        this.closeTrigger();
        if (!range) return;
        range.deleteContents();
        if (block && block !== this.content() && block.textContent === '' && !block.querySelector('br, img')) {
            const br = document.createElement('br');
            range.insertNode(br);
            range.setStartBefore(br);
        }
        range.collapse(true);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
        this.rememberSelection();
    },
};
resources/js/ui/editor-images.js JS
/**
 * Images (mixed into uiEditor by editor.js).
 *
 * The slash menu's Image command (editor-image) opens the editor's image
 * form: an image URL, alt text and, when an upload hook is configured, an
 * Upload button. The URL must be http(s) or relative (a raster data: URL only
 * with `allow-data-urls`); anything else is refused with a message and
 * nothing is inserted. Cancel (or Escape) inserts nothing.
 *
 * Upload hook, either:
 *   - `upload` prop: the editor dispatches a bubbling
 *     `editor-upload { file, resolve, reject }`; the host uploads the file and
 *     calls resolve(url) (or resolve({ url, alt, width, height })), or
 *     reject(message) to show that message;
 *   - `upload-method` prop (Livewire): the editor runs
 *     $wire.upload(uploadProperty, file) and then $wire[uploadMethod](),
 *     which returns the stored URL (or { url, alt, width, height }).
 * With a hook, image files dropped or pasted into the document go through
 * the same path. The form shows an uploading status and the error.
 *
 * The inserted image is the editor-media figure from editor-embeds.js.
 */
import { createImageFigure, safeImageSrc } from './editor-embeds.js';

export const closedForm = () => ({ open: false, src: '', alt: '', error: '', uploading: false });
const isImageFile = (file) => !!file && /^image\//.test(file.type || '');

// editor.js gives each editor its own `imageForm: closedForm()`.
export const editorImageMethods = {
    _imageTicket: 0,

    uploadsEnabled() {
        return !!(this._upload?.enabled || this._upload?.method);
    },

    editorRoot() {
        return this.content()?.closest('[data-slot="editor"]') ?? null;
    },

    openImageForm() {
        if (this.selectionInside()) this.rememberSelection();
        this.imageForm = { ...closedForm(), open: true };
        this.$nextTick(() => this.editorRoot()?.querySelector('[data-slot="editor-image-url"]')?.focus());
    },

    // Close the form; a pending upload is abandoned (its result is ignored).
    closeImageForm(refocus = true) {
        this._imageTicket++;
        this.imageForm = closedForm();
        if (refocus) this.refocusLater();
    },

    insertImageFromForm() {
        const form = this.imageForm;
        if (form.uploading) return;
        const strings = this._strings;
        const src = String(form.src || '').trim();
        const error = src === '' ? strings.imageUrlRequired : safeImageSrc(src, this._clean.allowData) ? '' : strings.imageUrlInvalid;
        if (error) {
            form.error = error;
            this.editorRoot()?.querySelector('[data-slot="editor-image-url"]')?.focus();
            return;
        }
        this.insertImage({ src, alt: form.alt });
        this.closeImageForm(false);
    },

    /** Insert an image figure as a block at the remembered caret. */
    insertImage(image) {
        const figure = createImageFigure(image, this._clean.allowData);
        if (!figure) return false;
        this.restoreSelection();
        const root = this.content();
        const block = this.selectionInside() ? this.leafBlock() : null;
        const empty = block && block.tagName === 'P' && block.textContent.trim() === '' && !block.querySelector('img, hr, table');
        if (empty) block.replaceWith(figure);
        else if (block) block.after(figure);
        else root.appendChild(figure);
        let next = figure.nextElementSibling;
        if (!next) {
            next = document.createElement('p');
            next.appendChild(document.createElement('br'));
            figure.after(next);
        }
        this.placeCaret(next, false);
        this.structureChanged();
        return true;
    },

    uploadFromInput(event) {
        const file = event.target.files?.[0];
        event.target.value = '';
        if (file) this.uploadImage(file);
    },

    async uploadImages(files) {
        for (const file of files) {
            await this.uploadImage(file);
            if (this.imageForm.error) break;
        }
    },

    async uploadImage(file) {
        const strings = this._strings;
        if (!this.imageForm.open) this.imageForm = { ...closedForm(), open: true };
        if (!isImageFile(file)) {
            this.imageForm.error = strings.imageNotImage;
            return;
        }
        const ticket = ++this._imageTicket;
        this.imageForm.uploading = true;
        this.imageForm.error = '';
        try {
            const result = await this.requestUpload(file);
            if (ticket !== this._imageTicket) return;
            const image = typeof result === 'string' ? { src: result } : { src: result?.url ?? result?.src, alt: result?.alt, width: result?.width, height: result?.height };
            if (!safeImageSrc(image.src, this._clean.allowData)) throw strings.imageUrlInvalid;
            this.insertImage({ ...image, alt: this.imageForm.alt || image.alt || '' });
            this.closeImageForm(false);
        } catch (error) {
            if (ticket !== this._imageTicket) return;
            this.imageForm.uploading = false;
            this.imageForm.error = typeof error === 'string' && error.trim() !== '' ? error : strings.imageUploadFailed;
        }
    },

    // The configured hook: Livewire's upload then the method, else the event.
    requestUpload(file) {
        const { method, property } = this._upload || {};
        if (method) {
            const wire = this.$wire;
            return new Promise((resolve, reject) => wire.upload(property || 'upload', file, () => resolve(), () => reject(null)))
                .then(() => wire[method]());
        }
        return new Promise((resolve, reject) => {
            this.content().dispatchEvent(new CustomEvent('editor-upload', { bubbles: true, detail: { file, resolve, reject } }));
        });
    },

    /* --------------------------------------------------- drop and paste */

    // Files dragged over the document may drop (never navigate the page).
    onDragOver(event) {
        if ([...(event.dataTransfer?.types || [])].includes('Files')) event.preventDefault();
    },

    // A file drop never lands as native markup: image files go through the
    // upload hook at the drop point, anything else is ignored.
    onDrop(event) {
        const files = [...(event.dataTransfer?.files || [])];
        if (files.length === 0) return;
        event.preventDefault();
        const images = files.filter(isImageFile);
        if (images.length === 0 || !this.uploadsEnabled()) return;
        this.caretFromPoint(event.clientX, event.clientY);
        this.uploadImages(images);
    },

    // Returns true when a paste of image files was taken over.
    pasteImages(event) {
        const images = [...(event.clipboardData?.files || [])].filter(isImageFile);
        if (images.length === 0 || !this.uploadsEnabled()) return false;
        event.preventDefault();
        if (this.selectionInside()) this.rememberSelection();
        this.uploadImages(images);
        return true;
    },

    caretFromPoint(x, y) {
        let range = null;
        if (document.caretPositionFromPoint) {
            const position = document.caretPositionFromPoint(x, y);
            if (position) {
                range = document.createRange();
                range.setStart(position.offsetNode, position.offset);
            }
        } else if (document.caretRangeFromPoint) {
            range = document.caretRangeFromPoint(x, y);
        }
        if (!range || !this.content().contains(range.startContainer)) return;
        range.collapse(true);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
        this.rememberSelection();
    },
};
resources/js/ui/editor-menu-trigger.js JS
/**
 * The menu side of the editor's typed triggers (editor-triggers.js), shared
 * by the slash menu and the mention menu. The editor opens, updates and
 * closes a menu through these methods; focus stays in the editor, which
 * points aria-activedescendant at the menu's active option.
 *
 * A floating menu (`floating` prop) is hidden until a trigger opens it, then
 * sits at the caret: placed with the dropdown's placeOverlay() engine (below
 * the trigger text, flipped above it near the viewport edge, aligned to the
 * inline start, mirrored in RTL) and re-placed on scroll, resize and a
 * direction change. An inline menu keeps its place and only follows the
 * query.
 *
 * The positioning engine ships with the dropdown item; each menu imports it
 * and passes it in, so the editor never needs the dropdown installed.
 *
 * The host menu provides: query, activeIndex, floating, listId, visible,
 * activeId(), menuStrings { resultsOne, resultsMany, noResults }.
 */
export function menuTriggerMethods({ placeOverlay, trackOverlayPosition }) {
    return {
        triggerActive: false,
        announcement: '',
        _triggerHost: null,
        _triggerAnchor: null,
        _untrackTrigger: null,
        _announceTimer: 0,

        hasResults() {
            return this.visible.length > 0 && !this.loading;
        },

        triggerOpen({ query = '', host = null, anchor = null } = {}) {
            this._triggerHost = host;
            this._triggerAnchor = anchor;
            this.triggerActive = true;
            this.query = query;
            this.activeIndex = 0;
            if (typeof this.scheduleSearch === 'function') this.scheduleSearch(query);
            if (this.floating) {
                this._untrackTrigger?.();
                const place = () => this.placeTrigger();
                const untrack = trackOverlayPosition(place);
                // A direction change reflows the line: follow the caret.
                const observer = new MutationObserver(place);
                observer.observe(document.documentElement, { attributes: true, subtree: true, attributeFilter: ['dir'] });
                this._untrackTrigger = () => {
                    untrack();
                    observer.disconnect();
                };
            }
            this.$nextTick(() => {
                this.placeTrigger();
                this.syncTriggerHost();
            });
        },

        triggerUpdate({ query = '' } = {}) {
            if (this.query !== query) this.query = query;
            this.$nextTick(() => {
                this.placeTrigger();
                this.syncTriggerHost();
            });
        },

        triggerClose() {
            this.triggerActive = false;
            this._untrackTrigger?.();
            this._untrackTrigger = null;
            clearTimeout(this._announceTimer);
            this.announcement = '';
            this._triggerHost?.removeAttribute('aria-activedescendant');
            this._triggerHost = null;
            this._triggerAnchor = null;
            this.query = '';
        },

        placeTrigger() {
            if (!this.floating || !this.triggerActive || !this._triggerAnchor) return;
            const rect = this._triggerAnchor();
            if (rect) placeOverlay(this.$el, rect, { align: 'start', gap: 4 });
        },

        // The editor's aria-activedescendant follows the active option.
        syncTriggerHost() {
            const host = this._triggerHost;
            if (!host) return;
            const id = this.activeId();
            if (id && document.getElementById(id)) host.setAttribute('aria-activedescendant', id);
            else host.removeAttribute('aria-activedescendant');
        },

        // One polite result-count message once typing settles, not per key.
        announceResults() {
            clearTimeout(this._announceTimer);
            this._announceTimer = setTimeout(() => {
                if (this.loading) return;
                const count = this.visible.length;
                const strings = this.menuStrings || {};
                this.announcement = count === 0
                    ? strings.noResults || ''
                    : count === 1 ? strings.resultsOne || '' : String(strings.resultsMany || '').replace(':count', String(count));
            }, 600);
        },

        teardownTrigger() {
            this._untrackTrigger?.();
            this._untrackTrigger = null;
            clearTimeout(this._announceTimer);
        },
    };
}
resources/js/ui/editor-pages.js JS
/**
 * Sub-page and page-link blocks (mixed into uiEditor by editor.js).
 *
 * The slash menu's Page and Link to page commands dispatch
 * `editor-page { kind }` ('sub' | 'link'). The editor asks the host through a
 * bubbling event and waits for its answer:
 *
 *   editor-page-create { resolve, reject }   create a page inside this one
 *   editor-page-link   { resolve, reject }   pick an existing page
 *
 * The host calls resolve({ id, title, href }) to insert the block, or
 * reject(message) to show that message; reject() with no message cancels
 * quietly (a picker the person closed). Livewire:
 *
 *   x-on:editor-page-create="$wire.createSubPage().then($event.detail.resolve, $event.detail.reject)"
 *
 * The block is the page contract from editor-embeds.js; it lands on the
 * empty line with the caret (or after the caret's block), and the caret
 * moves to the line after it.
 */
import { createPageBlock } from './editor-embeds.js';

export const editorPageMethods = {
    _pageTicket: 0,

    requestPage(kind) {
        const type = kind === 'sub' ? 'sub' : 'link';
        if (this.selectionInside()) this.rememberSelection();
        const ticket = ++this._pageTicket;
        this.pageState = { busy: true, error: '' };
        const name = type === 'sub' ? 'editor-page-create' : 'editor-page-link';
        return new Promise((resolve, reject) => {
            this.content().dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { resolve, reject } }));
        }).then((page) => {
            if (ticket !== this._pageTicket) return;
            this.pageState = { busy: false, error: '' };
            if (!this.insertPage(page, type)) this.pageState.error = this._strings.pageFailed || '';
        }, (error) => {
            if (ticket !== this._pageTicket) return;
            const message = typeof error === 'string' ? error.trim() : '';
            this.pageState = { busy: false, error: message };
            if (!message) this.refocusLater();
        });
    },

    dismissPageError() {
        this.pageState = { busy: false, error: '' };
    },

    /** Insert a page block at the remembered caret; false when the page has no title. */
    insertPage(page, kind) {
        const block = createPageBlock(page ?? {}, kind);
        if (!block) return false;
        this.restoreSelection();
        const root = this.content();
        const current = this.selectionInside() ? this.leafBlock() : null;
        const empty = current && current.tagName === 'P' && current.textContent.trim() === '' && !current.querySelector('img, hr, table');
        if (empty) current.replaceWith(block);
        else if (current) current.after(block);
        else root.appendChild(block);
        let next = block.nextElementSibling;
        if (!next || next.getAttribute('contenteditable') === 'false') {
            next = document.createElement('p');
            next.appendChild(document.createElement('br'));
            block.after(next);
        }
        this.placeCaret(next, false);
        this.structureChanged();
        return true;
    },
};
resources/js/ui/editor-markdown.js JS
/**
 * Editor markdown shortcuts (imported by editor.js).
 *
 * Typed at the start of a paragraph, a markdown prefix turns the paragraph
 * into that block; the text after the caret stays in the new block:
 *
 *   `# ` `## ` `### `   heading 1, 2, 3
 *   `- ` `* `           bulleted list
 *   `1. `               numbered list (another number sets its start)
 *   `[] ` `[ ] `        to-do (`[x] ` a checked one)
 *   `> `                quote
 *   ``` ``` then space or Enter   code block
 *   `---`               divider (on the third dash; `—-` after a smart dash)
 *
 * Only a paragraph converts, so a prefix typed inside a heading, a list item,
 * a code block or a to-do stays text. Mod+Z (or the browser's undo) right
 * after a conversion puts the paragraph back with the typed prefix and the
 * caret after it; any other key, input or click first makes the conversion
 * final and leaves undo to the browser.
 */
import { createTodoItem } from './editor-blocks.js';

const RULES = [
    { pattern: /^(#{1,3})\s$/, block: (match) => `h${match[1].length}` },
    { pattern: /^[-*]\s$/, block: () => 'bulleted' },
    { pattern: /^(\d{1,9})\.\s$/, block: () => 'numbered' },
    { pattern: /^\[( |x|X)?\]\s$/, block: () => 'todo' },
    { pattern: /^>\s$/, block: () => 'blockquote' },
    { pattern: /^```\s$/, block: () => 'code' },
    { pattern: /^(---|—-)$/, block: () => 'divider' },
];

const HEADINGS = { h1: 'H1', h2: 'H2', h3: 'H3', blockquote: 'BLOCKQUOTE', code: 'PRE' };

/** The block for a typed prefix, with its match, or null. */
export function matchShortcut(prefix) {
    for (const rule of RULES) {
        const match = prefix.match(rule.pattern);
        if (match) return { type: rule.block(match), match };
    }

    return null;
}

// Move a paragraph's content (after the removed prefix) into another element.
function moveInline(from, to) {
    [...from.childNodes].forEach((child) => to.appendChild(child));
    if (to.textContent === '' && !to.querySelector('br, img')) to.appendChild(document.createElement('br'));

    return to;
}

// Put the caret `offset` characters into `block`'s text.
function caretAtText(block, offset) {
    const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
    let left = offset;
    let node = walker.nextNode();
    const range = document.createRange();
    while (node) {
        if (left <= node.textContent.length) {
            range.setStart(node, left);
            range.collapse(true);
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);

            return;
        }
        left -= node.textContent.length;
        node = walker.nextNode();
    }
    range.selectNodeContents(block);
    range.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
}

/**
 * Methods mixed into the uiEditor Alpine component. `this` is the editor:
 * content(), leafBlock(), placeCaret(), structureChanged() come from
 * editor.js and editor-blocks.js.
 */
export const editorMarkdownMethods = {
    // The last conversion, while Mod+Z can still revert it: { snapshot, nodes, offset }.
    _markdownUndo: null,

    /**
     * After an input: convert the caret's paragraph when the text before the
     * caret is a complete prefix. `force` converts a code fence on Enter.
     * Returns true when the paragraph was converted.
     */
    applyMarkdownShortcut(event, force = false) {
        if (!force && (event?.inputType !== 'insertText' || event.isComposing)) return false;
        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0 || !sel.isCollapsed || !this.selectionInside()) return false;
        // Text typed straight into an empty document has no paragraph yet.
        const typed = sel.anchorNode;
        if (typed.nodeType === 3 && typed.parentNode === this.content()) {
            const offset = sel.anchorOffset;
            const paragraph = document.createElement('p');
            typed.before(paragraph);
            paragraph.appendChild(typed);
            sel.collapse(typed, offset);
        }
        const block = this.leafBlock();
        if (!block || block.tagName !== 'P') return false;
        const before = document.createRange();
        before.selectNodeContents(block);
        before.setEnd(sel.anchorNode, sel.anchorOffset);
        const prefix = before.toString();
        if (prefix.length > 12) return false;
        const found = force ? (/^```$/.test(prefix) ? { type: 'code', match: null } : null) : matchShortcut(prefix);
        if (!found) return false;

        const snapshot = block.cloneNode(true);
        before.deleteContents();
        const nodes = this.convertParagraph(block, found);
        this._markdownUndo = { snapshot, nodes, offset: prefix.length };
        this.closeTrigger?.();
        this.structureChanged();

        return true;
    },

    /** Replace `block` with the block for `found`; returns the nodes that replaced it. */
    convertParagraph(block, { type, match }) {
        if (HEADINGS[type]) {
            const next = moveInline(block, document.createElement(HEADINGS[type]));
            block.replaceWith(next);
            this.placeCaret(next);

            return [next];
        }
        if (type === 'bulleted' || type === 'numbered' || type === 'todo') {
            const list = document.createElement(type === 'numbered' ? 'ol' : 'ul');
            const start = type === 'numbered' ? Number.parseInt(match[1], 10) : 1;
            if (start !== 1) list.setAttribute('start', String(start));
            let item = document.createElement('li');
            if (type === 'todo') {
                list.setAttribute('data-type', 'todo');
                item = createTodoItem(/x/i.test(match[1] ?? ''));
            }
            list.appendChild(moveInline(block, item));
            block.replaceWith(list);
            this.placeCaret(item);

            return [list];
        }
        // Divider: the rule, then the paragraph (with any text after the dashes).
        const rule = document.createElement('hr');
        block.before(rule);
        if (block.textContent === '' && !block.querySelector('br, img')) block.appendChild(document.createElement('br'));
        this.placeCaret(block);

        return [rule, block];
    },

    /** Mod+Z right after a conversion: back to the paragraph with the prefix. */
    undoMarkdownShortcut() {
        const undo = this._markdownUndo;
        this._markdownUndo = null;
        if (!undo || !undo.nodes.every((node) => node.isConnected)) return false;
        const [first] = undo.nodes;
        first.before(undo.snapshot);
        undo.nodes.forEach((node) => node.remove());
        this.content()?.focus({ preventScroll: true });
        caretAtText(undo.snapshot, undo.offset);
        this.structureChanged();

        return true;
    },

    /**
     * Keydown: Mod+Z reverts the last conversion, Enter after a code fence
     * converts it; any other key (not a lone modifier) makes a conversion final.
     * Returns true when the key was handled here.
     */
    markdownKeydown(event) {
        if (event.isComposing) return false;
        const mod = (event.metaKey || event.ctrlKey) && !event.altKey;
        if (mod && !event.shiftKey && event.key.toLowerCase() === 'z' && this._markdownUndo) {
            if (!this.undoMarkdownShortcut()) return false;
            event.preventDefault();

            return true;
        }
        if (!['Shift', 'Control', 'Meta', 'Alt'].includes(event.key)) this._markdownUndo = null;
        if (event.key === 'Enter' && !event.shiftKey && !mod && this.applyMarkdownShortcut(event, true)) {
            event.preventDefault();

            return true;
        }

        return false;
    },

    /** beforeinput historyUndo (the Edit menu, a shake to undo): same as Mod+Z. */
    markdownBeforeInput(event) {
        if (event.inputType !== 'historyUndo' || !this._markdownUndo) return;
        if (this.undoMarkdownShortcut()) event.preventDefault();
    },
};

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.7.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0