mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import NextImage from "next/image";
|
|
import { RotateCw } from "lucide-react";
|
|
import {
|
|
CANVAS_HANDLE_BASE_SIZE,
|
|
CANVAS_ROTATE_HANDLE_BASE_SIZE,
|
|
} from "../lib/editor-constants";
|
|
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
|
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 {
|
|
createStroke,
|
|
paintSegment,
|
|
type BrushStroke,
|
|
type BrushOptions,
|
|
} from "../lib/brush-painter";
|
|
import {
|
|
getLayerRuntimeSource,
|
|
type Layer,
|
|
type LayerEffect,
|
|
} from "@pien-studio/types";
|
|
|
|
interface CanvasRendererProps {
|
|
layers: Layer[];
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
selectedLayerId: string | null;
|
|
onSelectLayer: (id: string | null) => void;
|
|
onMoveLayer: (id: string, x: number, y: number) => void;
|
|
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
|
onResizeLayer?: (id: string, width: number, height: number) => void;
|
|
onResizeLayerEnd?: (id: string, width: number, height: number) => void;
|
|
onRotateLayer?: (id: string, rotation: number) => void;
|
|
onRotateLayerEnd?: (id: string, rotation: number) => void;
|
|
onInteractionStart?: () => void;
|
|
onInteractionEnd?: () => void;
|
|
onContextMenu?: (x: number, y: number) => void;
|
|
isDark: boolean;
|
|
tool?: string;
|
|
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
|
brushOptions?: BrushOptions;
|
|
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
|
|
faceDetections?: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
label?: string;
|
|
}[];
|
|
faceOverlayLayerId?: string | null;
|
|
faceBlurPreview?: {
|
|
layerId: string;
|
|
effects: LayerEffect[];
|
|
} | null;
|
|
}
|
|
|
|
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,
|
|
effectsOverride,
|
|
}: {
|
|
layer: Layer;
|
|
width: number;
|
|
height: number;
|
|
effectsOverride?: LayerEffect[] | null;
|
|
}) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
|
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
|
|
|
const activeEffects = effectsOverride ?? layer.effects;
|
|
const sourceUri = getLayerRuntimeSource(layer);
|
|
|
|
const draw = React.useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const image = imageRef.current;
|
|
if (!canvas || !image) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
renderLayerWithEffects(
|
|
ctx,
|
|
image,
|
|
activeEffects,
|
|
canvas.width,
|
|
canvas.height,
|
|
);
|
|
}, [activeEffects]);
|
|
|
|
React.useEffect(() => {
|
|
if (!sourceUri) return;
|
|
let canceled = false;
|
|
const image = new Image();
|
|
image.crossOrigin = "anonymous";
|
|
image.onload = () => {
|
|
if (canceled) return;
|
|
imageRef.current = image;
|
|
draw();
|
|
};
|
|
image.src = sourceUri;
|
|
return () => {
|
|
canceled = true;
|
|
};
|
|
}, [draw, sourceUri]);
|
|
|
|
React.useEffect(() => {
|
|
draw();
|
|
}, [draw, width, height]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={Math.max(1, Math.round(width))}
|
|
height={Math.max(1, Math.round(height))}
|
|
className="pointer-events-none h-full w-full rounded object-cover"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function CanvasRenderer({
|
|
layers,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
selectedLayerId,
|
|
onSelectLayer,
|
|
onMoveLayer,
|
|
onMoveLayerEnd,
|
|
onResizeLayer,
|
|
onResizeLayerEnd,
|
|
onRotateLayer,
|
|
onRotateLayerEnd,
|
|
onInteractionStart,
|
|
onInteractionEnd,
|
|
onContextMenu,
|
|
onFillLayer,
|
|
brushOptions,
|
|
onBrushCommit,
|
|
isDark,
|
|
tool = "pointer",
|
|
faceDetections = [],
|
|
faceOverlayLayerId = null,
|
|
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,
|
|
isSpacePan,
|
|
onContainerPointerDown,
|
|
onContainerPointerMove,
|
|
onContainerPointerUp,
|
|
onLayerPointerDown,
|
|
onResizeHandleDown,
|
|
onRotateHandleDown,
|
|
onTouchStart,
|
|
onTouchMove,
|
|
onTouchEnd,
|
|
onContextMenuOpen,
|
|
} = useCanvasInteractions({
|
|
canvasWidth,
|
|
canvasHeight,
|
|
tool,
|
|
onSelectLayer,
|
|
onMoveLayer,
|
|
onMoveLayerEnd,
|
|
onResizeLayer,
|
|
onResizeLayerEnd,
|
|
onRotateLayer,
|
|
onRotateLayerEnd,
|
|
onInteractionStart,
|
|
onInteractionEnd,
|
|
onContextMenu,
|
|
onFillLayer,
|
|
onBrushStrokeStart: handleBrushStrokeStart,
|
|
onBrushStrokeMove: handleBrushStrokeMove,
|
|
onBrushStrokeEnd: handleBrushStrokeEnd,
|
|
});
|
|
|
|
const faceLabelOverlays = React.useMemo(() => {
|
|
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0)
|
|
return [];
|
|
return buildFaceLabelOverlays(
|
|
layers,
|
|
faceOverlayLayerId,
|
|
faceDetections,
|
|
viewport,
|
|
);
|
|
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative overflow-hidden"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
touchAction: "none",
|
|
userSelect: "none",
|
|
cursor: isSpacePan
|
|
? "grab"
|
|
: (getToolUiDefinition(tool)?.cursor ?? "default"),
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (
|
|
getToolDefinition(tool)?.interactionMode === "select" &&
|
|
e.button === 0
|
|
)
|
|
onSelectLayer(null);
|
|
onContainerPointerDown(e);
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onPointerMove={onContainerPointerMove}
|
|
onPointerUp={onContainerPointerUp}
|
|
onPointerCancel={onContainerPointerUp}
|
|
onDoubleClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onContextMenu={onContextMenuOpen}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
willChange: "transform",
|
|
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
|
transformOrigin: "0 0",
|
|
outline: `${2 / viewport.scale}px solid var(--color-accent-strong)`,
|
|
background:
|
|
"linear-gradient(45deg, #d0d0d0 25%, transparent 25%), linear-gradient(-45deg, #d0d0d0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d0d0d0 75%), linear-gradient(-45deg, transparent 75%, #d0d0d0 75%)",
|
|
backgroundSize: "16px 16px",
|
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
|
}}
|
|
>
|
|
{layers.map((layer, idx) => {
|
|
const isSelected = layer.id === selectedLayerId;
|
|
const isImage = layer.type === "raster";
|
|
const sourceUri = getLayerRuntimeSource(layer);
|
|
const layerWidth = layer.width;
|
|
const layerHeight = layer.height;
|
|
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
|
const handleSizePx = `${handleSize}px`;
|
|
const largeHandleSize =
|
|
CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
|
|
const largeHandleSizePx = `${largeHandleSize}px`;
|
|
|
|
return (
|
|
<div
|
|
key={layer.id}
|
|
className="absolute"
|
|
style={{
|
|
left: layer.x,
|
|
top: layer.y,
|
|
width: layerWidth,
|
|
height: layerHeight,
|
|
transform: `rotate(${layer.rotation}deg)`,
|
|
opacity: layer.opacity,
|
|
display: layer.visible === false ? "none" : undefined,
|
|
cursor: getToolUiDefinition(tool)?.cursor ?? "move",
|
|
border: isSelected
|
|
? "2px solid var(--color-accent-strong)"
|
|
: "1px dashed transparent",
|
|
outline: isSelected
|
|
? "2px solid var(--color-accent-strong)"
|
|
: "none",
|
|
outlineOffset: "2px",
|
|
zIndex: idx,
|
|
}}
|
|
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
|
onClick={() => onSelectLayer(layer.id)}
|
|
>
|
|
{isImage && sourceUri ? (
|
|
layer.effects.length > 0 ||
|
|
(faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
|
<EffectImageLayer
|
|
layer={layer}
|
|
width={layerWidth ?? 1}
|
|
height={layerHeight ?? 1}
|
|
effectsOverride={
|
|
faceBlurPreview && faceBlurPreview.layerId === layer.id
|
|
? faceBlurPreview.effects
|
|
: null
|
|
}
|
|
/>
|
|
) : (
|
|
<NextImage
|
|
src={sourceUri}
|
|
alt={layer.name ?? t("editor.layer")}
|
|
width={layerWidth ?? 1}
|
|
height={layerHeight ?? 1}
|
|
unoptimized
|
|
className="pointer-events-none h-full w-full rounded object-cover"
|
|
draggable={false}
|
|
/>
|
|
)
|
|
) : (
|
|
<div
|
|
className={`flex items-center justify-center rounded border px-2 py-1 text-xs font-semibold ${
|
|
isSelected
|
|
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
|
: isDark
|
|
? "border-white/20 bg-[#2d3036] text-[#d7dae0]"
|
|
: "border-black/15 bg-white text-[#1f2430]"
|
|
}`}
|
|
>
|
|
{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
|
|
key={`${layer.id}-face-${index}`}
|
|
className="absolute pointer-events-none border-2 border-[#00d2ff]"
|
|
style={{
|
|
left: face.x,
|
|
top: face.y,
|
|
width: face.width,
|
|
height: face.height,
|
|
zIndex: layers.length + 20 + index,
|
|
boxShadow: "0 0 0 1px rgba(0,0,0,0.35)",
|
|
}}
|
|
/>
|
|
))
|
|
: null}
|
|
{isSelected && tool === "pointer" && isImage && onResizeLayer ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
|
style={{
|
|
width: handleSizePx,
|
|
height: handleSizePx,
|
|
top: -handleSize / 2,
|
|
left: -handleSize / 2,
|
|
}}
|
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
|
|
title={t("editor.resize")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
|
style={{
|
|
width: handleSizePx,
|
|
height: handleSizePx,
|
|
top: -handleSize / 2,
|
|
right: -handleSize / 2,
|
|
}}
|
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
|
|
title={t("editor.resize")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
|
style={{
|
|
width: handleSizePx,
|
|
height: handleSizePx,
|
|
bottom: -handleSize / 2,
|
|
left: -handleSize / 2,
|
|
}}
|
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
|
|
title={t("editor.resize")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
|
style={{
|
|
width: handleSizePx,
|
|
height: handleSizePx,
|
|
bottom: -handleSize / 2,
|
|
right: -handleSize / 2,
|
|
}}
|
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
|
|
title={t("editor.resize")}
|
|
/>
|
|
<div
|
|
className="absolute pointer-events-none"
|
|
style={{
|
|
top: -largeHandleSize,
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
height: largeHandleSize,
|
|
}}
|
|
>
|
|
<div
|
|
className="w-px bg-[var(--color-accent-strong)]"
|
|
style={{
|
|
width: "1px",
|
|
height: "100%",
|
|
marginLeft: "0px",
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="pointer-events-auto absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
|
|
style={{
|
|
width: largeHandleSizePx,
|
|
height: largeHandleSizePx,
|
|
}}
|
|
onPointerDown={(e) => onRotateHandleDown(e, layer)}
|
|
title={t("editor.rotate")}
|
|
>
|
|
<RotateCw
|
|
className="text-white"
|
|
style={{
|
|
width: handleSize * 0.6,
|
|
height: handleSize * 0.6,
|
|
}}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{faceLabelOverlays.map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="pointer-events-none absolute rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap text-white"
|
|
style={{
|
|
left: label.left,
|
|
top: label.top,
|
|
background: "rgba(0, 210, 255, 0.9)",
|
|
boxShadow: "0 2px 6px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.22)",
|
|
textShadow: "0 1px 1px rgba(0,0,0,0.35)",
|
|
zIndex: layers.length + 100,
|
|
}}
|
|
>
|
|
{label.text}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|