feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
+89 -37
View File
@@ -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