mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"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 { renderFaceBlurRegions } from "../lib/face-blur-renderer";
|
||||
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
|
||||
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?: "pointer" | "hand" | "face";
|
||||
faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[];
|
||||
faceOverlayLayerId?: string | null;
|
||||
faceBlurPreview?: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
function BlurredImageLayer({
|
||||
layer,
|
||||
width,
|
||||
height,
|
||||
faceBlurOverride,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
|
||||
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);
|
||||
ctx.drawImage(image, 0, 0, cw, ch);
|
||||
const blur = faceBlurOverride ?? layer.faceBlur;
|
||||
if (!blur || blur.regions.length === 0) return;
|
||||
renderFaceBlurRegions(ctx, image, blur, cw, ch);
|
||||
}, [faceBlurOverride, layer.faceBlur]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layer.sourceUri) return;
|
||||
let canceled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
imageRef.current = image;
|
||||
draw();
|
||||
};
|
||||
image.src = layer.sourceUri;
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [draw, layer.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,
|
||||
isDark,
|
||||
tool = "pointer",
|
||||
faceDetections = [],
|
||||
faceOverlayLayerId = null,
|
||||
faceBlurPreview = null,
|
||||
}: CanvasRendererProps) {
|
||||
const { t } = useTranslations();
|
||||
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,
|
||||
});
|
||||
|
||||
const faceLabelOverlays = React.useMemo(() => {
|
||||
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return [];
|
||||
return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport);
|
||||
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
|
||||
onPointerDown={(e) => {
|
||||
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
||||
onContainerPointerDown(e);
|
||||
}}
|
||||
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",
|
||||
boxShadow: "0 0 0 1px var(--color-accent-strong)",
|
||||
background: isDark ? "#17181b" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
{layers.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
const 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,
|
||||
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 && layer.sourceUri ? (
|
||||
(faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) ||
|
||||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
||||
<BlurredImageLayer
|
||||
layer={layer}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
src={layer.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>
|
||||
)}
|
||||
{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="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { AspectRatio } from "@pien-studio/types";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
interface CanvasSizeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentWidth: number;
|
||||
currentHeight: number;
|
||||
currentAspect: AspectRatio;
|
||||
onApply: (width: number, height: number, aspect: AspectRatio) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ labelKey: "editor.presetSquare", sublabel: "1080 × 1080", aspect: "1:1" as AspectRatio, width: 1080, height: 1080 },
|
||||
{ labelKey: "editor.presetPortrait45", sublabel: "1080 × 1350", aspect: "4:5" as AspectRatio, width: 1080, height: 1350 },
|
||||
{ labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 },
|
||||
{ labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 },
|
||||
{ labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 },
|
||||
{ labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 1080 },
|
||||
];
|
||||
|
||||
export function CanvasSizeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentWidth,
|
||||
currentHeight,
|
||||
currentAspect,
|
||||
onApply,
|
||||
isDark,
|
||||
}: CanvasSizeModalProps) {
|
||||
const { t } = useTranslations();
|
||||
const [mode, setMode] = React.useState<"preset" | "custom">(
|
||||
PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom",
|
||||
);
|
||||
const [customWidth, setCustomWidth] = React.useState(currentWidth.toString());
|
||||
const [customHeight, setCustomHeight] = React.useState(currentHeight.toString());
|
||||
const [selectedPreset, setSelectedPreset] = React.useState<AspectRatio>(currentAspect);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function handleApply() {
|
||||
if (mode === "preset") {
|
||||
const preset = PRESETS.find((p) => p.aspect === selectedPreset)!;
|
||||
onApply(preset.width, preset.height, preset.aspect);
|
||||
} else {
|
||||
const w = parseInt(customWidth, 10);
|
||||
const h = parseInt(customHeight, 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
|
||||
onApply(w, h, "free");
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||
const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<div className={panel} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>
|
||||
{t("editor.canvasSizeTitle")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["preset", "custom"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded border py-1.5 text-xs font-semibold capitalize ${
|
||||
mode === m
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/20 text-[#d7dae0]"
|
||||
: "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{m === "preset" ? t("editor.modePreset") : t("editor.modeCustom")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "preset" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.aspect}
|
||||
onClick={() => setSelectedPreset(p.aspect)}
|
||||
className={`rounded border p-3 text-left ${
|
||||
selectedPreset === p.aspect
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#343841] text-[#d7dae0]"
|
||||
: "border-black/10 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
|
||||
<div className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}>
|
||||
{p.sublabel}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.widthShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => setCustomWidth(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.heightShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => setCustomHeight(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="rounded border border-dashed border-black/20 p-2 text-center">
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
||||
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-3 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{t("editor.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type CanvasContextMenuProps = {
|
||||
isDark: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
labels: {
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
};
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`}
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.copy}
|
||||
</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.cut}
|
||||
</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.paste}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorCanvasStage } from "./editor-canvas-stage";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => (
|
||||
<button type="button" data-testid="canvas-renderer" onClick={() => onContextMenu?.(10, 12)}>
|
||||
canvas
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EditorCanvasStage", () => {
|
||||
it("wires context menu actions", () => {
|
||||
const onCopy = vi.fn();
|
||||
const onCloseContextMenu = vi.fn();
|
||||
|
||||
render(
|
||||
<EditorCanvasStage
|
||||
isDark={false}
|
||||
layers={[]}
|
||||
canvasWidth={100}
|
||||
canvasHeight={100}
|
||||
selectedLayerId={null}
|
||||
tool="pointer"
|
||||
faceDetections={[]}
|
||||
faceOverlayLayerId={null}
|
||||
faceBlurPreview={null}
|
||||
contextMenu={{ x: 10, y: 20 }}
|
||||
labels={{ copy: "Copy", cut: "Cut", paste: "Paste" }}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
onCloseContextMenu={onCloseContextMenu}
|
||||
onCopy={onCopy}
|
||||
onCut={() => undefined}
|
||||
onPaste={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import { CanvasContextMenu } from "./canvas-context-menu";
|
||||
|
||||
type EditorCanvasStageProps = {
|
||||
isDark: boolean;
|
||||
layers: Layer[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
contextMenu: { x: number; y: number } | null;
|
||||
labels: { copy: string; cut: string; paste: string };
|
||||
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;
|
||||
onCloseContextMenu: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
const {
|
||||
isDark,
|
||||
layers,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceBlurPreview,
|
||||
contextMenu,
|
||||
labels,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onCloseContextMenu,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? "linear-gradient(#2b2d31 1px, transparent 1px), linear-gradient(90deg, #2b2d31 1px, transparent 1px)"
|
||||
: "linear-gradient(#e8eaed 1px, transparent 1px), linear-gradient(90deg, #e8eaed 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden shadow-2xl ring-1 ring-black/10">
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
onContextMenu={onContextMenu}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
{contextMenu ? (
|
||||
<CanvasContextMenu
|
||||
isDark={isDark}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
labels={labels}
|
||||
onCopy={() => {
|
||||
onCopy();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onCut={() => {
|
||||
onCut();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onPaste={() => {
|
||||
onPaste();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { UiPreferences } from "../ui-preferences";
|
||||
import { dividerClass, hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type EditorHeaderProps = {
|
||||
isDark: boolean;
|
||||
projectTitle: string;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isDirty: boolean;
|
||||
labels: {
|
||||
file: string;
|
||||
edit: string;
|
||||
view: string;
|
||||
settings: string;
|
||||
save: string;
|
||||
exportPng: string;
|
||||
exportProjectFile: string;
|
||||
importImage: string;
|
||||
canvasSize: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
preferences: string;
|
||||
panTool: string;
|
||||
pointerTool: string;
|
||||
unsavedChanges: string;
|
||||
saved: string;
|
||||
};
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onSetHandTool: () => void;
|
||||
onSetPointerTool: () => void;
|
||||
};
|
||||
|
||||
export function EditorHeader({
|
||||
isDark,
|
||||
projectTitle,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isDirty,
|
||||
labels,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
onSetHandTool,
|
||||
onSetPointerTool,
|
||||
}: EditorHeaderProps) {
|
||||
const menuClass = `absolute left-0 top-full z-30 min-w-[180px] rounded border p-1 text-[11px] opacity-0 shadow-xl transition-opacity pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 hover:pointer-events-auto hover:opacity-100 ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" className="group">
|
||||
<p className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
pien.studio
|
||||
</p>
|
||||
<h1 className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{projectTitle}</h1>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2 text-xs font-medium">
|
||||
<MenuShell isDark={isDark} label={labels.file} menuClass={menuClass}>
|
||||
<FileMenu
|
||||
isDark={isDark}
|
||||
labels={labels}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
onSave={onSave}
|
||||
onExportPng={onExportPng}
|
||||
onExportProjectFile={onExportProjectFile}
|
||||
onImportImage={onImportImage}
|
||||
onOpenCanvasSize={onOpenCanvasSize}
|
||||
/>
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
|
||||
<EditMenu isDark={isDark} labels={labels} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onCopy={onCopy} onCut={onCut} onPaste={onPaste} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
|
||||
<ViewMenu isDark={isDark} labels={labels} onSetHandTool={onSetHandTool} onSetPointerTool={onSetPointerTool} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
|
||||
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
||||
</MenuShell>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={labels.undo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={labels.redo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
{isDirty ? labels.unsavedChanges : labels.saved}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button type="button" className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}>
|
||||
{label}
|
||||
</button>
|
||||
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
|
||||
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
||||
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} className={`w-full rounded px-2 py-1 text-left ${!canUndo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.undo}</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} className={`w-full rounded px-2 py-1 text-left ${!canRedo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.redo}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.copy}</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.cut}</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.paste}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSetHandTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.panTool}</button>
|
||||
<button type="button" onClick={onSetPointerTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.pointerTool}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<p className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}>{preferences}</p>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorMobileSection } from "./editor-mobile-section";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: () => <div data-testid="canvas-renderer" />,
|
||||
}));
|
||||
|
||||
describe("EditorMobileSection", () => {
|
||||
it("shows face detection helper message in face mode", () => {
|
||||
render(
|
||||
<EditorMobileSection
|
||||
isDark={false}
|
||||
canvasWidth={100}
|
||||
canvasHeight={120}
|
||||
layers={[]}
|
||||
selectedLayerId={null}
|
||||
tool="face"
|
||||
faceDetections={[{ x: 1, y: 1, width: 10, height: 10, label: "f" }]}
|
||||
faceOverlayLayerId={null}
|
||||
faceStatus="idle"
|
||||
faceBlurPreview={null}
|
||||
labels={{
|
||||
resize: "Resize",
|
||||
faceMlFailedShort: "Face failed",
|
||||
detectingFacesShort: "Detecting",
|
||||
faceDetectionTip: (count) => `Faces ${count}`,
|
||||
import: "Import",
|
||||
mood: "Mood",
|
||||
quick: "Quick",
|
||||
face: "Face",
|
||||
decor: "Decor",
|
||||
}}
|
||||
onOpenCanvasSize={() => undefined}
|
||||
onImportImage={() => undefined}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Faces 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Import")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorMobileSectionProps = {
|
||||
isDark: boolean;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
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;
|
||||
labels: {
|
||||
resize: string;
|
||||
faceMlFailedShort: string;
|
||||
detectingFacesShort: string;
|
||||
faceDetectionTip: (count: number) => string;
|
||||
import: string;
|
||||
mood: string;
|
||||
quick: string;
|
||||
face: string;
|
||||
decor: string;
|
||||
};
|
||||
onOpenCanvasSize: () => void;
|
||||
onImportImage: () => void;
|
||||
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;
|
||||
};
|
||||
|
||||
export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
const {
|
||||
isDark,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceStatus,
|
||||
faceBlurPreview,
|
||||
labels,
|
||||
onOpenCanvasSize,
|
||||
onImportImage,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<section className="lg:hidden">
|
||||
<div className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}>
|
||||
{canvasWidth} x {canvasHeight}px
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenCanvasSize}
|
||||
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.resize}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border ${isDark ? "border-white/10 bg-[#17181b]" : "border-black/10 bg-[#f7f8fa]"}`}
|
||||
style={{ height: "55vw", maxHeight: "70vh" }}
|
||||
>
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
</div>
|
||||
{tool === "face" ? (
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? labels.faceMlFailedShort
|
||||
: faceStatus === "detecting"
|
||||
? labels.detectingFacesShort
|
||||
: labels.faceDetectionTip(faceDetections.length)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportImage}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.import}
|
||||
</button>
|
||||
{[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => (
|
||||
<button
|
||||
key={toolLabel}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{toolLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
||||
import { FacePanel } from "./face-panel";
|
||||
import { HistoryPanel } from "./history-panel";
|
||||
import { LayersPanel } from "./layers-panel";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorSidebarProps = {
|
||||
isDark: boolean;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayer: Layer | null;
|
||||
history: { past: Project[]; future: Project[] };
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: FaceBlurMethod;
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
onSelectLayer: (layerId: string | null) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
onSetBlurMethod: (method: FaceBlurMethod) => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function EditorSidebar({
|
||||
isDark,
|
||||
tool,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
history,
|
||||
canUndo,
|
||||
canRedo,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
onSelectLayer,
|
||||
onMoveLayerOrder,
|
||||
onRemoveSelectedLayer,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onJumpToPast,
|
||||
onJumpToFuture,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: EditorSidebarProps) {
|
||||
return (
|
||||
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="space-y-3">
|
||||
<LayersPanel
|
||||
layers={layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
isDark={isDark}
|
||||
onSelectLayer={(layerId) => onSelectLayer(layerId)}
|
||||
onMoveLayerOrder={onMoveLayerOrder}
|
||||
onRemoveSelectedLayer={onRemoveSelectedLayer}
|
||||
/>
|
||||
|
||||
{tool === "face" ? (
|
||||
<FacePanel
|
||||
isDark={isDark}
|
||||
selectedLayer={selectedLayer}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
|
||||
onSetBlurMethod={onSetBlurMethod}
|
||||
onSetBlurAmount={onSetBlurAmount}
|
||||
onSetCensorColor={onSetCensorColor}
|
||||
onToggleFaceIndex={onToggleFaceIndex}
|
||||
onBlur={onBlur}
|
||||
onClearBlur={onClearBlur}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HistoryPanel
|
||||
history={history}
|
||||
isDark={isDark}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
onJumpToPast={onJumpToPast}
|
||||
onJumpToFuture={onJumpToFuture}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
isDark: boolean;
|
||||
selectedLayer: Layer | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: "gaussian" | "pixelate" | "censor";
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
hasActiveBlur: boolean;
|
||||
onSetBlurMethod: (method: "gaussian" | "pixelate" | "censor") => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function FacePanel({
|
||||
isDark,
|
||||
selectedLayer,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
hasActiveBlur,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: Props) {
|
||||
const { t } = useTranslations();
|
||||
const hasFaces = faceDetections.length > 0;
|
||||
|
||||
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.faceTool")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>{faceDetections.length}</span>
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? t("editor.faceMlFailed")
|
||||
: faceStatus === "detecting"
|
||||
? t("editor.detectingFaces")
|
||||
: selectedLayer?.type !== "image"
|
||||
? t("editor.selectImageLayer")
|
||||
: faceDetections.length === 0
|
||||
? t("editor.noFacesFound")
|
||||
: t("editor.facesDetected")}
|
||||
</p>
|
||||
{hasFaces ? (
|
||||
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{faceDetections.map((face, index) => (
|
||||
<button
|
||||
key={`face-result-${index}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] ${isDark ? "bg-white/5 text-[#d7dae0]" : "bg-white text-[#1f2430]"} ${
|
||||
selectedFaceIndices.includes(index)
|
||||
? isDark
|
||||
? "ring-1 ring-[#7cdcff]"
|
||||
: "ring-1 ring-[#00b7f0]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onToggleFaceIndex(index)}
|
||||
>
|
||||
{facePreviews[index]?.src ? (
|
||||
<Image src={facePreviews[index].src} alt={`Face preview ${index + 1}`} width={40} height={40} unoptimized className="h-10 w-10 rounded object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold">{t("editor.person")} {index + 1}</p>
|
||||
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`mt-3 space-y-2 rounded-md border p-2 ${panelInsetClass(isDark)}`}>
|
||||
<label className="block text-[11px] font-semibold">{t("editor.blurMethod")}</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{[
|
||||
{ id: "gaussian", label: t("editor.soft") },
|
||||
{ id: "pixelate", label: t("editor.pixelate") },
|
||||
{ id: "censor", label: t("editor.censor") },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
blurMethod === option.id
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "bg-white/10 text-[#d7dae0]"
|
||||
: "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{blurMethod !== "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.strength")}</span>
|
||||
<span>{blurAmount}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={40}
|
||||
value={blurAmount}
|
||||
onChange={(event) => onSetBlurAmount(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{blurMethod === "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.color")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className="h-6 w-6 cursor-pointer rounded border-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className={`flex-1 rounded border px-1 py-0.5 text-[11px] ${isDark ? "border-white/20 bg-white/10 text-white" : "border-black/20 bg-white text-black"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBlur(selectedFaceIndices)}
|
||||
disabled={!hasFaces || selectedFaceIndices.length === 0}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
!hasFaces || selectedFaceIndices.length === 0
|
||||
? "opacity-50"
|
||||
: "bg-[var(--color-accent-strong)] text-white"
|
||||
}`}
|
||||
>
|
||||
{hasFaces && selectedFaceIndices.length === 0
|
||||
? t("editor.selectFacesToBlur")
|
||||
: t("editor.blurFaces", { count: selectedFaceIndices.length })}
|
||||
</button>
|
||||
{hasActiveBlur ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearBlur}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
isDark ? "bg-white/10 text-[#d7dae0]" : "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.clearBlur")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
history: { past: Project[]; future: Project[] };
|
||||
isDark: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: 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.history")}</h2>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} title={t("editor.undo")} className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} title={t("editor.redo")} className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}>
|
||||
{[...history.past].reverse().map((_, i) => {
|
||||
const idx = history.past.length - i;
|
||||
return (
|
||||
<button
|
||||
key={`past-${idx}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToPast(idx)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{idx > history.past.length - 2 ? t("editor.beforeLastAction") : `${t("editor.step")} ${idx}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button" className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`} disabled>
|
||||
{t("editor.now")}
|
||||
</button>
|
||||
{[...history.future].map((_, i) => (
|
||||
<button
|
||||
key={`future-${i}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToFuture(i)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{t("editor.undoneStep")} {i + 1}
|
||||
</button>
|
||||
))}
|
||||
{history.past.length === 0 && history.future.length === 0 ? <p className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}>{t("editor.noHistoryYet")}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
isDark: boolean;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
};
|
||||
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: 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>
|
||||
<div className={`mt-3 max-h-[320px] space-y-1 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";
|
||||
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 ${
|
||||
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>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { MousePointer2 } from "lucide-react";
|
||||
import type { EditorToolController } from "../../lib/editor-tool-controller";
|
||||
import type { EditorToolId } from "../../store/editor-store";
|
||||
|
||||
type Props = {
|
||||
controllers: EditorToolController[];
|
||||
selectedTool: EditorToolId;
|
||||
isDark: boolean;
|
||||
icons: Record<string, React.ComponentType<{ className?: string }>>;
|
||||
onSetTool: (tool: EditorToolId) => void;
|
||||
};
|
||||
|
||||
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
|
||||
return (
|
||||
<aside className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{controllers.map((controller) => {
|
||||
const Icon = icons[controller.id] ?? MousePointer2;
|
||||
const isSelected = controller.kind === "mode" && controller.id === selectedTool;
|
||||
return (
|
||||
<button
|
||||
key={controller.id}
|
||||
type="button"
|
||||
title={controller.label}
|
||||
onClick={() => (controller.kind === "mode" ? onSetTool(controller.id) : controller.run())}
|
||||
className={`rounded border p-2 text-[11px] font-medium ${
|
||||
isSelected
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#2d3036] text-[#d7dae0] hover:bg-[#353942]"
|
||||
: "border-black/10 bg-white text-[#1f2430] hover:bg-[#f4f6f9]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import { cx, subtleButtonClass } from "../lib/theme";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
||||
const { theme, locale, setTheme, setLocale } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const isDark = theme === "dark";
|
||||
const wrapperHeight = compact ? "h-7" : "h-9";
|
||||
const textSize = compact ? "text-[10px]" : "text-xs";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className={cx("inline-flex items-center rounded border px-2", subtleButtonClass(isDark), wrapperHeight)}>
|
||||
<span className={cx("mr-2 font-semibold opacity-70", textSize)}>{t("ui.locale")}</span>
|
||||
<select
|
||||
aria-label={t("ui.locale")}
|
||||
value={locale}
|
||||
onChange={(event) => setLocale(event.target.value as "en" | "th" | "ja")}
|
||||
className={cx(
|
||||
"bg-transparent font-semibold outline-none",
|
||||
textSize,
|
||||
isDark ? "text-[#f5f7fa]" : "text-[#1f2430]",
|
||||
)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="th">Thai</option>
|
||||
<option value="ja">Japanese</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
|
||||
>
|
||||
{theme === "dark" ? t("ui.darkMode") : t("ui.lightMode")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user