defmodule PhiaUi.Components.Editor do
@moduledoc """
Editor Suite — 19 components for building rich text editing experiences.
Inspired by **TipTap** — the most popular headless rich text editor (2.5M+ weekly
npm downloads, built on ProseMirror, used by Notion, Linear, Vercel, and more) —
this module provides all the building blocks needed to assemble a full-featured editor.
## Research: Most Popular Rich Text Editors (2025)
| Editor | Weekly DLs | Foundation | Used by |
|--------------|-------------|---------------|--------------------------------|
| **TipTap** | 2.5M+ | ProseMirror | Notion clones, Linear, Vercel |
| Quill v2 | 1.8M+ | Custom | Slack, LinkedIn, Figma, Airtable |
| Lexical | 1.2M+ | Custom (Meta) | Facebook, WhatsApp Web |
| ProseMirror | 900K+ | — | Base for TipTap/Remirror |
| CKEditor 5 | 600K+ | Custom | Enterprise / CMS |
TipTap wins on DX: headless, framework-agnostic, tree-shakable, collaborative.
`advanced_editor/1` is a PhiaUI transpilation of TipTap's editor design.
## Components
### Group A — Toolbar Primitives (no JS hooks)
- `editor_toolbar/1` — `role="toolbar"` wrapper
- `toolbar_button/1` — single action button (active, disabled, size variants)
- `toolbar_group/1` — `role="group"` semantic grouping
- `toolbar_separator/1` — vertical `role="separator"` divider
### Group B — Floating / Contextual (JS hooks)
- `bubble_menu/1` — floating toolbar above text selection (PhiaBubbleMenu)
- `floating_menu/1` — block-insertion menu at empty cursor (PhiaFloatingMenu)
- `slash_command_menu/1` — `/` trigger command palette (PhiaSlashCommand)
### Group C — Enhanced Inline Editing
- `inline_edit/1` — extended editable with type/validation/buttons (PhiaEditable)
- `inline_edit_group/1` — wrapper for bulk-editing multiple inline fields
### Group D — Rich Text Utilities
- `editor_color_picker/1` — text/bg color palette toolbar dropdown (PhiaEditorColorPicker)
- `editor_toolbar_dropdown/1` — generic toolbar dropdown (PhiaEditorDropdown)
- `editor_link_dialog/1` — modal for insert/edit link
- `editor_code_block/1` — language badge + copy button + line numbers
- `editor_character_count/1` — char/word counter with optional progress bar
- `markdown_editor/1` — split-pane textarea + preview (PhiaMarkdownEditor)
- `rich_text_viewer/1` — read-only HTML container with prose sizing
- `editor_find_replace/1` — slide-in search+replace bar (PhiaEditorFindReplace)
- `editor_word_count/1` — words / reading-time display widget
### Bonus — TipTap-Inspired Full Editor
- `advanced_editor/1` — complete WYSIWYG combining all primitives (PhiaAdvancedEditor)
"""
use Phoenix.Component
import PhiaUi.ClassMerger, only: [cn: 1]
# ============================================================================
# Group A — Toolbar Primitives
# ============================================================================
# ----------------------------------------------------------------------------
# editor_toolbar/1
# ----------------------------------------------------------------------------
attr :id, :string, default: nil
attr :variant, :atom, default: :default, values: [:default, :floating, :compact]
attr :aria_label, :string, default: "Editor toolbar"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
@doc """
Renders a `role="toolbar"` container for editor controls.
Variants:
- `:default` — bordered bar, wraps to multiple lines
- `:floating` — elevated popover-style (shadow, backdrop blur)
- `:compact` — minimal single-line strip
## Example
<.editor_toolbar aria_label="Formatting toolbar">
<.toolbar_button action="bold" aria_label="Bold">
"""
def editor_toolbar(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
defp toolbar_variant(:default),
do: "flex flex-wrap items-center gap-0.5 rounded-md border border-input bg-background p-1"
defp toolbar_variant(:floating),
do:
"flex items-center gap-0.5 rounded-lg border border-border bg-popover px-1.5 py-1 shadow-lg backdrop-blur-sm"
defp toolbar_variant(:compact),
do: "flex items-center gap-px rounded border border-input bg-muted/30 px-0.5 py-0.5"
# ----------------------------------------------------------------------------
# toolbar_button/1
# ----------------------------------------------------------------------------
attr :action, :string, required: true
attr :aria_label, :string, required: true
attr :active, :boolean, default: false
attr :disabled, :boolean, default: false
attr :tooltip, :string, default: nil
attr :size, :atom, default: :sm, values: [:xs, :sm, :md]
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
@doc """
Renders a single toolbar action button.
The hook reads `data-action` to dispatch formatting commands and toggles
`aria-pressed` and the `is-active` CSS class automatically.
## Example
<.toolbar_button action="bold" aria_label="Bold" tooltip="Bold (Ctrl+B)" active={@bold_active}>
"""
def toolbar_button(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
defp toolbar_btn_size(:xs), do: "h-6 w-6 p-1"
defp toolbar_btn_size(:sm), do: "h-7 w-7 p-1.5"
defp toolbar_btn_size(:md), do: "h-8 w-8 p-2"
# ----------------------------------------------------------------------------
# toolbar_group/1
# ----------------------------------------------------------------------------
attr :label, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
@doc """
Renders a `role="group"` semantic group for related toolbar buttons.
## Example
<.toolbar_group label="Text formatting">
<.toolbar_button action="bold" aria_label="Bold">...
<.toolbar_button action="italic" aria_label="Italic">...
"""
def toolbar_group(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
# ----------------------------------------------------------------------------
# toolbar_separator/1
# ----------------------------------------------------------------------------
attr :class, :string, default: nil
@doc """
Renders a thin vertical `role="separator"` divider between toolbar groups.
## Example
<.toolbar_group label="Marks">...
<.toolbar_separator />
<.toolbar_group label="Blocks">...
"""
def toolbar_separator(assigns) do
~H"""
"""
end
# ============================================================================
# Group B — Floating / Contextual
# ============================================================================
# ----------------------------------------------------------------------------
# bubble_menu/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :editor_id, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
@doc """
Renders a floating toolbar that appears above the user's text selection.
The `PhiaBubbleMenu` hook listens to `selectionchange`, computes the selection
bounding rect, and positions this div above the selected text. Clamps to viewport.
## Example
<.bubble_menu id="bubble" editor_id="my-editor">
<.toolbar_button action="bold" aria_label="Bold" size={:xs}>
"""
def bubble_menu(assigns) do
~H"""
cn([
"fixed z-50 flex items-center gap-0.5 rounded-lg",
"border border-border bg-popover px-1.5 py-1 shadow-lg",
@class
])}
>
{render_slot(@inner_block)}
"""
end
# ----------------------------------------------------------------------------
# floating_menu/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :editor_id, :string, required: true
attr :trigger, :atom, default: :empty_line, values: [:empty_line, :slash]
attr :class, :string, default: nil
slot :inner_block, required: true
@doc """
Renders a block-insertion menu that floats at the empty cursor line.
The `PhiaFloatingMenu` hook shows this panel when the cursor is on an empty block,
and hides it on any text input or Escape.
## Example
<.floating_menu id="float-menu" editor_id="my-editor" trigger={:empty_line}>
<.toolbar_button action="bulletList" aria_label="Bullet list">...
<.toolbar_button action="h1" aria_label="Heading 1">...
"""
def floating_menu(assigns) do
~H"""
cn([
"fixed z-50 flex items-center gap-1",
"rounded-lg border border-border bg-popover px-2 py-1.5 shadow-md",
@class
])}
>
{render_slot(@inner_block)}
"""
end
# ----------------------------------------------------------------------------
# slash_command_menu/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :editor_id, :string, required: true
attr :on_select, :string, required: true
attr :class, :string, default: nil
slot :item do
attr :value, :string, required: true
attr :label, :string, required: true
attr :icon, :string
attr :description, :string
attr :shortcut, :string
end
@doc """
Renders a `/` trigger command palette for block-level actions.
The `PhiaSlashCommand` hook activates when the user types `/` at the start of an
empty block. It fuzzy-filters items, supports arrow-key navigation and Enter to select.
## Example
<.slash_command_menu id="slash" editor_id="my-editor" on_select="block_inserted">
<:item value="h1" label="Heading 1" icon="H1" description="Large section heading" shortcut="/h1" />
<:item value="bulletList" label="Bullet List" icon="•" description="Simple unordered list" />
<:item value="codeBlock" label="Code Block" icon="<>" description="Insert code snippet" />
"""
def slash_command_menu(assigns) do
items_data =
Enum.map(assigns.item, fn item ->
%{
value: Map.get(item, :value, ""),
label: Map.get(item, :label, ""),
icon: Map.get(item, :icon, nil),
description: Map.get(item, :description, nil),
shortcut: Map.get(item, :shortcut, nil)
}
end)
assigns = assign(assigns, :items_json, Jason.encode!(items_data))
~H"""
{Map.get(item, :icon, "")}
{item.label}
{Map.get(item, :description, "")}
{Map.get(item, :shortcut, "")}
"""
end
# ============================================================================
# Group C — Enhanced Inline Editing
# ============================================================================
# ----------------------------------------------------------------------------
# inline_edit/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :value, :string, default: nil
attr :type, :atom, default: :text, values: [:text, :number, :textarea, :select]
attr :placeholder, :string, default: "Click to edit"
attr :on_submit, :string, default: nil
attr :on_cancel, :string, default: nil
attr :show_buttons, :boolean, default: false
attr :loading, :boolean, default: false
attr :error, :string, default: nil
attr :required, :boolean, default: false
attr :min, :any, default: nil
attr :max, :any, default: nil
attr :options, :list, default: []
attr :class, :string, default: nil
slot :preview
@doc """
Enhanced inline editable field. Extends `editable/1` with input type variants,
validation errors, loading state, and optional confirm/cancel buttons.
Uses the `PhiaEditable` hook (same as `editable/1`).
## Example
<.inline_edit id="title-edit" value={@title} type={:text} on_submit="update_title"
show_buttons placeholder="Enter a title...">
<:preview>{@title}
"""
def inline_edit(assigns) do
~H"""
<%!-- Preview mode --%>
{if @preview != [],
do: render_slot(@preview),
else: @value || @placeholder}
<%!-- Edit mode --%>
"""
end
defp inline_opt_value({v, _l}), do: to_string(v)
defp inline_opt_value(v) when is_binary(v), do: v
defp inline_opt_value(v), do: to_string(v)
defp inline_opt_label({_v, l}), do: l
defp inline_opt_label(v) when is_binary(v), do: v
defp inline_opt_label(v), do: to_string(v)
# ----------------------------------------------------------------------------
# inline_edit_group/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :on_save_all, :string, default: nil
attr :on_cancel_all, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
slot :actions
@doc """
Wrapper for grouping multiple `inline_edit/1` fields that share a bulk save/cancel flow.
Pass custom action buttons via the `:actions` slot, or use `on_save_all`/`on_cancel_all`
to auto-render default Save all / Cancel buttons.
## Example
<.inline_edit_group id="profile-edit" on_save_all="save_profile" on_cancel_all="reset_profile">
<.inline_edit id="name-edit" value={@name} on_submit="update_name" />
<.inline_edit id="email-edit" value={@email} on_submit="update_email" />
"""
def inline_edit_group(assigns) do
~H"""
{render_slot(@inner_block)}
{render_slot(@actions)}
Save all
Cancel
"""
end
# ============================================================================
# Group D — Rich Text Utilities
# ============================================================================
# ----------------------------------------------------------------------------
# editor_color_picker/1
# ----------------------------------------------------------------------------
@default_colors ~w[
#000000 #374151 #6B7280 #9CA3AF #D1D5DB #F9FAFB #FFFFFF
#EF4444 #F97316 #EAB308 #22C55E #3B82F6 #8B5CF6 #EC4899
]
attr :id, :string, default: nil
attr :action, :string, default: "foreColor"
attr :label, :string, default: "Text color"
attr :colors, :list, default: []
attr :value, :string, default: nil
attr :class, :string, default: nil
@doc """
Renders a color palette picker toolbar dropdown for text/background colors.
The `PhiaEditorColorPicker` hook dispatches `execCommand(action, false, hex)`
on swatch click and tracks the current color via `queryCommandValue` on selection change.
## Example
<.editor_color_picker action="foreColor" label="Text color" />
<.editor_color_picker action="hiliteColor" label="Highlight" value="#EAB308" />
"""
def editor_color_picker(assigns) do
palette = if assigns.colors == [], do: @default_colors, else: assigns.colors
picker_id = assigns.id || "phia-color-picker-#{System.unique_integer([:positive])}"
assigns =
assigns
|> assign(:palette, palette)
|> assign(:picker_id, picker_id)
~H"""
<%!-- Letter A icon --%>
<%!-- Color indicator bar --%>
<%!-- Palette panel --%>
"""
end
# ----------------------------------------------------------------------------
# editor_toolbar_dropdown/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :label, :string, required: true
attr :value, :string, default: nil
attr :class, :string, default: nil
slot :item do
attr :value, :string, required: true
attr :action, :string
attr :label, :string, required: true
end
@doc """
Renders a generic dropdown inside a toolbar (heading level, font size, etc.).
The `PhiaEditorDropdown` hook toggles the panel on trigger click and dispatches
the selected item's `data-action` to the linked editor.
## Example
<.editor_toolbar_dropdown id="heading-dd" label="Paragraph">
<:item value="paragraph" action="paragraph" label="Paragraph" />
<:item value="h1" action="h1" label="Heading 1" />
<:item value="h2" action="h2" label="Heading 2" />
<:item value="h3" action="h3" label="Heading 3" />
"""
def editor_toolbar_dropdown(assigns) do
~H"""
"""
end
# ----------------------------------------------------------------------------
# editor_link_dialog/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :on_submit, :string, default: nil
attr :initial_url, :string, default: nil
attr :initial_title, :string, default: nil
attr :class, :string, default: nil
@doc """
Renders a modal dialog for inserting or editing a hyperlink.
Fields: URL, title (optional), open-in-new-tab checkbox.
The dialog is shown/hidden programmatically via the editor hook calling
`document.getElementById(id).classList.remove('hidden')`.
## Example
<.editor_link_dialog id="link-dialog" on_submit="insert_link" />
"""
def editor_link_dialog(assigns) do
~H"""
<%!-- Backdrop --%>
<%!-- Dialog panel --%>
"""
end
# ----------------------------------------------------------------------------
# editor_code_block/1
# ----------------------------------------------------------------------------
attr :id, :string, default: "editor-code-block"
attr :language, :string, default: nil
attr :filename, :string, default: nil
attr :show_copy, :boolean, default: true
attr :copy_value, :string, default: nil
attr :show_line_numbers, :boolean, default: false
attr :class, :string, default: nil
slot :inner_block, required: true
@doc """
Renders a styled code block with a header bar (language badge, filename, copy button).
The copy button reuses `PhiaCopyButton` via `data-value={copy_value}`.
Pass `copy_value` with the raw code string for clipboard support.
## Example
<.editor_code_block id="my-snippet" language="elixir" filename="hello.ex"
copy_value={@raw_code}>
def hello, do: IO.puts("Hello, world!")
"""
def editor_code_block(assigns) do
~H"""
<%!-- Header bar --%>
<%!-- Traffic-light dots --%>
{@filename}
{@language}
Copy
<%!-- Code area with optional line numbers --%>
<%!-- Line numbers --%>
1
{render_slot(@inner_block)}
"""
end
# ----------------------------------------------------------------------------
# editor_character_count/1
# ----------------------------------------------------------------------------
attr :count, :integer, default: 0
attr :max, :integer, default: nil
attr :mode, :atom, default: :characters, values: [:characters, :words, :both]
attr :show_bar, :boolean, default: false
attr :class, :string, default: nil
@doc """
Renders a server-side character/word counter with optional progress bar.
Drives via `phx-change` on the editor: the LiveView counts characters/words
and assigns `:count`. The progress bar fills and changes colour as `count` nears `max`.
## Example
<.editor_character_count count={@char_count} max={500} mode={:characters} show_bar />
<.editor_character_count count={@word_count} max={100} mode={:words} />
<.editor_character_count count={@char_count} mode={:both} />
"""
def editor_character_count(assigns) do
~H"""
= @max && "text-destructive"])}>
{@count}
{if @max, do: " / #{@max}", else: ""} characters
{char_to_words(@count)} words
0} class="h-1 w-full overflow-hidden rounded-full bg-muted">
"""
end
defp char_to_words(0), do: 0
defp char_to_words(chars), do: max(0, div(chars, 5))
defp count_bar_color(count, max) when not is_nil(max) and count >= max, do: "bg-destructive"
defp count_bar_color(count, max)
when not is_nil(max) and count >= div(max * 9, 10),
do: "bg-orange-500"
defp count_bar_color(_count, _max), do: "bg-primary"
# ----------------------------------------------------------------------------
# markdown_editor/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :value, :string, default: nil
attr :preview_html, :string, default: nil
attr :on_change, :string, default: nil
attr :label, :string, default: nil
attr :placeholder, :string, default: "Write in Markdown..."
attr :min_height, :string, default: "200px"
attr :class, :string, default: nil
slot :toolbar
@doc """
Renders a split-pane Markdown editor: textarea (write) + server-rendered preview.
The `PhiaMarkdownEditor` hook debounces textarea `input` events (300 ms) and
calls `pushEvent(on_change, {raw, length, words})`. The LiveView re-renders
with the updated `preview_html` after server-side Markdown parsing.
## Example
<.markdown_editor id="body-editor" value={@draft} preview_html={@preview}
on_change="md_changed" label="Article body" />
"""
def markdown_editor(assigns) do
~H"""
{@label}
<%!-- Tab bar --%>
Write
Preview
{render_slot(@toolbar)}
<%!-- Content panes --%>
{if @preview_html do
Phoenix.HTML.raw(@preview_html)
else
Phoenix.HTML.raw(~s[
Nothing to preview.
])
end}
"""
end
# ----------------------------------------------------------------------------
# rich_text_viewer/1
# ----------------------------------------------------------------------------
attr :content, :string, required: true
attr :prose_size, :atom, default: :base, values: [:sm, :base, :lg]
attr :class, :string, default: nil
attr :rest, :global
@doc """
Renders a read-only `Phoenix.HTML.raw` container with prose sizing.
**Security:** Always sanitize `content` server-side before passing to this
component. Use `HtmlSanitizeEx` or equivalent.
## Example
<.rich_text_viewer content={@post.body} prose_size={:lg} />
"""
def rich_text_viewer(assigns) do
~H"""
{Phoenix.HTML.raw(@content)}
"""
end
defp viewer_prose_class(:sm), do: "prose-sm"
defp viewer_prose_class(:base), do: ""
defp viewer_prose_class(:lg), do: "prose-lg"
# ----------------------------------------------------------------------------
# editor_find_replace/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :editor_id, :string, required: true
attr :class, :string, default: nil
@doc """
Renders a slide-in search-and-replace bar for a `contenteditable` editor.
The `PhiaEditorFindReplace` hook opens on Ctrl/Cmd+F inside the editor,
injects `` elements for matches, and provides find prev/next and replace.
## Example
<.editor_find_replace id="find-bar" editor_id="my-editor" />
"""
def editor_find_replace(assigns) do
~H"""
"""
end
# ----------------------------------------------------------------------------
# editor_word_count/1
# ----------------------------------------------------------------------------
attr :content, :string, default: ""
attr :show_reading_time, :boolean, default: true
attr :words_per_minute, :integer, default: 200
attr :class, :string, default: nil
@doc """
Renders a standalone words / characters / reading-time display widget.
All computation is server-side — pass the raw text content.
## Example
<.editor_word_count content={@body_text} show_reading_time words_per_minute={250} />
"""
def editor_word_count(assigns) do
words = count_words(assigns.content)
chars = String.length(assigns.content || "")
wpm = max(1, assigns.words_per_minute)
reading_min = max(1, ceil(words / wpm))
assigns =
assigns
|> assign(:word_count, words)
|> assign(:char_count, chars)
|> assign(:reading_min, reading_min)
~H"""
{@word_count} words
{@char_count} characters
{@reading_min} min read
"""
end
defp count_words(nil), do: 0
defp count_words(""), do: 0
defp count_words(text), do: text |> String.split(~r/\s+/, trim: true) |> length()
# ============================================================================
# Bonus — TipTap-Inspired Advanced Editor
# ============================================================================
# ----------------------------------------------------------------------------
# advanced_editor/1
# ----------------------------------------------------------------------------
attr :id, :string, required: true
attr :field, Phoenix.HTML.FormField, default: nil
attr :value, :string, default: nil
attr :placeholder, :string, default: "Type '/' for commands, or start writing..."
attr :min_height, :string, default: "300px"
attr :toolbar_variant, :atom, default: :default, values: [:default, :compact, :floating]
attr :show_word_count, :boolean, default: true
attr :show_find_replace, :boolean, default: false
attr :class, :string, default: nil
@doc """
Full-featured WYSIWYG editor inspired by TipTap's design.
**TipTap** is the most popular headless rich text editor (built on ProseMirror,
2.5M+ weekly npm downloads). This component transpiles TipTap's UX patterns
to PhiaUI: same toolbar layout, bubble menus, slash commands, and inline SVG icons —
powered entirely by vanilla JS + `execCommand` via the `PhiaAdvancedEditor` hook.
Features:
- Full toolbar: undo/redo, heading dropdown, marks (bold/italic/underline/strike/code),
lists (bullet/ordered), links (insert/remove), text color picker, find & replace toggle
- Bubble menu for quick inline formatting of selected text
- Floating menu at empty cursor for block insertion
- Find & replace bar (Ctrl/Cmd+F)
- Live word/character count footer
- `Phoenix.HTML.FormField` integration via hidden ` `
- Dark mode support via `.dark` class
## Example
<%!-- Standalone --%>
<.advanced_editor id="blog-editor" placeholder="Write your post..." min_height="400px" />
<%!-- Form-integrated --%>
<.form for={@form} phx-submit="publish">
<.advanced_editor id="post-editor" field={@form[:body]} show_word_count />
## Hook registration
import PhiaAdvancedEditor from "./hooks/advanced_editor"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { PhiaAdvancedEditor, PhiaBubbleMenu, PhiaEditorColorPicker, PhiaEditorFindReplace }
})
"""
def advanced_editor(assigns) do
field = assigns.field
id = assigns.id
assigns =
assigns
|> assign(:editor_id, "#{id}-content")
|> assign(:bubble_id, "#{id}-bubble")
|> assign(:float_id, "#{id}-float")
|> assign(:find_id, "#{id}-find")
|> assign(:link_id, "#{id}-link")
|> assign(:hidden_id, if(field, do: field.id, else: "#{id}-value"))
|> assign(:hidden_name, if(field, do: field.name, else: id))
|> assign(:initial_html, if(field, do: field.value || "", else: assigns.value || ""))
|> assign(:errors, if(field, do: field.errors, else: []))
~H"""
<%!-- Find & Replace bar (hidden by default, opened by Ctrl+F) --%>
<.editor_find_replace :if={@show_find_replace} id={@find_id} editor_id={@editor_id} />
<%!-- Main toolbar --%>
<.editor_toolbar
variant={@toolbar_variant}
aria_label="Editor formatting toolbar"
class="px-2 py-1.5"
>
<%!-- History --%>
<.toolbar_group label="History">
<.toolbar_button action="undo" aria_label="Undo" tooltip="Undo (Ctrl+Z)">
<.toolbar_button action="redo" aria_label="Redo" tooltip="Redo (Ctrl+Shift+Z)">
<.toolbar_separator />
<%!-- Block format dropdown --%>
<.editor_toolbar_dropdown id={"#{@id}-block-dd"} label="Paragraph">
<:item value="paragraph" action="paragraph" label="Paragraph" />
<:item value="h1" action="h1" label="Heading 1" />
<:item value="h2" action="h2" label="Heading 2" />
<:item value="h3" action="h3" label="Heading 3" />
<:item value="blockquote" action="blockquote" label="Blockquote" />
<:item value="codeBlock" action="codeBlock" label="Code Block" />
<.toolbar_separator />
<%!-- Inline marks --%>
<.toolbar_group label="Text formatting">
<.toolbar_button action="bold" aria_label="Bold" tooltip="Bold (Ctrl+B)">
<.toolbar_button action="italic" aria_label="Italic" tooltip="Italic (Ctrl+I)">
<.toolbar_button action="underline" aria_label="Underline" tooltip="Underline (Ctrl+U)">
<.toolbar_button action="strike" aria_label="Strikethrough" tooltip="Strikethrough">
<.toolbar_button action="code" aria_label="Inline code" tooltip="Inline code">
<.toolbar_separator />
<%!-- Lists --%>
<.toolbar_group label="Lists">
<.toolbar_button action="bulletList" aria_label="Bullet list" tooltip="Bullet list">
<.toolbar_button action="orderedList" aria_label="Ordered list" tooltip="Ordered list">
<.toolbar_separator />
<%!-- Links --%>
<.toolbar_group label="Links">
<.toolbar_button action="link" aria_label="Insert link" tooltip="Insert link">
<.toolbar_button action="unlink" aria_label="Remove link" tooltip="Remove link">
<.toolbar_separator />
<%!-- Color picker --%>
<.editor_color_picker action="foreColor" label="Text color" />
<.toolbar_separator />
<%!-- Find & replace toggle --%>
<.toolbar_button action="findReplace" aria_label="Find and replace" tooltip="Find and replace (Ctrl+F)">
<%!-- Bubble menu (appears above selection) --%>
<.bubble_menu id={@bubble_id} editor_id={@editor_id}>
<.toolbar_button action="bold" aria_label="Bold" size={:xs}>
<.toolbar_button action="italic" aria_label="Italic" size={:xs}>
<.toolbar_button action="underline" aria_label="Underline" size={:xs}>
<.toolbar_button action="strike" aria_label="Strikethrough" size={:xs}>
<.toolbar_separator />
<.toolbar_button action="link" aria_label="Link" size={:xs}>
<.toolbar_button action="code" aria_label="Code" size={:xs}>
<%!-- Editor content area --%>
<%!-- Footer --%>
<.editor_word_count :if={@show_word_count} content={@initial_html} show_reading_time />
{Enum.map_join(@errors, "; ", fn {msg, _} -> msg end)}
<%!-- Link dialog --%>
<.editor_link_dialog id={@link_id} />
<%!-- Hidden input for form integration --%>
"""
end
end