All notable changes to Etcher are documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.
[0.4.11] — 2026-05-24
Polishes touch-and-tooltip UX on <Fresco.scroll_strip> hosts +
plumbs into Fresco 0.5.9's tap-suppression hooks so consumer tap-
zone navigation no longer races etcher's shape interactions.
Backwards-compatible — every fix is either purely additive (new
API surface) or scoped to the broken state.
Requires Fresco ~> 0.5.9
The data-fresco-suppress-tap shape-attribute and
handle.suppressNextTap(...) call paths only engage when paired
with Fresco 0.5.9+. The etcher.js side guards the latter with
typeof handle.suppressNextTap === "function" so older Fresco
silently degrades (no crash, but the iOS tap-race fixes don't
activate). The dep constraint in mix.exs is bumped to make
the requirement explicit.
Fixed
- Strip-mode tooltip lands at the right position when the
container is scrolled.
_showTooltipFornow adds the container'sscrollLeft/scrollTopwhen computing the tooltip'sstyle.top/style.left. The tooltip isposition: absoluteinside the relatively-positioned scroll container, so its coordinates are interpreted in CONTENT space — without the scroll offset, every tooltip past the first viewport-worth of content landedscrollToppx above the visible area. Canvas-mode containers don't scroll (they pan via CSS transform) soscrollTopstays 0 and the addition is a no-op there — no per-mode branching. - Tooltip hover-bridge race on first show. After
_showTooltipFor, a 250 ms grace window suppresses the next_scheduleHideTooltipcall. Without it, iOS Safari's synthesized mousemove (which lands on the just-shown tooltip, not the originating shape) fires_setHoveredShape(null)→_scheduleHideTooltipbefore the tooltip's ownmouseentercancellation runs, so the tooltip flashed visible then hid in the same frame. - Touch-drag of vertex / midpoint handles + edit-mode shape
body no longer fights the strip container's native scroll
on mobile. New CSS rule applies
touch-action: noneto.etcher-handle,.etcher-handle-midpoint, and.etcher-shape.is-editing/.is-moving— defers iOS's scroll-vs-app classification long enough forsetPointerCaptureto claim the gesture. Scoped to interactive states so static shapes don't block native scroll past them.
Added
handle.tooltip()on the public layer API. Returns the currently-shown tooltip's{shape, pinned}ornull. Theshapefield is the same{uuid, kind, geometry, style?, metadata?}descriptorgetShapereturns. Lets consumers driving custom chrome react to "user opened the tooltip on shape X" without scraping the DOM. The rawtooltipElis intentionally not exposed.handle.repositionTooltip()on the public layer API. Re- anchors the currently-shown tooltip to its shape. No-op when no tooltip is up. Useful after a consumer-driven layout change (toggling a side panel, adjusting strip padding) has drifted the tooltip from its anchor.data-fresco-suppress-tapon every.etcher-shapeelement (set by both_finalizeShapeand_renderAnnotation). Fresco 0.5.9+ probes for this attribute under the tap point viadocument.elementsFromPointand skips thetapemit, so tapping an existing annotation pins the tooltip without bubbling to consumer-side tap-zone navigation. Older Fresco versions ignore the attribute entirely (no behavior change).handle.suppressNextTap(250)call after every shape-commit (inside_finalizeShape). Closes the synthesized-tap-after- drag race that would fire a consumer's tap-zone navigation immediately after the user finished drawing a shape. Guards ontypeof handle.suppressNextTap === "function"so older Fresco versions silently no-op.
[0.4.10] — 2026-05-23
Added
handle.addShape(payload)/handle.addShapes(payloads)on the public layer API. Splices one or more shapes into a live-mounted layer without remounting — preserves the active tool, color selection, multi-selection, undo stack, and any pinned tooltip. Useful for multi-chapter strip readers that fetch the next chapter's annotations on scroll, or canvas hosts that grow with new images at runtime.The payload mirrors the persisted-annotation shape used by
etcher:annotations-changed:{ uuid?: "01HXY...", // optional; generated if omitted kind: "rectangle", geometry: { ... }, image_idx?: 17, // strip mode (REQUIRED) image_id?: "page-3", // canvas multi-image (auto-resolved // from centroid when omitted) style?: %{ color: "..." }, metadata?: %{ ... } }addShapereturns the shape's uuid (ornullon validation failure — strip mode requires a validimage_idx).addShapesreturns an array of uuids in input order with any rejected payloads filtered out.Multiple sibling
addShape/addShapescalls scheduled in the same microtask batch into oneetcher:annotations-changedemit, so the consumer's server-sync handler doesn't see a flurry of full-array replays.
[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_buttonsattr 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 tohandle.toggleMode()/handle.toggleVisible()(orsetMode(true|false)/setVisible(true|false)).- A subset list — only those buttons render.
Mirrors as
data-nav-buttonson the layer host using the"none"sentinel for the empty case, matching Fresco's convention.:toolbarattr on<Etcher.layer>(boolean, defaulttrue).falseskips building the bottom toolbar entirely. Annotation mode still works programmatically — consumers wire their own toolbar UI tohandle.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_idbackfilled 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 noimage_idfield, so 0.4.7's_applyImageVisibilityfilter couldn't tell which page they belonged to and left them unconditionally visible._renderAnnotationnow runs the same centroid hit-test that draw-time uses (_resolveCanvasImageId) for any canvas-mode annotation that hydrates without animage_id. The full hydration pass batches its work and_renderInitialemits oneetcher:annotations-changedafter the loop — so the consumer persists the new ids once, not per shape, and the next mount reads them straight fromann.image_idwithout re-running the lookup. Single-image canvases (wheregetImages().length < 2) and shapes that already carry animage_idskip 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 callhandle.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-changeand togglesdisplay: noneon shapes whoseimage_idis 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_idfield 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 onshape.image_id+ emitted inetcher:annotations-changed. Shapes that land in empty canvas space (between images, in an unallocated region) get noimage_idand behave like always — visible regardless of any image's visibility. Single-image canvas consumers are unaffected: thelength > 1gate skips the lookup entirely, so noimage_idis emitted.Hydrated annotations with
image_idround-trip cleanly through the LiveView — Etcher reads it fromann.image_idin_renderAnnotationand tags the new DOM element withdata-image-idfor consumer CSS hooks.
[0.4.6] — 2026-05-21
Fixed
- Touch taps after a finger-move no longer re-grab the previously-
moved shape.
_docPointerDownnow hit-tests fresh on everypointertype === "touch"event instead of trusting_hoveredShape. iOS synthesizes amousemoveat thetouchendpoint after every gesture, which fires_docMouseMoveand 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_wireStripPointerInputadds atouchstartlistener that hit-tests the touch point and callspreventDefault()only when the finger lands on an existing shape — defers iOS's scroll-vs-app classification long enough forpointerdown+setPointerCaptureto 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: nonefix, but scoped to cursor mode where blanket-disablingtouch-actionwould break reader navigation.{ passive: false }on the listener sopreventDefaultactually 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 uptouch-action: nonewhile annotation mode is on AND a drawing tool is active, so iOS hands everytouchmoveto the app instead of classifying the gesture as scroll attouchstart— too early forpointerdown'spreventDefaultto override. Cursor mode (no drawing tool) keepstouch-action: autoso the reader can still scroll the chapter to reach existing shapes.The
.etcher-strip-drawingclass was already toggled on the strip container in_selectToolfor the crosshair cursor and a consumer-CSS hook; this release adds the matchingtouch-action: nonerule 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
ResizeObserveron 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/redocollapse 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 onpointerup. Saturation is fixed at 100 %. - Recent custom colors. Up to 5 picks are persisted to
localStorageunderetcher.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
_recentColorsis empty but the canvas already has annotations (a hydrated.frescofile, a manga chapter with persisted comments), the inline toolbar derives from the top 5 most-used colors on existing shapes — inferred fromstyle.colorfrequencies. Once the user picks any color, recents takes over and the inferred palette stops contributing. - Shift-click multi-select. In annotation cursor mode,
Shift+clicktoggles a shape in/out ofselectedShapes. The group can be dragged together (image-px delta applied uniformly, title boxes translate too) or deleted with a singleBackspace/Deleteunder onebulk_deleteundo entry + oneetcher:annotations-changedemit. 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/Deletesplices the selected vertices out ofgeometry.pointsand re-renders.Shift+clickextends the vertex selection. Falls through to whole-shape delete if the removal would leave fewer than 3 vertices. Etcher.registerInputOwnerSelector(selector)on the globalwindow.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 keepsactiveColorin agreement with the leftmost slot — so "what's highlighted" always matches "what will draw." etcher:annotations-changedpayload carries afresco_idkey alongsideannotationsso 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 sharedisInputOwner(target)helper instead of inline selector lists. Adding.etcher-popupto 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/stopPropagationshims.
Added
Etcher.registerInputOwnerSelector(selector)on the globalwindow.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.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.
_initStripRendererwas missing the_wireGlobalShapeListeners()call canvas mode has. The doc-level listeners already understand strip's{imageIdx, x, y}coords (since_shapeAtfilters bypt.imageIdx), so wiring them in unlocks: tooltip on hover,.is-hoveredstyling,_onShapeTapin browse mode (pin tooltip → firesetcher:tooltip-pin), and_enterEditModeon 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-stripattribute and aposition: fixedCSS 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.
_buildStripOverlaysand_onResizenow readleft/widthfromgetImages()per image, so consumer-side horizontal padding or centered narrow pages render correctly. Previously every overlay was hardcoded toleft: 0; width: 100%, stretching shapes to fill the container width. - viewBox refreshes on resize / image-load.
_onResize(which also fires on Fresco'simage-loadedevent) now refreshes each overlay'sviewBoxfrom the currentnaturalWidth/naturalHeight. Combined with Fresco 0.5.4 preferring loaded bitmap dims, consumers who patchsources[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
preserveAspectRatiois 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 stillwindow.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'soffsetTop/offsetHeight, withviewBoxset 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_idxfield (the page they live on), pushed in theetcher:annotations-changedpayload and round-tripped throughextensions.etcher. Canvas-mode payloads are unchanged —image_idxis strip-only. handle.revealShape(uuid, opts)on the layer API. Scroll a strip to center a shape's bbox in the viewport (or callhandle.fitBoundson the shape's bbox in canvas mode).opts:{behavior: "smooth" | "instant", padding: <natural-px>}. Returnstrueif 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.
_docPointerDownnow 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
pointermovethat 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
crosshaircursor on the scroll container while a drawing tool is active, plus anetcher-strip-drawingclass hook for consumer CSS that wants to restyle native scrollbars or hide page chrome while drawing.
Changed
_initdispatch. 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 viawindow.Etcher.layerFor(...)see the sameapieither way._shapeAt(pt)filters hit-tests bypt.imageIdxin 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/_renderAnnotationstampdata-image-idxon the shape's<g>/<rect>/<polygon>element in strip mode for DOM-level debugging + consumer CSS hooks.mix.exsdep 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.AnnotationEcto schema. Annotations are plain maps insideextensions.etcher, not DB rows.Etcher.Storagebehaviour +Etcher.Storage.Defaultadapter. No adapter pattern — one storage path:Fresco.Canvas.put_extension/3andFresco.Canvas.write!/2.mix etcher.gen.migrationtask + theetcher_annotationstable.:target_type,:target_uuid,:initial_annotationsattrs on<Etcher.layer>. The canvas IS the target; hydration comes fromhandle.getExtension("etcher")at mount.Etcher.create_annotation/list_annotations_for/update_annotation/delete_annotationdefdelegates on theEtchermodule.etcher:created/etcher:updated/etcher:deleted/etcher:selectedevents and the matchingetcher:annotation-saved/:annotation-removed/:annotation-added/:annotation-updated/:exit-drawingpush-events. Replaced by a single bulketcher:annotations-changedevent.- tmp_id ⇄ real-uuid round-trip. UUIDv7 is generated client-side
via
crypto.getRandomValuesat draw time; the server never assigns ids. The_pendingTitle/_discardOnSave/syncLiveUuiddeferred-action plumbing all goes away. OpenSeadragon.Pointreferences and thehandle.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'sextensionsmap — the consumer loads a.frescofile viaFresco.Canvas.read!/1and 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 throughFresco.Canvas.put_extension(canvas, "etcher", %{"version" => "1", "annotations" => annotations}). etcher:shape-drawnevent, payload%{"uuid", "kind"}. Fires once per_finalizeShapecall — i.e. on actual user-draw intent. Distinct fromannotations-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 withphx-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 firesannotations-changedso 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 withdimension. 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 innerpointer-events: allrects. - 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
_deleteShapepath 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.titlewith 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:
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:
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-panevent (introduced in fresco0.3.0for the:pan_optimizedviewer mode). When Fresco emitsfast-pan {phase, x, y}during a fast-path pan, the EtcherLayer hook applies the sametranslate3d(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. Onphase: "end", the transform is cleared — Fresco has restored OSD's drawer and the nextanimationtick re-renders the overlay from the committed viewport.
Notes
- Backwards compatible. Older Fresco (
< 0.3.0) never emits thefast-panevent, 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
_unsubViewportarray, sodestroyed()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.Storagemoduledoc: 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.exmoduledoc: 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 includecallout, text, dimensionrather than only the original four.Etcher.Layermoduledoc: 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 withdata-annotation-uuidfor 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 theEtcherLayerhook 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 onetcher: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
_startTextEditin the_finalizeShapeafterCreate for dimensions (mirroring the callout flow), which stacked a foreignObject input over the label position. Consumers that pop a separate composer popup onetcher: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 theetcher:created→ composer-popup →etcher:updatedchain). 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 inmetadata.titleandmetadata.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@kindswidened to include"callout","text", and"dimension"(the schema docs were also out of date for callout/text — fixed in passing).
Changed
- The bundled
Etcher.Annotationschema 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'sdy="1em"on the first tspan, callout text rendered correctly in Safari but floated below the rect in Firefox. All three render paths (_renderTitleSibling, thecase "text"branch, and the callout branch in_renderShape) now overridedominant-baselinetoalphabeticon the text element each render. Both browsers honor alphabetic identically: the alphabetic baseline lands attext.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
_renderTitleSiblingwas 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.
_startTitleHandleDragand_startHandleDragunconditionally firedetcher:updatedand snapped geometry to the rendered (shrunk) box onpointerup, 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 fullgeometry.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
_startHandleDragonUpsnap-to-_renderedBoxis 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,
_fillTextWithWrappedTspanswrapped it onto multiple lines.actualH = measured.height + pad·2then exceeded the inputth, that taller height got persisted back intometadata.title_boxon release, the next render derived an even larger font, more lines wrapped, and the title grew exponentially per interaction (title_box.hgoing 22 → 54 → 273 → … in three drags)._renderTitleSiblingnow caps the font-size so the title fits the box width on a single line (floor of 10 px), boundingactualHto 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-enablespointer-events: visiblePaintedvia a.etcher-shape.is-editingrule — 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 arepointer-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 anonTitleflag.- New helper
_pointOnTitleOf/2for 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: visiblePaintedso 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 nowpointer-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/2to a shared_shapeContainsPoint/2helper. 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 asmetadata.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-resizecursors 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: trueflag 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.coloron each annotation; vertex + title handles inherit the shape's color viacurrentColor(no more always-orange dots). Override the palette viawindow.Etcher.colorSwatches; initial color viawindow.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 genericmetadata.{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_uuidso consumers can re-link soft-deleted child rows (e.g. comments) to the new uuid the server assigns to the recreated annotation. appendNavButtonmutable 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
:toolslist onEtcher.Layer.layer/1is 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 usefill-opacityso they tint correctly with whichever color the shape carries. - Single-shape deletes now flow through the same compound
bulk_deleteundo 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-selectedoutline when the cursor leaves it (was getting stuck visually deselected). - Tooltip
.is-hoveredno longer sticks after the cursor leaves a pinned shape.
0.1.0 — 2026-05-06
Initial release.
Added
Etcher.LayerPhoenix LiveView function component — attaches an annotation overlay to a named Fresco viewer and adds a pencil button to its nav column.Etcher.Storagebehaviour — pluggable storage adapter contract with four callbacks (create/1,list_for/2,update/2,delete/1).Etcher.Storage.Default— bundled implementation backed by theetcher_annotationstable. Reads the consumer's Repo fromconfig :etcher, repo: ….Etcher.AnnotationEcto schema for the bundled table (UUIDv7 primary key,target_type/target_uuid, four geometry kinds: rectangle, circle, polygon, freehand).mix etcher.gen.migration— generates theetcher_annotationstable migration into the consumer'spriv/repo/migrations/.- JS engine at
priv/static/etcher.js— registers theEtcherLayerLiveView hook, draws shapes as SVG overlays anchored to image coordinates, emitsetcher:created/:updated/:deleted/:selectedevents. - 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+).