Media Editor
alphav0.3.1Media capture and edit surface for photo, video, and text — capability dials and an Instagram-style chrome model.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/media-editorAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/media-editor-fixturespnpm dlx shadcn@latest add @ilinxa/media-editor-captureCamera photo and video capture with permission flows, shutter controls, and multi-instance guard.
CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}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
"use client"; import * as React from "react";import { MediaEditor } from "./media-editor";// Capture feature slice (P3 S3) — the docs site demonstrates the full// composed component (base + capture), so tabs with camera-eligible// mediaSources wire the extension. Base-alone behavior (no capture, file/// gallery intake fallback) is exercised separately below in `NoCaptureDemo`.import { mediaCapture } from "./features/capture";import { SAMPLE_BRAND_STICKERS, SAMPLE_CHAT_IMAGE_URL, SAMPLE_CHAT_INITIAL_SOURCE, SAMPLE_HERO_INITIAL_SOURCE,} from "./dummy-data";import type { InitialSource, MediaEditorHandle, SourceError,} from "./types"; /** * media-editor demo (C12) — six tabs covering the principal * consumer surfaces (incl. the P3 no-capture fallback): * * - Defaults — bare editor with all default capabilities. * - News-hero — 16:9 + editorial tools, hero re-edit via initialSource. * - Chat — 9:16 dialog mode, photo/video only, full-tools. * - Edit-only — enabledModes:[] + initialSource, exercises URL / blob / * file paths + the four documented SourceError kinds. * - Dark — Defaults wrapped in a `dark` scope to spot-check the * graphite-cool dark surface. * - No-capture — base-alone (P3 S3): camera requested via `mediaSources` * but NO `capture` extension wired — proves the file/ * gallery intake fallback (pick file → edit → export) plus * the one dev console.warn. */type Tab = "defaults" | "news-hero" | "chat" | "edit-only" | "dark" | "no-capture"; const TABS: { value: Tab; label: string }[] = [ { value: "defaults", label: "Defaults" }, { value: "news-hero", label: "News-hero" }, { value: "chat", label: "Chat" }, { value: "edit-only", label: "Edit-only" }, { value: "dark", label: "Dark" }, { value: "no-capture", label: "No-capture (base-alone)" },]; export default function MediaEditorDemo() { const [tab, setTab] = React.useState<Tab>("defaults"); return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap gap-1 self-start rounded-md border border-border bg-muted/30 p-1"> {TABS.map(({ value, label }) => ( <button key={value} type="button" className={ "rounded-sm px-3 py-1 text-xs font-medium " + (tab === value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground") } onClick={() => setTab(value)} > {label} </button> ))} </div> {tab === "defaults" ? ( <DefaultsDemo /> ) : tab === "news-hero" ? ( <NewsHeroDemo /> ) : tab === "chat" ? ( <ChatDemo /> ) : tab === "edit-only" ? ( <EditOnlyDemo /> ) : tab === "dark" ? ( <DarkDemo /> ) : ( <NoCaptureDemo /> )} </div> );} // ─── Defaults ───────────────────────────────────────────────────────── function DefaultsDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); return ( <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Default capabilities — all three modes, all six edit tools, both media sources, free aspect. The only override is{" "} <code>presentation="inline"</code> so the docs surface renders on tab switch; auto-resolve would otherwise pick <code>dialog</code>{" "} for capture-enabled instances and need an <code>isOpen</code> handler. </p> <MediaEditor ref={editorRef} presentation="inline" capture={mediaCapture} /> <DemoActions editorRef={editorRef} /> </div> );} // ─── News-hero ──────────────────────────────────────────────────────── function NewsHeroDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); return ( <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> CMS hero re-edit: a previously-uploaded image lands in the editor at{" "} <code>aspect="16:9"</code> with only the editorial edit tools (<code>text</code>, <code>filters</code>, <code>adjust</code>, <code>crop</code>) — no stickers, no drawing. <code>enabledModes</code> is empty so the capture surface is gone. </p> <MediaEditor ref={editorRef} aspect="16:9" presentation="inline" enabledModes={[]} enabledTools={["text", "filters", "adjust", "crop"]} initialSource={SAMPLE_HERO_INITIAL_SOURCE} stickers={[SAMPLE_BRAND_STICKERS]} /> <DemoActions editorRef={editorRef} /> </div> );} // ─── Chat ───────────────────────────────────────────────────────────── function ChatDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); const [isOpen, setIsOpen] = React.useState(false); return ( <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> DM-style: tap <em>Open</em> to launch a 9:16 dialog editor for capture + edit + send. <code>enabledModes</code> excludes{" "} <code>text</code> (text-mode stories don't fit chat UX); all six edit tools available. </p> <button type="button" className="self-start rounded-md border border-border bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90" onClick={() => setIsOpen(true)} > Open editor </button> <MediaEditor ref={editorRef} aspect="9:16" presentation="dialog" enabledModes={["photo", "video"]} capture={mediaCapture} isOpen={isOpen} onClose={() => setIsOpen(false)} stickers={[SAMPLE_BRAND_STICKERS]} /> </div> );} // ─── Edit-only ────────────────────────────────────────────────────────//// Exercises the C9 initialSource intake + C10 export path. Switches between// 4 source presets to surface the SourceError surface for the three// documented validation failures. type EditOnlyPreset = | "url-photo" | "url-mode-mismatch" | "blob-photo" | "file-unsupported"; function EditOnlyDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); const [preset, setPreset] = React.useState<EditOnlyPreset>("url-photo"); const [lastError, setLastError] = React.useState<SourceError | null>(null); const [exportPreview, setExportPreview] = React.useState<{ url: string; size: number; mime: string; } | null>(null); const [exportProgress, setExportProgress] = React.useState<number | null>( null, ); const [exportFormat, setExportFormat] = React.useState< "image/jpeg" | "image/png" | "image/webp" >("image/jpeg"); React.useEffect(() => { if (!exportPreview) return; return () => URL.revokeObjectURL(exportPreview.url); }, [exportPreview]); // Build a tiny PNG blob lazily (1×1 white pixel) for the "blob-photo" case. const blobSource = React.useMemo<InitialSource>(() => { const bytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const blob = new Blob([bytes], { type: "image/png" }); return { kind: "blob", blob, mode: "photo" }; }, []); const fileSource = React.useMemo<InitialSource>(() => { const file = new File(["not a photo"], "note.txt", { type: "text/plain" }); return { kind: "file", file }; }, []); const initialSource = React.useMemo<InitialSource>(() => { switch (preset) { case "url-photo": return SAMPLE_CHAT_INITIAL_SOURCE; case "url-mode-mismatch": return { kind: "url", url: SAMPLE_CHAT_IMAGE_URL, mode: "video", }; case "blob-photo": return blobSource; case "file-unsupported": return fileSource; } }, [preset, blobSource, fileSource]); // url-mode-mismatch: enable only photo while source declares video → fires // mode-not-enabled. Everything else stays edit-only. const enabledModes = preset === "url-mode-mismatch" ? ["photo" as const] : []; const handleExport = async () => { if (!editorRef.current) return; setExportProgress(0); try { const { blob, metadata } = await editorRef.current.exportImage({ format: exportFormat, quality: 0.9, onProgress: setExportProgress, }); const url = URL.createObjectURL(blob); setExportPreview({ url, size: blob.size, mime: blob.type }); console.log("export metadata:", metadata); } catch (e) { console.error("export failed:", e); } finally { setExportProgress(null); } }; return ( <div className="flex flex-col gap-3"> <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-xs"> <div className="flex flex-wrap items-center gap-2"> <span className="w-28 shrink-0 font-medium text-muted-foreground"> initialSource </span> {( [ ["url-photo", "URL · photo (happy path)"], ["blob-photo", "Blob · photo"], ["url-mode-mismatch", "URL · mode mismatch"], ["file-unsupported", "File · unsupported type"], ] as const ).map(([value, label]) => ( <Chip key={value} active={preset === value} onClick={() => { setPreset(value); setLastError(null); }} > {label} </Chip> ))} </div> <p className="text-[11px] text-muted-foreground"> Edit-only path:{" "} <code>enabledModes: {JSON.stringify(enabledModes)}</code> ·{" "} <code>presentation="inline"</code>. Capture surface is gone; the editor lands directly in <code>stage: "edit"</code>{" "} with the source pre-loaded. </p> {lastError ? ( <p className="text-[11px] text-destructive"> onInitialSourceError fired:{" "} <code>{JSON.stringify(lastError, replaceErrors)}</code> </p> ) : null} </div> <MediaEditor ref={editorRef} aspect="free" presentation="inline" enabledModes={enabledModes} initialSource={initialSource} onInitialSourceError={setLastError} stickers={[SAMPLE_BRAND_STICKERS]} /> <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-xs"> <div className="flex flex-wrap items-center gap-2"> <span className="w-28 shrink-0 font-medium text-muted-foreground"> Export format </span> {(["image/jpeg", "image/png", "image/webp"] as const).map((fmt) => ( <Chip key={fmt} active={exportFormat === fmt} onClick={() => setExportFormat(fmt)} > {fmt.replace("image/", "")} </Chip> ))} </div> <div className="flex flex-wrap gap-2"> <button type="button" disabled={exportProgress !== null} className="rounded-md border border-border bg-primary px-3 py-1.5 font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" onClick={handleExport} > {exportProgress === null ? `Export as ${exportFormat.replace("image/", "")}` : `Exporting… ${Math.round((exportProgress ?? 0) * 100)}%`} </button> <button type="button" className="rounded-md border border-border bg-muted/40 px-3 py-1.5 hover:bg-muted" onClick={() => { console.log("getState:", editorRef.current?.getState()); console.log("isDirty:", editorRef.current?.getIsDirty()); console.log("mode:", editorRef.current?.getMode()); }} > Log state </button> <button type="button" className="rounded-md border border-border bg-muted/40 px-3 py-1.5 hover:bg-muted" onClick={() => { setExportPreview(null); editorRef.current?.reset(); }} > Reset </button> </div> {exportPreview ? ( <div className="flex flex-col gap-2"> <p className="text-[11px] text-muted-foreground"> Exported{" "} <code>{Math.round(exportPreview.size / 1024)} KB</code> ·{" "} <code>{exportPreview.mime}</code> </p> <img src={exportPreview.url} alt="Export preview" className="max-h-48 max-w-xs rounded-md border border-border bg-card object-contain" /> </div> ) : null} </div> </div> );} // ─── Dark ───────────────────────────────────────────────────────────── function DarkDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); return ( <div className="dark flex flex-col gap-3 rounded-xl border border-border bg-background p-4 text-foreground"> <p className="text-xs text-muted-foreground"> Same as Defaults but scoped to the <code>dark</code> token surface. Verifies the graphite-cool dark palette from{" "} <code>globals.css</code> holds across the editor chrome + canvas placeholder + toolbar. </p> <MediaEditor ref={editorRef} presentation="inline" capture={mediaCapture} /> <DemoActions editorRef={editorRef} /> </div> );} // ─── No-capture (base-alone) ───────────────────────────────────────────//// P3 S3 invariant: mediaSources includes "camera" but NO capture extension// is passed — media-editor falls back to the base file/gallery intake// surface (one dev console.warn logged to the browser console) instead of a// dead camera surface. Pick a file → edit → export still works end to end. function NoCaptureDemo() { const editorRef = React.useRef<MediaEditorHandle>(null); return ( <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Same capability dials as Defaults, but no <code>capture</code> prop — the shape a consumer who installed <code>@ilinxa/media-editor</code>{" "} WITHOUT <code>@ilinxa/media-editor-capture</code> is in.{" "} <code>mediaSources</code> still includes <code>"camera"</code>, so the editor falls back to file/gallery intake (check the console for the one dev-warn) — pick a file, then edit and export it like normal. </p> <MediaEditor ref={editorRef} presentation="inline" /> <DemoActions editorRef={editorRef} /> </div> );} // ─── Shared helpers ─────────────────────────────────────────────────── function DemoActions({ editorRef,}: { editorRef: React.RefObject<MediaEditorHandle | null>;}) { return ( <div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <button type="button" className="rounded-md border border-border bg-muted/40 px-3 py-1.5 hover:bg-muted" onClick={() => editorRef.current?.applyFilter("clarendon")} > Apply Clarendon </button> <button type="button" className="rounded-md border border-border bg-muted/40 px-3 py-1.5 hover:bg-muted" onClick={() => editorRef.current?.reset()} > Reset </button> <button type="button" className="rounded-md border border-border bg-muted/40 px-3 py-1.5 hover:bg-muted" onClick={() => { console.log("getState:", editorRef.current?.getState()); console.log("isDirty:", editorRef.current?.getIsDirty()); }} > Log state </button> </div> );} function Chip({ active, onClick, children,}: { active: boolean; onClick: () => void; children: React.ReactNode;}) { return ( <button type="button" onClick={onClick} className={ "rounded-md border px-2 py-0.5 transition-colors " + (active ? "border-foreground/40 bg-foreground/10 text-foreground" : "border-border bg-muted/30 text-muted-foreground hover:bg-muted") } > {children} </button> );} // JSON.stringify replacer that unwraps Error instances for printable output.function replaceErrors(_key: string, value: unknown): unknown { if (value instanceof Error) { return { name: value.name, message: value.message }; } return value;} 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 withinitialSource).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 nocaptureextension — 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 }— editorfetch()es the URL. Same-origin or CORS-friendly only; CORS failure surfaces viaonInitialSourceErroras{ 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 fromfile.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
- v0.3.1 — `handle.open()` dev-warns instead of failing silently (dialog mode is controlled, so set `isOpen` on the component)
- 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; a library source is not implemented)
- 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 — not implemented)
- 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.