mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
♻️ refactor!: massive refactor
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { startAssetCleanupJob } from "@pien-studio/storage";
|
||||
import { localProjectRepository } from "../lib/project-repository";
|
||||
|
||||
export function useAssetCleanupJob() {
|
||||
React.useEffect(() => {
|
||||
const stop = startAssetCleanupJob();
|
||||
return () => stop();
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const id = window.setInterval(() => {
|
||||
void localProjectRepository.cleanupAssets();
|
||||
}, 45_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,13 @@ type UseCanvasInteractionsOptions = {
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
||||
onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void;
|
||||
onBrushStrokeStart?: (
|
||||
layerId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
layerWidth: number,
|
||||
layerHeight: number,
|
||||
) => void;
|
||||
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
|
||||
onBrushStrokeEnd?: (layerId: string) => void;
|
||||
};
|
||||
@@ -53,7 +59,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onBrushStrokeMove,
|
||||
onBrushStrokeEnd,
|
||||
} = options;
|
||||
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
|
||||
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 });
|
||||
@@ -64,7 +74,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 resizeMovePendingRef = React.useRef<{
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const dragRef = React.useRef<{
|
||||
id: string;
|
||||
startLayerX: number;
|
||||
@@ -95,7 +110,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
startRotation: number;
|
||||
lastRotation: number;
|
||||
} | null>(null);
|
||||
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
|
||||
const brushRef = React.useRef<{
|
||||
id: string;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
rect: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
const pinchRef = React.useRef<{
|
||||
active: boolean;
|
||||
@@ -119,7 +139,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onInteractionEnd?.();
|
||||
}
|
||||
|
||||
function clientDist(t0: Pick<Touch, "clientX" | "clientY">, t1: Pick<Touch, "clientX" | "clientY">) {
|
||||
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);
|
||||
@@ -130,12 +153,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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;
|
||||
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 nextScale = Math.max(
|
||||
MIN_SCALE,
|
||||
Math.min(MAX_SCALE, vp.scale * factor),
|
||||
);
|
||||
const scaleChange = nextScale / vp.scale;
|
||||
return {
|
||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||
@@ -147,7 +174,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
|
||||
function zoomBy(factor: number, pivotX: number, pivotY: number) {
|
||||
setViewport((vp) => {
|
||||
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
|
||||
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,
|
||||
@@ -193,13 +223,21 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 (
|
||||
!(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;
|
||||
if (
|
||||
!(e.target instanceof HTMLElement) ||
|
||||
/^(input|textarea|select)$/i.test(e.target.tagName)
|
||||
)
|
||||
return;
|
||||
setIsShiftPressed(true);
|
||||
return;
|
||||
}
|
||||
@@ -231,11 +269,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
|
||||
window.addEventListener("wheel", handleWheelCaptured, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
|
||||
window.removeEventListener("wheel", handleWheelCaptured, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [isSpacePan]);
|
||||
|
||||
@@ -281,7 +324,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
}
|
||||
const toolDef = getToolDefinition(tool);
|
||||
if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) {
|
||||
const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
|
||||
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;
|
||||
@@ -312,7 +356,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||
}
|
||||
|
||||
if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) {
|
||||
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);
|
||||
@@ -321,8 +371,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth;
|
||||
if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight;
|
||||
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;
|
||||
@@ -338,7 +390,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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) {
|
||||
if (
|
||||
(pending.x !== resizeRef.current.startLayerX ||
|
||||
pending.y !== resizeRef.current.startLayerY) &&
|
||||
onMoveLayer
|
||||
) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
});
|
||||
@@ -349,14 +405,19 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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;
|
||||
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);
|
||||
onMoveLayer(
|
||||
dragRef.current.id,
|
||||
dragRef.current.lastX,
|
||||
dragRef.current.lastY,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,7 +433,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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) {
|
||||
if (
|
||||
(pending.x !== resizeRef.current.startLayerX ||
|
||||
pending.y !== resizeRef.current.startLayerY) &&
|
||||
onMoveLayer
|
||||
) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
}
|
||||
@@ -381,11 +446,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 (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 (typeof lastWidth === "number" && typeof lastHeight === "number")
|
||||
onResizeLayerEnd(id, lastWidth, lastHeight);
|
||||
}
|
||||
if (rotateRef.current && onRotateLayerEnd) {
|
||||
const { id, lastRotation } = rotateRef.current;
|
||||
@@ -402,7 +469,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
endInteraction();
|
||||
}
|
||||
|
||||
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
|
||||
function onLayerPointerDown(
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
layer: Layer,
|
||||
) {
|
||||
if (isSpacePan) return;
|
||||
e.stopPropagation();
|
||||
const toolDef = getToolDefinition(tool);
|
||||
@@ -413,9 +483,14 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onSelectLayer(layer.id);
|
||||
if (tool === "brush" && onBrushStrokeStart) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
brushRef.current = { id: layer.id, lastX: localX, lastY: localY, rect: e.currentTarget.getBoundingClientRect() };
|
||||
const lw = layer.width ?? Math.round(200 * layer.scale);
|
||||
const lh = layer.height ?? Math.round(150 * layer.scale);
|
||||
brushRef.current = {
|
||||
id: layer.id,
|
||||
lastX: localX,
|
||||
lastY: localY,
|
||||
rect: e.currentTarget.getBoundingClientRect(),
|
||||
};
|
||||
const lw = layer.width;
|
||||
const lh = layer.height;
|
||||
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
|
||||
beginInteraction();
|
||||
} else if (onFillLayer) {
|
||||
@@ -437,13 +512,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
|
||||
function onResizeHandleDown(
|
||||
e: React.PointerEvent<HTMLButtonElement>,
|
||||
layer: Layer,
|
||||
corner: string,
|
||||
) {
|
||||
if (!getToolDefinition(tool)?.allowsLayerResize || !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);
|
||||
const width = layer.width;
|
||||
const height = layer.height;
|
||||
resizeRef.current = {
|
||||
id: layer.id,
|
||||
startWidth: width,
|
||||
@@ -461,16 +540,27 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
|
||||
function onRotateHandleDown(
|
||||
e: React.PointerEvent<HTMLButtonElement>,
|
||||
layer: Layer,
|
||||
) {
|
||||
if (!getToolDefinition(tool)?.allowsLayerRotate || !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 width = layer.width;
|
||||
const height = layer.height;
|
||||
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,
|
||||
@@ -488,7 +578,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 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,
|
||||
@@ -507,7 +598,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 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({
|
||||
|
||||
@@ -11,7 +11,9 @@ describe("useEditorBindings", () => {
|
||||
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(result.current.state.selectedLayer?.id).toBe(
|
||||
result.current.state.selectedLayerId,
|
||||
);
|
||||
expect(typeof result.current.actions.undo).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,10 @@ export function useEditorBindings() {
|
||||
);
|
||||
|
||||
const selectedLayer = React.useMemo(
|
||||
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
|
||||
() =>
|
||||
state.project.layers.find(
|
||||
(layer) => layer.id === state.selectedLayerId,
|
||||
) ?? null,
|
||||
[state.project.layers, state.selectedLayerId],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,14 +4,22 @@ 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 [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 {
|
||||
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)) });
|
||||
setContextMenu({
|
||||
x: Math.max(pad, Math.min(x, maxX)),
|
||||
y: Math.max(pad, Math.min(y, maxY)),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = React.useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
type Translator = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function useEditorLabels(t: Translator) {
|
||||
const headerLabels = React.useMemo(
|
||||
@@ -29,7 +32,11 @@ export function useEditorLabels(t: Translator) {
|
||||
);
|
||||
|
||||
const contextMenuLabels = React.useMemo(
|
||||
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
|
||||
() => ({
|
||||
copy: t("editor.copy"),
|
||||
cut: t("editor.cut"),
|
||||
paste: t("editor.paste"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -38,7 +45,8 @@ export function useEditorLabels(t: Translator) {
|
||||
resize: t("editor.resize"),
|
||||
faceMlFailedShort: t("editor.faceMlFailedShort"),
|
||||
detectingFacesShort: t("editor.detectingFacesShort"),
|
||||
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
|
||||
faceDetectionTip: (count: number) =>
|
||||
t("editor.faceDetectionTip", { count }),
|
||||
import: t("editor.import"),
|
||||
mood: t("editor.mood"),
|
||||
quick: t("editor.quick"),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from "react";
|
||||
import { createProject } from "@pien-studio/editor-core";
|
||||
import { upsertProject } from "@pien-studio/storage";
|
||||
import { localProjectRepository } from "../lib/project-repository";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
type Translator = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type UseEditorProjectLifecycleOptions = {
|
||||
projectId: string;
|
||||
@@ -12,7 +15,9 @@ type UseEditorProjectLifecycleOptions = {
|
||||
t: Translator;
|
||||
};
|
||||
|
||||
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
|
||||
export function useEditorProjectLifecycle(
|
||||
options: UseEditorProjectLifecycleOptions,
|
||||
) {
|
||||
const { projectId, hydrate, loadProjectById, setProject, t } = options;
|
||||
const initializedProjectId = React.useRef<string | null>(null);
|
||||
|
||||
@@ -27,7 +32,7 @@ export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOpti
|
||||
if (projectId === "new") {
|
||||
const nextProject = createProject(t("home.untitledProject"));
|
||||
setProject(nextProject);
|
||||
void upsertProject(nextProject);
|
||||
void localProjectRepository.upsertProject(nextProject);
|
||||
return;
|
||||
}
|
||||
void loadProjectById(projectId);
|
||||
|
||||
@@ -11,7 +11,10 @@ type UseEditorShortcutsOptions = {
|
||||
};
|
||||
|
||||
function isEditableTarget(e: Event) {
|
||||
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
|
||||
return (
|
||||
e.target instanceof HTMLElement &&
|
||||
/^(input|textarea|select)$/i.test(e.target.tagName)
|
||||
);
|
||||
}
|
||||
|
||||
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||
|
||||
@@ -7,16 +7,18 @@ function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "raster",
|
||||
sourceUri: "data:image/png;base64,abc",
|
||||
asset: { kind: "inline", uri: "data:image/png;base64,abc" },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
...overrides,
|
||||
};
|
||||
} as Layer;
|
||||
}
|
||||
|
||||
describe("useFaceBlurWorkflow", () => {
|
||||
@@ -25,8 +27,24 @@ describe("useFaceBlurWorkflow", () => {
|
||||
const removeLayerEffect = 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 },
|
||||
{
|
||||
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(() =>
|
||||
@@ -41,8 +59,14 @@ describe("useFaceBlurWorkflow", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
|
||||
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2);
|
||||
const faceBlurEffect = result.current.faceBlurPreview?.effects.find(
|
||||
(e) => e.kind === "face-blur",
|
||||
);
|
||||
expect(
|
||||
faceBlurEffect?.kind === "face-blur"
|
||||
? faceBlurEffect.regions
|
||||
: undefined,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +86,17 @@ describe("useFaceBlurWorkflow", () => {
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
|
||||
faceDetections: [
|
||||
{
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 10,
|
||||
height: 12,
|
||||
label: "a",
|
||||
sourceWidth: 100,
|
||||
sourceHeight: 100,
|
||||
},
|
||||
],
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
}),
|
||||
@@ -79,8 +113,24 @@ describe("useFaceBlurWorkflow", () => {
|
||||
const removeLayerEffect = 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 },
|
||||
{
|
||||
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(() =>
|
||||
@@ -115,7 +165,15 @@ describe("useFaceBlurWorkflow", () => {
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: expect.arrayContaining([
|
||||
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
|
||||
expect.objectContaining({
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 10,
|
||||
height: 12,
|
||||
censorColor: "#111111",
|
||||
sourceWidth: 100,
|
||||
sourceHeight: 100,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
|
||||
import {
|
||||
getLayerRuntimeSource,
|
||||
type FaceBlurMethod,
|
||||
type Layer,
|
||||
type LayerEffect,
|
||||
} from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "./use-face-detection";
|
||||
|
||||
type FaceBlurPreview = {
|
||||
@@ -21,15 +26,31 @@ type UseFaceBlurWorkflowOptions = {
|
||||
};
|
||||
|
||||
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
|
||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
||||
const {
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId,
|
||||
faceDetections,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
} = options;
|
||||
const [blurMethod, setBlurMethod] =
|
||||
React.useState<FaceBlurMethod>("gaussian");
|
||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
|
||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
|
||||
const [faceSelection, setFaceSelection] =
|
||||
React.useState<FaceSelectionState | null>(null);
|
||||
const hasDetectableSelection = Boolean(
|
||||
selectedLayer &&
|
||||
selectedLayer.type === "raster" &&
|
||||
faceDetectionsLayerId === selectedLayer.id,
|
||||
);
|
||||
|
||||
const faceBlurEffect = React.useMemo(
|
||||
() => selectedLayer?.effects.find((e): e is Extract<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
|
||||
() =>
|
||||
selectedLayer?.effects.find(
|
||||
(e): e is Extract<LayerEffect, { kind: "face-blur" }> =>
|
||||
e.kind === "face-blur",
|
||||
) ?? null,
|
||||
[selectedLayer?.effects],
|
||||
);
|
||||
|
||||
@@ -40,18 +61,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
return faceDetections.map((_, index) => index);
|
||||
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
|
||||
|
||||
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
|
||||
const selectedFaceIndices =
|
||||
faceSelection?.key === selectionKey
|
||||
? faceSelection.indices
|
||||
: defaultSelectedFaceIndices;
|
||||
|
||||
const buildBlurRegions = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) 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 baseWidth = Math.max(1, selectedLayer.width);
|
||||
const baseHeight = Math.max(1, selectedLayer.height);
|
||||
const scaleX = face.sourceWidth / baseWidth;
|
||||
const scaleY = face.sourceHeight / baseHeight;
|
||||
return {
|
||||
@@ -68,13 +96,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
|
||||
);
|
||||
|
||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
||||
setFaceSelection((prev) => {
|
||||
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
|
||||
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
|
||||
return { key: selectionKey, indices };
|
||||
});
|
||||
}, [defaultSelectedFaceIndices, selectionKey]);
|
||||
const toggleFaceIndex = React.useCallback(
|
||||
(index: number) => {
|
||||
setFaceSelection((prev) => {
|
||||
const current =
|
||||
prev?.key === selectionKey
|
||||
? prev.indices
|
||||
: defaultSelectedFaceIndices;
|
||||
const indices = current.includes(index)
|
||||
? current.filter((item) => item !== index)
|
||||
: [...current, index];
|
||||
return { key: selectionKey, indices };
|
||||
});
|
||||
},
|
||||
[defaultSelectedFaceIndices, selectionKey],
|
||||
);
|
||||
|
||||
const clearBlur = React.useCallback(() => {
|
||||
if (!selectedLayer) return;
|
||||
@@ -84,8 +120,17 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
|
||||
const blurFaces = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
|
||||
if (
|
||||
!selectedLayer ||
|
||||
selectedLayer.type !== "raster" ||
|
||||
!getLayerRuntimeSource(selectedLayer)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
faceDetectionsLayerId !== selectedLayer.id ||
|
||||
faceDetections.length === 0
|
||||
)
|
||||
return;
|
||||
const regions = buildBlurRegions(indices);
|
||||
setLayerEffect(selectedLayer.id, {
|
||||
kind: "face-blur",
|
||||
@@ -97,11 +142,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
});
|
||||
setFaceSelection({ key: selectionKey, indices: [] });
|
||||
},
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
|
||||
[
|
||||
blurAmount,
|
||||
blurMethod,
|
||||
buildBlurRegions,
|
||||
censorColor,
|
||||
faceDetections.length,
|
||||
faceDetectionsLayerId,
|
||||
selectedLayer,
|
||||
selectionKey,
|
||||
setLayerEffect,
|
||||
],
|
||||
);
|
||||
|
||||
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
||||
if (
|
||||
!hasDetectableSelection ||
|
||||
!selectedLayer ||
|
||||
selectedLayer.type !== "raster"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -111,16 +170,26 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
|
||||
return {
|
||||
layerId: selectedLayer.id,
|
||||
effects: [{
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions: buildBlurRegions(selectedFaceIndices),
|
||||
censorColor,
|
||||
}],
|
||||
effects: [
|
||||
{
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions: buildBlurRegions(selectedFaceIndices),
|
||||
censorColor,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
||||
}, [
|
||||
blurAmount,
|
||||
blurMethod,
|
||||
buildBlurRegions,
|
||||
censorColor,
|
||||
hasDetectableSelection,
|
||||
selectedFaceIndices,
|
||||
selectedLayer,
|
||||
]);
|
||||
|
||||
return {
|
||||
blurMethod,
|
||||
|
||||
@@ -26,8 +26,8 @@ export type FacePreview = {
|
||||
type SelectedImageLayer = {
|
||||
id: string;
|
||||
sourceUri: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type UseFaceDetectionOptions = {
|
||||
@@ -37,11 +37,7 @@ type UseFaceDetectionOptions = {
|
||||
};
|
||||
|
||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
const {
|
||||
tool,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected,
|
||||
} = options;
|
||||
const { tool, selectedImageLayer, activeLayerStillSelected } = options;
|
||||
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
||||
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
||||
const selectedImageWidth = selectedImageLayer?.width;
|
||||
|
||||
@@ -28,7 +28,10 @@ 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 {
|
||||
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]) => {
|
||||
@@ -39,4 +42,4 @@ export function useTranslations() {
|
||||
}
|
||||
|
||||
return { t, locale };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user