feat: transparency, clipboard paste, face detection optimization

This commit is contained in:
2026-05-16 11:53:57 +07:00
parent 80744f40ba
commit b13d70f2db
16 changed files with 1186 additions and 215 deletions
@@ -0,0 +1,63 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CanvasRenderer } from "./canvas-renderer";
import type { Layer } from "@pien-studio/types";
vi.mock("next/image", () => ({
default: ({ alt, src, unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => React.createElement("img", { alt, src, ...props }),
}));
vi.mock("../hooks/use-translations", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
class ResizeObserverMock {
observe() {}
disconnect() {}
}
describe("CanvasRenderer", () => {
beforeEach(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: vi.fn() });
});
it("keeps the rotation handle interactive", () => {
const layer: Layer = {
id: "layer-1",
type: "image",
sourceUri: "data:image/png;base64,test",
x: 0,
y: 0,
width: 100,
height: 100,
scale: 1,
rotation: 0,
opacity: 1,
};
const onRotateLayer = vi.fn();
render(
<CanvasRenderer
layers={[layer]}
canvasWidth={200}
canvasHeight={200}
selectedLayerId={layer.id}
onSelectLayer={() => undefined}
onMoveLayer={() => undefined}
onResizeLayer={() => undefined}
onRotateLayer={onRotateLayer}
isDark={false}
/>,
);
const rotateHandle = screen.getByTitle("editor.rotate");
expect(rotateHandle).toHaveClass("pointer-events-auto");
fireEvent(rotateHandle, new MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 50, clientY: 0 }));
fireEvent(rotateHandle, new MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 50 }));
expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90);
});
});
+132 -30
View File
@@ -3,9 +3,12 @@
import React from "react";
import NextImage from "next/image";
import { RotateCw } from "lucide-react";
import { CANVAS_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE } from "../lib/editor-constants";
import {
CANVAS_HANDLE_BASE_SIZE,
CANVAS_ROTATE_HANDLE_BASE_SIZE,
} from "../lib/editor-constants";
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
import { renderFaceBlurRegions } from "../lib/face-blur-renderer";
import { renderImageWithFaceBlur } from "../lib/face-blur-renderer";
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
import { useTranslations } from "../hooks/use-translations";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
@@ -27,7 +30,13 @@ interface CanvasRendererProps {
onContextMenu?: (x: number, y: number) => void;
isDark: boolean;
tool?: "pointer" | "hand" | "face";
faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[];
faceDetections?: {
x: number;
y: number;
width: number;
height: number;
label?: string;
}[];
faceOverlayLayerId?: string | null;
faceBlurPreview?: {
layerId: string;
@@ -49,7 +58,13 @@ function BlurredImageLayer({
faceBlurOverride?: {
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number; censorColor?: string }[];
regions: {
x: number;
y: number;
width: number;
height: number;
censorColor?: string;
}[];
censorColor?: string;
} | null;
}) {
@@ -65,10 +80,8 @@ function BlurredImageLayer({
const cw = canvas.width;
const ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(image, 0, 0, cw, ch);
const blur = faceBlurOverride ?? layer.faceBlur;
if (!blur || blur.regions.length === 0) return;
renderFaceBlurRegions(ctx, image, blur, cw, ch);
renderImageWithFaceBlur(ctx, image, blur, cw, ch);
}, [faceBlurOverride, layer.faceBlur]);
React.useEffect(() => {
@@ -91,7 +104,14 @@ function BlurredImageLayer({
draw();
}, [draw, width, height]);
return <canvas ref={canvasRef} width={Math.max(1, Math.round(width))} height={Math.max(1, Math.round(height))} className="pointer-events-none h-full w-full rounded object-cover" />;
return (
<canvas
ref={canvasRef}
width={Math.max(1, Math.round(width))}
height={Math.max(1, Math.round(height))}
className="pointer-events-none h-full w-full rounded object-cover"
/>
);
}
export function CanvasRenderer({
@@ -147,15 +167,35 @@ export function CanvasRenderer({
});
const faceLabelOverlays = React.useMemo(() => {
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return [];
return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport);
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]);
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0)
return [];
return buildFaceLabelOverlays(
layers,
faceOverlayLayerId,
faceDetections,
viewport,
);
}, [
faceDetections,
faceOverlayLayerId,
layers,
tool,
viewport.scale,
viewport.x,
viewport.y,
]);
return (
<div
ref={containerRef}
className="relative overflow-hidden"
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
style={{
width: "100%",
height: "100%",
touchAction: "none",
userSelect: "none",
cursor: tool === "hand" || isSpacePan ? "grab" : "default",
}}
onPointerDown={(e) => {
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
onContainerPointerDown(e);
@@ -183,18 +223,26 @@ export function CanvasRenderer({
willChange: "transform",
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
transformOrigin: "0 0",
boxShadow: "0 0 0 1px var(--color-accent-strong)",
background: isDark ? "#17181b" : "#ffffff",
outline: `${2 / viewport.scale}px solid var(--color-accent-strong)`,
background:
"linear-gradient(45deg, #d0d0d0 25%, transparent 25%), linear-gradient(-45deg, #d0d0d0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d0d0d0 75%), linear-gradient(-45deg, transparent 75%, #d0d0d0 75%)",
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
}}
>
{layers.map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isImage = layer.type === "image";
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
const layerWidth =
layer.width ??
(isImage ? Math.round(200 * layer.scale) : undefined);
const layerHeight =
layer.height ??
(isImage ? Math.round(150 * layer.scale) : undefined);
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
const handleSizePx = `${handleSize}px`;
const largeHandleSize = CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
const largeHandleSize =
CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
const largeHandleSizePx = `${largeHandleSize}px`;
return (
@@ -209,8 +257,12 @@ export function CanvasRenderer({
transform: `rotate(${layer.rotation}deg)`,
opacity: layer.opacity,
cursor: "move",
border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent",
outline: isSelected ? "2px solid var(--color-accent-strong)" : "none",
border: isSelected
? "2px solid var(--color-accent-strong)"
: "1px dashed transparent",
outline: isSelected
? "2px solid var(--color-accent-strong)"
: "none",
outlineOffset: "2px",
zIndex: idx,
}}
@@ -218,13 +270,19 @@ export function CanvasRenderer({
onClick={() => onSelectLayer(layer.id)}
>
{isImage && layer.sourceUri ? (
(faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) ||
(faceBlurPreview &&
faceBlurPreview.layerId === layer.id &&
faceBlurPreview.regions.length > 0) ||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
<BlurredImageLayer
layer={layer}
width={layerWidth ?? 1}
height={layerHeight ?? 1}
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
faceBlurOverride={
faceBlurPreview && faceBlurPreview.layerId === layer.id
? faceBlurPreview
: null
}
/>
) : (
<NextImage
@@ -271,41 +329,85 @@ export function CanvasRenderer({
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, left: -handleSize / 2 }}
style={{
width: handleSizePx,
height: handleSizePx,
top: -handleSize / 2,
left: -handleSize / 2,
}}
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, right: -handleSize / 2 }}
style={{
width: handleSizePx,
height: handleSizePx,
top: -handleSize / 2,
right: -handleSize / 2,
}}
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, left: -handleSize / 2 }}
style={{
width: handleSizePx,
height: handleSizePx,
bottom: -handleSize / 2,
left: -handleSize / 2,
}}
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, right: -handleSize / 2 }}
style={{
width: handleSizePx,
height: handleSizePx,
bottom: -handleSize / 2,
right: -handleSize / 2,
}}
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
title={t("editor.resize")}
/>
<div className="absolute pointer-events-none" style={{ top: -largeHandleSize, left: "50%", transform: "translateX(-50%)", height: largeHandleSize }}>
<div className="w-px bg-[var(--color-accent-strong)]" style={{ width: "1px", height: "100%", marginLeft: "0px" }} />
<div
className="absolute pointer-events-none"
style={{
top: -largeHandleSize,
left: "50%",
transform: "translateX(-50%)",
height: largeHandleSize,
}}
>
<div
className="w-px bg-[var(--color-accent-strong)]"
style={{
width: "1px",
height: "100%",
marginLeft: "0px",
}}
/>
<button
type="button"
className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
style={{ width: largeHandleSizePx, height: largeHandleSizePx }}
className="pointer-events-auto absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
style={{
width: largeHandleSizePx,
height: largeHandleSizePx,
}}
onPointerDown={(e) => onRotateHandleDown(e, layer)}
title={t("editor.rotate")}
>
<RotateCw className="text-white" style={{ width: handleSize * 0.6, height: handleSize * 0.6 }} />
<RotateCw
className="text-white"
style={{
width: handleSize * 0.6,
height: handleSize * 0.6,
}}
/>
</button>
</div>
</>
+1 -1
View File
@@ -176,12 +176,12 @@ function FileMenu({
}) {
return (
<div className="space-y-1">
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
</div>
);