ilinxa
blockmedia-editorsource: ok · hit
Media Editor
Media capture and edit surface for photo, video, and text — capability dials and an Instagram-style chrome model.
npx shadcn add "https://ui.ilinxa.com/r/media-editor.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
dialogalert-dialogpopoverbuttonslider
Files35 · source cached 10/8/2026, 9:05:16 PM
src/registry/components/media/media-editor/media-editor.tsx
"use client";
import * as React from "react";
import type Konva from "konva";
import { ArrowLeft, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { useMediaEditorState } from "./hooks/use-media-editor-state";
import { useCaptureExtensionOptional } from "./hooks/use-capture-extension";
import { resolvePresentation } from "./lib/presentation-resolver";
import { dialogDimsForAspect } from "./lib/dialog-size-for-aspect";
import { resolveCropAspects } from "./lib/resolve-crop-aspects";
import { loadInitialSource } from "./lib/initial-source-loader";
import { exportPhotoBlob, exportTextOnlyBlob } from "./lib/export-blob";
import { compositeVideo } from "./lib/composite-video";
import { validateGalleryFile } from "./lib/validate-media-file";
import { DiscardConfirmDialog } from "./parts/discard-confirm-dialog";
import { TextOnlyCanvas } from "./parts/text-only-canvas";
import { ToolAdjustSliders } from "./parts/tool-adjust-sliders";
import { ToolDrawControls } from "./parts/tool-draw-controls";
import { ToolFilterStrip } from "./parts/tool-filter-strip";
import { ToolStickerPicker } from "./parts/tool-sticker-picker";
import { ToolTextInput } from "./parts/tool-text-input";
import { resolveFilterPresets } from "./lib/konva-filters";
import { resolveStickerSets } from "./lib/built-in-stickers";
import {
DEFAULT_COLOR_PRESETS,
DEFAULT_FONTS,
DEFAULT_TEXT_GRADIENTS,
} from "./lib/defaults";
import { useHistory } from "./hooks/use-history";
import type {
AspectRatio,
ComposerMode,
EditAction,
EditTool,
ExportImageOpts,
ExportMetadata,
ExportOpts,
ExportVideoOpts,
ImageAdjustments,
LoadStateOpts,
MediaEditorHandle,
MediaEditorProps,
MediaEditorState,
SourceError,
StickerOption,
StoryComposerLabels,
ValidationError,
} from "./types";
import type {
CapturedPhotoLike as CapturedPhoto,
CapturedVideoLike as CapturedVideo,
} from "./hooks/use-capture-extension";
// Lazy-load the konva canvas (compound-component-structure rule — heavy dep
// boundary). Module-scoped (not inside the component) so the lazy factory's
// identity is stable across renders. `parts/lazy-editor-canvas` is a
// same-folder default-export wrapper — EditorCanvas itself is a named
// export and React.lazy needs a default one.
const LazyEditorCanvas = React.lazy(() => import("./parts/lazy-editor-canvas"));
/**
* media-editor — black-box orchestrator.
*
* Owns the editor state machine, capability gating
* (enabledModes/Tools/Sources/aspect), presentation (inline / dialog / auto),
* initial-source intake, the Konva editor surface (camera surface / EditorCanvas /
* tool panels), export pipeline, and the imperative ref handle.
*
* P3 S3 (feature-slicing): the camera capture surface is no longer statically
* imported here — it's injected via the optional `capture` prop
* (`MediaCaptureExtension`, see `hooks/use-capture-extension.ts`). Install
* `@ilinxa/media-editor-capture` and pass `capture={mediaCapture}` to enable
* a live camera; without it, "camera" in `mediaSources` falls back to the
* base file/gallery intake surface (one dev console.warn) — base-alone stays
* a real pick-file → edit → export flow. `MediaEditor` itself is a thin
* wrapper that mounts `props.capture.Provider` (when present) around
* `MediaEditorImpl`, which holds all the logic below and reads the
* extension back out via `useCaptureExtensionOptional()`.
*
* Known v0.1.x gap: the imperative *capture* handle methods (takePhoto,
* startRecording, stopRecording, switchCamera, importFromGallery) are deferred
* to v0.2 — they dev-warn if called. The in-UI camera controls are the
* supported capture path in v0.1.x; everything else on the handle (inspect /
* state / export / edit-overlay mutation) is fully wired.
*/
const NOT_IMPLEMENTED_MARKER =
"media-editor: imperative capture (takePhoto / startRecording / stopRecording / switchCamera / importFromGallery) is not implemented — drive capture via the in-UI controls instead.";
const NOT_WIRED_HISTORY_MARKER =
"media-editor: undo/redo is not wired in v0.1.x (no mutations are recorded as commands yet) — no-op. The per-mutation command layer lands in v0.2.";
// English fallback for the v0.1.5-shaped StoryComposerLabels that several
// part files (EditorCamera, EditorToolbar, DiscardConfirmDialog, etc.) still
// require until the C17 refactor moves them onto MediaEditorLabels.
// Module-scoped; not exported via the barrel. Consumers override individual
// strings via the `labels` prop (mapped through DEFAULT_LABELS).
const INTERNAL_LABELS_FALLBACK: Required<StoryComposerLabels> = {
composerLabel: "Media editor",
composerDescription: "Capture a photo, record a video, or compose text-only content.",
modePhoto: "Photo",
modeVideo: "Video",
modeText: "Text",
shutterPhoto: "Take photo",
shutterVideoStart: "Start recording",
shutterVideoStop: "Stop recording",
galleryPicker: "Choose from gallery",
switchCamera: "Switch camera",
permissionDeniedTitle: "Camera access blocked",
permissionDeniedBody:
"We need camera permission to capture media. Enable it in your browser settings, or pick a file from your gallery.",
permissionRetry: "Try again",
permissionUsePicker: "Use gallery instead",
permissionRequesting: "Requesting camera…",
toolText: "Text",
toolDraw: "Draw",
toolStickers: "Stickers",
toolFilters: "Filters",
toolAdjust: "Adjust",
toolCrop: "Crop",
adjustBrightness: "Brightness",
adjustContrast: "Contrast",
adjustSaturation: "Saturation",
adjustBlur: "Blur",
drawColor: "Color",
drawBrush: "Brush size",
drawEraser: "Eraser",
drawUndo: "Undo",
drawRedo: "Redo",
textPlaceholder: "Type something…",
textFontFamily: "Font",
textFontSize: "Size",
textAlign: "Align",
publish: "Publish",
publishing: "Publishing…",
published: "Published",
close: "Close",
discardConfirmTitle: "Discard?",
discardConfirmBody: "You'll lose your captured media and edits.",
discardConfirm: "Discard",
discardCancel: "Keep editing",
uploadFailedTitle: "Upload failed",
uploadRetry: "Retry",
recordingLabel: "Recording",
trimStart: "Trim start",
trimEnd: "Trim end",
};
function devWarnOnce(memo: Set<string>, key: string, message: string) {
if (process.env.NODE_ENV === "production") return;
if (memo.has(key)) return;
memo.add(key);
console.warn(message);
}
// Thin outer wrapper — its ONLY job is mounting `props.capture.Provider`
// (when a capture extension is wired) as an ANCESTOR of the real
// implementation, so `MediaEditorImpl` can read it back via
// `useCaptureExtensionOptional()` (a component can't consume a context
// Provider it renders itself — the Provider must be an ancestor). No other
// logic lives here; everything else is unchanged in `MediaEditorImpl` below.
export const MediaEditor = React.forwardRef<MediaEditorHandle, MediaEditorProps>(
function MediaEditor(props, ref) {
if (props.capture) {
const { Provider } = props.capture;
return (
<Provider>
<MediaEditorImpl {...props} ref={ref} />
</Provider>
);
}
return <MediaEditorImpl {...props} ref={ref} />;
},
);
const MediaEditorImpl = React.forwardRef<
MediaEditorHandle,
MediaEditorProps
>(function MediaEditorImpl(props, ref) {
const captureExt = useCaptureExtensionOptional();
const { defaultMode } = props;
// Seed the editor's cropAspect with the first allowed option (the hook's
// "free" fallback may not be in the consumer-derived list). Computed
// inline here — the memoized version below covers the render path.
const initialCropAspect =
resolveCropAspects(props.aspect ?? "free", props.cropAspects)[0] ?? "free";
const editor = useMediaEditorState({
defaultMode,
defaultCropAspect: initialCropAspect,
onDirtyChange: props.onDirtyChange,
});
const warnedRef = React.useRef<Set<string>>(new Set());
// ─── Observability emitters (onModeChange / onEditAction) ──────────
// Latest-callback refs so the emit effects below depend only on the state
// they watch — never on parent-supplied callback identity (which churns on
// every parent render and would refire the effects spuriously).
const onModeChangeRef = React.useRef(props.onModeChange);
const onEditActionRef = React.useRef(props.onEditAction);
React.useEffect(() => {
onModeChangeRef.current = props.onModeChange;
onEditActionRef.current = props.onEditAction;
});
const emitEditAction = React.useCallback((action: EditAction) => {
onEditActionRef.current?.(action);
}, []);
// Emit mode changes to onModeChange + onEditAction. Effect-driven so it
// catches EVERY transition source (mode tab, gallery pick, initialSource
// load, programmatic) rather than threading the callback through each call
// site. Skips the initial mount so a seeded defaultMode doesn't fire.
const prevEmitModeRef = React.useRef<ComposerMode | null | undefined>(
undefined,
);
React.useEffect(() => {
const mode = editor.state.mode;
const prev = prevEmitModeRef.current;
if (prev !== undefined && prev !== mode) {
onModeChangeRef.current?.(mode);
emitEditAction({ kind: "mode-change", from: prev, to: mode });
}
prevEmitModeRef.current = mode;
}, [editor.state.mode, emitEditAction]);
// Emit tool-open / tool-close as the active tool changes. A tool→tool switch
// emits a close for the old then an open for the new. Skips the initial mount.
const prevEmitToolRef = React.useRef<EditTool | null | undefined>(undefined);
React.useEffect(() => {
const curr = editor.activeTool;
const prev = prevEmitToolRef.current;
if (prev !== undefined && prev !== curr) {
if (prev !== null) emitEditAction({ kind: "tool-close", tool: prev });
if (curr !== null) emitEditAction({ kind: "tool-open", tool: curr });
}
prevEmitToolRef.current = curr;
}, [editor.activeTool, emitEditAction]);
// ─── Capability defaults (description §8 — Q-P-locked) ─────────────
// Memoized so the `??`-fallback arrays keep a stable identity across
// renders (both feed hook dependency arrays further down).
const enabledModes = React.useMemo(
() => props.enabledModes ?? (["photo", "video", "text"] as const),
[props.enabledModes],
);
const enabledTools = React.useMemo(
() =>
props.enabledTools ??
(["text", "draw", "stickers", "filters", "adjust", "crop"] as const),
[props.enabledTools],
);
const mediaSources = props.mediaSources ?? (["camera", "upload"] as const);
const aspect = props.aspect ?? "free";
// Crop aspects derivation per description §4.
const resolvedCropAspects = React.useMemo(
() => resolveCropAspects(aspect, props.cropAspects),
[aspect, props.cropAspects],
);
// Mode pill is hidden when ≤1 mode enabled (per description §1).
const showModePill = enabledModes.length >= 2;
// ─── Initial source intake (description §5) ─────────────────────────
// When `initialSource` is set, fetch + validate, then land directly in
// the edit stage with mode + image/video pre-loaded. On error, surface
// via onInitialSourceError + sit in an empty state (no auto-fallback to
// capture — the consumer asked for a specific source and got an error).
const [sourceError, setSourceError] =
React.useState<SourceError | null>(null);
const onInitialSourceErrorRef = React.useRef(props.onInitialSourceError);
React.useEffect(() => {
onInitialSourceErrorRef.current = props.onInitialSourceError;
});
// Snapshot enabledModes into a ref so the load effect doesn't re-fire on
// array identity churn. enabledModes is read by the loader at the moment
// the source changes; subsequent toggles of enabledModes don't retrigger
// a re-fetch (which would be surprising — and slow on URL sources).
const enabledModesRef = React.useRef<readonly ComposerMode[]>(enabledModes);
React.useEffect(() => {
enabledModesRef.current = enabledModes;
});
// Value-based effect key: url-kind sources are idiomatically passed as
// inline object literals (fresh identity every parent render) — keying the
// load effect on the object identity would cancel + revoke + refetch on
// every render, killing the loaded image mid-edit. url-kind keys by
// content; blob/file-kind keys by the payload's identity (the wrapper
// object may churn, the Blob/File itself doesn't).
const initialSourceDep: string | Blob | File | null = !props.initialSource
? null
: props.initialSource.kind === "url"
? `url|${props.initialSource.mode}|${props.initialSource.url}`
: props.initialSource.kind === "blob"
? props.initialSource.blob
: props.initialSource.file;
React.useEffect(() => {
const source = props.initialSource;
if (!source) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSourceError(null);
return;
}
let cancelled = false;
let allocatedUrl: string | null = null;
(async () => {
const result = await loadInitialSource(source, enabledModesRef.current);
if (cancelled) {
if (result.ok) URL.revokeObjectURL(result.loaded.objectUrl);
return;
}
if (!result.ok) {
setSourceError(result.error);
onInitialSourceErrorRef.current?.(result.error);
return;
}
allocatedUrl = result.loaded.objectUrl;
setSourceError(null);
editor.setMode(result.loaded.mode);
if (result.loaded.mode === "photo") {
editor.setImageSrc(result.loaded.objectUrl);
} else {
editor.setVideoBlob(result.loaded.blob);
}
// Also register as a draft so the canvas mounts via the unified
// draft path. Lifetime of the URL is governed by the cleanup below
// (which revokes on cancel/unmount), so we don't double-revoke.
editor.setDraft({
source: "initial",
kind: result.loaded.mode === "photo" ? "image" : "video",
blob: result.loaded.blob,
url: result.loaded.objectUrl,
mimeType: result.loaded.blob.type,
});
editor.setStage("edit");
})();
return () => {
cancelled = true;
if (allocatedUrl) URL.revokeObjectURL(allocatedUrl);
};
// editor methods are stable refs from the hook; only the VALUE-keyed
// `initialSourceDep` drives this effect (see derivation above). The
// effect body reads props.initialSource from the render that changed the
// key. enabledModes is read via ref above so a tools-dial toggle doesn't
// refetch a URL source mid-edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSourceDep]);
// Camera surface: show if camera intake allowed AND at least one capture mode
// is enabled; otherwise fall back to the base file/gallery intake surface.
const hasCaptureMode = enabledModes.some(
(m) => m === "photo" || m === "video",
);
// `cameraIntakeAvailable` is CONFIG-only (does the consumer want camera at
// all). `captureSurfaceReady` additionally requires the capture extension
// to actually be wired (`props.capture` → `captureExt`) — camera requested
// but unwired degrades to the file-intake surface below (one dev-warn),
// rather than a dead "Connect to camera" button.
const cameraIntakeAvailable =
mediaSources.includes("camera") && hasCaptureMode;
const CameraSurface = captureExt?.CameraSurface ?? null;
const captureSurfaceReady = cameraIntakeAvailable && CameraSurface !== null;
React.useEffect(() => {
if (!cameraIntakeAvailable) return;
if (captureExt?.CameraSurface) return;
devWarnOnce(
warnedRef.current,
"capture-extension-missing",
'media-editor: mediaSources includes "camera" but no capture extension is wired — install @ilinxa/media-editor-capture and pass capture={mediaCapture} (MediaEditorProps.capture) to enable the camera. Falling back to file/gallery intake.',
);
}, [cameraIntakeAvailable, captureExt]);
// ─── R4: Undo / redo history + discard guard ─────────────────────────
// Command-stack scaffolding — nothing calls history.execute() in v0.1.x, so
// the stack is always empty. bindKeyboard stays OFF until the per-mutation
// command layer lands (v0.2): a live window Ctrl/Cmd+Z listener that
// preventDefault()s page-wide while doing nothing is strictly worse than no
// listener. handle.undo/redo dev-warn accordingly (see the handle below).
const history = useHistory({ capacity: 50, bindKeyboard: false });
const [showDiscardConfirm, setShowDiscardConfirm] = React.useState(false);
const textOnlyRef = React.useRef<HTMLDivElement | null>(null);
// Run a clean reset that also drops the history stack. Emits the `reset`
// edit-action centrally so every reset path (handle.reset, back-to-capture,
// discard-confirm) reports it once.
const performReset = React.useCallback(() => {
editor.reset();
history.reset();
emitEditAction({ kind: "reset" });
}, [editor, history, emitEditAction]);
// Close path with confirm-on-discard. Mirrors v0.1.5 Q-P10a:
// - In dialog mode, fires onClose unless dirty (then shows confirm).
// - In inline mode, just lets the consumer know via onClose (no UI
// close to perform).
const requestClose = React.useCallback(() => {
if (props.confirmOnDiscard !== false && editor.isDirty) {
setShowDiscardConfirm(true);
return;
}
props.onClose?.();
}, [editor.isDirty, props]);
// ─── Konva stage handle (C10 export wiring) ─────────────────────────
// EditorCanvas surfaces its Konva.Stage via onStageReady. The handle's
// export methods read this ref to snapshot the canvas at publish time.
const stageRef = React.useRef<Konva.Stage | null>(null);
// Derive a stable object URL from the loaded video blob so EditorCanvas
// can play it. Allocated lazily and revoked when the blob identity
// changes or the component unmounts.
const videoBlob = editor.state.videoBlob;
const [videoUrl, setVideoUrl] = React.useState<string | null>(null);
React.useEffect(() => {
if (!videoBlob) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVideoUrl(null);
return;
}
const url = URL.createObjectURL(videoBlob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [videoBlob]);
// Edit canvas is mounted whenever we have a loaded source. Source can land
// via initialSource (sets imageSrc/videoBlob + draft) OR camera capture
// (sets draft only) OR gallery pick (sets draft only). The unified read
// is `editor.draft`.
const hasLoadedSource =
editor.draft !== null ||
editor.state.imageSrc !== null ||
editor.state.videoBlob !== null;
const showEditCanvas =
!sourceError && editor.state.stage === "edit" && hasLoadedSource;
// ─── R2: Capture flow handlers ─────────────────────────────────────
// Camera + gallery callbacks promote captured media to a draft and
// transition to the edit stage. EditorCanvas takes over from there.
const handlePhoto = React.useCallback(
(photo: CapturedPhoto) => {
const url = URL.createObjectURL(photo.blob);
editor.setDraft({
source: "camera",
kind: "image",
blob: photo.blob,
url,
width: photo.width,
height: photo.height,
mimeType: photo.mimeType,
});
editor.setMode("photo");
editor.setImageSrc(url);
editor.setStage("edit");
},
[editor],
);
const handleVideo = React.useCallback(
(video: CapturedVideo) => {
const url = URL.createObjectURL(video.blob);
editor.setDraft({
source: "camera",
kind: "video",
blob: video.blob,
url,
durationMs: video.durationMs,
mimeType: video.mimeType,
});
editor.setMode("video");
editor.setVideoBlob(video.blob);
editor.setStage("edit");
},
[editor],
);
const handleGalleryFile = React.useCallback(
(file: File) => {
const kind: "image" | "video" = file.type.startsWith("video/")
? "video"
: "image";
const url = URL.createObjectURL(file);
editor.setDraft({
source: "gallery",
kind,
blob: file,
url,
mimeType: file.type,
});
editor.setMode(kind === "image" ? "photo" : "video");
if (kind === "image") {
editor.setImageSrc(url);
} else {
editor.setVideoBlob(file);
}
editor.setStage("edit");
},
[editor],
);
// ─── R3: Tool-panel resolved data ─────────────────────────────────
const resolvedStickerSets = React.useMemo(
() =>
resolveStickerSets(props.stickers, !!props.replaceBuiltinStickers),
[props.stickers, props.replaceBuiltinStickers],
);
const resolvedFilterPresets = React.useMemo(
() =>
resolveFilterPresets(
props.filterPresets,
!!props.replaceBuiltinFilters,
),
[props.filterPresets, props.replaceBuiltinFilters],
);
const resolvedFonts = React.useMemo(
() => props.fonts ?? DEFAULT_FONTS,
[props.fonts],
);
const resolvedColorPresets = React.useMemo(
() => props.colorPresets ?? DEFAULT_COLOR_PRESETS,
[props.colorPresets],
);
// Flatten resolved sticker sets into a Map<id, StickerOption> for the
// canvas-side resolveSticker(stickerId) lookup. Memoized — only rebuilds
// when the sets change.
const stickerIndex = React.useMemo(() => {
const m = new Map<string, StickerOption>();
for (const set of resolvedStickerSets) {
for (const sticker of set.stickers) m.set(sticker.id, sticker);
}
return m;
}, [resolvedStickerSets]);
const resolveSticker = React.useCallback(
(stickerId: string) => stickerIndex.get(stickerId),
[stickerIndex],
);
// In-progress drawing stroke kept locally — committed to editor state on
// pointer-up via addDrawingStroke. Lives outside MediaEditorState because
// it's transient render-only data.
const [currentDrawingStroke, setCurrentDrawingStroke] = React.useState<
import("./types").DrawingStroke | null
>(null);
const handleDrawBegin = React.useCallback(
(x: number, y: number) => {
setCurrentDrawingStroke({
id: `stroke-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
points: [x, y],
color: editor.drawingTool.color,
brushSize: editor.drawingTool.brushSize,
mode: editor.drawingTool.mode,
});
},
[editor.drawingTool],
);
const handleDrawExtend = React.useCallback((x: number, y: number) => {
setCurrentDrawingStroke((prev) =>
prev ? { ...prev, points: [...prev.points, x, y] } : prev,
);
}, []);
const handleDrawEnd = React.useCallback(() => {
// Commit OUTSIDE the setState updater — React 19 strict mode invokes the
// updater twice in dev, which would call addDrawingStroke twice with the
// same id and produce duplicate-key warnings. setState updaters must be
// pure. Closure-read of the current stroke is fine here.
if (currentDrawingStroke && currentDrawingStroke.points.length >= 4) {
editor.addDrawingStroke(currentDrawingStroke);
}
setCurrentDrawingStroke(null);
}, [currentDrawingStroke, editor]);
// Tool-panel state snapshot: captures editor.state at the moment the user
// enters a tool (activeTool flips from null → tool) so a "Back" button can
// revert any in-tool changes. Cleared on tool exit so re-entering captures
// a fresh baseline.
const toolEntrySnapshot = React.useRef<MediaEditorState | null>(null);
const prevActiveToolRef = React.useRef<EditTool | null>(null);
React.useEffect(() => {
const prev = prevActiveToolRef.current;
const curr = editor.activeTool;
if (prev === null && curr !== null) {
toolEntrySnapshot.current = editor.state;
} else if (curr === null) {
toolEntrySnapshot.current = null;
}
prevActiveToolRef.current = curr;
// editor.state is read intentionally at entry; deps only on activeTool.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor.activeTool]);
const handleApplyTool = React.useCallback(() => {
editor.setSelectedTextId(null);
editor.setSelectedStickerId(null);
editor.setActiveTool(null);
}, [editor]);
const handleBackTool = React.useCallback(() => {
const snap = toolEntrySnapshot.current;
if (snap) editor.loadState(snap);
editor.setSelectedTextId(null);
editor.setSelectedStickerId(null);
editor.setActiveTool(null);
}, [editor]);
const selectedText = React.useMemo(
() =>
editor.state.textOverlays.find((o) => o.id === editor.selectedTextId) ??
null,
[editor.state.textOverlays, editor.selectedTextId],
);
// Auto-add a text overlay when the text tool activates with nothing
// selected. Mirrors v0.1.5 behavior — tapping "Text" gives you something
// to edit immediately rather than presenting an empty inspector.
const lastSeededToolRef = React.useRef<EditTool | null>(null);
React.useEffect(() => {
if (editor.activeTool !== "text") {
lastSeededToolRef.current = null;
return;
}
if (selectedText) return;
if (lastSeededToolRef.current === "text") return;
lastSeededToolRef.current = "text";
const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
editor.addTextOverlay({
id,
text: INTERNAL_LABELS_FALLBACK.textPlaceholder,
x: 100,
y: 200,
rotation: 0,
scale: 1,
fontFamily: resolvedFonts[0]?.family ?? "sans-serif",
fontSize: 40,
fill: "#ffffff",
align: "center",
});
editor.setSelectedTextId(id);
// editor methods are stable; only activeTool/selectedText/fonts drive seed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor.activeTool, selectedText, resolvedFonts]);
// Sticker pick → add at canvas center + select for the transformer.
const handleStickerPick = React.useCallback(
(sticker: StickerOption) => {
const id = `sticker-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
editor.addSticker({
id,
stickerId: sticker.id,
x: 200,
y: 200,
rotation: 0,
scale: 1,
});
editor.setSelectedStickerId(id);
},
[editor],
);
const mergedLabels: Required<StoryComposerLabels> = React.useMemo(() => {
// The MediaEditorLabels → StoryComposerLabels mapping is C17 work.
// Until then, only string overrides at the keys that overlap with the
// flat shim flow through (e.g., `composerLabel`); everything else
// falls back to the English defaults.
const overrides = (props.labels ?? {}) as Partial<StoryComposerLabels>;
return { ...INTERNAL_LABELS_FALLBACK, ...overrides };
}, [props.labels]);
// Capture surface is mounted when the editor is in capture stage AND the
// current mode is photo/video AND a camera extension is actually wired.
const showCameraSurface =
editor.state.stage === "capture" &&
captureSurfaceReady &&
(editor.state.mode === "photo" || editor.state.mode === "video");
// Mode auto-seed removed — initial state is intentionally mode:null so the
// canvas can render a clean "Connect to camera" entry-point button instead
// of jumping straight into a permission prompt. The button + mode tabs are
// the user gestures that move the editor out of the empty state. Only the
// text-only fallback (no capture modes available) does NOT need a gesture.
React.useEffect(() => {
if (editor.state.mode !== null) return;
if (editor.state.stage !== "capture") return;
const captureModesEnabled = enabledModes.some(
(m) => m === "photo" || m === "video",
);
if (!captureModesEnabled && enabledModes.includes("text")) {
editor.setMode("text");
}
// editor.setMode is stable per hook contract; mode/stage/modes drive it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor.state.mode, editor.state.stage, enabledModes]);
// Tracks whether the user has explicitly clicked a button that should kick
// off the camera-acquire flow. When true, EditorCamera receives autoAcquire
// and the browser permission prompt fires inside the same user-gesture
// tick — avoiding spontaneous prompts on first paint.
const [userInitiatedCamera, setUserInitiatedCamera] = React.useState(false);
// "Back" from the edit stage → return to the capture surface so the user can
// re-take. Clears the captured draft + edits + history (via performReset),
// then restores the capture mode so the camera reopens in the same mode —
// gesture-credited through userInitiatedCamera so there's no fresh permission
// friction. Only offered when a capture mode exists (see hasCaptureMode).
const backToCapture = React.useCallback(() => {
const mode = editor.state.mode;
performReset();
if (mode === "photo" || mode === "video") {
editor.setMode(mode);
setUserInitiatedCamera(true);
}
}, [editor, performReset]);
// ─── Multi-instance guard (C11 — Q-P5 (b)) ──────────────────────────
// P3 S3: the guard's call site moved into the capture feature's
// EditorCamera (features/capture/parts/editor-camera.tsx) — it's a pure
// capture concern (live-camera contention), and the base can no longer
// statically import the hook that implements it. It now fires while the
// camera surface is actually mounted rather than merely "configured for
// camera" — a tighter, still-accurate trigger window. Base-alone (no
// capture extension) never acquires a camera, so it needs no guard at all.
// ─── Empty-state footgun guard (C11 — description §1) ────────────────
// `enabledModes: []` AND no `initialSource` AND no current source loaded
// → editor has nothing to capture and nothing to edit. Render the
// renderEmpty slot output (or a default "No source provided" surface)
// and dev-warn the misconfiguration.
const isEmptyConfig =
enabledModes.length === 0 &&
props.initialSource === undefined &&
!hasLoadedSource;
React.useEffect(() => {
if (!isEmptyConfig) return;
if (process.env.NODE_ENV === "production") return;
devWarnOnce(
warnedRef.current,
"empty-config",
"media-editor: enabledModes=[] AND no initialSource — editor has nothing to capture or edit. Provide an initialSource or enable at least one capture mode.",
);
}, [isEmptyConfig]);
// ─── Presentation resolution (description §6) ───────────────────────
const resolved = resolvePresentation(props.presentation, enabledModes);
// Dev-only required-prop guard: dialog mode needs isOpen + onClose.
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return;
if (resolved !== "dialog") return;
if (props.isOpen === undefined || props.onClose === undefined) {
devWarnOnce(
warnedRef.current,
"dialog-requires-isOpen-onClose",
"media-editor: presentation='dialog' requires isOpen + onClose props. Falling back to uncontrolled-open behaviour for now.",
);
}
}, [resolved, props.isOpen, props.onClose]);
// ─── Export implementations (C10) ───────────────────────────────────
// Factored out of the handle factory so the polymorphic export() can
// dispatch by mode without a `this`-binding gotcha.
const exportImage = React.useCallback(
async (opts?: ExportImageOpts) => {
const stage = stageRef.current;
if (!stage) throw new Error("media-editor: editor canvas not ready");
const format = opts?.format ?? "image/jpeg";
const quality = opts?.quality ?? 0.9;
const pixelRatio = opts?.pixelRatio ?? 2;
const cropRect = editor.state.crop;
// Identity-transform guard: exporting while pan/zoomed would bake the
// viewport transform into the output (stage.toCanvas captures the
// rendered stage). Normalize, snapshot, restore.
const blob = await withStageIdentityTransform(stage, () =>
exportPhotoBlob({
stage,
cropRect,
mimeType: format,
quality,
pixelRatio,
onProgress: opts?.onProgress,
}),
);
const width = cropRect?.width ?? stage.width();
const height = cropRect?.height ?? stage.height();
return {
blob,
metadata: buildExportMetadata(
editor.state,
"photo",
width,
height,
format,
),
};
},
[editor.state],
);
// Text-only mode rasterizes the styled DOM surface (no Konva stage exists in
// text mode) — review 1.2: without this branch, publishing a text story
// threw "editor canvas not ready".
const exportText = React.useCallback(
async (opts?: ExportImageOpts) => {
const el = textOnlyRef.current;
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
throw new Error("media-editor: text canvas not ready");
}
const width = el.offsetWidth;
const height = el.offsetHeight;
opts?.onProgress?.(0);
const blob = await exportTextOnlyBlob(el, width, height);
opts?.onProgress?.(1);
return {
blob,
metadata: buildExportMetadata(
editor.state,
"text",
width,
height,
"image/png",
),
};
},
[editor.state],
);
const exportVideo = React.useCallback(
async (opts?: ExportVideoOpts) => {
const videoBlob = editor.state.videoBlob;
if (!videoBlob) {
throw new Error("media-editor: no video blob loaded");
}
// Perf-shortcut: no overlays AND no crop → return the raw blob.
// Skips the expensive MediaRecorder re-encode for the
// "captured then immediately published" path (chat / DM flows).
const shortcut = !hasAnyOverlay(editor.state) && !editor.state.crop;
if (shortcut) {
opts?.onProgress?.(0);
opts?.onProgress?.(1);
return {
blob: videoBlob,
metadata: buildExportMetadata(
editor.state,
"video",
editor.state.crop?.width ?? 720,
editor.state.crop?.height ?? 1280,
videoBlob.type || "video/webm",
),
};
}
// Full re-encode path: bake Konva overlays into each video frame.
const stage = stageRef.current;
const cropRect = editor.state.crop;
const ow = cropRect?.width ?? stage?.width() ?? 720;
const oh = cropRect?.height ?? stage?.height() ?? 1280;
// Identity-transform guard for the per-frame overlay snapshot — same
// viewport-baking issue as exportImage, held for the whole re-encode.
const runComposite = () => compositeVideo({
sourceBlob: videoBlob,
outputWidth: Math.round(ow),
outputHeight: Math.round(oh),
mimeType: opts?.mimeType,
bitsPerSecond: opts?.bitsPerSecond,
onProgress: opts?.onProgress,
renderFrame: (ctx, video) => {
ctx.clearRect(0, 0, ow, oh);
const vw = video.videoWidth || ow;
const vh = video.videoHeight || oh;
const scale = Math.max(ow / vw, oh / vh);
const dw = vw * scale;
const dh = vh * scale;
ctx.drawImage(video, (ow - dw) / 2, (oh - dh) / 2, dw, dh);
if (stage) {
const overlay = stage.toCanvas({
x: cropRect?.x ?? 0,
y: cropRect?.y ?? 0,
width: cropRect?.width ?? stage.width(),
height: cropRect?.height ?? stage.height(),
pixelRatio: 1,
});
ctx.drawImage(overlay, 0, 0, ow, oh);
}
},
});
const result = stage
? await withStageIdentityTransform(stage, runComposite)
: await runComposite();
return {
blob: result.blob,
metadata: buildExportMetadata(
editor.state,
"video",
Math.round(ow),
Math.round(oh),
result.mimeType,
result.durationMs,
),
};
},
[editor.state],
);
const exportPolymorphic = React.useCallback(
async (opts?: ExportOpts) => {
const mode = editor.state.mode;
if (mode === "video") {
return exportVideo(opts as ExportVideoOpts | undefined);
}
if (mode === "text") {
return exportText(opts as ExportImageOpts | undefined);
}
return exportImage(opts as ExportImageOpts | undefined);
},
[editor.state.mode, exportImage, exportVideo, exportText],
);
// ─── Imperative handle ──────────────────────────────────────────────
// Inspect / state / export / edit-overlay methods are fully wired. The
// imperative CAPTURE methods (takePhoto / startRecording / stopRecording /
// switchCamera / importFromGallery) are not implemented — they dev-warn
// until then; the in-UI camera controls are the supported capture path.
React.useImperativeHandle(
ref,
(): MediaEditorHandle => ({
// === Inspect ===
getIsDirty: () => editor.isDirty,
getMode: () => editor.state.mode,
getState: () => editor.state,
loadState: (state: MediaEditorState, opts?: LoadStateOpts) => {
const sourceBlob = opts?.sourceBlob ?? null;
if (sourceBlob && state.mode !== "video") {
// Re-materialize (review 1.3): a persisted snapshot's imageSrc is a
// dead object URL once the capturing editor unmounted. Mint a fresh
// URL from the caller-persisted source blob and rebuild the draft so
// the canvas AND the draft-gated tool panels run as they did
// pre-unmount. setDraft's lifecycle owns revoking the minted URL.
const url = URL.createObjectURL(sourceBlob);
editor.loadState({ ...state, imageSrc: url });
editor.setDraft({
source: "initial",
kind: "image",
blob: sourceBlob,
url,
mimeType: sourceBlob.type || "image/jpeg",
});
} else {
editor.loadState(state);
}
},
getSourceBlob: () =>
editor.draft && editor.draft.kind === "image"
? editor.draft.blob
: null,
// === Capture (imperative path not implemented — dev-warns; see note above) ===
switchCamera: async () => {
devWarnOnce(warnedRef.current, "switchCamera", NOT_IMPLEMENTED_MARKER);
},
takePhoto: async () => {
devWarnOnce(warnedRef.current, "takePhoto", NOT_IMPLEMENTED_MARKER);
},
startRecording: async () => {
devWarnOnce(warnedRef.current, "startRecording", NOT_IMPLEMENTED_MARKER);
},
stopRecording: async () => {
devWarnOnce(warnedRef.current, "stopRecording", NOT_IMPLEMENTED_MARKER);
},
importFromGallery: () => {
devWarnOnce(warnedRef.current, "importFromGallery", NOT_IMPLEMENTED_MARKER);
},
// === Edit ===
addText: (text?: string) => {
if (text !== undefined) {
editor.setTextContent(text);
} else {
devWarnOnce(warnedRef.current, "addText", NOT_IMPLEMENTED_MARKER);
}
},
addSticker: (sticker: StickerOption) => {
editor.addSticker({
id: `sticker-${Date.now()}`,
stickerId: sticker.id,
x: 0,
y: 0,
rotation: 0,
scale: 1,
});
},
setAdjustments: (adj: Partial<ImageAdjustments>) =>
editor.setAdjustments(adj),
applyFilter: (name: string | null) => editor.setFilter(name),
clearLayer: (layer: "drawing" | "stickers" | "text") =>
editor.clearLayer(layer),
// No execute() callers exist yet (per-mutation commands land in v0.2) —
// the stack is always empty, so these are honest no-ops, not silent ones.
undo: () => {
devWarnOnce(warnedRef.current, "undo", NOT_WIRED_HISTORY_MARKER);
},
redo: () => {
devWarnOnce(warnedRef.current, "redo", NOT_WIRED_HISTORY_MARKER);
},
// === Export (C10) ===
exportImage,
exportVideo,
export: exportPolymorphic,
// === Lifecycle ===
reset: () => performReset(),
/**
* Dialog mode is controlled: the consumer owns `isOpen`, so the handle
* genuinely cannot open the editor, and inline mode has nothing to open.
* That made this a legitimate no-op — but a SILENT one, which is
* indistinguishable from a broken method at the call site.
*
* It now says so, exactly as this component's capture methods already
* do. Being unable to act is fine; being unable to act quietly is not.
*
* @notImplemented Set `isOpen` on the component instead.
*/
open: () => {
devWarnOnce(
warnedRef.current,
"handle-open-noop",
"media-editor: handle.open() cannot open the editor — dialog mode is controlled, so set isOpen={true} on the component (inline mode has nothing to open).",
);
},
close: () => {
// Routes through requestClose so confirm-on-discard guard fires
// if the editor is dirty. The dialog-mode branch in the wrapper
// also wires onOpenChange→requestClose for backdrop/Escape.
if (resolved === "dialog") {
requestClose();
}
},
}),
[
editor,
exportImage,
exportVideo,
exportPolymorphic,
resolved,
performReset,
requestClose,
],
);
// ─── Slot context for render* props ─────────────────────────────────
const slotCtx = React.useMemo(
() => ({
mode: editor.state.mode,
stage: editor.state.stage,
isDirty: editor.isDirty,
isCapturing: editor.state.stage === "capture",
isExporting: editor.state.stage === "publishing",
activeTool: editor.activeTool,
enabledTools: [...enabledTools] as EditTool[],
enabledModes: [...enabledModes] as ComposerMode[],
aspect,
}),
[editor, enabledTools, enabledModes, aspect],
);
// ─── Render — inner surface (shared by inline + dialog branches) ──
const inner = (
<div
className={cn(
// `relative` anchors the floating top-center control (mode pill / Back)
// and the consumer's renderTopBar (X / Publish) to the SAME box so they
// share one row instead of stacking.
"relative flex h-full w-full flex-col gap-3 p-4",
// Inline-only: gets its own card chrome. In dialog mode, DialogContent
// provides the chrome and we go edge-to-edge inside it.
resolved === "inline" &&
"rounded-2xl border border-border bg-card text-card-foreground shadow-sm min-h-[400px]",
)}
data-slot="media-editor"
data-mode={editor.state.mode ?? "none"}
data-stage={editor.state.stage}
data-presentation={resolved}
data-aspect={aspect}
>
{props.renderTopBar?.(slotCtx)}
{/* Top-center control — absolutely centered so it shares the consumer
top-bar's row (X left / Publish right) instead of stacking under it.
Capture stage → mode pill (choose what to capture). Hidden once a
draft exists: switching modes mid-edit is meaningless. */}
{editor.state.stage === "capture" && showModePill ? (
<div className="absolute top-[max(0.75rem,env(safe-area-inset-top))] left-1/2 z-30 -translate-x-1/2">
<div className="inline-flex gap-1 rounded-full border border-border bg-muted/50 p-1 text-xs backdrop-blur">
{enabledModes.map((m) => (
<button
key={m}
type="button"
onClick={() => {
editor.setMode(m);
if (m === "photo" || m === "video") {
setUserInitiatedCamera(true);
}
}}
className={cn(
"rounded-full px-3 py-1 font-medium transition-colors",
editor.state.mode === m
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
data-mode={m}
>
{m === "photo"
? mergedLabels.modePhoto
: m === "video"
? mergedLabels.modeVideo
: mergedLabels.modeText}
</button>
))}
</div>
</div>
) : null}
{/* Edit stage → Back-to-capture (icon-only, top-left), taking the close
button's slot. The consumer's renderTopBar hides its own close here so
Back is the single left-corner action (Instagram convention). Only
when a capture mode exists to return to — edit-only consumers
(initialSource, no capture modes) keep their close button instead. */}
{editor.state.stage === "edit" && hasCaptureMode ? (
<div className="absolute top-[max(0.75rem,env(safe-area-inset-top))] left-3 z-30">
<button
type="button"
onClick={backToCapture}
aria-label="Back to capture"
className="inline-flex size-9 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur transition-colors hover:bg-black/45"
>
<ArrowLeft className="size-5" />
</button>
</div>
) : null}
{/* Canvas — aspect-locked placeholder (real Konva stage lands in C10).
min-h-0 lets this flex-1 region shrink below the aspect-locked card's
intrinsic height so the bottom tool row keeps its space instead of
being pushed past the dialog's clipped (overflow-hidden) bottom edge. */}
<div className="relative flex flex-1 min-h-0 items-center justify-center">
<div
className={cn(
"flex flex-col items-stretch justify-center gap-2 overflow-hidden rounded-xl border border-dashed border-border/60 bg-muted/30 text-center text-xs text-muted-foreground",
// Fixed aspect ratio, sized to fill the frame (no side letterbox).
resolved === "dialog"
? // Dialog: fill the region edge-to-edge. The dialog itself is
// aspect-locked, so the canvas fills full width + height and the
// bottom controls OVERLAY the canvas (like the camera shutter)
// rather than taking flow space — so nothing shifts or crops.
"h-full w-full max-h-full max-w-full"
: // Inline has no guaranteed definite height, so drive by WIDTH
// with min/max bounds — responsive between a chat-embed (small)
// and a full surface (large) without breaking the ratio.
aspect === "free"
? "w-full min-h-64 min-w-50 max-w-150 max-h-[70vh]"
: "w-full min-h-64 min-w-50 max-w-120 max-h-[70vh]",
)}
style={{
aspectRatio:
aspect === "free" ? "16 / 9" : aspect.replace(":", " / "),
}}
data-canvas-placeholder=""
data-stage={editor.state.stage}
>
{sourceError ? (
<div className="flex flex-col items-center gap-1 p-4">
<p className="font-medium text-destructive">
Couldn't load source
</p>
<p className="text-[11px] text-muted-foreground">
<code>{sourceError.kind}</code>
{"url" in sourceError ? (
<>
{" · "}
<code className="break-all">{sourceError.url}</code>
</>
) : null}
{sourceError.kind === "mode-not-enabled" ? (
<>
{" · attempted "}
<code>{sourceError.attempted}</code>
{" · enabled "}
<code>{sourceError.enabled.join(",") || "(none)"}</code>
</>
) : null}
{sourceError.kind === "unsupported-file-type" ? (
<>
{" · type "}
<code>{sourceError.fileType || "(blank)"}</code>
</>
) : null}
</p>
{props.renderEmpty ? props.renderEmpty() : null}
</div>
) : isEmptyConfig ? (
<div
role="status"
aria-label="Media editor: no source provided. Pass initialSource or enable a capture mode."
className="flex flex-col items-center gap-2 p-6 text-muted-foreground"
data-empty-config=""
>
{props.renderEmpty ? (
props.renderEmpty()
) : (
<>
<p className="font-medium text-foreground">
No source provided
</p>
<p className="text-[11px]">
Pass <code>initialSource</code> or enable a capture mode
in <code>enabledModes</code>.
</p>
</>
)}
</div>
) : showEditCanvas ? (
<React.Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2
className="size-6 animate-spin text-primary"
aria-hidden="true"
/>
</div>
}
>
<LazyEditorCanvas
imageUrl={
editor.draft?.kind === "image"
? editor.draft.url
: editor.state.imageSrc
}
videoUrl={
editor.draft?.kind === "video" ? editor.draft.url : videoUrl
}
textOverlays={editor.state.textOverlays}
selectedTextId={editor.selectedTextId}
onTextChange={(next) => editor.updateTextOverlay(next.id, next)}
onTextSelect={editor.setSelectedTextId}
stickers={editor.state.stickers}
resolveSticker={resolveSticker}
selectedStickerId={editor.selectedStickerId}
onStickerChange={(next) => editor.updateSticker(next.id, next)}
onStickerSelect={editor.setSelectedStickerId}
drawingStrokes={editor.state.drawingStrokes}
currentDrawingStroke={currentDrawingStroke}
isDrawing={editor.activeTool === "draw"}
onDrawBegin={handleDrawBegin}
onDrawExtend={handleDrawExtend}
onDrawEnd={handleDrawEnd}
cropRect={editor.state.crop}
cropActive={editor.activeTool === "crop"}
cropAspectRatio={aspectAsRatio(editor.cropAspect)}
onCropChange={editor.setCrop}
adjustments={editor.state.adjustments}
activeFilter={
resolvedFilterPresets.find(
(f) => f.id === editor.state.filter,
) ?? null
}
onStageReady={(s) => {
stageRef.current = s;
}}
className="h-full w-full"
/>
</React.Suspense>
) : editor.state.stage === "capture" &&
editor.state.mode === "text" &&
enabledModes.includes("text") ? (
<TextOnlyCanvas
ref={textOnlyRef}
gradients={DEFAULT_TEXT_GRADIENTS}
fonts={resolvedFonts}
colorPresets={resolvedColorPresets}
labels={mergedLabels}
value={editor.textOnly}
onChange={(next) => editor.setTextOnly(next)}
/>
) : showCameraSurface && CameraSurface ? (
<CameraSurface
enabled={true}
mode={editor.state.mode === "video" ? "video" : "photo"}
defaultFacing={props.defaultFacing}
recordAudio={props.recordAudio}
maxFileSizeMb={props.maxFileSizeMb}
maxVideoDurationSec={props.maxVideoDuration}
captureAspectRatio={aspectAsRatio(aspect)}
autoAcquireOverride={userInitiatedCamera}
labels={mergedLabels}
onPhoto={handlePhoto}
onVideo={handleVideo}
onGalleryFile={handleGalleryFile}
onValidationError={props.onValidationError}
onPermissionDenied={props.onPermissionDenied}
renderPermissionDenied={props.renderPermissionDenied}
/>
) : captureSurfaceReady ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-sm text-muted-foreground">
Pick a mode above, or jump straight to the camera.
</p>
<button
type="button"
onClick={() => {
const firstCapture = enabledModes.find(
(m) => m === "photo" || m === "video",
);
if (firstCapture) {
editor.setMode(firstCapture);
setUserInitiatedCamera(true);
}
}}
className="rounded-full bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Connect to camera
</button>
</div>
) : mediaSources.includes("upload") ||
mediaSources.includes("camera") ? (
// Base file/gallery intake — the ONLY intake surface base-alone
// ships (no capture extension installed), and the fallback when
// "camera" was requested but nothing answered it (dev-warn fired
// above). Real, not a placeholder: picking a file promotes it
// through the SAME draft path as a camera capture would
// (handleGalleryFile → editor.setDraft → stage "edit").
<FileIntakeSurface
galleryLabel={mergedLabels.galleryPicker}
maxFileSizeMb={props.maxFileSizeMb ?? 50}
onFile={handleGalleryFile}
onValidationError={props.onValidationError}
/>
) : (
<>
<p className="font-medium text-foreground">
No capture source available
</p>
<p>
aspect: <code>{aspect}</code> · sources:{" "}
<code>{mediaSources.join(",") || "(none)"}</code>
</p>
</>
)}
</div>
</div>
{/* Bottom control overlay — edit stage only. Floats over the bottom of
the full-bleed canvas (mirrors the camera's overlaid shutter) with a
scrim, so the tool panel + chip row never steal the canvas's space or
get clipped by the dialog's bottom edge.
EXCEPTION — crop: the crop frame's bottom drag-handles live at the
image's bottom edge, so a floating overlay would cover them (you
couldn't grab the bottom handles). When the crop tool is active the
controls drop INTO FLOW below the canvas instead — the canvas region
(flex-1) shrinks to reserve the space, keeping every handle reachable.
Other tools are sliders/inputs (no canvas-edge handles), so they keep
the space-saving overlay. */}
{showEditCanvas ? (
<div
className={cn(
"flex flex-col items-stretch gap-2 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] *:pointer-events-auto",
editor.activeTool === "crop"
? "relative pt-2"
: "pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-linear-to-t from-black/70 via-black/30 to-transparent pt-10",
)}
>
{/* Active tool panel — keyed by editor.activeTool. Renders above the
chip-row toolbar. Image-draft tools only show on image drafts;
video-draft trim/etc. land in R4. */}
{editor.draft?.kind === "image" ? (
<>
{editor.activeTool === "text" && selectedText ? (
<ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>
<ToolTextInput
overlay={selectedText}
fonts={resolvedFonts}
colorPresets={resolvedColorPresets}
labels={mergedLabels}
onChange={(next) =>
editor.updateTextOverlay(next.id, next)
}
onDelete={() => {
editor.removeTextOverlay(selectedText.id);
editor.setSelectedTextId(null);
}}
/>
</ToolPanelFrame>
) : null}
{editor.activeTool === "stickers" ? (
<ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>
<ToolStickerPicker
sets={resolvedStickerSets}
onPick={handleStickerPick}
/>
</ToolPanelFrame>
) : null}
{editor.activeTool === "filters" && editor.draft.url ? (
<ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>
<ToolFilterStrip
presets={resolvedFilterPresets}
sourceUrl={editor.draft.url}
activeId={editor.state.filter}
onSelect={(id) => editor.setFilter(id)}
/>
</ToolPanelFrame>
) : null}
{editor.activeTool === "adjust" ? (
<ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>
<ToolAdjustSliders
value={editor.state.adjustments}
onChange={(next) => editor.setAdjustments(next)}
labels={mergedLabels}
/>
</ToolPanelFrame>
) : null}
{editor.activeTool === "draw" ? (
<ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>
<ToolDrawControls
color={editor.drawingTool.color}
brushSize={editor.drawingTool.brushSize}
mode={editor.drawingTool.mode}
colorPresets={resolvedColorPresets}
labels={mergedLabels}
onColorChange={(color) => editor.setDrawingTool({ color })}
onBrushSizeChange={(brushSize) =>
editor.setDrawingTool({ brushSize })
}
onModeChange={(mode) => editor.setDrawingTool({ mode })}
/>
</ToolPanelFrame>
) : null}
{editor.activeTool === "crop" ? (
<div className="flex flex-wrap items-center justify-center gap-2 rounded-md border border-border bg-muted/30 p-2">
{resolvedCropAspects.length > 1 ? (
<div className="flex flex-wrap items-center gap-1 border-r border-border pr-2">
{resolvedCropAspects.map((a) => (
<button
key={a}
type="button"
onClick={() => {
editor.setCropAspect(a);
editor.setCrop(null);
}}
className={cn(
"rounded px-2 py-0.5 text-xs font-medium transition-colors",
editor.cropAspect === a
? "bg-primary text-primary-foreground"
: "border border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{a}
</button>
))}
</div>
) : null}
<button
type="button"
onClick={async () => {
// Apply crop = burn the cropRect into the source image at
// NATIVE resolution. We can't use stage.toCanvas() — that
// captures the displayed stage size (typically smaller than
// source), so output would be down-sampled. Instead: load
// the source image as <img>, map cropRect from stage coords
// → image natural coords via the object-contain fit region,
// then drawImage into a new canvas at natural resolution.
const stage = stageRef.current;
const crop = editor.state.crop;
const sourceUrl =
editor.draft?.url ?? editor.state.imageSrc;
if (!stage || !crop || !sourceUrl) {
editor.setActiveTool(null);
return;
}
try {
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () =>
reject(new Error("image load failed"));
img.src = sourceUrl;
});
// Compute the image's object-contain fit region in stage
// coords (mirrors the Konva canvas's fitInto helper).
const stageW = stage.width();
const stageH = stage.height();
const fitScale = Math.min(
stageW / img.naturalWidth,
stageH / img.naturalHeight,
);
const fitW = img.naturalWidth * fitScale;
const fitH = img.naturalHeight * fitScale;
const fitX = (stageW - fitW) / 2;
const fitY = (stageH - fitH) / 2;
// Intersect crop with fit (clip letterbox area out).
const ix = Math.max(crop.x, fitX);
const iy = Math.max(crop.y, fitY);
const iw =
Math.min(crop.x + crop.width, fitX + fitW) - ix;
const ih =
Math.min(crop.y + crop.height, fitY + fitH) - iy;
if (iw <= 0 || ih <= 0) {
editor.setActiveTool(null);
return;
}
// Map intersection to natural image coords.
const natScale = img.naturalWidth / fitW;
const sx = (ix - fitX) * natScale;
const sy = (iy - fitY) * natScale;
const sw = iw * natScale;
const sh = ih * natScale;
const canvas = document.createElement("canvas");
canvas.width = Math.round(sw);
canvas.height = Math.round(sh);
const ctx = canvas.getContext("2d");
if (!ctx) {
editor.setActiveTool(null);
return;
}
ctx.drawImage(
img,
sx,
sy,
sw,
sh,
0,
0,
canvas.width,
canvas.height,
);
const blob = await new Promise<Blob | null>((res) => {
canvas.toBlob(res, "image/jpeg", 0.92);
});
if (!blob) {
editor.setActiveTool(null);
return;
}
const newUrl = URL.createObjectURL(blob);
// Replace draft + imageSrc with the cropped image so the
// tool sub-panel gate (editor.draft?.kind === "image")
// stays true and re-crop works on the new image.
editor.setDraft({
source: editor.draft?.source ?? "initial",
kind: "image",
blob,
url: newUrl,
width: canvas.width,
height: canvas.height,
mimeType: "image/jpeg",
});
editor.setImageSrc(newUrl);
editor.setCrop(null);
editor.setActiveTool(null);
} catch {
editor.setActiveTool(null);
}
}}
className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
>
Apply
</button>
<button
type="button"
onClick={() => {
editor.setCrop(null);
editor.setActiveTool(null);
}}
className="rounded border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</button>
</div>
) : null}
</>
) : null}
{/* Edit-tool chip row — filtered by enabledTools (description §4). Only
in the edit stage: the tools act on a captured draft, so they have
nothing to do during capture / text-only / empty-config. Centered when
the chips fit, horizontally scrollable when they don't, so they never
crop on narrow (9:16) widths. */}
{enabledTools.length > 0 && !isEmptyConfig ? (
<div className="max-w-full self-center overflow-x-auto rounded-full border border-border bg-background/80 backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto flex w-max items-center gap-1 p-1">
{enabledTools.map((tool) => (
<button
key={tool}
type="button"
onClick={() => editor.setActiveTool(tool)}
className={cn(
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors",
editor.activeTool === tool
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
data-tool={tool}
>
{toolChipLabel(tool, mergedLabels)}
</button>
))}
{enabledTools.includes("crop") && resolvedCropAspects.length > 1 ? (
<span className="ml-2 shrink-0 self-center text-[10px] text-muted-foreground/70">
({resolvedCropAspects.length} crop aspects)
</span>
) : null}
</div>
</div>
) : null}
</div>
) : null}
{props.renderBottomToolbar?.(slotCtx)}
{/* Discard confirm — fires from requestClose() when isDirty + close
attempt (backdrop / Escape / handle.close()). Q-P10a. */}
<DiscardConfirmDialog
open={showDiscardConfirm}
labels={mergedLabels}
onCancel={() => setShowDiscardConfirm(false)}
onConfirm={() => {
setShowDiscardConfirm(false);
performReset();
props.onClose?.();
}}
/>
</div>
);
// ─── Render — presentation branch ──
if (resolved === "inline") {
return inner;
}
// Dialog branch — sizing derived from aspect (description §6). On desktop
// the dialog is a viewport-relative box constrained by aspect-ratio:
// height-driven for portrait aspects, width-driven for landscape. The
// unspecified dimension is computed from CSS aspect-ratio; max-* clamps
// the secondary axis when the driver would force overflow.
const { aspectRatio, orientation } = dialogDimsForAspect(aspect);
return (
<Dialog
open={props.isOpen ?? false}
onOpenChange={(open) => {
if (!open) requestClose();
}}
>
<DialogContent
showCloseButton={false}
className={cn(
// Mobile: edge-to-edge fullscreen. Defeat shadcn base centering
// (top-1/2 left-1/2 + -translate-x/y-1/2) so the dialog anchors at 0,0.
"!fixed !inset-0 !top-0 !left-0 !translate-x-0 !translate-y-0",
"!max-w-none gap-0 overflow-hidden p-0",
"h-[100dvh] w-screen !rounded-none",
// Desktop: restore viewport-centered positioning. !inset-auto cancels
// the mobile !inset-0 so top/left:50% can land.
"md:!inset-auto md:!top-1/2 md:!left-1/2",
"md:!-translate-x-1/2 md:!-translate-y-1/2",
"md:!rounded-2xl",
// Aspect-ratio-driven sizing. Only the DRIVER dimension is sized; the
// other follows from the inline aspectRatio. Using clamp() on the
// driver gives a min + max floor/ceiling WITHOUT breaking the ratio
// (the derived dimension still tracks it) — so the dialog can't
// collapse to an unusable thumbnail on a short window. portrait drives
// height (clamped 24rem–44rem around 85dvh); landscape drives width
// (clamped 28rem–60rem around 85vw). max-w/max-h clamp the derived
// axis if it would exceed the viewport.
orientation === "portrait"
? "md:h-[clamp(24rem,85dvh,44rem)] md:w-auto md:max-w-[90vw]"
: "md:w-[clamp(28rem,85vw,60rem)] md:h-auto md:max-h-[85dvh]",
)}
style={{
// aspect-ratio is driven inline (Tailwind v4's md:aspect-[…] doesn't
// support runtime values without a JIT-friendly literal).
aspectRatio,
}}
>
<DialogTitle className="sr-only">{mergedLabels.composerLabel}</DialogTitle>
<DialogDescription className="sr-only">
{mergedLabels.composerDescription}
</DialogDescription>
{inner}
</DialogContent>
</Dialog>
);
});
// ─── Export-time helpers ──────────────────────────────────────────────
/**
* Returns true if the edit state has any overlay / mutation that would
* change the output vs the raw source. Drives the video perf-shortcut:
* if nothing's been changed, skip the MediaRecorder re-encode.
*
* Adjustments are checked by ALL three default values — any deviation
* counts as "modified."
*/
/** Default display ratio used for `aspect="free"` — chosen to match the
* typical webcam frame so preview ↔ capture stay consistent and the canvas
* doesn't render as a too-wide / too-short box. */
const FREE_ASPECT_FALLBACK = 16 / 9;
/**
* Convert an `AspectRatio` string ("9:16", "1:1", etc.) to a numeric ratio
* (width / height) for capture-time cropping. For `"free"`, returns
* `FREE_ASPECT_FALLBACK` so the captured frame is cropped to the same shape
* the canvas is displayed at (avoiding the "captured photo doesn't match
* preview" mismatch the canvas-placeholder uses the same ratio).
*/
function aspectAsRatio(aspect: AspectRatio): number {
if (aspect === "free") return FREE_ASPECT_FALLBACK;
const [w, h] = aspect.split(":").map(Number);
if (!w || !h) return FREE_ASPECT_FALLBACK;
return w / h;
}
/**
* Base file/gallery intake surface (P3 S3 — part B). The ONLY intake path
* base-alone ships once the capture feature slice is a separate install:
* a button + a hidden `<input type="file">`, honoring `maxFileSizeMb` via
* `validateGalleryFile` (same validation the capture feature's gallery
* button used pre-split). Matches the existing "Connect to camera" chrome —
* no new visual language.
*/
function FileIntakeSurface({
galleryLabel,
maxFileSizeMb,
onFile,
onValidationError,
}: {
galleryLabel: string;
maxFileSizeMb: number;
onFile: (file: File) => void;
onValidationError?: (error: ValidationError) => void;
}) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
return (
<div
className="flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center"
data-intake-surface="file"
>
<p className="text-sm text-muted-foreground">
Pick a photo or video from your device.
</p>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="rounded-full bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
{galleryLabel}
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // reset so picking the same file twice re-fires
if (!file) return;
const err = validateGalleryFile(file, maxFileSizeMb);
if (err) {
onValidationError?.(err);
return;
}
onFile(file);
}}
className="hidden"
/>
</div>
);
}
/**
* Shared chrome for tool sub-panels: wraps the tool's own UI with a Back +
* Apply button row so every tool follows the same Apply/revert contract.
* Back calls `onBack` which restores the state snapshot captured at tool
* entry. Apply just closes the panel, keeping all in-tool changes.
* Crop has its own custom panel chrome because Apply burns the cropped
* image into a new blob — that's outside the generic snapshot/restore model.
*/
function ToolPanelFrame({
onBack,
onApply,
children,
}: {
onBack: () => void;
onApply: () => void;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end gap-2 px-1">
<button
type="button"
onClick={onBack}
className="rounded border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back
</button>
<button
type="button"
onClick={onApply}
className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
>
Apply
</button>
</div>
{children}
</div>
);
}
/** Chip label for an edit tool — routed through the labels prop (i18n). */
function toolChipLabel(
tool: EditTool,
labels: Required<StoryComposerLabels>,
): string {
switch (tool) {
case "text":
return labels.toolText;
case "draw":
return labels.toolDraw;
case "stickers":
return labels.toolStickers;
case "filters":
return labels.toolFilters;
case "adjust":
return labels.toolAdjust;
case "crop":
return labels.toolCrop;
}
}
/**
* Run `fn` with the stage temporarily at the identity transform (scale 1,
* position 0,0), restoring the prior pan/zoom afterwards. Export snapshots
* (stage.toCanvas) capture the RENDERED stage — exporting while zoomed/panned
* would bake the viewport into the output.
*/
async function withStageIdentityTransform<T>(
stage: Konva.Stage,
fn: () => Promise<T>,
): Promise<T> {
const prior = {
x: stage.x(),
y: stage.y(),
scaleX: stage.scaleX(),
scaleY: stage.scaleY(),
};
if (
prior.x === 0 &&
prior.y === 0 &&
prior.scaleX === 1 &&
prior.scaleY === 1
) {
return fn();
}
stage.scale({ x: 1, y: 1 });
stage.position({ x: 0, y: 0 });
stage.batchDraw();
try {
return await fn();
} finally {
stage.scale({ x: prior.scaleX, y: prior.scaleY });
stage.position({ x: prior.x, y: prior.y });
stage.batchDraw();
}
}
function hasAnyOverlay(state: MediaEditorState): boolean {
if (state.textOverlays.length > 0) return true;
if (state.stickers.length > 0) return true;
if (state.drawingStrokes.length > 0) return true;
if (state.filter !== null) return true;
const a = state.adjustments;
if (a.brightness !== 0 || a.contrast !== 0 || a.saturation !== 0 || a.blur !== 0) {
return true;
}
return false;
}
function buildExportMetadata(
state: MediaEditorState,
mode: ComposerMode,
width: number,
height: number,
mimeType: string,
durationMs?: number,
): ExportMetadata {
return {
mode,
width,
height,
durationMs,
mimeType,
textOverlays: state.textOverlays,
stickers: state.stickers,
drawingStrokes: state.drawingStrokes.length,
appliedFilter: state.filter ?? undefined,
adjustments: state.adjustments,
crop: state.crop ?? undefined,
};
}
// ─── Tail type re-exports (cross-procomp consumers) ──────────────────────────
// Procomps that compose media-editor must import these types from THIS
// component-file path, not `./types`: the shadcn path rewriter preserves the
// component-file path but mangles `/types` cross-procomp imports (F-S1).
export type {
MediaEditorProps,
MediaEditorHandle,
MediaEditorState,
ExportMetadata,
InitialSource,
ComposerMode,
EditTool,
MediaSource,
AspectRatio,
LoadStateOpts,
} from "./types";
src/registry/components/media/media-editor/index.ts
// media-editor — public barrel (BASE item; capture feature slice is
// `features/capture/` — its own barrel, own registry item).
//
// Exports grow progressively as each commit lands its surface:
// C2 (this commit) — main component + all editor-shaped types
// C3 — moved hooks (useMediaCapture + 6 internal)
// C4 — moved lib helpers (filters, stickers, export, composite, mime)
// C5 — moved parts (EditorCamera, EditorCanvas, EditorToolbar,
// ColorSwatchPicker, DiscardConfirmDialog)
// C6 — useMediaEditorState (NEW)
// C9-C11 — initial-source / multi-instance internal helpers (NOT re-exported)
// P3 S3 (feature-slicing) — capture split OUT to features/capture/: useMediaCapture,
// useCameraPermissions, useMultiInstanceGuard, EditorCamera,
// CameraPermissionPrompt, ShutterButton (+ their types) no
// longer export from here — import from `@ilinxa/media-editor-capture`
// (features/capture/index.ts) instead. `validateGalleryFile` STAYS
// here (generic file-intake, not a capture concern) — moved to
// `./lib/validate-media-file`. The capture injection seam
// (`MediaCaptureExtension` + friends) is NEW here.
//
// Backward-compat for story-composer v0.1.5 consumers is handled in
// story-composer v0.2.0's barrel via re-exports from THIS barrel
// (see plan §"Type re-export band").
// ─── Main component ────────────────────────────────────────────────────
export { MediaEditor } from "./media-editor";
// ─── Types — editor-shaped (story-composer v0.2.0 re-exports these) ───
export type {
// Modes + stage
ComposerMode,
ComposerStage,
// Validation
ValidationError,
// Stickers
StickerOption,
StickerSet,
// Filters + adjustments
KonvaFilterSpec,
FilterPreset,
ImageAdjustments,
// Overlay items
TextOverlay,
PlacedSticker,
DrawingStroke,
// Fonts
FontOption,
// Aspect + tools
AspectRatio,
EditTool,
// Source intake
MediaSource,
// Crop
CropRect,
// Initial source
InitialSource,
SourceError,
// Draft + edit-working state
DraftMedia,
TrimRange,
DrawingToolConfig,
TextOnlyState,
// Editor state + actions
GradientPreset,
MediaEditorState,
EditAction,
// Export
ExportImageOpts,
ExportVideoOpts,
ExportOpts,
ExportMetadata,
// Slots
EditorCtx,
// Labels
MediaEditorLabels,
// Public component contracts
MediaEditorProps,
MediaEditorHandle,
LoadStateOpts,
} from "./types";
// ─── Constants ─────────────────────────────────────────────────────────
export { DEFAULT_ADJUSTMENTS, DEFAULT_LABELS } from "./types";
// ─── Capture injection seam (P3 S3 — see hooks/use-capture-extension.ts) ─
// For consumers building a CUSTOM capture provider (not the shipped
// `@ilinxa/media-editor-capture`). Ordinary consumers just import
// `mediaCapture` from the capture feature and pass `capture={mediaCapture}`.
export {
MediaCaptureExtensionContext,
type MediaCaptureExtension,
type MediaCaptureExtensionContextValue,
type MediaCaptureSurfaceProps,
type CapturedPhotoLike,
type CapturedVideoLike,
} from "./hooks/use-capture-extension";
// ─── Hooks (C3 — moved from story-composer v0.1.5 via git mv) ───────
export {
useDrawingStroke,
type UseDrawingStrokeOptions,
type UseDrawingStrokeResult,
} from "./hooks/use-drawing-stroke";
export {
useHistory,
type Command,
type UseHistoryOptions,
type UseHistoryResult,
} from "./hooks/use-history";
export {
useKonvaSelection,
type SelectableKind,
type SelectedItem,
type UseKonvaSelectionResult,
} from "./hooks/use-konva-selection";
export {
useKonvaStageSize,
type StageSize,
} from "./hooks/use-konva-stage-size";
export {
usePanZoom,
type PanZoomTransform,
type UsePanZoomOptions,
type UsePanZoomResult,
} from "./hooks/use-pan-zoom";
// useMediaEditorState (C6) — written NEW; replaces story-composer's
// use-story-composer-state for editor-shaped concerns.
export {
useMediaEditorState,
type UseMediaEditorStateOptions,
type UseMediaEditorStateResult,
} from "./hooks/use-media-editor-state";
// useMultiInstanceGuard moved to features/capture/ in P3 S3 — its call site
// is now inside the feature's EditorCamera (live-camera contention is a
// capture-only concern). Advanced consumers: import from
// `@ilinxa/media-editor-capture` instead.
// ─── Lib helpers (C3 + C4 — moved from story-composer v0.1.5 via git mv) ──
// mime-fallback (C3, SHARED — capture recording + base export re-encode
// both use it):
export {
selectRecorderMime,
containerFor,
PREFERRED_RECORDER_MIME_TYPES,
type RecorderMimeType,
} from "./lib/mime-fallback";
// validate-media-file (P3 S3 — extracted from the old use-media-capture.ts;
// generic file-intake, not a capture concern, so it stayed in base):
export { validateGalleryFile } from "./lib/validate-media-file";
// konva-filters (C4):
export {
resolveFilterPresets,
BUILT_IN_FILTER_PRESETS,
} from "./lib/konva-filters";
// built-in-stickers (C4):
export {
resolveStickerSets,
BUILT_IN_STICKER_SETS,
} from "./lib/built-in-stickers";
// export-blob (C4):
export {
exportPhotoBlob,
exportTextOnlyBlob,
type ExportPhotoOptions,
} from "./lib/export-blob";
// composite-video (C4):
export {
compositeVideo,
type CompositeVideoOptions,
type CompositeVideoResult,
} from "./lib/composite-video";
// defaults — editor tokens (C4):
export {
DEFAULT_TEXT_GRADIENTS,
DEFAULT_FONTS,
DEFAULT_COLOR_PRESETS,
} from "./lib/defaults";
// ─── Parts (C5 — moved from story-composer v0.1.5 via git mv) ──────
// EditorCamera / CameraPermissionPrompt / ShutterButton moved to
// features/capture/ in P3 S3 — import from `@ilinxa/media-editor-capture`.
export { EditorCanvas } from "./parts/editor-canvas";
export type { EditorCanvasProps } from "./parts/editor-canvas";
// lazy-editor-canvas is an internal same-folder default-export shim for
// React.lazy() (see parts/lazy-editor-canvas.tsx) — not re-exported; use
// `EditorCanvas` above for direct/non-lazy composition.
export { EditorToolbar } from "./parts/editor-toolbar";
export type { EditorToolbarProps } from "./parts/editor-toolbar";
// Other public parts (per description §10):
export { ColorSwatchPicker } from "./parts/color-swatch-picker";
export type { ColorSwatchPickerProps } from "./parts/color-swatch-picker";
export { DiscardConfirmDialog } from "./parts/discard-confirm-dialog";
// Crop helpers (v0.1.5 public surface preserved):
export {
ToolCropOverlay,
ASPECT_RATIO_VALUES,
fitCropToStage,
} from "./parts/tool-crop-overlay";
export type { ToolCropOverlayProps } from "./parts/tool-crop-overlay";
// Internal-by-convention parts — re-exported for sealed-folder consumers.
export { ModeTogglePill } from "./parts/mode-toggle-pill";
export { VideoTrimBar } from "./parts/video-trim-bar";
export { TextOnlyCanvas } from "./parts/text-only-canvas";
export type {
TextOnlyCanvasProps,
TextOnlyCanvasState,
} from "./parts/text-only-canvas";
export { ToolAdjustSliders } from "./parts/tool-adjust-sliders";
export { ToolDrawControls } from "./parts/tool-draw-controls";
export { ToolFilterStrip } from "./parts/tool-filter-strip";
export { ToolStickerPicker } from "./parts/tool-sticker-picker";
export { ToolTextInput } from "./parts/tool-text-input";
src/registry/components/media/media-editor/types.ts
import type { ReactNode } from "react";
import type { MediaCaptureExtension } from "./hooks/use-capture-extension";
// ─── Modes + stage ──────────────────────────────────────────────────────
// (Locked: NO renames from story-composer v0.1.5 — see plan §"Type system".
// story-composer v0.2.0 re-exports these for backward-compat.)
export type ComposerMode = "photo" | "video" | "text";
export type ComposerStage =
| "capture"
| "edit"
| "publishing"
| "done"
| "error";
// ─── Validation ─────────────────────────────────────────────────────────
export interface ValidationError {
kind:
| "file-too-large"
| "unsupported-type"
| "unsupported-codec"
| "duration-exceeded"
| "no-camera"
| "permission-denied";
message: string;
file?: File;
}
// ─── Stickers ───────────────────────────────────────────────────────────
export interface StickerOption {
id: string;
src: string;
alt: string;
width?: number;
height?: number;
}
export interface StickerSet {
id: string;
label: string;
stickers: StickerOption[];
}
// ─── Filters + adjustments ──────────────────────────────────────────────
export interface KonvaFilterSpec {
name: string;
params?: Record<string, number>;
}
export interface FilterPreset {
id: string;
label: string;
konvaFilters: KonvaFilterSpec[];
}
export interface ImageAdjustments {
brightness: number;
contrast: number;
saturation: number;
blur: number;
}
export const DEFAULT_ADJUSTMENTS: ImageAdjustments = {
brightness: 0,
contrast: 0,
saturation: 0,
blur: 0,
};
// ─── Overlay items ──────────────────────────────────────────────────────
export interface TextOverlay {
id: string;
text: string;
x: number;
y: number;
rotation: number;
scale: number;
fontFamily: string;
fontSize: number;
fill: string;
align: "left" | "center" | "right";
}
export interface PlacedSticker {
id: string;
stickerId: string;
x: number;
y: number;
rotation: number;
scale: number;
}
export interface DrawingStroke {
id: string;
points: number[];
color: string;
brushSize: number;
mode: "draw" | "erase";
}
// ─── Fonts ──────────────────────────────────────────────────────────────
export interface FontOption {
id: string;
label: string;
family: string;
}
// ─── Aspect ratios + tools ──────────────────────────────────────────────
// v0.1.0 of media-editor adds "16:9" to AspectRatio (additive — non-breaking
// for v0.1.5 consumers; they used at most "9:16" | "1:1" | "4:5" | "free").
export type AspectRatio = "9:16" | "1:1" | "16:9" | "4:5" | "free";
export type EditTool =
| "text"
| "draw"
| "stickers"
| "filters"
| "adjust"
| "crop";
// ─── Media source intake (NEW in media-editor — for capability gating) ──
export type MediaSource = "camera" | "upload";
// "library" is not implemented — see description §"Out of scope"
// ─── Crop ───────────────────────────────────────────────────────────────
// Lifted from story-composer/parts/tool-crop-overlay.tsx (where v0.1.5
// defined it). Shape preserved verbatim — 4 fields, no `aspect`. Adding fields
// would break v0.1.5 consumers via type-narrowing.
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
// ─── Initial source (NEW — CMS re-edit / draft restore path) ────────────
export type InitialSource =
| { kind: "url"; url: string; mode: "photo" | "video" }
| { kind: "blob"; blob: Blob; mode: "photo" | "video" }
| { kind: "file"; file: File };
// ─── Draft media (NEW — capture output before edit transition) ──────────
/**
* Captured (or imported) media awaiting edit. Set by camera flow + gallery
* pick. Held in editor state so getState/loadState can round-trip draft
* restores (e.g., draft persisted to localStorage between sessions).
*/
export interface DraftMedia {
source: "camera" | "gallery" | "initial";
kind: "image" | "video";
blob: Blob;
/** Object URL for preview — caller owns revocation when draft is replaced. */
url: string;
width?: number;
height?: number;
durationMs?: number;
mimeType: string;
}
/** Video trim range, in seconds. */
export interface TrimRange {
startSec: number;
endSec: number;
durationSec: number;
}
/** Drawing-tool UI configuration. Not content; not in MediaEditorState. */
export interface DrawingToolConfig {
color: string;
brushSize: number;
mode: "draw" | "erase";
}
/** Text-only mode capture state. */
export interface TextOnlyState {
text: string;
fontFamily: string;
textColor: string;
gradientId: string;
}
// ─── Source / load errors (NEW) ─────────────────────────────────────────
export type SourceError =
| { kind: "cors"; url: string; underlying: Error }
| { kind: "fetch-failed"; url: string; underlying: Error }
| {
kind: "mode-not-enabled";
attempted: ComposerMode;
enabled: ComposerMode[];
}
| { kind: "unsupported-file-type"; fileType: string; file: File }
| { kind: "invalid-blob"; reason: string };
// ─── Editor state (NEW — serializable snapshot for getState / loadState) ──
export interface GradientPreset {
id: string;
label: string;
/** CSS background value — used directly on the text-only canvas. (Preserves v0.1.5 contract.) */
background: string;
}
export interface MediaEditorState {
mode: ComposerMode | null;
stage: ComposerStage;
imageSrc: string | null;
videoBlob: Blob | null;
textBackground: GradientPreset | null;
textContent: string | null;
textOverlays: TextOverlay[];
stickers: PlacedSticker[];
drawingStrokes: DrawingStroke[];
filter: string | null;
adjustments: ImageAdjustments;
crop: CropRect | null;
}
// ─── Edit action (NEW — analytics + undo sync) ──────────────────────────
export type EditAction =
| { kind: "mode-change"; from: ComposerMode | null; to: ComposerMode | null }
| { kind: "tool-open"; tool: EditTool }
| { kind: "tool-close"; tool: EditTool }
| { kind: "text-add"; overlay: TextOverlay }
| { kind: "text-update"; id: string; patch: Partial<TextOverlay> }
| { kind: "text-remove"; id: string }
| { kind: "sticker-add"; placed: PlacedSticker }
| { kind: "sticker-remove"; id: string }
| { kind: "draw-stroke"; stroke: DrawingStroke }
| { kind: "filter-apply"; name: string | null }
| { kind: "adjust-change"; patch: Partial<ImageAdjustments> }
| { kind: "crop-set"; crop: CropRect | null }
| { kind: "undo" }
| { kind: "redo" }
| { kind: "reset" };
// ─── Export contracts (NEW — locked at Q-P3 sign-off) ───────────────────
export interface ExportImageOpts {
/** Default "image/jpeg" (Q-P3 lock). */
format?: "image/jpeg" | "image/png" | "image/webp";
/** 0..1; default 0.9; only meaningful for jpeg/webp. */
quality?: number;
/** Default 2 (retina). */
pixelRatio?: number;
/** 0..1; fires twice (start, end) for jpeg/png; webp may fire interim. */
onProgress?: (progress: number) => void;
}
export interface ExportVideoOpts {
/** Default auto-pick via mime-fallback chain (vp9 → vp8 → mp4). */
mimeType?: string;
/** Default browser-chosen. */
bitsPerSecond?: number;
/** 0..1; fires ~10× during re-encode (or once if perf-shortcut applies). */
onProgress?: (progress: number) => void;
}
export type ExportOpts = ExportImageOpts | ExportVideoOpts;
export interface ExportMetadata {
mode: ComposerMode;
width: number;
height: number;
durationMs?: number;
mimeType: string;
textOverlays: TextOverlay[];
stickers: PlacedSticker[];
drawingStrokes: number;
appliedFilter?: string;
adjustments: ImageAdjustments;
crop?: CropRect;
}
// ─── Slot context (NEW — replaces ComposerCtx for editor-only consumers) ─
export interface EditorCtx {
mode: ComposerMode | null;
stage: ComposerStage;
isDirty: boolean;
isCapturing: boolean;
isExporting: boolean;
activeTool: EditTool | null;
enabledTools: EditTool[];
enabledModes: ComposerMode[];
aspect: AspectRatio;
}
// ─── Compile-time shim for v0.1.5 part label deps (REMOVED IN C17) ──────
// Several parts (camera-permission-prompt, discard-confirm-dialog, editor-camera,
// editor-toolbar, mode-toggle-pill, text-only-canvas, tool-*) were authored
// against story-composer's flat StoryComposerLabels shape. Refactoring
// each part to use MediaEditorLabels is C17-territory (wrapper refactor).
// This temporary alias mirrors the v0.1.5 shape verbatim so the parts compile
// post-move. NOT exported via barrel — purely an internal compile shim.
/** @internal — temporary shim; refactored to MediaEditorLabels in C17. */
export interface StoryComposerLabels {
composerLabel?: string;
composerDescription?: string;
modePhoto?: string;
modeVideo?: string;
modeText?: string;
shutterPhoto?: string;
shutterVideoStart?: string;
shutterVideoStop?: string;
galleryPicker?: string;
switchCamera?: string;
permissionDeniedTitle?: string;
permissionDeniedBody?: string;
permissionRetry?: string;
permissionUsePicker?: string;
permissionRequesting?: string;
toolText?: string;
toolDraw?: string;
toolStickers?: string;
toolFilters?: string;
toolAdjust?: string;
toolCrop?: string;
adjustBrightness?: string;
adjustContrast?: string;
adjustSaturation?: string;
adjustBlur?: string;
drawColor?: string;
drawBrush?: string;
drawEraser?: string;
drawUndo?: string;
drawRedo?: string;
textPlaceholder?: string;
textFontFamily?: string;
textFontSize?: string;
textAlign?: string;
publish?: string;
publishing?: string;
published?: string;
close?: string;
discardConfirmTitle?: string;
discardConfirmBody?: string;
discardConfirm?: string;
discardCancel?: string;
uploadFailedTitle?: string;
uploadRetry?: string;
recordingLabel?: string;
trimStart?: string;
trimEnd?: string;
}
// ─── i18n labels (NEW — full key tree per description §11) ──────────────
export interface MediaEditorLabels {
capture: {
requestingCamera?: string;
cameraDenied: {
title?: string;
body?: string;
retryCta?: string;
useGalleryCta?: string;
};
galleryButton?: string;
switchCamera?: string;
modes: { photo?: string; video?: string; text?: string };
};
toolbar: {
text?: string;
draw?: string;
stickers?: string;
filters?: string;
adjust?: string;
crop?: string;
};
adjust: {
brightness?: string;
contrast?: string;
saturation?: string;
blur?: string;
};
publish: { exporting?: string; cancel?: string; ready?: string };
discard: {
title?: string;
body?: string;
cancel?: string;
confirm?: string;
};
empty: { noSource?: string };
}
// ─── Public component props ─────────────────────────────────────────────
export interface MediaEditorProps {
// === Capability surface (the "partial use" dials, plan-locked defaults) ===
/** Which capture modes are available. Default ["photo","video","text"] (Q-P2). Empty array + initialSource = edit-only mode. */
enabledModes?: ComposerMode[];
/** Which edit tools appear in the toolbar. Default all 6. */
enabledTools?: EditTool[];
/** Which source intake methods are offered. Default ["camera","upload"]. "library" deferred. */
mediaSources?: MediaSource[];
/** Aspect lock for the editor canvas. Default "free" (Q-P1). */
aspect?: AspectRatio;
// === Initial source (skip capture surface) ===
initialSource?: InitialSource;
onInitialSourceError?: (error: SourceError) => void;
// === Presentation ===
/** "inline" | "dialog" | "auto". Default "auto" — picks dialog if enabledModes non-empty, else inline. */
presentation?: "inline" | "dialog" | "auto";
/** Required when presentation resolves to "dialog". Dev-error if missing. */
isOpen?: boolean;
/** Required when presentation resolves to "dialog". */
onClose?: () => void;
// === Capture extension (P3 S3 — install @ilinxa/media-editor-capture) ===
/**
* Enables camera capture. Install `@ilinxa/media-editor-capture` and pass
* its `mediaCapture` export straight through:
* `<MediaEditor capture={mediaCapture} />`. Without it, `"camera"` in
* `mediaSources` falls back to the base file/gallery intake surface (one
* dev console.warn) — base-alone stays fully usable (pick a file → edit →
* export), it just can't drive a live camera.
*/
capture?: MediaCaptureExtension;
// === Capture config (only meaningful if enabledModes overlaps capture modes) ===
defaultMode?: ComposerMode;
defaultFacing?: "user" | "environment";
/** Default 30s. */
maxVideoDuration?: number;
/** Default true. */
recordAudio?: boolean;
/** Default 50MB. */
maxFileSizeMb?: number;
onValidationError?: (error: ValidationError) => void;
onPermissionDenied?: () => void;
// === Edit-tool config ===
stickers?: StickerSet[];
replaceBuiltinStickers?: boolean;
fonts?: FontOption[];
colorPresets?: string[];
filterPresets?: FilterPreset[];
replaceBuiltinFilters?: boolean;
/** Overrides default crop choices. Default derivation: if aspect !== "free" → [aspect]; else all 5. */
cropAspects?: AspectRatio[];
// === Discard guard ===
/** Default true. Dialog mode only — fires on Escape/backdrop click/programmatic close() when dirty. Inline mode ignores (consumer owns navigation guarding via getIsDirty() handle). */
confirmOnDiscard?: boolean;
// === Localization ===
labels?: Partial<MediaEditorLabels>;
// === State change observability ===
/** Fires whenever the active capture mode changes (tab switch, gallery pick,
* initialSource load, programmatic). Skips the initial mount. */
onModeChange?: (mode: ComposerMode | null) => void;
onDirtyChange?: (isDirty: boolean) => void;
/**
* Analytics/undo-sync hook. v0.1.x emits the navigation + lifecycle actions:
* `mode-change`, `tool-open`, `tool-close`, and `reset`. The fine-grained
* content-mutation actions (`text-*`, `sticker-*`, `draw-stroke`,
* `filter-apply`, `adjust-change`, `crop-set`, `undo`, `redo`) land in v0.2
* when the per-mutation instrumentation layer ships — the `EditAction` union
* is already complete so consumers can switch exhaustively today.
*/
onEditAction?: (action: EditAction) => void;
// === Slots (escape hatches) ===
renderTopBar?: (ctx: EditorCtx) => ReactNode;
renderBottomToolbar?: (ctx: EditorCtx) => ReactNode;
renderPermissionDenied?: (ctx: {
retry: () => void;
usePicker: () => void;
}) => ReactNode;
renderEmpty?: () => ReactNode;
}
// ─── Imperative handle (LOCKED in description §7) ──────────────────────
export interface LoadStateOpts {
/**
* The blob backing `state.imageSrc` (photo path). A persisted snapshot's
* `imageSrc` is typically a dead object URL (revoked when the capturing
* editor instance unmounted) — when `sourceBlob` is provided, `loadState`
* re-mints a fresh object URL from it instead of trusting `imageSrc`, and
* rebuilds the internal draft so the edit tools work as they did pre-unmount.
* Obtain it from `getSourceBlob()` at snapshot time. (Review 1.3.)
*/
sourceBlob?: Blob | null;
}
export interface MediaEditorHandle {
// === Inspect ===
/** True iff the user has captured OR loaded OR edited something. Capture without edit IS dirty. */
getIsDirty: () => boolean;
getMode: () => ComposerMode | null;
/** Serializable snapshot — for draft persistence. */
getState: () => MediaEditorState;
loadState: (state: MediaEditorState, opts?: LoadStateOpts) => void;
/**
* Blob backing the current photo draft / canvas image — persist it alongside
* `getState()` so a later `loadState(state, { sourceBlob })` can
* re-materialize the (revoked) object URL. Null when nothing is loaded, or
* in video mode (persist `getState().videoBlob` instead).
*/
getSourceBlob: () => Blob | null;
// === Capture (only if enabledModes includes capture modes; else dev-warn + no-op) ===
switchCamera: () => Promise<void>;
takePhoto: () => Promise<void>;
startRecording: () => Promise<void>;
stopRecording: () => Promise<void>;
importFromGallery: () => void;
// === Edit (gated by enabledTools — disabled tool methods dev-warn + no-op) ===
addText: (text?: string) => void;
addSticker: (sticker: StickerOption) => void;
setAdjustments: (adj: Partial<ImageAdjustments>) => void;
applyFilter: (name: string | null) => void;
clearLayer: (layer: "drawing" | "stickers" | "text") => void;
undo: () => void;
redo: () => void;
// === Export ===
exportImage: (
opts?: ExportImageOpts,
) => Promise<{ blob: Blob; metadata: ExportMetadata }>;
exportVideo: (
opts?: ExportVideoOpts,
) => Promise<{ blob: Blob; metadata: ExportMetadata }>;
/** Polymorphic — picks exportImage or exportVideo based on current mode. */
export: (
opts?: ExportOpts,
) => Promise<{ blob: Blob; metadata: ExportMetadata }>;
// === Lifecycle ===
reset: () => void;
/** Dialog mode only — no-op in inline. */
open: () => void;
/** Dialog mode only — no-op in inline. */
close: () => void;
}
// ─── Default labels ─────────────────────────────────────────────────────
export const DEFAULT_LABELS: MediaEditorLabels = {
capture: {
requestingCamera: "Requesting camera…",
cameraDenied: {
title: "Camera access blocked",
body: "We need camera permission to continue. Enable it in your browser settings, or choose a file from your gallery.",
retryCta: "Try again",
useGalleryCta: "Use gallery instead",
},
galleryButton: "Choose from gallery",
switchCamera: "Switch camera",
modes: { photo: "Photo", video: "Video", text: "Text" },
},
toolbar: {
text: "Text",
draw: "Draw",
stickers: "Stickers",
filters: "Filters",
adjust: "Adjust",
crop: "Crop",
},
adjust: {
brightness: "Brightness",
contrast: "Contrast",
saturation: "Saturation",
blur: "Blur",
},
publish: {
exporting: "Exporting…",
cancel: "Cancel",
ready: "Ready",
},
discard: {
title: "Discard changes?",
body: "You'll lose your captured media and edits. This can't be undone.",
cancel: "Keep editing",
confirm: "Discard",
},
empty: {
noSource: "No source provided.",
},
};
src/registry/components/media/media-editor/hooks/use-drawing-stroke.ts
"use client";
import { useCallback, useRef, useState } from "react";
import type { DrawingStroke } from "../types";
export interface UseDrawingStrokeOptions {
color: string;
brushSize: number;
mode: "draw" | "erase";
/** Fires once when the user lifts the pointer — receives the completed stroke. */
onStrokeComplete: (stroke: DrawingStroke) => void;
}
export interface UseDrawingStrokeResult {
/** In-progress stroke (rendered live until pointer-up). */
currentStroke: DrawingStroke | null;
/** Bind onto Stage pointer events when draw tool is active. */
beginAt: (x: number, y: number) => void;
extendTo: (x: number, y: number) => void;
end: () => void;
cancel: () => void;
}
/**
* Pointer-frame collector for a single freehand stroke.
*
* The composer-editor wires Stage pointer events into beginAt/extendTo/end
* when the draw tool is active. Each completed stroke is dispatched once via
* `onStrokeComplete` — the parent records that as a single undo command (one
* command per stroke, not one per pointer frame).
*/
export function useDrawingStroke(
options: UseDrawingStrokeOptions,
): UseDrawingStrokeResult {
const { color, brushSize, mode, onStrokeComplete } = options;
const [currentStroke, setCurrentStroke] = useState<DrawingStroke | null>(
null,
);
const strokeRef = useRef<DrawingStroke | null>(null);
const beginAt = useCallback(
(x: number, y: number) => {
const id = `stroke-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const next: DrawingStroke = {
id,
points: [x, y],
color,
brushSize,
mode,
};
strokeRef.current = next;
setCurrentStroke(next);
},
[color, brushSize, mode],
);
const extendTo = useCallback((x: number, y: number) => {
const s = strokeRef.current;
if (!s) return;
s.points.push(x, y);
// Re-render — clone the points array so React notices.
setCurrentStroke({ ...s, points: [...s.points] });
}, []);
const end = useCallback(() => {
const s = strokeRef.current;
strokeRef.current = null;
setCurrentStroke(null);
if (!s || s.points.length < 4) return; // ignore single-point taps
onStrokeComplete(s);
}, [onStrokeComplete]);
const cancel = useCallback(() => {
strokeRef.current = null;
setCurrentStroke(null);
}, []);
return { currentStroke, beginAt, extendTo, end, cancel };
}
src/registry/components/media/media-editor/hooks/use-history.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export interface Command {
do: () => void;
undo: () => void;
label?: string;
}
export interface UseHistoryOptions {
/** Hard cap on undo depth. Oldest entries FIFO-evicted past this. Default 50. */
capacity?: number;
/** When true, Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z bind to undo/redo. */
bindKeyboard?: boolean;
}
export interface UseHistoryResult {
/** Run a command + push it onto the undo stack. Clears redo stack. */
execute: (cmd: Command) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
/** Drops both stacks (e.g., when the draft is replaced). */
reset: () => void;
}
/**
* Generic undo/redo command stack.
*
* Each command's `do()` is run on execute() and again on redo(); `undo()` is
* run on undo. The hook does NOT mediate state — callers wrap their state
* mutations into Command pairs.
*
* Drawing strokes record one command per completed stroke (not per pointer
* frame). Text/sticker add/delete/move/transform record one command per
* terminal action.
*/
export function useHistory(
options: UseHistoryOptions = {},
): UseHistoryResult {
const { capacity = 50, bindKeyboard = true } = options;
const pastRef = useRef<Command[]>([]);
const futureRef = useRef<Command[]>([]);
// Stack-depth flags mirrored into state by `tick()` after every mutation
// (execute / undo / redo / reset all end with tick()), so render reads
// state — never the refs — while the fresh object identity still forces a
// re-render exactly like the old force-counter did.
const [stackFlags, setStackFlags] = useState({
canUndo: false,
canRedo: false,
});
const tick = useCallback(() => {
setStackFlags({
canUndo: pastRef.current.length > 0,
canRedo: futureRef.current.length > 0,
});
}, []);
const execute = useCallback(
(cmd: Command) => {
cmd.do();
pastRef.current.push(cmd);
if (pastRef.current.length > capacity) {
pastRef.current.shift();
}
futureRef.current = [];
tick();
},
[capacity, tick],
);
const undo = useCallback(() => {
const cmd = pastRef.current.pop();
if (!cmd) return;
cmd.undo();
futureRef.current.push(cmd);
tick();
}, [tick]);
const redo = useCallback(() => {
const cmd = futureRef.current.pop();
if (!cmd) return;
cmd.do();
pastRef.current.push(cmd);
tick();
}, [tick]);
const reset = useCallback(() => {
pastRef.current = [];
futureRef.current = [];
tick();
}, [tick]);
// Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z keyboard shortcuts.
useEffect(() => {
if (!bindKeyboard || typeof window === "undefined") return;
const handler = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
if (e.key.toLowerCase() !== "z") return;
// Don't hijack while typing in an input/textarea.
const tgt = e.target as HTMLElement | null;
if (
tgt?.tagName === "INPUT" ||
tgt?.tagName === "TEXTAREA" ||
tgt?.isContentEditable
) {
return;
}
e.preventDefault();
if (e.shiftKey) redo();
else undo();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [bindKeyboard, undo, redo]);
return {
execute,
undo,
redo,
canUndo: stackFlags.canUndo,
canRedo: stackFlags.canRedo,
reset,
};
}
src/registry/components/media/media-editor/hooks/use-konva-selection.ts
"use client";
import { useCallback, useRef, useState } from "react";
import type Konva from "konva";
export type SelectableKind = "text" | "sticker" | "drawing";
export interface SelectedItem {
kind: SelectableKind;
id: string;
}
export interface UseKonvaSelectionResult {
selected: SelectedItem | null;
transformerRef: React.MutableRefObject<Konva.Transformer | null>;
/** Select an overlay item and attach the transformer to its node. */
select: (item: SelectedItem | null, node: Konva.Node | null) => void;
/** Clear selection (Esc / click on background / draft replaced). */
clear: () => void;
}
/**
* Konva.Transformer attach/detach helper.
*
* C6 lands the hook surface only — there are no selectable items yet
* (text/stickers/drawing land C8/C9/C10). Each tool wires its node into
* `select(item, node)`; the hook calls Transformer.nodes([node]).
*/
export function useKonvaSelection(): UseKonvaSelectionResult {
const [selected, setSelected] = useState<SelectedItem | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
const select = useCallback(
(item: SelectedItem | null, node: Konva.Node | null) => {
setSelected(item);
const t = transformerRef.current;
if (!t) return;
if (item && node) {
t.nodes([node]);
t.getLayer()?.batchDraw();
} else {
t.nodes([]);
t.getLayer()?.batchDraw();
}
},
[],
);
const clear = useCallback(() => {
select(null, null);
}, [select]);
return { selected, transformerRef, select, clear };
}
src/registry/components/media/media-editor/hooks/use-konva-stage-size.ts
"use client";
import { useEffect, useState, type RefObject } from "react";
export interface StageSize {
width: number;
height: number;
}
/**
* Observe the container element's content box and return its size in px.
*
* Konva.Stage needs explicit `width` + `height` props in pixels — it can't
* track CSS sizing on its own. This hook bridges that gap via ResizeObserver.
*/
export function useKonvaStageSize(
ref: RefObject<HTMLElement | null>,
): StageSize {
const [size, setSize] = useState<StageSize>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
// Seed from the initial bounding rect so first paint isn't 0×0.
const rect = el.getBoundingClientRect();
setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
// Prefer contentBoxSize when available (Safari 13.4+, Chrome 84+).
const box = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: entry.contentBoxSize;
const width = box
? box.inlineSize
: entry.contentRect.width;
const height = box
? box.blockSize
: entry.contentRect.height;
setSize({ width: Math.round(width), height: Math.round(height) });
});
observer.observe(el);
return () => observer.disconnect();
}, [ref]);
return size;
}
src/registry/components/media/media-editor/hooks/use-media-editor-state.ts
"use client";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import type {
AspectRatio,
ComposerMode,
ComposerStage,
CropRect,
DraftMedia,
DrawingToolConfig,
EditTool,
ImageAdjustments,
MediaEditorState,
PlacedSticker,
TextOnlyState,
TextOverlay,
TrimRange,
} from "../types";
import { DEFAULT_ADJUSTMENTS } from "../types";
import { DEFAULT_FONTS } from "../lib/defaults";
import { DEFAULT_TEXT_GRADIENTS } from "../lib/defaults";
/**
* Editor-shaped state machine for media-editor.
*
* Pure editor concerns only — no publish/upload state (those are consumer-owned,
* see plan §"What media-editor does NOT own"). story-composer v0.2.0's
* useStoryComposerState COMPOSES this hook and augments with publish state.
*
* Two state surfaces are managed here:
* - **`state` (MediaEditorState)** — the serializable snapshot returned by
* `getState()` for draft persistence. Holds mode / stage / mediaSrc /
* overlays / filter / adjustments / crop.
* - **Edit working state** (alongside `state`) — captured draft, video
* trim, selection ids, drawing tool config, text-only state, crop
* aspect. These shape ongoing edits but aren't part of the persistent
* snapshot.
*
* The split keeps the persistent snapshot clean (12 fields, all content)
* while letting the working state grow without breaking `loadState`
* call sites.
*/
// ─── Action surface (internal) ──────────────────────────────────────────
type Action =
| { type: "set-mode"; mode: ComposerMode | null }
| { type: "set-stage"; stage: ComposerStage }
| { type: "set-image-src"; src: string | null }
| { type: "set-video-blob"; blob: Blob | null }
| { type: "set-text-content"; content: string | null }
| { type: "add-text-overlay"; overlay: TextOverlay }
| { type: "update-text-overlay"; id: string; patch: Partial<TextOverlay> }
| { type: "remove-text-overlay"; id: string }
| { type: "add-sticker"; placed: PlacedSticker }
| { type: "update-sticker"; id: string; patch: Partial<PlacedSticker> }
| { type: "remove-sticker"; id: string }
| { type: "add-drawing-stroke"; stroke: import("../types").DrawingStroke }
| { type: "set-filter"; name: string | null }
| { type: "set-adjustments"; adjustments: Partial<ImageAdjustments> }
| { type: "set-crop"; crop: CropRect | null }
| { type: "clear-layer"; layer: "drawing" | "stickers" | "text" }
| { type: "load-state"; state: MediaEditorState }
| { type: "reset" };
// ─── Initial state ──────────────────────────────────────────────────────
const INITIAL_STATE: MediaEditorState = {
mode: null,
stage: "capture",
imageSrc: null,
videoBlob: null,
textBackground: null,
textContent: null,
textOverlays: [],
stickers: [],
drawingStrokes: [],
filter: null,
adjustments: { ...DEFAULT_ADJUSTMENTS },
crop: null,
};
const INITIAL_DRAWING_TOOL: DrawingToolConfig = {
color: "#ffffff",
brushSize: 8,
mode: "draw",
};
const INITIAL_TEXT_ONLY: TextOnlyState = {
text: "",
fontFamily: DEFAULT_FONTS[0]?.family ?? "Onest",
textColor: "#ffffff",
gradientId: DEFAULT_TEXT_GRADIENTS[0]?.id ?? "midnight",
};
// ─── Reducer ────────────────────────────────────────────────────────────
function reducer(state: MediaEditorState, action: Action): MediaEditorState {
switch (action.type) {
case "set-mode":
return { ...state, mode: action.mode };
case "set-stage":
return { ...state, stage: action.stage };
case "set-image-src":
return { ...state, imageSrc: action.src };
case "set-video-blob":
return { ...state, videoBlob: action.blob };
case "set-text-content":
return { ...state, textContent: action.content };
case "add-text-overlay":
return { ...state, textOverlays: [...state.textOverlays, action.overlay] };
case "update-text-overlay":
return {
...state,
textOverlays: state.textOverlays.map((o) =>
o.id === action.id ? { ...o, ...action.patch } : o,
),
};
case "remove-text-overlay":
return {
...state,
textOverlays: state.textOverlays.filter((o) => o.id !== action.id),
};
case "add-sticker":
return { ...state, stickers: [...state.stickers, action.placed] };
case "update-sticker":
return {
...state,
stickers: state.stickers.map((s) =>
s.id === action.id ? { ...s, ...action.patch } : s,
),
};
case "remove-sticker":
return {
...state,
stickers: state.stickers.filter((s) => s.id !== action.id),
};
case "add-drawing-stroke":
return {
...state,
drawingStrokes: [...state.drawingStrokes, action.stroke],
};
case "set-filter":
return { ...state, filter: action.name };
case "set-adjustments":
return {
...state,
adjustments: { ...state.adjustments, ...action.adjustments },
};
case "set-crop":
return { ...state, crop: action.crop };
case "clear-layer":
if (action.layer === "drawing") {
return { ...state, drawingStrokes: [] };
}
if (action.layer === "stickers") {
return { ...state, stickers: [] };
}
return { ...state, textOverlays: [], textContent: null };
case "load-state":
return action.state;
case "reset":
return INITIAL_STATE;
default:
return state;
}
}
// ─── Public hook ────────────────────────────────────────────────────────
export interface UseMediaEditorStateOptions {
defaultMode?: ComposerMode;
defaultCropAspect?: AspectRatio;
onDirtyChange?: (isDirty: boolean) => void;
}
export interface UseMediaEditorStateResult {
// === Persistent snapshot ===
state: MediaEditorState;
isDirty: boolean;
// === Working / UI state (not in MediaEditorState snapshot) ===
activeTool: EditTool | null;
draft: DraftMedia | null;
trim: TrimRange | null;
selectedTextId: string | null;
selectedStickerId: string | null;
drawingTool: DrawingToolConfig;
cropAspect: AspectRatio;
textOnly: TextOnlyState;
// === Persistent-snapshot actions ===
setMode: (mode: ComposerMode | null) => void;
setStage: (stage: ComposerStage) => void;
setImageSrc: (src: string | null) => void;
setVideoBlob: (blob: Blob | null) => void;
setTextContent: (content: string | null) => void;
addTextOverlay: (overlay: TextOverlay) => void;
updateTextOverlay: (id: string, patch: Partial<TextOverlay>) => void;
removeTextOverlay: (id: string) => void;
addSticker: (placed: PlacedSticker) => void;
updateSticker: (id: string, patch: Partial<PlacedSticker>) => void;
removeSticker: (id: string) => void;
addDrawingStroke: (stroke: import("../types").DrawingStroke) => void;
setFilter: (name: string | null) => void;
setAdjustments: (adjustments: Partial<ImageAdjustments>) => void;
setCrop: (crop: CropRect | null) => void;
clearLayer: (layer: "drawing" | "stickers" | "text") => void;
loadState: (state: MediaEditorState) => void;
reset: () => void;
// === Working-state actions ===
setActiveTool: (tool: EditTool | null) => void;
setDraft: (draft: DraftMedia | null) => void;
setTrim: (trim: TrimRange | null) => void;
setSelectedTextId: (id: string | null) => void;
setSelectedStickerId: (id: string | null) => void;
setDrawingTool: (patch: Partial<DrawingToolConfig>) => void;
setCropAspect: (aspect: AspectRatio) => void;
setTextOnly: (patch: Partial<TextOnlyState>) => void;
}
export function useMediaEditorState(
opts: UseMediaEditorStateOptions = {},
): UseMediaEditorStateResult {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE, (init) => ({
...init,
mode: opts.defaultMode ?? null,
}));
// Working state — not part of the persistent snapshot.
const [activeTool, setActiveToolState] = useState<EditTool | null>(null);
const [draft, setDraftState] = useState<DraftMedia | null>(null);
const [trim, setTrimState] = useState<TrimRange | null>(null);
const [selectedTextId, setSelectedTextIdState] = useState<string | null>(
null,
);
const [selectedStickerId, setSelectedStickerIdState] = useState<
string | null
>(null);
const [drawingTool, setDrawingToolState] = useState<DrawingToolConfig>(
INITIAL_DRAWING_TOOL,
);
const [cropAspect, setCropAspectState] = useState<AspectRatio>(
opts.defaultCropAspect ?? "9:16",
);
const [textOnly, setTextOnlyState] = useState<TextOnlyState>(INITIAL_TEXT_ONLY);
// Revoke draft object URL on replacement / unmount. The revoke runs OUTSIDE
// the setState updater — updaters must stay pure (React 19 StrictMode
// double-invokes them). draftUrlRef mirrors the last committed URL, and
// every draft write goes through setDraft, so it's always current.
const draftUrlRef = useRef<string | null>(null);
const setDraft = useCallback((next: DraftMedia | null) => {
const prevUrl = draftUrlRef.current;
if (prevUrl && prevUrl !== next?.url) {
URL.revokeObjectURL(prevUrl);
}
draftUrlRef.current = next?.url ?? null;
setDraftState(next);
}, []);
useEffect(() => {
return () => {
if (draftUrlRef.current) URL.revokeObjectURL(draftUrlRef.current);
};
}, []);
const setActiveTool = useCallback((tool: EditTool | null) => {
setActiveToolState(tool);
}, []);
const setTrim = useCallback((next: TrimRange | null) => {
setTrimState(next);
}, []);
const setSelectedTextId = useCallback((id: string | null) => {
setSelectedTextIdState(id);
}, []);
const setSelectedStickerId = useCallback((id: string | null) => {
setSelectedStickerIdState(id);
}, []);
const setDrawingTool = useCallback((patch: Partial<DrawingToolConfig>) => {
setDrawingToolState((prev) => ({ ...prev, ...patch }));
}, []);
const setCropAspect = useCallback((aspect: AspectRatio) => {
setCropAspectState(aspect);
}, []);
const setTextOnly = useCallback((patch: Partial<TextOnlyState>) => {
setTextOnlyState((prev) => ({ ...prev, ...patch }));
}, []);
// Dirty derivation: any non-initial state counts as dirty. Includes draft
// (capture-without-edit IS dirty per description §7) and text-only edits.
const isDirty =
state.mode !== null ||
state.imageSrc !== null ||
state.videoBlob !== null ||
state.textContent !== null ||
state.textOverlays.length > 0 ||
state.stickers.length > 0 ||
state.drawingStrokes.length > 0 ||
state.filter !== null ||
state.crop !== null ||
draft !== null ||
textOnly.text.length > 0;
// Fire onDirtyChange when isDirty flips. Effect skips the initial mount.
const prevDirtyRef = useRef<boolean | null>(null);
const onDirtyChangeRef = useRef(opts.onDirtyChange);
useEffect(() => {
onDirtyChangeRef.current = opts.onDirtyChange;
});
useEffect(() => {
if (prevDirtyRef.current !== null && prevDirtyRef.current !== isDirty) {
onDirtyChangeRef.current?.(isDirty);
}
prevDirtyRef.current = isDirty;
}, [isDirty]);
// Reset clears BOTH the persistent snapshot AND the working state.
const reset = useCallback(() => {
dispatch({ type: "reset" });
setDraft(null);
setTrimState(null);
setActiveToolState(null);
setSelectedTextIdState(null);
setSelectedStickerIdState(null);
setTextOnlyState(INITIAL_TEXT_ONLY);
setDrawingToolState(INITIAL_DRAWING_TOOL);
}, [setDraft]);
return {
state,
isDirty,
activeTool,
draft,
trim,
selectedTextId,
selectedStickerId,
drawingTool,
cropAspect,
textOnly,
setMode: (mode) => dispatch({ type: "set-mode", mode }),
setStage: (stage) => dispatch({ type: "set-stage", stage }),
setImageSrc: (src) => dispatch({ type: "set-image-src", src }),
setVideoBlob: (blob) => dispatch({ type: "set-video-blob", blob }),
setTextContent: (content) =>
dispatch({ type: "set-text-content", content }),
addTextOverlay: (overlay) =>
dispatch({ type: "add-text-overlay", overlay }),
updateTextOverlay: (id, patch) =>
dispatch({ type: "update-text-overlay", id, patch }),
removeTextOverlay: (id) =>
dispatch({ type: "remove-text-overlay", id }),
addSticker: (placed) => dispatch({ type: "add-sticker", placed }),
updateSticker: (id, patch) =>
dispatch({ type: "update-sticker", id, patch }),
removeSticker: (id) => dispatch({ type: "remove-sticker", id }),
addDrawingStroke: (stroke) =>
dispatch({ type: "add-drawing-stroke", stroke }),
setFilter: (name) => dispatch({ type: "set-filter", name }),
setAdjustments: (adjustments) =>
dispatch({ type: "set-adjustments", adjustments }),
setCrop: (crop) => dispatch({ type: "set-crop", crop }),
clearLayer: (layer) => dispatch({ type: "clear-layer", layer }),
loadState: (s) => dispatch({ type: "load-state", state: s }),
reset,
setActiveTool,
setDraft,
setTrim,
setSelectedTextId,
setSelectedStickerId,
setDrawingTool,
setCropAspect,
setTextOnly,
};
}
src/registry/components/media/media-editor/hooks/use-pan-zoom.ts
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent,
type RefObject,
} from "react";
export interface PanZoomTransform {
scale: number;
x: number;
y: number;
}
export interface UsePanZoomOptions {
/**
* Container element receiving the gestures. The wheel listener is attached
* here as a NATIVE non-passive listener so `e.preventDefault()` actually
* blocks the browser's default Ctrl+wheel page-zoom. React's `onWheel` is
* registered as passive in modern browsers and can't preventDefault.
*/
targetRef: RefObject<HTMLElement | null>;
/** When false, all gestures + keys are no-ops. Default true. */
enabled?: boolean;
/**
* When true, the hook listens for keydown (arrows / +/- / 0) ON THE
* `targetRef` element — NOT on `window` — so the keys only act while the
* canvas (or a child) holds focus and never hijack arrow keys elsewhere on
* the page. The target must be focusable (e.g. `tabIndex={0}`) to receive
* them. Default true.
*/
bindKeyboard?: boolean;
/** Min zoom. Default 1 (full-screen fit baseline). */
minScale?: number;
/** Max zoom. Default 4. */
maxScale?: number;
/** Keyboard pan step in px. Default 32. */
panStep?: number;
/** Keyboard zoom step (multiplicative). Default 1.15. */
zoomStep?: number;
/**
* Called on single-pointer pointer-down to decide whether this gesture should
* drag-pan the canvas. Return false to yield the pointer to whatever is
* underneath (e.g. a draggable text/sticker overlay). Receives only the
* pointer's viewport coordinates — enough to hit-test — so callers needn't
* accept a full React event. Default: always pan.
*/
shouldStartPan?: (point: { clientX: number; clientY: number }) => boolean;
/** Movement in px before a single-pointer drag counts as a pan (tap tolerance). Default 4. */
panThreshold?: number;
}
export interface UsePanZoomResult {
transform: PanZoomTransform;
/** Whether the transform is at the identity (1× / 0,0) — drives the "reset" CTA. */
isIdentity: boolean;
/** Attach to a wrapper element receiving the gestures. */
handlers: {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
onPointerCancel: (e: PointerEvent) => void;
};
reset: () => void;
/** Programmatic zoom. `center` is in container coordinates. */
zoomBy: (factor: number, center?: { x: number; y: number }) => void;
}
interface PointerInfo {
id: number;
x: number;
y: number;
}
/**
* Pan + pinch-zoom hook for the Konva editor.
*
* Touch:
* - 2-finger pinch → zoom anchored to the midpoint
* - 2-finger pan → translate by the midpoint delta
* - Single finger → drag-pan the canvas, UNLESS `shouldStartPan` vetoes the
* gesture (the consumer hit-tests so a finger landing on a draggable
* text/sticker overlay drags that overlay instead). A tap (movement under
* `panThreshold`) never pans, so selection/placement still works.
*
* Desktop:
* - Click-drag over the canvas → pan (same `shouldStartPan` gate).
* - Plain wheel over the canvas → zoom anchored to the cursor. The wheel
* listener is attached natively with `passive: false` + always
* preventDefaults, so the browser's Ctrl+wheel page-zoom doesn't ALSO
* zoom the page when the cursor is over the canvas. Trackpad pinch
* (which browsers report as wheel.ctrlKey=true) is covered too.
* - Vertical scroll over non-canvas areas works normally — the listener
* is scoped to the targetRef element.
*
* Keyboard (when bindKeyboard=true, the targetRef element is focused, and no
* text input is focused). Bound to the target element (not window) so the keys
* are focus-scoped to the canvas:
* - Arrow keys → pan (step = panStep)
* - + / = → zoom in
* - - / _ → zoom out
* - 0 → reset
*
* Transform is returned for the consumer to apply (e.g. as Konva.Stage
* scale + position props).
*/
export function usePanZoom(
options: UsePanZoomOptions,
): UsePanZoomResult {
const {
targetRef,
enabled = true,
bindKeyboard = true,
minScale = 1,
maxScale = 4,
panStep = 32,
zoomStep = 1.15,
shouldStartPan,
panThreshold = 4,
} = options;
const [transform, setTransform] = useState<PanZoomTransform>({
scale: 1,
x: 0,
y: 0,
});
const pointersRef = useRef<PointerInfo[]>([]);
const pinchStartRef = useRef<{
dist: number;
midX: number;
midY: number;
transform: PanZoomTransform;
} | null>(null);
// Single-pointer drag-pan bookkeeping. `active` flips once movement crosses
// panThreshold (so a tap stays a tap); `allowed` is the shouldStartPan verdict.
const panStateRef = useRef<{
id: number;
lastX: number;
lastY: number;
startX: number;
startY: number;
active: boolean;
allowed: boolean;
} | null>(null);
const clamp = useCallback(
(s: number) => Math.max(minScale, Math.min(maxScale, s)),
[minScale, maxScale],
);
const reset = useCallback(() => {
setTransform({ scale: 1, x: 0, y: 0 });
}, []);
// Zoom by `factor` around `center` (in container coordinates).
const zoomBy = useCallback(
(factor: number, center?: { x: number; y: number }) => {
setTransform((prev) => {
const nextScale = clamp(prev.scale * factor);
if (nextScale === prev.scale) return prev;
const cx = center?.x ?? 0;
const cy = center?.y ?? 0;
// Keep the world point under the cursor stationary:
// world = (screen - pos) / scale
// newPos = screen - world * newScale
const worldX = (cx - prev.x) / prev.scale;
const worldY = (cy - prev.y) / prev.scale;
return {
scale: nextScale,
x: cx - worldX * nextScale,
y: cy - worldY * nextScale,
};
});
},
[clamp],
);
const panBy = useCallback((dx: number, dy: number) => {
setTransform((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
}, []);
// ─── Pointer / pinch gestures ────────────────────────────────────────
const onPointerDown = useCallback(
(e: PointerEvent) => {
if (!enabled) return;
pointersRef.current = [
...pointersRef.current.filter((p) => p.id !== e.pointerId),
{ id: e.pointerId, x: e.clientX, y: e.clientY },
];
if (pointersRef.current.length === 2) {
const [a, b] = pointersRef.current;
const dx = b.x - a.x;
const dy = b.y - a.y;
pinchStartRef.current = {
dist: Math.hypot(dx, dy),
midX: (a.x + b.x) / 2,
midY: (a.y + b.y) / 2,
transform,
};
// A second pointer joined → pinch owns the gesture; cancel any
// single-pointer pan in progress.
panStateRef.current = null;
} else if (pointersRef.current.length === 1) {
// Arm a single-pointer drag-pan. Whether it engages is decided by
// shouldStartPan (so overlay drags can claim the pointer) and by
// crossing panThreshold (so taps still register as taps).
panStateRef.current = {
id: e.pointerId,
lastX: e.clientX,
lastY: e.clientY,
startX: e.clientX,
startY: e.clientY,
active: false,
allowed: shouldStartPan ? shouldStartPan(e) : true,
};
}
},
[enabled, transform, shouldStartPan],
);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!enabled) return;
const ptrs = pointersRef.current;
const idx = ptrs.findIndex((p) => p.id === e.pointerId);
if (idx === -1) return;
ptrs[idx] = { id: e.pointerId, x: e.clientX, y: e.clientY };
// Single-pointer drag-pan (desktop click-drag + touch one-finger).
const ps = panStateRef.current;
if (ptrs.length === 1 && ps && ps.id === e.pointerId && ps.allowed) {
if (!ps.active) {
if (
Math.hypot(e.clientX - ps.startX, e.clientY - ps.startY) <
panThreshold
) {
return;
}
// Crossing the threshold: anchor the pan to HERE so the first frame
// doesn't jump by the accumulated pre-threshold distance (up to
// panThreshold px). Subsequent frames compute incremental deltas.
ps.active = true;
ps.lastX = e.clientX;
ps.lastY = e.clientY;
return;
}
const dx = e.clientX - ps.lastX;
const dy = e.clientY - ps.lastY;
ps.lastX = e.clientX;
ps.lastY = e.clientY;
panBy(dx, dy);
return;
}
if (ptrs.length === 2 && pinchStartRef.current) {
const [a, b] = ptrs;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const start = pinchStartRef.current;
const rawScale = (start.transform.scale * dist) / start.dist;
const nextScale = clamp(rawScale);
// Anchor the start midpoint's world coords under the current midpoint.
const worldX = (start.midX - start.transform.x) / start.transform.scale;
const worldY = (start.midY - start.transform.y) / start.transform.scale;
setTransform({
scale: nextScale,
x: midX - worldX * nextScale,
y: midY - worldY * nextScale,
});
}
},
[clamp, enabled, panBy, panThreshold],
);
const onPointerUp = useCallback((e: PointerEvent) => {
pointersRef.current = pointersRef.current.filter(
(p) => p.id !== e.pointerId,
);
if (pointersRef.current.length < 2) pinchStartRef.current = null;
if (panStateRef.current?.id === e.pointerId) panStateRef.current = null;
}, []);
const onPointerCancel = onPointerUp;
// ─── Wheel (native non-passive — beats browser page-zoom) ────────────
// Plain wheel (no modifier) zooms when over the canvas. Always
// preventDefaults so the browser doesn't also zoom the page.
useEffect(() => {
if (!enabled) return;
const el = targetRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const center = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
const factor = e.deltaY < 0 ? zoomStep : 1 / zoomStep;
zoomBy(factor, center);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [enabled, targetRef, zoomBy, zoomStep]);
// ─── Keyboard ────────────────────────────────────────────────────────
useEffect(() => {
if (!enabled || !bindKeyboard) return;
// Focus-scoped: listen on the target element, not window, so the keys only
// act while the canvas holds focus and never hijack arrow keys/zoom on the
// rest of the page. Requires the target to be focusable (tabIndex).
const el = targetRef.current;
if (!el) return;
const handler = (e: KeyboardEvent) => {
// Don't hijack while typing into a nested input.
const tgt = e.target as HTMLElement | null;
if (
tgt?.tagName === "INPUT" ||
tgt?.tagName === "TEXTAREA" ||
tgt?.isContentEditable
) {
return;
}
// Arrow keys move the IMAGE in the arrow's direction (the natural
// mental model when you're looking at media you want to reposition),
// not the viewport. ArrowRight ⇒ image shifts right ⇒ transform.x +.
switch (e.key) {
case "ArrowLeft":
panBy(-panStep, 0);
e.preventDefault();
break;
case "ArrowRight":
panBy(panStep, 0);
e.preventDefault();
break;
case "ArrowUp":
panBy(0, -panStep);
e.preventDefault();
break;
case "ArrowDown":
panBy(0, panStep);
e.preventDefault();
break;
case "+":
case "=":
zoomBy(zoomStep);
e.preventDefault();
break;
case "-":
case "_":
zoomBy(1 / zoomStep);
e.preventDefault();
break;
case "0":
reset();
e.preventDefault();
break;
}
};
el.addEventListener("keydown", handler);
return () => el.removeEventListener("keydown", handler);
}, [bindKeyboard, enabled, targetRef, panBy, panStep, reset, zoomBy, zoomStep]);
return {
transform,
isIdentity:
transform.scale === 1 && transform.x === 0 && transform.y === 0,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
},
reset,
zoomBy,
};
}
src/registry/components/media/media-editor/lib/built-in-stickers.ts
import type { StickerOption, StickerSet } from "../types";
/**
* Built-in emoji stickers (Q-P9a — inline data URLs in TS).
*
* Implemented as SVG-text data URLs rather than base64 PNGs:
* - Each sticker is ~180 bytes vs ~3-5KB for an equivalent PNG.
* - Vector — scales infinitely with Transformer.
* - Renders consistently regardless of host emoji-font (the SVG inlines
* the unicode codepoint and the browser renders with its system emoji).
*
* Total payload for the 36 stickers below: ~6KB → no measurable bundle
* bloat (well under the ~80KB ceiling that would trigger a switch to
* the separate-PNG-files pattern from description §15 Q-P9b).
*/
function emojiSticker(id: string, emoji: string, alt: string): StickerOption {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><text x="128" y="170" font-size="200" text-anchor="middle">${emoji}</text></svg>`;
return {
id,
src: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,
alt,
width: 256,
height: 256,
};
}
const FACES: StickerOption[] = [
emojiSticker("smile", "😀", "Smile"),
emojiSticker("laugh", "😂", "Laugh"),
emojiSticker("heart-eyes", "😍", "Heart eyes"),
emojiSticker("kiss", "😘", "Kiss"),
emojiSticker("cool", "😎", "Cool"),
emojiSticker("wink", "😉", "Wink"),
emojiSticker("thinking", "🤔", "Thinking"),
emojiSticker("party", "🥳", "Party"),
emojiSticker("sleepy", "😴", "Sleepy"),
];
const HEARTS: StickerOption[] = [
emojiSticker("heart-red", "❤️", "Red heart"),
emojiSticker("heart-orange", "🧡", "Orange heart"),
emojiSticker("heart-yellow", "💛", "Yellow heart"),
emojiSticker("heart-green", "💚", "Green heart"),
emojiSticker("heart-blue", "💙", "Blue heart"),
emojiSticker("heart-purple", "💜", "Purple heart"),
emojiSticker("heart-black", "🖤", "Black heart"),
emojiSticker("heart-broken", "💔", "Broken heart"),
emojiSticker("heart-sparkle", "💖", "Sparkle heart"),
];
const SYMBOLS: StickerOption[] = [
emojiSticker("fire", "🔥", "Fire"),
emojiSticker("star", "⭐", "Star"),
emojiSticker("sparkles", "✨", "Sparkles"),
emojiSticker("crown", "👑", "Crown"),
emojiSticker("rocket", "🚀", "Rocket"),
emojiSticker("rainbow", "🌈", "Rainbow"),
emojiSticker("sun", "☀️", "Sun"),
emojiSticker("moon", "🌙", "Moon"),
emojiSticker("lightning", "⚡", "Lightning"),
];
const HANDS: StickerOption[] = [
emojiSticker("thumbs-up", "👍", "Thumbs up"),
emojiSticker("clap", "👏", "Clap"),
emojiSticker("raised-hands", "🙌", "Raised hands"),
emojiSticker("peace", "✌️", "Peace"),
emojiSticker("ok-hand", "👌", "OK hand"),
emojiSticker("muscle", "💪", "Muscle"),
emojiSticker("pray", "🙏", "Pray"),
emojiSticker("wave", "👋", "Wave"),
emojiSticker("point-up", "☝️", "Point up"),
];
export const BUILT_IN_STICKER_SETS: StickerSet[] = [
{ id: "faces", label: "Faces", stickers: FACES },
{ id: "hearts", label: "Hearts", stickers: HEARTS },
{ id: "symbols", label: "Symbols", stickers: SYMBOLS },
{ id: "hands", label: "Hands", stickers: HANDS },
];
/**
* Merge consumer-supplied sticker sets with the built-in catalog.
* `replace=true` → consumer set wins entirely.
*/
export function resolveStickerSets(
consumer: StickerSet[] | undefined,
replace: boolean,
): StickerSet[] {
if (replace) return consumer ?? [];
if (!consumer || consumer.length === 0) return BUILT_IN_STICKER_SETS;
return [...BUILT_IN_STICKER_SETS, ...consumer];
}
src/registry/components/media/media-editor/lib/composite-video.ts
import { selectRecorderMime } from "./mime-fallback";
export interface CompositeVideoOptions {
sourceBlob: Blob;
/** Seconds — trim range. Optional; defaults to full duration. */
startSec?: number;
endSec?: number;
/** Renders one frame onto the canvas. Called per RAF tick. */
renderFrame: (
ctx: CanvasRenderingContext2D,
sourceVideo: HTMLVideoElement,
) => void;
/** Output dimensions — usually crop size. */
outputWidth: number;
outputHeight: number;
/** Capture frame rate. Default 30. */
fps?: number;
/** Override mime selection; falls back to selectRecorderMime() chain. */
mimeType?: string;
/** MediaRecorder bitsPerSecond (browser default if omitted). */
bitsPerSecond?: number;
/**
* 0..1; fires ~10× during re-encode keyed off video.currentTime /
* (endSec - startSec). Capped at 1 to avoid overshoot on slow ticks.
*/
onProgress?: (progress: number) => void;
}
export interface CompositeVideoResult {
blob: Blob;
durationMs: number;
mimeType: string;
}
/**
* Bake text/sticker/drawing overlays into a video file (Q-P1a — full
* Instagram parity).
*
* Pipeline:
* 1. Play the source video in a hidden <video> from startSec → endSec
* 2. Each requestVideoFrameCallback (or RAF) tick, renderFrame() draws
* both the video frame AND the overlay snapshot onto an offscreen
* <canvas> at outputWidth × outputHeight
* 3. canvas.captureStream(fps) feeds a new MediaRecorder
* 4. Audio tracks from the source video are mixed into the output stream
* 5. On video-end (or endSec), stop the recorder; resolve with the blob
*
* The caller owns the overlay rendering (`renderFrame`) — typically that
* function snapshots the current Konva stage with stage.toCanvas() and
* draws it on top of the video frame. The composer wires that hook up at
* publish time.
*/
export async function compositeVideo(
opts: CompositeVideoOptions,
): Promise<CompositeVideoResult> {
const {
sourceBlob,
startSec,
endSec,
renderFrame,
outputWidth,
outputHeight,
fps = 30,
mimeType,
bitsPerSecond,
onProgress,
} = opts;
const mime = mimeType ?? selectRecorderMime();
if (!mime) {
throw new Error(
"This browser does not support MediaRecorder — video publish unavailable.",
);
}
const sourceUrl = URL.createObjectURL(sourceBlob);
const video = document.createElement("video");
video.src = sourceUrl;
video.muted = false;
video.playsInline = true;
video.crossOrigin = "anonymous";
const canvas = document.createElement("canvas");
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
try {
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error("Source video failed to load"));
});
const begin = startSec ?? 0;
const end = endSec ?? video.duration;
video.currentTime = begin;
// Build the output stream: canvas video + source audio (if any).
const canvasStream = canvas.captureStream(fps);
// Mix in original audio so the export isn't silent.
try {
// captureStream on the video element only works in some browsers;
// fall back to a MediaStreamAudioSourceNode chain otherwise.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vAny = video as any;
if (typeof vAny.captureStream === "function") {
const srcStream: MediaStream = vAny.captureStream();
for (const track of srcStream.getAudioTracks()) {
canvasStream.addTrack(track);
}
} else if (typeof vAny.mozCaptureStream === "function") {
const srcStream: MediaStream = vAny.mozCaptureStream();
for (const track of srcStream.getAudioTracks()) {
canvasStream.addTrack(track);
}
}
} catch {
/* audio mixing best-effort; some browsers lock down crossorigin */
}
const recorder = new MediaRecorder(canvasStream, {
mimeType: mime,
...(bitsPerSecond !== undefined ? { bitsPerSecond } : {}),
});
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunks.push(e.data);
};
const startedAt = Date.now();
// Progress is bucketed into 10% steps to satisfy the ExportVideoOpts
// "fires ~10× during re-encode" contract without flooding the callback.
const duration = Math.max(end - begin, 0.001);
let lastBucket = -1;
onProgress?.(0);
// RAF loop drives the canvas frames. We stop when the video crosses
// `end`, or earlier if the video ends naturally.
let raf = 0;
// Shared teardown for EVERY exit path (stop / recorder error / play
// rejection). Without it an error path leaves the hidden source video
// playing and the captureStream tracks (canvas video + mixed-in source
// audio) live until GC.
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
cancelAnimationFrame(raf);
video.pause();
for (const track of canvasStream.getTracks()) track.stop();
};
let failed = false;
const tick = () => {
if (video.currentTime >= end || video.ended) {
if (recorder.state === "recording") recorder.stop();
return;
}
renderFrame(ctx, video);
if (onProgress) {
const elapsed = Math.max(video.currentTime - begin, 0);
const bucket = Math.min(9, Math.floor((elapsed / duration) * 10));
if (bucket > lastBucket) {
lastBucket = bucket;
onProgress((bucket + 1) / 10);
}
}
raf = requestAnimationFrame(tick);
};
return await new Promise<CompositeVideoResult>((resolve, reject) => {
recorder.onstop = () => {
cleanup();
if (failed) return; // stop forced by a failure path — already rejected
onProgress?.(1);
const blob = new Blob(chunks, { type: mime });
resolve({ blob, durationMs: Date.now() - startedAt, mimeType: mime });
};
recorder.onerror = (ev) => {
failed = true;
cleanup();
const e =
(ev as unknown as { error?: Error }).error ??
new Error("MediaRecorder failed during composite");
reject(e);
};
recorder.start(250);
video
.play()
.then(() => {
tick();
})
.catch((err) => {
failed = true;
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
/* already stopped */
}
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
});
});
} finally {
URL.revokeObjectURL(sourceUrl);
}
}
src/registry/components/media/media-editor/lib/defaults.ts
import type { FontOption, GradientPreset } from "../types";
// ─── Gradient presets for text-only mode ────────────────────────────────
// GradientPreset type moved to ../types.ts in v0.1.0 of media-editor.
export const DEFAULT_TEXT_GRADIENTS: GradientPreset[] = [
{
id: "lime",
label: "Lime",
background:
"linear-gradient(135deg, oklch(0.86 0.18 132) 0%, oklch(0.62 0.16 145) 100%)",
},
{
id: "sunset",
label: "Sunset",
background:
"linear-gradient(135deg, oklch(0.78 0.18 35) 0%, oklch(0.58 0.22 15) 100%)",
},
{
id: "lavender",
label: "Lavender",
background:
"linear-gradient(135deg, oklch(0.78 0.14 290) 0%, oklch(0.56 0.18 305) 100%)",
},
{
id: "ocean",
label: "Ocean",
background:
"linear-gradient(135deg, oklch(0.72 0.14 220) 0%, oklch(0.45 0.18 245) 100%)",
},
{
id: "mono",
label: "Mono",
background:
"linear-gradient(135deg, oklch(0.30 0.005 250) 0%, oklch(0.13 0.006 250) 100%)",
},
{
id: "fire",
label: "Fire",
background:
"linear-gradient(135deg, oklch(0.82 0.20 60) 0%, oklch(0.55 0.22 25) 100%)",
},
{
id: "rose",
label: "Rose",
background:
"linear-gradient(135deg, oklch(0.80 0.16 10) 0%, oklch(0.58 0.20 350) 100%)",
},
{
id: "midnight",
label: "Midnight",
background:
"linear-gradient(135deg, oklch(0.32 0.10 270) 0%, oklch(0.18 0.04 250) 100%)",
},
];
// ─── Default fonts (Onest + JetBrains Mono first per design system) ─────
export const DEFAULT_FONTS: FontOption[] = [
{ id: "onest", label: "Onest", family: "var(--font-onest, Onest, sans-serif)" },
{
id: "mono",
label: "Mono",
family: "var(--font-mono, 'JetBrains Mono', monospace)",
},
{ id: "serif", label: "Serif", family: "Georgia, 'Times New Roman', serif" },
{ id: "display", label: "Display", family: "'Playfair Display', Georgia, serif" },
{ id: "rounded", label: "Rounded", family: "system-ui, -apple-system, sans-serif" },
{ id: "handwriting", label: "Handwriting", family: "'Comic Sans MS', cursive" },
{ id: "impact", label: "Impact", family: "Impact, 'Arial Black', sans-serif" },
{ id: "courier", label: "Courier", family: "'Courier New', monospace" },
];
// ─── Default color palette ──────────────────────────────────────────────
export const DEFAULT_COLOR_PRESETS: string[] = [
"#ffffff",
"#000000",
"#ef4444",
"#f97316",
"#eab308",
"#84cc16",
"#22c55e",
"#14b8a6",
"#0ea5e9",
"#6366f1",
"#a855f7",
"#ec4899",
];
src/registry/components/media/media-editor/lib/dialog-size-for-aspect.ts
import type { AspectRatio } from "../types";
export interface DialogDims {
/** CSS `aspect-ratio` value, e.g. `"9 / 16"`. */
aspectRatio: string;
/** Drives which dimension is the viewport-relative driver. */
orientation: "portrait" | "landscape";
}
/**
* Aspect → desktop dialog sizing hints per description §6.
*
* Mobile (< md breakpoint) always falls back to fullscreen; these hints only
* take effect at the `md:` breakpoint, where the dialog is constructed as a
* viewport-relative box constrained by `aspect-ratio` — height-driven for
* portrait aspects, width-driven for landscape. The CSS in media-editor.tsx
* sets the driver dimension to a viewport-relative size (e.g. `85dvh`) and
* lets `aspect-ratio` compute the other.
*
* v0.1 does NOT expose a width-override prop — consumers needing custom sizing
* pass `presentation="inline"` and own the wrapper.
*/
const DIALOG_DIMS_FOR_ASPECT: Record<AspectRatio, DialogDims> = {
"9:16": { aspectRatio: "9 / 16", orientation: "portrait" },
"1:1": { aspectRatio: "1 / 1", orientation: "portrait" },
"16:9": { aspectRatio: "16 / 9", orientation: "landscape" },
"4:5": { aspectRatio: "4 / 5", orientation: "portrait" },
free: { aspectRatio: "4 / 3", orientation: "landscape" },
};
export function dialogDimsForAspect(aspect: AspectRatio): DialogDims {
return DIALOG_DIMS_FOR_ASPECT[aspect];
}
src/registry/components/media/media-editor/lib/export-blob.ts
import type Konva from "konva";
import type { CropRect } from "../types";
export interface ExportPhotoOptions {
stage: Konva.Stage;
cropRect?: CropRect | null;
/** Pixel ratio multiplier. Default 2 for retina sharpness. */
pixelRatio?: number;
/** Default "image/jpeg" per Q-P3 lock. PNG and WebP also supported. */
mimeType?: "image/jpeg" | "image/png" | "image/webp";
/** 0..1; default 0.9 per Q-P3. Only meaningful for jpeg / webp. */
quality?: number;
/** Fires (0) at start and (1) at end. Two-tick contract for image export. */
onProgress?: (progress: number) => void;
}
/**
* Export the Konva stage to a Blob at the crop rect (or full stage if none).
*
* Uses stage.toCanvas() with x/y/width/height to write only the crop region.
* stage.toBlob() is not available in all Konva versions; the canvas →
* toBlob() path is more portable.
*
* Progress emits twice for jpeg/png/webp (start=0, end=1) per the
* ExportImageOpts.onProgress contract. WebP encoders in some browsers may
* yield mid-encode but we don't expose those intermediate ticks.
*/
export function exportPhotoBlob(opts: ExportPhotoOptions): Promise<Blob> {
const {
stage,
cropRect,
pixelRatio = 2,
mimeType = "image/jpeg",
quality = 0.9,
onProgress,
} = opts;
const region = cropRect ?? {
x: 0,
y: 0,
width: stage.width(),
height: stage.height(),
};
onProgress?.(0);
const canvas = stage.toCanvas({
x: region.x,
y: region.y,
width: region.width,
height: region.height,
pixelRatio,
});
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => {
if (b) {
onProgress?.(1);
resolve(b);
} else {
reject(new Error("Canvas.toBlob returned null"));
}
},
mimeType,
quality,
);
});
}
/** Copy every computed style property from `src` onto `dst` inline. The
* serialized SVG has no stylesheet, so class-driven styles (Tailwind) would
* otherwise be lost in the rasterized output. The text-only surface is tiny
* (a handful of nodes), so the full property walk is cheap. */
function inlineComputedStyles(src: Element, dst: HTMLElement): void {
const cs = window.getComputedStyle(src);
for (let i = 0; i < cs.length; i++) {
const prop = cs.item(i);
dst.style.setProperty(prop, cs.getPropertyValue(prop));
}
}
/** Clone `source` for serialization: inline computed styles on every element
* and swap textareas for divs carrying their live `.value` (a controlled
* textarea's value lives on the DOM property, which XMLSerializer drops). */
function prepareExportClone(
source: HTMLElement,
width: number,
height: number,
): HTMLElement {
const clone = source.cloneNode(true) as HTMLElement;
const srcNodes: Element[] = [source, ...source.querySelectorAll("*")];
const dstNodes: Element[] = [clone, ...clone.querySelectorAll("*")];
for (let i = 0; i < srcNodes.length; i++) {
const src = srcNodes[i];
const dst = dstNodes[i] as HTMLElement;
if (src instanceof HTMLTextAreaElement) {
const div = clone.ownerDocument.createElement("div");
div.textContent = src.value;
inlineComputedStyles(src, div);
div.style.whiteSpace = "pre-wrap";
div.style.overflow = "hidden";
dst.replaceWith(div);
} else {
inlineComputedStyles(src, dst);
}
}
// Pin the root to the export dimensions (the live element is positioned by
// its parent, which isn't serialized).
clone.style.position = "static";
clone.style.width = `${width}px`;
clone.style.height = `${height}px`;
return clone;
}
/**
* Export a text-only mode capture from the styled DOM surface.
*
* SVG-foreignObject technique: clone the element (computed styles inlined,
* textarea values materialized), serialise it into an SVG, then draw the SVG
* onto a 2× canvas. Avoids a third-party html-to-canvas dependency.
*/
export async function exportTextOnlyBlob(
source: HTMLElement,
width: number,
height: number,
): Promise<Blob> {
const xml = new XMLSerializer().serializeToString(
prepareExportClone(source, width, height),
);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="100%" height="100%">${xml}</foreignObject></svg>`;
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Text-mode SVG render failed"));
img.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context unavailable");
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0, width, height);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob null"))),
"image/png",
);
});
} finally {
URL.revokeObjectURL(url);
}
}
src/registry/components/media/media-editor/lib/initial-source-loader.ts
// initial-source-loader — InitialSource intake + validation (C9).
//
// Pure helpers (no React). Resolves `InitialSource` → either a loaded
// payload (object URL + derived mode + blob) or a `SourceError` per
// description §5 validation rules:
//
// - For `kind: "file"`, mode is derived from `File.type` prefix.
// - For `kind: "url"`, `fetch()` is attempted; CORS / network failures
// surface as `kind: "cors"` or `kind: "fetch-failed"`.
// - For `kind: "blob"`, mode is the consumer-supplied value.
// - In all paths, the resolved mode MUST be a member of `enabledModes`.
// Mismatch → `kind: "mode-not-enabled"`.
// - Empty / blank blobs → `kind: "invalid-blob"`.
//
// The orchestrator owns objectURL lifetime — the loader returns the URL
// and the caller is expected to revoke it on unmount / source change.
import type { ComposerMode, InitialSource, SourceError } from "../types";
export type LoadedInitialSource = {
mode: "photo" | "video";
blob: Blob;
objectUrl: string;
};
export type LoadInitialSourceResult =
| { ok: true; loaded: LoadedInitialSource }
| { ok: false; error: SourceError };
/**
* Derive mode from a `File.type` MIME prefix. Returns `null` for anything
* other than `image/*` or `video/*` (consumer should treat as unsupported).
*/
export function inferModeFromFile(
file: File,
): "photo" | "video" | null {
const type = file.type ?? "";
if (type.startsWith("image/")) return "photo";
if (type.startsWith("video/")) return "video";
return null;
}
/**
* Resolve `InitialSource` to either a loaded payload or a `SourceError`.
*
* The returned object URL is allocated via `URL.createObjectURL` — caller
* MUST revoke it (typically on unmount / next source change) to avoid
* leaking blob references.
*
* `enabledModes` gates the resolved mode. If the consumer disabled the
* mode required by the source, returns `mode-not-enabled` without
* fetching (URL path) — so we don't waste a request on a source we
* can't display anyway.
*/
export async function loadInitialSource(
source: InitialSource,
enabledModes: readonly ComposerMode[],
): Promise<LoadInitialSourceResult> {
// Step 1 — resolve { mode, blob }. file/blob paths are sync; url path fetches.
let mode: "photo" | "video";
let blob: Blob;
if (source.kind === "file") {
const inferred = inferModeFromFile(source.file);
if (inferred === null) {
return {
ok: false,
error: {
kind: "unsupported-file-type",
fileType: source.file.type ?? "",
file: source.file,
},
};
}
mode = inferred;
blob = source.file;
} else if (source.kind === "blob") {
mode = source.mode;
blob = source.blob;
} else {
// kind === "url"
mode = source.mode;
// Pre-check enabledModes before paying the network cost.
// Empty enabledModes signals the edit-only path (initialSource without
// capture); the mode gate only applies when the consumer has explicitly
// enabled at least one capture mode.
if (enabledModes.length > 0 && !enabledModes.includes(mode)) {
return {
ok: false,
error: {
kind: "mode-not-enabled",
attempted: mode,
enabled: [...enabledModes],
},
};
}
try {
const res = await fetch(source.url, { mode: "cors" });
if (!res.ok) {
return {
ok: false,
error: {
kind: "fetch-failed",
url: source.url,
underlying: new Error(`HTTP ${res.status} ${res.statusText}`),
},
};
}
blob = await res.blob();
} catch (err) {
// CORS rejection surfaces as TypeError per spec; non-CORS network
// failures usually do too. The distinction matters for the consumer's
// recovery path (pre-fetch + blob vs. retry url), so we attempt to
// disambiguate via the error name + message.
const underlying = err instanceof Error ? err : new Error(String(err));
const looksLikeCors =
underlying.name === "TypeError" &&
/cors|cross.origin|failed to fetch|networkerror/i.test(
underlying.message,
);
return {
ok: false,
error: looksLikeCors
? { kind: "cors", url: source.url, underlying }
: { kind: "fetch-failed", url: source.url, underlying },
};
}
}
// Step 2 — validate against enabledModes (file/blob paths only; url path
// pre-checked above to avoid a wasted fetch). Empty enabledModes = edit-only
// path, same exemption as the url pre-check.
if (enabledModes.length > 0 && !enabledModes.includes(mode)) {
return {
ok: false,
error: {
kind: "mode-not-enabled",
attempted: mode,
enabled: [...enabledModes],
},
};
}
// Step 3 — sanity-check the blob isn't empty.
if (blob.size === 0) {
return {
ok: false,
error: {
kind: "invalid-blob",
reason: "Blob is empty (size: 0).",
},
};
}
// Step 4 — allocate the object URL. Caller owns revocation.
const objectUrl = URL.createObjectURL(blob);
return { ok: true, loaded: { mode, blob, objectUrl } };
}
src/registry/components/media/media-editor/lib/konva-filters.ts
import type { FilterPreset, KonvaFilterSpec } from "../types";
/**
* Built-in filter presets (Q-P9-style Instagram-inspired chains, recreated
* with Konva's filter primitives + reusable param overrides).
*
* Each preset is applied by:
* node.cache();
* node.filters(buildKonvaFilterChain(preset.konvaFilters));
* node.brightness(params.brightness ?? 0);
* // ...etc. per filter
*
* Real wiring lives in `composer-editor.tsx` — this file ships the data.
*/
const f = (name: string, params?: Record<string, number>): KonvaFilterSpec =>
params ? { name, params } : { name };
export const BUILT_IN_FILTER_PRESETS: FilterPreset[] = [
{
id: "original",
label: "Original",
konvaFilters: [],
},
{
id: "clarendon",
label: "Clarendon",
konvaFilters: [
f("Brighten", { brightness: 0.08 }),
f("Contrast", { contrast: 18 }),
f("HSL", { saturation: 0.45, hue: 4 }),
],
},
{
id: "gingham",
label: "Gingham",
konvaFilters: [
f("Brighten", { brightness: 0.05 }),
f("Contrast", { contrast: -12 }),
f("HSL", { saturation: -0.25 }),
f("Sepia"),
],
},
{
id: "moon",
label: "Moon",
konvaFilters: [
f("Grayscale"),
f("Brighten", { brightness: 0.04 }),
f("Contrast", { contrast: 14 }),
],
},
{
id: "lark",
label: "Lark",
konvaFilters: [
f("Brighten", { brightness: 0.12 }),
f("HSL", { saturation: 0.2, hue: -4 }),
],
},
{
id: "reyes",
label: "Reyes",
konvaFilters: [
f("Brighten", { brightness: 0.1 }),
f("Contrast", { contrast: -18 }),
f("HSL", { saturation: -0.3 }),
f("Sepia"),
],
},
{
id: "juno",
label: "Juno",
konvaFilters: [
f("Contrast", { contrast: 22 }),
f("HSL", { saturation: 0.4, hue: 6 }),
],
},
{
id: "slumber",
label: "Slumber",
konvaFilters: [
f("Brighten", { brightness: -0.04 }),
f("HSL", { saturation: -0.2, hue: 8 }),
f("Sepia"),
],
},
{
id: "crema",
label: "Crema",
konvaFilters: [
f("Brighten", { brightness: 0.06 }),
f("HSL", { saturation: -0.15 }),
f("Sepia"),
],
},
{
id: "ludwig",
label: "Ludwig",
konvaFilters: [
f("Brighten", { brightness: 0.08 }),
f("Contrast", { contrast: 16 }),
f("HSL", { saturation: 0.1 }),
],
},
];
/**
* Merge consumer-supplied presets with built-ins.
* `replace` true → consumer set wins entirely (no built-ins).
*/
export function resolveFilterPresets(
consumer: FilterPreset[] | undefined,
replace: boolean,
): FilterPreset[] {
if (replace) return consumer ?? [];
if (!consumer || consumer.length === 0) return BUILT_IN_FILTER_PRESETS;
// Consumer presets appended after built-ins; ids are not de-duped (consumer's
// responsibility — id collisions can't happen by accident with built-in names).
return [...BUILT_IN_FILTER_PRESETS, ...consumer];
}
src/registry/components/media/media-editor/lib/mime-fallback.ts
/**
* MediaRecorder codec preference chain.
*
* Chrome/Edge/Firefox prefer WebM (VP9 → VP8); Safari only supports MP4.
* Each entry includes the audio codec so the resulting MIME string is
* directly usable in `new MediaRecorder(stream, { mimeType })`.
*/
export const PREFERRED_RECORDER_MIME_TYPES = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4;codecs=avc1.42E01E,mp4a.40.2",
"video/mp4;codecs=avc1,mp4a",
"video/mp4",
] as const;
export type RecorderMimeType = (typeof PREFERRED_RECORDER_MIME_TYPES)[number];
/**
* Picks the highest-preference MIME the current browser supports for
* MediaRecorder. Returns null when nothing matches (very old browsers).
*/
export function selectRecorderMime(): RecorderMimeType | null {
if (typeof MediaRecorder === "undefined") return null;
for (const mime of PREFERRED_RECORDER_MIME_TYPES) {
try {
if (MediaRecorder.isTypeSupported(mime)) return mime;
} catch {
// Some browsers throw on unknown MIMEs — keep looking.
}
}
return null;
}
/** Extracts the bare container ("webm" or "mp4") for filename hints. */
export function containerFor(mime: string): "webm" | "mp4" | "unknown" {
if (mime.startsWith("video/webm")) return "webm";
if (mime.startsWith("video/mp4")) return "mp4";
return "unknown";
}
src/registry/components/media/media-editor/lib/presentation-resolver.ts
import type { ComposerMode } from "../types";
export type ResolvedPresentation = "inline" | "dialog";
/**
* Resolves the runtime `presentation` mode per description §6.
*
* Rules:
* - "inline" — render bare in parent layout. No portal/modal/focus-trap.
* - "dialog" — wrap in shadcn dialog.
* - "auto" — if `enabledModes` is empty (pure-edit context, no capture
* surface), pick "inline". Otherwise pick "dialog". The presence
* of `initialSource` does NOT change the rule.
*/
export function resolvePresentation(
presentation: "inline" | "dialog" | "auto" | undefined,
enabledModes: readonly ComposerMode[],
): ResolvedPresentation {
if (presentation === "inline") return "inline";
if (presentation === "dialog") return "dialog";
// auto (or undefined, defaults to auto)
return enabledModes.length === 0 ? "inline" : "dialog";
}
src/registry/components/media/media-editor/lib/resolve-crop-aspects.ts
import type { AspectRatio } from "../types";
const ALL_ASPECTS: readonly AspectRatio[] = [
"9:16",
"1:1",
"16:9",
"4:5",
"free",
];
/**
* Crop tool's `cropAspects` default-derivation per description §4 "crop" row:
*
* - If parent `aspect !== "free"` (canvas IS locked):
* default cropAspects = [aspect] — crop tool offers sub-region selection
* WITHIN the locked aspect only.
*
* - If parent `aspect === "free"` (no canvas lock):
* default cropAspects = ["9:16","1:1","16:9","4:5","free"] — full menu.
*
* - If consumer passes `cropAspects` explicitly, that overrides the default.
*/
export function resolveCropAspects(
aspect: AspectRatio,
cropAspectsOverride: AspectRatio[] | undefined,
): AspectRatio[] {
if (cropAspectsOverride !== undefined) return cropAspectsOverride;
if (aspect === "free") return [...ALL_ASPECTS];
return [aspect];
}
src/registry/components/media/media-editor/parts/color-swatch-picker.tsx
"use client";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export interface ColorSwatchPickerProps {
value: string;
presets: string[];
onChange: (color: string) => void;
ariaLabel: string;
triggerClassName?: string;
}
/**
* Popover color picker — preset swatch grid + free-form hex input.
*
* The trigger renders a 28px circle showing the current color.
* No native <input type="color"> (intentionally — it breaks design-system
* consistency across OS pickers).
*
* F-cross-13 defense: PopoverTrigger renders as its own button without
* `asChild` — Base UI's PopoverTrigger doesn't accept Slot mergeProps,
* and the namespaced `radix-ui` package isn't portable across consumers
* (some have only @radix-ui/react-popover individually). Composing
* directly through PopoverTrigger works against either shape.
*/
export function ColorSwatchPicker({
value,
presets,
onChange,
ariaLabel,
triggerClassName,
}: ColorSwatchPickerProps) {
const [hexDraft, setHexDraft] = useState(value);
const [open, setOpen] = useState(false);
const commitHex = (raw: string) => {
const trimmed = raw.trim();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(trimmed)) {
onChange(trimmed);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label={ariaLabel}
className={cn(
"inline-grid place-items-center size-7 rounded-full ring-2 ring-white/40 hover:ring-white transition-shadow",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white",
triggerClassName,
)}
style={{ background: value }}
>
<span className="sr-only">{ariaLabel}</span>
</PopoverTrigger>
<PopoverContent
sideOffset={8}
className="w-56 p-3 bg-popover text-popover-foreground"
>
<div className="grid grid-cols-6 gap-2">
{presets.map((color) => {
const isActive = color.toLowerCase() === value.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => {
onChange(color);
setHexDraft(color);
}}
aria-label={`Color ${color}`}
aria-pressed={isActive}
className={cn(
"size-7 rounded-full ring-2 ring-offset-1 ring-offset-popover",
isActive ? "ring-foreground" : "ring-transparent",
)}
style={{ background: color }}
/>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<label className="text-xs text-muted-foreground" htmlFor="color-hex">
Hex
</label>
<input
id="color-hex"
type="text"
value={hexDraft}
onChange={(e) => setHexDraft(e.target.value)}
onBlur={(e) => commitHex(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitHex((e.target as HTMLInputElement).value);
setOpen(false);
}
}}
placeholder="#ffffff"
className="flex-1 min-w-0 h-7 rounded-md border border-input bg-background px-2 text-xs font-mono"
/>
</div>
</PopoverContent>
</Popover>
);
}
src/registry/components/media/media-editor/parts/discard-confirm-dialog.tsx
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { StoryComposerLabels } from "../types";
export interface DiscardConfirmDialogProps {
open: boolean;
labels: Required<StoryComposerLabels>;
onCancel: () => void;
onConfirm: () => void;
}
/**
* Q-P10a guard — confirms before discarding unsaved edits.
*
* Mounted unconditionally; the parent owns `open`. Consumers opt out
* entirely via `confirmOnDiscard: false`.
*/
export function DiscardConfirmDialog({
open,
labels,
onCancel,
onConfirm,
}: DiscardConfirmDialogProps) {
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{labels.discardConfirmTitle}</AlertDialogTitle>
<AlertDialogDescription>
{labels.discardConfirmBody}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
{labels.discardCancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{labels.discardConfirm}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
src/registry/components/media/media-editor/parts/editor-canvas.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Konva from "konva";
import type { Filter as KonvaFilter } from "konva/lib/Node";
import {
Stage,
Layer,
Image as KonvaImage,
Line as KonvaLine,
Text as KonvaText,
Transformer,
} from "react-konva";
import { cn } from "@/lib/utils";
import {
useKonvaStageSize,
useKonvaSelection,
usePanZoom,
type StageSize,
} from "../../media-editor";
import { Button } from "@/components/ui/button";
import { Maximize2 } from "lucide-react";
import type { CropRect } from "./tool-crop-overlay";
import type {
DrawingStroke,
FilterPreset,
ImageAdjustments,
PlacedSticker,
StickerOption,
TextOverlay,
} from "../types";
export interface EditorCanvasProps {
/** Image draft preview URL. For video drafts, the editor renders a <video> overlay instead (C7+). */
imageUrl: string | null;
/** Video draft preview URL (overlay edits land C7+; for now displays as background). */
videoUrl?: string | null;
/** Canvas surface background — usually composer's editorBackground. */
background?: string;
/** Brightness/contrast/saturation/blur sliders (C7). */
adjustments?: ImageAdjustments;
/** Active filter preset (C7). null = no preset. */
activeFilter?: FilterPreset | null;
/** Text overlays (C8). */
textOverlays?: TextOverlay[];
/** Currently selected text overlay id (C8). */
selectedTextId?: string | null;
/** Fired on drag-end or transform-end of a text overlay. */
onTextChange?: (next: TextOverlay) => void;
/** Fired when a text overlay is tapped (selects it). */
onTextSelect?: (id: string | null) => void;
/** Sticker overlays (C9). */
stickers?: PlacedSticker[];
/** Sticker-id → StickerOption resolver (lets the editor look up src/dimensions). */
resolveSticker?: (stickerId: string) => StickerOption | undefined;
/** Currently selected sticker id (C9). */
selectedStickerId?: string | null;
onStickerChange?: (next: PlacedSticker) => void;
onStickerSelect?: (id: string | null) => void;
/** Completed drawing strokes (C10). */
drawingStrokes?: DrawingStroke[];
/** In-progress stroke (rendered live while pointer is down). */
currentDrawingStroke?: DrawingStroke | null;
/** When true, Stage pointer events feed the drawing pipeline. */
isDrawing?: boolean;
onDrawBegin?: (x: number, y: number) => void;
onDrawExtend?: (x: number, y: number) => void;
onDrawEnd?: () => void;
/** Crop rect in stage coordinates (C11). Editor renders a dim+frame overlay when active. */
cropRect?: CropRect | null;
/** When true, the crop overlay is interactive (drag handles + reposition). */
cropActive?: boolean;
/** Aspect ratio of crop — locks resize behavior. */
cropAspectRatio?: number | null;
onCropChange?: (next: CropRect) => void;
/** Surfaces stage dimensions to the parent (used to recompute crop rects on aspect change). */
onStageSize?: (size: { width: number; height: number }) => void;
/** Surfaces the underlying Konva.Stage instance for export (C12). */
onStageReady?: (stage: Konva.Stage | null) => void;
className?: string;
}
/**
* Multi-layer Konva canvas with:
* <Layer id="image"> — captured media as Konva.Image (filters land C7)
* <Layer id="drawing"> — freehand strokes (C10)
* <Layer id="stickers"> — sticker instances (C9)
* <Layer id="text"> — text overlays (C8)
* <Layer id="ui"> — Konva.Transformer (selection handles)
*
* C6: only the image layer renders. Other layers are wired empty so
* subsequent commits land their items into the right z-order without
* a substrate refactor.
*/
export function EditorCanvas({
imageUrl,
videoUrl,
background = "#000",
adjustments,
activeFilter,
textOverlays,
selectedTextId,
onTextChange,
onTextSelect,
stickers,
resolveSticker,
selectedStickerId,
onStickerChange,
onStickerSelect,
drawingStrokes,
currentDrawingStroke,
isDrawing = false,
onDrawBegin,
onDrawExtend,
onDrawEnd,
cropRect,
cropActive = false,
cropAspectRatio,
onCropChange,
onStageSize,
onStageReady,
className,
}: EditorCanvasProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const stageSize = useKonvaStageSize(containerRef);
// Destructure the selection hook result to locals up front. `transformerRef`
// is a ref-typed field; reading it as `selection.transformerRef` makes the
// React Compiler's aliasing analysis treat the whole result object as a ref
// container and flag every access (known false-positive class — same fix as
// story-viewer/reply-composer).
const { transformerRef, clear: clearSelection } = useKonvaSelection();
const imageNodeRef = useRef<Konva.Image | null>(null);
// Single-pointer drag-pan must yield to draggable overlays. Hit-test the
// stage on pointer-down: if a listening node (text / sticker / transformer
// handle) is under the pointer, don't pan — let Konva drag that node. The
// image layer is listening={false}, so background/image hits return null →
// pan engages.
const shouldStartPan = useCallback((e: { clientX: number; clientY: number }) => {
const stage = stageRef.current;
if (!stage) return true;
const rect = stage.container().getBoundingClientRect();
return !stage.getIntersection({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}, []);
// Pan + pinch-zoom (single-pointer drag / touch 2-finger / wheel / keyboard).
// Disabled when:
// - drawing is active (Stage owns the pointer pipeline for strokes)
// - cropping is active (crop overlay is DOM; can't follow stage transform)
const panZoom = usePanZoom({
targetRef: containerRef,
enabled: !isDrawing && !cropActive,
shouldStartPan,
});
// Crop maps stage-coordinate rects onto the source image assuming an
// identity stage transform, and the DOM crop overlay can't follow stage
// zoom/pan — normalize the view the moment the crop tool takes over.
// (Pan/zoom is merely DISABLED while cropping; without this reset a
// zoom-then-crop crops the wrong region.)
const panZoomReset = panZoom.reset;
useEffect(() => {
if (cropActive) panZoomReset();
}, [cropActive, panZoomReset]);
const [image, imageSize] = useImage(imageUrl);
const fit = fitInto(imageSize, stageSize);
// Plain-<img> underlay: visible while Konva decodes the blob URL into its
// own HTMLImageElement and waits for the Stage's ResizeObserver to fire.
// Without this the user sees a black background between Suspense resolving
// and the KonvaImage actually rendering. Once `image` lands, hide the
// underlay so applied filters/adjustments aren't doubled by the unfiltered
// copy bleeding through Konva's transparent canvas.
const showUnderlay = !!imageUrl && !image;
useEffect(() => {
if (stageSize.width > 0 && stageSize.height > 0) {
onStageSize?.(stageSize);
}
}, [stageSize, onStageSize]);
// Auto-initialize cropRect when the crop tool activates without an existing
// rect. Default = centered, 80% of stage dimensions, respecting cropAspectRatio
// if set (rect aspect = min of stage-fit + crop-lock). Without this, clicking
// the crop button activates the tool but leaves the overlay invisible because
// it gates on `cropRect != null`.
useEffect(() => {
if (!cropActive) return;
if (cropRect) return;
if (stageSize.width <= 0 || stageSize.height <= 0) return;
if (!onCropChange) return;
const sw = stageSize.width;
const sh = stageSize.height;
let rw = sw * 0.8;
let rh = sh * 0.8;
if (cropAspectRatio && cropAspectRatio > 0) {
// Fit the largest centered rect with cropAspectRatio inside 80%-of-stage.
const boxAspect = rw / rh;
if (boxAspect > cropAspectRatio) {
rw = rh * cropAspectRatio;
} else {
rh = rw / cropAspectRatio;
}
}
onCropChange({
x: (sw - rw) / 2,
y: (sh - rh) / 2,
width: rw,
height: rh,
});
}, [cropActive, cropRect, stageSize, cropAspectRatio, onCropChange]);
// Apply filter chain + adjustments to the image node.
// Konva requires .cache() before filters take effect. Re-cache when the
// image source changes; re-apply filter params on every change.
useEffect(() => {
const node = imageNodeRef.current;
if (!node || !image) return;
const filterChain = buildKonvaFilterChain(
activeFilter ?? null,
adjustments,
);
node.cache();
// The Konva.Filters array type is loose; chain is a list of filter fns.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node.filters(filterChain as any);
applyFilterParams(node, activeFilter ?? null, adjustments);
node.getLayer()?.batchDraw();
}, [image, activeFilter, adjustments]);
const textNodeRefs = useRef<Map<string, Konva.Text>>(new Map());
const stickerNodeRefs = useRef<Map<string, Konva.Image>>(new Map());
// Attach the Transformer to the currently-selected node (text or sticker).
useEffect(() => {
const t = transformerRef.current;
if (!t) return;
let node: Konva.Node | undefined;
if (selectedTextId) {
node = textNodeRefs.current.get(selectedTextId);
} else if (selectedStickerId) {
node = stickerNodeRefs.current.get(selectedStickerId);
}
if (node) {
t.nodes([node]);
} else {
t.nodes([]);
}
t.getLayer()?.batchDraw();
}, [
selectedTextId,
selectedStickerId,
textOverlays,
stickers,
transformerRef,
]);
// Click/tap on bare Stage background → clear all selection. Accepts mouse OR
// touch events so it can wire to both onMouseDown + onTouchStart without a cast.
const handleStageMouseDown = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => {
if (isDrawing) {
// Drawing intercepts everything — see handleStagePointerDown below.
return;
}
if (e.target === e.target.getStage()) {
onTextSelect?.(null);
onStickerSelect?.(null);
clearSelection();
}
};
// Drawing-mode pointer pipeline. We read the cursor position from the
// Stage so it's already in stage coordinates (not page coordinates).
const handleStagePointerDown = (
e: Konva.KonvaEventObject<PointerEvent>,
) => {
if (!isDrawing) return;
const stage = e.target.getStage();
// getRelativePointerPosition returns coords AFTER the Stage's own scale +
// position transform, which is what we want — strokes stick to the image
// regardless of zoom/pan state.
const pos = stage?.getRelativePointerPosition();
if (pos) onDrawBegin?.(pos.x, pos.y);
};
const handleStagePointerMove = (
e: Konva.KonvaEventObject<PointerEvent>,
) => {
if (!isDrawing || !currentDrawingStroke) return;
const stage = e.target.getStage();
// getRelativePointerPosition returns coords AFTER the Stage's own scale +
// position transform, which is what we want — strokes stick to the image
// regardless of zoom/pan state.
const pos = stage?.getRelativePointerPosition();
if (pos) onDrawExtend?.(pos.x, pos.y);
};
const handleStagePointerUp = () => {
if (!isDrawing) return;
onDrawEnd?.();
};
return (
<div
ref={containerRef}
// Focusable so usePanZoom's keyboard listener (bound to this element, not
// window) is focus-scoped — arrow/zoom keys only act when the canvas holds
// focus. role/aria-label name the region for AT.
tabIndex={0}
role="group"
aria-label="Media editor canvas — arrow keys pan, +/- zoom, 0 resets"
className={cn(
"relative w-full h-full overflow-hidden touch-none",
"outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-inset",
className,
)}
style={{ background }}
onPointerDown={panZoom.handlers.onPointerDown}
onPointerMove={panZoom.handlers.onPointerMove}
onPointerUp={panZoom.handlers.onPointerUp}
onPointerCancel={panZoom.handlers.onPointerCancel}
>
{/* Instant-photo underlay — see `showUnderlay` above. */}
{showUnderlay ? (
<img
src={imageUrl ?? undefined}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
) : null}
{/* Video drafts render as plain <video> until C7+ wires overlays. */}
{videoUrl ? (
<video
src={videoUrl}
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-contain"
/>
) : null}
{/* Stage requires non-zero dimensions; render only after size is measured. */}
{stageSize.width > 0 && stageSize.height > 0 ? (
<Stage
ref={(s) => {
stageRef.current = s;
onStageReady?.(s);
}}
width={stageSize.width}
height={stageSize.height}
scaleX={panZoom.transform.scale}
scaleY={panZoom.transform.scale}
x={panZoom.transform.x}
y={panZoom.transform.y}
onMouseDown={handleStageMouseDown}
onTouchStart={handleStageMouseDown}
onPointerDown={handleStagePointerDown}
onPointerMove={handleStagePointerMove}
onPointerUp={handleStagePointerUp}
onPointerCancel={handleStagePointerUp}
className="absolute inset-0"
style={isDrawing ? { cursor: "crosshair" } : undefined}
>
{/* image layer */}
<Layer listening={false}>
{image && imageUrl ? (
<KonvaImage
ref={imageNodeRef}
image={image}
x={fit.x}
y={fit.y}
width={fit.width}
height={fit.height}
/>
) : null}
</Layer>
{/* drawing layer (C10) */}
<Layer listening={false}>
{drawingStrokes?.map((stroke) => (
<KonvaLine
key={stroke.id}
points={stroke.points}
stroke={stroke.color}
strokeWidth={stroke.brushSize}
lineCap="round"
lineJoin="round"
tension={0.4}
globalCompositeOperation={
stroke.mode === "erase"
? "destination-out"
: "source-over"
}
/>
))}
{currentDrawingStroke ? (
<KonvaLine
key="current"
points={currentDrawingStroke.points}
stroke={currentDrawingStroke.color}
strokeWidth={currentDrawingStroke.brushSize}
lineCap="round"
lineJoin="round"
tension={0.4}
globalCompositeOperation={
currentDrawingStroke.mode === "erase"
? "destination-out"
: "source-over"
}
/>
) : null}
</Layer>
{/* sticker layer (C9) */}
<Layer>
{stickers?.map((placed) => {
const option = resolveSticker?.(placed.stickerId);
if (!option) return null;
return (
<PlacedStickerImage
key={placed.id}
placed={placed}
option={option}
onMount={(node) => {
if (node) stickerNodeRefs.current.set(placed.id, node);
else stickerNodeRefs.current.delete(placed.id);
}}
onSelect={() => onStickerSelect?.(placed.id)}
onChange={onStickerChange}
/>
);
})}
</Layer>
{/* text layer (C8) */}
<Layer>
{textOverlays?.map((overlay) => (
<KonvaText
key={overlay.id}
ref={(node) => {
if (node) textNodeRefs.current.set(overlay.id, node);
else textNodeRefs.current.delete(overlay.id);
}}
x={overlay.x}
y={overlay.y}
text={overlay.text}
fontFamily={overlay.fontFamily}
fontSize={overlay.fontSize}
fill={overlay.fill}
align={overlay.align}
rotation={overlay.rotation}
scaleX={overlay.scale}
scaleY={overlay.scale}
draggable
onMouseDown={(e) => {
e.cancelBubble = true;
onTextSelect?.(overlay.id);
}}
onTouchStart={(e) => {
e.cancelBubble = true;
onTextSelect?.(overlay.id);
}}
onDragEnd={(e) => {
onTextChange?.({
...overlay,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={(e) => {
const node = e.target as Konva.Text;
const nextScale = node.scaleX();
onTextChange?.({
...overlay,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
scale: nextScale,
});
}}
/>
))}
</Layer>
{/* ui layer — Transformer attached on selection */}
<Layer>
<Transformer
ref={transformerRef}
rotateEnabled
keepRatio={false}
boundBoxFunc={(_oldBox, newBox) => {
// Prevent degenerate negative-size handles.
if (newBox.width < 8 || newBox.height < 8) return _oldBox;
return newBox;
}}
/>
</Layer>
</Stage>
) : null}
{/* Reset-zoom CTA — only visible when zoomed/panned, hidden during crop */}
{!panZoom.isIdentity && !cropActive ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={panZoom.reset}
aria-label="Reset zoom"
className="absolute top-3 left-3 z-20 gap-1.5 bg-black/60 backdrop-blur-md text-white hover:bg-black/80 hover:text-white"
>
<Maximize2 className="size-3.5" />
<span className="text-xs font-mono tabular-nums">
{Math.round(panZoom.transform.scale * 100)}%
</span>
</Button>
) : null}
{/* Crop overlay (DOM over the Konva canvas). C11. */}
{cropActive && cropRect && stageSize.width > 0 ? (
<CropOverlay
rect={cropRect}
stageWidth={stageSize.width}
stageHeight={stageSize.height}
aspectRatio={cropAspectRatio ?? null}
onChange={onCropChange}
/>
) : null}
</div>
);
}
// ─── Crop overlay (DOM) ───────────────────────────────────────────────
interface CropOverlayProps {
rect: CropRect;
stageWidth: number;
stageHeight: number;
aspectRatio: number | null;
onChange?: (next: CropRect) => void;
}
const HANDLE_SIZE = 14;
const MIN_CROP = 40;
function CropOverlay({
rect,
stageWidth,
stageHeight,
aspectRatio,
onChange,
}: CropOverlayProps) {
const dragStateRef = useRef<{
mode: "move" | "ne" | "nw" | "se" | "sw";
startX: number;
startY: number;
startRect: CropRect;
} | null>(null);
// Plain two-arg handler invoked from inline arrows in the JSX props below.
// (The previous curried `beginDrag(mode)` builder ran during render, which
// made the compiler flag its ref write; the handler form only runs on the
// pointer event.)
const beginDrag = (
mode: "move" | "ne" | "nw" | "se" | "sw",
e: React.PointerEvent,
) => {
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragStateRef.current = {
mode,
startX: e.clientX,
startY: e.clientY,
startRect: { ...rect },
};
};
const onPointerMove = (e: React.PointerEvent) => {
const ds = dragStateRef.current;
if (!ds || !onChange) return;
const dx = e.clientX - ds.startX;
const dy = e.clientY - ds.startY;
let { x, y, width, height } = ds.startRect;
const r = aspectRatio; // null = free
if (ds.mode === "move") {
x = clamp(ds.startRect.x + dx, 0, stageWidth - ds.startRect.width);
y = clamp(ds.startRect.y + dy, 0, stageHeight - ds.startRect.height);
} else {
// Resize from a corner. Compute signed "growth" for each axis (positive
// = corner drag enlarges the rect). When aspect-locked, the rect must
// stay locked end-to-end — including when it hits a stage edge: growth
// stops cleanly, never breaks ratio. We compute max-allowable next
// dimensions given the anchor position + stage bounds, then clamp the
// drive to the most-restrictive of (candidate-from-drag, max-from-X,
// max-from-Y-mapped-through-r). When free, axes are independent.
const growX =
ds.mode === "se" || ds.mode === "ne" ? dx : -dx;
const growY =
ds.mode === "se" || ds.mode === "sw" ? dy : -dy;
// Max additional growth allowed in each axis, given the anchor.
const maxGrowX =
ds.mode === "ne" || ds.mode === "se"
? stageWidth - (ds.startRect.x + ds.startRect.width)
: ds.startRect.x;
const maxGrowY =
ds.mode === "sw" || ds.mode === "se"
? stageHeight - (ds.startRect.y + ds.startRect.height)
: ds.startRect.y;
const maxNextW = ds.startRect.width + maxGrowX;
const maxNextH = ds.startRect.height + maxGrowY;
let nextWidth: number;
let nextHeight: number;
if (r) {
// User drives EITHER axis. Pick the axis with larger signed growth.
const xEquivFromY = growY * r;
const drive =
Math.abs(xEquivFromY) > Math.abs(growX) ? xEquivFromY : growX;
let candidateW = ds.startRect.width + drive;
// Aspect-respecting upper bound: width can't exceed maxNextW OR
// maxNextH * r (whichever binds first). Lower bound: MIN_CROP.
const aspectCappedMaxW = Math.min(maxNextW, maxNextH * r);
candidateW = Math.min(candidateW, aspectCappedMaxW);
candidateW = Math.max(candidateW, MIN_CROP);
// Same for shrink direction — width derived height must be >= MIN_CROP.
candidateW = Math.max(candidateW, MIN_CROP * r);
nextWidth = candidateW;
nextHeight = nextWidth / r;
} else {
// Free resize — each axis clamped independently.
nextWidth = Math.max(
MIN_CROP,
Math.min(ds.startRect.width + growX, maxNextW),
);
nextHeight = Math.max(
MIN_CROP,
Math.min(ds.startRect.height + growY, maxNextH),
);
}
// Anchor: the corner opposite to the dragged one stays fixed.
if (ds.mode === "nw" || ds.mode === "sw") {
x = ds.startRect.x + (ds.startRect.width - nextWidth);
}
if (ds.mode === "nw" || ds.mode === "ne") {
y = ds.startRect.y + (ds.startRect.height - nextHeight);
}
// Stage-bound clamp (safety; aspect-respecting cap above should make
// these no-ops in normal cases).
x = clamp(x, 0, stageWidth - nextWidth);
y = clamp(y, 0, stageHeight - nextHeight);
width = nextWidth;
height = nextHeight;
}
onChange({ x, y, width, height });
};
const endDrag = () => {
dragStateRef.current = null;
};
return (
<div
className="absolute inset-0 pointer-events-none"
style={{ width: stageWidth, height: stageHeight }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{/* Dim 4 quadrants outside the crop rect */}
<div
className="absolute inset-x-0 top-0 bg-black/60"
style={{ height: rect.y }}
/>
<div
className="absolute inset-x-0 bottom-0 bg-black/60"
style={{ height: stageHeight - rect.y - rect.height }}
/>
<div
className="absolute left-0 bg-black/60"
style={{
top: rect.y,
height: rect.height,
width: rect.x,
}}
/>
<div
className="absolute right-0 bg-black/60"
style={{
top: rect.y,
height: rect.height,
width: stageWidth - rect.x - rect.width,
}}
/>
{/* Crop frame (movable) */}
<div
className="absolute border-2 border-white pointer-events-auto cursor-move"
style={{
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height,
}}
onPointerDown={(e) => beginDrag("move", e)}
>
{/* Rule-of-thirds grid */}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 pointer-events-none">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="border border-white/20" />
))}
</div>
{/* Corner handles */}
{(["nw", "ne", "sw", "se"] as const).map((corner) => (
<button
type="button"
key={corner}
onPointerDown={(e) => beginDrag(corner, e)}
className="absolute bg-white rounded-full shadow"
style={{
width: HANDLE_SIZE,
height: HANDLE_SIZE,
left:
corner === "nw" || corner === "sw"
? -HANDLE_SIZE / 2
: undefined,
right:
corner === "ne" || corner === "se"
? -HANDLE_SIZE / 2
: undefined,
top:
corner === "nw" || corner === "ne"
? -HANDLE_SIZE / 2
: undefined,
bottom:
corner === "sw" || corner === "se"
? -HANDLE_SIZE / 2
: undefined,
cursor:
corner === "nw" || corner === "se"
? "nwse-resize"
: "nesw-resize",
}}
aria-label={`Resize ${corner.toUpperCase()}`}
/>
))}
</div>
</div>
);
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
// ─── Sticker rendering ────────────────────────────────────────────────
interface PlacedStickerImageProps {
placed: PlacedSticker;
option: StickerOption;
onMount: (node: Konva.Image | null) => void;
onSelect: () => void;
onChange?: (next: PlacedSticker) => void;
}
function PlacedStickerImage({
placed,
option,
onMount,
onSelect,
onChange,
}: PlacedStickerImageProps) {
const [image] = useImage(option.src);
if (!image) return null;
return (
<KonvaImage
ref={(n) => onMount(n)}
image={image}
x={placed.x}
y={placed.y}
width={option.width ?? 128}
height={option.height ?? 128}
offsetX={(option.width ?? 128) / 2}
offsetY={(option.height ?? 128) / 2}
rotation={placed.rotation}
scaleX={placed.scale}
scaleY={placed.scale}
draggable
onMouseDown={(e) => {
e.cancelBubble = true;
onSelect();
}}
onTouchStart={(e) => {
e.cancelBubble = true;
onSelect();
}}
onDragEnd={(e) => {
onChange?.({
...placed,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={(e) => {
const node = e.target as Konva.Image;
onChange?.({
...placed,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
scale: node.scaleX(),
});
}}
/>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────
interface Size {
width: number;
height: number;
}
/** Load an image element + report its natural size. Returns [image, size]. */
function useImage(
src: string | null,
): [HTMLImageElement | null, Size] {
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
// Reset when the source is CLEARED — during render via the guarded
// prev-props pattern (React docs "adjusting state when props change"), so
// the effect below only does external-system work (image loading).
// Deliberately fires only on the →null transition: a src→src change keeps
// the old image visible until the replacement loads (existing behavior).
const [prevSrc, setPrevSrc] = useState(src);
if (prevSrc !== src) {
setPrevSrc(src);
if (!src) {
setImage(null);
setSize({ width: 0, height: 0 });
}
}
useEffect(() => {
if (!src) return;
const img = new window.Image();
img.crossOrigin = "anonymous";
let cancelled = false;
img.onload = () => {
if (cancelled) return;
setImage(img);
setSize({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
if (cancelled) return;
setImage(null);
};
img.src = src;
return () => {
cancelled = true;
};
}, [src]);
return [image, size];
}
// ─── Filter helpers ───────────────────────────────────────────────────
const KONVA_FILTER_MAP: Record<string, KonvaFilter | undefined> = {
Brighten: Konva.Filters.Brighten,
Contrast: Konva.Filters.Contrast,
HSL: Konva.Filters.HSL,
Sepia: Konva.Filters.Sepia,
Grayscale: Konva.Filters.Grayscale,
Blur: Konva.Filters.Blur,
Noise: Konva.Filters.Noise,
};
/** Build the KonvaFilter array for the active preset + adjust sliders. */
function buildKonvaFilterChain(
preset: FilterPreset | null,
adjustments?: ImageAdjustments,
): KonvaFilter[] {
const seen = new Set<string>();
const out: KonvaFilter[] = [];
const add = (name: string) => {
if (seen.has(name)) return;
const fn = KONVA_FILTER_MAP[name];
if (!fn) return;
seen.add(name);
out.push(fn);
};
if (preset) {
for (const spec of preset.konvaFilters) add(spec.name);
}
// Always include the adjustment filters so the sliders work over any preset.
if (adjustments) {
if (adjustments.brightness !== 0) add("Brighten");
if (adjustments.contrast !== 0) add("Contrast");
if (adjustments.saturation !== 0) add("HSL");
if (adjustments.blur > 0) add("Blur");
}
return out;
}
/** Push numeric filter params onto the image node. */
function applyFilterParams(
node: Konva.Image,
preset: FilterPreset | null,
adjustments?: ImageAdjustments,
) {
// Reset to neutral before applying.
node.brightness(0);
node.contrast(0);
node.saturation(0);
node.hue(0);
node.blurRadius(0);
// Apply preset values first.
if (preset) {
for (const spec of preset.konvaFilters) {
const p = spec.params ?? {};
if (spec.name === "Brighten" && p.brightness !== undefined) {
node.brightness(p.brightness);
} else if (spec.name === "Contrast" && p.contrast !== undefined) {
node.contrast(p.contrast);
} else if (spec.name === "HSL") {
if (p.saturation !== undefined) node.saturation(p.saturation);
if (p.hue !== undefined) node.hue(p.hue);
} else if (spec.name === "Blur" && p.blurRadius !== undefined) {
node.blurRadius(p.blurRadius);
}
}
}
// Adjustments add on top of preset values.
if (adjustments) {
node.brightness(node.brightness() + adjustments.brightness);
node.contrast(node.contrast() + adjustments.contrast);
node.saturation(node.saturation() + adjustments.saturation);
if (adjustments.blur > 0) node.blurRadius(adjustments.blur);
}
}
/** Object-fit: contain inside the stage. */
function fitInto(
source: Size,
target: StageSize,
): { x: number; y: number; width: number; height: number } {
if (
source.width === 0 ||
source.height === 0 ||
target.width === 0 ||
target.height === 0
) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const scale = Math.min(
target.width / source.width,
target.height / source.height,
);
const width = source.width * scale;
const height = source.height * scale;
const x = (target.width - width) / 2;
const y = (target.height - height) / 2;
return { x, y, width, height };
}
src/registry/components/media/media-editor/parts/editor-toolbar.tsx
"use client";
import {
Crop,
Palette,
Sliders,
Smile,
Type,
Wand,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { EditTool, StoryComposerLabels } from "../types";
export interface EditorToolbarProps {
activeTool: EditTool | null;
enabledTools: EditTool[];
/** Tools that aren't shipped yet — render disabled. */
pendingTools?: EditTool[];
labels: Required<StoryComposerLabels>;
onSelect: (tool: EditTool | null) => void;
className?: string;
}
interface ToolConfig {
key: EditTool;
icon: React.ComponentType<{ className?: string }>;
label: (labels: Required<StoryComposerLabels>) => string;
}
const TOOL_ORDER: ToolConfig[] = [
{ key: "text", icon: Type, label: (l) => l.toolText },
{ key: "draw", icon: Wand, label: (l) => l.toolDraw },
{ key: "stickers", icon: Smile, label: (l) => l.toolStickers },
{ key: "filters", icon: Palette, label: (l) => l.toolFilters },
{ key: "adjust", icon: Sliders, label: (l) => l.toolAdjust },
{ key: "crop", icon: Crop, label: (l) => l.toolCrop },
];
export function EditorToolbar({
activeTool,
enabledTools,
pendingTools = [],
labels,
onSelect,
className,
}: EditorToolbarProps) {
const visible = TOOL_ORDER.filter((t) => enabledTools.includes(t.key));
return (
<div
role="toolbar"
aria-label="Edit tools"
className={cn(
"flex items-center justify-around gap-1 rounded-2xl",
"bg-black/70 backdrop-blur-md px-1 py-1.5 text-white",
className,
)}
>
{visible.map((tool) => {
const Icon = tool.icon;
const isActive = activeTool === tool.key;
const isPending = pendingTools.includes(tool.key);
return (
<Button
key={tool.key}
type="button"
variant="ghost"
size="sm"
disabled={isPending}
aria-pressed={isActive}
aria-label={tool.label(labels)}
onClick={() => onSelect(isActive ? null : tool.key)}
className={cn(
"flex flex-col items-center gap-0.5 h-auto px-2 py-1.5 text-[10px] text-white/80 hover:bg-white/10 hover:text-white",
isActive && "text-white bg-white/15",
isPending && "opacity-40",
)}
>
<Icon className="size-5" />
<span>{tool.label(labels)}</span>
</Button>
);
})}
</div>
);
}
src/registry/components/media/media-editor/parts/mode-toggle-pill.tsx
"use client";
import { cn } from "@/lib/utils";
import type { ComposerMode, StoryComposerLabels } from "../types";
export interface ModeTogglePillProps {
mode: ComposerMode;
visibleModes: ComposerMode[];
labels: Required<StoryComposerLabels>;
onModeChange: (mode: ComposerMode) => void;
className?: string;
}
const ORDER: ComposerMode[] = ["photo", "video", "text"];
/**
* Plain-button segmented control. Deliberately NOT shadcn ToggleGroup — its
* value model diverges between Radix (single: string) and Base UI (string[]),
* an F-cross-13 carrier that breaks Base-UI consumers (filter-panel 0.1.1 /
* event-calendar v0.2.1 precedent: same swap). Re-tapping the active mode stays
* a noop (ToggleGroup used to emit "" on re-tap; here we simply don't fire).
*/
export function ModeTogglePill({
mode,
visibleModes,
labels,
onModeChange,
className,
}: ModeTogglePillProps) {
if (visibleModes.length < 2) return null;
const orderedVisible = ORDER.filter((m) => visibleModes.includes(m));
return (
<div
role="group"
className={cn(
"inline-flex rounded-full bg-black/40 backdrop-blur-md p-1 text-white",
className,
)}
>
{orderedVisible.map((m) => {
const active = m === mode;
return (
<button
key={m}
type="button"
aria-pressed={active}
aria-label={labelFor(m, labels)}
onClick={() => {
if (!active) onModeChange(m);
}}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-medium tracking-wide uppercase",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-white text-black"
: "text-white/70 hover:bg-white/10 hover:text-white",
)}
>
{labelFor(m, labels)}
</button>
);
})}
</div>
);
}
function labelFor(
mode: ComposerMode,
labels: Required<StoryComposerLabels>,
): string {
switch (mode) {
case "photo":
return labels.modePhoto;
case "video":
return labels.modeVideo;
case "text":
return labels.modeText;
}
}
src/registry/components/media/media-editor/parts/text-only-canvas.tsx
"use client";
import { forwardRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ColorSwatchPicker } from "./color-swatch-picker";
import type { GradientPreset, FontOption } from "../../media-editor";
import type { StoryComposerLabels } from "../types";
export interface TextOnlyCanvasProps {
gradients: GradientPreset[];
fonts: FontOption[];
colorPresets: string[];
labels: Required<StoryComposerLabels>;
className?: string;
}
export interface TextOnlyCanvasState {
text: string;
fontFamily: string;
textColor: string;
gradientId: string;
}
/**
* Text-only mode capture surface.
*
* Rendered as a styled DOM element (NOT Konva) so `exportTextOnlyBlob()`
* can rasterise it via the SVG-foreignObject technique on publish. The
* forwardRef exposes the canvas <div> element so the publish path can
* pass it to the exporter.
*/
export const TextOnlyCanvas = forwardRef<
HTMLDivElement,
TextOnlyCanvasProps & {
value: TextOnlyCanvasState;
onChange: (next: TextOnlyCanvasState) => void;
}
>(function TextOnlyCanvas(
{ gradients, fonts, colorPresets, labels, value, onChange, className },
ref,
) {
const [pickerOpen, setPickerOpen] = useState<"gradient" | "font" | null>(
null,
);
const activeGradient =
gradients.find((g) => g.id === value.gradientId) ?? gradients[0];
const update = (patch: Partial<TextOnlyCanvasState>) =>
onChange({ ...value, ...patch });
return (
<div
className={cn("relative flex-1 flex flex-col overflow-hidden", className)}
>
{/* Render surface — exported as PNG on publish */}
<div
ref={ref}
className="absolute inset-0 grid place-items-center px-8"
style={{ background: activeGradient?.background ?? "#000" }}
>
<textarea
value={value.text}
onChange={(e) => update({ text: e.target.value })}
placeholder={labels.textPlaceholder}
rows={3}
aria-label={labels.textPlaceholder}
className="w-full max-w-[20rem] resize-none bg-transparent text-center placeholder:text-current/40 focus:outline-none"
style={{
color: value.textColor,
fontFamily: value.fontFamily,
fontSize: "clamp(1.5rem, 7vw, 3rem)",
lineHeight: 1.2,
fontWeight: 600,
textShadow: "0 2px 8px rgba(0,0,0,0.25)",
}}
/>
</div>
{/* Bottom control bar — gradient picker + font + color */}
<div
className="absolute left-0 right-0 z-20 flex flex-col gap-2 px-3"
style={{
bottom: "max(1.25rem, env(safe-area-inset-bottom))",
}}
>
{pickerOpen === "gradient" ? (
<div className="flex items-center gap-2 overflow-x-auto rounded-2xl bg-black/70 backdrop-blur-md p-2">
{gradients.map((g) => {
const isActive = g.id === value.gradientId;
return (
<button
key={g.id}
type="button"
onClick={() => update({ gradientId: g.id })}
aria-label={g.label}
aria-pressed={isActive}
className={cn(
"flex-none size-10 rounded-full ring-2 transition-shadow",
isActive ? "ring-white" : "ring-transparent",
)}
style={{ background: g.background }}
/>
);
})}
</div>
) : null}
<div className="flex items-center justify-between gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white">
<Button
variant="ghost"
size="sm"
onClick={() =>
setPickerOpen((p) => (p === "gradient" ? null : "gradient"))
}
aria-pressed={pickerOpen === "gradient"}
className="text-xs gap-2 text-white hover:bg-white/10 hover:text-white"
>
<span
className="inline-block size-4 rounded-full ring-2 ring-white/40"
style={{ background: activeGradient?.background ?? "#000" }}
aria-hidden
/>
<span>Background</span>
</Button>
<div className="flex items-center gap-2">
<select
value={value.fontFamily}
onChange={(e) => update({ fontFamily: e.target.value })}
aria-label={labels.textFontFamily}
className="h-7 rounded-md bg-black/40 border border-white/15 px-2 text-xs"
>
{fonts.map((font) => (
<option
key={font.id}
value={font.family}
style={{ fontFamily: font.family }}
>
{font.label}
</option>
))}
</select>
<ColorSwatchPicker
value={value.textColor}
presets={colorPresets}
onChange={(c) => update({ textColor: c })}
ariaLabel={labels.drawColor}
/>
</div>
</div>
</div>
</div>
);
});
src/registry/components/media/media-editor/parts/tool-adjust-sliders.tsx
"use client";
import { useCallback } from "react";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { ImageAdjustments, StoryComposerLabels } from "../types";
export interface ToolAdjustSlidersProps {
value: ImageAdjustments;
onChange: (next: ImageAdjustments) => void;
labels: Required<StoryComposerLabels>;
className?: string;
}
interface SliderConfig {
key: keyof ImageAdjustments;
label: string;
min: number;
max: number;
step: number;
}
export function ToolAdjustSliders({
value,
onChange,
labels,
className,
}: ToolAdjustSlidersProps) {
const configs: SliderConfig[] = [
{
key: "brightness",
label: labels.adjustBrightness,
min: -1,
max: 1,
step: 0.05,
},
{
key: "contrast",
label: labels.adjustContrast,
min: -100,
max: 100,
step: 5,
},
{
key: "saturation",
label: labels.adjustSaturation,
min: -2,
max: 5,
step: 0.1,
},
{
key: "blur",
label: labels.adjustBlur,
min: 0,
max: 40,
step: 1,
},
];
const update = useCallback(
(key: keyof ImageAdjustments, next: number) => {
onChange({ ...value, [key]: next });
},
[onChange, value],
);
return (
<div
className={cn(
"flex flex-col gap-4 p-4 rounded-2xl bg-black/70 backdrop-blur-md text-white",
className,
)}
>
{configs.map((cfg) => (
<div key={cfg.key} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{cfg.label}</span>
<span className="font-mono tabular-nums text-white/60">
{formatValue(value[cfg.key], cfg.key)}
</span>
</div>
<Slider
value={[value[cfg.key]]}
min={cfg.min}
max={cfg.max}
step={cfg.step}
// Radix emits number[]; Base UI number | readonly number[] (F-cross-13)
onValueChange={(v) => update(cfg.key, typeof v === "number" ? v : (v[0] ?? 0))}
aria-label={cfg.label}
/>
</div>
))}
</div>
);
}
function formatValue(v: number, key: keyof ImageAdjustments): string {
if (key === "contrast" || key === "blur") return String(Math.round(v));
return v.toFixed(2);
}
src/registry/components/media/media-editor/parts/tool-crop-overlay.tsx
"use client";
import { cn } from "@/lib/utils";
import type { AspectRatio, CropRect } from "../types";
// Decoupled from story-shaped labels post-extraction. Story-composer-01 v0.2.0
// wrapper passes `cropLabel={labels.toolCrop}`; other consumers pass their own
// label string directly.
export interface ToolCropOverlayProps {
activeAspect: AspectRatio;
availableAspects: AspectRatio[];
/** Raw "Crop" label text. Defaults to "Crop". */
cropLabel?: string;
onAspectChange: (aspect: AspectRatio) => void;
className?: string;
}
const ASPECT_LABEL: Record<AspectRatio, string> = {
"9:16": "9:16",
"1:1": "1:1",
"16:9": "16:9",
"4:5": "4:5",
free: "Free",
};
export function ToolCropOverlay({
activeAspect,
availableAspects,
cropLabel = "Crop",
onAspectChange,
className,
}: ToolCropOverlayProps) {
return (
<div
className={cn(
"flex items-center justify-center gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white",
className,
)}
>
<span className="text-[10px] uppercase tracking-wider text-white/60 px-2">
{cropLabel}
</span>
{availableAspects.map((aspect) => {
const isActive = activeAspect === aspect;
return (
<button
key={aspect}
type="button"
onClick={() => onAspectChange(aspect)}
aria-pressed={isActive}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium tracking-wide transition-colors",
isActive
? "bg-white text-black"
: "text-white/70 hover:bg-white/10 hover:text-white",
)}
>
{ASPECT_LABEL[aspect]}
</button>
);
})}
</div>
);
}
// ─── Geometry helpers (consumed by editor-canvas) ─────────────────────
// CropRect now lives in ../types — single source of truth across the procomp.
// Re-exported here for any v0.1.5 consumer of story-composer that imported
// `CropRect` from `parts/tool-crop-overlay` (story-composer v0.2.0 barrel
// preserves the re-export for backward-compat).
export type { CropRect };
export const ASPECT_RATIO_VALUES: Record<AspectRatio, number | null> = {
"9:16": 9 / 16,
"1:1": 1,
"16:9": 16 / 9,
"4:5": 4 / 5,
free: null,
};
/**
* Centered, max-fitting rect for the given aspect ratio inside the stage.
* Returns the full stage rect for "free" (no constraint).
*/
export function fitCropToStage(
aspect: AspectRatio,
stageWidth: number,
stageHeight: number,
): CropRect {
const ratio = ASPECT_RATIO_VALUES[aspect];
if (ratio === null) {
return { x: 0, y: 0, width: stageWidth, height: stageHeight };
}
// Find largest rect with the given aspect that fits inside stage.
let width = stageWidth;
let height = stageWidth / ratio;
if (height > stageHeight) {
height = stageHeight;
width = stageHeight * ratio;
}
return {
x: (stageWidth - width) / 2,
y: (stageHeight - height) / 2,
width,
height,
};
}
src/registry/components/media/media-editor/parts/tool-draw-controls.tsx
"use client";
import { Eraser, Pen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { ColorSwatchPicker } from "./color-swatch-picker";
import type { StoryComposerLabels } from "../types";
export interface ToolDrawControlsProps {
color: string;
brushSize: number;
mode: "draw" | "erase";
colorPresets: string[];
labels: Required<StoryComposerLabels>;
onColorChange: (color: string) => void;
onBrushSizeChange: (size: number) => void;
onModeChange: (mode: "draw" | "erase") => void;
className?: string;
}
const MIN_BRUSH = 1;
const MAX_BRUSH = 60;
export function ToolDrawControls({
color,
brushSize,
mode,
colorPresets,
labels,
onColorChange,
onBrushSizeChange,
onModeChange,
className,
}: ToolDrawControlsProps) {
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white",
className,
)}
>
{/* Top row: mode toggle + color */}
<div className="flex items-center justify-between gap-3">
<div className="inline-flex rounded-md bg-black/40 border border-white/15 p-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onModeChange("draw")}
aria-pressed={mode === "draw"}
aria-label={labels.toolDraw}
className={cn(
"h-7 px-2 rounded-sm gap-1.5 text-xs",
mode === "draw"
? "bg-white text-black hover:bg-white hover:text-black"
: "text-white/80 hover:bg-white/10 hover:text-white",
)}
>
<Pen className="size-3.5" />
<span>{labels.toolDraw}</span>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onModeChange("erase")}
aria-pressed={mode === "erase"}
aria-label={labels.drawEraser}
className={cn(
"h-7 px-2 rounded-sm gap-1.5 text-xs",
mode === "erase"
? "bg-white text-black hover:bg-white hover:text-black"
: "text-white/80 hover:bg-white/10 hover:text-white",
)}
>
<Eraser className="size-3.5" />
<span>{labels.drawEraser}</span>
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-white/60">
{labels.drawColor}
</span>
<ColorSwatchPicker
value={color}
presets={colorPresets}
onChange={onColorChange}
ariaLabel={labels.drawColor}
/>
</div>
</div>
{/* Brush size */}
<div className="flex items-center gap-3">
<span className="text-[10px] uppercase tracking-wider text-white/60 w-12">
{labels.drawBrush}
</span>
<Slider
value={[brushSize]}
min={MIN_BRUSH}
max={MAX_BRUSH}
step={1}
// Radix emits number[]; Base UI number | readonly number[] (F-cross-13)
onValueChange={(v) =>
onBrushSizeChange(typeof v === "number" ? v : (v[0] ?? brushSize))
}
aria-label={labels.drawBrush}
className="flex-1"
/>
<span className="text-xs font-mono tabular-nums w-7 text-right">
{brushSize}
</span>
</div>
</div>
);
}
src/registry/components/media/media-editor/parts/tool-filter-strip.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { FilterPreset } from "../types";
export interface ToolFilterStripProps {
presets: FilterPreset[];
/** Image URL the thumbnails sample (the captured photo). */
sourceUrl: string;
/** Currently-active preset id. null = "original" / no filter. */
activeId: string | null;
onSelect: (id: string | null) => void;
className?: string;
}
interface ThumbnailEntry {
preset: FilterPreset;
url: string;
}
const THUMB_SIZE = 56;
/**
* Horizontal scrollable strip of filter presets, each rendered as a
* pre-rendered thumbnail of the captured photo with that filter applied.
*
* Q-P7a — thumbnails are computed once on edit-mode entry (~1s for ~10
* filters on a typical 1080p photo). Subsequent taps are instant.
*
* For C7 we use CSS-filter approximations (cheap, no Konva), which gives
* the user a near-WYSIWYG preview. The actual on-stage application
* (which uses Konva.Filters) is wired in composer-editor.tsx and matches
* the CSS recipe closely enough for browse-then-confirm UX.
*/
export function ToolFilterStrip({
presets,
sourceUrl,
activeId,
onSelect,
className,
}: ToolFilterStripProps) {
const [thumbs, setThumbs] = useState<ThumbnailEntry[]>([]);
const cancelledRef = useRef(false);
// Clear stale thumbnails when the input empties — during render via the
// guarded prev-props pattern, so the effect below only does external-system
// work (image load + canvas rendering). Fires only on the →empty
// transition; a sourceUrl/presets change between non-empty values keeps the
// old thumbs visible until the re-render lands (existing behavior).
const inputEmpty = !sourceUrl || presets.length === 0;
const [prevInputEmpty, setPrevInputEmpty] = useState(inputEmpty);
if (inputEmpty !== prevInputEmpty) {
setPrevInputEmpty(inputEmpty);
if (inputEmpty) setThumbs([]);
}
useEffect(() => {
cancelledRef.current = false;
if (!sourceUrl || presets.length === 0) return;
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
if (cancelledRef.current) return;
const canvas = document.createElement("canvas");
canvas.width = THUMB_SIZE;
canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const drawCovered = () => {
const ratio = Math.max(
THUMB_SIZE / img.naturalWidth,
THUMB_SIZE / img.naturalHeight,
);
const w = img.naturalWidth * ratio;
const h = img.naturalHeight * ratio;
const x = (THUMB_SIZE - w) / 2;
const y = (THUMB_SIZE - h) / 2;
ctx.clearRect(0, 0, THUMB_SIZE, THUMB_SIZE);
ctx.drawImage(img, x, y, w, h);
};
const out: ThumbnailEntry[] = [];
for (const preset of presets) {
ctx.filter = cssFilterForPreset(preset);
drawCovered();
out.push({ preset, url: canvas.toDataURL("image/jpeg", 0.7) });
}
ctx.filter = "none";
if (!cancelledRef.current) setThumbs(out);
};
img.src = sourceUrl;
return () => {
cancelledRef.current = true;
};
}, [sourceUrl, presets]);
return (
<div
className={cn(
"flex items-center gap-2 overflow-x-auto overscroll-x-contain",
"rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white",
"scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent",
className,
)}
>
{(thumbs.length > 0
? thumbs
: presets.map((p) => ({ preset: p, url: "" }))
).map(({ preset, url }) => {
const isActive = activeId === preset.id || (!activeId && preset.id === "original");
return (
<button
key={preset.id}
type="button"
onClick={() =>
onSelect(preset.id === "original" ? null : preset.id)
}
className={cn(
"flex-none flex flex-col items-center gap-1 outline-none",
"focus-visible:ring-2 focus-visible:ring-white rounded-lg",
)}
aria-pressed={isActive}
aria-label={preset.label}
>
<div
className={cn(
"size-14 rounded-lg overflow-hidden bg-black/60 ring-2 transition-shadow",
isActive ? "ring-white" : "ring-transparent",
)}
>
{url ? (
<img
src={url}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-white/5 animate-pulse" />
)}
</div>
<span className="text-[10px] font-medium uppercase tracking-wider">
{preset.label}
</span>
</button>
);
})}
</div>
);
}
/**
* Approximate each Konva filter chain with a CSS `filter:` string for the
* preview thumbnail. CSS doesn't have a 1:1 mapping, but this approximation
* is good enough for browse-and-pick UX.
*/
function cssFilterForPreset(preset: FilterPreset): string {
if (preset.konvaFilters.length === 0) return "none";
const parts: string[] = [];
let saturate = 1;
let brightness = 1;
let contrast = 1;
let sepia = 0;
let hue = 0;
let grayscale = 0;
let blur = 0;
for (const f of preset.konvaFilters) {
switch (f.name) {
case "Brighten":
brightness *= 1 + (f.params?.brightness ?? 0);
break;
case "Contrast":
contrast *= 1 + (f.params?.contrast ?? 0) / 100;
break;
case "HSL":
saturate *= 1 + (f.params?.saturation ?? 0);
hue += f.params?.hue ?? 0;
break;
case "Sepia":
sepia = Math.min(1, sepia + 0.25);
break;
case "Grayscale":
grayscale = 1;
break;
case "Blur":
blur += f.params?.blurRadius ?? 0;
break;
}
}
parts.push(`brightness(${brightness.toFixed(2)})`);
parts.push(`contrast(${contrast.toFixed(2)})`);
parts.push(`saturate(${Math.max(0, saturate).toFixed(2)})`);
if (hue !== 0) parts.push(`hue-rotate(${hue}deg)`);
if (sepia > 0) parts.push(`sepia(${sepia.toFixed(2)})`);
if (grayscale > 0) parts.push(`grayscale(${grayscale})`);
if (blur > 0) parts.push(`blur(${blur}px)`);
return parts.join(" ");
}
src/registry/components/media/media-editor/parts/tool-sticker-picker.tsx
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { StickerOption, StickerSet } from "../types";
export interface ToolStickerPickerProps {
sets: StickerSet[];
onPick: (sticker: StickerOption) => void;
className?: string;
}
/**
* Bottom-sheet sticker picker. Top row of category tabs + scrollable grid
* of stickers from the active set. Tap a sticker → consumer adds it to
* the stage (center) and selects it for transformer manipulation.
*/
export function ToolStickerPicker({
sets,
onPick,
className,
}: ToolStickerPickerProps) {
const [activeSetId, setActiveSetId] = useState(sets[0]?.id ?? "");
const activeSet = sets.find((s) => s.id === activeSetId) ?? sets[0];
if (!activeSet) return null;
return (
<div
className={cn(
"flex flex-col gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white",
className,
)}
>
{/* Category tabs */}
{sets.length > 1 ? (
<div className="flex items-center gap-1 overflow-x-auto">
{sets.map((set) => {
const isActive = set.id === activeSet.id;
return (
<button
key={set.id}
type="button"
onClick={() => setActiveSetId(set.id)}
aria-pressed={isActive}
className={cn(
"flex-none rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase transition-colors",
isActive
? "bg-white text-black"
: "text-white/70 hover:text-white hover:bg-white/10",
)}
>
{set.label}
</button>
);
})}
</div>
) : null}
{/* Sticker grid — capped at ~3 rows so the panel doesn't take the whole screen */}
<div
className={cn(
"grid grid-cols-6 gap-2 overflow-y-auto",
"max-h-[40dvh] sm:max-h-44",
)}
>
{activeSet.stickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => onPick(sticker)}
aria-label={sticker.alt}
className="aspect-square rounded-lg bg-white/5 hover:bg-white/15 active:scale-95 transition-transform p-1 grid place-items-center"
>
<img
src={sticker.src}
alt={sticker.alt}
className="w-full h-full object-contain"
/>
</button>
))}
</div>
</div>
);
}
src/registry/components/media/media-editor/parts/tool-text-input.tsx
"use client";
import { useEffect, useRef } from "react";
import { AlignCenter, AlignLeft, AlignRight, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { ColorSwatchPicker } from "./color-swatch-picker";
import type {
FontOption,
StoryComposerLabels,
TextOverlay,
} from "../types";
export interface ToolTextInputProps {
overlay: TextOverlay;
fonts: FontOption[];
colorPresets: string[];
labels: Required<StoryComposerLabels>;
onChange: (next: TextOverlay) => void;
onDelete: () => void;
className?: string;
}
const MIN_FONT_SIZE = 16;
const MAX_FONT_SIZE = 96;
/**
* Active-text-overlay editor — text input, font, size, color, align.
* Mounts when there's a selected text overlay.
*/
export function ToolTextInput({
overlay,
fonts,
colorPresets,
labels,
onChange,
onDelete,
className,
}: ToolTextInputProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// Autofocus when a new overlay is selected.
useEffect(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
}, [overlay.id]);
const update = (patch: Partial<TextOverlay>) =>
onChange({ ...overlay, ...patch });
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white",
className,
)}
>
<textarea
ref={textareaRef}
value={overlay.text}
onChange={(e) => update({ text: e.target.value })}
placeholder={labels.textPlaceholder}
rows={2}
className="w-full resize-none rounded-md bg-black/40 border border-white/15 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-white/40"
style={{ fontFamily: overlay.fontFamily }}
/>
{/* Font + color + delete */}
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor="text-font">
{labels.textFontFamily}
</label>
<select
id="text-font"
value={overlay.fontFamily}
onChange={(e) => update({ fontFamily: e.target.value })}
className="flex-1 h-8 rounded-md bg-black/40 border border-white/15 px-2 text-xs"
>
{fonts.map((font) => (
<option
key={font.id}
value={font.family}
style={{ fontFamily: font.family }}
>
{font.label}
</option>
))}
</select>
<ColorSwatchPicker
value={overlay.fill}
presets={colorPresets}
onChange={(color) => update({ fill: color })}
ariaLabel={labels.drawColor}
/>
{/* Align toggle */}
<div className="inline-flex rounded-md bg-black/40 border border-white/15 p-0.5">
{(["left", "center", "right"] as const).map((align) => (
<button
key={align}
type="button"
onClick={() => update({ align })}
aria-label={`${labels.textAlign} ${align}`}
aria-pressed={overlay.align === align}
className={cn(
"p-1 rounded-sm",
overlay.align === align
? "bg-white text-black"
: "text-white/80 hover:text-white",
)}
>
{align === "left" ? (
<AlignLeft className="size-3.5" />
) : align === "center" ? (
<AlignCenter className="size-3.5" />
) : (
<AlignRight className="size-3.5" />
)}
</button>
))}
</div>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
aria-label="Delete text"
className="size-8 text-red-300 hover:bg-red-500/15 hover:text-red-200"
>
<Trash2 className="size-4" />
</Button>
</div>
{/* Font size slider */}
<div className="flex items-center gap-3">
<span className="text-[10px] uppercase tracking-wider text-white/60 w-10">
{labels.textFontSize}
</span>
<Slider
value={[overlay.fontSize]}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
// Radix emits number[]; Base UI number | readonly number[] (F-cross-13)
onValueChange={(v) =>
update({ fontSize: typeof v === "number" ? v : (v[0] ?? overlay.fontSize) })
}
aria-label={labels.textFontSize}
className="flex-1"
/>
<span className="text-xs font-mono tabular-nums w-8 text-right">
{overlay.fontSize}
</span>
</div>
</div>
);
}
src/registry/components/media/media-editor/parts/video-trim-bar.tsx
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent,
} from "react";
import { cn } from "@/lib/utils";
import type { StoryComposerLabels } from "../types";
export interface VideoTrimBarProps {
/** Source video URL (object URL from a Blob is fine). */
videoUrl: string;
/** Trim range in seconds — controlled. */
startSec: number;
endSec: number;
/** Authoritative duration from the source video. */
durationSec: number;
/** Number of preview thumbnails to generate (default 8). */
thumbnailCount?: number;
labels: Required<StoryComposerLabels>;
onChange: (next: { startSec: number; endSec: number }) => void;
className?: string;
}
interface FrameThumb {
t: number;
url: string;
}
const MIN_DURATION_SEC = 0.5;
/**
* Two-handle trim bar with auto-generated frame previews.
*
* Thumbnails are produced once on mount by seeking an off-DOM <video>
* to N evenly-spaced timestamps and snapping each frame to a canvas.
*/
export function VideoTrimBar({
videoUrl,
startSec,
endSec,
durationSec,
thumbnailCount = 8,
labels,
onChange,
className,
}: VideoTrimBarProps) {
const trackRef = useRef<HTMLDivElement | null>(null);
const [thumbs, setThumbs] = useState<FrameThumb[]>([]);
const draggingRef = useRef<"start" | "end" | null>(null);
// ─── Generate frame thumbnails ──────────────────────────────────────
useEffect(() => {
let cancelled = false;
if (!videoUrl || durationSec <= 0) return;
const video = document.createElement("video");
video.src = videoUrl;
video.crossOrigin = "anonymous";
video.muted = true;
video.preload = "auto";
video.playsInline = true;
const cleanup = () => {
video.removeAttribute("src");
video.load();
};
const onLoaded = async () => {
const canvas = document.createElement("canvas");
const w = 64;
const h = 96;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) {
cleanup();
return;
}
const out: FrameThumb[] = [];
for (let i = 0; i < thumbnailCount; i++) {
const t = (i / (thumbnailCount - 1 || 1)) * durationSec;
await seekTo(video, t);
if (cancelled) return;
ctx.drawImage(video, 0, 0, w, h);
const url = canvas.toDataURL("image/jpeg", 0.6);
out.push({ t, url });
}
if (!cancelled) setThumbs(out);
cleanup();
};
video.addEventListener("loadedmetadata", onLoaded, { once: true });
return () => {
cancelled = true;
video.removeEventListener("loadedmetadata", onLoaded);
cleanup();
};
}, [videoUrl, durationSec, thumbnailCount]);
// ─── Drag handlers ──────────────────────────────────────────────────
const positionForX = useCallback(
(clientX: number): number => {
const track = trackRef.current;
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return ratio * durationSec;
},
[durationSec],
);
const handlePointerDown = useCallback(
(which: "start" | "end") => (e: PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
draggingRef.current = which;
},
[],
);
const handlePointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
const next = positionForX(e.clientX);
if (draggingRef.current === "start") {
const clamped = Math.min(next, endSec - MIN_DURATION_SEC);
onChange({ startSec: Math.max(0, clamped), endSec });
} else {
const clamped = Math.max(next, startSec + MIN_DURATION_SEC);
onChange({ startSec, endSec: Math.min(durationSec, clamped) });
}
},
[endSec, startSec, durationSec, onChange, positionForX],
);
const handlePointerUp = useCallback(() => {
draggingRef.current = null;
}, []);
// ─── Render ─────────────────────────────────────────────────────────
const startPct = durationSec > 0 ? (startSec / durationSec) * 100 : 0;
const endPct = durationSec > 0 ? (endSec / durationSec) * 100 : 100;
const placeholders = useMemo(
() => Array.from({ length: thumbnailCount }, (_, i) => i),
[thumbnailCount],
);
return (
<div className={cn("flex flex-col gap-2 select-none", className)}>
<div className="flex items-center justify-between text-[10px] font-mono text-white/70">
<span>{labels.trimStart}: {startSec.toFixed(1)}s</span>
<span>{labels.trimEnd}: {endSec.toFixed(1)}s</span>
</div>
<div
ref={trackRef}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="relative h-16 rounded-md overflow-hidden bg-black/40 ring-1 ring-white/10"
>
{/* Frame strip */}
<div className="absolute inset-0 flex">
{(thumbs.length ? thumbs : placeholders).map((entry, i) =>
typeof entry === "number" ? (
<div
key={i}
className="flex-1 bg-gradient-to-b from-white/5 to-white/10 animate-pulse"
/>
) : (
<div
key={i}
className="flex-1 bg-cover bg-center"
style={{ backgroundImage: `url(${entry.url})` }}
/>
),
)}
</div>
{/* Dim out trimmed regions */}
<div
className="absolute inset-y-0 left-0 bg-black/60"
style={{ width: `${startPct}%` }}
aria-hidden
/>
<div
className="absolute inset-y-0 right-0 bg-black/60"
style={{ width: `${100 - endPct}%` }}
aria-hidden
/>
{/* Selection outline */}
<div
className="absolute inset-y-0 border-y-2 border-white pointer-events-none"
style={{ left: `${startPct}%`, right: `${100 - endPct}%` }}
aria-hidden
/>
{/* Start handle */}
<button
type="button"
aria-label={labels.trimStart}
onPointerDown={handlePointerDown("start")}
className="absolute top-0 bottom-0 w-3 -ml-1.5 grid place-items-center bg-white text-black cursor-ew-resize touch-none"
style={{ left: `${startPct}%` }}
>
<span className="block h-6 w-0.5 bg-black/60" aria-hidden />
</button>
{/* End handle */}
<button
type="button"
aria-label={labels.trimEnd}
onPointerDown={handlePointerDown("end")}
className="absolute top-0 bottom-0 w-3 -ml-1.5 grid place-items-center bg-white text-black cursor-ew-resize touch-none"
style={{ left: `${endPct}%` }}
>
<span className="block h-6 w-0.5 bg-black/60" aria-hidden />
</button>
</div>
</div>
);
}
function seekTo(video: HTMLVideoElement, t: number): Promise<void> {
return new Promise((resolve) => {
const onSeeked = () => {
video.removeEventListener("seeked", onSeeked);
resolve();
};
video.addEventListener("seeked", onSeeked, { once: true });
try {
video.currentTime = Math.min(t, Math.max(0, video.duration || t));
} catch {
video.removeEventListener("seeked", onSeeked);
resolve();
}
});
}
src/registry/components/media/media-editor/hooks/use-capture-extension.ts
"use client";
import * as React from "react";
import type { ComposerMode, StoryComposerLabels, ValidationError } from "../types";
/**
* Capture injection seam (P3 S3 — feature-slicing, strategy b: injection
* surface, R1 VERDICT). Base never statically imports `features/capture/**`
* — a consumer wires camera capture in by passing `capture={mediaCapture}`
* (MediaEditorProps.capture) from `@ilinxa/media-editor-capture`. This file
* owns the context, the extension TYPE, and the surface-props contract the
* base orchestrator (media-editor.tsx) mounts against — mirroring the
* renderer-registry pattern used elsewhere in the library.
*/
// ─── Structural mirrors of the capture feature's capture-result shapes ────
// Duplicated here (NOT imported from features/capture/**) so this base file
// carries zero static dependency on the feature folder — the F-S1 inline-copy
// pattern (see cross-procomp-imports precedent). The feature's own
// `CapturedPhoto` / `CapturedVideo` (features/capture/hooks/use-media-capture.ts)
// are kept structurally identical to these so `EditorCamera` type-checks
// as a `CameraSurface` without any cast.
export interface CapturedPhotoLike {
blob: Blob;
width: number;
height: number;
mimeType: string;
}
export interface CapturedVideoLike {
blob: Blob;
durationMs: number;
mimeType: string;
}
/**
* Props the base orchestrator passes to whatever camera surface answers the
* capture extension. Mirrors `EditorCameraProps` (features/capture/parts/
* editor-camera.tsx) field-for-field — the base mount site passes exactly
* this shape regardless of which implementation is wired in.
*/
export interface MediaCaptureSurfaceProps {
/** Composer is open + we should hold a live camera stream. */
enabled: boolean;
/** Drives mirror behavior, default facing, and the shutter aria-label. */
mode: ComposerMode;
defaultFacing?: "user" | "environment";
recordAudio?: boolean;
maxFileSizeMb?: number;
maxVideoDurationSec?: number;
captureAspectRatio?: number | null;
autoAcquireOverride?: boolean;
labels: Required<StoryComposerLabels>;
onPhoto: (photo: CapturedPhotoLike) => void;
onVideo: (video: CapturedVideoLike) => void;
/** Loads an image / video file picked from the gallery into the editor. */
onGalleryFile: (file: File) => void;
onValidationError?: (error: ValidationError) => void;
onPermissionDenied?: () => void;
renderPermissionDenied?: (ctx: {
retry: () => void;
usePicker: () => void;
}) => React.ReactNode;
}
export interface MediaCaptureExtensionContextValue {
/**
* The camera surface component to mount when the editor is in the capture
* stage with a photo/video mode active. No further capture-state
* accessors are exposed today — capture's own state (permissions, live
* stream, recording) lives inside the surface component itself, so there's
* nothing else for the orchestrator to read.
*/
CameraSurface: React.ComponentType<MediaCaptureSurfaceProps>;
}
export const MediaCaptureExtensionContext =
React.createContext<MediaCaptureExtensionContextValue | null>(null);
/** Orchestrator-internal read. Null when no `capture` extension is wired. */
export function useCaptureExtensionOptional(): MediaCaptureExtensionContextValue | null {
return React.useContext(MediaCaptureExtensionContext);
}
/**
* Public extension contract a consumer installs to enable camera capture.
* `@ilinxa/media-editor-capture` ships `mediaCapture: MediaCaptureExtension`
* — pass it straight through: `<MediaEditor capture={mediaCapture} />`.
* Custom/alternative capture implementations can supply their own `Provider`
* as long as it renders `MediaCaptureExtensionContext.Provider` with a
* `CameraSurface` matching `MediaCaptureSurfaceProps`.
*/
export type MediaCaptureExtension = {
Provider: React.ComponentType<{ children: React.ReactNode }>;
};
src/registry/components/media/media-editor/lib/validate-media-file.ts
import type { ValidationError } from "../types";
/**
* Gallery/file-intake validation — type + size check. Generic (not
* camera-specific): lives in BASE so the file/gallery intake surface works
* with the capture feature slice absent (P3 S3 — the pure-CAPTURE files
* moved to `features/capture/`, but picking a file off disk isn't a capture
* concern). Used by the base file-intake surface (media-editor.tsx) AND by
* `features/capture/parts/editor-camera.tsx`'s gallery button — the feature
* imports this DIRECTLY (relative into base), never through the barrel.
*/
export function validateGalleryFile(
file: File,
maxFileSizeMb: number,
): ValidationError | null {
if (!file.type.startsWith("image/") && !file.type.startsWith("video/")) {
return {
kind: "unsupported-type",
message: `Unsupported file type: ${file.type || "unknown"}`,
file,
};
}
const bytes = file.size;
const maxBytes = maxFileSizeMb * 1024 * 1024;
if (bytes > maxBytes) {
return {
kind: "file-too-large",
message: `File is ${(bytes / 1024 / 1024).toFixed(1)} MB — maximum is ${maxFileSizeMb} MB`,
file,
};
}
return null;
}
src/registry/components/media/media-editor/parts/lazy-editor-canvas.tsx
// Local default-export wrapper so `React.lazy(() => import("./lazy-editor-canvas"))`
// has a target — `EditorCanvas` itself is a named export. Same-folder
// (intra-component): this carries none of the cross-procomp rewriter hazards
// the media-library `lazy-code-block.tsx` / `lazy-markdown-editor.tsx`
// wrappers document (no category segment to mangle, no other registry item
// involved) — it exists purely for the `React.lazy(...)` default-export
// contract. Compound-component-structure rule: the konva canvas is the
// heavy dep (konva + react-konva) every consumer pulls in, so it gets the
// mandated lazy boundary even though (unlike media-library's viewers) it
// isn't itself droppable — the win here is deferring the konva chunk off
// the editor's initial paint (capture stage / text-only mode never need it).
export { EditorCanvas as default } from "./editor-canvas";