"use client"; import React from "react"; import { useParams } from "next/navigation"; import { MousePointer2, Hand, ScanFace, 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 { createEditorToolControllers } 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, "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, importImageFromFile, setImageLayerFaceBlur, 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 previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle"); const imageInputRef = React.useRef(null); const selectedImageLayer = React.useMemo(() => { if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) { return null; } return { id: selectedLayer.id, sourceUri: selectedLayer.sourceUri, width: selectedLayer.width, height: selectedLayer.height, }; }, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]); const isLayerStillSelected = React.useCallback((layerId: string) => { const state = useEditorStore.getState(); return state.selectedLayerId === layerId; }, []); const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({ tool, selectedLayerId, selectedImageLayer, activeLayerStillSelected: isLayerStillSelected, }); const { blurMethod, setBlurMethod, blurAmount, setBlurAmount, censorColor, setCensorColor, selectedFaceIndices, faceBlurPreview, toggleFaceIndex, clearBlur, blurFaces, } = useFaceBlurWorkflow({ selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur, }); 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, exportProjectAsPng]); 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, 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) { setCanvasSize(width, height); } const toolControllers = React.useMemo( () => createEditorToolControllers({ onAddTextLayer: () => addLayerByType("text"), onImportImage: () => imageInputRef.current?.click(), labels: { pointer: t("editor.toolPointer"), pan: t("editor.toolPan"), face: t("editor.toolFace"), text: t("editor.toolText"), image: t("editor.toolImage"), }, }), [addLayerByType, 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")} />
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}
); }