{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-compact-layout",
  "title": "Compact Layout",
  "description": "Phone layout: canvas first, a bottom tool bar, and panels in a bottom sheet that keeps the photo touchable.",
  "dependencies": [
    "lucide-react",
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "button",
    "dropdown-menu",
    "https://photocn.dev/r/image-editor-canvas.json",
    "https://photocn.dev/r/image-editor-export.json",
    "https://photocn.dev/r/image-editor-layout.json",
    "https://photocn.dev/r/image-editor-tool-panel.json",
    "https://photocn.dev/r/image-editor-toolbar.json"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/compact-layout.tsx",
      "content": "\"use client\";\n\nimport {\n  useRef,\n  useState,\n  type ChangeEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport {\n  BookmarkPlusIcon,\n  EllipsisIcon,\n  FolderOpenIcon,\n  ImagePlusIcon,\n  RotateCcwIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { downloadRecipe, parseRecipe } from \"photocn\";\nimport { useImageEditor, type ImageEditorToolId } from \"photocn/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ImageEditorCanvas } from \"./canvas\";\nimport { ImageEditorExportDialog } from \"./export-dialog\";\nimport { useElementHeight } from \"./layout\";\nimport { ImageEditorToolPanel } from \"./tool-panel\";\nimport {\n  ImageEditorCompareButton,\n  ImageEditorRedoButton,\n  ImageEditorUndoButton,\n  type EditorToolbarTool,\n} from \"./toolbar\";\n\nexport interface ImageEditorCompactLayoutProps {\n  tools: readonly EditorToolbarTool[];\n  panels?: Partial<Record<ImageEditorToolId, ReactNode>>;\n  showOpenButton?: boolean;\n  showRecipes?: boolean;\n  toolbarExtra?: ReactNode;\n  onSave?: Parameters<typeof ImageEditorExportDialog>[0][\"onSave\"];\n  /**\n   * Where the active tool's panel goes: `\"drawer\"` (default), a sheet that\n   * slides up inside the editor with two snap points, or `\"inline\"`, a\n   * panel between the photo and the tool bar.\n   */\n  panelMode?: \"drawer\" | \"inline\";\n  className?: string;\n}\n\n/** Sheet heights as a fraction of the viewport: peek and expanded. */\nconst SNAP_POINTS = [0.42, 0.9] as const;\n\n/**\n * The phone layout: canvas first, a thumb-reachable tool bar at the bottom,\n * and the active tool's controls in a drawer that leaves the photo visible.\n */\nexport function ImageEditorCompactLayout({\n  tools,\n  panels,\n  showOpenButton = true,\n  showRecipes = true,\n  toolbarExtra,\n  onSave,\n  panelMode,\n  className,\n}: ImageEditorCompactLayoutProps) {\n  const editor = useImageEditor();\n  const mode = panelMode ?? \"drawer\";\n  // The drawer is sized against the editor, not the window, so it works the\n  // same full screen on a phone and in a narrow container on desktop.\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const rootHeight = useElementHeight(rootRef);\n  const [panelOpen, setPanelOpen] = useState(false);\n  const [snap, setSnap] = useState<number>(SNAP_POINTS[0]);\n  const activeTool = tools.find((tool) => tool.value === editor.tool);\n\n  const selectTool = (value: string) => {\n    if (value === editor.tool) {\n      setPanelOpen((open) => !open);\n      return;\n    }\n    editor.setTool(value);\n    setPanelOpen(true);\n    setSnap(SNAP_POINTS[0]);\n  };\n\n  // Keep the photo above the drawer: pad the canvas by the drawer's peek height.\n  // The canvas sits above the bottom tool bar (~3.25rem), so this leaves\n  // ~1.5rem between the photo and the sheet: room for the 44px crop handles.\n  const drawerInset =\n    mode === \"drawer\" && panelOpen && rootHeight ? Math.max(0, rootHeight * SNAP_POINTS[0] - 28) : undefined;\n\n  return (\n    <div\n      className={cn(\"relative flex h-full min-h-0 flex-col overflow-hidden bg-background\", className)}\n      data-panel-mode={mode}\n      data-slot=\"image-editor-compact\"\n      ref={rootRef}\n    >\n      <header className=\"flex h-12 shrink-0 items-center gap-1 border-b px-2 pt-[env(safe-area-inset-top)]\">\n        <ImageEditorUndoButton className=\"size-11\" />\n        <ImageEditorRedoButton className=\"size-11\" />\n        <div className=\"ml-auto flex items-center gap-1\">\n          <ImageEditorCompareButton className=\"size-11\" showLabel={false} variant=\"ghost\" />\n          {toolbarExtra}\n          <ImageEditorExportDialog onSave={onSave} />\n          <CompactMenu showOpenButton={showOpenButton} showRecipes={showRecipes} />\n        </div>\n      </header>\n\n      <div\n        className=\"min-h-0 flex-1 transition-[padding] duration-200\"\n        style={{ paddingBottom: drawerInset }}\n      >\n        <ImageEditorCanvas className=\"p-2\" padding={12} showOpenButton={showOpenButton} />\n      </div>\n\n      {mode === \"inline\" && panelOpen ? (\n        <div className=\"max-h-[45%] min-h-0 shrink-0 overflow-y-auto border-t\" data-slot=\"image-editor-inline-panel\">\n          <ImageEditorToolPanel panels={panels} />\n        </div>\n      ) : null}\n\n      <CompactToolBar activeTool={editor.tool} disabled={!editor.hasImage || editor.disabled} onSelect={selectTool} tools={tools} />\n\n      {mode === \"drawer\" ? (\n        <PanelSheet\n          label={activeTool?.label ?? \"Tool\"}\n          onOpenChange={setPanelOpen}\n          onSnapChange={setSnap}\n          open={panelOpen}\n          snap={snap}\n        >\n          <div className=\"flex items-center gap-1 border-b px-2 pb-1\">\n            <CompactToolBar\n              activeTool={editor.tool}\n              className=\"flex-1 border-0 pb-0\"\n              disabled={editor.disabled}\n              onSelect={(value) => editor.setTool(value)}\n              tools={tools}\n            />\n            <Button\n              aria-label=\"Close panel\"\n              className=\"size-11 shrink-0\"\n              onClick={() => setPanelOpen(false)}\n              size=\"icon\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n            </Button>\n          </div>\n          <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]\">\n            <ImageEditorToolPanel panels={panels} />\n          </div>\n        </PanelSheet>\n      ) : null}\n    </div>\n  );\n}\n\n/** Horizontally scrollable tool picker with 44px targets. */\nexport function CompactToolBar({\n  tools,\n  activeTool,\n  disabled,\n  onSelect,\n  className,\n}: {\n  tools: readonly EditorToolbarTool[];\n  activeTool: string;\n  disabled?: boolean;\n  onSelect: (value: string) => void;\n  className?: string;\n}) {\n  return (\n    <nav\n      aria-label=\"Editor tools\"\n      className={cn(\n        \"flex shrink-0 gap-1 overflow-x-auto border-t px-2 pt-1 pb-[max(0.25rem,env(safe-area-inset-bottom))] [scrollbar-width:none]\",\n        className,\n      )}\n      data-slot=\"image-editor-compact-tools\"\n    >\n      {tools.map((tool) => {\n        const Icon = tool.icon;\n        const active = tool.value === activeTool;\n        return (\n          <button\n            aria-pressed={active}\n            className={cn(\n              \"flex min-h-11 min-w-14 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg px-2 text-[11px] text-muted-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50\",\n              active && \"bg-primary text-primary-foreground\",\n            )}\n            disabled={disabled || tool.disabled}\n            key={tool.value}\n            onClick={() => onSelect(tool.value)}\n            type=\"button\"\n          >\n            <Icon aria-hidden=\"true\" className=\"size-5\" />\n            {tool.label}\n          </button>\n        );\n      })}\n    </nav>\n  );\n}\n\nfunction CompactMenu({ showOpenButton, showRecipes }: { showOpenButton: boolean; showRecipes: boolean }) {\n  const editor = useImageEditor();\n  const recipeInput = useRef<HTMLInputElement | null>(null);\n  const loadRecipe = async (event: ChangeEvent<HTMLInputElement>) => {\n    const file = event.currentTarget.files?.[0];\n    event.currentTarget.value = \"\";\n    if (!file) return;\n    try {\n      await editor.recipes.apply(parseRecipe(await file.text()));\n    } catch (error) {\n      console.error(\"[photocn] Failed to load recipe\", error);\n    }\n  };\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button aria-label=\"More\" className=\"size-11\" size=\"icon\" type=\"button\" variant=\"ghost\">\n              <EllipsisIcon />\n            </Button>\n          }\n        />\n        <DropdownMenuContent align=\"end\" className=\"min-w-48\">\n          {showOpenButton ? (\n            <DropdownMenuItem onClick={() => void editor.openFile()}>\n              <ImagePlusIcon /> Open image\n            </DropdownMenuItem>\n          ) : null}\n          {showRecipes ? (\n            <>\n              <DropdownMenuItem\n                disabled={!editor.recipes.current}\n                onClick={() => editor.recipes.current && downloadRecipe(editor.recipes.current, editor.filename)}\n              >\n                <BookmarkPlusIcon /> Save look\n              </DropdownMenuItem>\n              <DropdownMenuItem onClick={() => recipeInput.current?.click()}>\n                <FolderOpenIcon /> Load look\n              </DropdownMenuItem>\n            </>\n          ) : null}\n          <DropdownMenuSeparator />\n          <DropdownMenuItem disabled={!editor.hasImage} onClick={editor.resetAll} variant=\"destructive\">\n            <RotateCcwIcon /> Reset all edits\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n      <input\n        accept=\"application/json\"\n        aria-hidden=\"true\"\n        className=\"hidden\"\n        onChange={loadRecipe}\n        ref={recipeInput}\n        tabIndex={-1}\n        type=\"file\"\n      />\n    </>\n  );\n}\n\n/**\n * A non-modal bottom sheet styled like shadcn's Drawer. vaul's Drawer is\n * always modal (it blocks the page), but the photo must stay touchable while\n * a panel is open, e.g. to drag the crop frame. Drag the handle between the\n * two snap points; drag down to close.\n */\nfunction PanelSheet({\n  open,\n  onOpenChange,\n  snap,\n  onSnapChange,\n  label,\n  children,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  snap: number;\n  onSnapChange: (snap: number) => void;\n  label: string;\n  children: ReactNode;\n}) {\n  const drag = useRef<{ pointerId: number; startY: number; startSnap: number } | null>(null);\n  const [dragSnap, setDragSnap] = useState<number | null>(null);\n  const height = dragSnap ?? snap;\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { pointerId: event.pointerId, startY: event.clientY, startSnap: snap };\n  };\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== event.pointerId) return;\n    const container = event.currentTarget.closest(\"[data-slot=image-editor-compact]\");\n    const containerHeight = container?.clientHeight || window.innerHeight;\n    const next = d.startSnap - (event.clientY - d.startY) / containerHeight;\n    setDragSnap(Math.min(0.96, Math.max(0.08, next)));\n  };\n  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== event.pointerId) return;\n    drag.current = null;\n    const released = dragSnap ?? snap;\n    setDragSnap(null);\n    if (released < SNAP_POINTS[0] * 0.6) {\n      onOpenChange(false);\n      return;\n    }\n    const nearest = SNAP_POINTS.reduce((best, point) =>\n      Math.abs(point - released) < Math.abs(best - released) ? point : best,\n    );\n    onSnapChange(nearest);\n  };\n\n  return (\n    <section\n      aria-hidden={!open}\n      aria-label={`${label} controls`}\n      className={cn(\n        \"absolute inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl border-t bg-popover text-sm text-popover-foreground shadow-lg\",\n        dragSnap === null && \"transition-[height,transform] duration-300 ease-out\",\n        !open && \"pointer-events-none translate-y-full\",\n      )}\n      data-open={open || undefined}\n      data-slot=\"image-editor-drawer\"\n      inert={!open}\n      style={{ height: `${height * 100}%` }}\n    >\n      <div\n        aria-label=\"Resize panel\"\n        className=\"flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing\"\n        onPointerCancel={onPointerUp}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        role=\"separator\"\n      >\n        <span className=\"h-1.5 w-12 rounded-full bg-muted\" />\n      </div>\n      {children}\n    </section>\n  );\n}\n\n",
      "type": "registry:component",
      "target": "components/image-editor/compact-layout.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}