# Changelog
All notable changes to **Etcher** are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).
## [0.4.9] — 2026-05-21
Lets consumers hide Etcher's built-in chrome and wire their own UI
to the same actions. Pure-additive — defaults preserve every
existing consumer's behavior. Pairs naturally with Fresco 0.5.7's
matching `:nav_buttons` empty-list semantics on the viewer side,
though no Fresco upgrade is required.
### Added
- **`:nav_buttons` attr** on `<Etcher.layer>`. Atom list controlling
which buttons get appended to Fresco's nav column:
`[:pencil, :visibility]`.
- `nil` (default) — both enabled.
- `[]` — both hidden. Consumers wire their own UI to
`handle.toggleMode()` / `handle.toggleVisible()` (or
`setMode(true|false)` / `setVisible(true|false)`).
- A subset list — only those buttons render.
Mirrors as `data-nav-buttons` on the layer host using the
`"none"` sentinel for the empty case, matching Fresco's
convention.
- **`:toolbar` attr** on `<Etcher.layer>` (boolean, default `true`).
`false` skips building the bottom toolbar entirely. Annotation
mode still works programmatically — consumers wire their own
toolbar UI to `handle.selectTool(...)` / `handle.selectColor(...)` /
`handle.undo()` / `handle.redo()` / `handle.setMode(false)`.
### Programmatic equivalents for every built-in button
The `window.Etcher.layerFor(id)` handle already exposes the
matching primitives — this release just clarifies the mapping so
consumers hiding a built-in button know exactly which method to
call from their replacement UI:
| Built-in button | Programmatic equivalent |
|-------------------------|-----------------------------------------------|
| Pencil (annotation on) | `handle.setMode(true)` / `toggleMode()` |
| Visibility (eye) | `handle.setVisible(true)` / `toggleVisible()` |
| Toolbar — cursor | `handle.exitDrawing()` / `selectTool(null)` |
| Toolbar — rectangle… | `handle.selectTool("rectangle")` (etc.) |
| Toolbar — undo / redo | `handle.undo()` / `handle.redo()` |
| Toolbar — color swatch | `handle.setColor("#hex")` |
| Toolbar — close (×) | `handle.setMode(false)` |
All seven were already on the API surface; this entry just gives
consumers building their own chrome a single place to find the
mapping.
## [0.4.8] — 2026-05-21
### Fixed
- **Pre-0.4.7 canvas shapes now get their `image_id` backfilled on
hydration**, closing the gap where annotations persisted before
0.4.7 still ghost-rendered into adjacent viewport bands on
multi-image `<Fresco.canvas>` hosts. Older payloads had no
`image_id` field, so 0.4.7's `_applyImageVisibility` filter
couldn't tell which page they belonged to and left them
unconditionally visible.
`_renderAnnotation` now runs the same centroid hit-test that
draw-time uses (`_resolveCanvasImageId`) for any canvas-mode
annotation that hydrates without an `image_id`. The full
hydration pass batches its work and `_renderInitial` emits one
`etcher:annotations-changed` after the loop — so the consumer
persists the new ids once, not per shape, and the next mount
reads them straight from `ann.image_id` without re-running the
lookup. Single-image canvases (where `getImages().length < 2`)
and shapes that already carry an `image_id` skip the branch.
Shapes that sit in empty canvas margin (not over any image)
stay untagged — matching draw-time behavior. A freeform note
between two pages isn't owned by any page, so it stays visible
regardless of which image is hidden.
## [0.4.7] — 2026-05-21
### Requires Fresco ~> 0.5.5
The visibility-mirroring fix below relies on Fresco 0.5.5's new
`image-visibility-change` event + `getHiddenImageIds()` snapshot.
Older Fresco gracefully degrades (the wiring no-ops; shapes still
leak into hidden-image bands as before).
### Fixed
- **Shapes on a hidden image no longer leak into adjacent viewport
bands on multi-image `<Fresco.canvas>` hosts.** Paged readers,
spreads, and lookbooks lay every page out side-by-side on one
canvas and call `handle.setImageVisible(id, false)` to hide the
non-active pages. Before this release, the page imgs were
hidden but their Etcher shapes stayed visible — ghost-rendering
inside the surrounding canvas-space the host exposes via
page-padding bands.
Etcher now subscribes to Fresco's `image-visibility-change` and
toggles `display: none` on shapes whose `image_id` is in the
hidden set (plus their title satellites). Hidden shapes that
were being edited drop out of edit mode; hidden shapes with a
pinned tooltip unpin — both states would otherwise float with
no visible anchor.
### Added
- **`image_id` field on canvas-mode annotations.** When a shape is
finalized on a multi-image canvas (`handle.getImages().length > 1`),
its centroid is hit-tested against every image rect and the
matching id is recorded on `shape.image_id` + emitted in
`etcher:annotations-changed`. Shapes that land in empty canvas
space (between images, in an unallocated region) get no
`image_id` and behave like always — visible regardless of any
image's visibility. Single-image canvas consumers are
unaffected: the `length > 1` gate skips the lookup entirely, so
no `image_id` is emitted.
Hydrated annotations with `image_id` round-trip cleanly through
the LiveView — Etcher reads it from `ann.image_id` in
`_renderAnnotation` and tags the new DOM element with
`data-image-id` for consumer CSS hooks.
## [0.4.6] — 2026-05-21
### Fixed
- **Touch taps after a finger-move no longer re-grab the previously-
moved shape.** `_docPointerDown` now hit-tests fresh on every
`pointertype === "touch"` event instead of trusting
`_hoveredShape`. iOS synthesizes a `mousemove` at the
`touchend` point after every gesture, which fires
`_docMouseMove` and leaves the hover cache pinned to the last-
touched shape — making the *next* tap re-select that shape
regardless of where the finger actually landed. Mouse / pen
events still consult the cache (it's accurate there) and fall
back to a fresh hit-test only when it's empty.
## [0.4.5] — 2026-05-21
### Fixed
- **Cursor-mode finger-drag on a shape in `<Fresco.scroll_strip>`
now moves the shape instead of scrolling the page.** Strip
`_wireStripPointerInput` adds a `touchstart` listener that
hit-tests the touch point and calls `preventDefault()` only when
the finger lands on an existing shape — defers iOS's
scroll-vs-app classification long enough for `pointerdown` +
`setPointerCapture` to claim the gesture for the move handler.
Every other tap (empty area, between pages, etc.) still scrolls,
so the reader can navigate the chapter without exiting
annotation mode.
Sibling to 0.4.4's `touch-action: none` fix, but scoped to
cursor mode where blanket-disabling `touch-action` would break
reader navigation. `{ passive: false }` on the listener so
`preventDefault` actually overrides the scroll classification.
## [0.4.4] — 2026-05-21
### Fixed
- **Finger-drawing on iOS Safari in `<Fresco.scroll_strip>` mode no
longer commits a single oversized shape spanning finger-down to
finger-up.** The strip container now picks up
`touch-action: none` while annotation mode is on AND a drawing
tool is active, so iOS hands every `touchmove` to the app
instead of classifying the gesture as scroll at `touchstart` —
too early for `pointerdown`'s `preventDefault` to override.
Cursor mode (no drawing tool) keeps `touch-action: auto` so the
reader can still scroll the chapter to reach existing shapes.
The `.etcher-strip-drawing` class was already toggled on the
strip container in `_selectTool` for the crosshair cursor and a
consumer-CSS hook; this release adds the matching
`touch-action: none` rule to Etcher's injected stylesheet.
## [0.4.3] — 2026-05-20
**Mobile-friendly toolbar + custom color picker + multi-select +
polygon vertex deletion.** A round of UX features that turn the
annotation surface from "works on desktop" into "comfortable on a
phone with a real workflow." Backwards-compatible: no API breakage,
no consumer changes required.
### Added
- **Progressive-overflow toolbar.** A `ResizeObserver` on the viewer
container drives a layout pass that walks tools and swatches in
lockstep and collapses the rightmost non-active items into one of
two new `[⋯]` overflow popups (`tools`, `colors`) as the
container narrows. Tools and swatches shrink in alternation so
the row stays visually balanced; once both groups are exhausted,
`undo` / `redo` collapse together as the final step (still
reachable from the bottom of the tools popup under a hairline).
The active tool / swatch is pinned and never collapses.
- **Custom color picker.** The colors popup now hosts a 132 px hue
ring + 120 px lightness slider + preview chip. Press + drag on
either canvas commits live via `_selectColor` (in-flight drafts
and edits update under the finger); the final color pushes to
recents on `pointerup`. Saturation is fixed at 100 %.
- **Recent custom colors.** Up to 5 picks are persisted to
`localStorage` under `etcher.recentColors` (MRU, dedup +
move-to-front on re-pick). The toolbar's inline swatch row now
reflects this list — new users see the static preset palette,
and once they pick anything the row transitions to their actual
usage. Presets backfill any unused slots so the toolbar is never
empty.
- **Canvas-frequent bootstrap.** When `_recentColors` is empty but
the canvas already has annotations (a hydrated `.fresco` file, a
manga chapter with persisted comments), the inline toolbar
derives from the top 5 most-used colors on existing shapes —
inferred from `style.color` frequencies. Once the user picks any
color, recents takes over and the inferred palette stops
contributing.
- **Shift-click multi-select.** In annotation cursor mode,
`Shift+click` toggles a shape in/out of `selectedShapes`. The
group can be dragged together (image-px delta applied uniformly,
title boxes translate too) or deleted with a single
`Backspace`/`Delete` under one `bulk_delete` undo entry + one
`etcher:annotations-changed` emit. Selection clears on empty-
canvas click, drawing-tool select, or annotation-mode exit.
- **Polygon vertex deletion.** While a polygon is in edit mode,
clicking a vertex (no drag) highlights it red; `Backspace`/
`Delete` splices the selected vertices out of `geometry.points`
and re-renders. `Shift+click` extends the vertex selection.
Falls through to whole-shape delete if the removal would leave
fewer than 3 vertices.
- **`Etcher.registerInputOwnerSelector(selector)`** on the global
`window.Etcher`. Append a CSS selector to the input-owner escape
list for non-conventional overlays that don't match the built-in
modal / dialog / tooltip / handle defaults. Idempotent.
### Changed
- **Default active color** now snaps to whatever lands as the
leftmost toolbar swatch on first paint, not the legacy preset
blue. Once the user picks anything, `_pushRecentColor`'s
move-to-front keeps `activeColor` in agreement with the leftmost
slot — so "what's highlighted" always matches "what will draw."
- **`etcher:annotations-changed` payload** carries a `fresco_id`
key alongside `annotations` so a LiveView hosting multiple
`<Etcher.layer>` instances can pattern-match the source.
- **Doc-level hit-test handlers** (`_docPointerDown`,
`_docDblClick`, `_outsideClickHandler`,
`_titleOutsideClickHandler`, `_tooltipOutsideClick`,
`_docMouseMove`) route through a shared `isInputOwner(target)`
helper instead of inline selector lists. Adding `.etcher-popup`
to the input-owner set means the new picker popups don't tear
down their own state.
## [0.4.2] — 2026-05-20
### Fixed
- **Clicks inside consumer-owned modals no longer fall through to
shapes behind them.** Etcher's doc-level hit-test handlers
(`_docPointerDown`, `_docDblClick`, `_outsideClickHandler`,
`_titleOutsideClickHandler`, `_tooltipOutsideClick`, and the
hover-tracker `_docMouseMove`) now skip events whose target is
inside a standard modal / dialog. Three selectors are recognized
out of the box:
- `dialog[open]` — native HTML5 `<dialog>` shown via `.showModal()`
or `.show()`
- `.modal-open` — daisyUI / Bootstrap convention
- `[role='dialog']` — ARIA-compliant custom modals
Previously, tapping a button inside a modal that sat over the
viewer would shadow the button's own handler and pin / move /
select the shape underneath instead. Consumers shipping a comment
composer, settings sheet, share dialog, or confirmation prompt
layered over a Fresco viewer can now drop their per-modal
`pointerdown` / `stopPropagation` shims.
### Added
- **`Etcher.registerInputOwnerSelector(selector)`** on the global
`window.Etcher`. Append a CSS selector to the input-owner escape
list for non-conventional overlays that don't match the three
defaults. Idempotent — re-registering the same selector is a
no-op. Affects every doc-level Etcher handler immediately on the
next event.
```js
window.Etcher.registerInputOwnerSelector(".my-custom-overlay");
```
## [0.4.1] — 2026-05-20
Closes strip-mode parity gaps flagged by the consumer reader on top of
0.4.0. Most issues collapsed to two missing wires; a couple needed
small fresco-side help (`getImages()` now reports horizontal layout +
prefers live natural dims — see Fresco 0.5.4).
### Requires Fresco ~> 0.5.4
The overlay-positioning fix below relies on Fresco 0.5.4's enriched
`getImages()` (added `left` / `width`, switched `naturalWidth` /
`naturalHeight` to prefer loaded bitmap dims over consumer-passed
`sources` hints). Older Fresco gracefully falls back to the 0.4.0
behavior (overlay pinned to container width).
### Fixed
- **Shape hover + tap now work on strip.** `_initStripRenderer` was
missing the `_wireGlobalShapeListeners()` call canvas mode has. The
doc-level listeners already understand strip's `{imageIdx, x, y}`
coords (since `_shapeAt` filters by `pt.imageIdx`), so wiring them
in unlocks: tooltip on hover, `.is-hovered` styling, `_onShapeTap`
in browse mode (pin tooltip → fires `etcher:tooltip-pin`), and
`_enterEditMode` on shape click in annotation cursor mode (no
drawing tool active). No consumer-side hover/tap workarounds
needed.
- **Toolbar stays in view while scrolling.** Strip-mode toolbar gets
a `data-strip` attribute and a `position: fixed` CSS rule so it
anchors to the viewport instead of the scroll container (which IS
the scrolling element in strip mode and was carrying the toolbar
off-screen).
- **Overlays size to each image, not to the container.**
`_buildStripOverlays` and `_onResize` now read `left` / `width`
from `getImages()` per image, so consumer-side horizontal padding
or centered narrow pages render correctly. Previously every
overlay was hardcoded to `left: 0; width: 100%`, stretching shapes
to fill the container width.
- **viewBox refreshes on resize / image-load.** `_onResize` (which
also fires on Fresco's `image-loaded` event) now refreshes each
overlay's `viewBox` from the current `naturalWidth` /
`naturalHeight`. Combined with Fresco 0.5.4 preferring loaded
bitmap dims, consumers who patch `sources[i]` after the bitmap
arrives no longer end up with stale-ratio viewBoxes that distort
geometry.
- **No more momentary stretch on layout mismatches.** The overlay
SVG's `preserveAspectRatio` is now the default (`xMidYMid meet`),
which letterboxes if there's a brief mismatch between the
element's box and the viewBox (during load / aspect-ratio
correction / padding changes). Previously `"none"` would stretch
shapes during those windows as a user-visible flash of distorted
geometry. Trade-off: shapes don't perfectly fill the element
during the mismatch — but a momentary letterbox is strictly
better UX than a momentary stretch.
### Added
- **`_applyStripOverlayLayout(svg, page)`** internal helper: shared
by mount + resize paths so viewBox / position / size always
refresh together. Universal re-sync entrypoint is still
`window.dispatchEvent(new Event("resize"))` — same hook the
browser uses on its own. Consumers who mutate `<img>` layout via
CSS (toggling a padding slider, swapping an aspect-ratio class)
should dispatch a resize event to nudge etcher to re-query.
## [0.4.0] — 2026-05-20
**`<Fresco.scroll_strip>` support.** Etcher now renders on strip-format
viewers (vertical-scroll manga/manhwa, long-form web comics) with the
same UX surface canvas mode has — pencil button, toolbar, hover
tooltips, undo/redo, hydration from `extensions.etcher`. Pure-additive
on the consumer side: the existing `<Etcher.layer>` mounts unchanged
and dispatches strip vs canvas internally based on the Fresco handle
shape.
### Requires Fresco ~> 0.5.3
Strip mode relies on `handle.getExtension("etcher")` and
`handle.getImages()` (both added in Fresco 0.5.3). Mixing Etcher 0.4
with an older Fresco prints a console warning and skips hydration but
otherwise no-ops cleanly.
### Added
- **Strip renderer.** `<Etcher.layer>` inspects the Fresco handle at
mount and picks between two renderers: the existing canvas renderer
(one SVG overlay spanning the whole canvas) and a new strip renderer
(one SVG sibling per image, sized to each image's `offsetTop` /
`offsetHeight`, with `viewBox` set to natural pixel dimensions so
geometry stored in image-px renders 1:1 without any per-frame coord
math). Native browser scroll moves overlays with their images for
free.
- **Per-image annotations.** Strip shapes carry an `image_idx` field
(the page they live on), pushed in the `etcher:annotations-changed`
payload and round-tripped through `extensions.etcher`. Canvas-mode
payloads are unchanged — `image_idx` is strip-only.
- **`handle.revealShape(uuid, opts)` on the layer API.** Scroll a
strip to center a shape's bbox in the viewport (or call
`handle.fitBounds` on the shape's bbox in canvas mode). `opts`:
`{behavior: "smooth" | "instant", padding: <natural-px>}`. Returns
`true` if the shape was found and a reveal action was issued.
Useful for "click a comment thread → jump to the page it's on"
flows.
- **Touch-native tap-to-select.** `_docPointerDown` now hit-tests
directly on pointerdown when no shape is currently hovered.
Previously, devices without hover (mobile Safari / Chrome on
Android) never populated `_hoveredShape`, so finger-tapping a shape
never pinned its tooltip — fixed for canvas and strip alike.
- **Per-page click + drag locking.** When the user starts drawing on
image #3, a `pointermove` that wanders into image #4 is clamped to
image #3's screen rect — the resulting shape stays anchored to the
page it was started on. Polygon and callout multi-click flows lock
the same way: clicks outside the starting page are ignored.
- **Strip-mode `crosshair` cursor** on the scroll container while a
drawing tool is active, plus an `etcher-strip-drawing` class hook
for consumer CSS that wants to restyle native scrollbars or hide
page chrome while drawing.
### Changed
- **`_init` dispatch.** The mount-time init split into
`_initCanvasRenderer(handle)` and `_initStripRenderer(handle)`.
Detection: `"scrollTo" in handle && typeof handle.scrollTo === "function"`
→ strip; `typeof handle.getCanvasSize === "function"` → canvas;
anything else logs a warning and bails. Consumers driving the layer
via `window.Etcher.layerFor(...)` see the same `api` either way.
- **`_shapeAt(pt)`** filters hit-tests by `pt.imageIdx` in strip mode.
Without the filter, a shape on image 2 with bbox
`{x: 100, y: 200, w: 50, h: 50}` would falsely match a click at
the same image-px coordinates on image 5. Canvas mode is
unaffected.
- **`_finalizeShape` / `_renderAnnotation`** stamp `data-image-idx`
on the shape's `<g>` / `<rect>` / `<polygon>` element in strip mode
for DOM-level debugging + consumer CSS hooks.
- **`mix.exs`** dep pinned to `{:fresco, "~> 0.5.3"}` (was `~> 0.5`).
### Why now
The consumer reader was migrating their long-form manhwa chapters
from `<Fresco.canvas>` (paged, one image at a time) to
`<Fresco.scroll_strip>` (vertical scroll, all pages stitched) but
couldn't bring Etcher with them — strip's handle missed the surface
canvas had, and Etcher's geometry model assumed a single canvas-pixel
coord space. Fresco 0.5.3 closed the handle-side gap; this release
closes the Etcher-side gap.
## [0.3.0] — 2026-05-19
**Major rewrite.** Etcher now plugs into `<Fresco.canvas>`'s
`extensions.etcher` blob instead of a separate Ecto table. Annotations
live in the `.fresco` file alongside the image layout, so a single
`Fresco.Canvas.write!/2` saves the entire scene — no more scattered
DB rows.
### Requires Fresco ~> 0.5
Etcher 0.2 was OpenSeadragon-coupled (through Fresco 0.3.x). Fresco 0.5
dropped OSD entirely; Etcher's coord transforms (`pointFromPixel`,
`pixelFromPoint`, `world.getItemAt`) port to Fresco 0.5's stable
`handle.screenToImage` / `handle.imageToScreen` / `handle.getCanvasSize`.
The "tile-source axis shift" and "modal-traversal drift" problems that
motivated Etcher's custom OSD-viewport math are gone in Fresco 0.5, so
the bridge math is now four lines instead of a forty-line workaround
with footnotes.
### Removed
- **`Etcher.Annotation` Ecto schema.** Annotations are plain maps inside
`extensions.etcher`, not DB rows.
- **`Etcher.Storage` behaviour** + `Etcher.Storage.Default` adapter. No
adapter pattern — one storage path: `Fresco.Canvas.put_extension/3`
and `Fresco.Canvas.write!/2`.
- **`mix etcher.gen.migration`** task + the `etcher_annotations` table.
- **`:target_type`, `:target_uuid`, `:initial_annotations` attrs** on
`<Etcher.layer>`. The canvas IS the target; hydration comes from
`handle.getExtension("etcher")` at mount.
- **`Etcher.create_annotation` / `list_annotations_for` /
`update_annotation` / `delete_annotation`** defdelegates on the
`Etcher` module.
- **`etcher:created` / `etcher:updated` / `etcher:deleted` /
`etcher:selected`** events and the matching `etcher:annotation-saved`
/ `:annotation-removed` / `:annotation-added` /
`:annotation-updated` / `:exit-drawing` push-events. Replaced by a
single bulk `etcher:annotations-changed` event.
- **tmp_id ⇄ real-uuid round-trip.** UUIDv7 is generated client-side
via `crypto.getRandomValues` at draw time; the server never assigns
ids. The `_pendingTitle` / `_discardOnSave` / `syncLiveUuid`
deferred-action plumbing all goes away.
- **`OpenSeadragon.Point` references** and the `handle.on("fast-pan")`
listener. Fresco 0.5's CSS-transform engine doesn't need either.
### Added
- **Hydration from `handle.getExtension("etcher")`** on mount. Initial
annotations come from the canvas's `extensions` map — the consumer
loads a `.fresco` file via `Fresco.Canvas.read!/1` and stashes it in
assigns; Etcher reads it through Fresco's handle.
- **Single bulk event** `etcher:annotations-changed`, payload
`%{"annotations" => [%{uuid, kind, geometry, style, metadata}, …]}`.
Consumer's LiveView pipes through `Fresco.Canvas.put_extension(canvas,
"etcher", %{"version" => "1", "annotations" => annotations})`.
- **`etcher:shape-drawn` event**, payload `%{"uuid", "kind"}`. Fires
once per `_finalizeShape` call — i.e. on actual user-draw intent.
Distinct from `annotations-changed` (which fires on every mutation
including undo/redo of deletes, drags, color picks). Use this when
a consumer wants to open a composer / inspector keyed on "the user
just drew a new shape" without false positives.
- **`patchShape(uuid, {metadata, style})` API** on the layer handle.
Merges the supplied fields into the in-memory shape and re-renders
so DOM that derives from metadata (dimension labels, callout text,
title siblings) reflects the patch. Designed for consumers hosting
the canvas with `phx-update="ignore"` — `handle.getExtension("etcher")`
freezes at mount, so a full layer remount used to be the only way
to push server-side state updates.
- **`deleteShape(uuid)` API** on the layer handle. Removes the shape
from local state + DOM, pushes the deletion onto Etcher's undo
stack (Cmd+Z restores), and fires `annotations-changed` so the
consumer's persistence layer catches up automatically.
- **Line annotation tool** — eighth drawing kind. Two-endpoint stroke,
no arrows, no inline label. Geometry (`{a: [x,y], b: [x,y]}`) and
edit-handle mechanics shared with `dimension`. Title rides the
standard sibling-above-shape path (the same movable label group
rectangle / circle / polygon use).
- **Direct shape drag in annotation cursor mode** — pointerdown on
any shape's body now immediately starts the move gesture. Stationary
clicks still select via the no-drag fallback. Doc-level pointer
routing was extended so shapes with `.etcher-shape { pointer-events:
none }` wrappers (rectangle, circle, polygon, line, dimension,
freehand) participate; callout and text were already covered via
their inner `pointer-events: all` rects.
- **Select-on-grab** — shape enters edit mode the moment the user
starts a move gesture, not on release. Handles appear immediately
so drag feels like "select and move" instead of "move then select."
- **Backspace / Delete keyboard shortcut** removes the
currently-selected shape. Routes through the same `_deleteShape`
path as the eraser tool, so undo + sync behavior is identical.
### Changed
- **Callout commit flow.** Second-click no longer auto-opens Etcher's
inline text editor and no longer seeds `metadata.title` with an
`"Add a title…"` placeholder. Consumers wiring their own composer
(taking the title via a UI field + creating a linked comment in
one flow) now get a clean draft to work with — the composer is the
single edit surface for the title. Re-editing the title later via
double-click is unchanged.
- **Tooltip placement** flips below the shape when sitting above
would clip the container's top edge, and clamps horizontally so
the tooltip stays inside the container near the left/right edges.
Previously a shape near the top of the viewport had its tooltip
rendered partially off-screen above the container.
### Unchanged (drawing UX)
The eight drawing tools (rectangle, circle, polygon, freehand,
callout, text, dimension, line) plus the eraser keep their existing
draw + edit mechanics. Hit-testing, undo/redo (⌘Z / ⌘⇧Z / Ctrl+Y),
inline text editor for text shapes, color swatches, tooltips, the
bottom toolbar, the pencil + visibility nav buttons — all preserved.
The new drag-without-tap + select-on-grab + keyboard-delete layers
above these without changing the per-shape draw paths. The ~5000
lines of shape drawing code are substantially unchanged; only the
~200-line Fresco bridge was rewritten.
### Migration from 0.2.x
Consumers on Etcher 0.2 with persisted annotations in
`etcher_annotations` need to migrate. Export the rows you care about:
```sql
SELECT uuid, kind, geometry, style, metadata
FROM etcher_annotations
WHERE target_type = ? AND target_uuid = ?
ORDER BY position;
```
Marshal them into the new `extensions.etcher.annotations` array shape
and stash into the canvas struct:
```elixir
canvas =
Fresco.Canvas.new(width: 4000, height: 3000)
|> Fresco.Canvas.add_image(%{src: image_url, x: 0, y: 0, width: 4000})
|> Fresco.Canvas.put_extension("etcher", %{
"version" => "1",
"annotations" => exported_rows
})
Fresco.Canvas.write!("/path/to/scene.fresco", canvas)
```
Drop the `etcher_annotations` table once migrated. `<Etcher.layer>`
loses its `:target_type` / `:target_uuid` / `:initial_annotations`
attrs in the template — pass just `fresco_id="..."` and optionally
`tools={...}`. The handle_event clauses for `etcher:created`,
`etcher:updated`, `etcher:deleted` collapse into a single
`etcher:annotations-changed` clause.
`<Fresco.viewer>` users need to switch to `<Fresco.canvas>` (use a
canvas with a single image for the same effect) — Etcher 0.3 only
attaches to canvases.
## [0.2.8] — 2026-05-17
Coordinate with Fresco's new CSS-transform pan fast path so
annotations stay anchored to the canvas during the pan window. No
behavior change for consumers on Fresco `< 0.3.0` or for viewers
not opted into `:pan_optimized` — the new subscription is inert
when the event never fires.
### Added
- Subscribe to Fresco's `fast-pan` event (introduced in fresco
`0.3.0` for the `:pan_optimized` viewer mode). When Fresco emits
`fast-pan {phase, x, y}` during a fast-path pan, the EtcherLayer
hook applies the same `translate3d(x, y, 0)` CSS transform to
its SVG overlay wrapper so annotations, tooltips, and
foreignObject editors glide in lockstep with the canvas. CSS
transform propagates to descendants automatically; hit-testing
follows the visual transform so clicks during fast-pan still
register on the correct annotation. On `phase: "end"`, the
transform is cleared — Fresco has restored OSD's drawer and the
next `animation` tick re-renders the overlay from the committed
viewport.
### Notes
- Backwards compatible. Older Fresco (`< 0.3.0`) never emits the
`fast-pan` event, so the new subscription is dead code with no
overhead. Etcher 0.2.8 works identically against any Fresco
version it was previously compatible with.
- The subscription is added to the existing `_unsubViewport`
array, so `destroyed()` cleans it up like the other viewport
bridges.
## [0.2.7] — 2026-05-15
Documentation + comment cleanup release. No runtime behavior changes
— every existing call site behaves exactly as in 0.2.6. The goal is
to make Etcher visibly **decoupled from any specific consumer** so
that a third-party Phoenix dev reading the source or docs sees a
clean, drop-in library rather than an obvious satellite.
### Changed
- `Etcher.Storage` moduledoc: replaced the "PhoenixKit, for example"
paragraph with a generic "consumer that pairs every annotation
with a comment thread" example. The behaviour itself is unchanged
— only the explanatory prose.
- `lib/etcher.ex` moduledoc: corrected the install snippet
(`{:fresco, "~> 0.1"}, {:etcher, "~> 0.2"}` — the old version pins
pointed at non-existent fresco 0.2 / outdated etcher 0.1) and
expanded the shape list to include `callout, text, dimension`
rather than only the original four.
- `Etcher.Layer` moduledoc: tools example and "Tools" section now
include `:eraser`, matching the actual default. Added one line
explaining how to opt out.
- `priv/static/etcher.js`: four inline comments that named PhoenixKit
/ PhoenixKitComments now describe the generic contract instead
(any element with `data-annotation-uuid` for cross-component
highlight; "consumer's annotation-creation UI" / "host apps" for
extension-point comments). The contracts themselves were already
generic — only the prose changed.
- `README.md`: corrected the Installation version pins, expanded the
bottom-toolbar ASCII diagram to show all eight buttons, replaced
"the four drawing tools" with "seven drawing tools (rectangle,
circle, polygon, freehand, callout, text, dimension) plus an
eraser," documented the `EtcherLayer` hook name for explicit
hook-map wiring, and rewrote the Out-of-scope section to drop a
stale "v0.1 is draw-and-commit" claim (editing has been supported
for several releases) and the "four built-ins" reference.
- `CHANGELOG.md`: reworded the 0.2.6 entry to drop the two PhoenixKit
name-drops; the same fix applies to any consumer that opens its
own composer popup on `etcher:created`.
## [0.2.6] — 2026-05-15
Single fix to the dimension-creation flow so consumer apps that open
their own composer popup on `etcher:created` can attach a comment to
a freshly-drawn dimension without fighting an auto-opened inline
editor.
### Fixed
- **Dimension creation no longer auto-opens the inline label editor.**
0.2.5 fired `_startTextEdit` in the `_finalizeShape` afterCreate
for dimensions (mirroring the callout flow), which stacked a
foreignObject input over the label position. Consumers that pop a
separate composer popup on `etcher:created` (for setting an
annotation title + comment in one flow) lost the composer behind
the inline editor — users would dismiss the composer they hadn't
noticed and lose the comment, sometimes the whole shape (such
composers typically treat cancel as "discard the annotation").
Dimensions now spawn empty; the consumer's UI sets the label via
whatever path it normally uses for non-text shapes (typically the
`etcher:created` → composer-popup → `etcher:updated` chain). Re-
editing the label after creation still works via double-click on
the dimension — that path was wired in 0.2.5 and is unchanged.
## [0.2.5] — 2026-05-15
New annotation kind — `dimension` — for measurement-style labeling.
A horizontal-or-angled shaft with V-arrows on both ends and a
black, slidable label. Arrow color follows the active swatch; label
stays black with a white halo so it's legible on any color.
### Added
- **Dimension tool** (`kind: "dimension"`). Two endpoints
(`geometry: {a: [x,y], b: [x,y]}`); label text + position along
the shaft live in `metadata.title` and `metadata.title_offset`
(0–1, default 0.5 = midpoint). Drawn either by click-drag (commit
on release) or by two-click rubberband (first click locks endpoint
A, the line follows the cursor, second click commits endpoint B).
After commit, drops straight into inline-edit mode for the label.
- **Slidable label** — in cursor mode, click and drag the label to
slide it along the shaft. Persists as `metadata.title_offset`.
- Endpoint corner handles + body-drag translate, double-click to
re-edit the label, eraser supports the new kind.
- `Etcher.Annotation`'s `@kinds` widened to include `"callout"`,
`"text"`, and `"dimension"` (the schema docs were also out of date
for callout/text — fixed in passing).
### Changed
- The bundled `Etcher.Annotation` schema now accepts the same kinds
the JS toolbar exposes. Consumers using the bundled storage need
no migration if their CHECK constraint was already widened for
callout/text — add `'dimension'` to the same allow-list.
- Inline text editor input pinned to black so the typed text stays
readable on the white-ish input background regardless of the
shape's stroke color (light pastels were nearly invisible).
## [0.2.4] — 2026-05-15
Cross-browser fixes + a callout stability sweep — mostly fallout from
the 0.2.3 title fix not being symmetric across the rendering paths.
### Fixed
- **Title / callout / text-shape labels render at the same vertical
position in Firefox as in Safari.** The text elements were created
with `dominant-baseline: hanging`, which Safari and Firefox
interpret differently for Latin text — Safari renders glyphs ABOVE
the hanging baseline while Firefox renders them BELOW per spec.
Combined with the wrap helper's `dy="1em"` on the first tspan,
callout text rendered correctly in Safari but floated below the
rect in Firefox. All three render paths (`_renderTitleSibling`,
the `case "text"` branch, and the callout branch in `_renderShape`)
now override `dominant-baseline` to `alphabetic` on the text
element each render. Both browsers honor alphabetic identically:
the alphabetic baseline lands at `text.y + 1em`, putting the text
cleanly inside the rect with the underline / leader attaching at
the rect's bottom edge.
- **Callouts no longer grow exponentially when text overflows.** The
width-fit font cap that 0.2.3 added to `_renderTitleSibling` was
missing from the callout render path, so a long callout label
triggered the same multi-line wrap → grow → wider font → more wraps
feedback loop the title fix originally addressed. Same cap applied
to callouts.
- **Click on a title or callout/text-shape handle no longer triggers
a phantom resize.** `_startTitleHandleDrag` and `_startHandleDrag`
unconditionally fired `etcher:updated` and snapped geometry to the
rendered (shrunk) box on `pointerup`, even when the user just
clicked without dragging. Both now use a 3-px screen-space dead
zone (matching the body-drag and title-drag handlers) so a bare
click is a no-op.
- **Callout corner drags no longer shrink the box on every
interaction.** Drag math used `_renderedBox` (shrink-fit visual)
as the start reference, so each drag computed a new geometry of
`(visual + delta)` instead of `(geometry + delta)`. With shrink-fit
on, that baked the shrink offset back into storage every drag —
the callout visibly shrunk a bit each time, then converged. Drag
math now uses pointer DELTA (`pt - startPt`) against the full
`geometry.text_box`, so dragging a handle by Δpx grows or shrinks
geometry by exactly Δpx; the visual continues to shrink-fit
independently. Anchor drag (idx 0) still uses absolute pt — no
visual/storage offset there.
- The `_startHandleDrag` `onUp` snap-to-`_renderedBox` is skipped for
callouts (delta math already keeps geometry consistent with the
visible drag). Text shapes still snap on release — same 0.2.x
behavior, no regression.
## [0.2.3] — 2026-05-14
Single bug fix — stops the runaway growth of shape titles on drag /
click. No API change, no behavior change for code that doesn't hit
the bug.
### Fixed
- **Shape title text no longer balloons on every interaction.** When
a title's content overflowed the default box width at the
height-derived font-size, `_fillTextWithWrappedTspans` wrapped it
onto multiple lines. `actualH = measured.height + pad·2` then
exceeded the input `th`, that taller height got persisted back
into `metadata.title_box` on release, the next render derived an
even larger font, more lines wrapped, and the title grew
exponentially per interaction (`title_box.h` going
22 → 54 → 273 → … in three drags). `_renderTitleSibling` now
caps the font-size so the title fits the box width on a single
line (floor of 10 px), bounding `actualH` to one line of text.
The shrink-to-text rendering + handle-drag commit are unchanged;
with the cap in place the system has a fixed point instead of a
feedback loop.
## [0.2.2] — 2026-05-14
Follow-up patch to 0.2.1: restore body-grab on the editing shape,
keep the tooltip from blocking the satellite title label, and make
edit-mode survive a click on a sibling shape now that shapes are
`pointer-events: none`.
### Fixed
- **Body-grab restored on the editing shape.** With 0.2.1 flipping
every shape to `pointer-events: none`, the click-drag-the-body-
to-move-the-shape gesture stopped firing because the shape's
own pointerdown listener no longer saw any events. The
currently-edit-mode shape now re-enables `pointer-events:
visiblePainted` via a `.etcher-shape.is-editing` rule — only
THAT shape catches its own pointerdown; the rest of the shapes
stay invisible to events so pan/zoom still passes through them.
- **Tooltip no longer covers the title satellite.** When the cursor
was over a shape's movable title label, the hover tooltip
rendered above the parent shape — directly on top of the title
the user was trying to grab. The doc-level hover hit-test now
detects when the cursor is inside a title's bbox and suppresses
the tooltip for that hit; hover styling on the parent shape
stays applied so it's still clear which annotation is targeted.
- **Edit-mode and tooltip-pin survive a click on a sibling shape.**
Both outside-click handlers (the edit-mode tear-down and the
tooltip-pin tear-down) used `e.target.closest(".etcher-shape")`
to detect "is this click on a shape?" — but since 0.2.1 shapes
are `pointer-events: none`, the click's DOM target is OSD's
canvas, not the shape. The handlers now fall back to an
image-px hit-test via `_shapeAt(pt)` so clicking a different
shape switches edit mode or the pin instead of tearing down to
empty.
### Internal
- `_setHoveredShape/2` (was /1) gains an `onTitle` flag.
- New helper `_pointOnTitleOf/2` for the title-bbox hit-test.
## [0.2.1] — 2026-05-14
Patch release: pan / zoom now work over shapes.
### Fixed
- Scroll-wheel zoom and click-drag pan on the underlying viewer
stopped working whenever the cursor was over an annotation
(rectangle, circle, polygon, freehand, callout, text). Root
cause: shapes had `pointer-events: visiblePainted` so they
caught wheel + pointerdown before OSD's MouseTracker on the
canvas sibling could see them — pointer events bubble UP the
DOM, not sideways. Shapes are now `pointer-events: none`, and
hover + click are re-detected at the document level via
image-px hit-testing (reuses the eraser's per-kind point-in-
shape check). Hover styling, tooltips, click-to-pin, click-to-
edit, and dblclick-inline-edit all continue to work; pan and
zoom now pass through every annotation cleanly.
### Internal
- Renamed `_eraserHit/2` to a shared `_shapeContainsPoint/2`
helper. The eraser keeps a thin alias for readability at its
call sites.
- New helpers: `_shapeAt/1` (topmost-shape lookup), `_onShapeTap/1`
(shared tap-handling entry), `_wireGlobalShapeListeners/0` +
`_unwireGlobalShapeListeners/0`, `_setHoveredShape/1`.
- Tap-vs-drag disambiguation with a 5px dead-zone keeps a quick
click-without-drag firing the shape's selection / pin / edit
flow, while any drag-with-movement passes through to OSD's
pan unchanged.
## [0.2.0] — 2026-05-14
A backwards-compatible second release: two new shape kinds, an eraser
tool, undo/redo with full history, satellite titles, edge-resize
grabbers, polygon midpoint insertion, a visibility toggle, and a
complete programmatic API so consumers can drive the layer without
rendering its built-in toolbar.
### Added
- **Callout tool** (`kind: "callout"`) — blueprint-style leader-line
annotation: an anchor dot pointing at the image, a thin line to a
resizable text bbox (with a horizontal underline spanning the bbox
bottom). Text inside scales to fit the bbox.
- **Text tool** (`kind: "text"`) — freestanding text label drawn as
a click-drag bbox. Inline editor (`<foreignObject>` + `<input>`)
opens on commit and on double-click for re-edit. Font scales with
bbox height; bbox shrink-wraps to the text on release.
- **Eraser tool** — press-and-drag wipes shapes by sweep. Each shape
the cursor crosses dims (`.is-erasing`) for preview; release
flushes them all as a single compound delete. Idle hover (eraser
selected, no button held) previews the single shape under the
cursor.
- **Optional title field per annotation** — every kind can carry a
short label (`title varchar(200)`). On rect/circle/polygon/freehand
the title renders as a movable, resizable satellite group with a
dashed leader line back to the parent's nearest perimeter point
(leader auto-hides when the title is inside the parent). On
callouts the title is the in-bbox content. Drag to move (persisted
as `metadata.title_box`), 4 corner handles to resize, double-click
to inline-edit.
- **Edge-midpoint resize grabbers on rectangles** — small rounded
rect handles on each side; drag a side to slide one edge while
the opposite edge stays anchored. Distinct visual + `ns-resize` /
`ew-resize` cursors so they don't conflate with polygon midpoints.
- **Polygon midpoint vertex insertion** — every polygon edge now
carries a "ghost" midpoint handle that lights up when the cursor
is near. Grab it to insert a new vertex at that midpoint and place
it via a vertex-style drag.
- **Undo / Redo** — toolbar buttons + ⌘Z / ⌘⇧Z / Ctrl+Y keyboard
shortcuts. 50-op session history covers geometry, style, metadata,
title text, and deletes (including bulk-delete from the eraser).
Delete recreation honors a new `restore: true` flag so the consumer
can suppress its create-time UI (e.g. comment composer) on undo.
- **Visibility toggle** — eye / eye-slash button above the pencil in
Fresco's nav column. Hides/shows the entire SVG overlay with one
click.
- **Color picker** — bottom-toolbar swatches; persisted as
`style.color` on each annotation; vertex + title handles inherit
the shape's color via `currentColor` (no more always-orange dots).
Override the palette via `window.Etcher.colorSwatches`; initial
color via `window.Etcher.defaultColor`.
- **Tooltip slot extension API** — `window.Etcher.tooltipSlots = {
header, body, footer }` lets consumers replace the tooltip content
per-slot while keeping the wrapper (trash button, pin/unpin, hover
bridge) under Etcher's control. Default slots read generic
`metadata.{title,body,subtitle}` keys.
- **Complete programmatic control surface** on
`window.Etcher.layerFor(frescoId)` so every built-in button is
callable from outside. Methods grouped by mode, visibility, tool,
color, history, and shape selection/edit. Consumers can render
their own toolbar and drive the layer headlessly.
- **CustomEvents** for state changes:
`etcher:mode-changed`, `etcher:tool-changed`, `etcher:color-changed`,
`etcher:visibility-changed`, `etcher:history-changed`,
`etcher:tooltip-show / -hide / -pin / -unpin`.
- **Restored comment threads on undo-of-delete** — the etcher:created
payload for a restore carries `restore_from_uuid` so consumers can
re-link soft-deleted child rows (e.g. comments) to the new uuid the
server assigns to the recreated annotation.
- **`appendNavButton` mutable handle (Fresco 0.1.2+)** — Etcher's nav
buttons can now update their icon / title in place (used by the
visibility toggle to flip eye ↔ eye-slash).
### Changed
- Default `:tools` list on `Etcher.Layer.layer/1` is now
`[:rectangle, :circle, :polygon, :freehand, :callout, :text,
:eraser]` (all seven). Pass an explicit list to subset.
- Text + title + callout bboxes shrink-wrap to the rendered text on
every render; the stored geometry is rewritten to the shrunk
dimensions on release so storage always matches what's visible.
- Vertex handles now inherit the shape's color (`currentColor`)
instead of hard-coded orange. CSS hover / drag fills use
`fill-opacity` so they tint correctly with whichever color the
shape carries.
- Single-shape deletes now flow through the same compound
`bulk_delete` undo op the eraser uses, so the tooltip trash button
also gets redo support.
### Fixed
- Tooltip stops hijacking hover state on a different shape while
another shape's tooltip is pinned.
- Pinned shape keeps its `.is-selected` outline when the cursor
leaves it (was getting stuck visually deselected).
- Tooltip `.is-hovered` no longer sticks after the cursor leaves a
pinned shape.
## [0.1.0] — 2026-05-06
Initial release.
### Added
- `Etcher.Layer` Phoenix LiveView function component — attaches an
annotation overlay to a named Fresco viewer and adds a pencil button
to its nav column.
- `Etcher.Storage` behaviour — pluggable storage adapter contract with
four callbacks (`create/1`, `list_for/2`, `update/2`, `delete/1`).
- `Etcher.Storage.Default` — bundled implementation backed by the
`etcher_annotations` table. Reads the consumer's Repo from
`config :etcher, repo: …`.
- `Etcher.Annotation` Ecto schema for the bundled table (UUIDv7 primary
key, `target_type` / `target_uuid`, four geometry kinds: rectangle,
circle, polygon, freehand).
- `mix etcher.gen.migration` — generates the `etcher_annotations` table
migration into the consumer's `priv/repo/migrations/`.
- JS engine at `priv/static/etcher.js` — registers the `EtcherLayer`
LiveView hook, draws shapes as SVG overlays anchored to image
coordinates, emits `etcher:created` / `:updated` / `:deleted` /
`:selected` events.
- Bottom drawing toolbar with rectangle / circle / polygon / freehand
tools; pencil-button toggle integrated with Fresco's nav column via
`handle.appendNavButton/3` (Fresco 0.2+).
[0.2.2]: https://github.com/alexdont/etcher/releases/tag/v0.2.2
[0.2.1]: https://github.com/alexdont/etcher/releases/tag/v0.2.1
[0.2.0]: https://github.com/alexdont/etcher/releases/tag/v0.2.0
[0.1.0]: https://github.com/alexdont/etcher/releases/tag/v0.1.0