# Changelog

## 0.6.0

### Fixed

- **Undo now works, in every mode.** Leaf had no history of its own and
  delegated to the browser's, which could not survive how the editor works.
  The button in markdown mode was a literal no-op (`case "undo": break;`)
  while Ctrl+Z still worked there, so the two disagreed. In visual and hybrid
  mode `document.execCommand("undo")` only ever saw edits execCommand itself
  made — never Leaf's own DOM work — and the native stack was discarded
  outright by every wholesale `innerHTML` write: markdown→visual sync, mode
  switches, `set_content`, and select-all-then-delete during ordinary typing.
  That last one was the most visible: select all, delete, Ctrl+Z, and nothing
  came back.

- **Undo survives a mode switch.** The markdown textarea and the visual
  contenteditable kept separate native stacks, so undo silently meant
  different things per tab and could never step across a switch.

- **Undo and redo buttons reflect availability.** They were always clickable,
  including with nothing to undo.

- **The undo button no longer stays greyed out while Ctrl+Z works.** The
  visual editor's history listener referenced a `self` its enclosing scope
  never bound, so every keystroke threw inside the event handler and nothing
  was captured. Ctrl+Z still appeared to work — the undo path captures for
  itself before restoring — so a single undo jumped all the way back to the
  state at mount rather than stepping.

- **Enter on an empty bullet only leaves the list at the end of one.** With
  further items below, that gesture used to delete the blank item and cut the
  list in two around it. Mid-list it now continues the list — the blank item
  stays and a fresh one opens under it — because leaving is not a sensible
  reading of Enter when there is still list below the cursor. At the end of a
  list, where exiting is the only thing it can reasonably mean, the behaviour
  is unchanged.

- **Ordered lists no longer renumber themselves from 1.** A list can start at
  19 — continuing one interrupted by a paragraph, or numbering steps on from an
  earlier section — and CommonMark preserves that as `<ol start="19">`. The
  serializer counted from 1 regardless, so every trip out to markdown silently
  rewrote the numbering; switching modes was where it showed. The start is now
  read from the list, hybrid reveals the number an item actually displays
  rather than its position, and typing a number on the FIRST item renumbers the
  list. Later items are left alone: their numbers carry no meaning in markdown,
  and renumbering from one of them would move everything above it.

- **A blank row survives at the end of a list that another list follows.**
  Editing produces adjacent lists more readily than it looks: deleting an
  item's `- ` marker breaks that item out to a paragraph and moves everything
  below it into a second list, and deleting the paragraph closes the two up
  against each other. They render as one continuous list, but a blank row at
  the end of the first had no next item, so it was tidied away while bullets
  were plainly visible underneath. A directly adjacent list now counts as a
  continuation; anything else between them still means the list ended.

- **Deleting an empty row no longer leaves the editor tracking a removed
  block.** The Backspace merge detached the item without clearing
  `_sourceBlock`, so the editor went on believing it was editing a node that
  had left the document — which is why list handling misbehaved specifically
  after deleting a row.

- **An empty bullet is visible after a page load.** The normaliser that gives
  an empty item a caret home was wired into the three paths that assign content
  after mount, and not into mount itself — so content LiveView rendered into
  the page arrived as bare `<li></li>`: no height, nothing to click. After a
  reload a deliberately blank row was invisible, and a click aimed at it landed
  in a neighbouring item.

- **Enter on an empty bullet mid-list keeps it and opens another.** Hybrid mode
  has its own Enter handler for source-mode blocks, which exited the list
  whenever the item held nothing but its `- ` marker — wherever that item sat.
  It now exits only as the last item of a list, matching the blur rule below.
  The keyboard escape from a list is unchanged where it is the only sensible
  reading.

- **An empty bullet in the middle of a list survives the caret leaving it.**
  In hybrid mode, leaving a block re-renders it from its markdown source, and
  that path deleted an empty list item outright — reasonable for a trailing
  bullet somebody abandoned, wrong for a blank row deliberately left inside a
  list, which vanished the moment the caret moved away. The tidy-up now applies
  only at the end of a list, matching the Enter rule.

- **A bullet made by pressing Enter at the end of a line is no longer invisible
  from birth.** The new item's emptiness was decided by `firstChild`, but
  `Range.extractContents()` hands back a fragment holding an empty text node
  when the caret sat at the end of a line — so the item had a child, reported
  itself occupied, skipped the placeholder that gives it height, and rendered
  as nothing. Emptiness is now measured by text. This was the actual cause of
  bullets appearing to clear themselves; the sync-path fix below was real but
  addressed a later symptom of the same missing placeholder.

- **A deliberately empty list item no longer disappears.** An `<li>` with no
  content renders at zero height and gives the caret nowhere to sit, and the
  marker hides with it — so leaving a bullet blank and clicking away looked
  like the bullet had been deleted. It never was: the Enter-split path drops a
  zero-width placeholder into a new item for exactly this reason, but that
  placeholder is a client-side artifact stripped on the way out to markdown, so
  an item coming back from the server arrived bare. Every path that assigns
  content wholesale now normalises empty items the same way.

### Added

- **Live editing.** Several people in one document at once, opt in with
  `collaboration={%{operations: true, awareness: true}}`. The editor sends what
  changed rather than only what the document now is, applies somebody else's
  edit without moving the local caret, and shows everyone else's caret and
  selection in their own colour under the name the host gives them.

  `Leaf.Collab.join/2` is the whole integration on the host side — it attaches
  a `handle_info` hook, so a page writes no message handling of its own:

  ```elixir
  def mount(%{"id" => id}, _session, socket) do
    {:ok,
     Leaf.Collab.join(socket,
       room: MyApp.Notes.room_name(id),
       editor_id: "note-editor",
       identity: %{name: socket.assigns.current_user.name}
     )}
  end
  ```

  Edits that cross on the wire are rebased against each other, so two people
  typing in different places both land where they meant to and the order they
  arrive in does not change what anybody ends up reading. An edit that cannot
  be placed is refused rather than applied at an offset that means something
  else, and that session is put back in step from its own next snapshot —
  losing nothing it had typed.

  A session whose socket drops keeps working and is reconciled when it comes
  back: whichever side is behind adopts the other, decided by version rather
  than guessed, and the editor is replaced without dropping the writer's caret.

  Not a CRDT. Two people changing the same characters at the same instant
  converge on the same text, but somebody's keystroke loses.

- **Persistence, as a callback.** `Leaf.Collab.Store` says where a document
  lives, with `save/2` on every change for a store cheap enough to write to
  constantly and `flush/2` when the writing pauses, at a bounded interval while
  it continues, and once more on the way down.

  `Leaf.Collab.Store.File` ships for a vault of markdown files: with nobody
  editing, the `.md` file is the document. It records the hash it read and
  checks it before writing, so a note edited outside the session is answered
  with a conflict rather than overwritten. `Leaf.Collab.Store.None` is the
  default and keeps nothing.

  A store that raises does not take the room with it. It is somebody else's
  code talking to somebody else's disk, and the document is still in memory
  with people editing it.

- **`Leaf.Collab.Log`**, opt in with `debug: true`. A running account of what
  every session believes it is holding. Two sessions can hold the same document
  and still disagree about how many characters are in it — which is what
  misplaces a caret, and is invisible from either side alone. It notices that,
  asks both for the text they are counting, and names the character they stop
  agreeing on. Off by default: it fingerprints the whole document on every
  keystroke.

- **Obsidian-style `[[wiki-links]]`.** Opt in with `wiki_links={%{resolve: true}}`
  and `[[Target]]`, `[[Target|Alias]]` and `[[Target#Heading]]` render as link
  tokens in the visual and hybrid surfaces while the markdown stays exactly as
  written. Only the host knows which notes exist, so Leaf asks:

      {:leaf_resolve_links, %{editor_id: id, targets: [...], seq: n}}
      send_update(Leaf, id: id, action: :link_targets, seq: n, targets: %{...})

  Unresolved targets are styled distinctly, as Obsidian mutes them. Clicking one
  emits `{:leaf_link_clicked, %{editor_id, target, heading, href}}` — Leaf never
  navigates, because where a target lives is the host's question, and it may
  want a modal or a "create this note" flow. Ctrl/Cmd-click follows while
  editing, a plain click follows in a read-only surface.

  Decorating and resolving are independent: `%{resolve: false}` renders the
  links without ever asking the host to answer, for a viewer or a host whose
  targets are known to exist. `%{follow: :click}` lets a bare click follow while
  editing, where the default keeps it for the caret. Leaf ships no wording for
  an unresolved target — a label like "Not found" is the host's to write, in
  the host's language, and rides along in the resolution reply.

  Off unless configured, so a document using `[[…]]` for something else is
  untouched.

- **Typing a bracket or quote with text selected wraps it.** Select `hello`,
  press `(`, get `(hello)` with `hello` still selected so the wrap can be
  stacked. Handles `(`, `[`, `{`, `"`, `'`, `` ` ``, `*` and `_`, in every
  mode — the last two wrap into markdown emphasis, and stacking `*` twice
  gives bold. Only
  openers: a closing character stays typeable, and pressing one after a wrap
  does not nest again. The characters are inserted AROUND the selection rather
  than replacing its text, so formatting inside it survives.

### Changed

- **`phoenix_pubsub` is now a declared dependency.** Leaf calls it directly for
  live editing. It arrived transitively through `phoenix_live_view` before,
  which was luck rather than a contract.

- **Toolbar actions no longer die on an empty row.** Measuring the caret
  recursed without end whenever it sat ON an element rather than in text —
  which is every empty block. The resulting "too much recursion" was thrown by
  the history capture that runs before each toolbar action, so the action never
  ran: on an empty row the buttons did nothing, while the same click on a row
  with text worked, because a caret in text returned before reaching that
  branch.

- **The Task List toolbar button works without selecting text first.** It
  appeared to do nothing on an empty line. The item WAS created — and then
  removed the instant the caret left it, by the tidy-up that clears an
  abandoned trailing bullet. An empty checkbox is not residue: it renders as a
  tickable box and is exactly what the button is for, so task items are now
  exempt from that tidy-up. Converting an empty line also leaves the item with
  somewhere to type, which it previously did not. Enter continues the checklist
  and, on an empty item, finishes it — as with bullets.

- **A completed task item is struck through, not just faded.** Checking one off
  now reads as done at a glance, the way Obsidian renders it. The line is drawn
  by the item and skips the checkbox — CSS does not propagate text-decoration
  into atomic inline-level boxes, and the box is `display: inline-block` — so
  the tick stays readable. It is suppressed while the item is being edited in
  hybrid mode, where the block under the cursor shows its markdown source and a
  line through `- [x] label` would only get in the way.

### Added

- DOM-level tests for the editor (`test/js/*_dom.test.cjs`), driving keydown
  handlers, `Range` splitting and `htmlToMarkdown` against jsdom. jsdom is a
  test-only dependency and is not required to use leaf; without it those tests
  skip rather than fail. Added because several list and undo defects in this
  release shipped past a green stubbed suite — a stub cannot tell "has a child
  node" from "has text", and cannot run a keydown handler at all.

- Leaf owns an undo stack: snapshots of the active surface plus caret, one
  array with a cursor so redo falls out of the same structure. Typing is
  coalesced on a 350 ms debounce so one undo removes a word rather than a
  character, and structural actions snapshot immediately so they are always a
  single step. `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z` and `Ctrl+Y` are handled on
  every surface. The stack is capped at 200 entries; `set_content` starts a
  new one, since a replaced document's history is not reachable from it.

## 0.5.1

> `0.5.0` was tagged but never published to Hex — its atomic-block preview
> still read as an attribute dump. Everything below is the whole release.

### Upgrading

- **Re-copy the JS bundle.** Almost everything below is a server↔client
  contract. A vendored copy of `priv/static/assets/leaf.js`, or a CDN pin,
  must move with the dependency — pin `@v0.5.1`. A bundle left behind is
  otherwise silent: the editor renders identically and just stops
  implementing things the server now expects (no `{:leaf_flushed, …}`
  reply, no dirty re-baseline, atomic blocks with no styling for their
  preview). Leaf now warns in the console when the two disagree.
- **The fallback for a denied mode changed** from a hardcoded `:visual` to
  the first allowed mode. Only reachable when `mode:` names a mode the same
  editor denies.
- **Hosts that persist the `html` from `{:leaf_changed, …}`** (rather than
  the markdown) and configure a `#` suggestion trigger will see
  `<span class="leaf-hashtag">` in that HTML. The markdown is unchanged.

### Added

- **Atomic preserved blocks read as a preview, not as source.** A
  `preserve_tags` block used to render as an opaque `⧉ Hero` chip, hiding
  the text, links and images the tag wrapped — most of the reason to look
  at a document at all.

  Known attribute names are now mapped to typographic roles and typeset in
  the editor's own prose voice: `title`/`heading`/`headline` as a title,
  `subtitle`/`tagline`/`description`/`alt` as supporting text,
  `kicker`/`eyebrow`/`category` as an eyebrow, `image`/`cover`/`poster`
  (or an image-shaped `src`) as a banner, and `label` + `href` as a
  call-to-action with its destination. Children render as formatted text,
  so bold and links inside `<Header>…</Header>` are visible. Attributes
  with no role fall through to a small, faint source line — for those
  there is nothing better to say. A tag with nothing to show collapses to
  its nameplate instead of opening an empty box.

  The scale stays close to prose deliberately: this is a placeholder that
  reads like a document, not an imitation of the published component. Leaf
  has never seen the host's `<Hero>`, and a document with four of them
  still has to be readable.

  The serialized form is unchanged — `data-leaf-raw` still carries the
  verbatim source, so the round trip stays byte-for-byte identical.
- **Editing an atomic block in place.** Double-click one to open a raw
  source editor for just that block; ⌘/Ctrl+Enter or Save commits, Escape
  cancels. Previously the only way to change a `<Showcase>` was to switch
  to markdown mode and hunt for it by hand, which made the visual surfaces
  useless for component-heavy documents.
- **A flush you can await.** `send_update(Leaf, action: :flush, ref: "…")`
  now answers with `{:leaf_flushed, %{editor_id, ref, markdown, html}}`
  after the matching `{:leaf_changed, …}`. A bare flush produced a reply
  indistinguishable from the debounce firing, so save-before-navigate
  (version switch, language switch, translation enqueue) had nothing to
  wait for. Omitting `ref` sends no `{:leaf_flushed, …}` at all, so
  existing hosts are untouched.
- **`deny: [:visual_mode]` / `[:hybrid_mode]`.** The other two modes were
  already deniable; these complete the set. A denied mode loses its tab in
  every switcher and its `:set_mode` command is ignored, so the deny list
  is one rule rather than a default a stray click can talk its way past.
  Denying every mode raises; when only one survives, the switcher is hidden
  rather than rendered as a single dead tab.
- **A warning for undeclared custom tags.** Content holding `<Foo …>` tags
  that are not in `preserve_tags` now logs a one-off `Logger.warning`
  naming them and showing the declaration to paste. The visual surfaces
  flatten undeclared tags into loose paragraphs and autosave writes that
  back over the original — this turns silent, irreversible content loss
  into a one-line diagnosis. Silence with
  `config :leaf, warn_unpreserved_tags: false`.
- **Bundle-presence and staleness checks.** Leaf does not bundle its JS
  into the host, and an editor whose hook never attached is
  indistinguishable from a working one at a glance. A small inline script
  now logs a console error naming the likely causes when the hook has not
  attached shortly after paint, and `window.LeafHooks.version` (plus
  `Leaf.js_version/0`) catches a vendored or CDN-pinned copy that stayed
  behind. `bundle_check={false}` turns the inline script off for hosts
  under a CSP that allows neither it nor a `script_nonce`.
- **`priv/gettext/leaf.pot`.** `gettext_backend` worked, but Leaf shipped
  no catalogs and a host's `mix gettext.extract` cannot see msgids living
  in a dependency's source — so it was wired but untranslatable in
  practice. Copy the template in and `mix gettext.merge`. Lookups try the
  `"leaf"` domain first and fall back to `"default"`.
  `mix leaf.gettext.extract` regenerates it; a test fails when it drifts.
- **Hashtag styling.** Configuring a `#` suggestion trigger also tells Leaf
  that `#` means "tag" here, so hashtags render as tinted, slightly-italic
  tokens in the visual and hybrid surfaces instead of reading as ordinary
  prose. Purely a decoration — the markdown stays `#tag`. An editor with no
  `#` trigger is left alone, so a document using `#` for issue numbers is
  unaffected.
- **`boundary: :not_line_start`.** For `#`, where the first column is
  already spoken for: `# ` opens a heading and `#tag` mid-line opens the
  popup, with no keystroke where both are live.
- **`toolbar_extra` buttons can refuse to collapse.** `collapse: false`
  pins a button to the main toolbar row instead of letting it fold into the
  "More" menu, which at a two-column editor layout was essentially always.
  `toolbar_extra` is the only way a host adds a primary action, and buried
  under "More" those are barely more discoverable than typing the tag by
  hand — the problem they existed to solve.

### Changed

- **`set_content` re-baselines the dirty snapshot.** Replacing content
  programmatically — loading a different version, a collaborative sync, a
  reload — is not a user edit, but the old baseline stayed put, so with
  `protect_navigation` the writer got a prompt accusing them of unsaved
  work they never did. Pass `mark_saved: false` for the rare case where the
  new content really is a draft.
- **A denied mode falls back to the first allowed one** (`:hybrid`,
  `:visual`, `:markdown`, `:html` order) rather than to a hardcoded
  `:visual`, which stopped being a safe default once `:visual` itself
  became deniable.
- **`toolbar_extra`'s `:icon` is documented as trusted markup.** It goes
  through `raw/1` so an inline `<svg>` works, which means a host passing
  anything user-influenced there has an XSS. Use `:glyph` for a built-in
  icon when custom artwork isn't needed.

### Fixed

- **The compact and mobile mode switchers ignored the deny list.** Both
  rendered the markdown and HTML tabs unconditionally, so denying a mode
  only hid its inline tab — a narrow viewport was a way straight back into
  it.
- **Escape from the image alt-text popover left the caret nowhere.**
  Opening the popover moves focus into its alt-text input; dismissing it
  did not hand focus back, so keystrokes went to a detached input and
  typing appeared to do nothing. Focus now returns to the editing surface
  with the caret placed after the image.
- **A chip thumbnail that fails to load removes itself** rather than
  leaving a broken-image icon in the document — the thumbnail is a guess
  from an attribute name, and a wrong guess should cost nothing.

## 0.4.1

### Fixed

- **Toolbar icons that didn't match their tool.** Task List used a
  chevron-left-plus-bar (an outdent glyph, nothing to do with checkboxes);
  Remove Formatting used a bare ✕, which reads as "close this" rather than
  "strip the styling"; Code Block was near-identical to Inline Code at
  14px; Details/Accordion used the same bare chevron as the image-options
  dropdown; and Blockquote used a `bars-3` that was hard to tell apart from
  the list and indent glyphs. Spoiler moved from a filled bar to an
  eye-slash.
- **Dropdown menus could run off the screen.** They were plain
  `position: absolute` with no clamping, so near a right edge or the bottom
  of the viewport they overflowed — worst on a phone, where the tools menu
  is twenty rows tall. Menus now clamp horizontally and flip above their
  trigger when there's more room there, capping their height so they scroll
  instead of overflowing.
- **The mobile menus had no way to dismiss them.** Both are `<details>`
  elements and stayed open until the summary was tapped again, leaving a
  full-height menu covering the text. They now close on Escape, on an
  outside tap, and when an item is picked.

### Changed

- **Every dropdown row has an icon, and they line up.** The desktop "More
  formatting" menu had icons on 7 of its 25 rows and the mobile tools menu
  had none at all, so labels started at a different x on almost every row —
  most visible exactly when the responsive toolbar pushed the most items
  into the menu. All rows now render through a shared icon component with a
  fixed-width slot, so a row whose glyph is text (X², Ω, H) or absent still
  aligns with the rest.
- **Menus size to their content** (`min-w-max`) instead of the fixed
  `w-28`/`w-36`/`w-40`/`w-44` that truncated longer labels such as
  "Details / Accordion" once they gained a leading icon. Width is clamped
  to the viewport on small screens.
- **Larger touch targets on coarse pointers.** Menu rows and mobile toolbar
  buttons go to 44px under `@media (pointer: coarse)`; desktop keeps its
  compact rows.

## 0.4.0

### Added

- **Inline suggestions (`suggestions` assign).** The editor can offer a popup
  as the writer types a trigger character — `#` for tags, `@` for people, `/`
  for components, `:` for emoji. It knows nothing about any of those: it
  detects a configured trigger, asks the host what matches via
  `{:leaf_suggest, %{editor_id, trigger, query, seq}}`, and the host replies
  with `send_update(Leaf, action: :suggestions, ...)`. Works in all four
  modes. Per-trigger config covers `:boundary`, `:token`, `:first_char`,
  `:min_chars`, `:max_length`, `:debounce`, `:max_results`, `:allow_create`,
  `:keep_trigger`, `:insert_suffix`, `:label` and `:exclude`; several
  triggers can run in one editor, nearest-to-caret wins. Omitting
  `suggestions` adds no markup, no listeners and no event registration.

  Stale replies are dropped by matching trigger + query + seq, because
  keystrokes routinely outrun a round trip. Typing is never blocked: a host
  that never answers gets a short spinner and then the popup closes on its
  own. The popup is portaled to `<body>`, anchored to the caret (hidden
  mirror for the textareas, `Range` rects for the contenteditable), flips
  above when there is no room below, and never fires mid-IME-composition.
  ↑/↓ wrap, Enter and Tab accept, Escape dismisses; while it is open Enter
  neither inserts a newline, nor continues a list, nor submits the
  surrounding form. Full ARIA combobox wiring with a polite live region,
  applied only while the popup is open. By default it stays shut inside
  fenced and inline code, inside a markdown link destination, and after a
  non-space character.

### Fixed

- **Toolbar and programmatic edits no longer destroy the browser's undo
  stack.** The markdown-mode helpers assigned `textarea.value` directly,
  which clears undo history wholesale — after any toolbar action, Ctrl/Cmd+Z
  restored neither the action nor the typing that preceded it. They now go
  through `document.execCommand("insertText")` over a selected range, with
  the old assignment plus a synthetic `input` event as a fallback. Type,
  apply a toolbar action, undo: the action is reverted and the typed text
  survives.
- **`#hashtag` at the start of a line is no longer eaten by heading
  detection.** The hybrid engine treated the space after `#` as optional, so
  `#elixir` previewed as `<h1>elixir</h1>` while the server stored a plain
  paragraph — and leaving the block serialized it back out as a real
  `# elixir` heading, silently destroying the tag.

### Changed

- **Heading formatting now appears only once the space is typed.** `# Notes`
  is a heading; `#`, `##` and `#notes` are paragraphs. Previously a bare hash
  run retagged the block immediately, which made every hashtag flash into h1
  styling on its first keystroke and back out on the second. One consequence:
  a line holding nothing but hashes previews as a paragraph while MDEx still
  renders it as an (empty, invisible) heading.

## 0.3.2

### Fixed

- **Hybrid mode: Shift+Enter soft line breaks survive source mode.**
  Clicking into a formatted run inside a multi-line paragraph flattened it
  to one long line: the source view rendered "\n" as collapsing whitespace,
  Earmark's pretty-print text node after each `<br>` doubled the break into
  a paragraph-splitting "\n\n", and exiting source mode left "\n" as
  collapsed text instead of restoring `<br>`s — so the breaks were gone and
  new Shift+Enters showed up as "just a space". Source blocks now render
  `white-space: pre-wrap`, the block serializer skips the post-`<br>`
  pretty-print newline, and source→rendered conversion turns "\n" back into
  real `<br>` elements. Breaks survive enter/exit round-trips and new
  Shift+Enter breaks work inside source mode.
- **Hybrid mode: drag-selecting across formatted text no longer resets the
  selection.** When a selection endpoint entered a `**bold**` / `*italic*`
  / other formatted run, the source-mode markers popped in, and the DOM
  mutation collapsed the selection mid-drag. Source-mode transitions and
  delimiter decorations are now frozen while a non-collapsed selection is
  active — markers reveal only for a caret — so selecting, and applying
  toolbar formatting to the selection, work across any mix of formatted
  runs.

## 0.3.1

### Fixed

- **List indent no longer loses items.** Indent/outdent on a list item now
  builds valid `li > ol/ul` nesting instead of Chrome's `execCommand`
  structures (nested list as a sibling of the `li`, or a bare `<p>` inside
  the list) — and the markdown serializer handles all three shapes, emitting
  properly indented nested-list markdown instead of silently dropping the
  nested item.
- **Hybrid mode no longer emits corrupted markdown while the caret is inside
  a formatted run.** Serialization respects source-mode marker spans on
  bold/italic/strike/inline-code/spoiler wrappers (previously only links),
  so `{:leaf_changed}` payloads no longer double delimiters
  (`****bold****`, `~~~~str~~~~`, `||||spoil||||`) or duplicate list
  markers (`2. 2.`) when an autosave fires mid-edit.
- **Superscript/subscript survive saves.** `<sup>`/`<sub>` serialize to
  inline HTML in the markdown instead of being dropped.
- **Details/Accordion inserts correctly.** The `<summary>` is created inside
  the `<details>` element (previously Chrome's `insertHTML` stranded the
  "Summary" text in the adjacent paragraph and the block serialized without
  its summary).
- **Block inserts no longer split the current paragraph mid-word.** Task
  List converts the current line (matching the bullet/numbered list
  buttons); Callout and Details insert after the current block. Code Block
  with the caret in a list item converts the whole item — no more orphaned
  task checkbox.
- **Select-all + Delete fully clears the editor.** Previously empty shells
  of preserved custom-tag elements survived the deletion and polluted later
  edits.
- **`window.prompt` is gone.** Link insert/edit (visual, markdown, and
  selection toolbar), image URL edit, and code-block language now use a
  small inline dialog instead of the page-blocking native prompt. Link
  selections survive the dialog's focus steal, including across hybrid
  source-mode re-renders.
- **Emoji/symbol inserts can no longer replace unrelated content.** The
  saved selection is only captured when it lives inside the editor and is
  validated before restoring; a stale range falls back to a caret at the
  end instead of overwriting whatever the range used to cover.
- The floating code-block CODE/Copy bar rebuilds itself if a LiveView DOM
  patch wipes its buttons.
- **Drag-reorder indicator no longer suggests no-op drops.** Dragging a
  block slightly up or down used to move the drop line to the slot directly
  adjacent to the block — a drop there changes nothing. Adjacent slots now
  keep the line parked on the block's current position; it only jumps once
  the pointer crosses another block's midline (a drop that actually moves
  the block).

## 0.3.0

### Added

- **Deny-list controls — `:deny` attr (#1):** opt out of specific editor
  capabilities by passing a list of atoms: `:links`, `:images`, `:video`,
  `:markdown_mode`, `:html_mode`. Denied controls are hidden from every
  toolbar (advanced / simple / compact-overflow), the Ctrl/Cmd+K link
  shortcut is blocked, and a denied mode falls back to `:visual`. Denied
  content is stripped at both layers: server-side before every
  `{:leaf_changed}` payload and on the `:set_content` action (the security
  boundary), and client-side as paste-time DOM cleanup (UX). The regex/DOM
  sanitization is a UX-level guard, not a substitute for an allowlist HTML
  sanitizer at your persistence boundary. Thanks to @zoten.

### Changed

- **Markdown parser swapped from Earmark to MDEx (comrak).**
  `markdown_to_html/1,2` now renders via `MDEx.to_html/2`. Callout,
  task-list, custom-tag preservation, and link/image round-trips are
  unchanged. Consumers gain a precompiled Rust NIF dependency (`mdex` →
  `mdex_native` via `rustler_precompiled`); no application code changes are
  required.

### Fixed

- `<.live_component module={Leaf}>` invocations no longer crash with
  `KeyError: key :class not found` when the caller omits `class=` (see
  0.2.24 below for the full account). Rolled into this release.

## 0.2.24

### Fixed

- `<.live_component module={Leaf}>` invocations no longer crash with
  `KeyError: key :class not found` when the caller omits `class=`. 0.2.23
  added the `:class` attr and referenced `@class` in `render/1`, but the
  matching `mount/1` `assign_new` was missed, so the default only worked for
  function-component callers (`<.leaf_editor ... />`). Hosts that forward
  assigns through `live_component/1` (e.g. wrapping Leaf in their own
  LiveComponent) crashed on first render. Adding the seed in `mount/1`
  restores the documented default for both invocation forms.

## 0.2.23

A large feature release: GFM task lists & callouts, custom-tag round-trip
preservation, a host-integration/authoring API, RTL + symbol/date inserts,
and a full Obsidian-style hybrid live preview for list markers and
checkboxes. All additions are opt-in or default-preserving — stored
markdown and existing hosts are unchanged.

### Features

- **GFM task lists (#14):** `- [ ] ` / `- [x] ` render as clickable
  checkboxes (toolbar + markdown action, click-to-toggle) and round-trip
  via a server `apply_task_lists/1` transform + client `<li>` serializer.
  Loose checklists (a blank line between items makes CommonMark wrap each
  item's text in a `<p>`, breaking the checkbox match) are unwrapped back
  to `<li>[ ] x</li>` on both the server and the client, so they no longer
  round-trip as literal `[ ] ` text.
- **GFM callouts (#16):** `> [!NOTE|TIP|IMPORTANT|WARNING|CAUTION]`
  blockquotes render as colored admonition blocks with a derived,
  non-editable title and round-trip via `apply_callouts/1` + a
  `data-callout` serializer.
- **Custom / unknown tag preservation (#3):** a new `preserve_tags` attr
  (default `[]`). Listed tags (e.g. `<Hero/>`, `<CTA>`) are pulled out
  before Earmark, rendered in visual/hybrid as atomic, non-editable chips,
  and restored byte-for-byte; the client serializes the chip's
  `data-leaf-raw` straight back to source, so custom XML round-trips
  exactly. A single preserved tag inserted live via `:insert_markdown`
  becomes a chip on the spot.
- **Host-integration & authoring API (all backward-compatible):**
  `send_update` actions `:insert_markdown`, `:flush`, `:mark_saved`; new
  attrs `toolbar_extra` (+ `{:leaf_toolbar_action}`), `toolbar_layout`,
  `min_height`/`max_height` + `height="auto"`, `maxlength`,
  `smart_typography`, `export`, `protect_navigation`, `save_status`,
  per-instance `gettext_backend`, `class` (now actually applied),
  `emit_events`, `flush_on_blur`. `{:leaf_changed}` gains a `dirty`
  boolean. Lifecycle events (`{:leaf_focus}`, `{:leaf_blur}`,
  `{:leaf_selection_changed}`, `{:leaf_paste_image}`) are gated behind
  `emit_events` (default `false`) so existing hosts can't crash on an
  unhandled message. Authoring: autolink on URL paste, image
  caption/alignment, paste image→upload (with inline fallback) and
  paste-as-plain-text (Ctrl/Cmd+Shift+V), TSV/CSV→table, and code blocks
  with a language tag + copy button (` ```lang ` round-trip).
- **Symbols / date picker (#31)** in the insert menu (symbol grid + insert
  date/time), and **RTL support (#46)** via a new `dir` attr
  (`"ltr"|"rtl"|"auto"`, default `"ltr"`).
- **Spellcheck toggle (#55):** new `spellcheck` attr (default `true`).
- **Obsidian-style hybrid live preview for lists & checkboxes:** a list
  item's `- ` / `N. ` / `- [ ] ` marker now reveals as editable source
  *only while the cursor is on it* — exactly like the inline `**` / `*`
  markers — and shows the bullet / checkbox otherwise, instead of the
  whole row switching to source. The revealed marker is seated in the
  bullet/checkbox gutter so it lines up with sibling items; ArrowLeft from
  the body start steps into the hidden marker; deleting the marker breaks
  the item out to a paragraph immediately; and typing `- ` leaves a
  blinking caret at the end of the new item, ready to type.

### Fixes

- Hybrid: list editing no longer breaks after a markdown↔hybrid round-trip.
  The server's pretty-printed HTML left whitespace-only text nodes between
  block children — a cursor trap that resolved `_getCurrentBlock` to the
  `<ul>` so Enter/Backspace couldn't act on a list item — and loose lists
  wrapped each item in a `<p>` so Enter inserted a nested paragraph instead
  of a new item. Both are now stripped / unwrapped on init and on every
  markdown→hybrid sync.
- Hybrid: leading and repeated spaces typed inside list / checkbox content
  are preserved (pinned as NBSP) instead of collapsing when the line
  re-renders; single inter-word spaces stay regular so wrapping is
  unaffected.
- Task lists: don't destroy the checkbox when the cursor lands on a task
  item (excluded from source-mode swapping); toggle on mousedown so a tap
  reliably checks/unchecks; fix a stranded caret before the checkbox box.
- Custom-tag chips: blocks containing a `.leaf-atomic` chip are excluded
  from source-mode swapping, so clicking on or around a chip no longer
  collapses it to raw `<Hero/>` text that never came back.
- Serialization: list / paragraph spacing keeps a following paragraph from
  folding into the last list item (lists end with a blank line), and
  task-list Enter behavior matches the existing list UX (continue / split /
  exit). Auto-formatting `- ` re-focuses the editor and places the caret at
  the end of the new item.

## 0.2.22

- Hybrid: fix markdown links round-tripping into `[[label](url)](url)` (and compounding further on every edit). The `htmlToMarkdown` serializer's `<a>` case always synthesized `[...](url)` markers — even when the link was in hybrid source mode and already carried its `[` / `](url)` marker spans — doubling them. It now returns the inner text as-is when the `<a>` already has `leaf-source-marker` children (mirroring the inline serializer's existing guard), and only synthesizes markers for a bare `<a>` (rendered/visual mode, Earmark output, or `createLink`). Companion to the 0.2.20 builder-side fix that stopped `****bold****` compounding.

## 0.2.21

- Form integration: add a `sync_input_name` attribute. When set, the editor mirrors its current markdown into a hidden `<input>` (auto-created inside the surrounding `<form>`) on mount and on every visual/markdown/html change, so the editor's value submits as a normal form field without extra wiring. Also adds a `set_content` command (used for programmatic reset) that replaces the visual/markdown/html buffers, clears the drag-handle and source-block state, and re-syncs the hidden input.
- Editor gutter: the visual editor's left padding (the gutter the block drag handle sits in) and the wrapper's positioning context are now emitted inline by the server-rendered `<style>` instead of relying solely on the host app's Tailwind utilities (`p-4 pl-10`, `relative`). Fixes the drag handle ("grabber") overlapping the text when Leaf is embedded in a host whose Tailwind build does not scan the Leaf library files (e.g. inside another component library). No change where those utilities were already generated.

## 0.2.20

- Toolbar: keep overflow icon direction consistent across desktop and mobile layouts. Tool menus use horizontal dots, while mode/options menus use vertical dots.
- Hybrid: toolbar and keyboard formatting now refresh the active source block immediately, preventing duplicated markdown markers such as `****bold****` after applying formatting to a selection.
- Hybrid: Backspace/Delete now removes empty first list items and empty first blockquote lines, matching the existing Enter behavior for empty list/quote exits.

## 0.2.19

- Toolbar: mode switching and fullscreen now live in a right-side compact options menu on constrained layouts, with dropdown behavior that keeps only one toolbar menu open at a time.
- Toolbar: remove formatting and lower-priority tools now move into collapsible menus for cleaner narrow editor layouts.
- Mobile editing: add a dedicated mobile writing toolbar for very narrow editor containers. The mobile toolbar keeps core actions visible (`Bold`, `Italic`, `Link`, `Bullet List`) and moves formatting, insert tools, modes, and fullscreen into compact menus.

## 0.2.18

- Toolbar: narrow editor layouts now use the editor's own container width to compact the toolbar, not the viewport. The toolbar stays stationary and wraps instead of becoming a horizontally scrolling strip.
- Toolbar: mode switching collapses into a compact menu on narrow layouts, and fullscreen is hidden there for now to keep comment-editor toolbars focused.
- Toolbar: advanced list and insert tools progressively move into the inline More menu as space tightens, so the rightmost tools disappear one by one instead of whole sections abruptly wrapping into extra rows.
- Mobile editing: add touch-oriented editor tweaks, spellcheck/autocorrect attributes, and a visual-viewport caret scroll helper for soft keyboards. The selection toolbar implementation is included but remains disabled for now.
- Docs: include `LICENSE` in the generated ExDoc bundle so the README license link resolves.

## 0.2.17

- Hybrid: source-mode markers (`**`, `*`, `~~`, `||`, `` ` ``, `[…](url)`) now appear inside `<li>` body text. `<li>` joins `_isSourceModeBlock` and `_enterSourceMode` builds a `<li data-leaf-source="li">` (keeping the `<ul>` / `<ol>` parent intact); on exit `_buildFormattedFragment` rebuilds the inline body inside a fresh `<li>`. `_scanSource` is skipped for `<li>` source so block-level patterns (`# `, `> `, `1. `) don't mis-retag a list item.
- Hybrid: typing `> ` at the start of a paragraph auto-formats to a `<blockquote><p>…</p></blockquote>`, mirroring the `- ` / `1. ` list path. The marker is stripped from the body; each typed `> ` creates a fresh `<blockquote>` (no merging into the previous one).
- Hybrid: Enter inside a blockquote now mirrors list two-Enter UX. Non-empty line → split in place. Empty line in the middle of a quote → split the `<blockquote>` into two with a `<p>` between (mid-quote exit). Empty trailing line → exits to a `<p>` placed after the quote and drops the empty inner block. The same split-into-two-lists pattern applies to empty `<li>` Enter inside a multi-item list — first list + `<p>` + second list with the trailing items.
- Hybrid: Delete at the end of a source-mode `<li>` or `<p>` inside `<blockquote>` explicitly merges the next sibling of the same kind into the current block. Chrome's default forward-delete didn't always succeed for source-mode blocks (the marker spans and `data-leaf-source` attributes confused its merge path), so the keystroke could appear to do nothing.

## 0.2.16

- Rework hybrid mode around a per-block source/render toggle: the cursor's paragraph (or heading) is swapped for a `<p data-leaf-source="origTag">` carrying its markdown source as literal text; every other block stays rendered. Cursor-leave re-renders the source back to HTML via `_renderBlockFromSource`. Replaces the old whole-paragraph decoration-span approach, which suffered from Chrome caret-affinity issues and "markers stuck to plain text after switching modes" leaks.
- Source-mode inline matches (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `code` ``, `***bold-italic***`) build the real formatted element (`<strong>`, `<em>`, etc.) wrapping `<span class="leaf-source-marker">` opening + body + closing marker. Markers fade in only for the wrapper the cursor is inside (`.leaf-source-active`) and fade out as soon as the cursor leaves, with all ancestor wrappers in the chain decorated together. Arrow-key entry into an inactive nest snaps the caret to just outside the outermost wrapper so the user can step through each marker char with one keypress.
- Add full Obsidian-style link support: `[text](url)` round-trips between `<a href="">text</a>` and faded `[` / `](url)` markers as the cursor moves in and out of the link's range. Works for typed links AND links inserted via the toolbar's Cmd+K (`execCommand("createLink")`) — `_refreshSourceBlock` derives the canonical source via `_serializeBlockInline` rather than `textContent`, so a bare `<a>` is recognized and rebuilt with markers. Click on a link in hybrid mode moves the caret into it for editing (no navigation); the floating-island popover stays visual-mode only.
- Bold-italic (`***body***`) markers are now split across `<strong>` (`**`) and `<em>` (`*`), each with its own marker spans. Fixes a feedback loop where the serializer synthesized extra `*`s around the inner em and the source grew `***body***` → `****body****` → `*****body*****` on every refresh.
- `_serializeBlockInline` preserves NBSPs verbatim (`_scanSource` and `_renderBlockFromSource` normalize them on the boundary instead), so Chrome's trailing-space NBSP doesn't get rewritten into a regular space and visually collapse — fixes the "spaces disappear until you make a link" bug.
- HR navigation: ArrowUp / ArrowDown adjacent to an `<hr>` now reliably swap it for `<p data-leaf-hr-source>---</p>` so the user can edit or delete the rule with the keyboard, not just the mouse. Falls back to a single-line-block-height fast path so the line-position guard doesn't refuse on empty `<p><br></p>` filler blocks.
- Lists: `- foo` / `1. foo` auto-format on the space (single-item `<ul>` / `<ol>` with native bullets / numbers, not faded markers). Enter inside a non-empty `<li>` reliably splits into a fresh sibling `<li>` (Chrome's default fell through to `<p>` after certain editing operations); Enter inside an empty `<li>` exits the list to a `<p>` below. Backspace inside an empty `<li>` consistently merges back into the previous item (no more "delete the new item, Enter to continue, get a `<p>` instead" cycle).
- Backspace at the very start of the editor's only block is swallowed so Chrome can't delete the anchor `<p>` (drag handle disappears, typing lands in a bare text node).
- Heading prefix (`# `, `## `, …) is now emitted exactly once on every refresh (the marker `<span class="leaf-source-marker">` is the single source of truth, no synthesized `_blockSourcePrefix` prepend).
- Footer word / char counter works in hybrid mode — strips marker spans from the count so the number doesn't jitter when the cursor moves in and out of a wrapper.

## 0.2.15

- Add a fullscreen toggle button to the toolbar. Clicking it puts the editor host into real OS-level fullscreen via the browser Fullscreen API (`element.requestFullscreen()`) — browser chrome (tabs, address bar, taskbar) hides and the editor fills the entire screen, the same immersive feel as Fresco's nav button. Escape exits natively (handled by the Fullscreen API, no custom keydown listener). Includes Safari webkit-prefix fallbacks. The hook listens to `fullscreenchange` and reflects browser state into a `data-leaf-fullscreen='true'` attribute on the host; an inline CSS rule keyed on that attribute flexes the inner toolbar/body/footer so the editor body absorbs the screen height instead of staying at its configured `:height`. The button uses heroicons arrows-pointing-out / arrows-pointing-in to signal current state. Sits to the right of the mode switcher in the `:advanced` preset only — `:simple` (comments / lightweight editing) skips it. Works in readonly mode too — fullscreen is a view feature, not an edit feature, so it bypasses the readonly guard. Cleanup detaches the `fullscreenchange` listener from `destroyed()` so it doesn't leak across LiveComponent re-mounts.

## 0.2.14

- Remove the unused `Leaf.Icon` heroicons-wrapper component (`lib/leaf/icon.ex`) and its smoke test. The module was orphan code referenced nowhere in Leaf's own templates, no external consumer, and never exposed through Leaf's public API moduledoc — keeping it was just noise on the public surface. Anyone who wants a heroicons helper has Phoenix's own `<.icon>` pattern, which is two lines to inline.

## 0.2.13

- Add **hybrid mode** — Obsidian-style live preview that renders formatting inline (bold, italic, strike, code, spoiler, headings, horizontal rule, ordered/unordered lists) while keeping the source markers editable. Markers (`**`, `*`, `~~`, `||`, `` ` ``, `# `…`###### `) appear as faded characters when the cursor is inside their wrapper and fade out as soon as the cursor leaves; arrowing into a wrapper from either side reveals them again. Hybrid is now the first tab in the mode switcher.
- Hybrid auto-format as you type: `**word**`, `*word*`, `~~word~~`, `||word||`, `` `word` ``, and `***word***` wrap on the closing delimiter; `# ` through `###### ` retag the current paragraph as a heading and live-retag when the user adds or removes leading `#`s; `---` on its own line becomes a real `<hr>`; `- ` / `* ` / `+ ` becomes a `<ul>`; `1. ` (or any `\d+. `) becomes an `<ol>` (with `start="N"` when N ≠ 1). Consecutive list paragraphs merge into one wrapper rather than producing one wrapper per item.
- Hybrid horizontal rule is cursor-aware: clicking the rule, or arrowing onto it from above / below, swaps it for an editable `<p data-leaf-hr-source>---</p>` so the dashes can be adjusted or deleted; moving the cursor away renders the rule again. Arrow detection uses bounding-rect line measurement so it fires from the first/last visual line of any block (empty, multi-line, or with a trailing `<br>` filler), not just from an empty paragraph.
- Hybrid handles nested formatting (`***bold-italic***`, `~~**within strike**~~`, etc.) by decorating the full chain of ancestors at once, deferring auto-format inside an unclosed outer delimiter, then recursively wrapping the inner pattern when the outer closes. Editing or deleting a delimiter span unwraps the formatting back to plain text.
- Hybrid keeps typing past the closing delimiter working reliably across browsers — the keystroke is intercepted in `keydown` and inserted outside the wrapper as a sibling, even when Chrome's caret affinity would otherwise pull the cursor back inside. Typing inside an already-formatted paragraph now lands at the cursor position instead of being silently redirected to the end of the line.
- After hybrid auto-formats a wrapper, the caret rests *just past* the closing delimiter (`**bold**|`, not `**bold|**`) while still anchored inside the wrapper so the markers stay visible without needing a click. Decoration markers also appear immediately for second / third / nested wrappers in the same paragraph (previously a click-in was needed).
- Stop the hybrid-only `**` / `*` / `~~` / `# ` decoration markers from leaking into pure visual mode: the deferred toolbar refresh and the heading-decoration listener are now mode-gated, and switching from hybrid to visual strips every existing decoration span (and the cursor-anchoring zero-width spaces heading decoration leaves behind) from the contenteditable.
- Replace the visual-mode HR toolbar handler. `document.execCommand("insertHorizontalRule")` produced inconsistent DOM that didn't round-trip through `htmlToMarkdown`, so the rule vanished on the next re-render and never appeared in markdown mode. The handler now builds the `<hr>` and trailing `<p>` manually (same shape hybrid mode uses), and the duplicate `<hr>` CSS that the JS hook was injecting on top of the inline `<style>` rule is gone — the line renders once, vertically centered inside an 18px hover-friendly hit area.
- Center the drag handle against blocks shorter than the handle itself (in particular hybrid-mode `<hr>`), so the grab icon aligns with the rule's line instead of hovering above the block.
- Fix `markdown → hybrid` and `html → hybrid` mode switches losing the latest edits. `_syncModes` only matched `to === "visual"` when copying content out of the markdown / html textareas, so switching to hybrid left the visual contenteditable showing its previous DOM. Both branches now fire for hybrid too (hybrid reuses the same contenteditable as visual).
- Wire hybrid mode into the footer word / char counter. Counts read 0 / 0 in hybrid before because `_updateCounts` had no branch for it; the new branch reads the contenteditable's text after stripping decoration spans and ZWSPs, so the numbers track user-perceived content and don't jitter when the cursor moves in / out of formatted runs.

## 0.2.12

- Add a vertical resize grip to the visual editor's bottom-right corner so users can drag to grow or shrink the editing area, mirroring the native grip the markdown and html textareas already had. Per-mode resize state (each mode keeps its own height — resizing in visual doesn't change markdown's height and vice versa).
- Double-click the resize grip to auto-fit the editor height to its content. Works for visual, markdown, and html modes. The auto-fit clamps to the configured `:height` as a floor — shorter content still respects the minimum.
- Show a small tooltip ("Drag to resize · Double-click to fit content") when the mouse hovers the resize-grip area, so the double-click gesture is discoverable.
- Fix the bold toolbar button lighting up for plain heading text. `document.queryCommandState("bold")` returns true for any text whose computed `font-weight` is bold, and the editor's own CSS sets `font-weight: 700` on `h1`–`h4`, so a heading without an explicit `<b>`/`<strong>` was misreported as bold. The button now probes for an actual `<b>`/`<strong>` ancestor; explicit bold inside a heading still lights up correctly.
- Add inline spoilers (Discord-style `||hidden text||` markdown) with a Spoiler entry in the More-formatting dropdown. Renders as a censored block (dark background, hidden text) anywhere on the page; click anywhere on the page to reveal. Inside the editor itself the spoiler text is always shown (with a subtle background hint) so writers can see what they're typing.
- Quality-of-life cursor escapes for any inline formatting (`<b>`, `<strong>`, `<i>`, `<em>`, `<s>`, `<del>`, `<code>`, `<u>`, `<sub>`, `<sup>`, `<mark>`, `<a>`, and the spoiler span). Pressing Enter inside any of them breaks out into a fresh paragraph instead of carrying the formatting into the new `<p>`. ArrowLeft at the start or ArrowRight at the end exits the wrapper on a single press; if there's no content on the target side, a non-breaking space is inserted so the cursor has a typeable home.
- Preserve the contenteditable's selection when the user miss-clicks anywhere on the editor's chrome (toolbar gaps, dividers, mode tabs, footer, border, background). `mousedown` is intercepted on the editor wrapper and `preventDefault` keeps focus in the contenteditable; clicks still register so buttons and dropdown triggers behave normally. Clicks on form controls and inside the contenteditable itself are unaffected.
- Make the main image toolbar button fall back to the URL dialog when the consumer hasn't configured an `upload_handler`. The wrapper's `data-has-upload` attribute now correctly reflects whether `upload_handler` is set (it previously misreported truthy whenever `:image` was in the toolbar list). Result: with `:image` in the toolbar but no upload handler, clicking the main image button opens the URL dialog directly instead of silently no-opping.
- Stop the LiveView from crashing on `media_ui_opened` / `media_ui_closed` events that the image-URL dialog (and other media popovers) push. Added no-op handlers; the events are kept on the wire so future server-side reactions to media UI being active can hook in without breaking existing consumers.
- Align toolbar icons on a consistent vertical centerline. SVG icons no longer drift from inline-baseline (`svg { display: block }`), text-glyph buttons (B/I/S/H) get a tighter `line-height: 1`, and the dropdown wrappers for heading, more-formatting, table, and more-inserts now use `inline-flex` so their buttons sit at the same height as direct flex-child buttons instead of being pushed up by the wrapper's line-height. Rules ship in the inline `<style>` block emitted server-side so the alignment is correct on first paint, not just after `mounted()` runs.
- Force Shift+Enter to always insert a soft break (`<br>`) inside the current block. Some browsers — notably Chromium contenteditables that have `defaultParagraphSeparator` set to `"p"` — otherwise treat Shift+Enter the same as plain Enter and start a new `<p>`. The editor now intercepts the key and inserts a `<br>` explicitly, so a single Shift+Enter always continues the current paragraph on a new line and any following `<p>` stays separate.
- Preserve `<br>` soft breaks across visual↔markdown↔html round-trips. Earmark's `breaks: true` HTML output puts a literal `\n` after every `<br>` as pretty-print whitespace, which `htmlToMarkdown` was reading and combining with the `<br>`'s own `\n` into `\n\n` — a markdown paragraph break — causing a single paragraph with internal soft breaks to split into multiple paragraphs after a round-trip. The walker now strips leading newlines from text nodes that follow a `<br>`. Same root cause for the cursor-visibility filler `<br>` that the Shift+Enter handler appends at end of block — it's now marked `data-leaf-filler` and skipped by the markdown walker so a Shift+Enter at end of paragraph doesn't get serialized as a paragraph break.

## 0.2.11

- Treat single newlines in markdown as line breaks when rendering to HTML for visual mode (`breaks: true` passed to Earmark). Content like emoji-prefixed lists or any line-by-line text without blank lines now renders line-by-line in visual mode, matching how the markdown source visually appears in markdown mode and how editors like GitHub, Slack, and Notion handle the same input. Round-trips through visual→markdown still preserve the original `\n`-separated source.

## 0.2.10

- Fix the editor expanding horizontally on mount and stealing space from sibling flex items. The outer wrapper and the toolbar now have `min-width: 0` so the editor's intrinsic min-content width can no longer push past its parent's allocated width. Pages with a `flex-[2] / flex-1` split (or similar) no longer redistribute when the editor finishes mounting.

## 0.2.9

- Loading placeholder now picks a random label per page load by default (`loading_preset` defaults to `:random`, drawing from the bundled set of `:unpuzzling`, `:brewing`, `:polishing`, `:composing`, `:crafting`, `:tidying`). Use `loading_preset={:default}` for the plain "Loading…" label, or `loading_text="…"` to fully customize.
- Fix layout jump on loading→ready: the toolbar, mode tabs, border wrapper, and footer now render as a real skeleton during loading, so the page no longer shifts when the editor finishes mounting.
- Pin `data-leaf-mount-state` to `"ready"` in the JS hook's `updated()` callback so a parent re-render can't briefly flicker the editor back through the loading state.

## 0.2.8

- Add a styled loading placeholder shown until the editor JS mounts, replacing the brief flash of unstyled content on cold page loads. Configurable via two new `leaf_editor/1` attrs:
  - `loading_preset` (`:default | :unpuzzling | :brewing | :polishing | :composing | :crafting | :tidying`) — pick a bundled label
  - `loading_text` — fully custom string, wins over the preset

## 0.2.7

- Fix excessive blank lines in markdown output when pressing Enter in visual mode
- Fix mode toggle reverting to visual and in-progress keystrokes being lost while typing in markdown or html mode
- Fix table column widths shifting while typing into cells

## 0.2.6

- Add edit image URL button (pencil icon) to image floating island
- Add simple/advanced toolbar presets for lightweight vs full editing
- Prevent bold toggling inside headings to keep visual-markdown sync
- Fix drag handle jumping to wrong block on large/resized images
- Fix image popover persistence through LiveView re-renders

## 0.2.5

- Add image insert by URL with split button toolbar (upload + by URL options)
- Add inline URL dialog with alt text support for both visual and markdown modes
- Bump sticky toolbar z-index for better stacking with fixed navbars

## 0.2.4

- Remove blue focus outline from contenteditable editor area

## 0.2.3

- Fix footer word/char counts resetting to zero on component re-render

## 0.2.2

- Add `leaf_editor/1` function component wrapper for cleaner `<.leaf_editor />` syntax

## 0.2.1

- Add editor footer with live word and character count

## 0.2.0

- Add drag-and-drop block reordering for images and any block element
- Add drag handles for easier block manipulation with margin hover activation
- Add image resize handles with persistent dimensions through save
- Add table support with insert, add/remove row and column operations
- Add More Inserts dropdown to toolbar for organized insert options
- Add superscript and subscript toolbar buttons
- Add indent/outdent toolbar buttons
- Add emoji picker toolbar button (keeps open for multiple inserts)
- Update toolbar icons to Heroicons
- Fix italic/bold/strikethrough lost on save due to whitespace in markers
- Add sticky toolbar navbar offset detection and morphdom resilience

## 0.1.0

- Initial release
- Dual-mode editor: visual (WYSIWYG) and markdown
- Toolbar with formatting, headings, lists, links, code blocks
- Content syncs between modes via Earmark
- Optional gettext support for i18n
- No npm dependencies — vendored JS bundle
