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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user