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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user