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