feat: initial app

This commit is contained in:
2026-05-10 03:06:54 +07:00
parent 23be44dbe0
commit d5b92cc9a4
95 changed files with 8104 additions and 0 deletions
+11
View File
@@ -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();
}, []);
}
+497
View File
@@ -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,
};
}
+30
View File
@@ -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");
});
});
+56
View File
@@ -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 };
}
+22
View File
@@ -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 };
}
+52
View File
@@ -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]);
}
+49
View File
@@ -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();
});
});
+132
View File
@@ -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,
};
}
+134
View File
@@ -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 };
}
+42
View File
@@ -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 };
}