PhoenixKitWeb.Components.MediaCanvasViewer (phoenix_kit v2.41.0)

Copy Markdown View Source

Per-file viewer LiveComponent: <Fresco.canvas> + <Etcher.layer> for images, video / PDF / icon fallback for other types, plus a sidebar with filename + Download + metadata + comments thread. Owns the annotation lifecycle (composer popover, persistence sync via EtcherAdapter, the patch-shape / delete-shape JS bridges).

Shared between MediaBrowser (admin file grid → click image → modal) and MediaViewer (lightbox embedded by MediaGallery and other consumers). Both parents own their own modal shell + prev/next navigation; this component is just the per-file content.

Required assigns

  • :id — DOM id, must include the file uuid so the parent's prev/next navigation remounts the component on file change (and Fresco's phx-update="ignore" canvas DOM is replaced wholesale with the new file's image).
  • :file — curated file map (%{file_uuid, mime_type, urls, filename, file_type, size, inserted_at, ...}). Same shape MediaBrowser's uploaded_files carries.
  • :current_user — logged-in user; nil-tolerant. When nil the composer + comments thread don't render (no user to attribute a comment to).
  • :parent_id — DOM id of the outer LiveComponent (the modal host). Currently unused by this component, but kept on the assigns map so future cross-component send_updates have a target without re-plumbing the API.

A board instead of a file

Pass :board (and no :file) to draw on an EMPTY canvas — a projects whiteboard, say: %{target_type: "projects_whiteboard", target_uuid: board.uuid, width: 1920, height: 1080, background: nil}. Fresco renders the scene with zero images (infinite_canvas), Etcher draws over it, and every shape persists against the target pair instead of a file (V183). No file means no rotation, no thumbnail job, no comment thread and no sidebar — the board is the canvas and its tools. The id should include the target uuid for the same remount reason as a file.

Optional assigns

  • :can_annotate (default true) — whether THIS viewer may change the drawings. false is read-only: every shape arrives locked (Etcher's readonly, so it renders and tooltips but cannot be moved, resized, deleted or edited), the drawing toolbar and the pencil are gone, and the server refuses etcher:annotations-changed / etcher:shape-drawn outright — Etcher's flag is UX, this is the boundary. Hosts pass their own write permission (a project member's can_write); the default keeps every existing host as it was.
  • :viewer_only (default false) — render only the canvas / composer column; suppresses the close button and the sidebar (filename + Download + metadata + comments). Used by standalone-page hosts like MediaDetail that have their own surrounding chrome (admin actions, metadata editor, file details).
  • :edit_target (default nil) — {module, id} of a LiveComponent that opens the image editor. When set, the sidebar of an editable image shows "Edit image", which sends it send_update(module, id: id, open_image_editor: file_uuid). MediaBrowser passes itself.
  • :write_scope (default nil) — the host's scope folder uuid. What this component writes onto the FILE row (title/description, a persisted rotation) shows in every folder holding the file, so a scoped host must not write a file that is merely linked into its scope — a content duplicate someone else uploaded first is theirs. With a scope set, those writes need the file's HOME folder inside it (Storage.within_scope?/2); the editor is not offered otherwise and a rotation stays view-only. nil means unscoped. MediaBrowser passes its own scope, the rule its grid rotation already follows.
  • :details_path (default nil) — when set, the sidebar shows an "Open details page" button navigating to this path. Admin-context hosts (MediaBrowser with admin={true}) pass the file's /admin/media/:uuid page; public hosts leave it off.
  • :featured (default nil) — mirrors MediaBrowser's own :featured attr (nil = off, or %{uuid: uuid | nil}). When set and the open file is an image, the sidebar shows a "Set as featured" / "Unset featured" button that posts "set_featured" / "unset_featured" straight to :parent_id (same phx-target routing the close/prev/next buttons use) — this component does not own the pointer and has no event handler of its own for it.

Summary

Functions

Build the comment_decorations registry that CommentsComponent uses to render external labels above comments. The comments package speaks a generic %{metadata_key => %{value => entry}} vocabulary; we package annotation titles under the "annotation_uuid" metadata key (matching the back-reference comments already store via metadata["annotation_uuid"]).

Runtime check for whether the optional phoenix_kit_comments package is installed AND enabled. Public so standalone-page hosts can gate their own embed of CommentsComponent the same way the sidebar does.

Loads annotations for a file into the curated map shape this component uses internally (uuid / kind / geometry / style / metadata with comment_* + title injected). Public so standalone-page hosts like MediaDetail can build their own decoration registry off the same shape without re-querying the schema by hand.

load_annotations_for/1 for any target: a file goes through the comment-preview loader; any other target has no comment threads, so its rows carry the zero-comment shape (badge 0, no preview).

The same shape, for a file nobody is looking at yet.

The picture's shape as a CSS aspect-ratio value, or nil for a file that has no shape to give.

Callback implementation for Phoenix.LiveComponent.render/1.

Whether the viewer's info sidebar will be open for this user.

Functions

build_comment_decorations(annotations)

Build the comment_decorations registry that CommentsComponent uses to render external labels above comments. The comments package speaks a generic %{metadata_key => %{value => entry}} vocabulary; we package annotation titles under the "annotation_uuid" metadata key (matching the back-reference comments already store via metadata["annotation_uuid"]).

Source for the label: load_annotations_for/1 projects the schema's title column into metadata["title"] (so Etcher's JS tooltip can read it as a single map). We read from the same place rather than bolt a top-level field onto the curated annotation map. Only entries with non-empty titles are included; an annotation without a title contributes nothing (and the comment renders without a label block).

Public so standalone-page hosts (MediaDetail) can build the same registry against the annotations they loaded via load_annotations_for/1.

comments_enabled?()

Runtime check for whether the optional phoenix_kit_comments package is installed AND enabled. Public so standalone-page hosts can gate their own embed of CommentsComponent the same way the sidebar does.

load_annotations_for(file_uuid)

Loads annotations for a file into the curated map shape this component uses internally (uuid / kind / geometry / style / metadata with comment_* + title injected). Public so standalone-page hosts like MediaDetail can build their own decoration registry off the same shape without re-querying the schema by hand.

load_annotations_for_target(target_type, file_uuid)

load_annotations_for/1 for any target: a file goes through the comment-preview loader; any other target has no comment threads, so its rows carry the zero-comment shape (badge 0, no preview).

neighbor_pane_aspect(file)

The same shape, for a file nobody is looking at yet.

Stepping prev/next inside the viewer repaints the instant stand-in with the neighbour's bitmap, and the popup is sized to the picture now — so a neighbour whose shape nobody announced would hand over by resizing the whole box. The browser reads these off the modal's dataset; rotation comes from the row, since no viewer has loaded that file's metadata yet.

pane_aspect(file, rotation \\ 0)

The picture's shape as a CSS aspect-ratio value, or nil for a file that has no shape to give.

The stacked layout sizes the picture pane to this instead of to a share of the popup's height. A 1080×2840 window makes the popup 2556px tall and a landscape photograph needs about 480 of them: the rest was empty canvas above and below the picture, with the details panel pushed to the far bottom of a popup the picture had no use for. Sized to the picture, the pane asks for the height it needs, the panel sits directly under it, and the popup is as tall as the two of them together.

Rotation is part of the shape. A quarter turn swaps the axes, so the pane has to turn with it or a sideways photograph gets a pane the wrong way round — taller than the picture and narrower than it needs. A half turn leaves the shape alone.

nil for anything with no dimensions recorded — a PDF, an audio file, a picture whose probe failed — and the pane falls back to a fixed share of the popup, which is what every file used to get.

render(assigns)

Callback implementation for Phoenix.LiveComponent.render/1.