shadimage
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 · light
iconslucide-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&apos;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";

More from ilinxa

Browse all components in this library →