Skip to main content

CHANGELOG.md

# Changelog

## 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