Skip to content
ilinxa/pro-ui

Media Editor

alphav0.3.0

Media capture and edit surface for photo, video, and text — capability dials and an Instagram-style chrome model.

Category: MediaUpdated: 2026-08-11Created: 2026-06-02Author: ilinxa

Context

The reusable Konva-based editor lifted out of story-composer v0.1.5. Four orthogonal capability dials (enabledModes, enabledTools, mediaSources, aspect) plus initialSource intake and inline/dialog presentation let consumers pull as little or as much editor surface as their context needs. The chrome follows an Instagram model: mode tabs appear only in the capture stage and are replaced by a back-to-capture arrow once a draft exists; bottom edit tools overlay a full-bleed canvas; the canvas drag-pans with a single pointer (plus 2-finger / wheel / keyboard zoom). Story-composer-01 v0.2.0 is a thin wrapper around this. content-composer, chat-panel attachments, and CMS hero editors are downstream consumers.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/media-editor

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/media-editor-fixtures
Optional feature slicesInstall on top of the base for opt-in heavier capability.
pnpm dlx shadcn@latest add @ilinxa/media-editor-capture

Camera photo and video capture with permission flows, shutter controls, and multi-instance guard.

Preview

Default capabilities — all three modes, all six edit tools, both media sources, free aspect. The only override is presentation="inline" so the docs surface renders on tab switch; auto-resolve would otherwise pick dialog for capture-enabled instances and need an isOpen handler.

Pick a mode above, or jump straight to the camera.

Demo source

demo.tsxtsx

Usage

When to use

MediaEditor is the reusable capture + edit surface underneath StoryComposer. Reach for it directly when you need an Instagram-style editor for a non-story context — a CMS hero re-edit, a chat attachment editor, the second step of a multi-step content composer. Four capability dials (enabledModes / enabledTools / mediaSources / aspect) plus inline/dialog presentation let you pull as little or as much editor surface as your context needs. Story-composer-01 is a thin wrapper around this in v0.2.0.

Quick start

import { useRef, useState } from "react"
import { MediaEditor, type MediaEditorHandle } from "@/components/media-editor"

export function Example() {
  const editorRef = useRef<MediaEditorHandle>(null)
  const [open, setOpen] = useState(false)

  return (
    <>
      <button onClick={() => setOpen(true)}>Edit photo</button>
      <MediaEditor
        ref={editorRef}
        aspect="9:16"
        presentation="dialog"
        isOpen={open}
        onClose={() => setOpen(false)}
        enabledModes={["photo", "video"]}
      />
    </>
  )
}

Capability dials

Four orthogonal props that gate the editor surface. Defaults are the maximal configuration.

  • enabledModes — array of "photo" / "video" / "text". Empty array = no capture surface (pure-edit; pair with initialSource).
  • enabledTools — array of "text" / "draw" / "stickers" / "filters" / "adjust" / "crop". Filters the toolbar and skips the corresponding layer at export.
  • mediaSources — array of "camera" / "upload". Without camera (or with camera but no capture extension — see below) you get the file/ gallery intake surface.
  • aspect"9:16" / "1:1" / "16:9" / "4:5" / "free". Locks the canvas aspect ratio and the default crop choice.

Camera capture (opt-in slice)

Camera capture (getUserMedia + MediaRecorder, permission flows, shutter, multi-instance guard) ships as a separate registry item — @ilinxa/media-editor-capture — not part of the base install. Install it and pass its export through the capture prop:

import { MediaEditor } from "@/components/media-editor"
import { mediaCapture } from "@/components/media-editor/features/capture"

<MediaEditor mediaSources={["camera", "upload"]} capture={mediaCapture} />

Without it, "camera" in mediaSources falls back to the base file/gallery intake surface (a button + hidden file input) — one dev console.warnfires, and the editor stays fully usable: pick a file → edit → export. Base-alone never pulls konva/ react-konva-adjacent camera weight it doesn't need.

Initial source (CMS re-edit / draft restore)

Pass initialSource to skip the capture surface and land directly in the edit canvas with the source pre-loaded. Three accepted shapes:

  • { kind: "url", url, mode } — editor fetch()es the URL. Same-origin or CORS-friendly only; CORS failure surfaces via onInitialSourceError as { kind: "cors" }.
  • { kind: "blob", blob, mode } — consumer-owned blob. Escape hatch for non-CORS URLs (pre-fetch on the server, pass the Blob through).
  • { kind: "file", file } — mode auto-detected from file.type (image/* → photo, video/* → video; anything else fires { kind: "unsupported-file-type" }).

The resolved mode must be a member of enabledModes; a mismatch fires { kind: "mode-not-enabled" }.

Presentation: inline / dialog / auto

inline renders bare in the parent layout — used by step-2-of-multi-step composers and CMS hero editors. dialog wraps in shadcn dialog (mobile-fullscreen / desktop-modal sized by aspect) — used by story-composer, chat-panel.

auto picks inline when enabledModes is empty (pure-edit context, no capture chrome to manage), otherwise dialog. Dialog mode requires isOpen + onClose — a dev-only console.error fires if either is missing.

Export

Call the imperative handle from your publish flow. Each method returns { blob, metadata }.

// Photo / text-mode → image. Default: image/jpeg, quality 0.9.
const { blob, metadata } = await editorRef.current.exportImage({
  format: "image/jpeg",        // or "image/png" | "image/webp"
  quality: 0.9,
  onProgress: (p) => …,        // fires (0) at start, (1) on completion
})

// Video — perf-shortcut returns the raw blob when no overlays have
// been added; otherwise re-encodes through MediaRecorder with the
// Konva overlay baked in per frame.
const out = await editorRef.current.exportVideo({
  onProgress: (p) => …,        // ~10 ticks across the re-encode
})

// Polymorphic — dispatches on the current mode.
const out = await editorRef.current.export()

Imperative handle

22 methods total — inspect (getIsDirty, getMode, getState, loadState), capture (switchCamera, takePhoto, startRecording, stopRecording, importFromGallery), edit (addText, addSticker, setAdjustments, applyFilter, clearLayer, undo, redo), export (exportImage, exportVideo, export), and lifecycle (reset, open, close).

More

Full reference, the capture-vs-edit chrome model, pan & zoom gestures, accessibility notes, and integration patterns live in docs/procomps/media-editor-procomp/media-editor-procomp-guide.md.

Features

  • Controllable capture modes (photo / video / text) gated by `enabledModes`
  • Controllable edit tools (text / draw / stickers / filters / adjust / crop) gated by `enabledTools`
  • Aspect lock (9:16 / 1:1 / 16:9 / 4:5 / free) for export + canvas
  • Capture-vs-edit chrome: mode tabs are capture-only and swap to a back-to-capture arrow in the edit stage; bottom edit tools overlay a full-bleed canvas (IG-style scrim)
  • Single-pointer drag-to-pan on the canvas, plus 2-finger / wheel / keyboard zoom (container-yields to draggable text/sticker overlays)
  • Container-query-sized capture controls + min-size floor (dialog clamp / inline min-h) so the surface never collapses or overflows
  • Media-source intake (camera + upload; library deferred to v0.2)
  • Inline / dialog / auto presentation — auto picks dialog if capture enabled, else inline
  • Imperative ref handle: inspect / state / edit-overlay / export wired (imperative *capture* methods dev-warn — deferred to v0.2)
  • Initial-source intake (URL / Blob / File) — skips capture surface for re-edit workflows
  • Polymorphic export() with format dispatch (jpeg / png / webp) + onProgress callback
  • Video-export perf shortcut: skips MediaRecorder re-encode when nothing has been overlaid on the source
  • Multi-instance dev-warn guard for camera contention
  • Sealed-folder parts exported (EditorCanvas / EditorToolbar / ColorSwatchPicker / DiscardConfirmDialog) for advanced composition
  • v0.1.5 — F-cross-13 path-b sweep: ModeTogglePill swaps shadcn ToggleGroup for a plain-button segmented control (Radix single-string vs Base-UI string[] value model; filter-panel 0.1.1 / event-calendar v0.2.1 precedent); pill styling + re-tap-noop semantics preserved. Zero public-API change.
  • v0.3.0 — P3 feature-slicing: camera capture (getUserMedia/MediaRecorder, permission flows, shutter, multi-instance guard) split into the opt-in `@ilinxa/media-editor-capture` slice, wired in via `capture={mediaCapture}` (injection extension, not a static import). Base-alone now ships a real file/gallery intake surface (pick a file → edit → export) instead of a placeholder; the konva edit canvas gained a `React.lazy` boundary.

Tags

media-editorimage-editorvideokonvacamerafiltersstickerseditor

Dependencies

shadcn primitives: dialog
npm peer deps: konva@^10.3.0, react-konva@^19.2.4, lucide-react@^1.11.0