mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: layer effects, bucket fill, simple brush
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react";
|
||||
import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react";
|
||||
import { useEditorStore } from "../../../store/editor-store";
|
||||
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
|
||||
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
||||
@@ -12,6 +12,8 @@ import { EditorMobileSection } from "../../../components/editor/editor-mobile-se
|
||||
import { EditorSidebar } from "../../../components/editor/editor-sidebar";
|
||||
import { ToolRail } from "../../../components/editor/tool-rail";
|
||||
import { exportProjectAsPng } from "../../../lib/export-png";
|
||||
import { floodFillDataUrl } from "../../../lib/flood-fill";
|
||||
import { commitStroke, type BrushStroke } from "../../../lib/brush-painter";
|
||||
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
|
||||
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
||||
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
||||
@@ -29,6 +31,8 @@ const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
pointer: MousePointer2,
|
||||
hand: Hand,
|
||||
face: ScanFace,
|
||||
fill: PaintBucket,
|
||||
brush: Brush,
|
||||
"add-text": Type,
|
||||
"import-image": ImagePlus,
|
||||
};
|
||||
@@ -64,8 +68,13 @@ export default function EditorPage() {
|
||||
removeSelectedLayer,
|
||||
addLayerByType,
|
||||
moveSelectedLayerOrder,
|
||||
addCanvasSizedLayer,
|
||||
importImageFromFile,
|
||||
setImageLayerFaceBlur,
|
||||
updateImageLayerSource,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
setLayerVisible,
|
||||
setEffectEnabled,
|
||||
setCanvasSize,
|
||||
setTool,
|
||||
undo,
|
||||
@@ -83,11 +92,17 @@ export default function EditorPage() {
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
|
||||
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
||||
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
||||
const [fillColor, setFillColor] = React.useState("#ff0000");
|
||||
const [fillTolerance, setFillTolerance] = React.useState(32);
|
||||
const [brushColor, setBrushColor] = React.useState("#000000");
|
||||
const [brushSize, setBrushSize] = React.useState(20);
|
||||
const [brushOpacity, setBrushOpacity] = React.useState(1);
|
||||
const [brushHardness, setBrushHardness] = React.useState(0.8);
|
||||
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedImageLayer = React.useMemo(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) {
|
||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -126,7 +141,8 @@ export default function EditorPage() {
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId,
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -174,6 +190,8 @@ export default function EditorPage() {
|
||||
|
||||
useEditorShortcuts({
|
||||
onSave: handleSave,
|
||||
onUndo: undo,
|
||||
onRedo: redo,
|
||||
onCopy: copySelectedLayer,
|
||||
onCut: cutSelectedLayer,
|
||||
onPaste: pasteLayer,
|
||||
@@ -204,6 +222,55 @@ export default function EditorPage() {
|
||||
setCanvasSize(width, height);
|
||||
}
|
||||
|
||||
const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
||||
const layerWidth = layer.width ?? Math.round(200 * layer.scale);
|
||||
const layerHeight = layer.height ?? Math.round(150 * layer.scale);
|
||||
// x/y are in layer CSS-pixel space; scale to image pixel space
|
||||
const img = new Image();
|
||||
const uri = layer.sourceUri;
|
||||
const color = fillColor;
|
||||
const tolerance = fillTolerance;
|
||||
img.onload = () => {
|
||||
const scaleX = img.naturalWidth / layerWidth;
|
||||
const scaleY = img.naturalHeight / layerHeight;
|
||||
const pixelX = x * scaleX;
|
||||
const pixelY = y * scaleY;
|
||||
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => {
|
||||
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
|
||||
}).catch(() => {});
|
||||
};
|
||||
img.src = uri;
|
||||
}, [project.layers, fillColor, fillTolerance, updateImageLayerSource]);
|
||||
|
||||
const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
||||
try {
|
||||
const nextUri = await commitStroke(layer.sourceUri, stroke);
|
||||
updateImageLayerSource(layerId, nextUri);
|
||||
} catch {}
|
||||
}, [project.layers, updateImageLayerSource]);
|
||||
|
||||
const handleCreateFillLayer = React.useCallback(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Fill Layer");
|
||||
}, [cw, ch, fillColor, addCanvasSizedLayer]);
|
||||
|
||||
const handleAddLayer = React.useCallback(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer");
|
||||
}, [cw, ch, addCanvasSizedLayer]);
|
||||
|
||||
const toolControllers = React.useMemo(
|
||||
() =>
|
||||
createEditorToolControllers({
|
||||
@@ -213,6 +280,8 @@ export default function EditorPage() {
|
||||
pointer: t("editor.toolPointer"),
|
||||
pan: t("editor.toolPan"),
|
||||
face: t("editor.toolFace"),
|
||||
fill: t("editor.toolFill"),
|
||||
brush: t("editor.toolBrush"),
|
||||
text: t("editor.toolText"),
|
||||
image: t("editor.toolImage"),
|
||||
},
|
||||
@@ -295,11 +364,28 @@ export default function EditorPage() {
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
onFillLayer={handleFillLayer}
|
||||
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
|
||||
onBrushCommit={handleBrushCommit}
|
||||
/>
|
||||
|
||||
<EditorSidebar
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
fillColor={fillColor}
|
||||
fillTolerance={fillTolerance}
|
||||
onSetFillColor={setFillColor}
|
||||
onSetFillTolerance={setFillTolerance}
|
||||
onCreateFillLayer={handleCreateFillLayer}
|
||||
brushColor={brushColor}
|
||||
brushSize={brushSize}
|
||||
brushOpacity={brushOpacity}
|
||||
brushHardness={brushHardness}
|
||||
onSetBrushColor={setBrushColor}
|
||||
onSetBrushSize={setBrushSize}
|
||||
onSetBrushOpacity={setBrushOpacity}
|
||||
onSetBrushHardness={setBrushHardness}
|
||||
onAddLayer={handleAddLayer}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayer={selectedLayer}
|
||||
@@ -314,6 +400,8 @@ export default function EditorPage() {
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
onSelectLayer={selectLayer}
|
||||
onSetLayerVisible={setLayerVisible}
|
||||
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
|
||||
onMoveLayerOrder={moveSelectedLayerOrder}
|
||||
onRemoveSelectedLayer={removeSelectedLayer}
|
||||
onUndo={undo}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function HomePage() {
|
||||
const base = createProject(title, "free");
|
||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
||||
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("image", {
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("raster", {
|
||||
name: file.name,
|
||||
sourceUri,
|
||||
x: 0,
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("CanvasRenderer", () => {
|
||||
it("keeps the rotation handle interactive", () => {
|
||||
const layer: Layer = {
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
type: "raster",
|
||||
sourceUri: "data:image/png;base64,test",
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -35,6 +35,8 @@ describe("CanvasRenderer", () => {
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
};
|
||||
const onRotateLayer = vi.fn();
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
CANVAS_ROTATE_HANDLE_BASE_SIZE,
|
||||
} from "../lib/editor-constants";
|
||||
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
||||
import { renderImageWithFaceBlur } from "../lib/face-blur-renderer";
|
||||
import { renderLayerWithEffects } from "../lib/effects/registry";
|
||||
import { getToolUiDefinition } from "../lib/tools/registry";
|
||||
import { getToolDefinition } from "@pien-studio/editor-core";
|
||||
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import { createStroke, paintSegment, type BrushStroke, type BrushOptions } from "../lib/brush-painter";
|
||||
import type { Layer, LayerEffect } from "@pien-studio/types";
|
||||
|
||||
interface CanvasRendererProps {
|
||||
layers: Layer[];
|
||||
@@ -29,7 +32,10 @@ interface CanvasRendererProps {
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
isDark: boolean;
|
||||
tool?: "pointer" | "hand" | "face";
|
||||
tool?: string;
|
||||
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
||||
brushOptions?: BrushOptions;
|
||||
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
|
||||
faceDetections?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -40,49 +46,57 @@ interface CanvasRendererProps {
|
||||
faceOverlayLayerId?: string | null;
|
||||
faceBlurPreview?: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
effects: LayerEffect[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
function BlurredImageLayer({
|
||||
function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
const ctx = el.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, el.width, el.height);
|
||||
ctx.drawImage(stroke, 0, 0, el.width, el.height);
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={Math.max(1, Math.round(width))}
|
||||
height={Math.max(1, Math.round(height))}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full rounded"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectImageLayer({
|
||||
layer,
|
||||
width,
|
||||
height,
|
||||
faceBlurOverride,
|
||||
effectsOverride,
|
||||
}: {
|
||||
layer: Layer;
|
||||
width: number;
|
||||
height: number;
|
||||
faceBlurOverride?: {
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
censorColor?: string;
|
||||
}[];
|
||||
censorColor?: string;
|
||||
} | null;
|
||||
effectsOverride?: LayerEffect[] | null;
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const activeEffects = effectsOverride ?? layer.effects;
|
||||
|
||||
const draw = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const cw = canvas.width;
|
||||
const ch = canvas.height;
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
const blur = faceBlurOverride ?? layer.faceBlur;
|
||||
renderImageWithFaceBlur(ctx, image, blur, cw, ch);
|
||||
}, [faceBlurOverride, layer.faceBlur]);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height);
|
||||
}, [activeEffects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layer.sourceUri) return;
|
||||
@@ -129,6 +143,9 @@ export function CanvasRenderer({
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onFillLayer,
|
||||
brushOptions,
|
||||
onBrushCommit,
|
||||
isDark,
|
||||
tool = "pointer",
|
||||
faceDetections = [],
|
||||
@@ -136,6 +153,36 @@ export function CanvasRenderer({
|
||||
faceBlurPreview = null,
|
||||
}: CanvasRendererProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const brushStrokeRef = React.useRef<BrushStroke | null>(null);
|
||||
const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const [brushOverlay, setBrushOverlay] = React.useState<{ layerId: string; canvas: HTMLCanvasElement } | null>(null);
|
||||
|
||||
const handleBrushStrokeStart = React.useCallback((layerId: string, x: number, y: number, layerWidth: number, layerHeight: number): void => {
|
||||
if (!brushOptions) return;
|
||||
const stroke = createStroke(layerWidth, layerHeight);
|
||||
brushStrokeRef.current = stroke;
|
||||
brushLastPosRef.current = { x, y };
|
||||
paintSegment(stroke, x, y, x, y, brushOptions);
|
||||
setBrushOverlay({ layerId, canvas: stroke.canvas });
|
||||
}, [brushOptions]);
|
||||
|
||||
const handleBrushStrokeMove = React.useCallback((_layerId: string, x: number, y: number) => {
|
||||
if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) return;
|
||||
const { x: lx, y: ly } = brushLastPosRef.current;
|
||||
paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions);
|
||||
brushLastPosRef.current = { x, y };
|
||||
setBrushOverlay((prev) => prev ? { ...prev } : prev);
|
||||
}, [brushOptions]);
|
||||
|
||||
const handleBrushStrokeEnd = React.useCallback((layerId: string) => {
|
||||
const stroke = brushStrokeRef.current;
|
||||
brushStrokeRef.current = null;
|
||||
brushLastPosRef.current = null;
|
||||
setBrushOverlay(null);
|
||||
if (stroke && onBrushCommit) onBrushCommit(layerId, stroke);
|
||||
}, [onBrushCommit]);
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
viewport,
|
||||
@@ -164,6 +211,10 @@ export function CanvasRenderer({
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onFillLayer,
|
||||
onBrushStrokeStart: handleBrushStrokeStart,
|
||||
onBrushStrokeMove: handleBrushStrokeMove,
|
||||
onBrushStrokeEnd: handleBrushStrokeEnd,
|
||||
});
|
||||
|
||||
const faceLabelOverlays = React.useMemo(() => {
|
||||
@@ -194,10 +245,10 @@ export function CanvasRenderer({
|
||||
height: "100%",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
cursor: tool === "hand" || isSpacePan ? "grab" : "default",
|
||||
cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"),
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
||||
if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null);
|
||||
onContainerPointerDown(e);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -232,7 +283,7 @@ export function CanvasRenderer({
|
||||
>
|
||||
{layers.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImage = layer.type === "image";
|
||||
const isImage = layer.type === "raster";
|
||||
const layerWidth =
|
||||
layer.width ??
|
||||
(isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
@@ -256,7 +307,8 @@ export function CanvasRenderer({
|
||||
height: layerHeight,
|
||||
transform: `rotate(${layer.rotation}deg)`,
|
||||
opacity: layer.opacity,
|
||||
cursor: "move",
|
||||
display: layer.visible === false ? "none" : undefined,
|
||||
cursor: getToolUiDefinition(tool)?.cursor ?? "move",
|
||||
border: isSelected
|
||||
? "2px solid var(--color-accent-strong)"
|
||||
: "1px dashed transparent",
|
||||
@@ -270,17 +322,14 @@ export function CanvasRenderer({
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
>
|
||||
{isImage && layer.sourceUri ? (
|
||||
(faceBlurPreview &&
|
||||
faceBlurPreview.layerId === layer.id &&
|
||||
faceBlurPreview.regions.length > 0) ||
|
||||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
||||
<BlurredImageLayer
|
||||
layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
||||
<EffectImageLayer
|
||||
layer={layer}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
faceBlurOverride={
|
||||
effectsOverride={
|
||||
faceBlurPreview && faceBlurPreview.layerId === layer.id
|
||||
? faceBlurPreview
|
||||
? faceBlurPreview.effects
|
||||
: null
|
||||
}
|
||||
/>
|
||||
@@ -308,6 +357,9 @@ export function CanvasRenderer({
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
{brushOverlay && brushOverlay.layerId === layer.id ? (
|
||||
<BrushOverlayCanvas stroke={brushOverlay.canvas} width={layerWidth ?? 1} height={layerHeight ?? 1} />
|
||||
) : null}
|
||||
{tool === "face" && faceOverlayLayerId === layer.id
|
||||
? faceDetections.map((face, index) => (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { Layer, LayerEffect } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import { CanvasContextMenu } from "./canvas-context-menu";
|
||||
import type { BrushOptions, BrushStroke } from "../../lib/brush-painter";
|
||||
|
||||
type EditorCanvasStageProps = {
|
||||
isDark: boolean;
|
||||
@@ -10,15 +11,13 @@ type EditorCanvasStageProps = {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
tool: string;
|
||||
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
||||
brushOptions?: BrushOptions;
|
||||
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
|
||||
contextMenu: { x: number; y: number } | null;
|
||||
labels: { copy: string; cut: string; paste: string };
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
@@ -64,6 +63,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
onFillLayer,
|
||||
brushOptions,
|
||||
onBrushCommit,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -93,6 +95,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
onContextMenu={onContextMenu}
|
||||
onFillLayer={onFillLayer}
|
||||
brushOptions={brushOptions}
|
||||
onBrushCommit={onBrushCommit}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("EditorMobileSection", () => {
|
||||
layers: [],
|
||||
selectedLayerId: null,
|
||||
tool: "face",
|
||||
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f" }],
|
||||
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }],
|
||||
faceOverlayLayerId: null,
|
||||
faceStatus: "idle",
|
||||
faceBlurPreview: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { Layer, LayerEffect } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorMobileSectionProps = {
|
||||
@@ -9,16 +9,11 @@ type EditorMobileSectionProps = {
|
||||
canvasHeight: number;
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
tool: string;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
|
||||
labels: {
|
||||
resize: string;
|
||||
faceMlFailedShort: string;
|
||||
|
||||
@@ -7,7 +7,21 @@ import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-det
|
||||
|
||||
type EditorSidebarProps = {
|
||||
isDark: boolean;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
tool: string;
|
||||
fillColor?: string;
|
||||
fillTolerance?: number;
|
||||
onSetFillColor?: (color: string) => void;
|
||||
onSetFillTolerance?: (tolerance: number) => void;
|
||||
onCreateFillLayer?: () => void;
|
||||
brushColor?: string;
|
||||
brushSize?: number;
|
||||
brushOpacity?: number;
|
||||
brushHardness?: number;
|
||||
onSetBrushColor?: (color: string) => void;
|
||||
onSetBrushSize?: (size: number) => void;
|
||||
onSetBrushOpacity?: (opacity: number) => void;
|
||||
onSetBrushHardness?: (hardness: number) => void;
|
||||
onAddLayer?: () => void;
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayer: Layer | null;
|
||||
@@ -22,6 +36,8 @@ type EditorSidebarProps = {
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
onSelectLayer: (layerId: string | null) => void;
|
||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onUndo: () => void;
|
||||
@@ -39,6 +55,20 @@ type EditorSidebarProps = {
|
||||
export function EditorSidebar({
|
||||
isDark,
|
||||
tool,
|
||||
fillColor,
|
||||
fillTolerance,
|
||||
onSetFillColor,
|
||||
onSetFillTolerance,
|
||||
onCreateFillLayer,
|
||||
brushColor,
|
||||
brushSize,
|
||||
brushOpacity,
|
||||
brushHardness,
|
||||
onSetBrushColor,
|
||||
onSetBrushSize,
|
||||
onSetBrushOpacity,
|
||||
onSetBrushHardness,
|
||||
onAddLayer,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
@@ -53,6 +83,8 @@ export function EditorSidebar({
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
onSelectLayer,
|
||||
onSetLayerVisible,
|
||||
onSetEffectEnabled,
|
||||
onMoveLayerOrder,
|
||||
onRemoveSelectedLayer,
|
||||
onUndo,
|
||||
@@ -74,10 +106,86 @@ export function EditorSidebar({
|
||||
selectedLayerId={selectedLayerId}
|
||||
isDark={isDark}
|
||||
onSelectLayer={(layerId) => onSelectLayer(layerId)}
|
||||
onSetLayerVisible={onSetLayerVisible}
|
||||
onSetEffectEnabled={onSetEffectEnabled}
|
||||
onMoveLayerOrder={onMoveLayerOrder}
|
||||
onRemoveSelectedLayer={onRemoveSelectedLayer}
|
||||
onAddLayer={onAddLayer}
|
||||
/>
|
||||
|
||||
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
|
||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
||||
Fill
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={fillColor ?? "#ff0000"}
|
||||
onChange={(e) => onSetFillColor(e.target.value)}
|
||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||
/>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={fillTolerance ?? 32}
|
||||
onChange={(e) => onSetFillTolerance(Number(e.target.value))}
|
||||
className="w-full accent-[var(--color-accent-strong)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
|
||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
||||
Brush
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={brushColor ?? "#000000"}
|
||||
onChange={(e) => onSetBrushColor(e.target.value)}
|
||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||
/>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
|
||||
</div>
|
||||
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tool === "face" ? (
|
||||
<FacePanel
|
||||
isDark={isDark}
|
||||
@@ -89,7 +197,7 @@ export function EditorSidebar({
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
|
||||
onSetBlurMethod={onSetBlurMethod}
|
||||
onSetBlurAmount={onSetBlurAmount}
|
||||
onSetCensorColor={onSetCensorColor}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function FacePanel({
|
||||
? t("editor.faceMlFailed")
|
||||
: faceStatus === "detecting"
|
||||
? t("editor.detectingFaces")
|
||||
: selectedLayer?.type !== "image"
|
||||
: selectedLayer?.type !== "raster"
|
||||
? t("editor.selectImageLayer")
|
||||
: faceDetections.length === 0
|
||||
? t("editor.noFacesFound")
|
||||
|
||||
@@ -2,69 +2,146 @@
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
const EFFECT_LABELS: Record<string, string> = {
|
||||
"face-blur": "Face Blur",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
isDark: boolean;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onAddLayer?: () => void;
|
||||
};
|
||||
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) {
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
||||
{layers.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onAddLayer ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddLayer}
|
||||
title={t("editor.newLayer")}
|
||||
className={`rounded border px-1.5 py-0.5 text-[10px] font-semibold ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
) : null}
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
||||
{layers.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{layers.slice().reverse().map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImageLayer = layer.type === "image" || layer.type === "sticker";
|
||||
const isRaster = layer.type === "raster" || layer.type === "sticker";
|
||||
const hasEffects = layer.effects.length > 0;
|
||||
const isHidden = layer.visible === false;
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
|
||||
<div key={layer.id}>
|
||||
<div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "text-[#d7dae0] hover:bg-white/10"
|
||||
: "text-[#1f2430] hover:bg-black/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span>
|
||||
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}>
|
||||
{isImageLayer && layer.sourceUri ? (
|
||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{layer.name ?? layer.type}</p>
|
||||
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p>
|
||||
</div>
|
||||
} ${isHidden ? "opacity-40" : ""}`}>
|
||||
{/* Thumbnail */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
|
||||
{isRaster && layer.sourceUri ? (
|
||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
|
||||
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
|
||||
{layer.type}
|
||||
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
type="button"
|
||||
title={isHidden ? "Show layer" : "Hide layer"}
|
||||
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
|
||||
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
|
||||
>
|
||||
{isHidden
|
||||
? <EyeOff className="h-3.5 w-3.5" />
|
||||
: <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Layer index */}
|
||||
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{layers.length - idx}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
|
||||
</button>
|
||||
|
||||
{/* Effects chips */}
|
||||
{hasEffects ? (
|
||||
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
|
||||
{layer.effects.map((effect) => {
|
||||
const isDisabled = effect.enabled === false;
|
||||
return (
|
||||
<button
|
||||
key={effect.kind}
|
||||
type="button"
|
||||
title={isDisabled ? "Enable effect" : "Disable effect"}
|
||||
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
|
||||
isDisabled
|
||||
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
|
||||
: isSelected
|
||||
? "bg-white/20 text-white"
|
||||
: isDark
|
||||
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
|
||||
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
|
||||
}`}
|
||||
>
|
||||
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
|
||||
{EFFECT_LABELS[effect.kind] ?? effect.kind}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
|
||||
{layers.length === 0 ? (
|
||||
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
||||
{t("editor.noLayersYet")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
|
||||
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
|
||||
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import { getToolDefinition } from "@pien-studio/editor-core";
|
||||
|
||||
type Viewport = {
|
||||
x: number;
|
||||
@@ -10,7 +11,7 @@ type Viewport = {
|
||||
type UseCanvasInteractionsOptions = {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
tool: string;
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
||||
@@ -21,6 +22,10 @@ type UseCanvasInteractionsOptions = {
|
||||
onInteractionStart?: () => void;
|
||||
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;
|
||||
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
|
||||
onBrushStrokeEnd?: (layerId: string) => void;
|
||||
};
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
@@ -43,6 +48,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onFillLayer,
|
||||
onBrushStrokeStart,
|
||||
onBrushStrokeMove,
|
||||
onBrushStrokeEnd,
|
||||
} = options;
|
||||
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -86,6 +95,8 @@ 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 pinchRef = React.useRef<{
|
||||
active: boolean;
|
||||
initialPinchPx: number;
|
||||
@@ -237,10 +248,18 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
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 };
|
||||
const toolDef = getToolDefinition(tool);
|
||||
if (toolDef?.interactionMode !== "select" && !isSpacePan) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isPanning.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
if (isSpacePan) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isPanning.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
@@ -251,7 +270,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy }));
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && rotateRef.current && onRotateLayer) {
|
||||
if (brushRef.current && onBrushStrokeMove) {
|
||||
const { rect } = brushRef.current;
|
||||
const localX = (e.clientX - rect.left) / viewport.scale;
|
||||
const localY = (e.clientY - rect.top) / viewport.scale;
|
||||
brushRef.current.lastX = localX;
|
||||
brushRef.current.lastY = localY;
|
||||
onBrushStrokeMove(brushRef.current.id, localX, localY);
|
||||
return;
|
||||
}
|
||||
const toolDef = getToolDefinition(tool);
|
||||
if (toolDef?.allowsLayerRotate && 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;
|
||||
@@ -260,7 +289,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
onRotateLayer(id, nextRotation);
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && resizeRef.current && onResizeLayer) {
|
||||
if (toolDef?.allowsLayerResize && 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;
|
||||
@@ -315,7 +344,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && dragRef.current) {
|
||||
if (toolDef?.allowsLayerDrag && 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;
|
||||
@@ -362,6 +391,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
const { id, lastRotation } = rotateRef.current;
|
||||
if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation);
|
||||
}
|
||||
if (brushRef.current && onBrushStrokeEnd) {
|
||||
onBrushStrokeEnd(brushRef.current.id);
|
||||
brushRef.current = null;
|
||||
}
|
||||
dragRef.current = null;
|
||||
resizeRef.current = null;
|
||||
resizeMovePendingRef.current = null;
|
||||
@@ -372,6 +405,24 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
|
||||
if (isSpacePan) return;
|
||||
e.stopPropagation();
|
||||
const toolDef = getToolDefinition(tool);
|
||||
if (toolDef?.interactionMode === "paint") {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const localX = (e.clientX - rect.left) / viewport.scale;
|
||||
const localY = (e.clientY - rect.top) / viewport.scale;
|
||||
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);
|
||||
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
|
||||
beginInteraction();
|
||||
} else if (onFillLayer) {
|
||||
onFillLayer(layer.id, localX, localY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
id: layer.id,
|
||||
@@ -387,7 +438,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
}
|
||||
|
||||
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
|
||||
if (tool !== "pointer" || !onResizeLayer) return;
|
||||
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
rotateRef.current = null;
|
||||
@@ -411,7 +462,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
}
|
||||
|
||||
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
|
||||
if (tool !== "pointer" || !onRotateLayer) return;
|
||||
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
resizeRef.current = null;
|
||||
|
||||
@@ -32,8 +32,13 @@ export function useEditorBindings() {
|
||||
removeSelectedLayer: s.removeSelectedLayer,
|
||||
addLayerByType: s.addLayerByType,
|
||||
moveSelectedLayerOrder: s.moveSelectedLayerOrder,
|
||||
addCanvasSizedLayer: s.addCanvasSizedLayer,
|
||||
importImageFromFile: s.importImageFromFile,
|
||||
setImageLayerFaceBlur: s.setImageLayerFaceBlur,
|
||||
updateImageLayerSource: s.updateImageLayerSource,
|
||||
setLayerEffect: s.setLayerEffect,
|
||||
removeLayerEffect: s.removeLayerEffect,
|
||||
setLayerVisible: s.setLayerVisible,
|
||||
setEffectEnabled: s.setEffectEnabled,
|
||||
setCanvasSize: s.setCanvasSize,
|
||||
setTool: s.setTool,
|
||||
undo: s.undo,
|
||||
|
||||
@@ -2,14 +2,20 @@ import React from "react";
|
||||
|
||||
type UseEditorShortcutsOptions = {
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onPaste: (e?: ClipboardEvent) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function isEditableTarget(e: Event) {
|
||||
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
|
||||
}
|
||||
|
||||
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||
const { onSave, onCopy, onCut, onPaste, onDelete } = options;
|
||||
const { onSave, onUndo, onRedo, onCopy, onCut, onPaste, onDelete } = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -19,31 +25,57 @@ export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
if (e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onCopy();
|
||||
onUndo();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "x") {
|
||||
if (e.key.toLowerCase() === "z" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onCut();
|
||||
onRedo();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "v") {
|
||||
if (e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
onPaste();
|
||||
onRedo();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
if (isEditableTarget(e)) return;
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(e: ClipboardEvent) {
|
||||
if (isEditableTarget(e)) return;
|
||||
e.preventDefault();
|
||||
onCopy();
|
||||
}
|
||||
|
||||
function handleCut(e: ClipboardEvent) {
|
||||
if (isEditableTarget(e)) return;
|
||||
e.preventDefault();
|
||||
onCut();
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
if (isEditableTarget(e)) return;
|
||||
e.preventDefault();
|
||||
onPaste(e);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCopy, onCut, onDelete, onPaste, onSave]);
|
||||
window.addEventListener("copy", handleCopy);
|
||||
window.addEventListener("cut", handleCut);
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("copy", handleCopy);
|
||||
window.removeEventListener("cut", handleCut);
|
||||
window.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [onCopy, onCut, onDelete, onPaste, onRedo, onSave, onUndo]);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import type { Layer, LayerEffect } from "@pien-studio/types";
|
||||
import { useFaceBlurWorkflow } from "./use-face-blur-workflow";
|
||||
|
||||
function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
type: "raster",
|
||||
sourceUri: "data:image/png;base64,abc",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFaceBlurWorkflow", () => {
|
||||
it("selects all detected faces by default when no existing face blur", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const setLayerEffect = vi.fn();
|
||||
const removeLayerEffect = vi.fn();
|
||||
const selectedLayer = makeImageLayer();
|
||||
const faceDetections = [
|
||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
||||
@@ -31,28 +34,37 @@ describe("useFaceBlurWorkflow", () => {
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
expect(result.current.faceBlurPreview?.regions).toHaveLength(2);
|
||||
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
|
||||
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).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 setLayerEffect = vi.fn();
|
||||
const removeLayerEffect = vi.fn();
|
||||
const faceBlurEffect: LayerEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: [{ x: 0, y: 0, width: 4, height: 4 }],
|
||||
};
|
||||
const selectedLayer = makeImageLayer({ effects: [faceBlurEffect] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
|
||||
setImageLayerFaceBlur,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -63,7 +75,8 @@ describe("useFaceBlurWorkflow", () => {
|
||||
});
|
||||
|
||||
it("toggles face selection and applies blur to selected regions", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const setLayerEffect = vi.fn();
|
||||
const removeLayerEffect = vi.fn();
|
||||
const selectedLayer = makeImageLayer();
|
||||
const faceDetections = [
|
||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
||||
@@ -75,7 +88,8 @@ describe("useFaceBlurWorkflow", () => {
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -93,10 +107,11 @@ describe("useFaceBlurWorkflow", () => {
|
||||
result.current.blurFaces(result.current.selectedFaceIndices);
|
||||
});
|
||||
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1);
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledWith(
|
||||
expect(setLayerEffect).toHaveBeenCalledTimes(1);
|
||||
expect(setLayerEffect).toHaveBeenCalledWith(
|
||||
"layer-1",
|
||||
expect.objectContaining({
|
||||
kind: "face-blur",
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: expect.arrayContaining([
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceBlurMethod, Layer, LayerEffect } 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;
|
||||
effects: LayerEffect[];
|
||||
};
|
||||
|
||||
type UseFaceBlurWorkflowOptions = {
|
||||
selectedLayer: Layer | null;
|
||||
faceDetectionsLayerId: string | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void;
|
||||
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
||||
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
||||
};
|
||||
|
||||
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options;
|
||||
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 [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
|
||||
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
|
||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id);
|
||||
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],
|
||||
);
|
||||
|
||||
const buildBlurRegions = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") return [];
|
||||
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
|
||||
const indexSet = new Set(indices);
|
||||
return faceDetections
|
||||
@@ -57,17 +60,19 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
}, []);
|
||||
|
||||
const clearBlur = React.useCallback(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") return;
|
||||
setImageLayerFaceBlur(selectedLayer.id, undefined);
|
||||
if (!selectedLayer) return;
|
||||
removeLayerEffect(selectedLayer.id, "face-blur");
|
||||
setFaceBlurPreview(null);
|
||||
}, [selectedLayer, setImageLayerFaceBlur]);
|
||||
}, [selectedLayer, removeLayerEffect]);
|
||||
|
||||
const blurFaces = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return;
|
||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
|
||||
const regions = buildBlurRegions(indices);
|
||||
setImageLayerFaceBlur(selectedLayer.id, {
|
||||
setLayerEffect(selectedLayer.id, {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions,
|
||||
@@ -76,7 +81,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
setSelectedFaceIndices([]);
|
||||
setFaceBlurPreview(null);
|
||||
},
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur],
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -85,7 +90,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
if (selectedLayer?.faceBlur) {
|
||||
if (faceBlurEffect) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
@@ -94,10 +99,10 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]);
|
||||
}, [faceDetections, hasDetectableSelection, faceBlurEffect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
@@ -109,10 +114,14 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
|
||||
setFaceBlurPreview({
|
||||
layerId: selectedLayer.id,
|
||||
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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export type BrushOptions = {
|
||||
color: string;
|
||||
size: number;
|
||||
opacity: number;
|
||||
hardness: number; // 0–1: 0 = fully soft, 1 = hard edge
|
||||
};
|
||||
|
||||
export type BrushStroke = {
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace("#", "");
|
||||
return {
|
||||
r: parseInt(clean.slice(0, 2), 16),
|
||||
g: parseInt(clean.slice(2, 4), 16),
|
||||
b: parseInt(clean.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function drawDab(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const r = options.size / 2;
|
||||
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
|
||||
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
const innerStop = Math.max(0, Math.min(1, options.hardness));
|
||||
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/** Creates a fresh stroke canvas sized to the layer. */
|
||||
export function createStroke(width: number, height: number): BrushStroke {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create brush canvas context");
|
||||
return { canvas, ctx, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */
|
||||
export function paintSegment(
|
||||
stroke: BrushStroke,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const step = Math.max(1, options.size * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = steps === 0 ? 0 : i / steps;
|
||||
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges stroke canvas on top of the source image and returns a data URL. */
|
||||
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Cannot create merge canvas context"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(image, 0, 0);
|
||||
// Scale stroke canvas to match image natural size
|
||||
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = sourceUri;
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ describe("buildFaceLabelOverlays", () => {
|
||||
const layers = [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image" as const,
|
||||
type: "raster" as const,
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 180,
|
||||
@@ -19,6 +19,8 @@ describe("buildFaceLabelOverlays", () => {
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function buildFaceLabelOverlays(
|
||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||
if (!layer) return [];
|
||||
|
||||
const isImage = layer.type === "image";
|
||||
const isImage = layer.type === "raster";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
if (!layerWidth || !layerHeight) return [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
import type { EditorToolId } from "@pien-studio/editor-core";
|
||||
|
||||
export type ToolModeController = {
|
||||
kind: "mode";
|
||||
@@ -22,6 +22,8 @@ type CreateEditorToolControllersOptions = {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
fill: string;
|
||||
brush: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
@@ -32,6 +34,8 @@ export function createEditorToolControllers(options: CreateEditorToolControllers
|
||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
||||
{ kind: "mode", id: "face", label: options.labels.face },
|
||||
{ kind: "mode", id: "fill", label: options.labels.fill },
|
||||
{ kind: "mode", id: "brush", label: options.labels.brush },
|
||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { faceBlurRenderer } from "./face-blur";
|
||||
import type { FaceBlurEffect } from "@pien-studio/types";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
fillStyle: "",
|
||||
filter: "none",
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function makeImage(width = 1200, height = 800) {
|
||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||
}
|
||||
|
||||
function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) {
|
||||
return { ctx, image, targetWidth: tw, targetHeight: th };
|
||||
}
|
||||
|
||||
describe("faceBlurRenderer.render (regions)", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledOnce();
|
||||
expect(ctx.filter).toBe("blur(24px)");
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders pixelate using sampled offscreen canvas", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sampleDrawImage = vi.fn();
|
||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(sampleCanvas.width).toBe(16);
|
||||
expect(sampleCanvas.height).toBe(12);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("renders censor with region color priority", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25);
|
||||
});
|
||||
|
||||
it("falls back to legacy region scaling when source dimensions are missing", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage(2400, 1600);
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("faceBlurRenderer.renderLayer", () => {
|
||||
it("applies blur to source-sized image before drawing the resized layer", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sourceDrawImage = vi.fn();
|
||||
const sourceCtx = { ...makeContext(), drawImage: sourceDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const sourceCanvas = { width: 0, height: 0, getContext: vi.fn(() => sourceCtx) } as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sourceCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(sourceCanvas.width).toBe(1200);
|
||||
expect(sourceCanvas.height).toBe(800);
|
||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800);
|
||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(2, image, 120, 80, 300, 200, 120, 80, 300, 200);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(sourceCanvas, 0, 0, 1200, 800, 0, 0, 600, 400);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("uses a canvas-filter fallback for gaussian blur when filters are unavailable", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
delete (ctx as Partial<CanvasRenderingContext2D>).filter;
|
||||
const image = makeImage();
|
||||
const regionDrawImage = vi.fn();
|
||||
const blurDrawImage = vi.fn();
|
||||
const regionCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: regionDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const blurCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: blurDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const regionCanvas = { width: 0, height: 0, getContext: vi.fn(() => regionCtx) } as unknown as HTMLCanvasElement;
|
||||
const blurCanvas = { width: 0, height: 0, getContext: vi.fn(() => blurCtx) } as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName !== "canvas") return nativeCreateElement(tagName);
|
||||
return createElement.mock.calls.length === 1 ? regionCanvas : blurCanvas;
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(ctx.save).not.toHaveBeenCalled();
|
||||
expect(regionCanvas.width).toBe(150);
|
||||
expect(regionCanvas.height).toBe(100);
|
||||
expect(regionDrawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 0, 0, 150, 100);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(regionCanvas, 0, 0, 150, 100, 60, 40, 150, 100);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FaceBlurSettings } from "@pien-studio/types";
|
||||
|
||||
type BlurRegion = FaceBlurSettings["regions"][number];
|
||||
import type { FaceBlurEffect } from "@pien-studio/types";
|
||||
import type { EffectRenderer, EffectRenderContext } from "./types";
|
||||
|
||||
function drawPixelatedRegion(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -21,36 +20,12 @@ function drawPixelatedRegion(
|
||||
const sampleCtx = sampleCanvas.getContext("2d");
|
||||
if (!sampleCtx) return;
|
||||
sampleCtx.imageSmoothingEnabled = false;
|
||||
sampleCtx.drawImage(
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
0,
|
||||
0,
|
||||
sampleCanvas.width,
|
||||
sampleCanvas.height,
|
||||
);
|
||||
sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(
|
||||
sampleCanvas,
|
||||
0,
|
||||
0,
|
||||
sampleCanvas.width,
|
||||
sampleCanvas.height,
|
||||
targetX,
|
||||
targetY,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
function canUseCanvasFilter(ctx: CanvasRenderingContext2D) {
|
||||
return "filter" in ctx && typeof ctx.filter === "string";
|
||||
}
|
||||
|
||||
function drawBlurredRegionFallback(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
@@ -69,86 +44,36 @@ function drawBlurredRegionFallback(
|
||||
regionCanvas.height = Math.max(1, Math.round(targetHeight));
|
||||
const regionCtx = regionCanvas.getContext("2d");
|
||||
if (!regionCtx) return;
|
||||
|
||||
regionCtx.drawImage(
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
0,
|
||||
0,
|
||||
regionCanvas.width,
|
||||
regionCanvas.height,
|
||||
);
|
||||
|
||||
regionCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, regionCanvas.width, regionCanvas.height);
|
||||
const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
|
||||
const blurCanvas = document.createElement("canvas");
|
||||
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
|
||||
blurCanvas.height = Math.max(1, Math.round(regionCanvas.height * scale));
|
||||
const blurCtx = blurCanvas.getContext("2d");
|
||||
if (!blurCtx) return;
|
||||
|
||||
blurCtx.imageSmoothingEnabled = true;
|
||||
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
|
||||
regionCtx.imageSmoothingEnabled = true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
|
||||
regionCtx.drawImage(
|
||||
blurCanvas,
|
||||
0,
|
||||
0,
|
||||
blurCanvas.width,
|
||||
blurCanvas.height,
|
||||
0,
|
||||
0,
|
||||
regionCanvas.width,
|
||||
regionCanvas.height,
|
||||
);
|
||||
regionCtx.drawImage(blurCanvas, 0, 0, blurCanvas.width, blurCanvas.height, 0, 0, regionCanvas.width, regionCanvas.height);
|
||||
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
|
||||
blurCtx.drawImage(
|
||||
regionCanvas,
|
||||
0,
|
||||
0,
|
||||
regionCanvas.width,
|
||||
regionCanvas.height,
|
||||
0,
|
||||
0,
|
||||
blurCanvas.width,
|
||||
blurCanvas.height,
|
||||
);
|
||||
blurCtx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, 0, 0, blurCanvas.width, blurCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
regionCanvas,
|
||||
0,
|
||||
0,
|
||||
regionCanvas.width,
|
||||
regionCanvas.height,
|
||||
targetX,
|
||||
targetY,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
ctx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
export function renderFaceBlurRegions(
|
||||
function renderRegions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: {
|
||||
method: FaceBlurSettings["method"];
|
||||
amount: number;
|
||||
regions: BlurRegion[];
|
||||
censorColor?: string;
|
||||
},
|
||||
effect: FaceBlurEffect,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur.regions.length) return;
|
||||
) {
|
||||
if (!effect.regions.length) return;
|
||||
const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth);
|
||||
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight);
|
||||
|
||||
for (const region of blur.regions) {
|
||||
for (const region of effect.regions) {
|
||||
const sourceWidth = region.sourceWidth ?? 0;
|
||||
const sourceHeight = region.sourceHeight ?? 0;
|
||||
const hasSourceDims = sourceWidth > 0 && sourceHeight > 0;
|
||||
@@ -159,80 +84,42 @@ export function renderFaceBlurRegions(
|
||||
const y = Math.max(0, Math.floor(region.y * scaleY));
|
||||
const w = Math.max(1, Math.floor(region.width * scaleX));
|
||||
const h = Math.max(1, Math.floor(region.height * scaleY));
|
||||
const sx0 = hasSourceDims
|
||||
? region.x
|
||||
: Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||
const sy0 = hasSourceDims
|
||||
? region.y
|
||||
: Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||
const sw = hasSourceDims
|
||||
? region.width
|
||||
: Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||
const sh = hasSourceDims
|
||||
? region.height
|
||||
: Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||
const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||
|
||||
if (blur.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||
if (effect.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blur.method === "pixelate") {
|
||||
const pixelSize = Math.max(4, Math.round(blur.amount / 2));
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize);
|
||||
if (effect.method === "pixelate") {
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (canUseCanvasFilter(ctx)) {
|
||||
if ("filter" in ctx && typeof ctx.filter === "string") {
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.filter = `blur(${effect.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
drawBlurredRegionFallback(
|
||||
ctx,
|
||||
image,
|
||||
sx0,
|
||||
sy0,
|
||||
sw,
|
||||
sh,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
blur.amount,
|
||||
);
|
||||
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderImageWithFaceBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur:
|
||||
| {
|
||||
method: FaceBlurSettings["method"];
|
||||
amount: number;
|
||||
regions: BlurRegion[];
|
||||
censorColor?: string;
|
||||
}
|
||||
| undefined,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur || blur.regions.length === 0) {
|
||||
function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
||||
const { ctx, image, targetWidth, targetHeight } = context;
|
||||
if (!effect.regions.length) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
const canBlurAtSourceSize = blur.regions.every(
|
||||
(region) => (region.sourceWidth ?? 0) > 0 && (region.sourceHeight ?? 0) > 0,
|
||||
);
|
||||
if (!canBlurAtSourceSize) {
|
||||
const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
|
||||
if (!allHaveSourceDims) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
renderFaceBlurRegions(ctx, image, blur, targetWidth, targetHeight);
|
||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,24 +131,15 @@ export function renderImageWithFaceBlur(
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||
renderFaceBlurRegions(
|
||||
sourceCtx,
|
||||
image,
|
||||
blur,
|
||||
sourceCanvas.width,
|
||||
sourceCanvas.height,
|
||||
);
|
||||
ctx.drawImage(
|
||||
sourceCanvas,
|
||||
0,
|
||||
0,
|
||||
sourceCanvas.width,
|
||||
sourceCanvas.height,
|
||||
0,
|
||||
0,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height);
|
||||
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
|
||||
kind: "face-blur",
|
||||
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
|
||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||
},
|
||||
renderLayer,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LayerEffect } from "@pien-studio/types";
|
||||
import type { EffectRenderer, EffectRenderContext } from "./types";
|
||||
import { faceBlurRenderer } from "./face-blur";
|
||||
|
||||
const renderers: EffectRenderer[] = [faceBlurRenderer];
|
||||
|
||||
const effectRendererRegistry = new Map<string, EffectRenderer>(
|
||||
renderers.map((r) => [r.kind, r]),
|
||||
);
|
||||
|
||||
export function getEffectRenderer(kind: string): EffectRenderer | undefined {
|
||||
return effectRendererRegistry.get(kind);
|
||||
}
|
||||
|
||||
/** Draws a layer image applying all its effects in order. */
|
||||
export function renderLayerWithEffects(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
effects: LayerEffect[],
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
const activeEffects = effects.filter((e) => {
|
||||
if (e.enabled === false) return false;
|
||||
return effectRendererRegistry.get(e.kind) !== undefined;
|
||||
});
|
||||
|
||||
if (activeEffects.length === 0) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight };
|
||||
|
||||
// The first effect owns the full layer render (draws base image + applies itself)
|
||||
const first = activeEffects[0];
|
||||
const firstRenderer = effectRendererRegistry.get(first.kind);
|
||||
firstRenderer?.renderLayer(context, first as never);
|
||||
|
||||
// Subsequent effects render on top (overlay only, no re-draw of base)
|
||||
for (let i = 1; i < activeEffects.length; i++) {
|
||||
const effect = activeEffects[i];
|
||||
const renderer = effectRendererRegistry.get(effect.kind);
|
||||
renderer?.render(context, effect as never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayerEffect } from "@pien-studio/types";
|
||||
|
||||
export type EffectRenderContext = {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
image: HTMLImageElement;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
};
|
||||
|
||||
export type EffectRenderer<T extends LayerEffect = LayerEffect> = {
|
||||
kind: T["kind"];
|
||||
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
||||
render: (context: EffectRenderContext, effect: T) => void;
|
||||
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
||||
renderLayer: (context: EffectRenderContext, effect: T) => void;
|
||||
};
|
||||
+11
-30
@@ -1,5 +1,5 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderImageWithFaceBlur } from "./face-blur-renderer";
|
||||
import { renderLayerWithEffects } from "./effects/registry";
|
||||
|
||||
type ExportOptions = {
|
||||
isDark: boolean;
|
||||
@@ -20,11 +20,7 @@ function loadImage(src: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function drawFallbackLayer(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
isDark: boolean,
|
||||
) {
|
||||
function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
||||
const text = layer.name ?? layer.type;
|
||||
const width = Math.max(80, layer.width ?? 120);
|
||||
const height = Math.max(34, layer.height ?? 40);
|
||||
@@ -49,24 +45,15 @@ function drawFallbackLayer(
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||
ctx.font =
|
||||
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, width / 2, height / 2);
|
||||
}
|
||||
|
||||
async function drawLayer(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
isDark: boolean,
|
||||
) {
|
||||
const width =
|
||||
layer.width ??
|
||||
(layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
||||
const height =
|
||||
layer.height ??
|
||||
(layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
||||
const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120);
|
||||
const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||
@@ -74,10 +61,10 @@ async function drawLayer(
|
||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
|
||||
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
||||
if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) {
|
||||
try {
|
||||
const image = await loadImage(layer.sourceUri);
|
||||
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height);
|
||||
renderLayerWithEffects(ctx, image, layer.effects, width, height);
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
@@ -88,14 +75,8 @@ async function drawLayer(
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export async function exportProjectAsPng(
|
||||
project: Project,
|
||||
options: ExportOptions,
|
||||
) {
|
||||
const pixelRatio = Math.max(
|
||||
1,
|
||||
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
|
||||
);
|
||||
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
|
||||
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
|
||||
const { width, height } = project.canvas;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * pixelRatio;
|
||||
@@ -103,10 +84,10 @@ export async function exportProjectAsPng(
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create export canvas context");
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
|
||||
for (const layer of project.layers) {
|
||||
if (layer.visible === false) continue;
|
||||
await drawLayer(ctx, layer, options.isDark);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderFaceBlurRegions,
|
||||
renderImageWithFaceBlur,
|
||||
} from "./face-blur-renderer";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
fillStyle: "",
|
||||
filter: "none",
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function makeImage(width = 1200, height = 800) {
|
||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||
}
|
||||
|
||||
describe("renderFaceBlurRegions", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [
|
||||
{
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 300,
|
||||
height: 200,
|
||||
sourceWidth: 1200,
|
||||
sourceHeight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledOnce();
|
||||
expect(ctx.filter).toBe("blur(24px)");
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
120,
|
||||
80,
|
||||
300,
|
||||
200,
|
||||
60,
|
||||
40,
|
||||
150,
|
||||
100,
|
||||
);
|
||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders pixelate using sampled offscreen canvas", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sampleDrawImage = vi.fn();
|
||||
const sampleCtx = {
|
||||
imageSmoothingEnabled: true,
|
||||
drawImage: sampleDrawImage,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => sampleCtx),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi
|
||||
.spyOn(doc, "createElement")
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [
|
||||
{
|
||||
x: 200,
|
||||
y: 100,
|
||||
width: 160,
|
||||
height: 120,
|
||||
sourceWidth: 1200,
|
||||
sourceHeight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(sampleCanvas.width).toBe(16);
|
||||
expect(sampleCanvas.height).toBe(12);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
200,
|
||||
100,
|
||||
160,
|
||||
120,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
12,
|
||||
);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
sampleCanvas,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
12,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
60,
|
||||
);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("renders censor with region color priority", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [
|
||||
{
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 50,
|
||||
sourceWidth: 1200,
|
||||
sourceHeight: 800,
|
||||
censorColor: "#00ff00",
|
||||
},
|
||||
],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25);
|
||||
});
|
||||
|
||||
it("falls back to legacy region scaling when source dimensions are missing", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage(2400, 1600);
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
400,
|
||||
480,
|
||||
1200,
|
||||
800,
|
||||
100,
|
||||
120,
|
||||
300,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies blur to source-sized image before drawing the resized layer", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sourceDrawImage = vi.fn();
|
||||
const sourceCtx = {
|
||||
...makeContext(),
|
||||
drawImage: sourceDrawImage,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const sourceCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => sourceCtx),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi
|
||||
.spyOn(doc, "createElement")
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sourceCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderImageWithFaceBlur(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [
|
||||
{
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 300,
|
||||
height: 200,
|
||||
sourceWidth: 1200,
|
||||
sourceHeight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(sourceCanvas.width).toBe(1200);
|
||||
expect(sourceCanvas.height).toBe(800);
|
||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800);
|
||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
image,
|
||||
120,
|
||||
80,
|
||||
300,
|
||||
200,
|
||||
120,
|
||||
80,
|
||||
300,
|
||||
200,
|
||||
);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
sourceCanvas,
|
||||
0,
|
||||
0,
|
||||
1200,
|
||||
800,
|
||||
0,
|
||||
0,
|
||||
600,
|
||||
400,
|
||||
);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("uses a canvas-filter fallback for gaussian blur when filters are unavailable", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
delete (ctx as Partial<CanvasRenderingContext2D>).filter;
|
||||
const image = makeImage();
|
||||
const regionDrawImage = vi.fn();
|
||||
const blurDrawImage = vi.fn();
|
||||
const regionCtx = {
|
||||
...makeContext(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: regionDrawImage,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const blurCtx = {
|
||||
...makeContext(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: blurDrawImage,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const regionCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => regionCtx),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
const blurCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => blurCtx),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi
|
||||
.spyOn(doc, "createElement")
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName !== "canvas") return nativeCreateElement(tagName);
|
||||
return createElement.mock.calls.length === 1
|
||||
? regionCanvas
|
||||
: blurCanvas;
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [
|
||||
{
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 300,
|
||||
height: 200,
|
||||
sourceWidth: 1200,
|
||||
sourceHeight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.save).not.toHaveBeenCalled();
|
||||
expect(regionCanvas.width).toBe(150);
|
||||
expect(regionCanvas.height).toBe(100);
|
||||
expect(regionDrawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
120,
|
||||
80,
|
||||
300,
|
||||
200,
|
||||
0,
|
||||
0,
|
||||
150,
|
||||
100,
|
||||
);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
regionCanvas,
|
||||
0,
|
||||
0,
|
||||
150,
|
||||
100,
|
||||
60,
|
||||
40,
|
||||
150,
|
||||
100,
|
||||
);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
type RGBA = [number, number, number, number];
|
||||
|
||||
function colorDistance(a: RGBA, b: RGBA): number {
|
||||
// Weight alpha at 25% so transparent regions fill correctly
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]) * 0.25;
|
||||
}
|
||||
|
||||
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
|
||||
// Fully transparent pixels all match each other regardless of RGB
|
||||
if (target[3] === 0) return pixel[3] <= tolerance;
|
||||
return colorDistance(pixel, target) <= tolerance;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string): RGBA {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
const a = clean.length === 8 ? parseInt(clean.slice(6, 8), 16) : 255;
|
||||
return [r, g, b, a];
|
||||
}
|
||||
|
||||
export function floodFillDataUrl(
|
||||
sourceUri: string,
|
||||
px: number,
|
||||
py: number,
|
||||
fillColor: string,
|
||||
tolerance: number = 32,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Cannot get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const { width, height } = canvas;
|
||||
const data = ctx.getImageData(0, 0, width, height);
|
||||
const pixels = data.data;
|
||||
|
||||
const x = Math.round(px);
|
||||
const y = Math.round(py);
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||||
resolve(sourceUri);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = (y * width + x) * 4;
|
||||
const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
||||
const fill = hexToRgba(fillColor);
|
||||
|
||||
if (matchesTarget(target, fill, 0)) {
|
||||
resolve(sourceUri);
|
||||
return;
|
||||
}
|
||||
|
||||
const visited = new Uint8Array(width * height);
|
||||
const stack: number[] = [x + y * width];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const pos = stack.pop()!;
|
||||
if (visited[pos]) continue;
|
||||
visited[pos] = 1;
|
||||
|
||||
const cx = pos % width;
|
||||
const cy = Math.floor(pos / width);
|
||||
const ci = pos * 4;
|
||||
const current: RGBA = [pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3]];
|
||||
|
||||
if (!matchesTarget(current, target, tolerance)) continue;
|
||||
|
||||
pixels[ci] = fill[0];
|
||||
pixels[ci + 1] = fill[1];
|
||||
pixels[ci + 2] = fill[2];
|
||||
pixels[ci + 3] = fill[3];
|
||||
|
||||
if (cx > 0) stack.push(pos - 1);
|
||||
if (cx < width - 1) stack.push(pos + 1);
|
||||
if (cy > 0) stack.push(pos - width);
|
||||
if (cy < height - 1) stack.push(pos + width);
|
||||
}
|
||||
|
||||
ctx.putImageData(data, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = sourceUri;
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function makeProject(): Project {
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
aspectRatio: "1:1",
|
||||
canvas: { width: 100, height: 100, unit: "px" },
|
||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1 }],
|
||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,18 +31,18 @@ describe("hasProjectChanged", () => {
|
||||
it("detects face blur region changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
b.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] };
|
||||
a.layers[0].type = "raster";
|
||||
b.layers[0].type = "raster";
|
||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
||||
b.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects layer order changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
||||
b.layers = [b.layers[1], b.layers[0]];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
@@ -58,9 +58,9 @@ describe("hasProjectChanged", () => {
|
||||
it("detects face blur removal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
a.layers[0].type = "raster";
|
||||
b.layers[0].type = "raster";
|
||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,26 +32,7 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blurA = a.faceBlur;
|
||||
const blurB = b.faceBlur;
|
||||
if (!blurA && !blurB) continue;
|
||||
if (!blurA || !blurB) return true;
|
||||
if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true;
|
||||
if (blurA.regions.length !== blurB.regions.length) return true;
|
||||
for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) {
|
||||
const regionA = blurA.regions[regionIndex];
|
||||
const regionB = blurB.regions[regionIndex];
|
||||
if (!regionA || !regionB) return true;
|
||||
if (
|
||||
regionA.x !== regionB.x ||
|
||||
regionA.y !== regionB.y ||
|
||||
regionA.width !== regionB.width ||
|
||||
regionA.height !== regionB.height ||
|
||||
regionA.censorColor !== regionB.censorColor
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ToolUiDefinition } from "./types";
|
||||
|
||||
const definitions: ToolUiDefinition[] = [
|
||||
{
|
||||
id: "pointer",
|
||||
interactionMode: "select",
|
||||
allowsLayerDrag: true,
|
||||
allowsLayerResize: true,
|
||||
allowsLayerRotate: true,
|
||||
iconName: "MousePointer2",
|
||||
cursor: "default",
|
||||
labelKey: "editor.toolPointer",
|
||||
},
|
||||
{
|
||||
id: "hand",
|
||||
interactionMode: "pan",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "Hand",
|
||||
cursor: "grab",
|
||||
labelKey: "editor.toolPan",
|
||||
},
|
||||
{
|
||||
id: "face",
|
||||
interactionMode: "annotate",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "ScanFace",
|
||||
cursor: "default",
|
||||
labelKey: "editor.toolFace",
|
||||
},
|
||||
{
|
||||
id: "fill",
|
||||
interactionMode: "paint",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "PaintBucket",
|
||||
cursor: "crosshair",
|
||||
labelKey: "editor.toolFill",
|
||||
},
|
||||
{
|
||||
id: "brush",
|
||||
interactionMode: "paint",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "Brush",
|
||||
cursor: "crosshair",
|
||||
labelKey: "editor.toolBrush",
|
||||
},
|
||||
];
|
||||
|
||||
const toolUiRegistry = new Map<string, ToolUiDefinition>(
|
||||
definitions.map((d) => [d.id, d]),
|
||||
);
|
||||
|
||||
export function getToolUiDefinition(id: string): ToolUiDefinition | undefined {
|
||||
return toolUiRegistry.get(id);
|
||||
}
|
||||
|
||||
export function getAllToolUiDefinitions(): ToolUiDefinition[] {
|
||||
return definitions;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ToolDefinition } from "@pien-studio/editor-core";
|
||||
|
||||
export type ToolUiDefinition = ToolDefinition & {
|
||||
/** Lucide icon component name (resolved at render time) */
|
||||
iconName: string;
|
||||
/** CSS cursor when this tool is active */
|
||||
cursor: string;
|
||||
/** i18n key for the toolbar label */
|
||||
labelKey: string;
|
||||
};
|
||||
@@ -47,6 +47,7 @@
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"saved": "Saved",
|
||||
"layers": "Layers",
|
||||
"newLayer": "New layer",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"delete": "Delete",
|
||||
@@ -86,9 +87,13 @@
|
||||
"faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.",
|
||||
"toolPointer": "Pointer",
|
||||
"toolPan": "Pan",
|
||||
"toolFace": "Face",
|
||||
"toolFace": "Face Blur",
|
||||
"toolFill": "Fill",
|
||||
"toolBrush": "Brush",
|
||||
"toolText": "Text",
|
||||
"toolImage": "Image",
|
||||
"fillColor": "Fill color",
|
||||
"fillTolerance": "Tolerance",
|
||||
"canvasSizeTitle": "Canvas Size",
|
||||
"modePreset": "Preset",
|
||||
"modeCustom": "Custom",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"unsavedChanges": "未保存の変更",
|
||||
"saved": "保存済み",
|
||||
"layers": "レイヤー",
|
||||
"newLayer": "新しいレイヤー",
|
||||
"up": "上へ",
|
||||
"down": "下へ",
|
||||
"delete": "削除",
|
||||
@@ -86,9 +87,13 @@
|
||||
"faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。",
|
||||
"toolPointer": "ポインター",
|
||||
"toolPan": "パン",
|
||||
"toolFace": "顔",
|
||||
"toolFace": "顔ぼかし",
|
||||
"toolFill": "塗りつぶし",
|
||||
"toolBrush": "ブラシ",
|
||||
"toolText": "テキスト",
|
||||
"toolImage": "画像",
|
||||
"fillColor": "塗りつぶし色",
|
||||
"fillTolerance": "許容値",
|
||||
"canvasSizeTitle": "キャンバスサイズ",
|
||||
"modePreset": "プリセット",
|
||||
"modeCustom": "カスタム",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก",
|
||||
"saved": "บันทึกแล้ว",
|
||||
"layers": "เลเยอร์",
|
||||
"newLayer": "เลเยอร์ใหม่",
|
||||
"up": "ขึ้น",
|
||||
"down": "ลง",
|
||||
"delete": "ลบ",
|
||||
@@ -86,9 +87,13 @@
|
||||
"faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด",
|
||||
"toolPointer": "ตัวชี้",
|
||||
"toolPan": "เลื่อน",
|
||||
"toolFace": "ใบหน้า",
|
||||
"toolFace": "เบลอใบหน้า",
|
||||
"toolFill": "เติมสี",
|
||||
"toolBrush": "แปรง",
|
||||
"toolText": "ข้อความ",
|
||||
"toolImage": "รูปภาพ",
|
||||
"fillColor": "สีเติม",
|
||||
"fillTolerance": "ความคลาดเคลื่อน",
|
||||
"canvasSizeTitle": "ขนาดแคนวาส",
|
||||
"modePreset": "พรีเซ็ต",
|
||||
"modeCustom": "กำหนดเอง",
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("editor store", () => {
|
||||
|
||||
it("imports and exports project json", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
useEditorStore.getState().addLayerByType("raster");
|
||||
const json = useEditorStore.getState().exportProjectToJson();
|
||||
|
||||
useEditorStore.getState().resetProject();
|
||||
@@ -46,7 +46,7 @@ describe("editor store", () => {
|
||||
|
||||
it("records pointer draft transforms in undo history on commit", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
useEditorStore.getState().addLayerByType("raster");
|
||||
const initial = useEditorStore.getState().project.layers[0];
|
||||
expect(initial).toBeDefined();
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("editor store", () => {
|
||||
it("reorders layers up and down", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
useEditorStore.getState().addLayerByType("raster");
|
||||
const layers = useEditorStore.getState().project.layers;
|
||||
const [first, second] = layers;
|
||||
expect(first.id).not.toBe(second.id);
|
||||
|
||||
+100
-78
@@ -10,10 +10,16 @@ import {
|
||||
reorderLayer,
|
||||
serializeProjectFile,
|
||||
setCanvasSize as applyCanvasSize,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
setLayerVisible,
|
||||
setEffectEnabled,
|
||||
updateLayerTransform,
|
||||
normalizeProject,
|
||||
getAllTools,
|
||||
type EditorToolId,
|
||||
} from "@pien-studio/editor-core";
|
||||
import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types";
|
||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
|
||||
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
||||
import { hasProjectChanged } from "../lib/project-equality";
|
||||
@@ -29,13 +35,10 @@ import {
|
||||
|
||||
const DRAFT_TRANSFORM_EPSILON = 0.01;
|
||||
|
||||
export const EDITOR_TOOLS = {
|
||||
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
|
||||
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
|
||||
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
|
||||
} as const;
|
||||
export { EditorToolId };
|
||||
|
||||
export type EditorToolId = keyof typeof EDITOR_TOOLS;
|
||||
const allTools = getAllTools();
|
||||
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
|
||||
|
||||
type TransactionState = {
|
||||
baselineProject: Project;
|
||||
@@ -52,7 +55,6 @@ type EditorState = {
|
||||
clipboardLayer: Layer | null;
|
||||
isDirty: boolean;
|
||||
transaction: TransactionState | null;
|
||||
tools: typeof EDITOR_TOOLS;
|
||||
startTransaction: () => void;
|
||||
commitTransaction: () => void;
|
||||
cancelTransaction: () => void;
|
||||
@@ -70,15 +72,19 @@ type EditorState = {
|
||||
selectLayer: (layerId: string | null) => void;
|
||||
copySelectedLayer: () => void;
|
||||
cutSelectedLayer: () => void;
|
||||
pasteLayer: () => void;
|
||||
pasteLayer: (e?: ClipboardEvent) => void;
|
||||
resetProject: () => void;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: Project) => void;
|
||||
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
|
||||
addCanvasSizedLayer: (sourceUri: string, name?: string) => void;
|
||||
importImageFromFile: (file: File) => Promise<void>;
|
||||
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void;
|
||||
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
||||
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
||||
setLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void;
|
||||
setCanvasSize: (width: number, height: number) => void;
|
||||
exportProjectToJson: () => string;
|
||||
undo: () => void;
|
||||
@@ -129,7 +135,6 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
clipboardLayer: null,
|
||||
isDirty: false,
|
||||
transaction: null,
|
||||
tools: EDITOR_TOOLS,
|
||||
|
||||
startTransaction: () =>
|
||||
set((state) => {
|
||||
@@ -172,7 +177,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
applyProjectDraft: (project) => set(() => ({ project })),
|
||||
|
||||
setTool: (tool) => {
|
||||
if (!(tool in EDITOR_TOOLS)) return;
|
||||
if (!EDITOR_TOOLS[tool]) return;
|
||||
set({ tool });
|
||||
},
|
||||
|
||||
@@ -183,6 +188,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
||||
}),
|
||||
|
||||
addCanvasSizedLayer: (sourceUri, name) =>
|
||||
set((state) => {
|
||||
const { width, height } = state.project.canvas;
|
||||
const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height });
|
||||
const nextProject = addLayer(state.project, layer);
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
||||
}),
|
||||
|
||||
setSelectedLayerPosition: (x, y) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
@@ -218,8 +231,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
||||
|
||||
if (current) {
|
||||
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined);
|
||||
const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
|
||||
|
||||
if (
|
||||
typeof currentWidth === "number" &&
|
||||
@@ -284,67 +297,64 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
||||
}),
|
||||
|
||||
pasteLayer: () => {
|
||||
pasteLayer: (e?: ClipboardEvent) => {
|
||||
const state = get();
|
||||
if (!state.clipboardLayer) {
|
||||
navigator.clipboard.read().then(async (clipboardItems) => {
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () =>
|
||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("image", {
|
||||
name: "Image",
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(imageSize.width)),
|
||||
height: Math.max(1, Math.round(imageSize.height)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("data:image") || trimmed.startsWith("http") || trimmed.startsWith("blob:")) {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const layer = createLayer("image", {
|
||||
name: "Image",
|
||||
sourceUri: trimmed,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
};
|
||||
image.src = trimmed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
// Internal layer clipboard takes priority
|
||||
if (state.clipboardLayer) {
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
|
||||
return;
|
||||
}
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
const nextProject = addLayer(state.project, pasted);
|
||||
set((s) => withCommittedProject(s, nextProject, { selectedLayerId: pasted.id }));
|
||||
|
||||
async function pasteImageBlob(blob: Blob) {
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("raster", {
|
||||
name: "Image",
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(imageSize.width)),
|
||||
height: Math.max(1, Math.round(imageSize.height)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
}
|
||||
|
||||
// Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt)
|
||||
if (e?.clipboardData) {
|
||||
for (const item of Array.from(e.clipboardData.items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) { void pasteImageBlob(blob); return; }
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: async Clipboard API (requires permission, may not work on Mac Safari)
|
||||
navigator.clipboard.read().then(async (clipboardItems) => {
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
await pasteImageBlob(blob);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
resetProject: () => {
|
||||
@@ -412,7 +422,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("image", {
|
||||
const layer = createLayer("raster", {
|
||||
name,
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
@@ -428,20 +438,32 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
set((state) => {
|
||||
if (!sourceUri) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
if (!layer || layer.type !== "raster") return state;
|
||||
if (layer.sourceUri === sourceUri) return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setImageLayerFaceBlur: (layerId, faceBlur) =>
|
||||
setLayerEffect: (layerId, effect) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur });
|
||||
return withCommittedProject(state, nextProject);
|
||||
if (!layer) return state;
|
||||
return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
|
||||
}),
|
||||
|
||||
removeLayerEffect: (layerId, kind) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer) return state;
|
||||
return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind));
|
||||
}),
|
||||
|
||||
setLayerVisible: (layerId, visible) =>
|
||||
set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))),
|
||||
|
||||
setEffectEnabled: (layerId, kind, enabled) =>
|
||||
set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))),
|
||||
|
||||
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
|
||||
|
||||
undo: () =>
|
||||
|
||||
Reference in New Issue
Block a user