Skip to content

alexdont/etcher

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Etcher

Hex.pm Hex Docs License

Etcher is the annotation layer for Fresco-based image viewers in Phoenix.

Users draw shapes (rectangle, circle, polygon, freehand) on top of any Fresco viewer; your LiveView receives geometry events; you decide what to persist. A bundled Ecto schema + migration generator covers the common case; consumers with richer needs implement a behaviour and plug in their own storage.

An etcher is the tool that incises marks into a surface — Etcher does the same digitally.

┌─────────────────────────────────────────────────────┐
│  <Fresco.viewer id="photo" src="/uploads/img.jpg"/> │
│   ┌──┐                                              │
│   │+ │  ← fresco's nav column                       │
│   │- │                                              │
│   │⟲ │                                              │
│   │⛶ │                                              │
│   │✎ │  ← added by <Etcher.layer />                 │
│   └──┘                                              │
│                                                     │
│         ┌───┐  ┌────────┐                           │
│         │   │  │        │   ← drawn annotations     │
│         │   │  │        │                           │
│         └───┘  └────────┘                           │
│                                                     │
│         [⌖] [▭] [○] [⬡] [〰] [×]   ← bottom toolbar  │
└─────────────────────────────────────────────────────┘

Installation

Add :fresco (the viewer) and :etcher to your mix.exs:

def deps do
  [
    {:fresco, "~> 0.2"},
    {:etcher, "~> 0.1"}
  ]
end

Wire the JS hooks in your assets/js/app.js:

import "../../deps/fresco/priv/static/fresco.js"
import "../../deps/etcher/priv/static/etcher.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...window.FrescoHooks, ...window.EtcherHooks, ...colocatedHooks }
})

If you want the bundled etcher_annotations table, run:

mix etcher.gen.migration
mix ecto.migrate

And point Etcher at your Repo in config/config.exs:

config :etcher, repo: MyApp.Repo

(You can skip both steps if you're implementing custom storage — see below.)

Quick start

defmodule MyAppWeb.PhotoLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <Fresco.viewer id="photo" src={~p"/uploads/photo.jpg"} class="w-full h-[80vh]" />

    <Etcher.layer
      fresco_id="photo"
      target_type="file"
      target_uuid={@file.uuid}
      initial_annotations={@annotations}
    />
    """
  end

  def handle_event("etcher:created", attrs, socket) do
    case Etcher.create_annotation(Map.put(attrs, "creator_uuid", socket.assigns.current_user.uuid)) do
      {:ok, annotation} ->
        # Reflect the persisted uuid back to the client so subsequent
        # updates/deletes can reference the saved row.
        {:noreply,
         push_event(socket, "etcher:annotation-saved", %{
           tmp_id: attrs["tmp_id"],
           uuid: annotation.uuid
         })}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, "Could not save annotation")}
    end
  end

  def handle_event("etcher:selected", %{"uuid" => uuid}, socket) do
    {:noreply, assign(socket, :selected_annotation_uuid, uuid)}
  end
end

Open the page, click the pencil in Fresco's nav column → the bottom toolbar appears with the four drawing tools. Pick rectangle, drag on the image, release — handle_event("etcher:created", …) fires with the geometry in image pixel coordinates.

The component

<Etcher.layer
  fresco_id="photo"
  target_type="file"
  target_uuid={@file.uuid}
  initial_annotations={@annotations}
  tools={[:rectangle, :circle, :polygon, :freehand]}
/>
Attr Required Notes
fresco_id yes DOM id of the <Fresco.viewer> this layer attaches to.
target_type yes What the annotation is on — "file", "document", "product", etc. Echoed back in every event.
target_uuid yes UUID of the resource being annotated.
initial_annotations no Pre-existing annotations to render on mount. Each needs at least :uuid, :kind, :geometry.
tools no Subset of drawing tools to expose. Defaults to all four.
id no DOM id of the layer host element. Defaults to "etcher-layer-<fresco_id>".

Events

The component emits four LiveView events. The consumer's LiveView handles whichever ones it cares about.

def handle_event("etcher:created", attrs, socket), do: ...
def handle_event("etcher:updated", %{"uuid" => uuid, "geometry" => geom}, socket), do: ...
def handle_event("etcher:deleted", %{"uuid" => uuid}, socket), do: ...
def handle_event("etcher:selected", %{"uuid" => uuid}, socket), do: ...

The etcher:created payload includes:

%{
  "target_type" => "file",
  "target_uuid" => "...",
  "kind" => "rectangle" | "circle" | "polygon" | "freehand",
  "geometry" => %{ ... },              # shape-specific, image-pixel coords
  "tmp_id" => "tmp-abc123-..."          # client-side temp id
}

After persisting, push back the saved uuid so the client can adopt it:

push_event(socket, "etcher:annotation-saved", %{tmp_id: tmp_id, uuid: annotation.uuid})

Geometry shapes:

kind geometry
rectangle %{"x" => x, "y" => y, "w" => w, "h" => h}
circle %{"cx" => cx, "cy" => cy, "r" => r}
polygon %{"points" => [[x1, y1], [x2, y2], ...]}
freehand %{"points" => [[x1, y1], [x2, y2], ...]}

All coordinates are in image pixels — Fresco's pan/zoom rescales them automatically.

Custom storage

Etcher.Storage is a behaviour. The default implementation is fine for most consumers, but you can swap in your own — useful when annotations need to be linked to other tables (comments, notifications, audit trails) inside the same transaction.

defmodule MyApp.AnnotationStorage do
  @behaviour Etcher.Storage

  alias MyApp.Repo
  alias MyApp.{Annotation, Comment}

  def create(attrs) do
    Repo.transaction(fn ->
      {:ok, comment} = %Comment{}
                       |> Comment.changeset(%{kind: "annotation", author_uuid: attrs.creator_uuid})
                       |> Repo.insert()

      {:ok, annotation} = %Annotation{}
                          |> Annotation.changeset(Map.put(attrs, :comment_uuid, comment.uuid))
                          |> Repo.insert()

      annotation
    end)
  end

  def list_for(target_type, target_uuid), do: ...
  def update(uuid, attrs), do: ...
  def delete(uuid), do: ...
end

Then in your LiveView:

def handle_event("etcher:created", attrs, socket) do
  {:ok, annotation} = MyApp.AnnotationStorage.create(attrs)
  # ...
end

Etcher's component doesn't run any persistence itself — it fires events and trusts the consumer. The bundled Etcher.create_annotation/1 is just a shortcut for Etcher.Storage.Default.create/1.

Customizing the tooltip

Hovering or clicking an annotation pops up a small tooltip with a trash button (for persisted shapes) and three content slots: header, footer, and body. The defaults read a few generic metadata keys and degrade to just the shape kind if those are absent, but a consumer can replace any slot with its own rendering by setting window.Etcher.tooltipSlots:

window.Etcher.tooltipSlots = {
  header: (shape) => Etcher.escapeHtml(shape.metadata.author || shape.kind),
  footer: (shape) => shape.metadata.last_edited || null,
  body:   (shape) => `<p>${Etcher.escapeHtml(shape.metadata.note || "")}</p>`
};
  • Slots are functions (shape) => string | null.
  • Returning null or undefined falls back to Etcher's default for that slot. An empty return for body / footer omits the row entirely.
  • The whole shape object is passed ({uuid, kind, geometry, style, metadata, …}) so consumers can build whatever HTML their data supports.
  • Etcher controls the wrapper, positioning, hover bridge, click-to-pin, and the trash button — slots only own content. This keeps delete + pin behavior consistent across consumers.
  • window.Etcher.escapeHtml(value) is exposed as a stable escape helper.

Default slot keys

If you don't register custom slots but want a meaningful tooltip, populate these on each annotation's metadata (server-side, in initial_annotations):

Slot Read from Fallback
header metadata.title capitalized shape.kind
body metadata.body (none — row omitted)
footer metadata.subtitle (none — row omitted)

Styling primitives

Etcher's stylesheet ships a handful of opt-in classes consumers can use inside their slot HTML for a layout consistent with the default look:

  • .etcher-tooltip-body — flex row, thumbnail on the left, text column on the right (gap: 8px, max-width: 260px)
  • .etcher-tooltip-thumb — 40×40 rounded box for an <img> or icon span
  • .etcher-tooltip-thumb-icon — modifier that centers an SVG icon inside the thumb box (paperclip-style fallback)
  • .etcher-tooltip-text — flex column container for the right-hand text
  • .etcher-tooltip-quote — italic, two-line clamp for a quoted text preview

These are entirely optional. A slot that just returns <p>plain text</p> lays out fine without any of them.

Lifecycle events

Slot APIs cover content. For interaction wiring the existing LiveView events still fire:

  • etcher:selected {uuid} on click (also pins the tooltip)
  • etcher:deleted {uuid} when the user hits the trash button

etcher:tooltip-show / -hide / -pin events would be a natural follow-up if a consumer needs them; not in v0.1.

Hooks reference

All extension points beyond the LiveView events listed above. None are required — Etcher works with zero configuration.

window.Etcher.colorSwatches — palette override

Replace the bundled pastel rainbow + monochrome bookends with your own swatches:

window.Etcher.colorSwatches = [
  { key: "brand",   color: "#ff6f00", title: "Brand orange" },
  { key: "muted",   color: "#9ca3af", title: "Muted gray" },
  { key: "ink",     color: "#0f172a", title: "Ink" }
];

Falls back to the default palette if unset or not an array.

window.Etcher.defaultColor — initial active color

Override which swatch starts pre-selected when annotation mode opens:

window.Etcher.defaultColor = "#ff6f00";

Falls back to the "blue" swatch in the active palette (back-compat) or the first swatch.

window.Etcher.layerFor(frescoId) — programmatic control

Returns the layer's control surface, or null if no layer is mounted for that fresco id. Lets you drive Etcher from outside (URL handlers, keyboard shortcuts, command palettes):

const layer = window.Etcher.layerFor("photo");
if (layer) {
  layer.setMode(true);           // enter annotation mode (toolbar opens)
  layer.exitDrawing();           // back to cursor (annotation mode stays on)
  layer.selectShape("uuid-…");   // pin the tooltip for that shape
  const shapes = layer.getShapes();
  // → [{ uuid, kind, geometry, style, metadata }, ...]
}

Lifecycle DOM events

Etcher dispatches bubbling CustomEvents on the layer's host element so consumer JS can react without reaching into the hook. Listen on the host or any ancestor:

document.addEventListener("etcher:tooltip-show", (e) => {
  console.log("Tooltip showing for", e.detail.uuid, "at", e.detail.anchor);
});
Event detail When
etcher:tooltip-show { uuid, anchor: {x, y} } Tooltip rendered (hover or pin)
etcher:tooltip-hide { uuid } Tooltip closes (hover-away timeout or pin dismissed)
etcher:tooltip-pin { uuid } User clicked a shape to pin its tooltip
etcher:tooltip-unpin { uuid } User clicked elsewhere / re-clicked to unpin
etcher:mode-changed { annotationMode: bool } User toggled annotation mode
etcher:tool-changed { tool: string | null } User picked a drawing tool (null = cursor)
etcher:color-changed { color: string } User picked a swatch

Server → client LiveView events

In addition to the create / update / delete / selected client→server events documented above, the server can push state into a running viewer via Phoenix.LiveView.push_event/3:

Event Payload Behavior
etcher:annotation-saved { tmp_id, uuid } Client adopts the persisted uuid for a temp shape
etcher:annotation-added { uuid, kind, geometry, style?, metadata? } Renders a new shape locally (collaboration / external create)
etcher:annotation-updated { uuid, metadata } Merges fresh tooltip metadata into an existing shape
etcher:annotation-removed { uuid } Removes a shape from the overlay
etcher:exit-drawing {} Switches to cursor mode (annotation mode stays on)

window.Etcher.escapeHtml(value) — escape helper

Stable helper exposed for use inside consumer slot functions. HTML-escapes &, <, >, ", '.

How it fits with Fresco

Etcher uses Fresco 0.2's handle.appendNavButton/3 extension point to add the pencil button — no other extension surface required. Drawing input is delivered as plain pointerdown / pointermove / pointerup events on an SVG overlay anchored to Fresco's image coordinate space, so shapes stay locked to image pixels through pan and zoom.

Out of scope (for now)

  • Editing existing shapes after commit (drag handles, vertex move). v0.1 is draw-and-commit; to change a shape, delete and redraw.
  • Touch + pinch gesture coexistence with Fresco's pan/zoom — annotation mode currently disables Fresco's drag-to-pan; refinement comes later.
  • Custom tools beyond the four built-ins. The geometry kind is a string, so adding a new kind is straightforward; the toolbar wiring isn't pluggable yet.
  • Annotation export / import in W3C Web Annotation Data Model JSON-LD.

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors