♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
+6 -2
View File
@@ -5,11 +5,15 @@ export const dynamic = "force-dynamic";
export function GET() {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown" });
return NextResponse.json({
hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown",
});
}
try {
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() }).toString().trim();
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() })
.toString()
.trim();
return NextResponse.json({ hash });
} catch {
return NextResponse.json({ hash: "unknown" });
+141 -64
View File
@@ -2,7 +2,16 @@
import React from "react";
import { useParams } from "next/navigation";
import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react";
import { getLayerRuntimeSource } from "@pien-studio/types";
import {
MousePointer2,
Hand,
ScanFace,
PaintBucket,
Brush,
Type,
ImagePlus,
} from "lucide-react";
import { useEditorStore } from "../../../store/editor-store";
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
@@ -27,7 +36,10 @@ import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job";
import { useTranslations } from "../../../hooks/use-translations";
import type { AspectRatio } from "@pien-studio/types";
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
const TOOL_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
pointer: MousePointer2,
hand: Hand,
face: ScanFace,
@@ -89,7 +101,8 @@ export default function EditorPage() {
const { theme, hydrate } = useUiStore((s) => s);
const { t } = useTranslations();
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
const { contextMenu, openContextMenu, closeContextMenu } =
useEditorContextMenu();
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
const [fillColor, setFillColor] = React.useState("#ff0000");
@@ -98,16 +111,21 @@ export default function EditorPage() {
const [brushSize, setBrushSize] = React.useState(20);
const [brushOpacity, setBrushOpacity] = React.useState(1);
const [brushHardness, setBrushHardness] = React.useState(0.8);
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
const previousFaceStatusRef = React.useRef<
"idle" | "detecting" | "unsupported"
>("idle");
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const selectedImageLayer = React.useMemo(() => {
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
const sourceUri = selectedLayer
? getLayerRuntimeSource(selectedLayer)
: undefined;
if (!selectedLayer || selectedLayer.type !== "raster" || !sourceUri) {
return null;
}
return {
id: selectedLayer.id,
sourceUri: selectedLayer.sourceUri,
sourceUri,
width: selectedLayer.width,
height: selectedLayer.height,
};
@@ -118,11 +136,12 @@ export default function EditorPage() {
return state.selectedLayerId === layerId;
}, []);
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
tool,
selectedImageLayer,
activeLayerStillSelected: isLayerStillSelected,
});
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } =
useFaceDetection({
tool,
selectedImageLayer,
activeLayerStillSelected: isLayerStillSelected,
});
const {
blurMethod,
@@ -204,7 +223,10 @@ export default function EditorPage() {
});
React.useEffect(() => {
if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") {
if (
faceStatus === "unsupported" &&
previousFaceStatusRef.current !== "unsupported"
) {
setFaceMlErrorModalOpen(true);
}
previousFaceStatusRef.current = faceStatus;
@@ -217,41 +239,53 @@ export default function EditorPage() {
event.target.value = "";
}
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
function handleCanvasApply(
width: number,
height: number,
aspect: AspectRatio,
) {
void aspect;
setCanvasSize(width, height);
}
const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => {
const layer = project.layers.find((l) => l.id === layerId);
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
const layerWidth = layer.width ?? Math.round(200 * layer.scale);
const layerHeight = layer.height ?? Math.round(150 * layer.scale);
// x/y are in layer CSS-pixel space; scale to image pixel space
const img = new Image();
const uri = layer.sourceUri;
const color = fillColor;
const tolerance = fillTolerance;
img.onload = () => {
const scaleX = img.naturalWidth / layerWidth;
const scaleY = img.naturalHeight / layerHeight;
const pixelX = x * scaleX;
const pixelY = y * scaleY;
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => {
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
}).catch(() => {});
};
img.src = uri;
}, [project.layers, fillColor, fillTolerance, updateImageLayerSource]);
const handleFillLayer = React.useCallback(
async (layerId: string, x: number, y: number) => {
const layer = project.layers.find((l) => l.id === layerId);
const uri = layer ? getLayerRuntimeSource(layer) : undefined;
if (!layer || layer.type !== "raster" || !uri) return;
const layerWidth = layer.width;
const layerHeight = layer.height;
const img = new Image();
const color = fillColor;
const tolerance = fillTolerance;
img.onload = () => {
const scaleX = img.naturalWidth / layerWidth;
const scaleY = img.naturalHeight / layerHeight;
const pixelX = x * scaleX;
const pixelY = y * scaleY;
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance)
.then((nextUri) => {
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
})
.catch(() => {});
};
img.src = uri;
},
[project.layers, fillColor, fillTolerance, updateImageLayerSource],
);
const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => {
const layer = project.layers.find((l) => l.id === layerId);
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
try {
const nextUri = await commitStroke(layer.sourceUri, stroke);
updateImageLayerSource(layerId, nextUri);
} catch {}
}, [project.layers, updateImageLayerSource]);
const handleBrushCommit = React.useCallback(
async (layerId: string, stroke: BrushStroke) => {
const layer = project.layers.find((l) => l.id === layerId);
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
if (!layer || layer.type !== "raster" || !sourceUri) return;
try {
const nextUri = await commitStroke(sourceUri, stroke);
updateImageLayerSource(layerId, nextUri);
} catch {}
},
[project.layers, updateImageLayerSource],
);
const handleCreateFillLayer = React.useCallback(() => {
const canvas = document.createElement("canvas");
@@ -275,23 +309,42 @@ export default function EditorPage() {
imageInputRef.current?.click();
}, []);
const toolControllers = React.useMemo<EditorToolController[]>(() => [
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
{ kind: "mode", id: "face", label: t("editor.toolFace") },
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
{ kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") },
{ kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick },
], [addLayerByType, handleImportImageClick, t]);
const toolControllers = React.useMemo<EditorToolController[]>(
() => [
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
{ kind: "mode", id: "face", label: t("editor.toolFace") },
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
{
kind: "action",
id: "add-text",
label: t("editor.toolText"),
run: () => addLayerByType("text"),
},
{
kind: "action",
id: "import-image",
label: t("editor.toolImage"),
run: handleImportImageClick,
},
],
[addLayerByType, handleImportImageClick, t],
);
const canvasBindings = {
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y),
onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height),
onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height),
onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation),
onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation),
onMoveLayer: (_id: string, x: number, y: number) =>
setSelectedLayerPositionDraft(x, y),
onMoveLayerEnd: (_id: string, x: number, y: number) =>
setSelectedLayerPosition(x, y),
onResizeLayer: (_id: string, width: number, height: number) =>
setSelectedLayerSizeDraft(width, height),
onResizeLayerEnd: (_id: string, width: number, height: number) =>
setSelectedLayerSize(width, height),
onRotateLayer: (_id: string, rotation: number) =>
setSelectedLayerRotationDraft(rotation),
onRotateLayerEnd: (_id: string, rotation: number) =>
setSelectedLayerRotation(rotation),
onInteractionStart: startTransaction,
onInteractionEnd: commitTransaction,
};
@@ -361,7 +414,12 @@ export default function EditorPage() {
onCut={cutSelectedLayer}
onPaste={pasteLayer}
onFillLayer={handleFillLayer}
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
brushOptions={{
color: brushColor,
size: brushSize,
opacity: brushOpacity,
hardness: brushHardness,
}}
onBrushCommit={handleBrushCommit}
/>
@@ -397,7 +455,7 @@ export default function EditorPage() {
selectedFaceIndices={selectedFaceIndices}
onSelectLayer={selectLayer}
onSetLayerVisible={setLayerVisible}
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
onSetEffectEnabled={setEffectEnabled}
onMoveLayerOrder={moveSelectedLayerOrder}
onRemoveSelectedLayer={removeSelectedLayer}
onUndo={undo}
@@ -437,7 +495,13 @@ export default function EditorPage() {
onInteractionStart={canvasBindings.onInteractionStart}
onInteractionEnd={canvasBindings.onInteractionEnd}
/>
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageImport}
/>
<CanvasSizeModal
isOpen={canvasModalOpen}
@@ -450,15 +514,24 @@ export default function EditorPage() {
/>
{faceMlErrorModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setFaceMlErrorModalOpen(false)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => setFaceMlErrorModalOpen(false)}
>
<div
className={`w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
isDark
? "border-white/15 bg-[#2b2d31]"
: "border-black/15 bg-white"
}`}
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{t("editor.faceTool")}</h2>
<h2
className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
>
{t("editor.faceTool")}
</h2>
<button
type="button"
onClick={() => setFaceMlErrorModalOpen(false)}
@@ -467,7 +540,11 @@ export default function EditorPage() {
</button>
</div>
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
<p
className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}
>
{t("editor.faceMlFailed")}
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
+259 -107
View File
@@ -2,12 +2,24 @@
import React from "react";
import { useRouter } from "next/navigation";
import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core";
import {
addLayer,
createLayer,
createProject,
parseProjectFile,
setCanvasSize,
} from "@pien-studio/editor-core";
import type { Project } from "@pien-studio/types";
import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage";
import { localProjectRepository } from "../lib/project-repository";
import { useEditorStore } from "../store/editor-store";
import { useUiStore, isDarkTheme } from "../store/ui-store";
import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme";
import {
accentButtonClass,
cx,
mutedSurfaceClass,
subtleButtonClass,
surfaceClass,
} from "../lib/theme";
import { UiPreferences } from "../components/ui-preferences";
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
import { useTranslations } from "../hooks/use-translations";
@@ -20,19 +32,20 @@ export default function HomePage() {
const { t } = useTranslations();
const [projects, setProjects] = React.useState<Project[]>([]);
const [showWipModal, setShowWipModal] = React.useState(true);
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
const [projectPendingDelete, setProjectPendingDelete] =
React.useState<Project | null>(null);
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const isDark = isDarkTheme(theme);
const refreshProjects = React.useCallback(async () => {
setProjects(await loadProjects());
setProjects(await localProjectRepository.listProjects());
}, []);
React.useEffect(() => {
let canceled = false;
hydrate();
loadProjects().then((loadedProjects) => {
localProjectRepository.listProjects().then((loadedProjects) => {
if (!canceled) setProjects(loadedProjects);
});
return () => {
@@ -47,25 +60,27 @@ export default function HomePage() {
async function confirmDeleteProject() {
if (!projectPendingDelete) return;
await deleteProject(projectPendingDelete.id);
await localProjectRepository.deleteProject(projectPendingDelete.id);
await refreshProjects();
setProjectPendingDelete(null);
}
async function handleNewProject() {
const project = createProject(t("home.untitledProject"));
await upsertProject(project);
await localProjectRepository.upsertProject(project);
openProject(project);
}
async function handleImportProjectFile(event: React.ChangeEvent<HTMLInputElement>) {
async function handleImportProjectFile(
event: React.ChangeEvent<HTMLInputElement>,
) {
const file = event.target.files?.[0];
if (!file) return;
const raw = await file.text();
try {
const parsed = parseProjectFile(raw);
if (!parsed.ok) return;
await upsertProject(parsed.project);
await localProjectRepository.upsertProject(parsed.project);
await refreshProjects();
openProject(parsed.project);
} finally {
@@ -80,34 +95,44 @@ export default function HomePage() {
const reader = new FileReader();
reader.onload = async () => {
try {
const sourceUri = typeof reader.result === "string" ? reader.result : undefined;
const sourceUri =
typeof reader.result === "string" ? reader.result : undefined;
if (!sourceUri) return;
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
const image = new Image();
image.onload = () => {
resolve({
width: Math.max(1, Math.round(image.naturalWidth)),
height: Math.max(1, Math.round(image.naturalHeight)),
});
};
image.onerror = () => resolve({ width: 1, height: 1 });
image.src = sourceUri;
});
const imageSize = await new Promise<{ width: number; height: number }>(
(resolve) => {
const image = new Image();
image.onload = () => {
resolve({
width: Math.max(1, Math.round(image.naturalWidth)),
height: Math.max(1, Math.round(image.naturalHeight)),
});
};
image.onerror = () => resolve({ width: 1, height: 1 });
image.src = sourceUri;
},
);
const base = createProject(title, "free");
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
const projectWithImageCanvas = setCanvasSize(
base,
imageSize.width,
imageSize.height,
);
const project = addLayer(projectWithImageCanvas, createLayer("raster", {
name: file.name,
sourceUri,
x: 0,
y: 0,
width: imageSize.width,
height: imageSize.height,
}));
const project = addLayer(
projectWithImageCanvas,
createLayer("raster", {
name: file.name,
asset: { kind: "inline", uri: sourceUri },
x: 0,
y: 0,
width: imageSize.width,
height: imageSize.height,
}),
);
await upsertProject(project);
await localProjectRepository.upsertProject(project);
await refreshProjects();
openProject(project);
} finally {
@@ -120,15 +145,33 @@ export default function HomePage() {
return (
<>
{showWipModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setShowWipModal(false)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
onClick={() => setShowWipModal(false)}
>
<div
className={cx("w-full max-w-lg rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
className={cx(
"w-full max-w-lg rounded-2xl border p-5 shadow-2xl",
surfaceClass(isDark),
)}
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold">{t("home.wipTitle")}</h2>
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>{t("home.wipBody")}</p>
<p className={cx("mt-3 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
{t("home.wipSupportPrefix")} {" "}
<p
className={cx(
"mt-2 text-sm leading-relaxed",
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
)}
>
{t("home.wipBody")}
</p>
<p
className={cx(
"mt-3 text-sm leading-relaxed",
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
)}
>
{t("home.wipSupportPrefix")}{" "}
<a
href="https://github.com/sponsors/YuzuZensai"
target="_blank"
@@ -142,7 +185,10 @@ export default function HomePage() {
<button
type="button"
onClick={() => setShowWipModal(false)}
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
className={cx(
"rounded border px-3 py-1.5 text-sm font-semibold",
accentButtonClass(),
)}
>
{t("home.wipAcknowledge")}
</button>
@@ -152,20 +198,38 @@ export default function HomePage() {
) : null}
{projectPendingDelete ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setProjectPendingDelete(null)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
onClick={() => setProjectPendingDelete(null)}
>
<div
className={cx("w-full max-w-md rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
className={cx(
"w-full max-w-md rounded-2xl border p-5 shadow-2xl",
surfaceClass(isDark),
)}
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold">Delete project?</h2>
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
This will permanently delete <span className="font-semibold">{projectPendingDelete.title}</span> from local storage.
<p
className={cx(
"mt-2 text-sm leading-relaxed",
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
)}
>
This will permanently delete{" "}
<span className="font-semibold">
{projectPendingDelete.title}
</span>{" "}
from local storage.
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setProjectPendingDelete(null)}
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", subtleButtonClass(isDark))}
className={cx(
"rounded border px-3 py-1.5 text-sm font-semibold",
subtleButtonClass(isDark),
)}
>
Cancel
</button>
@@ -188,74 +252,162 @@ export default function HomePage() {
isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]"
}`}
>
<section className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className={cx("text-[10px] uppercase tracking-[0.2em]", isDark ? "text-[#a8abb2]" : "text-[#6c7382]")}>pien.studio</p>
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
<p className={cx("text-sm", isDark ? "text-[#b9bec8]" : "text-[#5f6672]")}>{t("home.createOpenManage")}</p>
</div>
<UiPreferences />
</div>
</section>
<section className="mt-4 grid gap-3 sm:grid-cols-3">
<button
type="button"
onClick={handleNewProject}
className={cx("rounded border px-3 py-2 text-sm font-semibold", accentButtonClass())}
<section
className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}
>
{t("home.newProject")}
</button>
<button type="button" onClick={() => projectInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
{t("home.openProjectFile")}
</button>
<button type="button" onClick={() => imageInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
{t("home.openImage")}
</button>
<input ref={projectInputRef} type="file" accept=".json,.pien.json,application/json" className="hidden" onChange={handleImportProjectFile} />
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleOpenImage} />
</section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p
className={cx(
"text-[10px] uppercase tracking-[0.2em]",
isDark ? "text-[#a8abb2]" : "text-[#6c7382]",
)}
>
pien.studio
</p>
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
<p
className={cx(
"text-sm",
isDark ? "text-[#b9bec8]" : "text-[#5f6672]",
)}
>
{t("home.createOpenManage")}
</p>
</div>
<UiPreferences />
</div>
</section>
<section className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}>
<h2 className={cx("mb-3 text-sm font-semibold uppercase tracking-wide", isDark ? "text-[#c5cad3]" : "text-[#6c7382]")}>{t("home.myProjects")}</h2>
{projects.length === 0 ? <p className={cx("text-sm", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{t("home.noProjectsYet")}</p> : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<article key={project.id} className={cx("rounded border p-3", mutedSurfaceClass(isDark))}>
<p className={cx("truncate text-sm font-semibold", isDark ? "text-[#f3f5f8]" : "text-[#1f2430]")}>{project.title}</p>
<p className={cx("mt-1 text-xs", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{new Date(project.updatedAt).toLocaleString()}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => openProject(project)}
className={cx("rounded border px-2 py-1 text-xs font-semibold", accentButtonClass())}
<section className="mt-4 grid gap-3 sm:grid-cols-3">
<button
type="button"
onClick={handleNewProject}
className={cx(
"rounded border px-3 py-2 text-sm font-semibold",
accentButtonClass(),
)}
>
{t("home.newProject")}
</button>
<button
type="button"
onClick={() => projectInputRef.current?.click()}
className={cx(
"rounded border px-3 py-2 text-sm font-semibold",
subtleButtonClass(isDark),
)}
>
{t("home.openProjectFile")}
</button>
<button
type="button"
onClick={() => imageInputRef.current?.click()}
className={cx(
"rounded border px-3 py-2 text-sm font-semibold",
subtleButtonClass(isDark),
)}
>
{t("home.openImage")}
</button>
<input
ref={projectInputRef}
type="file"
accept=".json,.pien.json,application/json"
className="hidden"
onChange={handleImportProjectFile}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleOpenImage}
/>
</section>
<section
className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}
>
<h2
className={cx(
"mb-3 text-sm font-semibold uppercase tracking-wide",
isDark ? "text-[#c5cad3]" : "text-[#6c7382]",
)}
>
{t("home.myProjects")}
</h2>
{projects.length === 0 ? (
<p
className={cx(
"text-sm",
isDark ? "text-[#aeb3bc]" : "text-[#5f6672]",
)}
>
{t("home.noProjectsYet")}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<article
key={project.id}
className={cx("rounded border p-3", mutedSurfaceClass(isDark))}
>
<p
className={cx(
"truncate text-sm font-semibold",
isDark ? "text-[#f3f5f8]" : "text-[#1f2430]",
)}
>
{t("home.open")}
</button>
<button
type="button"
onClick={() => {
void duplicateProject(project.id).then(refreshProjects);
}}
className={cx("rounded border px-2 py-1 text-xs font-semibold", subtleButtonClass(isDark))}
{project.title}
</p>
<p
className={cx(
"mt-1 text-xs",
isDark ? "text-[#aeb3bc]" : "text-[#5f6672]",
)}
>
{t("home.duplicate")}
</button>
<button
type="button"
onClick={() => {
setProjectPendingDelete(project);
}}
className="rounded border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-semibold text-red-200"
>
{t("home.delete")}
</button>
</div>
</article>
))}
</div>
</section>
{new Date(project.updatedAt).toLocaleString()}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => openProject(project)}
className={cx(
"rounded border px-2 py-1 text-xs font-semibold",
accentButtonClass(),
)}
>
{t("home.open")}
</button>
<button
type="button"
onClick={() => {
void localProjectRepository
.duplicateProject(project.id)
.then(refreshProjects);
}}
className={cx(
"rounded border px-2 py-1 text-xs font-semibold",
subtleButtonClass(isDark),
)}
>
{t("home.duplicate")}
</button>
<button
type="button"
onClick={() => {
setProjectPendingDelete(project);
}}
className="rounded border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-semibold text-red-200"
>
{t("home.delete")}
</button>
</div>
</article>
))}
</div>
</section>
</main>
</>
);
+34 -6
View File
@@ -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);
});
+94 -49
View File
@@ -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) => (
+87 -19
View File
@@ -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={{
+169 -27
View File
@@ -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>
+127 -25
View File
@@ -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}
+50 -12
View File
@@ -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>
+47 -8
View File
@@ -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>
);
+191 -95
View File
@@ -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>
);
+17 -4
View File
@@ -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"
+5 -2
View File
@@ -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"));
}, []);
+27 -7
View File
@@ -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>
);
}
}
+6 -3
View File
@@ -1,11 +1,14 @@
"use client";
import React from "react";
import { startAssetCleanupJob } from "@pien-studio/storage";
import { localProjectRepository } from "../lib/project-repository";
export function useAssetCleanupJob() {
React.useEffect(() => {
const stop = startAssetCleanupJob();
return () => stop();
if (typeof window === "undefined") return undefined;
const id = window.setInterval(() => {
void localProjectRepository.cleanupAssets();
}, 45_000);
return () => window.clearInterval(id);
}, []);
}
+130 -36
View File
@@ -23,7 +23,13 @@ type UseCanvasInteractionsOptions = {
onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => void;
onFillLayer?: (layerId: string, x: number, y: number) => void;
onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void;
onBrushStrokeStart?: (
layerId: string,
x: number,
y: number,
layerWidth: number,
layerHeight: number,
) => void;
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
onBrushStrokeEnd?: (layerId: string) => void;
};
@@ -53,7 +59,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onBrushStrokeMove,
onBrushStrokeEnd,
} = options;
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
const [viewport, setViewport] = React.useState<Viewport>({
x: 0,
y: 0,
scale: 1,
});
const containerRef = React.useRef<HTMLDivElement>(null);
const isPanning = React.useRef(false);
const lastPos = React.useRef({ x: 0, y: 0 });
@@ -64,7 +74,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const interactionActiveRef = React.useRef(false);
const dragMoveRafRef = React.useRef<number | null>(null);
const resizeMoveRafRef = React.useRef<number | null>(null);
const resizeMovePendingRef = React.useRef<{ width: number; height: number; x: number; y: number } | null>(null);
const resizeMovePendingRef = React.useRef<{
width: number;
height: number;
x: number;
y: number;
} | null>(null);
const dragRef = React.useRef<{
id: string;
startLayerX: number;
@@ -95,7 +110,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
startRotation: number;
lastRotation: number;
} | null>(null);
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
const brushRef = React.useRef<{
id: string;
lastX: number;
lastY: number;
rect: DOMRect;
} | null>(null);
const pinchRef = React.useRef<{
active: boolean;
@@ -119,7 +139,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onInteractionEnd?.();
}
function clientDist(t0: Pick<Touch, "clientX" | "clientY">, t1: Pick<Touch, "clientX" | "clientY">) {
function clientDist(
t0: Pick<Touch, "clientX" | "clientY">,
t1: Pick<Touch, "clientX" | "clientY">,
) {
const dx = t0.clientX - t1.clientX;
const dy = t0.clientY - t1.clientY;
return Math.sqrt(dx * dx + dy * dy);
@@ -130,12 +153,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
e.stopPropagation();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const factor = e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
const factor =
e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
if (!Number.isFinite(factor) || factor === 0) return;
const pivotX = e.clientX - rect.left;
const pivotY = e.clientY - rect.top;
setViewport((vp) => {
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
const nextScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, vp.scale * factor),
);
const scaleChange = nextScale / vp.scale;
return {
x: pivotX - (pivotX - vp.x) * scaleChange,
@@ -147,7 +174,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
function zoomBy(factor: number, pivotX: number, pivotY: number) {
setViewport((vp) => {
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
const newScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, vp.scale * factor),
);
const scaleChange = newScale / vp.scale;
return {
x: pivotX - (pivotX - vp.x) * scaleChange,
@@ -193,13 +223,21 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
React.useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.code === "Space") {
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
if (
!(e.target instanceof HTMLElement) ||
/^(input|textarea|select)$/i.test(e.target.tagName)
)
return;
if (!isSpacePan) setIsSpacePan(true);
e.preventDefault();
return;
}
if (e.key === "Shift") {
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
if (
!(e.target instanceof HTMLElement) ||
/^(input|textarea|select)$/i.test(e.target.tagName)
)
return;
setIsShiftPressed(true);
return;
}
@@ -231,11 +269,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
window.addEventListener("wheel", handleWheelCaptured, {
capture: true,
passive: false,
});
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
window.removeEventListener("wheel", handleWheelCaptured, {
capture: true,
});
};
}, [isSpacePan]);
@@ -281,7 +324,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
const toolDef = getToolDefinition(tool);
if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) {
const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
const { centerX, centerY, startAngle, startRotation, id } =
rotateRef.current;
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
const delta = currentAngle - startAngle;
const nextRotation = startRotation + (delta * 180) / Math.PI;
@@ -312,7 +356,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
}
if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) {
if (
!e.shiftKey &&
(corner === "br" ||
corner === "bl" ||
corner === "tr" ||
corner === "tl")
) {
const aspect = resizeRef.current.aspect || 1;
if (Math.abs(dx) > Math.abs(dy)) {
nextHeight = Math.max(8, nextWidth / aspect);
@@ -321,8 +371,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
}
if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth;
if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight;
if (corner === "bl" || corner === "tl")
offsetX = resizeRef.current.startWidth - nextWidth;
if (corner === "tr" || corner === "tl")
offsetY = resizeRef.current.startHeight - nextHeight;
resizeRef.current.lastWidth = nextWidth;
resizeRef.current.lastHeight = nextHeight;
@@ -338,7 +390,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (!resizeRef.current || !resizeMovePendingRef.current) return;
const pending = resizeMovePendingRef.current;
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
if (
(pending.x !== resizeRef.current.startLayerX ||
pending.y !== resizeRef.current.startLayerY) &&
onMoveLayer
) {
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
}
});
@@ -349,14 +405,19 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
const nextX = dragRef.current.startLayerX + dx;
const nextY = dragRef.current.startLayerY + dy;
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY) return;
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY)
return;
dragRef.current.lastX = nextX;
dragRef.current.lastY = nextY;
if (dragMoveRafRef.current !== null) return;
dragMoveRafRef.current = window.requestAnimationFrame(() => {
dragMoveRafRef.current = null;
if (!dragRef.current) return;
onMoveLayer(dragRef.current.id, dragRef.current.lastX, dragRef.current.lastY);
onMoveLayer(
dragRef.current.id,
dragRef.current.lastX,
dragRef.current.lastY,
);
});
}
}
@@ -372,7 +433,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) {
const pending = resizeMovePendingRef.current;
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
if (
(pending.x !== resizeRef.current.startLayerX ||
pending.y !== resizeRef.current.startLayerY) &&
onMoveLayer
) {
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
}
}
@@ -381,11 +446,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
isMiddleMousePan.current = false;
if (dragRef.current && onMoveLayerEnd) {
const { id, lastX, lastY } = dragRef.current;
if (typeof lastX === "number" && typeof lastY === "number") onMoveLayerEnd(id, lastX, lastY);
if (typeof lastX === "number" && typeof lastY === "number")
onMoveLayerEnd(id, lastX, lastY);
}
if (resizeRef.current && onResizeLayerEnd) {
const { id, lastWidth, lastHeight } = resizeRef.current;
if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight);
if (typeof lastWidth === "number" && typeof lastHeight === "number")
onResizeLayerEnd(id, lastWidth, lastHeight);
}
if (rotateRef.current && onRotateLayerEnd) {
const { id, lastRotation } = rotateRef.current;
@@ -402,7 +469,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
endInteraction();
}
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
function onLayerPointerDown(
e: React.PointerEvent<HTMLDivElement>,
layer: Layer,
) {
if (isSpacePan) return;
e.stopPropagation();
const toolDef = getToolDefinition(tool);
@@ -413,9 +483,14 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onSelectLayer(layer.id);
if (tool === "brush" && onBrushStrokeStart) {
e.currentTarget.setPointerCapture(e.pointerId);
brushRef.current = { id: layer.id, lastX: localX, lastY: localY, rect: e.currentTarget.getBoundingClientRect() };
const lw = layer.width ?? Math.round(200 * layer.scale);
const lh = layer.height ?? Math.round(150 * layer.scale);
brushRef.current = {
id: layer.id,
lastX: localX,
lastY: localY,
rect: e.currentTarget.getBoundingClientRect(),
};
const lw = layer.width;
const lh = layer.height;
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
beginInteraction();
} else if (onFillLayer) {
@@ -437,13 +512,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onSelectLayer(layer.id);
}
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
function onResizeHandleDown(
e: React.PointerEvent<HTMLButtonElement>,
layer: Layer,
corner: string,
) {
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
rotateRef.current = null;
const width = layer.width ?? Math.round(200 * layer.scale);
const height = layer.height ?? Math.round(150 * layer.scale);
const width = layer.width;
const height = layer.height;
resizeRef.current = {
id: layer.id,
startWidth: width,
@@ -461,16 +540,27 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onSelectLayer(layer.id);
}
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
function onRotateHandleDown(
e: React.PointerEvent<HTMLButtonElement>,
layer: Layer,
) {
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
resizeRef.current = null;
const rect = containerRef.current?.getBoundingClientRect();
const width = layer.width ?? Math.round(200 * layer.scale);
const height = layer.height ?? Math.round(150 * layer.scale);
const centerX = (rect?.left ?? 0) + viewport.x + layer.x * viewport.scale + (width * viewport.scale) / 2;
const centerY = (rect?.top ?? 0) + viewport.y + layer.y * viewport.scale + (height * viewport.scale) / 2;
const width = layer.width;
const height = layer.height;
const centerX =
(rect?.left ?? 0) +
viewport.x +
layer.x * viewport.scale +
(width * viewport.scale) / 2;
const centerY =
(rect?.top ?? 0) +
viewport.y +
layer.y * viewport.scale +
(height * viewport.scale) / 2;
const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
rotateRef.current = {
id: layer.id,
@@ -488,7 +578,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (e.touches.length === 2) {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midX =
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
pinchRef.current = {
active: true,
@@ -507,7 +598,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
e.preventDefault();
const px = clientDist(e.touches[0], e.touches[1]);
const ratio = px / pinchRef.current.initialPinchPx;
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio));
const nextScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio),
);
const scaleChange = nextScale / pinchRef.current.initialScale;
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
setViewport({
+3 -1
View File
@@ -11,7 +11,9 @@ describe("useEditorBindings", () => {
const { result } = renderHook(() => useEditorBindings());
expect(result.current.state.project.layers.length).toBe(1);
expect(result.current.state.selectedLayer?.id).toBe(result.current.state.selectedLayerId);
expect(result.current.state.selectedLayer?.id).toBe(
result.current.state.selectedLayerId,
);
expect(typeof result.current.actions.undo).toBe("function");
});
});
+4 -1
View File
@@ -53,7 +53,10 @@ export function useEditorBindings() {
);
const selectedLayer = React.useMemo(
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
() =>
state.project.layers.find(
(layer) => layer.id === state.selectedLayerId,
) ?? null,
[state.project.layers, state.selectedLayerId],
);
+11 -3
View File
@@ -4,14 +4,22 @@ import { CONTEXT_MENU_SIZE } from "../lib/editor-constants";
type ContextMenuPosition = { x: number; y: number };
export function useEditorContextMenu() {
const [contextMenu, setContextMenu] = React.useState<ContextMenuPosition | null>(null);
const [contextMenu, setContextMenu] =
React.useState<ContextMenuPosition | null>(null);
const openContextMenu = React.useCallback((x: number, y: number) => {
const rect = document.documentElement.getBoundingClientRect();
const { width: menuWidth, height: menuHeight, viewportPadding: pad } = CONTEXT_MENU_SIZE;
const {
width: menuWidth,
height: menuHeight,
viewportPadding: pad,
} = CONTEXT_MENU_SIZE;
const maxX = rect.width - menuWidth - pad;
const maxY = rect.height - menuHeight - pad;
setContextMenu({ x: Math.max(pad, Math.min(x, maxX)), y: Math.max(pad, Math.min(y, maxY)) });
setContextMenu({
x: Math.max(pad, Math.min(x, maxX)),
y: Math.max(pad, Math.min(y, maxY)),
});
}, []);
const closeContextMenu = React.useCallback(() => {
+11 -3
View File
@@ -1,6 +1,9 @@
import React from "react";
type Translator = (key: string, params?: Record<string, string | number>) => string;
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
export function useEditorLabels(t: Translator) {
const headerLabels = React.useMemo(
@@ -29,7 +32,11 @@ export function useEditorLabels(t: Translator) {
);
const contextMenuLabels = React.useMemo(
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
() => ({
copy: t("editor.copy"),
cut: t("editor.cut"),
paste: t("editor.paste"),
}),
[t],
);
@@ -38,7 +45,8 @@ export function useEditorLabels(t: Translator) {
resize: t("editor.resize"),
faceMlFailedShort: t("editor.faceMlFailedShort"),
detectingFacesShort: t("editor.detectingFacesShort"),
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
faceDetectionTip: (count: number) =>
t("editor.faceDetectionTip", { count }),
import: t("editor.import"),
mood: t("editor.mood"),
quick: t("editor.quick"),
@@ -1,8 +1,11 @@
import React from "react";
import { createProject } from "@pien-studio/editor-core";
import { upsertProject } from "@pien-studio/storage";
import { localProjectRepository } from "../lib/project-repository";
type Translator = (key: string, params?: Record<string, string | number>) => string;
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
type UseEditorProjectLifecycleOptions = {
projectId: string;
@@ -12,7 +15,9 @@ type UseEditorProjectLifecycleOptions = {
t: Translator;
};
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
export function useEditorProjectLifecycle(
options: UseEditorProjectLifecycleOptions,
) {
const { projectId, hydrate, loadProjectById, setProject, t } = options;
const initializedProjectId = React.useRef<string | null>(null);
@@ -27,7 +32,7 @@ export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOpti
if (projectId === "new") {
const nextProject = createProject(t("home.untitledProject"));
setProject(nextProject);
void upsertProject(nextProject);
void localProjectRepository.upsertProject(nextProject);
return;
}
void loadProjectById(projectId);
+4 -1
View File
@@ -11,7 +11,10 @@ type UseEditorShortcutsOptions = {
};
function isEditableTarget(e: Event) {
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
return (
e.target instanceof HTMLElement &&
/^(input|textarea|select)$/i.test(e.target.tagName)
);
}
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
+68 -10
View File
@@ -7,16 +7,18 @@ function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
return {
id: "layer-1",
type: "raster",
sourceUri: "data:image/png;base64,abc",
asset: { kind: "inline", uri: "data:image/png;base64,abc" },
x: 0,
y: 0,
width: 100,
height: 100,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
...overrides,
};
} as Layer;
}
describe("useFaceBlurWorkflow", () => {
@@ -25,8 +27,24 @@ describe("useFaceBlurWorkflow", () => {
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
{ x: 5, y: 8, width: 7, height: 9, label: "b", sourceWidth: 100, sourceHeight: 100 },
{
x: 1,
y: 2,
width: 10,
height: 12,
label: "a",
sourceWidth: 100,
sourceHeight: 100,
},
{
x: 5,
y: 8,
width: 7,
height: 9,
label: "b",
sourceWidth: 100,
sourceHeight: 100,
},
];
const { result } = renderHook(() =>
@@ -41,8 +59,14 @@ describe("useFaceBlurWorkflow", () => {
await waitFor(() => {
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2);
const faceBlurEffect = result.current.faceBlurPreview?.effects.find(
(e) => e.kind === "face-blur",
);
expect(
faceBlurEffect?.kind === "face-blur"
? faceBlurEffect.regions
: undefined,
).toHaveLength(2);
});
});
@@ -62,7 +86,17 @@ describe("useFaceBlurWorkflow", () => {
useFaceBlurWorkflow({
selectedLayer,
faceDetectionsLayerId: "layer-1",
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
faceDetections: [
{
x: 1,
y: 2,
width: 10,
height: 12,
label: "a",
sourceWidth: 100,
sourceHeight: 100,
},
],
setLayerEffect,
removeLayerEffect,
}),
@@ -79,8 +113,24 @@ describe("useFaceBlurWorkflow", () => {
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
{ x: 50, y: 60, width: 20, height: 22, label: "b", sourceWidth: 100, sourceHeight: 100 },
{
x: 1,
y: 2,
width: 10,
height: 12,
label: "a",
sourceWidth: 100,
sourceHeight: 100,
},
{
x: 50,
y: 60,
width: 20,
height: 22,
label: "b",
sourceWidth: 100,
sourceHeight: 100,
},
];
const { result } = renderHook(() =>
@@ -115,7 +165,15 @@ describe("useFaceBlurWorkflow", () => {
method: "gaussian",
amount: 14,
regions: expect.arrayContaining([
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
expect.objectContaining({
x: 1,
y: 2,
width: 10,
height: 12,
censorColor: "#111111",
sourceWidth: 100,
sourceHeight: 100,
}),
]),
}),
);
+99 -30
View File
@@ -1,5 +1,10 @@
import React from "react";
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
import {
getLayerRuntimeSource,
type FaceBlurMethod,
type Layer,
type LayerEffect,
} from "@pien-studio/types";
import type { FaceDetectionOverlay } from "./use-face-detection";
type FaceBlurPreview = {
@@ -21,15 +26,31 @@ type UseFaceBlurWorkflowOptions = {
};
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
const {
selectedLayer,
faceDetectionsLayerId,
faceDetections,
setLayerEffect,
removeLayerEffect,
} = options;
const [blurMethod, setBlurMethod] =
React.useState<FaceBlurMethod>("gaussian");
const [blurAmount, setBlurAmount] = React.useState(14);
const [censorColor, setCensorColor] = React.useState("#111111");
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
const [faceSelection, setFaceSelection] =
React.useState<FaceSelectionState | null>(null);
const hasDetectableSelection = Boolean(
selectedLayer &&
selectedLayer.type === "raster" &&
faceDetectionsLayerId === selectedLayer.id,
);
const faceBlurEffect = React.useMemo(
() => selectedLayer?.effects.find((e): e is Extract<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
() =>
selectedLayer?.effects.find(
(e): e is Extract<LayerEffect, { kind: "face-blur" }> =>
e.kind === "face-blur",
) ?? null,
[selectedLayer?.effects],
);
@@ -40,18 +61,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
return faceDetections.map((_, index) => index);
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
const selectedFaceIndices =
faceSelection?.key === selectionKey
? faceSelection.indices
: defaultSelectedFaceIndices;
const buildBlurRegions = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "raster") return [];
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
if (
faceDetectionsLayerId !== selectedLayer.id ||
faceDetections.length === 0
)
return [];
const indexSet = new Set(indices);
return faceDetections
.filter((_, index) => indexSet.has(index))
.map((face) => {
const baseWidth = Math.max(1, selectedLayer.width ?? face.sourceWidth);
const baseHeight = Math.max(1, selectedLayer.height ?? face.sourceHeight);
const baseWidth = Math.max(1, selectedLayer.width);
const baseHeight = Math.max(1, selectedLayer.height);
const scaleX = face.sourceWidth / baseWidth;
const scaleY = face.sourceHeight / baseHeight;
return {
@@ -68,13 +96,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
);
const toggleFaceIndex = React.useCallback((index: number) => {
setFaceSelection((prev) => {
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
return { key: selectionKey, indices };
});
}, [defaultSelectedFaceIndices, selectionKey]);
const toggleFaceIndex = React.useCallback(
(index: number) => {
setFaceSelection((prev) => {
const current =
prev?.key === selectionKey
? prev.indices
: defaultSelectedFaceIndices;
const indices = current.includes(index)
? current.filter((item) => item !== index)
: [...current, index];
return { key: selectionKey, indices };
});
},
[defaultSelectedFaceIndices, selectionKey],
);
const clearBlur = React.useCallback(() => {
if (!selectedLayer) return;
@@ -84,8 +120,17 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const blurFaces = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
if (
!selectedLayer ||
selectedLayer.type !== "raster" ||
!getLayerRuntimeSource(selectedLayer)
)
return;
if (
faceDetectionsLayerId !== selectedLayer.id ||
faceDetections.length === 0
)
return;
const regions = buildBlurRegions(indices);
setLayerEffect(selectedLayer.id, {
kind: "face-blur",
@@ -97,11 +142,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
});
setFaceSelection({ key: selectionKey, indices: [] });
},
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
[
blurAmount,
blurMethod,
buildBlurRegions,
censorColor,
faceDetections.length,
faceDetectionsLayerId,
selectedLayer,
selectionKey,
setLayerEffect,
],
);
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
if (
!hasDetectableSelection ||
!selectedLayer ||
selectedLayer.type !== "raster"
) {
return null;
}
@@ -111,16 +170,26 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
return {
layerId: selectedLayer.id,
effects: [{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
}],
effects: [
{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
},
],
};
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
}, [
blurAmount,
blurMethod,
buildBlurRegions,
censorColor,
hasDetectableSelection,
selectedFaceIndices,
selectedLayer,
]);
return {
blurMethod,
+3 -7
View File
@@ -26,8 +26,8 @@ export type FacePreview = {
type SelectedImageLayer = {
id: string;
sourceUri: string;
width?: number;
height?: number;
width: number;
height: number;
};
type UseFaceDetectionOptions = {
@@ -37,11 +37,7 @@ type UseFaceDetectionOptions = {
};
export function useFaceDetection(options: UseFaceDetectionOptions) {
const {
tool,
selectedImageLayer,
activeLayerStillSelected,
} = options;
const { tool, selectedImageLayer, activeLayerStillSelected } = options;
const selectedImageLayerId = selectedImageLayer?.id ?? null;
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
const selectedImageWidth = selectedImageLayer?.width;
+5 -2
View File
@@ -28,7 +28,10 @@ export function useTranslations() {
const locale = useUiStore((s) => s.locale);
const msg = messages[locale] ?? messages.en;
function t(key: TranslationKey, params?: Record<string, string | number>): string {
function t(
key: TranslationKey,
params?: Record<string, string | number>,
): string {
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
if (params) {
Object.entries(params).forEach(([k, v]) => {
@@ -39,4 +42,4 @@ export function useTranslations() {
}
return { t, locale };
}
}
+15 -3
View File
@@ -9,13 +9,22 @@ describe("brush painter pure helpers", () => {
});
it("clamps invalid brush options", () => {
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
expect(
clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 }),
).toEqual({
color: "#fff",
size: 1,
opacity: 1,
hardness: 0,
});
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
expect(
clampBrushOptions({
color: "#000",
size: Number.NaN,
opacity: Number.NaN,
hardness: Number.NaN,
}),
).toEqual({
color: "#000",
size: 1,
opacity: 1,
@@ -31,6 +40,9 @@ describe("brush painter pure helpers", () => {
{ x: 3, y: 0 },
{ x: 4, y: 0 },
]);
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([
{ x: 2, y: 3 },
{ x: 2, y: 3 },
]);
});
});
+24 -10
View File
@@ -2,7 +2,7 @@ export type BrushOptions = {
color: string;
size: number;
opacity: number;
hardness: number; // 01: 0 = fully soft, 1 = hard edge
hardness: number; // 0 to 1: 0 is fully soft, 1 is a hard edge.
};
export type BrushStroke = {
@@ -31,12 +31,24 @@ export function clampBrushOptions(options: BrushOptions): BrushOptions {
return {
color: options.color,
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
opacity: Math.max(
0,
Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1),
),
hardness: Math.max(
0,
Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1),
),
};
}
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
export function buildBrushDabs(
x0: number,
y0: number,
x1: number,
y1: number,
size: number,
): BrushDab[] {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
@@ -65,7 +77,10 @@ function drawDab(
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(
normalized.hardness,
`rgba(${cr},${cg},${cb},${normalized.opacity})`,
);
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.beginPath();
@@ -74,7 +89,6 @@ function drawDab(
ctx.fill();
}
/** Creates a fresh stroke canvas sized to the layer. */
export function createStroke(width: number, height: number): BrushStroke {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
@@ -84,7 +98,6 @@ export function createStroke(width: number, height: number): BrushStroke {
return { canvas, ctx, width: canvas.width, height: canvas.height };
}
/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */
export function paintSegment(
stroke: BrushStroke,
x0: number,
@@ -99,8 +112,10 @@ export function paintSegment(
}
}
/** Merges stroke canvas on top of the source image and returns a data URL. */
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
export function commitStroke(
sourceUri: string,
stroke: BrushStroke,
): Promise<string> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
@@ -114,7 +129,6 @@ export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<st
return;
}
ctx.drawImage(image, 0, 0);
// Scale stroke canvas to match image natural size
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/png"));
};
+7 -1
View File
@@ -3,7 +3,12 @@ import { buildFaceLabelOverlays } from "./canvas-geometry";
describe("buildFaceLabelOverlays", () => {
it("returns no overlays when target layer is missing", () => {
const overlays = buildFaceLabelOverlays([], "missing", [{ x: 10, y: 10, width: 20, height: 20 }], { x: 0, y: 0, scale: 1 });
const overlays = buildFaceLabelOverlays(
[],
"missing",
[{ x: 10, y: 10, width: 20, height: 20 }],
{ x: 0, y: 0, scale: 1 },
);
expect(overlays).toEqual([]);
});
@@ -12,6 +17,7 @@ describe("buildFaceLabelOverlays", () => {
{
id: "layer-1",
type: "raster" as const,
asset: null,
x: 20,
y: 30,
width: 180,
+19 -7
View File
@@ -1,6 +1,12 @@
import type { Layer } from "@pien-studio/types";
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
type FaceDetection = {
x: number;
y: number;
width: number;
height: number;
label?: string;
};
type Viewport = { x: number; y: number; scale: number };
@@ -13,9 +19,8 @@ export function buildFaceLabelOverlays(
const layer = layers.find((item) => item.id === faceOverlayLayerId);
if (!layer) return [];
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 layerWidth = layer.width;
const layerHeight = layer.height;
if (!layerWidth || !layerHeight) return [];
const centerX = layer.x + layerWidth / 2;
@@ -23,7 +28,12 @@ export function buildFaceLabelOverlays(
const radians = (layer.rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const placed: Array<{ left: number; top: number; width: number; height: number }> = [];
const placed: Array<{
left: number;
top: number;
width: number;
height: number;
}> = [];
return faceDetections.map((face, index) => {
const worldX = layer.x + face.x;
@@ -41,8 +51,10 @@ export function buildFaceLabelOverlays(
while (
placed.some((rect) => {
const intersectsX = left < rect.left + rect.width && left + estimatedWidth > rect.left;
const intersectsY = top < rect.top + rect.height && top + estimatedHeight > rect.top;
const intersectsX =
left < rect.left + rect.width && left + estimatedWidth > rect.left;
const intersectsY =
top < rect.top + rect.height && top + estimatedHeight > rect.top;
return intersectsX && intersectsY;
})
) {
+204 -34
View File
@@ -18,7 +18,12 @@ function makeImage(width = 1200, height = 800) {
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
}
function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) {
function makeContext2d(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
tw = 600,
th = 400,
) {
return { ctx, image, targetWidth: tw, targetHeight: th };
}
@@ -31,13 +36,32 @@ describe("faceBlurRenderer.render (regions)", () => {
enabled: true,
method: "gaussian",
amount: 24,
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
regions: [
{
x: 120,
y: 80,
width: 300,
height: 200,
sourceWidth: 1200,
sourceHeight: 800,
},
],
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
expect(ctx.save).toHaveBeenCalledOnce();
expect(ctx.filter).toBe("blur(24px)");
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
expect(ctx.drawImage).toHaveBeenCalledWith(
image,
120,
80,
300,
200,
60,
40,
150,
100,
);
expect(ctx.restore).toHaveBeenCalledOnce();
});
@@ -48,27 +72,65 @@ describe("faceBlurRenderer.render (regions)", () => {
const ctx = makeContext();
const image = makeImage();
const sampleDrawImage = vi.fn();
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
const sampleCtx = {
imageSmoothingEnabled: true,
drawImage: sampleDrawImage,
} as unknown as CanvasRenderingContext2D;
const sampleCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => sampleCtx),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = doc.createElement.bind(doc);
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
if (tagName === "canvas") return sampleCanvas;
return nativeCreateElement(tagName);
});
const createElement = vi
.spyOn(doc, "createElement")
.mockImplementation((tagName: string) => {
if (tagName === "canvas") return sampleCanvas;
return nativeCreateElement(tagName);
});
const effect: FaceBlurEffect = {
kind: "face-blur",
enabled: true,
method: "pixelate",
amount: 10,
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
regions: [
{
x: 200,
y: 100,
width: 160,
height: 120,
sourceWidth: 1200,
sourceHeight: 800,
},
],
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
expect(sampleCanvas.width).toBe(16);
expect(sampleCanvas.height).toBe(12);
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
expect(sampleDrawImage).toHaveBeenCalledWith(
image,
200,
100,
160,
120,
0,
0,
16,
12,
);
expect(ctx.drawImage).toHaveBeenCalledWith(
sampleCanvas,
0,
0,
16,
12,
100,
50,
80,
60,
);
createElement.mockRestore();
});
@@ -81,7 +143,17 @@ describe("faceBlurRenderer.render (regions)", () => {
method: "censor",
amount: 20,
censorColor: "#ff0000",
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
regions: [
{
x: 20,
y: 30,
width: 40,
height: 50,
sourceWidth: 1200,
sourceHeight: 800,
censorColor: "#00ff00",
},
],
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
@@ -101,7 +173,17 @@ describe("faceBlurRenderer.render (regions)", () => {
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
expect(ctx.drawImage).toHaveBeenCalledWith(
image,
400,
480,
1200,
800,
100,
120,
300,
200,
);
});
});
@@ -113,28 +195,67 @@ describe("faceBlurRenderer.renderLayer", () => {
const ctx = makeContext();
const image = makeImage();
const sourceDrawImage = vi.fn();
const sourceCtx = { ...makeContext(), drawImage: sourceDrawImage } as unknown as CanvasRenderingContext2D;
const sourceCanvas = { width: 0, height: 0, getContext: vi.fn(() => sourceCtx) } as unknown as HTMLCanvasElement;
const sourceCtx = {
...makeContext(),
drawImage: sourceDrawImage,
} as unknown as CanvasRenderingContext2D;
const sourceCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => sourceCtx),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = doc.createElement.bind(doc);
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
if (tagName === "canvas") return sourceCanvas;
return nativeCreateElement(tagName);
});
const createElement = vi
.spyOn(doc, "createElement")
.mockImplementation((tagName: string) => {
if (tagName === "canvas") return sourceCanvas;
return nativeCreateElement(tagName);
});
const effect: FaceBlurEffect = {
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 24,
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
regions: [
{
x: 120,
y: 80,
width: 300,
height: 200,
sourceWidth: 1200,
sourceHeight: 800,
},
],
};
faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect);
expect(sourceCanvas.width).toBe(1200);
expect(sourceCanvas.height).toBe(800);
expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800);
expect(sourceDrawImage).toHaveBeenNthCalledWith(2, image, 120, 80, 300, 200, 120, 80, 300, 200);
expect(ctx.drawImage).toHaveBeenCalledWith(sourceCanvas, 0, 0, 1200, 800, 0, 0, 600, 400);
expect(sourceDrawImage).toHaveBeenNthCalledWith(
2,
image,
120,
80,
300,
200,
120,
80,
300,
200,
);
expect(ctx.drawImage).toHaveBeenCalledWith(
sourceCanvas,
0,
0,
1200,
800,
0,
0,
600,
400,
);
createElement.mockRestore();
});
@@ -147,30 +268,79 @@ describe("faceBlurRenderer.renderLayer", () => {
const image = makeImage();
const regionDrawImage = vi.fn();
const blurDrawImage = vi.fn();
const regionCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: regionDrawImage } as unknown as CanvasRenderingContext2D;
const blurCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: blurDrawImage } as unknown as CanvasRenderingContext2D;
const regionCanvas = { width: 0, height: 0, getContext: vi.fn(() => regionCtx) } as unknown as HTMLCanvasElement;
const blurCanvas = { width: 0, height: 0, getContext: vi.fn(() => blurCtx) } as unknown as HTMLCanvasElement;
const regionCtx = {
...makeContext(),
clearRect: vi.fn(),
drawImage: regionDrawImage,
} as unknown as CanvasRenderingContext2D;
const blurCtx = {
...makeContext(),
clearRect: vi.fn(),
drawImage: blurDrawImage,
} as unknown as CanvasRenderingContext2D;
const regionCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => regionCtx),
} as unknown as HTMLCanvasElement;
const blurCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => blurCtx),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = doc.createElement.bind(doc);
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
if (tagName !== "canvas") return nativeCreateElement(tagName);
return createElement.mock.calls.length === 1 ? regionCanvas : blurCanvas;
});
const createElement = vi
.spyOn(doc, "createElement")
.mockImplementation((tagName: string) => {
if (tagName !== "canvas") return nativeCreateElement(tagName);
return createElement.mock.calls.length === 1
? regionCanvas
: blurCanvas;
});
const effect: FaceBlurEffect = {
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 24,
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
regions: [
{
x: 120,
y: 80,
width: 300,
height: 200,
sourceWidth: 1200,
sourceHeight: 800,
},
],
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
expect(ctx.save).not.toHaveBeenCalled();
expect(regionCanvas.width).toBe(150);
expect(regionCanvas.height).toBe(100);
expect(regionDrawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 0, 0, 150, 100);
expect(ctx.drawImage).toHaveBeenCalledWith(regionCanvas, 0, 0, 150, 100, 60, 40, 150, 100);
expect(regionDrawImage).toHaveBeenCalledWith(
image,
120,
80,
300,
200,
0,
0,
150,
100,
);
expect(ctx.drawImage).toHaveBeenCalledWith(
regionCanvas,
0,
0,
150,
100,
60,
40,
150,
100,
);
createElement.mockRestore();
});
});
+125 -15
View File
@@ -20,9 +20,29 @@ function drawPixelatedRegion(
const sampleCtx = sampleCanvas.getContext("2d");
if (!sampleCtx) return;
sampleCtx.imageSmoothingEnabled = false;
sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
sampleCtx.drawImage(
source,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
sampleCanvas.width,
sampleCanvas.height,
);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
ctx.drawImage(
sampleCanvas,
0,
0,
sampleCanvas.width,
sampleCanvas.height,
targetX,
targetY,
targetWidth,
targetHeight,
);
ctx.imageSmoothingEnabled = true;
}
@@ -44,7 +64,17 @@ function drawBlurredRegionFallback(
regionCanvas.height = Math.max(1, Math.round(targetHeight));
const regionCtx = regionCanvas.getContext("2d");
if (!regionCtx) return;
regionCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, regionCanvas.width, regionCanvas.height);
regionCtx.drawImage(
source,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
regionCanvas.width,
regionCanvas.height,
);
const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
const blurCanvas = document.createElement("canvas");
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
@@ -55,11 +85,41 @@ function drawBlurredRegionFallback(
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
for (let i = 0; i < 3; i++) {
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
regionCtx.drawImage(blurCanvas, 0, 0, blurCanvas.width, blurCanvas.height, 0, 0, regionCanvas.width, regionCanvas.height);
regionCtx.drawImage(
blurCanvas,
0,
0,
blurCanvas.width,
blurCanvas.height,
0,
0,
regionCanvas.width,
regionCanvas.height,
);
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
blurCtx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, 0, 0, blurCanvas.width, blurCanvas.height);
blurCtx.drawImage(
regionCanvas,
0,
0,
regionCanvas.width,
regionCanvas.height,
0,
0,
blurCanvas.width,
blurCanvas.height,
);
}
ctx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, targetX, targetY, targetWidth, targetHeight);
ctx.drawImage(
regionCanvas,
0,
0,
regionCanvas.width,
regionCanvas.height,
targetX,
targetY,
targetWidth,
targetHeight,
);
}
function renderRegions(
@@ -84,10 +144,18 @@ function renderRegions(
const y = Math.max(0, Math.floor(region.y * scaleY));
const w = Math.max(1, Math.floor(region.width * scaleX));
const h = Math.max(1, Math.floor(region.height * scaleY));
const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
const sx0 = hasSourceDims
? region.x
: Math.max(0, Math.floor(region.x * legacyScaleX));
const sy0 = hasSourceDims
? region.y
: Math.max(0, Math.floor(region.y * legacyScaleY));
const sw = hasSourceDims
? region.width
: Math.max(1, Math.floor(region.width * legacyScaleX));
const sh = hasSourceDims
? region.height
: Math.max(1, Math.floor(region.height * legacyScaleY));
if (effect.method === "censor") {
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
@@ -95,7 +163,19 @@ function renderRegions(
continue;
}
if (effect.method === "pixelate") {
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
drawPixelatedRegion(
ctx,
image,
sx0,
sy0,
sw,
sh,
x,
y,
w,
h,
Math.max(4, Math.round(effect.amount / 2)),
);
continue;
}
if ("filter" in ctx && typeof ctx.filter === "string") {
@@ -105,7 +185,19 @@ function renderRegions(
ctx.restore();
continue;
}
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount);
drawBlurredRegionFallback(
ctx,
image,
sx0,
sy0,
sw,
sh,
x,
y,
w,
h,
effect.amount,
);
}
}
@@ -116,7 +208,9 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
return;
}
const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
const allHaveSourceDims = effect.regions.every(
(r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0,
);
if (!allHaveSourceDims) {
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
renderRegions(ctx, image, effect, targetWidth, targetHeight);
@@ -132,8 +226,24 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
return;
}
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height);
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
renderRegions(
sourceCtx,
image,
effect,
sourceCanvas.width,
sourceCanvas.height,
);
ctx.drawImage(
sourceCanvas,
0,
0,
sourceCanvas.width,
sourceCanvas.height,
0,
0,
targetWidth,
targetHeight,
);
}
export const faceBlurRenderer: EffectRenderer = {
+7 -4
View File
@@ -12,7 +12,6 @@ export function getEffectRenderer(kind: string): EffectRenderer | undefined {
return effectRendererRegistry.get(kind);
}
/** Draws a layer image applying all its effects in order. */
export function renderLayerWithEffects(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
@@ -30,14 +29,18 @@ export function renderLayerWithEffects(
return;
}
const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight };
const context: EffectRenderContext = {
ctx,
image,
targetWidth,
targetHeight,
};
// The first effect owns the full layer render (draws base image + applies itself)
// The first effect owns the base image draw; later effects only overlay their changes.
const first = activeEffects[0];
const firstRenderer = effectRendererRegistry.get(first.kind);
firstRenderer?.renderLayer(context, first);
// Subsequent effects render on top (overlay only, no re-draw of base)
for (let i = 1; i < activeEffects.length; i++) {
const effect = activeEffects[i];
const renderer = effectRendererRegistry.get(effect.kind);
-2
View File
@@ -9,8 +9,6 @@ export type EffectRenderContext = {
export type EffectRenderer = {
kind: LayerEffect["kind"];
/** Renders the effect onto the canvas. Called after the base image is drawn. */
render: (context: EffectRenderContext, effect: LayerEffect) => void;
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
};
+32 -12
View File
@@ -1,4 +1,8 @@
import type { Layer, Project } from "@pien-studio/types";
import {
getLayerRuntimeSource,
type Layer,
type Project,
} from "@pien-studio/types";
import { renderLayerWithEffects } from "./effects/registry";
type ExportOptions = {
@@ -20,10 +24,14 @@ function loadImage(src: string) {
});
}
function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
function drawFallbackLayer(
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
const text = layer.name ?? layer.type;
const width = Math.max(80, layer.width ?? 120);
const height = Math.max(34, layer.height ?? 40);
const width = Math.max(80, layer.width);
const height = Math.max(34, layer.height);
const radius = 8;
ctx.beginPath();
@@ -45,15 +53,21 @@ function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark:
ctx.stroke();
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
ctx.font =
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, width / 2, height / 2);
}
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120);
const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
async function drawLayer(
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
const width = layer.width;
const height = layer.height;
const sourceUri = getLayerRuntimeSource(layer);
ctx.save();
ctx.globalAlpha = clampOpacity(layer.opacity);
@@ -61,9 +75,9 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
ctx.rotate((layer.rotation * Math.PI) / 180);
ctx.translate(-width / 2, -height / 2);
if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) {
if ((layer.type === "raster" || layer.type === "sticker") && sourceUri) {
try {
const image = await loadImage(layer.sourceUri);
const image = await loadImage(sourceUri);
renderLayerWithEffects(ctx, image, layer.effects, width, height);
} catch {
drawFallbackLayer(ctx, layer, isDark);
@@ -75,8 +89,14 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
ctx.restore();
}
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
export async function exportProjectAsPng(
project: Project,
options: ExportOptions,
) {
const pixelRatio = Math.max(
1,
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
);
const { width, height } = project.canvas;
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
+18 -3
View File
@@ -2,7 +2,12 @@ type RGBA = [number, number, number, number];
function colorDistance(a: RGBA, b: RGBA): number {
// Weight alpha at 25% so transparent regions fill correctly
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]) * 0.25;
return (
Math.abs(a[0] - b[0]) +
Math.abs(a[1] - b[1]) +
Math.abs(a[2] - b[2]) +
Math.abs(a[3] - b[3]) * 0.25
);
}
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
@@ -53,7 +58,12 @@ export function floodFillDataUrl(
}
const idx = (y * width + x) * 4;
const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
const target: RGBA = [
pixels[idx],
pixels[idx + 1],
pixels[idx + 2],
pixels[idx + 3],
];
const fill = hexToRgba(fillColor);
if (matchesTarget(target, fill, 0)) {
@@ -72,7 +82,12 @@ export function floodFillDataUrl(
const cx = pos % width;
const cy = Math.floor(pos / width);
const ci = pos * 4;
const current: RGBA = [pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3]];
const current: RGBA = [
pixels[ci],
pixels[ci + 1],
pixels[ci + 2],
pixels[ci + 3],
];
if (!matchesTarget(current, target, tolerance)) continue;
+71 -10
View File
@@ -10,7 +10,44 @@ function makeProject(): Project {
updatedAt: "2024-01-01T00:00:00.000Z",
aspectRatio: "1:1",
canvas: { width: 100, height: 100, unit: "px" },
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }],
layers: [textLayer("l1")],
};
}
function textLayer(id: string) {
return {
id,
type: "text" as const,
x: 0,
y: 0,
width: 120,
height: 48,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
text: "Text",
fontFamily: "system-ui",
fontSize: 24,
color: "#000",
};
}
function rasterLayer(id: string) {
return {
id,
type: "raster" as const,
x: 0,
y: 0,
width: 120,
height: 80,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
asset: null,
};
}
@@ -31,18 +68,34 @@ describe("hasProjectChanged", () => {
it("detects face blur region changes", () => {
const a = makeProject();
const b = makeProject();
a.layers[0].type = "raster";
b.layers[0].type = "raster";
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
b.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }];
a.layers[0] = rasterLayer("l1");
b.layers[0] = rasterLayer("l1");
a.layers[0].effects = [
{
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 14,
regions: [{ x: 1, y: 1, width: 10, height: 10 }],
},
];
b.layers[0].effects = [
{
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 14,
regions: [{ x: 1, y: 1, width: 11, height: 10 }],
},
];
expect(hasProjectChanged(a, b)).toBe(true);
});
it("detects layer order changes", () => {
const a = makeProject();
const b = makeProject();
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
a.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
b.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
b.layers = [b.layers[1], b.layers[0]];
expect(hasProjectChanged(a, b)).toBe(true);
});
@@ -65,9 +118,17 @@ describe("hasProjectChanged", () => {
it("detects face blur removal", () => {
const a = makeProject();
const b = makeProject();
a.layers[0].type = "raster";
b.layers[0].type = "raster";
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
a.layers[0] = rasterLayer("l1");
b.layers[0] = rasterLayer("l1");
a.layers[0].effects = [
{
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 14,
regions: [{ x: 1, y: 1, width: 10, height: 10 }],
},
];
expect(hasProjectChanged(a, b)).toBe(true);
});
});
+27 -4
View File
@@ -1,4 +1,4 @@
import type { Project } from "@pien-studio/types";
import { getLayerAssetRef, type Project } from "@pien-studio/types";
export function hasProjectChanged(left: Project, right: Project): boolean {
if (left.id !== right.id) return true;
@@ -6,7 +6,11 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
if (left.createdAt !== right.createdAt) return true;
if (left.updatedAt !== right.updatedAt) return true;
if (left.aspectRatio !== right.aspectRatio) return true;
if (left.canvas.width !== right.canvas.width || left.canvas.height !== right.canvas.height || left.canvas.unit !== right.canvas.unit) {
if (
left.canvas.width !== right.canvas.width ||
left.canvas.height !== right.canvas.height ||
left.canvas.unit !== right.canvas.unit
) {
return true;
}
if (left.layers.length !== right.layers.length) return true;
@@ -19,8 +23,6 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
a.id !== b.id ||
a.type !== b.type ||
a.name !== b.name ||
a.assetId !== b.assetId ||
a.sourceUri !== b.sourceUri ||
a.x !== b.x ||
a.y !== b.y ||
a.width !== b.width ||
@@ -33,6 +35,27 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
return true;
}
if (
JSON.stringify(getLayerAssetRef(a)) !==
JSON.stringify(getLayerAssetRef(b))
)
return true;
if (
(a.type === "raster" || a.type === "sticker") &&
(b.type === "raster" || b.type === "sticker") &&
a.runtimeSourceUri !== b.runtimeSourceUri
)
return true;
if (
a.type === "text" &&
b.type === "text" &&
(a.text !== b.text ||
a.fontFamily !== b.fontFamily ||
a.fontSize !== b.fontSize ||
a.color !== b.color)
)
return true;
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Project } from "@pien-studio/types";
import {
cleanupOrphanAssets,
deleteProject,
duplicateProject,
getProjectById,
loadProjects,
releaseProjectObjectUrls,
upsertProject,
} from "@pien-studio/storage";
export type ProjectRepository = {
listProjects: () => Promise<Project[]>;
getProject: (projectId: string) => Promise<Project | null>;
upsertProject: (project: Project) => Promise<void>;
deleteProject: (projectId: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<Project | null>;
cleanupAssets: () => Promise<number>;
releaseObjectUrls: (
project: Project,
keepAssetIds?: Iterable<string>,
) => void;
};
export const localProjectRepository: ProjectRepository = {
listProjects: loadProjects,
getProject: getProjectById,
upsertProject,
deleteProject,
duplicateProject,
cleanupAssets: cleanupOrphanAssets,
releaseObjectUrls: releaseProjectObjectUrls,
};
+10 -3
View File
@@ -7,7 +7,9 @@ export function surfaceClass(isDark: boolean): string {
}
export function mutedSurfaceClass(isDark: boolean): string {
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
return isDark
? "border-white/10 bg-[#23252a]"
: "border-black/10 bg-[#f7f8fa]";
}
export function subtleButtonClass(isDark: boolean): string {
@@ -29,7 +31,10 @@ export function dividerClass(isDark: boolean): string {
}
export function panelClass(isDark: boolean): string {
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
return cx(
"rounded border p-3",
isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white",
);
}
export function panelTitleClass(isDark: boolean): string {
@@ -41,5 +46,7 @@ export function panelCounterClass(isDark: boolean): string {
}
export function panelInsetClass(isDark: boolean): string {
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
return isDark
? "border-white/10 bg-[#24262b]"
: "border-black/10 bg-[#f6f7f9]";
}
-3
View File
@@ -1,10 +1,7 @@
import type { ToolDefinition } from "@pien-studio/editor-core";
export type ToolUiDefinition = ToolDefinition & {
/** Lucide icon component name (resolved at render time) */
iconName: string;
/** CSS cursor when this tool is active */
cursor: string;
/** i18n key for the toolbar label */
labelKey: string;
};
+1 -1
View File
@@ -4,4 +4,4 @@ const withNextIntl = createNextIntlPlugin();
export default withNextIntl({
reactStrictMode: true,
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -p 3000",
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "eslint .",
+11 -42
View File
@@ -1,42 +1,11 @@
import type { Layer, Project } from "@pien-studio/types";
export const HISTORY_LIMIT = 120;
export type HistoryState = {
past: Project[];
present: Project;
future: Project[];
};
export function deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export function cloneProject(project: Project): Project {
return deepClone(project);
}
export function cloneLayer(layer: Layer): Layer {
return deepClone(layer);
}
export function makeHistory(project: Project): HistoryState {
return { past: [], present: cloneProject(project), future: [] };
}
export function computeHistoryFlags(history: HistoryState) {
return { canUndo: history.past.length > 0, canRedo: history.future.length > 0 };
}
export function capHistory(items: Project[]): Project[] {
if (items.length <= HISTORY_LIMIT) return items;
return items.slice(items.length - HISTORY_LIMIT);
}
export function resolveSelectedLayerId(project: Project, preferred: string | null): string | null {
if (preferred && project.layers.some((layer) => layer.id === preferred)) return preferred;
return project.layers[0]?.id ?? null;
}
export {
HISTORY_LIMIT,
capHistory,
cloneLayer,
cloneProject,
computeHistoryFlags,
deepClone,
makeHistory,
resolveSelectedLayerId,
} from "@pien-studio/editor-core";
export type { HistoryState } from "@pien-studio/editor-core";
+13 -3
View File
@@ -94,7 +94,11 @@ describe("editor store", () => {
const layer = useEditorStore.getState().project.layers[0];
expect(layer?.visible).toBe(false);
expect(layer?.effects[0]).toMatchObject({ amount: 40, enabled: false, regions: [{ x: 0, y: 1, width: 1, height: 2 }] });
expect(layer?.effects[0]).toMatchObject({
amount: 40,
enabled: false,
regions: [{ x: 0, y: 1, width: 1, height: 2 }],
});
expect(useEditorStore.getState().canUndo).toBe(true);
useEditorStore.getState().undo();
@@ -123,7 +127,10 @@ describe("editor store", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().setCanvasSize(222.4, 333.6);
useEditorStore.getState().addLayerByType("text");
expect(useEditorStore.getState().project.canvas).toMatchObject({ width: 222, height: 334 });
expect(useEditorStore.getState().project.canvas).toMatchObject({
width: 222,
height: 334,
});
useEditorStore.getState().undo();
expect(useEditorStore.getState().project.layers).toHaveLength(0);
@@ -142,7 +149,10 @@ describe("editor store", () => {
it("rejects invalid project json and replaces projects", () => {
useEditorStore.getState().resetProject();
const originalId = useEditorStore.getState().project.id;
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({ ok: false, error: "Invalid JSON" });
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({
ok: false,
error: "Invalid JSON",
});
expect(useEditorStore.getState().project.id).toBe(originalId);
const project = createProject("replacement");
+302 -106
View File
@@ -19,8 +19,16 @@ import {
getAllTools,
type EditorToolId,
} from "@pien-studio/editor-core";
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
import {
getAssetRefId,
getLayerAssetRef,
getLayerRuntimeSource,
type Layer,
type LayerEffect,
type LayerType,
type Project,
} from "@pien-studio/types";
import { localProjectRepository } from "../lib/project-repository";
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
import { hasProjectChanged } from "../lib/project-equality";
import {
@@ -38,7 +46,9 @@ const DRAFT_TRANSFORM_EPSILON = 0.01;
export { EditorToolId };
const allTools = getAllTools();
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
export const EDITOR_TOOLS = Object.fromEntries(
allTools.map((t) => [t.id, t]),
) as Record<string, (typeof allTools)[number]>;
type TransactionState = {
baselineProject: Project;
@@ -60,7 +70,7 @@ type EditorState = {
cancelTransaction: () => void;
applyProjectDraft: (project: Project) => void;
setTool: (tool: EditorToolId) => void;
addLayerByType: (type: Layer["type"]) => void;
addLayerByType: (type: LayerType) => void;
setSelectedLayerPosition: (x: number, y: number) => void;
setSelectedLayerPositionDraft: (x: number, y: number) => void;
setSelectedLayerSize: (width: number, height: number) => void;
@@ -84,7 +94,11 @@ type EditorState = {
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
setLayerVisible: (layerId: string, visible: boolean) => void;
setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void;
setEffectEnabled: (
layerId: string,
kind: LayerEffect["kind"],
enabled: boolean,
) => void;
setCanvasSize: (width: number, height: number) => void;
exportProjectToJson: () => string;
undo: () => void;
@@ -95,7 +109,11 @@ type EditorState = {
const initialProject = createProject("Untitled Project");
function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial<EditorState>) {
function withCommittedProject(
state: EditorState,
nextProject: Project,
extras?: Partial<EditorState>,
) {
const past = capHistory([...state.history.past, state.history.present]);
const history = { past, present: cloneProject(nextProject), future: [] };
return {
@@ -109,7 +127,10 @@ function withCommittedProject(state: EditorState, nextProject: Project, extras?:
} satisfies Partial<EditorState>;
}
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
function makeStableProjectState(
previousSelectedLayerId: string | null,
project: Project,
) {
const history = makeHistory(project);
return {
project,
@@ -123,7 +144,9 @@ function makeStableProjectState(previousSelectedLayerId: string | null, project:
}
function getProjectAssetIds(project: Project): string[] {
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
return project.layers
.map((layer) => getAssetRefId(getLayerAssetRef(layer)))
.filter((assetId): assetId is string => Boolean(assetId));
}
export const useEditorStore = create<EditorState>((set, get) => ({
@@ -150,12 +173,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
commitTransaction: () =>
set((state) => {
if (!state.transaction) return state;
if (!hasProjectChanged(state.transaction.baselineProject, state.project)) {
if (
!hasProjectChanged(state.transaction.baselineProject, state.project)
) {
return { transaction: null };
}
const past = capHistory([...state.history.past, cloneProject(state.transaction.baselineProject)]);
const history = { past, present: cloneProject(state.project), future: [] };
const past = capHistory([
...state.history.past,
cloneProject(state.transaction.baselineProject),
]);
const history = {
past,
present: cloneProject(state.project),
future: [],
};
return {
history,
transaction: null,
@@ -169,7 +201,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
if (!state.transaction) return state;
return {
project: cloneProject(state.transaction.baselineProject),
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
selectedLayerId: resolveSelectedLayerId(
state.transaction.baselineProject,
state.transaction.baselineSelectedLayerId,
),
transaction: null,
};
}),
@@ -185,23 +220,40 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => {
const layer = createLayer(type);
const nextProject = addLayer(state.project, layer);
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
return withCommittedProject(state, nextProject, {
selectedLayerId: layer.id,
});
}),
addCanvasSizedLayer: (sourceUri, name) =>
set((state) => {
const { width, height } = state.project.canvas;
const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height });
const layer = createLayer("raster", {
name: name ?? "Layer",
asset: { kind: "inline", uri: sourceUri },
x: 0,
y: 0,
width,
height,
});
const nextProject = addLayer(state.project, layer);
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
return withCommittedProject(state, nextProject, {
selectedLayerId: layer.id,
});
}),
setSelectedLayerPosition: (x, y) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
const committed = state.history.present.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
if (committed && committed.x === x && committed.y === y) return state;
const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y });
const nextProject = updateLayerTransform(
state.project,
state.selectedLayerId,
{ x, y },
);
return withCommittedProject(state, nextProject);
}),
@@ -209,30 +261,48 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => {
if (!state.selectedLayerId) return state;
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
const current = state.project.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
if (current && current.x === x && current.y === y) return state;
return { project: updateLayerTransform(state.project, state.selectedLayerId, { x, y }) };
return {
project: updateLayerTransform(state.project, state.selectedLayerId, {
x,
y,
}),
};
}),
setSelectedLayerSize: (width, height) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
if (committed && committed.width === width && committed.height === height) return state;
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { width, height }));
const committed = state.history.present.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
if (committed && committed.width === width && committed.height === height)
return state;
return withCommittedProject(
state,
updateLayerTransform(state.project, state.selectedLayerId, {
width,
height,
}),
);
}),
setSelectedLayerSizeDraft: (width, height) =>
set((state) => {
if (!state.selectedLayerId) return state;
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
const current = state.project.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
if (current) {
const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined);
const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
const currentWidth = current.width;
const currentHeight = current.height;
if (
typeof currentWidth === "number" &&
@@ -243,7 +313,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return state;
}
}
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
return {
project: updateLayerTransform(state.project, state.selectedLayerId, {
width: nextWidth,
height: nextHeight,
}),
};
}),
removeSelectedLayer: () =>
@@ -256,34 +331,57 @@ export const useEditorStore = create<EditorState>((set, get) => ({
moveSelectedLayerOrder: (direction) =>
set((state) => {
if (!state.selectedLayerId) return state;
const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId);
const idx = state.project.layers.findIndex(
(layer) => layer.id === state.selectedLayerId,
);
if (idx < 0) return state;
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
return withCommittedProject(state, reorderLayer(state.project, state.selectedLayerId, nextIndex));
return withCommittedProject(
state,
reorderLayer(state.project, state.selectedLayerId, nextIndex),
);
}),
selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })),
selectLayer: (layerId) =>
set((state) => ({
selectedLayerId: resolveSelectedLayerId(state.project, layerId),
})),
setSelectedLayerRotation: (rotation) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
const committed = state.history.present.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
if (committed && committed.rotation === rotation) return state;
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { rotation }));
return withCommittedProject(
state,
updateLayerTransform(state.project, state.selectedLayerId, {
rotation,
}),
);
}),
setSelectedLayerRotationDraft: (rotation) =>
set((state) => {
if (!state.selectedLayerId) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
const current = state.project.layers.find(
(layer) => layer.id === state.selectedLayerId,
);
if (current && current.rotation === rotation) return state;
return { project: updateLayerTransform(state.project, state.selectedLayerId, { rotation }) };
return {
project: updateLayerTransform(state.project, state.selectedLayerId, {
rotation,
}),
};
}),
copySelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
const layer = state.project.layers.find(
(item) => item.id === state.selectedLayerId,
);
if (!layer) return state;
return { clipboardLayer: cloneLayer(layer) };
}),
@@ -291,60 +389,89 @@ export const useEditorStore = create<EditorState>((set, get) => ({
cutSelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
const layer = state.project.layers.find(
(item) => item.id === state.selectedLayerId,
);
if (!layer) return state;
const nextProject = removeLayer(state.project, state.selectedLayerId);
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
return withCommittedProject(state, nextProject, {
clipboardLayer: cloneLayer(layer),
});
}),
pasteLayer: (e?: ClipboardEvent) => {
const state = get();
const state = get();
// Internal layer clipboard takes priority
if (state.clipboardLayer) {
const base = state.clipboardLayer;
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
return;
}
if (state.clipboardLayer) {
const base = state.clipboardLayer;
const pasted: Layer = {
...base,
id: crypto.randomUUID(),
x: base.x + 20,
y: base.y + 20,
};
set((s) =>
withCommittedProject(s, addLayer(s.project, pasted), {
selectedLayerId: pasted.id,
}),
);
return;
}
async function pasteImageBlob(blob: Blob) {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
async function pasteImageBlob(blob: Blob) {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const imageSize = await new Promise<{ width: number; height: number }>(
(resolve) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
image.onload = () =>
resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () =>
resolve({
width: DEFAULT_IMAGE_IMPORT.fallbackWidth,
height: DEFAULT_IMAGE_IMPORT.fallbackHeight,
});
image.src = dataUrl;
});
const layer = createLayer("raster", {
name: "Image",
sourceUri: dataUrl,
x: DEFAULT_IMAGE_IMPORT.offsetX,
y: DEFAULT_IMAGE_IMPORT.offsetY,
width: Math.max(1, Math.round(imageSize.width)),
height: Math.max(1, Math.round(imageSize.height)),
});
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
}
},
);
const layer = createLayer("raster", {
name: "Image",
asset: { kind: "inline", uri: dataUrl },
x: DEFAULT_IMAGE_IMPORT.offsetX,
y: DEFAULT_IMAGE_IMPORT.offsetY,
width: Math.max(1, Math.round(imageSize.width)),
height: Math.max(1, Math.round(imageSize.height)),
});
set((s) =>
withCommittedProject(s, addLayer(s.project, layer), {
selectedLayerId: layer.id,
}),
);
}
// Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt)
if (e?.clipboardData) {
for (const item of Array.from(e.clipboardData.items)) {
if (item.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) { void pasteImageBlob(blob); return; }
// Prefer event clipboard data to avoid permission prompts.
if (e?.clipboardData) {
for (const item of Array.from(e.clipboardData.items)) {
if (item.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) {
void pasteImageBlob(blob);
return;
}
}
return;
}
return;
}
// Fallback: async Clipboard API (requires permission, may not work on Mac Safari)
navigator.clipboard.read().then(async (clipboardItems) => {
// Async Clipboard API is a fallback because browser support and permissions vary.
navigator.clipboard
.read()
.then(async (clipboardItems) => {
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith("image/")) {
@@ -354,11 +481,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}
}
}
}).catch(() => {});
},
})
.catch(() => {});
},
resetProject: () => {
releaseProjectObjectUrls(get().project);
localProjectRepository.releaseObjectUrls(get().project);
const project = createProject("Untitled Project");
const history = makeHistory(project);
set({
@@ -373,22 +501,28 @@ export const useEditorStore = create<EditorState>((set, get) => ({
},
saveCurrentProject: async () => {
await upsertProject(normalizeProject(get().project));
await localProjectRepository.upsertProject(normalizeProject(get().project));
set({ isDirty: false });
},
loadProjectById: async (projectId) => {
const project = await getProjectById(projectId);
const project = await localProjectRepository.getProject(projectId);
if (!project) return false;
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
return true;
},
setProject: (project) => {
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
},
@@ -396,7 +530,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const parsed = parseProjectFile(raw);
if (!parsed.ok) return { ok: false, error: parsed.error };
const normalized = normalizeProject(parsed.project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
return { ok: true };
},
@@ -404,34 +541,47 @@ export const useEditorStore = create<EditorState>((set, get) => ({
exportProjectToJson: () => {
const project = normalizeProject(get().project);
const history = get().history;
return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length });
return serializeProjectFile(project, {
checkpointCount: history.past.length + history.future.length,
});
},
importImageFromFile: async (file) => {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = reject;
reader.readAsDataURL(file);
});
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () =>
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
image.src = dataUrl;
});
const imageSize = await new Promise<{ width: number; height: number }>(
(resolve) => {
const image = new Image();
image.onload = () =>
resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () =>
resolve({
width: DEFAULT_IMAGE_IMPORT.fallbackWidth,
height: DEFAULT_IMAGE_IMPORT.fallbackHeight,
});
image.src = dataUrl;
},
);
const layer = createLayer("raster", {
name,
sourceUri: dataUrl,
asset: { kind: "inline", uri: dataUrl },
x: DEFAULT_IMAGE_IMPORT.offsetX,
y: DEFAULT_IMAGE_IMPORT.offsetY,
width: Math.max(1, Math.round(imageSize.width)),
height: Math.max(1, Math.round(imageSize.height)),
});
set((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id }));
set((state) =>
withCommittedProject(state, addLayer(state.project, layer), {
selectedLayerId: layer.id,
}),
);
},
updateImageLayerSource: (layerId, sourceUri) =>
@@ -439,8 +589,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
if (!sourceUri) return state;
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer || layer.type !== "raster") return state;
if (layer.sourceUri === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
const currentSource = getLayerRuntimeSource(layer);
if (currentSource === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, {
asset: { kind: "inline", uri: sourceUri },
runtimeSourceUri: undefined,
});
return withCommittedProject(state, nextProject);
}),
@@ -448,23 +602,45 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer) return state;
return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
return withCommittedProject(
state,
setLayerEffect(state.project, layerId, effect),
);
}),
removeLayerEffect: (layerId, kind) =>
set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer) return state;
return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind));
return withCommittedProject(
state,
removeLayerEffect(state.project, layerId, kind),
);
}),
setLayerVisible: (layerId, visible) =>
set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))),
set((state) =>
withCommittedProject(
state,
setLayerVisible(state.project, layerId, visible),
),
),
setEffectEnabled: (layerId, kind, enabled) =>
set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))),
set((state) =>
withCommittedProject(
state,
setEffectEnabled(state.project, layerId, kind, enabled),
),
),
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
setCanvasSize: (width, height) =>
set((state) =>
withCommittedProject(
state,
applyCanvasSize(state.project, width, height),
),
),
undo: () =>
set((state) => {
@@ -477,7 +653,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return {
project: cloneProject(previous),
history,
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
selectedLayerId: resolveSelectedLayerId(
previous,
state.selectedLayerId,
),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
@@ -504,19 +683,29 @@ export const useEditorStore = create<EditorState>((set, get) => ({
jumpToPast: (idx) =>
set((state) => {
const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1));
const targetPastLength = Math.max(
0,
Math.min(state.history.past.length, idx - 1),
);
if (state.history.past.length === targetPastLength) return state;
const moved = state.history.past.slice(targetPastLength);
if (moved.length === 0) return state;
const previous = moved[0];
if (!previous) return state;
const past = state.history.past.slice(0, targetPastLength);
const future = [state.history.present, ...moved.slice(1), ...state.history.future];
const future = [
state.history.present,
...moved.slice(1),
...state.history.future,
];
const history = { past, present: cloneProject(previous), future };
return {
project: cloneProject(previous),
history,
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
selectedLayerId: resolveSelectedLayerId(
previous,
state.selectedLayerId,
),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
@@ -525,14 +714,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
jumpToFuture: (idx) =>
set((state) => {
const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx));
const targetFutureLength = Math.max(
0,
Math.min(state.history.future.length, idx),
);
if (state.history.future.length === targetFutureLength) return state;
const redoCount = state.history.future.length - targetFutureLength;
const next = state.history.future[redoCount - 1];
if (!next) return state;
const consumedFuture = state.history.future.slice(0, redoCount - 1);
const future = state.history.future.slice(redoCount);
const past = capHistory([...state.history.past, state.history.present, ...consumedFuture]);
const past = capHistory([
...state.history.past,
state.history.present,
...consumedFuture,
]);
const history = { past, present: cloneProject(next), future };
return {
project: cloneProject(next),
+3 -1
View File
@@ -10,7 +10,9 @@ const LOCALE_KEY = "pien.ui.locale";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function readTheme(): AppTheme {
+4 -16
View File
@@ -6,27 +6,15 @@
"name": "next"
}
],
"types": [
"node"
],
"types": ["node"],
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"isolatedModules": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}