mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
♻️ refactor!: massive refactor
This commit is contained in:
@@ -5,8 +5,18 @@ import { CanvasRenderer } from "./canvas-renderer";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ alt, src, unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
||||
React.createElement("img", { alt, src, "data-unoptimized": unoptimized ? "true" : undefined, ...props }),
|
||||
default: ({
|
||||
alt,
|
||||
src,
|
||||
unoptimized,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
||||
React.createElement("img", {
|
||||
alt,
|
||||
src,
|
||||
"data-unoptimized": unoptimized ? "true" : undefined,
|
||||
...props,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-translations", () => ({
|
||||
@@ -21,14 +31,17 @@ class ResizeObserverMock {
|
||||
describe("CanvasRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: vi.fn() });
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the rotation handle interactive", () => {
|
||||
const layer: Layer = {
|
||||
id: "layer-1",
|
||||
type: "raster",
|
||||
sourceUri: "data:image/png;base64,test",
|
||||
asset: { kind: "inline", uri: "data:image/png;base64,test" },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
@@ -58,8 +71,23 @@ describe("CanvasRenderer", () => {
|
||||
const rotateHandle = screen.getByTitle("editor.rotate");
|
||||
expect(rotateHandle).toHaveClass("pointer-events-auto");
|
||||
|
||||
fireEvent(rotateHandle, new MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 50, clientY: 0 }));
|
||||
fireEvent(rotateHandle, new MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
fireEvent(
|
||||
rotateHandle,
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 50,
|
||||
clientY: 0,
|
||||
}),
|
||||
);
|
||||
fireEvent(
|
||||
rotateHandle,
|
||||
new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90);
|
||||
});
|
||||
|
||||
@@ -13,8 +13,17 @@ 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 type { Layer, LayerEffect } from "@pien-studio/types";
|
||||
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[];
|
||||
@@ -50,7 +59,15 @@ interface CanvasRendererProps {
|
||||
} | null;
|
||||
}
|
||||
|
||||
function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) {
|
||||
function BrushOverlayCanvas({
|
||||
stroke,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
stroke: HTMLCanvasElement;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -87,6 +104,7 @@ function EffectImageLayer({
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const activeEffects = effectsOverride ?? layer.effects;
|
||||
const sourceUri = getLayerRuntimeSource(layer);
|
||||
|
||||
const draw = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -95,11 +113,17 @@ function EffectImageLayer({
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height);
|
||||
renderLayerWithEffects(
|
||||
ctx,
|
||||
image,
|
||||
activeEffects,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}, [activeEffects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layer.sourceUri) return;
|
||||
if (!sourceUri) return;
|
||||
let canceled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
@@ -108,11 +132,11 @@ function EffectImageLayer({
|
||||
imageRef.current = image;
|
||||
draw();
|
||||
};
|
||||
image.src = layer.sourceUri;
|
||||
image.src = sourceUri;
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [draw, layer.sourceUri]);
|
||||
}, [draw, sourceUri]);
|
||||
|
||||
React.useEffect(() => {
|
||||
draw();
|
||||
@@ -156,32 +180,51 @@ export function CanvasRenderer({
|
||||
|
||||
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 [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 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 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 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,
|
||||
@@ -226,13 +269,7 @@ export function CanvasRenderer({
|
||||
faceDetections,
|
||||
viewport,
|
||||
);
|
||||
}, [
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
layers,
|
||||
tool,
|
||||
viewport,
|
||||
]);
|
||||
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -243,10 +280,16 @@ export function CanvasRenderer({
|
||||
height: "100%",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"),
|
||||
cursor: isSpacePan
|
||||
? "grab"
|
||||
: (getToolUiDefinition(tool)?.cursor ?? "default"),
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null);
|
||||
if (
|
||||
getToolDefinition(tool)?.interactionMode === "select" &&
|
||||
e.button === 0
|
||||
)
|
||||
onSelectLayer(null);
|
||||
onContainerPointerDown(e);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -282,12 +325,9 @@ export function CanvasRenderer({
|
||||
{layers.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImage = layer.type === "raster";
|
||||
const layerWidth =
|
||||
layer.width ??
|
||||
(isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight =
|
||||
layer.height ??
|
||||
(isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
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 =
|
||||
@@ -319,8 +359,9 @@ export function CanvasRenderer({
|
||||
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
>
|
||||
{isImage && layer.sourceUri ? (
|
||||
layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
||||
{isImage && sourceUri ? (
|
||||
layer.effects.length > 0 ||
|
||||
(faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
||||
<EffectImageLayer
|
||||
layer={layer}
|
||||
width={layerWidth ?? 1}
|
||||
@@ -333,7 +374,7 @@ export function CanvasRenderer({
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
src={layer.sourceUri}
|
||||
src={sourceUri}
|
||||
alt={layer.name ?? t("editor.layer")}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
@@ -356,7 +397,11 @@ export function CanvasRenderer({
|
||||
</div>
|
||||
)}
|
||||
{brushOverlay && brushOverlay.layerId === layer.id ? (
|
||||
<BrushOverlayCanvas stroke={brushOverlay.canvas} width={layerWidth ?? 1} height={layerHeight ?? 1} />
|
||||
<BrushOverlayCanvas
|
||||
stroke={brushOverlay.canvas}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
/>
|
||||
) : null}
|
||||
{tool === "face" && faceOverlayLayerId === layer.id
|
||||
? faceDetections.map((face, index) => (
|
||||
|
||||
@@ -15,12 +15,48 @@ interface CanvasSizeModalProps {
|
||||
}
|
||||
|
||||
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 },
|
||||
{
|
||||
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({
|
||||
@@ -37,8 +73,11 @@ export function CanvasSizeModal({
|
||||
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);
|
||||
const [customHeight, setCustomHeight] = React.useState(
|
||||
currentHeight.toString(),
|
||||
);
|
||||
const [selectedPreset, setSelectedPreset] =
|
||||
React.useState<AspectRatio>(currentAspect);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -56,7 +95,8 @@ export function CanvasSizeModal({
|
||||
onClose();
|
||||
}
|
||||
|
||||
const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||
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"
|
||||
}`;
|
||||
@@ -65,7 +105,9 @@ export function CanvasSizeModal({
|
||||
<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]"}`}>
|
||||
<h2
|
||||
className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
{t("editor.canvasSizeTitle")}
|
||||
</h2>
|
||||
<button
|
||||
@@ -109,7 +151,9 @@ export function CanvasSizeModal({
|
||||
}`}
|
||||
>
|
||||
<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]"}`}>
|
||||
<div
|
||||
className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}
|
||||
>
|
||||
{p.sublabel}
|
||||
</div>
|
||||
</button>
|
||||
@@ -118,33 +162,55 @@ export function CanvasSizeModal({
|
||||
) : (
|
||||
<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>
|
||||
<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]"
|
||||
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>
|
||||
<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>
|
||||
<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]"
|
||||
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>
|
||||
<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]"}`}>
|
||||
<span
|
||||
className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||
>
|
||||
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,7 +221,9 @@ export function CanvasSizeModal({
|
||||
<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]"
|
||||
isDark
|
||||
? "border-white/20 text-[#d7dae0]"
|
||||
: "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.cancel")}
|
||||
|
||||
@@ -15,22 +15,44 @@ type CanvasContextMenuProps = {
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
|
||||
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]"
|
||||
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)}`}>
|
||||
<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)}`}>
|
||||
<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)}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPaste}
|
||||
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||
>
|
||||
{labels.paste}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,20 @@ 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 }) => (
|
||||
CanvasRenderer: ({
|
||||
onContextMenu,
|
||||
}: {
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ type: "button", "data-testid": "canvas-renderer", onClick: () => onContextMenu?.(10, 12) },
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": "canvas-renderer",
|
||||
onClick: () => onContextMenu?.(10, 12),
|
||||
},
|
||||
"canvas",
|
||||
)
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EditorCanvasStage", () => {
|
||||
|
||||
@@ -69,7 +69,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
|
||||
<div
|
||||
className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}
|
||||
>
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
style={{
|
||||
|
||||
@@ -70,17 +70,27 @@ export function EditorHeader({
|
||||
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]"
|
||||
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"}`}>
|
||||
<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]"}`}>
|
||||
<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>
|
||||
<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">
|
||||
@@ -98,12 +108,31 @@ export function EditorHeader({
|
||||
/>
|
||||
</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} />
|
||||
<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} />
|
||||
<ViewMenu
|
||||
isDark={isDark}
|
||||
labels={labels}
|
||||
onSetHandTool={onSetHandTool}
|
||||
onSetPointerTool={onSetPointerTool}
|
||||
/>
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
|
||||
<MenuShell
|
||||
isDark={isDark}
|
||||
label={labels.settings}
|
||||
menuClass={menuClass}
|
||||
>
|
||||
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
||||
</MenuShell>
|
||||
</nav>
|
||||
@@ -116,7 +145,9 @@ export function EditorHeader({
|
||||
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]"
|
||||
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" />
|
||||
@@ -127,12 +158,16 @@ export function EditorHeader({
|
||||
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]"
|
||||
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]"}`}>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}
|
||||
>
|
||||
{isDirty ? labels.unsavedChanges : labels.saved}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,10 +175,23 @@ export function EditorHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
|
||||
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"}`}>
|
||||
<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()}>
|
||||
@@ -176,13 +224,43 @@ function FileMenu({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportImage}
|
||||
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||
>
|
||||
{labels.importImage}
|
||||
</button>
|
||||
<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>
|
||||
<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={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</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>
|
||||
);
|
||||
}
|
||||
@@ -210,29 +288,93 @@ function EditMenu({
|
||||
}) {
|
||||
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>
|
||||
<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>
|
||||
<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 }) {
|
||||
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>
|
||||
<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 }) {
|
||||
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>
|
||||
<p
|
||||
className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}
|
||||
>
|
||||
{preferences}
|
||||
</p>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorMobileSection } from "./editor-mobile-section";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: () => React.createElement("div", { "data-testid": "canvas-renderer" }),
|
||||
CanvasRenderer: () =>
|
||||
React.createElement("div", { "data-testid": "canvas-renderer" }),
|
||||
}));
|
||||
|
||||
describe("EditorMobileSection", () => {
|
||||
@@ -17,7 +18,17 @@ describe("EditorMobileSection", () => {
|
||||
layers: [],
|
||||
selectedLayerId: null,
|
||||
tool: "face",
|
||||
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }],
|
||||
faceDetections: [
|
||||
{
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
label: "f",
|
||||
sourceWidth: 100,
|
||||
sourceHeight: 100,
|
||||
},
|
||||
],
|
||||
faceOverlayLayerId: null,
|
||||
faceStatus: "idle",
|
||||
faceBlurPreview: null,
|
||||
|
||||
@@ -66,15 +66,21 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
|
||||
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={`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]"}`}>
|
||||
<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]"
|
||||
isDark
|
||||
? "border-white/20 text-[#d7dae0]"
|
||||
: "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.resize}
|
||||
@@ -106,7 +112,9 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
{tool === "face" && (
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
<p
|
||||
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{faceStatus === "unsupported"
|
||||
? labels.faceMlFailedShort
|
||||
: faceStatus === "detecting"
|
||||
@@ -116,27 +124,35 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<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]"
|
||||
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>
|
||||
))}
|
||||
{[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>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
||||
import type {
|
||||
FaceBlurMethod,
|
||||
Layer,
|
||||
LayerEffect,
|
||||
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";
|
||||
import type {
|
||||
FaceDetectionOverlay,
|
||||
FacePreview,
|
||||
} from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorSidebarProps = {
|
||||
isDark: boolean;
|
||||
@@ -37,7 +45,11 @@ type EditorSidebarProps = {
|
||||
selectedFaceIndices: number[];
|
||||
onSelectLayer: (layerId: string | null) => void;
|
||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
|
||||
onSetEffectEnabled: (
|
||||
layerId: string,
|
||||
kind: LayerEffect["kind"],
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onUndo: () => void;
|
||||
@@ -99,7 +111,9 @@ export function EditorSidebar({
|
||||
onClearBlur,
|
||||
}: EditorSidebarProps) {
|
||||
return (
|
||||
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<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}
|
||||
@@ -114,24 +128,44 @@ export function EditorSidebar({
|
||||
/>
|
||||
|
||||
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
|
||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
|
||||
>
|
||||
<p
|
||||
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
Fill
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={fillColor ?? "#ff0000"}
|
||||
onChange={(e) => onSetFillColor(e.target.value)}
|
||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||
/>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
{fillColor ?? "#ff0000"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Tolerance
|
||||
</label>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{fillTolerance ?? 32}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -154,42 +188,107 @@ export function EditorSidebar({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
|
||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
||||
{tool === "brush" &&
|
||||
onSetBrushColor &&
|
||||
onSetBrushSize &&
|
||||
onSetBrushOpacity &&
|
||||
onSetBrushHardness ? (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
|
||||
>
|
||||
<p
|
||||
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
Brush
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={brushColor ?? "#000000"}
|
||||
onChange={(e) => onSetBrushColor(e.target.value)}
|
||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||
/>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
{brushColor ?? "#000000"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Size
|
||||
</label>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{brushSize ?? 20}px
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={200}
|
||||
value={brushSize ?? 20}
|
||||
onChange={(e) => onSetBrushSize(Number(e.target.value))}
|
||||
className="w-full accent-[var(--color-accent-strong)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Opacity
|
||||
</label>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{Math.round((brushOpacity ?? 1) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round((brushOpacity ?? 1) * 100)}
|
||||
onChange={(e) =>
|
||||
onSetBrushOpacity(Number(e.target.value) / 100)
|
||||
}
|
||||
className="w-full accent-[var(--color-accent-strong)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
|
||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
|
||||
<label
|
||||
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
Hardness
|
||||
</label>
|
||||
<span
|
||||
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{Math.round((brushHardness ?? 0.8) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round((brushHardness ?? 0.8) * 100)}
|
||||
onChange={(e) =>
|
||||
onSetBrushHardness(Number(e.target.value) / 100)
|
||||
}
|
||||
className="w-full accent-[var(--color-accent-strong)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +305,10 @@ export function EditorSidebar({
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
|
||||
hasActiveBlur={Boolean(
|
||||
selectedLayer &&
|
||||
selectedLayer.effects.some((e) => e.kind === "face-blur"),
|
||||
)}
|
||||
onSetBlurMethod={onSetBlurMethod}
|
||||
onSetBlurAmount={onSetBlurAmount}
|
||||
onSetCensorColor={onSetCensorColor}
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
import type {
|
||||
FaceDetectionOverlay,
|
||||
FacePreview,
|
||||
} from "../../hooks/use-face-detection";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
import {
|
||||
panelClass,
|
||||
panelCounterClass,
|
||||
panelInsetClass,
|
||||
panelTitleClass,
|
||||
} from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
isDark: boolean;
|
||||
@@ -49,10 +57,20 @@ export function FacePanel({
|
||||
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>
|
||||
<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]"}`}>
|
||||
<p
|
||||
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{faceStatus === "unsupported"
|
||||
? t("editor.faceMlFailed")
|
||||
: faceStatus === "detecting"
|
||||
@@ -64,7 +82,9 @@ export function FacePanel({
|
||||
: t("editor.facesDetected")}
|
||||
</p>
|
||||
{hasFaces ? (
|
||||
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
<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}`}
|
||||
@@ -79,12 +99,24 @@ export function FacePanel({
|
||||
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} />
|
||||
<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
|
||||
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 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>
|
||||
@@ -92,8 +124,12 @@ export function FacePanel({
|
||||
</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={`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") },
|
||||
@@ -110,7 +146,9 @@ export function FacePanel({
|
||||
? "bg-white/10 text-[#d7dae0]"
|
||||
: "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
|
||||
onClick={() =>
|
||||
onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
|
||||
@@ -16,23 +16,50 @@ type Props = {
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) {
|
||||
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>
|
||||
<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"}`}>
|
||||
<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"}`}>
|
||||
<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"}`}>
|
||||
<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 (
|
||||
@@ -42,11 +69,17 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
|
||||
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}`}
|
||||
{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>
|
||||
<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) => (
|
||||
@@ -59,7 +92,13 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
|
||||
{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}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import {
|
||||
getLayerRuntimeSource,
|
||||
type Layer,
|
||||
type LayerEffect,
|
||||
} from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
import {
|
||||
panelClass,
|
||||
panelCounterClass,
|
||||
panelInsetClass,
|
||||
panelTitleClass,
|
||||
} from "../../lib/theme";
|
||||
|
||||
const EFFECT_LABELS: Record<string, string> = {
|
||||
"face-blur": "Face Blur",
|
||||
@@ -16,19 +25,37 @@ type Props = {
|
||||
isDark: boolean;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
|
||||
onSetEffectEnabled: (
|
||||
layerId: string,
|
||||
kind: LayerEffect["kind"],
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onAddLayer?: () => void;
|
||||
};
|
||||
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) {
|
||||
export function LayersPanel({
|
||||
layers,
|
||||
selectedLayerId,
|
||||
isDark,
|
||||
onSelectLayer,
|
||||
onSetLayerVisible,
|
||||
onSetEffectEnabled,
|
||||
onMoveLayerOrder,
|
||||
onRemoveSelectedLayer,
|
||||
onAddLayer,
|
||||
}: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
|
||||
<h2
|
||||
className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}
|
||||
>
|
||||
{t("editor.layers")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onAddLayer ? (
|
||||
<button
|
||||
@@ -40,111 +67,180 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
|
||||
+ New
|
||||
</button>
|
||||
) : null}
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}
|
||||
>
|
||||
{layers.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{layers.slice().reverse().map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isRaster = layer.type === "raster" || layer.type === "sticker";
|
||||
const hasEffects = layer.effects.length > 0;
|
||||
const isHidden = layer.visible === false;
|
||||
return (
|
||||
<div key={layer.id}>
|
||||
<div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "text-[#d7dae0] hover:bg-white/10"
|
||||
: "text-[#1f2430] hover:bg-black/5"
|
||||
} ${isHidden ? "opacity-40" : ""}`}>
|
||||
{/* Thumbnail */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className="shrink-0"
|
||||
<div
|
||||
className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}
|
||||
>
|
||||
{layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isRaster =
|
||||
layer.type === "raster" || layer.type === "sticker";
|
||||
const sourceUri = getLayerRuntimeSource(layer);
|
||||
const hasEffects = layer.effects.length > 0;
|
||||
const isHidden = layer.visible === false;
|
||||
return (
|
||||
<div key={layer.id}>
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "text-[#d7dae0] hover:bg-white/10"
|
||||
: "text-[#1f2430] hover:bg-black/5"
|
||||
} ${isHidden ? "opacity-40" : ""}`}
|
||||
>
|
||||
<div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
|
||||
{isRaster && layer.sourceUri ? (
|
||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<div
|
||||
className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}
|
||||
>
|
||||
{isRaster && sourceUri ? (
|
||||
<Image
|
||||
src={sourceUri}
|
||||
alt={layer.name ?? layer.type}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}
|
||||
>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<p className="truncate font-semibold leading-tight">
|
||||
{layer.name ?? layer.type}
|
||||
</p>
|
||||
<p
|
||||
className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}
|
||||
>
|
||||
{layer.type}
|
||||
{layer.opacity < 1
|
||||
? ` · ${Math.round(layer.opacity * 100)}%`
|
||||
: ""}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={isHidden ? "Show layer" : "Hide layer"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetLayerVisible(layer.id, !isHidden);
|
||||
}}
|
||||
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
|
||||
>
|
||||
{isHidden ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
||||
{layer.type}
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
|
||||
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
|
||||
{layer.type}
|
||||
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
type="button"
|
||||
title={isHidden ? "Show layer" : "Hide layer"}
|
||||
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
|
||||
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
|
||||
>
|
||||
{isHidden
|
||||
? <EyeOff className="h-3.5 w-3.5" />
|
||||
: <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Layer index */}
|
||||
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{layers.length - idx}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Effects chips */}
|
||||
{hasEffects ? (
|
||||
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
|
||||
{layer.effects.map((effect) => {
|
||||
const isDisabled = effect.enabled === false;
|
||||
return (
|
||||
<button
|
||||
key={effect.kind}
|
||||
type="button"
|
||||
title={isDisabled ? "Enable effect" : "Disable effect"}
|
||||
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
|
||||
isDisabled
|
||||
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
|
||||
: isSelected
|
||||
? "bg-white/20 text-white"
|
||||
: isDark
|
||||
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
|
||||
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
|
||||
}`}
|
||||
>
|
||||
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
|
||||
{EFFECT_LABELS[effect.kind] ?? effect.kind}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span
|
||||
className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||
>
|
||||
{layers.length - idx}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasEffects ? (
|
||||
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
|
||||
{layer.effects.map((effect) => {
|
||||
const isDisabled = effect.enabled === false;
|
||||
return (
|
||||
<button
|
||||
key={effect.kind}
|
||||
type="button"
|
||||
title={
|
||||
isDisabled ? "Enable effect" : "Disable effect"
|
||||
}
|
||||
onClick={() =>
|
||||
onSetEffectEnabled(
|
||||
layer.id,
|
||||
effect.kind,
|
||||
isDisabled,
|
||||
)
|
||||
}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
|
||||
isDisabled
|
||||
? isDark
|
||||
? "bg-white/10 text-[#6b7280] line-through"
|
||||
: "bg-black/5 text-[#9ca3af] line-through"
|
||||
: isSelected
|
||||
? "bg-white/20 text-white"
|
||||
: isDark
|
||||
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
|
||||
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
|
||||
}`}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<EyeOff className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
)}
|
||||
{EFFECT_LABELS[effect.kind] ?? effect.kind}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 ? (
|
||||
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
||||
<div
|
||||
className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||
>
|
||||
{t("editor.noLayersYet")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
|
||||
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
|
||||
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -11,19 +11,32 @@ type Props = {
|
||||
onSetTool: (tool: EditorToolId) => void;
|
||||
};
|
||||
|
||||
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
|
||||
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]"}`}>
|
||||
<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;
|
||||
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())}
|
||||
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"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const BUILD_HASH = process.env.NEXT_PUBLIC_GIT_HASH || "unknown";
|
||||
const SHOULD_LOAD_DEV_HASH = process.env.NODE_ENV === "development" && BUILD_HASH === "unknown";
|
||||
const SHOULD_LOAD_DEV_HASH =
|
||||
process.env.NODE_ENV === "development" && BUILD_HASH === "unknown";
|
||||
|
||||
function formatHash(hash: string) {
|
||||
return hash === "unknown" ? hash : hash.slice(0, 7);
|
||||
@@ -17,7 +18,9 @@ export function Footer() {
|
||||
|
||||
fetch("/api/commit-hash")
|
||||
.then((res) => res.json())
|
||||
.then((data: { hash?: string }) => setHash(formatHash(data.hash || "unknown")))
|
||||
.then((data: { hash?: string }) =>
|
||||
setHash(formatHash(data.hash || "unknown")),
|
||||
)
|
||||
.catch(() => setHash("unknown"));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -15,12 +15,22 @@ export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
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>
|
||||
<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")}
|
||||
onChange={(event) =>
|
||||
setLocale(event.target.value as "en" | "th" | "ja")
|
||||
}
|
||||
className={cx(
|
||||
"bg-transparent font-semibold outline-none",
|
||||
textSize,
|
||||
@@ -35,13 +45,23 @@ export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
||||
const next =
|
||||
theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
||||
setTheme(next);
|
||||
}}
|
||||
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
|
||||
className={cx(
|
||||
"rounded border px-3 font-semibold",
|
||||
subtleButtonClass(isDark),
|
||||
wrapperHeight,
|
||||
textSize,
|
||||
)}
|
||||
>
|
||||
{theme === "dark" ? t("ui.darkMode") : theme === "system" ? t("ui.systemMode") : t("ui.lightMode")}
|
||||
{theme === "dark"
|
||||
? t("ui.darkMode")
|
||||
: theme === "system"
|
||||
? t("ui.systemMode")
|
||||
: t("ui.lightMode")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user