"use client"; import React from "react"; import { useParams } from "next/navigation"; import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react"; import { useEditorStore } from "../../../store/editor-store"; import { useUiStore, isDarkTheme } from "../../../store/ui-store"; import { CanvasSizeModal } from "../../../components/canvas-size-modal"; import { EditorCanvasStage } from "../../../components/editor/editor-canvas-stage"; import { EditorHeader } from "../../../components/editor/editor-header"; import { EditorMobileSection } from "../../../components/editor/editor-mobile-section"; import { EditorSidebar } from "../../../components/editor/editor-sidebar"; import { ToolRail } from "../../../components/editor/tool-rail"; import { exportProjectAsPng } from "../../../lib/export-png"; import { floodFillDataUrl } from "../../../lib/flood-fill"; import { commitStroke, type BrushStroke } from "../../../lib/brush-painter"; import type { EditorToolController } from "../../../lib/editor-tool-controller"; import { useFaceDetection } from "../../../hooks/use-face-detection"; import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow"; import { useEditorAutosave } from "../../../hooks/use-editor-autosave"; import { useEditorBindings } from "../../../hooks/use-editor-bindings"; import { useEditorContextMenu } from "../../../hooks/use-editor-context-menu"; import { useEditorLabels } from "../../../hooks/use-editor-labels"; import { useEditorProjectLifecycle } from "../../../hooks/use-editor-project-lifecycle"; import { useEditorShortcuts } from "../../../hooks/use-editor-shortcuts"; import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job"; import { useTranslations } from "../../../hooks/use-translations"; import type { AspectRatio } from "@pien-studio/types"; const TOOL_ICONS: Record> = { pointer: MousePointer2, hand: Hand, face: ScanFace, fill: PaintBucket, brush: Brush, "add-text": Type, "import-image": ImagePlus, }; export default function EditorPage() { useAssetCleanupJob(); const params = useParams<{ projectId: string }>(); const projectId = params.projectId; const { state, actions } = useEditorBindings(); const { project, selectedLayerId, selectedLayer, canUndo, canRedo, history, isDirty, tool, } = state; const { loadProjectById, setProject, saveCurrentProject, selectLayer, setSelectedLayerPosition, setSelectedLayerPositionDraft, setSelectedLayerSize, setSelectedLayerSizeDraft, setSelectedLayerRotation, setSelectedLayerRotationDraft, startTransaction, commitTransaction, removeSelectedLayer, addLayerByType, moveSelectedLayerOrder, addCanvasSizedLayer, importImageFromFile, updateImageLayerSource, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled, setCanvasSize, setTool, undo, redo, exportProjectToJson, jumpToPast, jumpToFuture, copySelectedLayer, cutSelectedLayer, pasteLayer, } = actions; const { theme, hydrate } = useUiStore((s) => s); const { t } = useTranslations(); const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t); const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu(); const [canvasModalOpen, setCanvasModalOpen] = React.useState(false); const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false); const [fillColor, setFillColor] = React.useState("#ff0000"); const [fillTolerance, setFillTolerance] = React.useState(32); const [brushColor, setBrushColor] = React.useState("#000000"); const [brushSize, setBrushSize] = React.useState(20); const [brushOpacity, setBrushOpacity] = React.useState(1); const [brushHardness, setBrushHardness] = React.useState(0.8); const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle"); const imageInputRef = React.useRef(null); const selectedImageLayer = React.useMemo(() => { if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) { return null; } return { id: selectedLayer.id, sourceUri: selectedLayer.sourceUri, width: selectedLayer.width, height: selectedLayer.height, }; }, [selectedLayer]); const isLayerStillSelected = React.useCallback((layerId: string) => { const state = useEditorStore.getState(); return state.selectedLayerId === layerId; }, []); const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({ tool, selectedImageLayer, activeLayerStillSelected: isLayerStillSelected, }); const { blurMethod, setBlurMethod, blurAmount, setBlurAmount, censorColor, setCensorColor, selectedFaceIndices, faceBlurPreview, toggleFaceIndex, clearBlur, blurFaces, } = useFaceBlurWorkflow({ selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect, }); React.useEffect(() => { function handleBeforeUnload(e: BeforeUnloadEvent) { if (isDirty) { e.preventDefault(); e.returnValue = ""; } } window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isDirty]); useEditorProjectLifecycle({ projectId, hydrate, loadProjectById, setProject, t, }); const isDark = isDarkTheme(theme); const { width: cw, height: ch } = project.canvas; const handleSave = React.useCallback(async () => { await saveCurrentProject(); }, [saveCurrentProject]); const handleExportPng = React.useCallback(async () => { await exportProjectAsPng(project, { isDark }); }, [isDark, project]); const handleExportProjectFile = React.useCallback(() => { const json = exportProjectToJson(); const blob = new Blob([json], { type: "application/json" }); const href = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = href; anchor.download = `${project.title || "project"}.pien.json`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(href); }, [exportProjectToJson, project.title]); useEditorShortcuts({ onSave: handleSave, onUndo: undo, onRedo: redo, onCopy: copySelectedLayer, onCut: cutSelectedLayer, onPaste: pasteLayer, onDelete: removeSelectedLayer, }); useEditorAutosave({ isDirty, projectUpdatedAt: project.updatedAt, saveCurrentProject, }); React.useEffect(() => { if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") { setFaceMlErrorModalOpen(true); } previousFaceStatusRef.current = faceStatus; }, [faceStatus]); async function handleImageImport(event: React.ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; await importImageFromFile(file); event.target.value = ""; } function handleCanvasApply(width: number, height: number, aspect: AspectRatio) { void aspect; setCanvasSize(width, height); } const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => { const layer = project.layers.find((l) => l.id === layerId); if (!layer || layer.type !== "raster" || !layer.sourceUri) return; const layerWidth = layer.width ?? Math.round(200 * layer.scale); const layerHeight = layer.height ?? Math.round(150 * layer.scale); // x/y are in layer CSS-pixel space; scale to image pixel space const img = new Image(); const uri = layer.sourceUri; const color = fillColor; const tolerance = fillTolerance; img.onload = () => { const scaleX = img.naturalWidth / layerWidth; const scaleY = img.naturalHeight / layerHeight; const pixelX = x * scaleX; const pixelY = y * scaleY; floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => { if (nextUri !== uri) updateImageLayerSource(layerId, nextUri); }).catch(() => {}); }; img.src = uri; }, [project.layers, fillColor, fillTolerance, updateImageLayerSource]); const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => { const layer = project.layers.find((l) => l.id === layerId); if (!layer || layer.type !== "raster" || !layer.sourceUri) return; try { const nextUri = await commitStroke(layer.sourceUri, stroke); updateImageLayerSource(layerId, nextUri); } catch {} }, [project.layers, updateImageLayerSource]); const handleCreateFillLayer = React.useCallback(() => { const canvas = document.createElement("canvas"); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.fillStyle = fillColor; ctx.fillRect(0, 0, cw, ch); addCanvasSizedLayer(canvas.toDataURL("image/png"), "Fill Layer"); }, [cw, ch, fillColor, addCanvasSizedLayer]); const handleAddLayer = React.useCallback(() => { const canvas = document.createElement("canvas"); canvas.width = cw; canvas.height = ch; addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer"); }, [cw, ch, addCanvasSizedLayer]); const handleImportImageClick = React.useCallback(() => { imageInputRef.current?.click(); }, []); const toolControllers = React.useMemo(() => [ { kind: "mode", id: "pointer", label: t("editor.toolPointer") }, { kind: "mode", id: "hand", label: t("editor.toolPan") }, { kind: "mode", id: "face", label: t("editor.toolFace") }, { kind: "mode", id: "fill", label: t("editor.toolFill") }, { kind: "mode", id: "brush", label: t("editor.toolBrush") }, { kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") }, { kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick }, ], [addLayerByType, handleImportImageClick, t]); const canvasBindings = { onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y), onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y), onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height), onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height), onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation), onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation), onInteractionStart: startTransaction, onInteractionEnd: commitTransaction, }; return (
imageInputRef.current?.click()} onOpenCanvasSize={() => setCanvasModalOpen(true)} onUndo={undo} onRedo={redo} onCopy={copySelectedLayer} onCut={cutSelectedLayer} onPaste={pasteLayer} onSetHandTool={() => setTool("hand")} onSetPointerTool={() => setTool("pointer")} />
setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)} onMoveLayerOrder={moveSelectedLayerOrder} onRemoveSelectedLayer={removeSelectedLayer} onUndo={undo} onRedo={redo} onJumpToPast={jumpToPast} onJumpToFuture={jumpToFuture} onSetBlurMethod={setBlurMethod} onSetBlurAmount={setBlurAmount} onSetCensorColor={setCensorColor} onToggleFaceIndex={toggleFaceIndex} onBlur={(indices) => void blurFaces(indices)} onClearBlur={clearBlur} />
setCanvasModalOpen(true)} onImportImage={() => imageInputRef.current?.click()} onSelectLayer={selectLayer} onMoveLayer={canvasBindings.onMoveLayer} onMoveLayerEnd={canvasBindings.onMoveLayerEnd} onResizeLayer={canvasBindings.onResizeLayer} onResizeLayerEnd={canvasBindings.onResizeLayerEnd} onRotateLayer={canvasBindings.onRotateLayer} onRotateLayerEnd={canvasBindings.onRotateLayerEnd} onInteractionStart={canvasBindings.onInteractionStart} onInteractionEnd={canvasBindings.onInteractionEnd} /> setCanvasModalOpen(false)} currentWidth={cw} currentHeight={ch} currentAspect={project.aspectRatio} onApply={handleCanvasApply} isDark={isDark} /> {faceMlErrorModalOpen ? (
setFaceMlErrorModalOpen(false)}>
event.stopPropagation()} >

{t("editor.faceTool")}

{t("editor.faceMlFailed")}

) : null}
); }