mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@pien-studio/api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist",
|
||||
"start": "bun run dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"elysia": "^1.1.25",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "./index";
|
||||
|
||||
describe("api endpoints", () => {
|
||||
it("returns root route info", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(new Request("http://localhost/"));
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.name).toBe("pien-api");
|
||||
});
|
||||
|
||||
it("returns healthy status", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(new Request("http://localhost/health"));
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns token for valid device payload", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(
|
||||
new Request("http://localhost/auth/device", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceId: "dev1234", locale: "en" }),
|
||||
}),
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.token).toBe("dev_dev1234");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { DeviceSessionSchema } from "@pien-studio/types";
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.get("/", () => ({
|
||||
name: "pien-api",
|
||||
status: "ok",
|
||||
}))
|
||||
.get("/health", () => ({ ok: true, service: "pien-api" }))
|
||||
.post("/auth/device", ({ body }) => {
|
||||
const parsed = DeviceSessionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify({ error: "invalid_device_payload" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return {
|
||||
token: `dev_${parsed.data.deviceId}`,
|
||||
scope: "local-sync",
|
||||
};
|
||||
})
|
||||
.get("/sync/bootstrap", () => ({
|
||||
replication: {
|
||||
pull: "/sync/pull",
|
||||
push: "/sync/push",
|
||||
strategy: "couch-compatible",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
createApp().listen(4000);
|
||||
console.log("pien api listening on http://localhost:4000");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
"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 } 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<string, React.ComponentType<{ className?: string }>> = {
|
||||
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<HTMLInputElement | null>(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 = theme === "dark";
|
||||
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<HTMLInputElement>) {
|
||||
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 (
|
||||
<main
|
||||
className={`h-screen p-0 overflow-hidden ${isDark ? "bg-[#202124] text-[#e8eaed]" : "bg-[#f2f4f8] text-[#1f2430]"}`}
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<EditorHeader
|
||||
isDark={isDark}
|
||||
projectTitle={project.title}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
isDirty={isDirty}
|
||||
labels={headerLabels}
|
||||
onSave={handleSave}
|
||||
onExportPng={handleExportPng}
|
||||
onExportProjectFile={handleExportProjectFile}
|
||||
onImportImage={() => imageInputRef.current?.click()}
|
||||
onOpenCanvasSize={() => setCanvasModalOpen(true)}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
onSetHandTool={() => setTool("hand")}
|
||||
onSetPointerTool={() => setTool("pointer")}
|
||||
/>
|
||||
|
||||
<section className="hidden h-[calc(100vh-56px)] grid-cols-[68px_1fr_300px] lg:grid">
|
||||
<ToolRail
|
||||
controllers={toolControllers}
|
||||
selectedTool={tool}
|
||||
isDark={isDark}
|
||||
icons={TOOL_ICONS}
|
||||
onSetTool={setTool}
|
||||
/>
|
||||
|
||||
<EditorCanvasStage
|
||||
isDark={isDark}
|
||||
layers={project.layers}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
contextMenu={contextMenu}
|
||||
labels={contextMenuLabels}
|
||||
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}
|
||||
onContextMenu={openContextMenu}
|
||||
onCloseContextMenu={closeContextMenu}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
/>
|
||||
|
||||
<EditorSidebar
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayer={selectedLayer}
|
||||
history={history}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
onSelectLayer={selectLayer}
|
||||
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}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<EditorMobileSection
|
||||
isDark={isDark}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceStatus={faceStatus}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
labels={mobileLabels}
|
||||
onOpenCanvasSize={() => 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}
|
||||
/>
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
|
||||
|
||||
<CanvasSizeModal
|
||||
isOpen={canvasModalOpen}
|
||||
onClose={() => setCanvasModalOpen(false)}
|
||||
currentWidth={cw}
|
||||
currentHeight={ch}
|
||||
currentAspect={project.aspectRatio}
|
||||
onApply={handleCanvasApply}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{faceMlErrorModalOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setFaceMlErrorModalOpen(false)}>
|
||||
<div
|
||||
className={`w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{t("editor.faceTool")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{t("editor.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
:root {
|
||||
--color-cream: #fff7ef;
|
||||
--color-berry: #cc5d8f;
|
||||
--color-wine: #5a2339;
|
||||
--color-mint: #c9f0df;
|
||||
--color-ink: #1f1b24;
|
||||
--color-accent: #f5a3c7;
|
||||
--color-accent-strong: #e782b1;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f5f6f8;
|
||||
--bg-a: #f5f6f8;
|
||||
--bg-b: #f5f6f8;
|
||||
--bg-c: #f5f6f8;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color-cream: #1f2126;
|
||||
--color-berry: #d979a9;
|
||||
--color-wine: #f1d9e7;
|
||||
--color-mint: #2d3a35;
|
||||
--color-ink: #eef1f5;
|
||||
--color-accent: #f3a9cc;
|
||||
--color-accent-strong: #e88bb8;
|
||||
--color-surface: #2a2c31;
|
||||
--color-surface-2: #23252a;
|
||||
--bg-a: #1b1d21;
|
||||
--bg-b: #1b1d21;
|
||||
--bg-c: #1b1d21;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
background: var(--bg-a), var(--bg-b), var(--bg-c);
|
||||
min-height: 100vh;
|
||||
font-family: "Zen Kaku Gothic New", "Noto Sans Thai", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "pien.studio",
|
||||
description: "Local-first expressive image editor",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme";
|
||||
import { UiPreferences } from "../components/ui-preferences";
|
||||
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
export default function HomePage() {
|
||||
useAssetCleanupJob();
|
||||
const router = useRouter();
|
||||
const setProject = useEditorStore((s) => s.setProject);
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [showWipModal, setShowWipModal] = React.useState(true);
|
||||
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
|
||||
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const refreshProjects = React.useCallback(async () => {
|
||||
setProjects(await loadProjects());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
void refreshProjects();
|
||||
}, [hydrate, refreshProjects]);
|
||||
|
||||
function openProject(project: Project) {
|
||||
setProject(project);
|
||||
router.push(`/editor/${project.id}`);
|
||||
}
|
||||
|
||||
async function confirmDeleteProject() {
|
||||
if (!projectPendingDelete) return;
|
||||
await deleteProject(projectPendingDelete.id);
|
||||
await refreshProjects();
|
||||
setProjectPendingDelete(null);
|
||||
}
|
||||
|
||||
async function handleNewProject() {
|
||||
const project = createProject(t("home.untitledProject"));
|
||||
await upsertProject(project);
|
||||
openProject(project);
|
||||
}
|
||||
|
||||
async function handleImportProjectFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const raw = await file.text();
|
||||
try {
|
||||
const parsed = parseProjectFile(raw);
|
||||
if (!parsed.ok) return;
|
||||
await upsertProject(parsed.project);
|
||||
await refreshProjects();
|
||||
openProject(parsed.project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const title = file.name.replace(/\.[^/.]+$/, "") || t("home.imageProject");
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const sourceUri = typeof reader.result === "string" ? reader.result : undefined;
|
||||
if (!sourceUri) return;
|
||||
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve({ width: 1, height: 1 });
|
||||
image.src = sourceUri;
|
||||
});
|
||||
|
||||
const base = createProject(title, "free");
|
||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
||||
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("image", {
|
||||
name: file.name,
|
||||
sourceUri,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
}));
|
||||
|
||||
await upsertProject(project);
|
||||
await refreshProjects();
|
||||
openProject(project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showWipModal ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setShowWipModal(false)}>
|
||||
<div
|
||||
className={cx("w-full max-w-lg rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{t("home.wipTitle")}</h2>
|
||||
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>{t("home.wipBody")}</p>
|
||||
<p className={cx("mt-3 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
||||
{t("home.wipSupportPrefix")} {" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/YuzuZensai"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="font-semibold text-[var(--color-accent-strong)] underline underline-offset-2"
|
||||
>
|
||||
github.com/sponsors/YuzuZensai
|
||||
</a>
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWipModal(false)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
|
||||
>
|
||||
{t("home.wipAcknowledge")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projectPendingDelete ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setProjectPendingDelete(null)}>
|
||||
<div
|
||||
className={cx("w-full max-w-md rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Delete project?</h2>
|
||||
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
||||
This will permanently delete <span className="font-semibold">{projectPendingDelete.title}</span> from local storage.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectPendingDelete(null)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", subtleButtonClass(isDark))}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void confirmDeleteProject();
|
||||
}}
|
||||
className="rounded border border-red-500/40 bg-red-500/15 px-3 py-1.5 text-sm font-semibold text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main
|
||||
className={`min-h-screen w-full px-4 py-5 sm:px-6 ${
|
||||
isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
<section className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className={cx("text-[10px] uppercase tracking-[0.2em]", isDark ? "text-[#a8abb2]" : "text-[#6c7382]")}>pien.studio</p>
|
||||
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
|
||||
<p className={cx("text-sm", isDark ? "text-[#b9bec8]" : "text-[#5f6672]")}>{t("home.createOpenManage")}</p>
|
||||
</div>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewProject}
|
||||
className={cx("rounded border px-3 py-2 text-sm font-semibold", accentButtonClass())}
|
||||
>
|
||||
{t("home.newProject")}
|
||||
</button>
|
||||
<button type="button" onClick={() => projectInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
||||
{t("home.openProjectFile")}
|
||||
</button>
|
||||
<button type="button" onClick={() => imageInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
||||
{t("home.openImage")}
|
||||
</button>
|
||||
<input ref={projectInputRef} type="file" accept=".json,.pien.json,application/json" className="hidden" onChange={handleImportProjectFile} />
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleOpenImage} />
|
||||
</section>
|
||||
|
||||
<section className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}>
|
||||
<h2 className={cx("mb-3 text-sm font-semibold uppercase tracking-wide", isDark ? "text-[#c5cad3]" : "text-[#6c7382]")}>{t("home.myProjects")}</h2>
|
||||
{projects.length === 0 ? <p className={cx("text-sm", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{t("home.noProjectsYet")}</p> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<article key={project.id} className={cx("rounded border p-3", mutedSurfaceClass(isDark))}>
|
||||
<p className={cx("truncate text-sm font-semibold", isDark ? "text-[#f3f5f8]" : "text-[#1f2430]")}>{project.title}</p>
|
||||
<p className={cx("mt-1 text-xs", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{new Date(project.updatedAt).toLocaleString()}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProject(project)}
|
||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", accentButtonClass())}
|
||||
>
|
||||
{t("home.open")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void duplicateProject(project.id).then(refreshProjects);
|
||||
}}
|
||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", subtleButtonClass(isDark))}
|
||||
>
|
||||
{t("home.duplicate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProjectPendingDelete(project);
|
||||
}}
|
||||
className="rounded border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-semibold text-red-200"
|
||||
>
|
||||
{t("home.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import NextImage from "next/image";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { CANVAS_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE } from "../lib/editor-constants";
|
||||
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
||||
import { renderFaceBlurRegions } from "../lib/face-blur-renderer";
|
||||
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
|
||||
interface CanvasRendererProps {
|
||||
layers: Layer[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer?: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd?: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer?: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd?: (id: string, rotation: number) => void;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
isDark: boolean;
|
||||
tool?: "pointer" | "hand" | "face";
|
||||
faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[];
|
||||
faceOverlayLayerId?: string | null;
|
||||
faceBlurPreview?: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
function BlurredImageLayer({
|
||||
layer,
|
||||
width,
|
||||
height,
|
||||
faceBlurOverride,
|
||||
}: {
|
||||
layer: Layer;
|
||||
width: number;
|
||||
height: number;
|
||||
faceBlurOverride?: {
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number; censorColor?: string }[];
|
||||
censorColor?: string;
|
||||
} | null;
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const draw = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const cw = canvas.width;
|
||||
const ch = canvas.height;
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
ctx.drawImage(image, 0, 0, cw, ch);
|
||||
const blur = faceBlurOverride ?? layer.faceBlur;
|
||||
if (!blur || blur.regions.length === 0) return;
|
||||
renderFaceBlurRegions(ctx, image, blur, cw, ch);
|
||||
}, [faceBlurOverride, layer.faceBlur]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layer.sourceUri) return;
|
||||
let canceled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
imageRef.current = image;
|
||||
draw();
|
||||
};
|
||||
image.src = layer.sourceUri;
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [draw, layer.sourceUri]);
|
||||
|
||||
React.useEffect(() => {
|
||||
draw();
|
||||
}, [draw, width, height]);
|
||||
|
||||
return <canvas ref={canvasRef} width={Math.max(1, Math.round(width))} height={Math.max(1, Math.round(height))} className="pointer-events-none h-full w-full rounded object-cover" />;
|
||||
}
|
||||
|
||||
export function CanvasRenderer({
|
||||
layers,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
selectedLayerId,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
isDark,
|
||||
tool = "pointer",
|
||||
faceDetections = [],
|
||||
faceOverlayLayerId = null,
|
||||
faceBlurPreview = null,
|
||||
}: CanvasRendererProps) {
|
||||
const { t } = useTranslations();
|
||||
const {
|
||||
containerRef,
|
||||
viewport,
|
||||
isSpacePan,
|
||||
onContainerPointerDown,
|
||||
onContainerPointerMove,
|
||||
onContainerPointerUp,
|
||||
onLayerPointerDown,
|
||||
onResizeHandleDown,
|
||||
onRotateHandleDown,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onContextMenuOpen,
|
||||
} = useCanvasInteractions({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
tool,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
});
|
||||
|
||||
const faceLabelOverlays = React.useMemo(() => {
|
||||
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return [];
|
||||
return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport);
|
||||
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
|
||||
onPointerDown={(e) => {
|
||||
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
||||
onContainerPointerDown(e);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onPointerMove={onContainerPointerMove}
|
||||
onPointerUp={onContainerPointerUp}
|
||||
onPointerCancel={onContainerPointerUp}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={onContextMenuOpen}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
willChange: "transform",
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||
transformOrigin: "0 0",
|
||||
boxShadow: "0 0 0 1px var(--color-accent-strong)",
|
||||
background: isDark ? "#17181b" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
{layers.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
||||
const handleSizePx = `${handleSize}px`;
|
||||
const largeHandleSize = CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
|
||||
const largeHandleSizePx = `${largeHandleSize}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: layer.x,
|
||||
top: layer.y,
|
||||
width: layerWidth,
|
||||
height: layerHeight,
|
||||
transform: `rotate(${layer.rotation}deg)`,
|
||||
opacity: layer.opacity,
|
||||
cursor: "move",
|
||||
border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent",
|
||||
outline: isSelected ? "2px solid var(--color-accent-strong)" : "none",
|
||||
outlineOffset: "2px",
|
||||
zIndex: idx,
|
||||
}}
|
||||
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
>
|
||||
{isImage && layer.sourceUri ? (
|
||||
(faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) ||
|
||||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
||||
<BlurredImageLayer
|
||||
layer={layer}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
src={layer.sourceUri}
|
||||
alt={layer.name ?? t("editor.layer")}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
unoptimized
|
||||
className="pointer-events-none h-full w-full rounded object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded border px-2 py-1 text-xs font-semibold ${
|
||||
isSelected
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/20 bg-[#2d3036] text-[#d7dae0]"
|
||||
: "border-black/15 bg-white text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
{tool === "face" && faceOverlayLayerId === layer.id
|
||||
? faceDetections.map((face, index) => (
|
||||
<div
|
||||
key={`${layer.id}-face-${index}`}
|
||||
className="absolute pointer-events-none border-2 border-[#00d2ff]"
|
||||
style={{
|
||||
left: face.x,
|
||||
top: face.y,
|
||||
width: face.width,
|
||||
height: face.height,
|
||||
zIndex: layers.length + 20 + index,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.35)",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{isSelected && tool === "pointer" && isImage && onResizeLayer ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, left: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, right: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, left: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, right: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<div className="absolute pointer-events-none" style={{ top: -largeHandleSize, left: "50%", transform: "translateX(-50%)", height: largeHandleSize }}>
|
||||
<div className="w-px bg-[var(--color-accent-strong)]" style={{ width: "1px", height: "100%", marginLeft: "0px" }} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
|
||||
style={{ width: largeHandleSizePx, height: largeHandleSizePx }}
|
||||
onPointerDown={(e) => onRotateHandleDown(e, layer)}
|
||||
title={t("editor.rotate")}
|
||||
>
|
||||
<RotateCw className="text-white" style={{ width: handleSize * 0.6, height: handleSize * 0.6 }} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{faceLabelOverlays.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="pointer-events-none absolute rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap text-white"
|
||||
style={{
|
||||
left: label.left,
|
||||
top: label.top,
|
||||
background: "rgba(0, 210, 255, 0.9)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.22)",
|
||||
textShadow: "0 1px 1px rgba(0,0,0,0.35)",
|
||||
zIndex: layers.length + 100,
|
||||
}}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { AspectRatio } from "@pien-studio/types";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
interface CanvasSizeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentWidth: number;
|
||||
currentHeight: number;
|
||||
currentAspect: AspectRatio;
|
||||
onApply: (width: number, height: number, aspect: AspectRatio) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ labelKey: "editor.presetSquare", sublabel: "1080 × 1080", aspect: "1:1" as AspectRatio, width: 1080, height: 1080 },
|
||||
{ labelKey: "editor.presetPortrait45", sublabel: "1080 × 1350", aspect: "4:5" as AspectRatio, width: 1080, height: 1350 },
|
||||
{ labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 },
|
||||
{ labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 },
|
||||
{ labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 },
|
||||
{ labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 1080 },
|
||||
];
|
||||
|
||||
export function CanvasSizeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentWidth,
|
||||
currentHeight,
|
||||
currentAspect,
|
||||
onApply,
|
||||
isDark,
|
||||
}: CanvasSizeModalProps) {
|
||||
const { t } = useTranslations();
|
||||
const [mode, setMode] = React.useState<"preset" | "custom">(
|
||||
PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom",
|
||||
);
|
||||
const [customWidth, setCustomWidth] = React.useState(currentWidth.toString());
|
||||
const [customHeight, setCustomHeight] = React.useState(currentHeight.toString());
|
||||
const [selectedPreset, setSelectedPreset] = React.useState<AspectRatio>(currentAspect);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function handleApply() {
|
||||
if (mode === "preset") {
|
||||
const preset = PRESETS.find((p) => p.aspect === selectedPreset)!;
|
||||
onApply(preset.width, preset.height, preset.aspect);
|
||||
} else {
|
||||
const w = parseInt(customWidth, 10);
|
||||
const h = parseInt(customHeight, 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
|
||||
onApply(w, h, "free");
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||
const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<div className={panel} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>
|
||||
{t("editor.canvasSizeTitle")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["preset", "custom"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded border py-1.5 text-xs font-semibold capitalize ${
|
||||
mode === m
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/20 text-[#d7dae0]"
|
||||
: "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{m === "preset" ? t("editor.modePreset") : t("editor.modeCustom")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "preset" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.aspect}
|
||||
onClick={() => setSelectedPreset(p.aspect)}
|
||||
className={`rounded border p-3 text-left ${
|
||||
selectedPreset === p.aspect
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#343841] text-[#d7dae0]"
|
||||
: "border-black/10 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
|
||||
<div className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}>
|
||||
{p.sublabel}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.widthShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => setCustomWidth(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.heightShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => setCustomHeight(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="rounded border border-dashed border-black/20 p-2 text-center">
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
||||
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-3 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{t("editor.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type CanvasContextMenuProps = {
|
||||
isDark: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
labels: {
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
};
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`}
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.copy}
|
||||
</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.cut}
|
||||
</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.paste}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorCanvasStage } from "./editor-canvas-stage";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => (
|
||||
<button type="button" data-testid="canvas-renderer" onClick={() => onContextMenu?.(10, 12)}>
|
||||
canvas
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EditorCanvasStage", () => {
|
||||
it("wires context menu actions", () => {
|
||||
const onCopy = vi.fn();
|
||||
const onCloseContextMenu = vi.fn();
|
||||
|
||||
render(
|
||||
<EditorCanvasStage
|
||||
isDark={false}
|
||||
layers={[]}
|
||||
canvasWidth={100}
|
||||
canvasHeight={100}
|
||||
selectedLayerId={null}
|
||||
tool="pointer"
|
||||
faceDetections={[]}
|
||||
faceOverlayLayerId={null}
|
||||
faceBlurPreview={null}
|
||||
contextMenu={{ x: 10, y: 20 }}
|
||||
labels={{ copy: "Copy", cut: "Cut", paste: "Paste" }}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
onCloseContextMenu={onCloseContextMenu}
|
||||
onCopy={onCopy}
|
||||
onCut={() => undefined}
|
||||
onPaste={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import { CanvasContextMenu } from "./canvas-context-menu";
|
||||
|
||||
type EditorCanvasStageProps = {
|
||||
isDark: boolean;
|
||||
layers: Layer[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
contextMenu: { x: number; y: number } | null;
|
||||
labels: { copy: string; cut: string; paste: string };
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd: (id: string, rotation: number) => void;
|
||||
onInteractionStart: () => void;
|
||||
onInteractionEnd: () => void;
|
||||
onContextMenu: (x: number, y: number) => void;
|
||||
onCloseContextMenu: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
const {
|
||||
isDark,
|
||||
layers,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceBlurPreview,
|
||||
contextMenu,
|
||||
labels,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onCloseContextMenu,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? "linear-gradient(#2b2d31 1px, transparent 1px), linear-gradient(90deg, #2b2d31 1px, transparent 1px)"
|
||||
: "linear-gradient(#e8eaed 1px, transparent 1px), linear-gradient(90deg, #e8eaed 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden shadow-2xl ring-1 ring-black/10">
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
onContextMenu={onContextMenu}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
{contextMenu ? (
|
||||
<CanvasContextMenu
|
||||
isDark={isDark}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
labels={labels}
|
||||
onCopy={() => {
|
||||
onCopy();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onCut={() => {
|
||||
onCut();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onPaste={() => {
|
||||
onPaste();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { UiPreferences } from "../ui-preferences";
|
||||
import { dividerClass, hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type EditorHeaderProps = {
|
||||
isDark: boolean;
|
||||
projectTitle: string;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isDirty: boolean;
|
||||
labels: {
|
||||
file: string;
|
||||
edit: string;
|
||||
view: string;
|
||||
settings: string;
|
||||
save: string;
|
||||
exportPng: string;
|
||||
exportProjectFile: string;
|
||||
importImage: string;
|
||||
canvasSize: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
preferences: string;
|
||||
panTool: string;
|
||||
pointerTool: string;
|
||||
unsavedChanges: string;
|
||||
saved: string;
|
||||
};
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onSetHandTool: () => void;
|
||||
onSetPointerTool: () => void;
|
||||
};
|
||||
|
||||
export function EditorHeader({
|
||||
isDark,
|
||||
projectTitle,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isDirty,
|
||||
labels,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
onSetHandTool,
|
||||
onSetPointerTool,
|
||||
}: EditorHeaderProps) {
|
||||
const menuClass = `absolute left-0 top-full z-30 min-w-[180px] rounded border p-1 text-[11px] opacity-0 shadow-xl transition-opacity pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 hover:pointer-events-auto hover:opacity-100 ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" className="group">
|
||||
<p className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
pien.studio
|
||||
</p>
|
||||
<h1 className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{projectTitle}</h1>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2 text-xs font-medium">
|
||||
<MenuShell isDark={isDark} label={labels.file} menuClass={menuClass}>
|
||||
<FileMenu
|
||||
isDark={isDark}
|
||||
labels={labels}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
onSave={onSave}
|
||||
onExportPng={onExportPng}
|
||||
onExportProjectFile={onExportProjectFile}
|
||||
onImportImage={onImportImage}
|
||||
onOpenCanvasSize={onOpenCanvasSize}
|
||||
/>
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
|
||||
<EditMenu isDark={isDark} labels={labels} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onCopy={onCopy} onCut={onCut} onPaste={onPaste} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
|
||||
<ViewMenu isDark={isDark} labels={labels} onSetHandTool={onSetHandTool} onSetPointerTool={onSetPointerTool} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
|
||||
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
||||
</MenuShell>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={labels.undo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={labels.redo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
{isDirty ? labels.unsavedChanges : labels.saved}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button type="button" className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}>
|
||||
{label}
|
||||
</button>
|
||||
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
|
||||
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
||||
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} className={`w-full rounded px-2 py-1 text-left ${!canUndo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.undo}</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} className={`w-full rounded px-2 py-1 text-left ${!canRedo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.redo}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.copy}</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.cut}</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.paste}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSetHandTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.panTool}</button>
|
||||
<button type="button" onClick={onSetPointerTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.pointerTool}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<p className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}>{preferences}</p>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorMobileSection } from "./editor-mobile-section";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: () => <div data-testid="canvas-renderer" />,
|
||||
}));
|
||||
|
||||
describe("EditorMobileSection", () => {
|
||||
it("shows face detection helper message in face mode", () => {
|
||||
render(
|
||||
<EditorMobileSection
|
||||
isDark={false}
|
||||
canvasWidth={100}
|
||||
canvasHeight={120}
|
||||
layers={[]}
|
||||
selectedLayerId={null}
|
||||
tool="face"
|
||||
faceDetections={[{ x: 1, y: 1, width: 10, height: 10, label: "f" }]}
|
||||
faceOverlayLayerId={null}
|
||||
faceStatus="idle"
|
||||
faceBlurPreview={null}
|
||||
labels={{
|
||||
resize: "Resize",
|
||||
faceMlFailedShort: "Face failed",
|
||||
detectingFacesShort: "Detecting",
|
||||
faceDetectionTip: (count) => `Faces ${count}`,
|
||||
import: "Import",
|
||||
mood: "Mood",
|
||||
quick: "Quick",
|
||||
face: "Face",
|
||||
decor: "Decor",
|
||||
}}
|
||||
onOpenCanvasSize={() => undefined}
|
||||
onImportImage={() => undefined}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Faces 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Import")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorMobileSectionProps = {
|
||||
isDark: boolean;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
labels: {
|
||||
resize: string;
|
||||
faceMlFailedShort: string;
|
||||
detectingFacesShort: string;
|
||||
faceDetectionTip: (count: number) => string;
|
||||
import: string;
|
||||
mood: string;
|
||||
quick: string;
|
||||
face: string;
|
||||
decor: string;
|
||||
};
|
||||
onOpenCanvasSize: () => void;
|
||||
onImportImage: () => void;
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd: (id: string, rotation: number) => void;
|
||||
onInteractionStart: () => void;
|
||||
onInteractionEnd: () => void;
|
||||
};
|
||||
|
||||
export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
const {
|
||||
isDark,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceStatus,
|
||||
faceBlurPreview,
|
||||
labels,
|
||||
onOpenCanvasSize,
|
||||
onImportImage,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<section className="lg:hidden">
|
||||
<div className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}>
|
||||
{canvasWidth} x {canvasHeight}px
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenCanvasSize}
|
||||
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.resize}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border ${isDark ? "border-white/10 bg-[#17181b]" : "border-black/10 bg-[#f7f8fa]"}`}
|
||||
style={{ height: "55vw", maxHeight: "70vh" }}
|
||||
>
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
</div>
|
||||
{tool === "face" ? (
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? labels.faceMlFailedShort
|
||||
: faceStatus === "detecting"
|
||||
? labels.detectingFacesShort
|
||||
: labels.faceDetectionTip(faceDetections.length)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportImage}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.import}
|
||||
</button>
|
||||
{[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => (
|
||||
<button
|
||||
key={toolLabel}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{toolLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
||||
import { FacePanel } from "./face-panel";
|
||||
import { HistoryPanel } from "./history-panel";
|
||||
import { LayersPanel } from "./layers-panel";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorSidebarProps = {
|
||||
isDark: boolean;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayer: Layer | null;
|
||||
history: { past: Project[]; future: Project[] };
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: FaceBlurMethod;
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
onSelectLayer: (layerId: string | null) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
onSetBlurMethod: (method: FaceBlurMethod) => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function EditorSidebar({
|
||||
isDark,
|
||||
tool,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
history,
|
||||
canUndo,
|
||||
canRedo,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
onSelectLayer,
|
||||
onMoveLayerOrder,
|
||||
onRemoveSelectedLayer,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onJumpToPast,
|
||||
onJumpToFuture,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: EditorSidebarProps) {
|
||||
return (
|
||||
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="space-y-3">
|
||||
<LayersPanel
|
||||
layers={layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
isDark={isDark}
|
||||
onSelectLayer={(layerId) => onSelectLayer(layerId)}
|
||||
onMoveLayerOrder={onMoveLayerOrder}
|
||||
onRemoveSelectedLayer={onRemoveSelectedLayer}
|
||||
/>
|
||||
|
||||
{tool === "face" ? (
|
||||
<FacePanel
|
||||
isDark={isDark}
|
||||
selectedLayer={selectedLayer}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
|
||||
onSetBlurMethod={onSetBlurMethod}
|
||||
onSetBlurAmount={onSetBlurAmount}
|
||||
onSetCensorColor={onSetCensorColor}
|
||||
onToggleFaceIndex={onToggleFaceIndex}
|
||||
onBlur={onBlur}
|
||||
onClearBlur={onClearBlur}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HistoryPanel
|
||||
history={history}
|
||||
isDark={isDark}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
onJumpToPast={onJumpToPast}
|
||||
onJumpToFuture={onJumpToFuture}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
isDark: boolean;
|
||||
selectedLayer: Layer | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: "gaussian" | "pixelate" | "censor";
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
hasActiveBlur: boolean;
|
||||
onSetBlurMethod: (method: "gaussian" | "pixelate" | "censor") => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function FacePanel({
|
||||
isDark,
|
||||
selectedLayer,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
hasActiveBlur,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: Props) {
|
||||
const { t } = useTranslations();
|
||||
const hasFaces = faceDetections.length > 0;
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.faceTool")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>{faceDetections.length}</span>
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? t("editor.faceMlFailed")
|
||||
: faceStatus === "detecting"
|
||||
? t("editor.detectingFaces")
|
||||
: selectedLayer?.type !== "image"
|
||||
? t("editor.selectImageLayer")
|
||||
: faceDetections.length === 0
|
||||
? t("editor.noFacesFound")
|
||||
: t("editor.facesDetected")}
|
||||
</p>
|
||||
{hasFaces ? (
|
||||
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{faceDetections.map((face, index) => (
|
||||
<button
|
||||
key={`face-result-${index}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] ${isDark ? "bg-white/5 text-[#d7dae0]" : "bg-white text-[#1f2430]"} ${
|
||||
selectedFaceIndices.includes(index)
|
||||
? isDark
|
||||
? "ring-1 ring-[#7cdcff]"
|
||||
: "ring-1 ring-[#00b7f0]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onToggleFaceIndex(index)}
|
||||
>
|
||||
{facePreviews[index]?.src ? (
|
||||
<Image src={facePreviews[index].src} alt={`Face preview ${index + 1}`} width={40} height={40} unoptimized className="h-10 w-10 rounded object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold">{t("editor.person")} {index + 1}</p>
|
||||
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`mt-3 space-y-2 rounded-md border p-2 ${panelInsetClass(isDark)}`}>
|
||||
<label className="block text-[11px] font-semibold">{t("editor.blurMethod")}</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{[
|
||||
{ id: "gaussian", label: t("editor.soft") },
|
||||
{ id: "pixelate", label: t("editor.pixelate") },
|
||||
{ id: "censor", label: t("editor.censor") },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
blurMethod === option.id
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "bg-white/10 text-[#d7dae0]"
|
||||
: "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{blurMethod !== "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.strength")}</span>
|
||||
<span>{blurAmount}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={40}
|
||||
value={blurAmount}
|
||||
onChange={(event) => onSetBlurAmount(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{blurMethod === "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.color")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className="h-6 w-6 cursor-pointer rounded border-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className={`flex-1 rounded border px-1 py-0.5 text-[11px] ${isDark ? "border-white/20 bg-white/10 text-white" : "border-black/20 bg-white text-black"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBlur(selectedFaceIndices)}
|
||||
disabled={!hasFaces || selectedFaceIndices.length === 0}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
!hasFaces || selectedFaceIndices.length === 0
|
||||
? "opacity-50"
|
||||
: "bg-[var(--color-accent-strong)] text-white"
|
||||
}`}
|
||||
>
|
||||
{hasFaces && selectedFaceIndices.length === 0
|
||||
? t("editor.selectFacesToBlur")
|
||||
: t("editor.blurFaces", { count: selectedFaceIndices.length })}
|
||||
</button>
|
||||
{hasActiveBlur ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearBlur}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
isDark ? "bg-white/10 text-[#d7dae0]" : "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.clearBlur")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
history: { past: Project[]; future: Project[] };
|
||||
isDark: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.history")}</h2>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} title={t("editor.undo")} className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} title={t("editor.redo")} className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}>
|
||||
{[...history.past].reverse().map((_, i) => {
|
||||
const idx = history.past.length - i;
|
||||
return (
|
||||
<button
|
||||
key={`past-${idx}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToPast(idx)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{idx > history.past.length - 2 ? t("editor.beforeLastAction") : `${t("editor.step")} ${idx}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button" className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`} disabled>
|
||||
{t("editor.now")}
|
||||
</button>
|
||||
{[...history.future].map((_, i) => (
|
||||
<button
|
||||
key={`future-${i}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToFuture(i)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{t("editor.undoneStep")} {i + 1}
|
||||
</button>
|
||||
))}
|
||||
{history.past.length === 0 && history.future.length === 0 ? <p className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}>{t("editor.noHistoryYet")}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
isDark: boolean;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
};
|
||||
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
||||
{layers.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{layers.slice().reverse().map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImageLayer = layer.type === "image" || layer.type === "sticker";
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "text-[#d7dae0] hover:bg-white/10"
|
||||
: "text-[#1f2430] hover:bg-black/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span>
|
||||
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}>
|
||||
{isImageLayer && layer.sourceUri ? (
|
||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{layer.name ?? layer.type}</p>
|
||||
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
|
||||
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
|
||||
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { MousePointer2 } from "lucide-react";
|
||||
import type { EditorToolController } from "../../lib/editor-tool-controller";
|
||||
import type { EditorToolId } from "../../store/editor-store";
|
||||
|
||||
type Props = {
|
||||
controllers: EditorToolController[];
|
||||
selectedTool: EditorToolId;
|
||||
isDark: boolean;
|
||||
icons: Record<string, React.ComponentType<{ className?: string }>>;
|
||||
onSetTool: (tool: EditorToolId) => void;
|
||||
};
|
||||
|
||||
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
|
||||
return (
|
||||
<aside className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{controllers.map((controller) => {
|
||||
const Icon = icons[controller.id] ?? MousePointer2;
|
||||
const isSelected = controller.kind === "mode" && controller.id === selectedTool;
|
||||
return (
|
||||
<button
|
||||
key={controller.id}
|
||||
type="button"
|
||||
title={controller.label}
|
||||
onClick={() => (controller.kind === "mode" ? onSetTool(controller.id) : controller.run())}
|
||||
className={`rounded border p-2 text-[11px] font-medium ${
|
||||
isSelected
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#2d3036] text-[#d7dae0] hover:bg-[#353942]"
|
||||
: "border-black/10 bg-white text-[#1f2430] hover:bg-[#f4f6f9]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import { cx, subtleButtonClass } from "../lib/theme";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
||||
const { theme, locale, setTheme, setLocale } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const isDark = theme === "dark";
|
||||
const wrapperHeight = compact ? "h-7" : "h-9";
|
||||
const textSize = compact ? "text-[10px]" : "text-xs";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className={cx("inline-flex items-center rounded border px-2", subtleButtonClass(isDark), wrapperHeight)}>
|
||||
<span className={cx("mr-2 font-semibold opacity-70", textSize)}>{t("ui.locale")}</span>
|
||||
<select
|
||||
aria-label={t("ui.locale")}
|
||||
value={locale}
|
||||
onChange={(event) => setLocale(event.target.value as "en" | "th" | "ja")}
|
||||
className={cx(
|
||||
"bg-transparent font-semibold outline-none",
|
||||
textSize,
|
||||
isDark ? "text-[#f5f7fa]" : "text-[#1f2430]",
|
||||
)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="th">Thai</option>
|
||||
<option value="ja">Japanese</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
|
||||
>
|
||||
{theme === "dark" ? t("ui.darkMode") : t("ui.lightMode")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { startAssetCleanupJob } from "@pien-studio/storage";
|
||||
|
||||
export function useAssetCleanupJob() {
|
||||
React.useEffect(() => {
|
||||
const stop = startAssetCleanupJob();
|
||||
return () => stop();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import React from "react";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
type Viewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
type UseCanvasInteractionsOptions = {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer?: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd?: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer?: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd?: (id: string, rotation: number) => void;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
};
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 10;
|
||||
const ZOOM_FACTOR = 0.001;
|
||||
const FIT_PADDING = 32;
|
||||
|
||||
export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
const {
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
tool,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
} = options;
|
||||
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isPanning = React.useRef(false);
|
||||
const lastPos = React.useRef({ x: 0, y: 0 });
|
||||
const isMiddleMousePan = React.useRef(false);
|
||||
const [isSpacePan, setIsSpacePan] = React.useState(false);
|
||||
const [, setIsShiftPressed] = React.useState(false);
|
||||
const didFitRef = React.useRef(false);
|
||||
const interactionActiveRef = React.useRef(false);
|
||||
const dragMoveRafRef = React.useRef<number | null>(null);
|
||||
const resizeMoveRafRef = React.useRef<number | null>(null);
|
||||
const resizeMovePendingRef = React.useRef<{ width: number; height: number; x: number; y: number } | null>(null);
|
||||
const dragRef = React.useRef<{
|
||||
id: string;
|
||||
startLayerX: number;
|
||||
startLayerY: number;
|
||||
startEventX: number;
|
||||
startEventY: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
} | null>(null);
|
||||
const resizeRef = React.useRef<{
|
||||
id: string;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
aspect: number;
|
||||
lastWidth: number;
|
||||
lastHeight: number;
|
||||
corner: string;
|
||||
startLayerX: number;
|
||||
startLayerY: number;
|
||||
} | null>(null);
|
||||
const rotateRef = React.useRef<{
|
||||
id: string;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
startAngle: number;
|
||||
startRotation: number;
|
||||
lastRotation: number;
|
||||
} | null>(null);
|
||||
const pinchRef = React.useRef<{
|
||||
active: boolean;
|
||||
initialPinchPx: number;
|
||||
initialScale: number;
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
pivotX: number;
|
||||
pivotY: number;
|
||||
} | null>(null);
|
||||
|
||||
function beginInteraction() {
|
||||
if (interactionActiveRef.current) return;
|
||||
interactionActiveRef.current = true;
|
||||
onInteractionStart?.();
|
||||
}
|
||||
|
||||
function endInteraction() {
|
||||
if (!interactionActiveRef.current) return;
|
||||
interactionActiveRef.current = false;
|
||||
onInteractionEnd?.();
|
||||
}
|
||||
|
||||
function clientDist(t0: Pick<Touch, "clientX" | "clientY">, t1: Pick<Touch, "clientX" | "clientY">) {
|
||||
const dx = t0.clientX - t1.clientX;
|
||||
const dy = t0.clientY - t1.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function wheelZoom(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const factor = e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
|
||||
if (!Number.isFinite(factor) || factor === 0) return;
|
||||
const pivotX = e.clientX - rect.left;
|
||||
const pivotY = e.clientY - rect.top;
|
||||
setViewport((vp) => {
|
||||
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
|
||||
const scaleChange = nextScale / vp.scale;
|
||||
return {
|
||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||
y: pivotY - (pivotY - vp.y) * scaleChange,
|
||||
scale: nextScale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function zoomBy(factor: number, pivotX: number, pivotY: number) {
|
||||
setViewport((vp) => {
|
||||
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
|
||||
const scaleChange = newScale / vp.scale;
|
||||
return {
|
||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||
y: pivotY - (pivotY - vp.y) * scaleChange,
|
||||
scale: newScale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = (event: WheelEvent) => {
|
||||
wheelZoom(event);
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
didFitRef.current = false;
|
||||
}, [canvasWidth, canvasHeight]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
if (didFitRef.current) return;
|
||||
const maxW = Math.max(1, rect.width - FIT_PADDING * 2);
|
||||
const maxH = Math.max(1, rect.height - FIT_PADDING * 2);
|
||||
const fitScale = Math.min(maxW / canvasWidth, maxH / canvasHeight, 1);
|
||||
const nextX = (rect.width - canvasWidth * fitScale) / 2;
|
||||
const nextY = (rect.height - canvasHeight * fitScale) / 2;
|
||||
setViewport({ x: nextX, y: nextY, scale: fitScale });
|
||||
didFitRef.current = true;
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [canvasWidth, canvasHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.code === "Space") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
if (!isSpacePan) setIsSpacePan(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Shift") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
setIsShiftPressed(true);
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
if (e.key === "-" || e.key === "_") {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
|
||||
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
|
||||
zoomBy(0.8, cx, cy);
|
||||
} else if (e.key === "+" || e.key === "=") {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
|
||||
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
|
||||
zoomBy(1.25, cx, cy);
|
||||
} else if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
setViewport({ x: 0, y: 0, scale: 1 });
|
||||
}
|
||||
}
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
if (e.code === "Space") setIsSpacePan(false);
|
||||
if (e.key === "Shift") setIsShiftPressed(false);
|
||||
}
|
||||
function handleWheelCaptured(e: WheelEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
|
||||
};
|
||||
}, [isSpacePan]);
|
||||
|
||||
function onContainerPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isMiddleMousePan.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (tool !== "hand" && tool !== "face" && !isSpacePan) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isPanning.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function onContainerPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (isMiddleMousePan.current || isPanning.current) {
|
||||
const dx = e.clientX - lastPos.current.x;
|
||||
const dy = e.clientY - lastPos.current.y;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy }));
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && rotateRef.current && onRotateLayer) {
|
||||
const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
|
||||
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
||||
const delta = currentAngle - startAngle;
|
||||
const nextRotation = startRotation + (delta * 180) / Math.PI;
|
||||
rotateRef.current.lastRotation = nextRotation;
|
||||
onRotateLayer(id, nextRotation);
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && resizeRef.current && onResizeLayer) {
|
||||
const dx = (e.clientX - resizeRef.current.startX) / viewport.scale;
|
||||
const dy = (e.clientY - resizeRef.current.startY) / viewport.scale;
|
||||
const corner = resizeRef.current.corner;
|
||||
let nextWidth = resizeRef.current.startWidth;
|
||||
let nextHeight = resizeRef.current.startHeight;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (corner === "br") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth + dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight + dy);
|
||||
} else if (corner === "bl") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth - dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight + dy);
|
||||
} else if (corner === "tr") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth + dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||
} else if (corner === "tl") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth - dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||
}
|
||||
|
||||
if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) {
|
||||
const aspect = resizeRef.current.aspect || 1;
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
nextHeight = Math.max(8, nextWidth / aspect);
|
||||
} else {
|
||||
nextWidth = Math.max(8, nextHeight * aspect);
|
||||
}
|
||||
}
|
||||
|
||||
if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth;
|
||||
if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight;
|
||||
|
||||
resizeRef.current.lastWidth = nextWidth;
|
||||
resizeRef.current.lastHeight = nextHeight;
|
||||
resizeMovePendingRef.current = {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: resizeRef.current.startLayerX + offsetX,
|
||||
y: resizeRef.current.startLayerY + offsetY,
|
||||
};
|
||||
if (resizeMoveRafRef.current !== null) return;
|
||||
resizeMoveRafRef.current = window.requestAnimationFrame(() => {
|
||||
resizeMoveRafRef.current = null;
|
||||
if (!resizeRef.current || !resizeMovePendingRef.current) return;
|
||||
const pending = resizeMovePendingRef.current;
|
||||
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
||||
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && dragRef.current) {
|
||||
const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale;
|
||||
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
|
||||
const nextX = dragRef.current.startLayerX + dx;
|
||||
const nextY = dragRef.current.startLayerY + dy;
|
||||
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY) return;
|
||||
dragRef.current.lastX = nextX;
|
||||
dragRef.current.lastY = nextY;
|
||||
if (dragMoveRafRef.current !== null) return;
|
||||
dragMoveRafRef.current = window.requestAnimationFrame(() => {
|
||||
dragMoveRafRef.current = null;
|
||||
if (!dragRef.current) return;
|
||||
onMoveLayer(dragRef.current.id, dragRef.current.lastX, dragRef.current.lastY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerPointerUp() {
|
||||
if (dragMoveRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(dragMoveRafRef.current);
|
||||
dragMoveRafRef.current = null;
|
||||
}
|
||||
if (resizeMoveRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(resizeMoveRafRef.current);
|
||||
resizeMoveRafRef.current = null;
|
||||
if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) {
|
||||
const pending = resizeMovePendingRef.current;
|
||||
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
||||
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
isPanning.current = false;
|
||||
isMiddleMousePan.current = false;
|
||||
if (dragRef.current && onMoveLayerEnd) {
|
||||
const { id, lastX, lastY } = dragRef.current;
|
||||
if (typeof lastX === "number" && typeof lastY === "number") onMoveLayerEnd(id, lastX, lastY);
|
||||
}
|
||||
if (resizeRef.current && onResizeLayerEnd) {
|
||||
const { id, lastWidth, lastHeight } = resizeRef.current;
|
||||
if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight);
|
||||
}
|
||||
if (rotateRef.current && onRotateLayerEnd) {
|
||||
const { id, lastRotation } = rotateRef.current;
|
||||
if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation);
|
||||
}
|
||||
dragRef.current = null;
|
||||
resizeRef.current = null;
|
||||
resizeMovePendingRef.current = null;
|
||||
rotateRef.current = null;
|
||||
endInteraction();
|
||||
}
|
||||
|
||||
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
|
||||
if (isSpacePan) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
id: layer.id,
|
||||
startLayerX: layer.x,
|
||||
startLayerY: layer.y,
|
||||
startEventX: e.clientX,
|
||||
startEventY: e.clientY,
|
||||
lastX: layer.x,
|
||||
lastY: layer.y,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
|
||||
if (tool !== "pointer" || !onResizeLayer) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
rotateRef.current = null;
|
||||
const width = layer.width ?? Math.round(200 * layer.scale);
|
||||
const height = layer.height ?? Math.round(150 * layer.scale);
|
||||
resizeRef.current = {
|
||||
id: layer.id,
|
||||
startWidth: width,
|
||||
startHeight: height,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
aspect: width > 0 && height > 0 ? width / height : 1,
|
||||
lastWidth: width,
|
||||
lastHeight: height,
|
||||
corner,
|
||||
startLayerX: layer.x,
|
||||
startLayerY: layer.y,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
|
||||
if (tool !== "pointer" || !onRotateLayer) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
resizeRef.current = null;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const width = layer.width ?? Math.round(200 * layer.scale);
|
||||
const height = layer.height ?? Math.round(150 * layer.scale);
|
||||
const centerX = (rect?.left ?? 0) + viewport.x + layer.x * viewport.scale + (width * viewport.scale) / 2;
|
||||
const centerY = (rect?.top ?? 0) + viewport.y + layer.y * viewport.scale + (height * viewport.scale) / 2;
|
||||
const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
||||
rotateRef.current = {
|
||||
id: layer.id,
|
||||
centerX,
|
||||
centerY,
|
||||
startAngle,
|
||||
startRotation: layer.rotation,
|
||||
lastRotation: layer.rotation,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||||
if (e.touches.length === 2) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
pinchRef.current = {
|
||||
active: true,
|
||||
initialPinchPx: clientDist(e.touches[0], e.touches[1]),
|
||||
initialScale: viewport.scale,
|
||||
initialX: viewport.x,
|
||||
initialY: viewport.y,
|
||||
pivotX: midX,
|
||||
pivotY: midY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||||
if (pinchRef.current?.active && e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
const px = clientDist(e.touches[0], e.touches[1]);
|
||||
const ratio = px / pinchRef.current.initialPinchPx;
|
||||
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio));
|
||||
const scaleChange = nextScale / pinchRef.current.initialScale;
|
||||
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
|
||||
setViewport({
|
||||
x: pivotX - (pivotX - initialX) * scaleChange,
|
||||
y: pivotY - (pivotY - initialY) * scaleChange,
|
||||
scale: nextScale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (pinchRef.current) pinchRef.current.active = false;
|
||||
}
|
||||
|
||||
function onContextMenuOpen(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!onContextMenu) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onContextMenu(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewport,
|
||||
isSpacePan,
|
||||
onContainerPointerDown,
|
||||
onContainerPointerMove,
|
||||
onContainerPointerUp,
|
||||
onLayerPointerDown,
|
||||
onResizeHandleDown,
|
||||
onRotateHandleDown,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onContextMenuOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { AUTOSAVE_DELAY_MS } from "../lib/editor-constants";
|
||||
|
||||
type UseEditorAutosaveOptions = {
|
||||
isDirty: boolean;
|
||||
projectUpdatedAt: string;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useEditorAutosave(options: UseEditorAutosaveOptions) {
|
||||
const { isDirty, projectUpdatedAt, saveCurrentProject } = options;
|
||||
const autosaveInFlightRef = React.useRef(false);
|
||||
const autosaveVersionRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
if (autosaveVersionRef.current === projectUpdatedAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (autosaveInFlightRef.current) return;
|
||||
autosaveInFlightRef.current = true;
|
||||
autosaveVersionRef.current = projectUpdatedAt;
|
||||
void saveCurrentProject().finally(() => {
|
||||
autosaveInFlightRef.current = false;
|
||||
});
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isDirty, projectUpdatedAt, saveCurrentProject]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
import { useEditorBindings } from "./use-editor-bindings";
|
||||
|
||||
describe("useEditorBindings", () => {
|
||||
it("returns selected layer derived from store state", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
|
||||
const { result } = renderHook(() => useEditorBindings());
|
||||
|
||||
expect(result.current.state.project.layers.length).toBe(1);
|
||||
expect(result.current.state.selectedLayer?.id).toBe(result.current.state.selectedLayerId);
|
||||
expect(typeof result.current.actions.undo).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
|
||||
export function useEditorBindings() {
|
||||
const state = useEditorStore(
|
||||
useShallow((s) => ({
|
||||
project: s.project,
|
||||
selectedLayerId: s.selectedLayerId,
|
||||
canUndo: s.canUndo,
|
||||
canRedo: s.canRedo,
|
||||
history: s.history,
|
||||
isDirty: s.isDirty,
|
||||
tool: s.tool,
|
||||
})),
|
||||
);
|
||||
|
||||
const bindings = useEditorStore(
|
||||
useShallow((s) => ({
|
||||
loadProjectById: s.loadProjectById,
|
||||
setProject: s.setProject,
|
||||
saveCurrentProject: s.saveCurrentProject,
|
||||
selectLayer: s.selectLayer,
|
||||
setSelectedLayerPosition: s.setSelectedLayerPosition,
|
||||
setSelectedLayerPositionDraft: s.setSelectedLayerPositionDraft,
|
||||
setSelectedLayerSize: s.setSelectedLayerSize,
|
||||
setSelectedLayerSizeDraft: s.setSelectedLayerSizeDraft,
|
||||
setSelectedLayerRotation: s.setSelectedLayerRotation,
|
||||
setSelectedLayerRotationDraft: s.setSelectedLayerRotationDraft,
|
||||
startTransaction: s.startTransaction,
|
||||
commitTransaction: s.commitTransaction,
|
||||
removeSelectedLayer: s.removeSelectedLayer,
|
||||
addLayerByType: s.addLayerByType,
|
||||
moveSelectedLayerOrder: s.moveSelectedLayerOrder,
|
||||
importImageFromFile: s.importImageFromFile,
|
||||
setImageLayerFaceBlur: s.setImageLayerFaceBlur,
|
||||
setCanvasSize: s.setCanvasSize,
|
||||
setTool: s.setTool,
|
||||
undo: s.undo,
|
||||
redo: s.redo,
|
||||
exportProjectToJson: s.exportProjectToJson,
|
||||
jumpToPast: s.jumpToPast,
|
||||
jumpToFuture: s.jumpToFuture,
|
||||
copySelectedLayer: s.copySelectedLayer,
|
||||
cutSelectedLayer: s.cutSelectedLayer,
|
||||
pasteLayer: s.pasteLayer,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedLayer = React.useMemo(
|
||||
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
|
||||
[state.project.layers, state.selectedLayerId],
|
||||
);
|
||||
|
||||
return { state: { ...state, selectedLayer }, actions: bindings };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { CONTEXT_MENU_SIZE } from "../lib/editor-constants";
|
||||
|
||||
type ContextMenuPosition = { x: number; y: number };
|
||||
|
||||
export function useEditorContextMenu() {
|
||||
const [contextMenu, setContextMenu] = React.useState<ContextMenuPosition | null>(null);
|
||||
|
||||
const openContextMenu = React.useCallback((x: number, y: number) => {
|
||||
const rect = document.documentElement.getBoundingClientRect();
|
||||
const { width: menuWidth, height: menuHeight, viewportPadding: pad } = CONTEXT_MENU_SIZE;
|
||||
const maxX = rect.width - menuWidth - pad;
|
||||
const maxY = rect.height - menuHeight - pad;
|
||||
setContextMenu({ x: Math.max(pad, Math.min(x, maxX)), y: Math.max(pad, Math.min(y, maxY)) });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = React.useCallback(() => {
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
return { contextMenu, openContextMenu, closeContextMenu };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function useEditorLabels(t: Translator) {
|
||||
const headerLabels = React.useMemo(
|
||||
() => ({
|
||||
file: t("editor.file"),
|
||||
edit: t("editor.edit"),
|
||||
view: t("editor.view"),
|
||||
settings: t("editor.settings"),
|
||||
save: t("editor.save"),
|
||||
exportPng: t("editor.exportPng"),
|
||||
exportProjectFile: t("editor.exportProjectFile"),
|
||||
importImage: t("editor.importImage"),
|
||||
canvasSize: t("editor.canvasSize"),
|
||||
undo: t("editor.undo"),
|
||||
redo: t("editor.redo"),
|
||||
copy: t("editor.copy"),
|
||||
cut: t("editor.cut"),
|
||||
paste: t("editor.paste"),
|
||||
preferences: t("editor.preferences"),
|
||||
panTool: t("editor.panTool"),
|
||||
pointerTool: t("editor.pointerTool"),
|
||||
unsavedChanges: t("editor.unsavedChanges"),
|
||||
saved: t("editor.saved"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const contextMenuLabels = React.useMemo(
|
||||
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const mobileLabels = React.useMemo(
|
||||
() => ({
|
||||
resize: t("editor.resize"),
|
||||
faceMlFailedShort: t("editor.faceMlFailedShort"),
|
||||
detectingFacesShort: t("editor.detectingFacesShort"),
|
||||
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
|
||||
import: t("editor.import"),
|
||||
mood: t("editor.mood"),
|
||||
quick: t("editor.quick"),
|
||||
face: t("editor.face"),
|
||||
decor: t("editor.decor"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return { headerLabels, contextMenuLabels, mobileLabels };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { createProject } from "@pien-studio/editor-core";
|
||||
import { upsertProject } from "@pien-studio/storage";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
type UseEditorProjectLifecycleOptions = {
|
||||
projectId: string;
|
||||
hydrate: () => void;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: ReturnType<typeof createProject>) => void;
|
||||
t: Translator;
|
||||
};
|
||||
|
||||
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
|
||||
const { projectId, hydrate, loadProjectById, setProject, t } = options;
|
||||
const initializedProjectId = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initializedProjectId.current === projectId) return;
|
||||
initializedProjectId.current = projectId;
|
||||
|
||||
if (projectId === "new") {
|
||||
const nextProject = createProject(t("home.untitledProject"));
|
||||
setProject(nextProject);
|
||||
void upsertProject(nextProject);
|
||||
return;
|
||||
}
|
||||
void loadProjectById(projectId);
|
||||
}, [loadProjectById, projectId, setProject, t]);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
|
||||
type UseEditorShortcutsOptions = {
|
||||
onSave: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||
const { onSave, onCopy, onCut, onPaste, onDelete } = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
onCopy();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "x") {
|
||||
e.preventDefault();
|
||||
onCut();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "v") {
|
||||
e.preventDefault();
|
||||
onPaste();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCopy, onCut, onDelete, onPaste, onSave]);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import { useFaceBlurWorkflow } from "./use-face-blur-workflow";
|
||||
|
||||
function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
sourceUri: "data:image/png;base64,abc",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFaceBlurWorkflow", () => {
|
||||
it("selects all detected faces by default when no existing face blur", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const selectedLayer = makeImageLayer();
|
||||
const faceDetections = [
|
||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
||||
{ x: 5, y: 8, width: 7, height: 9, label: "b", sourceWidth: 100, sourceHeight: 100 },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
expect(result.current.faceBlurPreview?.regions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps selection empty when selected image already has blur", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const selectedLayer = makeImageLayer({
|
||||
faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([]);
|
||||
expect(result.current.faceBlurPreview).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles face selection and applies blur to selected regions", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const selectedLayer = makeImageLayer();
|
||||
const faceDetections = [
|
||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
||||
{ x: 50, y: 60, width: 20, height: 22, label: "b", sourceWidth: 100, sourceHeight: 100 },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFaceIndex(1);
|
||||
});
|
||||
|
||||
expect(result.current.selectedFaceIndices).toEqual([0]);
|
||||
|
||||
act(() => {
|
||||
result.current.blurFaces(result.current.selectedFaceIndices);
|
||||
});
|
||||
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1);
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledWith(
|
||||
"layer-1",
|
||||
expect.objectContaining({
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: expect.arrayContaining([
|
||||
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(result.current.selectedFaceIndices).toEqual([]);
|
||||
expect(result.current.faceBlurPreview).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "./use-face-detection";
|
||||
|
||||
type FaceBlurPreview = {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[];
|
||||
censorColor?: string;
|
||||
};
|
||||
|
||||
type UseFaceBlurWorkflowOptions = {
|
||||
selectedLayer: Layer | null;
|
||||
faceDetectionsLayerId: string | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void;
|
||||
};
|
||||
|
||||
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options;
|
||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
|
||||
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
|
||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id);
|
||||
|
||||
const buildBlurRegions = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") return [];
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
|
||||
const indexSet = new Set(indices);
|
||||
return faceDetections
|
||||
.filter((_, index) => indexSet.has(index))
|
||||
.map((face) => {
|
||||
const baseWidth = Math.max(1, selectedLayer.width ?? face.sourceWidth);
|
||||
const baseHeight = Math.max(1, selectedLayer.height ?? face.sourceHeight);
|
||||
const scaleX = face.sourceWidth / baseWidth;
|
||||
const scaleY = face.sourceHeight / baseHeight;
|
||||
return {
|
||||
x: Math.max(0, Math.floor(face.x * scaleX)),
|
||||
y: Math.max(0, Math.floor(face.y * scaleY)),
|
||||
width: Math.max(1, Math.floor(face.width * scaleX)),
|
||||
height: Math.max(1, Math.floor(face.height * scaleY)),
|
||||
sourceWidth: face.sourceWidth,
|
||||
sourceHeight: face.sourceHeight,
|
||||
censorColor,
|
||||
};
|
||||
});
|
||||
},
|
||||
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
|
||||
);
|
||||
|
||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
||||
setSelectedFaceIndices((prev) => (prev.includes(index) ? prev.filter((item) => item !== index) : [...prev, index]));
|
||||
}, []);
|
||||
|
||||
const clearBlur = React.useCallback(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") return;
|
||||
setImageLayerFaceBlur(selectedLayer.id, undefined);
|
||||
setFaceBlurPreview(null);
|
||||
}, [selectedLayer, setImageLayerFaceBlur]);
|
||||
|
||||
const blurFaces = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return;
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
|
||||
const regions = buildBlurRegions(indices);
|
||||
setImageLayerFaceBlur(selectedLayer.id, {
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions,
|
||||
censorColor,
|
||||
});
|
||||
setSelectedFaceIndices([]);
|
||||
setFaceBlurPreview(null);
|
||||
},
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
if (selectedLayer?.faceBlur) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
setSelectedFaceIndices((prev) => {
|
||||
const next = faceDetections.map((_, i) => i);
|
||||
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFaceIndices.length === 0) {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFaceBlurPreview({
|
||||
layerId: selectedLayer.id,
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions: buildBlurRegions(selectedFaceIndices),
|
||||
censorColor,
|
||||
});
|
||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
||||
|
||||
return {
|
||||
blurMethod,
|
||||
setBlurMethod,
|
||||
blurAmount,
|
||||
setBlurAmount,
|
||||
censorColor,
|
||||
setCensorColor,
|
||||
selectedFaceIndices,
|
||||
faceBlurPreview,
|
||||
toggleFaceIndex,
|
||||
clearBlur,
|
||||
blurFaces,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from "react";
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
import { buildFacePreviews, loadImageFromUri, toFaceDetectionOverlays } from "../lib/face-detection-utils";
|
||||
|
||||
export type FaceDetectionOverlay = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
label: string;
|
||||
gender?: string;
|
||||
genderScore?: number;
|
||||
};
|
||||
|
||||
export type FacePreview = {
|
||||
id: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
type SelectedImageLayer = {
|
||||
id: string;
|
||||
sourceUri: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type UseFaceDetectionOptions = {
|
||||
tool: EditorToolId;
|
||||
selectedLayerId: string | null;
|
||||
selectedImageLayer: SelectedImageLayer | null;
|
||||
activeLayerStillSelected: (layerId: string) => boolean;
|
||||
};
|
||||
|
||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
const { tool, selectedLayerId, selectedImageLayer, activeLayerStillSelected } = options;
|
||||
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
||||
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
||||
const selectedImageWidth = selectedImageLayer?.width;
|
||||
const selectedImageHeight = selectedImageLayer?.height;
|
||||
const [faceDetections, setFaceDetections] = React.useState<FaceDetectionOverlay[]>([]);
|
||||
const [faceDetectionsLayerId, setFaceDetectionsLayerId] = React.useState<string | null>(null);
|
||||
const [faceStatus, setFaceStatus] = React.useState<"idle" | "detecting" | "unsupported">("idle");
|
||||
const [facePreviews, setFacePreviews] = React.useState<FacePreview[]>([]);
|
||||
|
||||
const resetFaceState = React.useCallback((status: "idle" | "detecting" | "unsupported" = "idle") => {
|
||||
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
||||
setFaceStatus((prev) => (prev === status ? prev : status));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
async function detectFaces() {
|
||||
const layerId = selectedImageLayerId;
|
||||
if (tool !== "face" || !layerId || !selectedImageSourceUri) {
|
||||
resetFaceState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
resetFaceState("detecting");
|
||||
const image = await loadImageFromUri(selectedImageSourceUri);
|
||||
if (!image) {
|
||||
if (!canceled) {
|
||||
resetFaceState("idle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { detectFaceBoxes } = await import("../lib/face-ml");
|
||||
const faces = await detectFaceBoxes(image);
|
||||
if (canceled) return;
|
||||
if (!activeLayerStillSelected(layerId) || tool !== "face") return;
|
||||
const overlays = toFaceDetectionOverlays(faces, image, selectedImageWidth, selectedImageHeight);
|
||||
setFaceDetections(overlays);
|
||||
setFaceDetectionsLayerId(layerId);
|
||||
setFaceStatus((prev) => (prev === "idle" ? prev : "idle"));
|
||||
} catch (err) {
|
||||
console.error("[face-detection] detectFaces error:", err);
|
||||
if (!canceled) {
|
||||
resetFaceState("unsupported");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detectFaces();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [activeLayerStillSelected, resetFaceState, selectedImageHeight, selectedImageLayerId, selectedImageSourceUri, selectedImageWidth, tool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tool !== "face") {
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
}, [tool, selectedLayerId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
async function generateFacePreviews() {
|
||||
if (tool !== "face" || !selectedImageSourceUri || faceDetections.length === 0) {
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await loadImageFromUri(selectedImageSourceUri);
|
||||
if (!image || canceled) {
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const previews = buildFacePreviews(image, faceDetections, selectedImageWidth, selectedImageHeight);
|
||||
|
||||
if (!canceled) setFacePreviews(previews);
|
||||
}
|
||||
|
||||
generateFacePreviews();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [faceDetections, selectedImageHeight, selectedImageSourceUri, selectedImageWidth, tool]);
|
||||
|
||||
return { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import en from "../messages/en.json";
|
||||
import ja from "../messages/ja.json";
|
||||
import th from "../messages/th.json";
|
||||
|
||||
type Messages = typeof en;
|
||||
|
||||
const messages: Record<string, Messages> = { en, ja, th };
|
||||
|
||||
type TranslationKey = string;
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||
const keys = path.split(".");
|
||||
let result: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (result && typeof result === "object" && key in result) {
|
||||
result = (result as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return typeof result === "string" ? result : path;
|
||||
}
|
||||
|
||||
export function useTranslations() {
|
||||
const locale = useUiStore((s) => s.locale);
|
||||
const msg = messages[locale] ?? messages.en;
|
||||
|
||||
function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
value = value.replace(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return { t, locale };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = "en";
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFaceLabelOverlays } from "./canvas-geometry";
|
||||
|
||||
describe("buildFaceLabelOverlays", () => {
|
||||
it("returns no overlays when target layer is missing", () => {
|
||||
const overlays = buildFaceLabelOverlays([], "missing", [{ x: 10, y: 10, width: 20, height: 20 }], { x: 0, y: 0, scale: 1 });
|
||||
expect(overlays).toEqual([]);
|
||||
});
|
||||
|
||||
it("stacks overlapping labels to avoid collisions", () => {
|
||||
const layers = [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image" as const,
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 180,
|
||||
height: 120,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const overlays = buildFaceLabelOverlays(
|
||||
layers,
|
||||
"layer-1",
|
||||
[
|
||||
{ x: 16, y: 20, width: 30, height: 30, label: "A" },
|
||||
{ x: 17, y: 21, width: 30, height: 30, label: "B" },
|
||||
],
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
);
|
||||
|
||||
expect(overlays).toHaveLength(2);
|
||||
expect(overlays[0]?.top).toBeGreaterThan(overlays[1]?.top ?? 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
|
||||
|
||||
type Viewport = { x: number; y: number; scale: number };
|
||||
|
||||
export function buildFaceLabelOverlays(
|
||||
layers: Layer[],
|
||||
faceOverlayLayerId: string,
|
||||
faceDetections: FaceDetection[],
|
||||
viewport: Viewport,
|
||||
) {
|
||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||
if (!layer) return [];
|
||||
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
if (!layerWidth || !layerHeight) return [];
|
||||
|
||||
const centerX = layer.x + layerWidth / 2;
|
||||
const centerY = layer.y + layerHeight / 2;
|
||||
const radians = (layer.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const placed: Array<{ left: number; top: number; width: number; height: number }> = [];
|
||||
|
||||
return faceDetections.map((face, index) => {
|
||||
const worldX = layer.x + face.x;
|
||||
const worldY = layer.y + face.y;
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
const rotatedWorldX = centerX + dx * cos - dy * sin;
|
||||
const rotatedWorldY = centerY + dx * sin + dy * cos;
|
||||
|
||||
const text = face.label ?? `Person ${index + 1}`;
|
||||
const estimatedWidth = Math.max(72, Math.min(260, text.length * 6 + 14));
|
||||
const estimatedHeight = 18;
|
||||
const left = viewport.x + rotatedWorldX * viewport.scale;
|
||||
let top = viewport.y + rotatedWorldY * viewport.scale - 22;
|
||||
|
||||
while (
|
||||
placed.some((rect) => {
|
||||
const intersectsX = left < rect.left + rect.width && left + estimatedWidth > rect.left;
|
||||
const intersectsY = top < rect.top + rect.height && top + estimatedHeight > rect.top;
|
||||
return intersectsX && intersectsY;
|
||||
})
|
||||
) {
|
||||
top -= estimatedHeight + 4;
|
||||
}
|
||||
|
||||
placed.push({ left, top, width: estimatedWidth, height: estimatedHeight });
|
||||
return { id: `${layer.id}-face-label-${index}`, text, left, top };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const AUTOSAVE_DELAY_MS = 1200;
|
||||
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
width: 170,
|
||||
height: 110,
|
||||
viewportPadding: 8,
|
||||
} as const;
|
||||
|
||||
export const CANVAS_HANDLE_BASE_SIZE = 12;
|
||||
export const CANVAS_ROTATE_HANDLE_BASE_SIZE = 18;
|
||||
|
||||
export const MIN_LAYER_SIZE = 8;
|
||||
|
||||
export const DEFAULT_IMAGE_IMPORT = {
|
||||
fallbackWidth: 200,
|
||||
fallbackHeight: 150,
|
||||
offsetX: 60,
|
||||
offsetY: 60,
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
|
||||
export type ToolModeController = {
|
||||
kind: "mode";
|
||||
id: EditorToolId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ToolActionController = {
|
||||
kind: "action";
|
||||
id: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export type EditorToolController = ToolModeController | ToolActionController;
|
||||
|
||||
type CreateEditorToolControllersOptions = {
|
||||
onAddTextLayer: () => void;
|
||||
onImportImage: () => void;
|
||||
labels: {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
|
||||
return [
|
||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
||||
{ kind: "mode", id: "face", label: options.labels.face },
|
||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
type ExportOptions = {
|
||||
isDark: boolean;
|
||||
pixelRatio?: number;
|
||||
};
|
||||
|
||||
function clampOpacity(value: number | undefined) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return 1;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = reject;
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
||||
const text = layer.name ?? layer.type;
|
||||
const width = Math.max(80, layer.width ?? 120);
|
||||
const height = Math.max(34, layer.height ?? 40);
|
||||
const radius = 8;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.lineTo(width - radius, 0);
|
||||
ctx.quadraticCurveTo(width, 0, width, radius);
|
||||
ctx.lineTo(width, height - radius);
|
||||
ctx.quadraticCurveTo(width, height, width - radius, height);
|
||||
ctx.lineTo(radius, height);
|
||||
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = isDark ? "#2d3036" : "#ffffff";
|
||||
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, width / 2, height / 2);
|
||||
}
|
||||
|
||||
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
||||
const width = layer.width ?? (layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
||||
const height = layer.height ?? (layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||
ctx.translate(layer.x + width / 2, layer.y + height / 2);
|
||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
|
||||
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
||||
try {
|
||||
const image = await loadImage(layer.sourceUri);
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
if (layer.faceBlur && layer.faceBlur.regions.length > 0) {
|
||||
renderFaceBlurRegions(ctx, image, layer.faceBlur, width, height);
|
||||
}
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
} else {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
|
||||
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
|
||||
const { width, height } = project.canvas;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * pixelRatio;
|
||||
canvas.height = height * pixelRatio;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create export canvas context");
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.fillStyle = options.isDark ? "#17181b" : "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const layer of project.layers) {
|
||||
await drawLayer(ctx, layer, options.isDark);
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `${project.title || "pien-project"}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
fillStyle: "",
|
||||
filter: "none",
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function makeImage(width = 1200, height = 800) {
|
||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||
}
|
||||
|
||||
describe("renderFaceBlurRegions", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledOnce();
|
||||
expect(ctx.filter).toBe("blur(24px)");
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders pixelate using sampled offscreen canvas", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sampleDrawImage = vi.fn();
|
||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(sampleCanvas.width).toBe(16);
|
||||
expect(sampleCanvas.height).toBe(12);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("renders censor with region color priority", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25);
|
||||
});
|
||||
|
||||
it("falls back to legacy region scaling when source dimensions are missing", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage(2400, 1600);
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FaceBlurSettings } from "@pien-studio/types";
|
||||
|
||||
type BlurRegion = FaceBlurSettings["regions"][number];
|
||||
|
||||
function drawPixelatedRegion(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
blockSize: number,
|
||||
) {
|
||||
const sampleCanvas = document.createElement("canvas");
|
||||
sampleCanvas.width = Math.max(1, Math.round(targetWidth / blockSize));
|
||||
sampleCanvas.height = Math.max(1, Math.round(targetHeight / blockSize));
|
||||
const sampleCtx = sampleCanvas.getContext("2d");
|
||||
if (!sampleCtx) return;
|
||||
sampleCtx.imageSmoothingEnabled = false;
|
||||
sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
export function renderFaceBlurRegions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string },
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur.regions.length) return;
|
||||
const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth);
|
||||
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight);
|
||||
|
||||
for (const region of blur.regions) {
|
||||
const hasSourceDims = typeof region.sourceWidth === "number" && typeof region.sourceHeight === "number" && region.sourceWidth > 0 && region.sourceHeight > 0;
|
||||
const scaleX = hasSourceDims ? targetWidth / region.sourceWidth : 1;
|
||||
const scaleY = hasSourceDims ? targetHeight / region.sourceHeight : 1;
|
||||
|
||||
const x = Math.max(0, Math.floor(region.x * scaleX));
|
||||
const y = Math.max(0, Math.floor(region.y * scaleY));
|
||||
const w = Math.max(1, Math.floor(region.width * scaleX));
|
||||
const h = Math.max(1, Math.floor(region.height * scaleY));
|
||||
const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||
|
||||
if (blur.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blur.method === "pixelate") {
|
||||
const pixelSize = Math.max(4, Math.round(blur.amount / 2));
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../hooks/use-face-detection";
|
||||
|
||||
export async function loadImageFromUri(uri: string): Promise<HTMLImageElement | null> {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = uri;
|
||||
});
|
||||
if (!image.naturalWidth || !image.naturalHeight) return null;
|
||||
return image;
|
||||
}
|
||||
|
||||
export function toFaceDetectionOverlays(
|
||||
faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>,
|
||||
image: HTMLImageElement,
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FaceDetectionOverlay[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const scaleX = width / image.naturalWidth;
|
||||
const scaleY = height / image.naturalHeight;
|
||||
|
||||
return faces.map((face, index) => {
|
||||
const genderLabel = face.gender ?? "unknown";
|
||||
const scoreLabel = face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
||||
const label = scoreLabel ? `Person ${index + 1} - ${genderLabel} ${scoreLabel}` : `Person ${index + 1} - ${genderLabel}`;
|
||||
|
||||
return {
|
||||
x: face.x * scaleX,
|
||||
y: face.y * scaleY,
|
||||
width: face.width * scaleX,
|
||||
height: face.height * scaleY,
|
||||
sourceWidth: image.naturalWidth,
|
||||
sourceHeight: image.naturalHeight,
|
||||
label,
|
||||
gender: face.gender,
|
||||
genderScore: face.genderScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFacePreviews(
|
||||
image: HTMLImageElement,
|
||||
faceDetections: FaceDetectionOverlay[],
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FacePreview[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const toImageScaleX = image.naturalWidth / Math.max(1, width);
|
||||
const toImageScaleY = image.naturalHeight / Math.max(1, height);
|
||||
|
||||
return faceDetections
|
||||
.map((face, index) => {
|
||||
const sx = Math.max(0, Math.floor(face.x * toImageScaleX));
|
||||
const sy = Math.max(0, Math.floor(face.y * toImageScaleY));
|
||||
const sw = Math.max(1, Math.floor(face.width * toImageScaleX));
|
||||
const sh = Math.max(1, Math.floor(face.height * toImageScaleY));
|
||||
const ex = Math.min(image.naturalWidth, sx + sw);
|
||||
const ey = Math.min(image.naturalHeight, sy + sh);
|
||||
const cw = Math.max(1, ex - sx);
|
||||
const ch = Math.max(1, ey - sy);
|
||||
const canvas = document.createElement("canvas");
|
||||
const targetWidth = 84;
|
||||
const scale = targetWidth / cw;
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = Math.max(1, Math.round(ch * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return { id: `face-${index + 1}`, src: "" };
|
||||
ctx.drawImage(image, sx, sy, cw, ch, 0, 0, canvas.width, canvas.height);
|
||||
return { id: `face-${index + 1}`, src: canvas.toDataURL("image/jpeg", 0.9) };
|
||||
})
|
||||
.filter((preview) => preview.src);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type FaceBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
gender?: "male" | "female";
|
||||
genderScore?: number;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[]> {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
await tf.ready();
|
||||
|
||||
const faceapi = await import("@vladmandic/face-api");
|
||||
await Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
faceapi.nets.faceLandmark68TinyNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
faceapi.nets.ageGenderNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
]);
|
||||
|
||||
const TinyFaceDetectorOptions = (faceapi as unknown as { TinyFaceDetectorOptions: new (o: object) => object }).TinyFaceDetectorOptions;
|
||||
|
||||
const passes = [
|
||||
{ inputSize: 320, scoreThreshold: 0.5 },
|
||||
{ inputSize: 416, scoreThreshold: 0.45 },
|
||||
{ inputSize: 512, scoreThreshold: 0.5 },
|
||||
{ inputSize: 608, scoreThreshold: 0.45 },
|
||||
{ inputSize: 736, scoreThreshold: 0.4 },
|
||||
{ inputSize: 864, scoreThreshold: 0.35 },
|
||||
];
|
||||
|
||||
type FaceApiDet = {
|
||||
gender: string;
|
||||
genderProbability: number;
|
||||
detection: { box: { x: number; y: number; width: number; height: number } };
|
||||
};
|
||||
|
||||
const allDets = await Promise.all(
|
||||
passes.map(({ inputSize, scoreThreshold }) =>
|
||||
(faceapi as unknown as {
|
||||
detectAllFaces: (
|
||||
img: HTMLImageElement,
|
||||
options: { inputSize: number; scoreThreshold: number }
|
||||
) => Promise<FaceApiDet[]>
|
||||
}).detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold }))
|
||||
.withFaceLandmarks(true)
|
||||
.withAgeAndGender()
|
||||
)
|
||||
);
|
||||
|
||||
const flat = allDets.flat();
|
||||
if (flat.length === 0) return [];
|
||||
|
||||
function iou(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) {
|
||||
const ix = Math.max(a.x, b.x);
|
||||
const iy = Math.max(a.y, b.y);
|
||||
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
||||
const iy2 = Math.min(a.y + b.height, b.y + b.height);
|
||||
const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
|
||||
const union = a.width * a.height + b.width * b.height - inter;
|
||||
return union > 0 ? inter / union : 0;
|
||||
}
|
||||
|
||||
function avgGender(dets: FaceApiDet[]): { gender: "male" | "female" | undefined; score: number } {
|
||||
let maleScore = 0;
|
||||
let femaleScore = 0;
|
||||
let count = 0;
|
||||
for (const det of dets) {
|
||||
if (det.gender === "male") maleScore += det.genderProbability;
|
||||
else if (det.gender === "female") femaleScore += det.genderProbability;
|
||||
count++;
|
||||
}
|
||||
if (count === 0) return { gender: undefined, score: 0 };
|
||||
const avgMale = maleScore / count;
|
||||
const avgFemale = femaleScore / count;
|
||||
if (avgMale > avgFemale) return { gender: "male", score: avgMale };
|
||||
if (avgFemale > avgMale) return { gender: "female", score: avgFemale };
|
||||
return { gender: undefined, score: 0 };
|
||||
}
|
||||
|
||||
const clusters: FaceApiDet[][] = [];
|
||||
for (const det of flat) {
|
||||
const b = det.detection?.box;
|
||||
if (!b) continue;
|
||||
let matched = false;
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.some((c) => iou(c.detection.box, b) > 0.4)) {
|
||||
cluster.push(det);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) clusters.push([det]);
|
||||
}
|
||||
|
||||
return clusters.map((group) => {
|
||||
const largest = [...group].sort(
|
||||
(a, b) =>
|
||||
(b.detection?.box?.width ?? 0) * (b.detection?.box?.height ?? 0) -
|
||||
(a.detection?.box?.width ?? 0) * (a.detection?.box?.height ?? 0)
|
||||
)[0];
|
||||
const box = largest.detection.box;
|
||||
const { gender, score: genderScore } = avgGender(group);
|
||||
|
||||
return {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
gender,
|
||||
genderScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { hasProjectChanged } from "./project-equality";
|
||||
|
||||
function makeProject(): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
title: "Project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
aspectRatio: "1:1",
|
||||
canvas: { width: 100, height: 100, unit: "px" },
|
||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasProjectChanged", () => {
|
||||
it("returns false for equal projects", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects canvas changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
b.canvas.width = 101;
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects face blur region changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
b.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] };
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects layer order changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers = [b.layers[1], b.layers[0]];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats missing optional fields and undefined as equal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].name = undefined;
|
||||
b.layers[0].name = undefined;
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects face blur removal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
if (left.id !== right.id) return true;
|
||||
if (left.title !== right.title) return true;
|
||||
if (left.createdAt !== right.createdAt) return true;
|
||||
if (left.updatedAt !== right.updatedAt) return true;
|
||||
if (left.aspectRatio !== right.aspectRatio) return true;
|
||||
if (left.canvas.width !== right.canvas.width || left.canvas.height !== right.canvas.height || left.canvas.unit !== right.canvas.unit) {
|
||||
return true;
|
||||
}
|
||||
if (left.layers.length !== right.layers.length) return true;
|
||||
|
||||
for (let index = 0; index < left.layers.length; index += 1) {
|
||||
const a = left.layers[index];
|
||||
const b = right.layers[index];
|
||||
if (!a || !b) return true;
|
||||
if (
|
||||
a.id !== b.id ||
|
||||
a.type !== b.type ||
|
||||
a.name !== b.name ||
|
||||
a.assetId !== b.assetId ||
|
||||
a.sourceUri !== b.sourceUri ||
|
||||
a.x !== b.x ||
|
||||
a.y !== b.y ||
|
||||
a.width !== b.width ||
|
||||
a.height !== b.height ||
|
||||
a.scale !== b.scale ||
|
||||
a.rotation !== b.rotation ||
|
||||
a.opacity !== b.opacity
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blurA = a.faceBlur;
|
||||
const blurB = b.faceBlur;
|
||||
if (!blurA && !blurB) continue;
|
||||
if (!blurA || !blurB) return true;
|
||||
if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true;
|
||||
if (blurA.regions.length !== blurB.regions.length) return true;
|
||||
for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) {
|
||||
const regionA = blurA.regions[regionIndex];
|
||||
const regionB = blurB.regions[regionIndex];
|
||||
if (!regionA || !regionB) return true;
|
||||
if (
|
||||
regionA.x !== regionB.x ||
|
||||
regionA.y !== regionB.y ||
|
||||
regionA.width !== regionB.width ||
|
||||
regionA.height !== regionB.height ||
|
||||
regionA.censorColor !== regionB.censorColor
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function surfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white";
|
||||
}
|
||||
|
||||
export function mutedSurfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
|
||||
}
|
||||
|
||||
export function subtleButtonClass(isDark: boolean): string {
|
||||
return isDark
|
||||
? "border-white/15 bg-[#25272b] text-[#e8eaed]"
|
||||
: "border-black/15 bg-[#f2f4f8] text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function accentButtonClass(): string {
|
||||
return "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white";
|
||||
}
|
||||
|
||||
export function hoverSubtleClass(isDark: boolean): string {
|
||||
return isDark ? "hover:bg-white/10" : "hover:bg-black/5";
|
||||
}
|
||||
|
||||
export function dividerClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10" : "bg-black/10";
|
||||
}
|
||||
|
||||
export function panelClass(isDark: boolean): string {
|
||||
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
|
||||
}
|
||||
|
||||
export function panelTitleClass(isDark: boolean): string {
|
||||
return isDark ? "text-[#dfe3ea]" : "text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function panelCounterClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10 text-[#cfd4dd]" : "bg-black/5 text-[#586071]";
|
||||
}
|
||||
|
||||
export function panelInsetClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "Make your mood visible",
|
||||
"subtitle": "Local-first editing, expressive presets, share when you want.",
|
||||
"start": "Start New Project",
|
||||
"moods": "Mood presets",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "Open",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete",
|
||||
"projectHub": "Project Hub",
|
||||
"createOpenManage": "Create, open, and manage local projects.",
|
||||
"newProject": "New Project",
|
||||
"openProjectFile": "Open Project File",
|
||||
"openImage": "Open Image",
|
||||
"myProjects": "My Projects",
|
||||
"noProjectsYet": "No projects yet",
|
||||
"untitledProject": "Untitled Project",
|
||||
"imageProject": "Image Project",
|
||||
"wipTitle": "Heavy WIP Warning",
|
||||
"wipBody": "This app is in very early development and heavily work in progress. Features may be broken, missing, unstable, or changed at any time.",
|
||||
"wipSupportPrefix": "If you want to support development, you can sponsor the project on GitHub:",
|
||||
"wipAcknowledge": "I Understand"
|
||||
},
|
||||
"editor": {
|
||||
"file": "File",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"settings": "Settings",
|
||||
"save": "Save",
|
||||
"exportPng": "Export PNG",
|
||||
"exportProjectFile": "Export Project File",
|
||||
"importImage": "Import image",
|
||||
"canvasSize": "Canvas size",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"copy": "Copy",
|
||||
"cut": "Cut",
|
||||
"paste": "Paste",
|
||||
"preferences": "Preferences",
|
||||
"panTool": "Pan tool",
|
||||
"pointerTool": "Pointer tool",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"saved": "Saved",
|
||||
"layers": "Layers",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"delete": "Delete",
|
||||
"noLayersYet": "No layers yet. Add text or image.",
|
||||
"faceTool": "Face Tool",
|
||||
"faceMlFailed": "Face ML model failed to load on this device/browser.",
|
||||
"detectingFaces": "Detecting faces on selected image...",
|
||||
"selectImageLayer": "Select an image layer to scan faces.",
|
||||
"noFacesFound": "No faces found in selected image.",
|
||||
"facesDetected": "Faces detected and highlighted on canvas.",
|
||||
"person": "Person",
|
||||
"unknown": "unknown",
|
||||
"neutral": "neutral",
|
||||
"blurMethod": "Blur method",
|
||||
"soft": "Soft",
|
||||
"pixelate": "Pixelate",
|
||||
"censor": "Censor",
|
||||
"strength": "Strength",
|
||||
"color": "Color",
|
||||
"selectFacesToBlur": "Select faces to blur",
|
||||
"blurFaces": "Blur {count} face(s)",
|
||||
"clearBlur": "Clear blur",
|
||||
"history": "History",
|
||||
"beforeLastAction": "Before last action",
|
||||
"step": "Step",
|
||||
"now": "Now",
|
||||
"undoneStep": "Undone step",
|
||||
"noHistoryYet": "No history yet",
|
||||
"resize": "Resize",
|
||||
"import": "Import",
|
||||
"mood": "Mood",
|
||||
"quick": "Quick",
|
||||
"face": "Face",
|
||||
"decor": "Decor",
|
||||
"faceMlFailedShort": "Face ML model failed to load on this device/browser.",
|
||||
"detectingFacesShort": "Detecting faces on-device...",
|
||||
"faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.",
|
||||
"toolPointer": "Pointer",
|
||||
"toolPan": "Pan",
|
||||
"toolFace": "Face",
|
||||
"toolText": "Text",
|
||||
"toolImage": "Image",
|
||||
"canvasSizeTitle": "Canvas Size",
|
||||
"modePreset": "Preset",
|
||||
"modeCustom": "Custom",
|
||||
"presetSquare": "Square",
|
||||
"presetPortrait45": "Portrait 4:5",
|
||||
"presetStory916": "Story 9:16",
|
||||
"presetWidescreen": "Widescreen",
|
||||
"presetPhoto43": "Photo 4:3",
|
||||
"presetClassic32": "Classic 3:2",
|
||||
"widthShort": "W",
|
||||
"heightShort": "H",
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
"apply": "Apply",
|
||||
"rotate": "Rotate",
|
||||
"layer": "layer"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "Locale",
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "気分をビジュアルに",
|
||||
"subtitle": "ローカル優先で編集。感情に合うプリセット。共有は好きな時に。",
|
||||
"start": "新しいプロジェクトを開始",
|
||||
"moods": "ムードプリセット",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "開く",
|
||||
"duplicate": "複製",
|
||||
"delete": "削除",
|
||||
"projectHub": "プロジェクトハブ",
|
||||
"createOpenManage": "ローカルプロジェクトの作成・読み込み・管理",
|
||||
"newProject": "新規プロジェクト",
|
||||
"openProjectFile": "プロジェクトファイルを開く",
|
||||
"openImage": "画像を開く",
|
||||
"myProjects": "マイプロジェクト",
|
||||
"noProjectsYet": "プロジェクトがありません",
|
||||
"untitledProject": "無題のプロジェクト",
|
||||
"imageProject": "画像プロジェクト",
|
||||
"wipTitle": "開発初期の警告",
|
||||
"wipBody": "このアプリは開発のかなり初期段階で、現在も大幅に作業中です。機能が壊れていたり、未実装だったり、不安定だったり、予告なく変更される場合があります。",
|
||||
"wipSupportPrefix": "開発を支援したい場合は、GitHub Sponsors からスポンサーできます:",
|
||||
"wipAcknowledge": "理解しました"
|
||||
},
|
||||
"editor": {
|
||||
"file": "ファイル",
|
||||
"edit": "編集",
|
||||
"view": "表示",
|
||||
"settings": "設定",
|
||||
"save": "保存",
|
||||
"exportPng": "PNGでエクスポート",
|
||||
"exportProjectFile": "プロジェクトファイルをエクスポート",
|
||||
"importImage": "画像をインポート",
|
||||
"canvasSize": "キャンバスサイズ",
|
||||
"undo": "元に戻す",
|
||||
"redo": "やり直す",
|
||||
"copy": "コピー",
|
||||
"cut": "切り取り",
|
||||
"paste": "貼り付け",
|
||||
"preferences": "環境設定",
|
||||
"panTool": "パン工具",
|
||||
"pointerTool": "ポインター工具",
|
||||
"unsavedChanges": "未保存の変更",
|
||||
"saved": "保存済み",
|
||||
"layers": "レイヤー",
|
||||
"up": "上へ",
|
||||
"down": "下へ",
|
||||
"delete": "削除",
|
||||
"noLayersYet": "レイヤーがありません。テキストまたは画像を追加してください。",
|
||||
"faceTool": "顔ツール",
|
||||
"faceMlFailed": "このデバイス/ブラウザでは顔認識MLモデルの読み込みに失敗しました。",
|
||||
"detectingFaces": "選択した画像から顔を検出中...",
|
||||
"selectImageLayer": "顔をスキャンするには画像レイヤーを選択してください。",
|
||||
"noFacesFound": "選択した画像内で顔が見つかりませんでした。",
|
||||
"facesDetected": "顔が検出されキャンバスにハイライトされました。",
|
||||
"person": "人物",
|
||||
"unknown": "不明",
|
||||
"neutral": "中立",
|
||||
"blurMethod": "ぼかし方法",
|
||||
"soft": "ソフト",
|
||||
"pixelate": "モザイク",
|
||||
"censor": "センソル",
|
||||
"strength": "強度",
|
||||
"color": "色",
|
||||
"selectFacesToBlur": "ぼかす顔を選択してください",
|
||||
"blurFaces": "{count}件の顔をぼかす",
|
||||
"clearBlur": "ぼかしをクリア",
|
||||
"history": "履歴",
|
||||
"beforeLastAction": "最後の操作の前",
|
||||
"step": "ステップ",
|
||||
"now": "現在",
|
||||
"undoneStep": "取り消したステップ",
|
||||
"noHistoryYet": "履歴がありません",
|
||||
"resize": "サイズ変更",
|
||||
"import": "インポート",
|
||||
"mood": "ムード",
|
||||
"quick": "クイック",
|
||||
"face": "顔",
|
||||
"decor": "デコル",
|
||||
"faceMlFailedShort": "このデバイス/ブラウザでは顔MLモデルの読み込みに失敗しました。",
|
||||
"detectingFacesShort": "デバイス上で顔を検出中...",
|
||||
"faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。",
|
||||
"toolPointer": "ポインター",
|
||||
"toolPan": "パン",
|
||||
"toolFace": "顔",
|
||||
"toolText": "テキスト",
|
||||
"toolImage": "画像",
|
||||
"canvasSizeTitle": "キャンバスサイズ",
|
||||
"modePreset": "プリセット",
|
||||
"modeCustom": "カスタム",
|
||||
"presetSquare": "正方形",
|
||||
"presetPortrait45": "縦長 4:5",
|
||||
"presetStory916": "ストーリー 9:16",
|
||||
"presetWidescreen": "ワイド 16:9",
|
||||
"presetPhoto43": "写真 4:3",
|
||||
"presetClassic32": "クラシック 3:2",
|
||||
"widthShort": "幅",
|
||||
"heightShort": "高",
|
||||
"cancel": "キャンセル",
|
||||
"dismiss": "閉じる",
|
||||
"apply": "適用",
|
||||
"rotate": "回転",
|
||||
"layer": "レイヤー"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "言語",
|
||||
"darkMode": "ダークモード",
|
||||
"lightMode": "ライトモード"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "ทำให้อารมณ์ของคุณมองเห็นได้",
|
||||
"subtitle": "แต่งภาพแบบ local-first พร้อมพรีเซ็ตสายอารมณ์ แชร์เมื่อพร้อม",
|
||||
"start": "เริ่มโปรเจกต์ใหม่",
|
||||
"moods": "พรีเซ็ตอารมณ์",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "เปิด",
|
||||
"duplicate": "ทำซ้ำ",
|
||||
"delete": "ลบ",
|
||||
"projectHub": "ศูนย์รวมโปรเจกต์",
|
||||
"createOpenManage": "สร้าง เปิด และจัดการโปรเจกต์ในเครื่อง",
|
||||
"newProject": "โปรเจกต์ใหม่",
|
||||
"openProjectFile": "เปิดไฟล์โปรเจกต์",
|
||||
"openImage": "เปิดรูปภาพ",
|
||||
"myProjects": "โปรเจกต์ของฉัน",
|
||||
"noProjectsYet": "ยังไม่มีโปรเจกต์",
|
||||
"untitledProject": "โปรเจกต์ไม่มีชื่อ",
|
||||
"imageProject": "โปรเจกต์รูปภาพ",
|
||||
"wipTitle": "คำเตือน: ยังอยู่ระหว่างพัฒนาอย่างหนัก",
|
||||
"wipBody": "แอปนี้ยังอยู่ในช่วงพัฒนาเริ่มต้นมาก ๆ และยังอยู่ระหว่างทำงานอย่างต่อเนื่อง ฟีเจอร์บางส่วนอาจใช้งานไม่ได้ หายไป ไม่เสถียร หรือเปลี่ยนแปลงได้ตลอดเวลา",
|
||||
"wipSupportPrefix": "ถ้าคุณอยากสนับสนุนการพัฒนา สามารถสปอนเซอร์ผ่าน GitHub ได้ที่:",
|
||||
"wipAcknowledge": "เข้าใจแล้ว"
|
||||
},
|
||||
"editor": {
|
||||
"file": "ไฟล์",
|
||||
"edit": "แก้ไข",
|
||||
"view": "มุมมอง",
|
||||
"settings": "การตั้งค่า",
|
||||
"save": "บันทึก",
|
||||
"exportPng": "ส่งออก PNG",
|
||||
"exportProjectFile": "ส่งออกไฟล์โปรเจกต์",
|
||||
"importImage": "นำเข้ารูปภาพ",
|
||||
"canvasSize": "ขนาด Canvas",
|
||||
"undo": "เลิกทำ",
|
||||
"redo": "ทำซ้ำ",
|
||||
"copy": "คัดลอก",
|
||||
"cut": "ตัด",
|
||||
"paste": "วาง",
|
||||
"preferences": "การตั้งค่า",
|
||||
"panTool": "เครื่องมือเลื่อน",
|
||||
"pointerTool": "เครื่องมือชี้",
|
||||
"unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก",
|
||||
"saved": "บันทึกแล้ว",
|
||||
"layers": "เลเยอร์",
|
||||
"up": "ขึ้น",
|
||||
"down": "ลง",
|
||||
"delete": "ลบ",
|
||||
"noLayersYet": "ยังไม่มีเลเยอร์ เพิ่มข้อความหรือรูปภาพ",
|
||||
"faceTool": "เครื่องมือใบหน้า",
|
||||
"faceMlFailed": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้",
|
||||
"detectingFaces": "กำลังตรวจจับใบหน้าในรูปภาพที่เลือก...",
|
||||
"selectImageLayer": "เลือกเลเยอร์รูปภาพเพื่อสแกนใบหน้า",
|
||||
"noFacesFound": "ไม่พบใบหน้าในรูปภาพที่เลือก",
|
||||
"facesDetected": "ตรวจพบใบหน้าและไฮไลท์บน Canvas",
|
||||
"person": "บุคคล",
|
||||
"unknown": "ไม่รู้จัก",
|
||||
"neutral": "เป็นกลาง",
|
||||
"blurMethod": "วิธีเบลอ",
|
||||
"soft": "นุ่ม",
|
||||
"pixelate": "ปิกเซล",
|
||||
"censor": "เซ็นเซอร์",
|
||||
"strength": "ความแรง",
|
||||
"color": "สี",
|
||||
"selectFacesToBlur": "เลือกใบหน้าที่จะเบลอ",
|
||||
"blurFaces": "เบลอ {count} ใบหน้า",
|
||||
"clearBlur": "ล้างการเบลอ",
|
||||
"history": "ประวัติ",
|
||||
"beforeLastAction": "ก่อนการทำงานล่าสุด",
|
||||
"step": "ขั้นตอน",
|
||||
"now": "ตอนนี้",
|
||||
"undoneStep": "ขั้นตอนที่เลิกทำ",
|
||||
"noHistoryYet": "ยังไม่มีประวัติ",
|
||||
"resize": "ปรับขนาด",
|
||||
"import": "นำเข้า",
|
||||
"mood": "อารมณ์",
|
||||
"quick": "ด่วน",
|
||||
"face": "ใบหน้า",
|
||||
"decor": "ตกแต่ง",
|
||||
"faceMlFailedShort": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้",
|
||||
"detectingFacesShort": "กำลังตรวจจับใบหน้าบนอุปกรณ์...",
|
||||
"faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด",
|
||||
"toolPointer": "ตัวชี้",
|
||||
"toolPan": "เลื่อน",
|
||||
"toolFace": "ใบหน้า",
|
||||
"toolText": "ข้อความ",
|
||||
"toolImage": "รูปภาพ",
|
||||
"canvasSizeTitle": "ขนาดแคนวาส",
|
||||
"modePreset": "พรีเซ็ต",
|
||||
"modeCustom": "กำหนดเอง",
|
||||
"presetSquare": "สี่เหลี่ยมจัตุรัส",
|
||||
"presetPortrait45": "แนวตั้ง 4:5",
|
||||
"presetStory916": "สตอรี่ 9:16",
|
||||
"presetWidescreen": "จอกว้าง 16:9",
|
||||
"presetPhoto43": "ภาพถ่าย 4:3",
|
||||
"presetClassic32": "คลาสสิก 3:2",
|
||||
"widthShort": "กว้าง",
|
||||
"heightShort": "สูง",
|
||||
"cancel": "ยกเลิก",
|
||||
"dismiss": "ปิด",
|
||||
"apply": "นำไปใช้",
|
||||
"rotate": "หมุน",
|
||||
"layer": "เลเยอร์"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "ภาษา",
|
||||
"darkMode": "โหมดมืด",
|
||||
"lightMode": "โหมดสว่าง"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
export default withNextIntl({
|
||||
reactStrictMode: true,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@pien-studio/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mediapipe/face_detection": "~0.4.0",
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/storage": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tensorflow-models/face-detection": "^1.0.3",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-cpu": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
|
||||
"@tensorflow/tfjs-converter": "^4.22.0",
|
||||
"@tensorflow/tfjs-core": "^4.22.0",
|
||||
"@vladmandic/face-api": "^1.7.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^16.2.4",
|
||||
"next-intl": "^4.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zustand": "^5.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
|
||||
export const HISTORY_LIMIT = 120;
|
||||
|
||||
export type HistoryState = {
|
||||
past: Project[];
|
||||
present: Project;
|
||||
future: Project[];
|
||||
};
|
||||
|
||||
export function deepClone<T>(value: T): T {
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function cloneProject(project: Project): Project {
|
||||
return deepClone(project);
|
||||
}
|
||||
|
||||
export function cloneLayer(layer: Layer): Layer {
|
||||
return deepClone(layer);
|
||||
}
|
||||
|
||||
export function makeHistory(project: Project): HistoryState {
|
||||
return { past: [], present: cloneProject(project), future: [] };
|
||||
}
|
||||
|
||||
export function computeHistoryFlags(history: HistoryState) {
|
||||
return { canUndo: history.past.length > 0, canRedo: history.future.length > 0 };
|
||||
}
|
||||
|
||||
export function capHistory(items: Project[]): Project[] {
|
||||
if (items.length <= HISTORY_LIMIT) return items;
|
||||
return items.slice(items.length - HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function resolveSelectedLayerId(project: Project, preferred: string | null): string | null {
|
||||
if (preferred && project.layers.some((layer) => layer.id === preferred)) return preferred;
|
||||
return project.layers[0]?.id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEditorStore } from "./editor-store";
|
||||
|
||||
describe("editor store", () => {
|
||||
it("saves and loads project from indexeddb", async () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("sticker");
|
||||
const savedId = useEditorStore.getState().project.id;
|
||||
|
||||
await useEditorStore.getState().saveCurrentProject();
|
||||
useEditorStore.getState().resetProject();
|
||||
expect(useEditorStore.getState().project.id).not.toBe(savedId);
|
||||
|
||||
await useEditorStore.getState().loadProjectById(savedId);
|
||||
expect(useEditorStore.getState().project.id).toBe(savedId);
|
||||
});
|
||||
|
||||
it("imports and exports project json", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const json = useEditorStore.getState().exportProjectToJson();
|
||||
|
||||
useEditorStore.getState().resetProject();
|
||||
const result = useEditorStore.getState().importProjectFromJson(json);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useEditorStore.getState().project.layers.length).toBe(1);
|
||||
});
|
||||
|
||||
it("adds layer by type and removes selected layer", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
expect(useEditorStore.getState().project.layers[0]?.type).toBe("text");
|
||||
useEditorStore.getState().removeSelectedLayer();
|
||||
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates selected layer position directly", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
useEditorStore.getState().setSelectedLayerPosition(220, 140);
|
||||
const layer = useEditorStore.getState().project.layers[0];
|
||||
expect(layer?.x).toBe(220);
|
||||
expect(layer?.y).toBe(140);
|
||||
});
|
||||
|
||||
it("records pointer draft transforms in undo history on commit", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const initial = useEditorStore.getState().project.layers[0];
|
||||
expect(initial).toBeDefined();
|
||||
|
||||
useEditorStore.getState().setSelectedLayerPositionDraft(180, 150);
|
||||
useEditorStore.getState().setSelectedLayerPosition(180, 150);
|
||||
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||
|
||||
useEditorStore.getState().undo();
|
||||
const movedBack = useEditorStore.getState().project.layers[0];
|
||||
expect(movedBack?.x).toBe(initial?.x);
|
||||
expect(movedBack?.y).toBe(initial?.y);
|
||||
});
|
||||
|
||||
it("reorders layers up and down", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const layers = useEditorStore.getState().project.layers;
|
||||
const [first, second] = layers;
|
||||
expect(first.id).not.toBe(second.id);
|
||||
|
||||
useEditorStore.getState().selectLayer(first.id);
|
||||
useEditorStore.getState().moveSelectedLayerOrder("up");
|
||||
const after = useEditorStore.getState().project.layers;
|
||||
expect(after[0].id).toBe(second.id);
|
||||
expect(after[1].id).toBe(first.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
addLayer,
|
||||
createLayer,
|
||||
createProject,
|
||||
parseProjectFile,
|
||||
removeLayer,
|
||||
reorderLayer,
|
||||
serializeProjectFile,
|
||||
setCanvasSize as applyCanvasSize,
|
||||
updateLayerTransform,
|
||||
normalizeProject,
|
||||
} from "@pien-studio/editor-core";
|
||||
import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types";
|
||||
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
|
||||
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
||||
import { hasProjectChanged } from "../lib/project-equality";
|
||||
import {
|
||||
capHistory,
|
||||
cloneLayer,
|
||||
cloneProject,
|
||||
computeHistoryFlags,
|
||||
makeHistory,
|
||||
resolveSelectedLayerId,
|
||||
type HistoryState,
|
||||
} from "./editor-store-helpers";
|
||||
|
||||
const DRAFT_TRANSFORM_EPSILON = 0.01;
|
||||
|
||||
export const EDITOR_TOOLS = {
|
||||
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
|
||||
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
|
||||
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
|
||||
} as const;
|
||||
|
||||
export type EditorToolId = keyof typeof EDITOR_TOOLS;
|
||||
|
||||
type TransactionState = {
|
||||
baselineProject: Project;
|
||||
baselineSelectedLayerId: string | null;
|
||||
};
|
||||
|
||||
type EditorState = {
|
||||
project: Project;
|
||||
selectedLayerId: string | null;
|
||||
tool: EditorToolId;
|
||||
history: HistoryState;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
clipboardLayer: Layer | null;
|
||||
isDirty: boolean;
|
||||
transaction: TransactionState | null;
|
||||
tools: typeof EDITOR_TOOLS;
|
||||
startTransaction: () => void;
|
||||
commitTransaction: () => void;
|
||||
cancelTransaction: () => void;
|
||||
applyProjectDraft: (project: Project) => void;
|
||||
setTool: (tool: EditorToolId) => void;
|
||||
addLayerByType: (type: Layer["type"]) => void;
|
||||
setSelectedLayerPosition: (x: number, y: number) => void;
|
||||
setSelectedLayerPositionDraft: (x: number, y: number) => void;
|
||||
setSelectedLayerSize: (width: number, height: number) => void;
|
||||
setSelectedLayerSizeDraft: (width: number, height: number) => void;
|
||||
setSelectedLayerRotation: (rotation: number) => void;
|
||||
setSelectedLayerRotationDraft: (rotation: number) => void;
|
||||
removeSelectedLayer: () => void;
|
||||
moveSelectedLayerOrder: (direction: "up" | "down") => void;
|
||||
selectLayer: (layerId: string | null) => void;
|
||||
copySelectedLayer: () => void;
|
||||
cutSelectedLayer: () => void;
|
||||
pasteLayer: () => void;
|
||||
resetProject: () => void;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: Project) => void;
|
||||
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
|
||||
importImageFromFile: (file: File) => Promise<void>;
|
||||
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void;
|
||||
setCanvasSize: (width: number, height: number) => void;
|
||||
exportProjectToJson: () => string;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
jumpToPast: (idx: number) => void;
|
||||
jumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
const initialProject = createProject("Untitled Project");
|
||||
|
||||
function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial<EditorState>) {
|
||||
const past = capHistory([...state.history.past, state.history.present]);
|
||||
const history = { past, present: cloneProject(nextProject), future: [] };
|
||||
return {
|
||||
project: nextProject,
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(nextProject, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
...extras,
|
||||
} satisfies Partial<EditorState>;
|
||||
}
|
||||
|
||||
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
|
||||
const history = makeHistory(project);
|
||||
return {
|
||||
project,
|
||||
selectedLayerId: resolveSelectedLayerId(project, previousSelectedLayerId),
|
||||
history,
|
||||
...computeHistoryFlags(history),
|
||||
clipboardLayer: null,
|
||||
transaction: null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getProjectAssetIds(project: Project): string[] {
|
||||
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
project: initialProject,
|
||||
selectedLayerId: null,
|
||||
tool: "pointer",
|
||||
history: makeHistory(initialProject),
|
||||
...computeHistoryFlags(makeHistory(initialProject)),
|
||||
clipboardLayer: null,
|
||||
isDirty: false,
|
||||
transaction: null,
|
||||
tools: EDITOR_TOOLS,
|
||||
|
||||
startTransaction: () =>
|
||||
set((state) => {
|
||||
if (state.transaction) return state;
|
||||
return {
|
||||
transaction: {
|
||||
baselineProject: cloneProject(state.project),
|
||||
baselineSelectedLayerId: state.selectedLayerId,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
commitTransaction: () =>
|
||||
set((state) => {
|
||||
if (!state.transaction) return state;
|
||||
if (!hasProjectChanged(state.transaction.baselineProject, state.project)) {
|
||||
return { transaction: null };
|
||||
}
|
||||
|
||||
const past = capHistory([...state.history.past, cloneProject(state.transaction.baselineProject)]);
|
||||
const history = { past, present: cloneProject(state.project), future: [] };
|
||||
return {
|
||||
history,
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
cancelTransaction: () =>
|
||||
set((state) => {
|
||||
if (!state.transaction) return state;
|
||||
return {
|
||||
project: cloneProject(state.transaction.baselineProject),
|
||||
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
|
||||
transaction: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyProjectDraft: (project) => set(() => ({ project })),
|
||||
|
||||
setTool: (tool) => {
|
||||
if (!(tool in EDITOR_TOOLS)) return;
|
||||
set({ tool });
|
||||
},
|
||||
|
||||
addLayerByType: (type) =>
|
||||
set((state) => {
|
||||
const layer = createLayer(type);
|
||||
const nextProject = addLayer(state.project, layer);
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
||||
}),
|
||||
|
||||
setSelectedLayerPosition: (x, y) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
if (committed && committed.x === x && committed.y === y) return state;
|
||||
const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setSelectedLayerPositionDraft: (x, y) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
|
||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
if (current && current.x === x && current.y === y) return state;
|
||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { x, y }) };
|
||||
}),
|
||||
|
||||
setSelectedLayerSize: (width, height) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
if (committed && committed.width === width && committed.height === height) return state;
|
||||
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { width, height }));
|
||||
}),
|
||||
|
||||
setSelectedLayerSizeDraft: (width, height) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
|
||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
|
||||
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
||||
|
||||
if (current) {
|
||||
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined);
|
||||
|
||||
if (
|
||||
typeof currentWidth === "number" &&
|
||||
typeof currentHeight === "number" &&
|
||||
Math.abs(currentWidth - nextWidth) < DRAFT_TRANSFORM_EPSILON &&
|
||||
Math.abs(currentHeight - nextHeight) < DRAFT_TRANSFORM_EPSILON
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
|
||||
}),
|
||||
|
||||
removeSelectedLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
moveSelectedLayerOrder: (direction) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId);
|
||||
if (idx < 0) return state;
|
||||
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
|
||||
return withCommittedProject(state, reorderLayer(state.project, state.selectedLayerId, nextIndex));
|
||||
}),
|
||||
|
||||
selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })),
|
||||
|
||||
setSelectedLayerRotation: (rotation) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
if (committed && committed.rotation === rotation) return state;
|
||||
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { rotation }));
|
||||
}),
|
||||
|
||||
setSelectedLayerRotationDraft: (rotation) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
||||
if (current && current.rotation === rotation) return state;
|
||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { rotation }) };
|
||||
}),
|
||||
|
||||
copySelectedLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
|
||||
if (!layer) return state;
|
||||
return { clipboardLayer: cloneLayer(layer) };
|
||||
}),
|
||||
|
||||
cutSelectedLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
|
||||
if (!layer) return state;
|
||||
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
||||
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
||||
}),
|
||||
|
||||
pasteLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.clipboardLayer) return state;
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
const nextProject = addLayer(state.project, pasted);
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: pasted.id });
|
||||
}),
|
||||
|
||||
resetProject: () => {
|
||||
releaseProjectObjectUrls(get().project);
|
||||
const project = createProject("Untitled Project");
|
||||
const history = makeHistory(project);
|
||||
set({
|
||||
project,
|
||||
selectedLayerId: null,
|
||||
history,
|
||||
...computeHistoryFlags(history),
|
||||
clipboardLayer: null,
|
||||
transaction: null,
|
||||
isDirty: false,
|
||||
});
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
await upsertProject(normalizeProject(get().project));
|
||||
set({ isDirty: false });
|
||||
},
|
||||
|
||||
loadProjectById: async (projectId) => {
|
||||
const project = await getProjectById(projectId);
|
||||
if (!project) return false;
|
||||
const normalized = normalizeProject(project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
return true;
|
||||
},
|
||||
|
||||
setProject: (project) => {
|
||||
const normalized = normalizeProject(project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
},
|
||||
|
||||
importProjectFromJson: (raw) => {
|
||||
const parsed = parseProjectFile(raw);
|
||||
if (!parsed.ok) return { ok: false, error: parsed.error };
|
||||
const normalized = normalizeProject(parsed.project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
exportProjectToJson: () => {
|
||||
const project = normalizeProject(get().project);
|
||||
const history = get().history;
|
||||
return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length });
|
||||
},
|
||||
|
||||
importImageFromFile: async (file) => {
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () =>
|
||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("image", {
|
||||
name,
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(imageSize.width)),
|
||||
height: Math.max(1, Math.round(imageSize.height)),
|
||||
});
|
||||
|
||||
set((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id }));
|
||||
},
|
||||
|
||||
updateImageLayerSource: (layerId, sourceUri) =>
|
||||
set((state) => {
|
||||
if (!sourceUri) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
if (layer.sourceUri === sourceUri) return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setImageLayerFaceBlur: (layerId, faceBlur) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
|
||||
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
if (state.history.past.length === 0) return state;
|
||||
const past = [...state.history.past];
|
||||
const previous = past.pop();
|
||||
if (!previous) return state;
|
||||
const future = [state.history.present, ...state.history.future];
|
||||
const history = { past, present: cloneProject(previous), future };
|
||||
return {
|
||||
project: cloneProject(previous),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
if (state.history.future.length === 0) return state;
|
||||
const future = [...state.history.future];
|
||||
const next = future.shift();
|
||||
if (!next) return state;
|
||||
const past = capHistory([...state.history.past, state.history.present]);
|
||||
const history = { past, present: cloneProject(next), future };
|
||||
return {
|
||||
project: cloneProject(next),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
jumpToPast: (idx) =>
|
||||
set((state) => {
|
||||
const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1));
|
||||
if (state.history.past.length === targetPastLength) return state;
|
||||
const moved = state.history.past.slice(targetPastLength);
|
||||
if (moved.length === 0) return state;
|
||||
const previous = moved[0];
|
||||
if (!previous) return state;
|
||||
const past = state.history.past.slice(0, targetPastLength);
|
||||
const future = [state.history.present, ...moved.slice(1), ...state.history.future];
|
||||
const history = { past, present: cloneProject(previous), future };
|
||||
return {
|
||||
project: cloneProject(previous),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
jumpToFuture: (idx) =>
|
||||
set((state) => {
|
||||
const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx));
|
||||
if (state.history.future.length === targetFutureLength) return state;
|
||||
const redoCount = state.history.future.length - targetFutureLength;
|
||||
const next = state.history.future[redoCount - 1];
|
||||
if (!next) return state;
|
||||
const consumedFuture = state.history.future.slice(0, redoCount - 1);
|
||||
const future = state.history.future.slice(redoCount);
|
||||
const past = capHistory([...state.history.past, state.history.present, ...consumedFuture]);
|
||||
const history = { past, present: cloneProject(next), future };
|
||||
return {
|
||||
project: cloneProject(next),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useUiStore } from "./ui-store";
|
||||
|
||||
describe("ui store", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
useUiStore.setState({ theme: "dark", locale: "en" });
|
||||
});
|
||||
|
||||
it("hydrates from local storage", () => {
|
||||
window.localStorage.setItem("pien.ui.theme", "light");
|
||||
window.localStorage.setItem("pien.ui.locale", "ja");
|
||||
useUiStore.getState().hydrate();
|
||||
expect(useUiStore.getState().theme).toBe("light");
|
||||
expect(useUiStore.getState().locale).toBe("ja");
|
||||
});
|
||||
|
||||
it("updates and persists theme", () => {
|
||||
useUiStore.getState().setTheme("light");
|
||||
expect(useUiStore.getState().theme).toBe("light");
|
||||
expect(window.localStorage.getItem("pien.ui.theme")).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("updates and persists locale", () => {
|
||||
useUiStore.getState().setLocale("th");
|
||||
expect(useUiStore.getState().locale).toBe("th");
|
||||
expect(window.localStorage.getItem("pien.ui.locale")).toBe("th");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type AppTheme = "light" | "dark";
|
||||
export type AppLocale = "en" | "th" | "ja";
|
||||
|
||||
const THEME_KEY = "pien.ui.theme";
|
||||
const LOCALE_KEY = "pien.ui.locale";
|
||||
|
||||
function readTheme(): AppTheme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const value = window.localStorage.getItem(THEME_KEY);
|
||||
return value === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function readLocale(): AppLocale {
|
||||
if (typeof window === "undefined") return "en";
|
||||
const value = window.localStorage.getItem(LOCALE_KEY);
|
||||
if (value === "th" || value === "ja" || value === "en") return value;
|
||||
return "en";
|
||||
}
|
||||
|
||||
function applyTheme(theme: AppTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
type UiState = {
|
||||
theme: AppTheme;
|
||||
locale: AppLocale;
|
||||
hydrate: () => void;
|
||||
setTheme: (theme: AppTheme) => void;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
};
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
theme: "light",
|
||||
locale: "en",
|
||||
hydrate: () => {
|
||||
const theme = readTheme();
|
||||
const locale = readLocale();
|
||||
applyTheme(theme);
|
||||
set((state) => {
|
||||
if (state.theme === theme && state.locale === locale) return state;
|
||||
return { theme, locale };
|
||||
});
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
}
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
setLocale: (locale) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(LOCALE_KEY, locale);
|
||||
}
|
||||
set({ locale });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
cream: "var(--color-cream)",
|
||||
berry: "var(--color-berry)",
|
||||
wine: "var(--color-wine)",
|
||||
mint: "var(--color-mint)",
|
||||
ink: "var(--color-ink)",
|
||||
accent: "var(--color-accent)",
|
||||
"accent-strong": "var(--color-accent-strong)",
|
||||
surface: "var(--color-surface)",
|
||||
"surface-2": "var(--color-surface-2)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["'M PLUS Rounded 1c'", "'Noto Sans Thai'", "sans-serif"],
|
||||
body: ["'Zen Kaku Gothic New'", "'Noto Sans Thai'", "sans-serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
float: "0 20px 55px rgba(48, 13, 22, 0.18)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["**/*.test.ts", "**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user