Current section

Files

Jump to
leaf CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
## 0.6.1
The checkbox-and-lists review release: everything here came out of one
maintainer's pass over 0.6.0, reported one issue at a time and fixed the
same way.
### Fixed
- **The list buttons start a list from nothing.** The bullet and numbered
toolbar buttons did nothing without a selection to convert; like the task
button before them, they now create one empty item ready to type into.
- **Typing over a selection in a checklist row works.** A whole family of
silent failures with one root: when a selection contained the row's
non-editable checkbox — a double-clicked lone word, a selected row, some
drag shapes — the browser's editing engine abandoned the edit without a
word. Leaf now takes the edit over at `beforeinput`: the selection is
shrunk past the chrome (or the whole row replaced, see below) and the text
placed by hand. Along the way: the caret-home placeholder is no longer
counted as content by Enter and Backspace, and the source machinery no
longer mutates the DOM mid-command from `selectionchange`, which was the
abort's other trigger.
- **A rendered `[[wiki-link]]` can be followed in hybrid mode.** The press
seated the caret, the source swap that followed detached the link's span,
and the click handler then held a dead node — so no gesture followed, in
either follow mode, and it read as broken. The mousedown now captures the
link before the caret moves, and the click acts on the capture when the
swap has taken the span.
- **Freshly typed wiki links resolve.** Mount and server pushes already
asked the host which targets exist; a link typed mid-edit — the one the
person is looking at — was exactly the one both missed.
- **`Leaf.Collab.Room`'s moduledoc caught up with its code.** It still
described mismatches as "recorded rather than resolved", text from its
testbed era that `rebase_over/2` and the refusal path in the same file
contradict.
### Added
- **Selecting a row selects its markdown.** Sweeping a selection across
list rows reveals each row's source marker under it — `- [ ]`, `- `,
`3. ` — the way Obsidian shows what a selection really holds, and copy
and cut carry those markers so a row pasted elsewhere is still a task,
a bullet, a numbered item. Pasted GFM checkboxes (`li > input`) are
adopted on the way in.
- **Selecting the whole row means the whole row.** With the row's revealed
marker inside the selection — the triple-click shape — typing replaces
the entire row, checkbox or bullet or number gone with it, leaving the
typed text as a plain line. Anything less keeps the row and replaces
only what was selected. One rule for all three list kinds.
- **Numbered lists divide and rejoin.** Enter on an empty item leaves the
list wherever it happens; mid-list, the items below become a fresh list
that restarts at 1, so a divided 1–6 reads 1–3, text, 1–3. Delete the
divider and the halves merge back into one list — adjacent lists of the
same kind are one list that something used to separate, which is how
markdown already reads them.
- **Checklist rows have a face, a way in, and a way out.** A new task row
shows the clickable checkbox rather than raw `- [ ]`; clicking beside
the box (or arrowing in) opens the source for editing; leaving closes it.
- **A click on a wiki link is an action; the caret edits it.**
`follow: :click` is now the default — Obsidian's live-preview gesture. A
bare click on a rendered link reports `{:leaf_link_clicked, …}` to the
host and never places the caret; arrow keys, shift+click and double-click
still open the raw `[[target]]` for editing. The payload now carries
`modifier: boolean` so a host can mean something extra by Ctrl-click.
The old behaviour remains as `follow: :modifier`.
- **A refused flush is no longer silent.** The store contract already
stopped a conflicting flush from overwriting work edited outside the
session — vim, git, another process — but nobody was told: the flag went
on the room's state, nothing read it, and the room eventually went down
discarding the only copy of what was typed. Now the room broadcasts
`{:leaf_conflict, %{document_id:}}` once per conflict (and
`{:leaf_conflict_cleared, …}` when a later flush lands), the
`Leaf.Collab` hook surfaces it as `@leaf_collab.conflict` for a banner,
`Room.flush_now/1` returns `{:error, :conflict}` / `{:error, :store}`
instead of a hardcoded `:ok` — making save-before-navigate honest — and
a room going down holding refused or unwritten work logs a warning
naming the document.
- **Rooms can stop.** A room used to run for the life of the node, so
browsing a large vault left a process per visited note. `Room.stop/1`
is the graceful way down by hand (it flushes first), and a host that
wants rooms to tidy themselves away passes `idle_after:` — the room
stops itself, writing first, once it has stood empty that long, with a
return during the linger cancelling the stop. Staying up remains the
default (it keeps the operation log warm for a reconnect), and
`start_link/1`'s docs now say which behaviour you have — they used to
describe the host's duty in words that read as the room's behaviour.
- **`Leaf.Collab.leave/1`** undoes what `join/2` did, for a LiveView that
switches documents without remounting: the session leaves the room (its
caret disappears for everyone now, not at tab close), the topic is
unsubscribed, the hook detached, `@leaf_collab` cleared. Idempotent, so
`socket |> leave() |> join(room: next)` needs no first-mount special
case. Before this, the next `join/2` raised on the hook it could not
attach twice and the person lingered as a ghost caret.
- **`debug_typing` attribute.** Mirrors the typing-diagnostics trace into
a panel on the page, so a reporter can reproduce and paste without
opening devtools. The panel is click-through except for its copy button
— it floats over the page, and a click meant for a row underneath must
reach that row.
## 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