mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.turbo
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
docs
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@pien-studio/api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist",
|
||||
"start": "bun run dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"elysia": "^1.1.25",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "./index";
|
||||
|
||||
describe("api endpoints", () => {
|
||||
it("returns root route info", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(new Request("http://localhost/"));
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.name).toBe("pien-api");
|
||||
});
|
||||
|
||||
it("returns healthy status", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(new Request("http://localhost/health"));
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns token for valid device payload", async () => {
|
||||
const app = createApp();
|
||||
const response = await app.handle(
|
||||
new Request("http://localhost/auth/device", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ deviceId: "dev1234", locale: "en" }),
|
||||
}),
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.token).toBe("dev_dev1234");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { DeviceSessionSchema } from "@pien-studio/types";
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.get("/", () => ({
|
||||
name: "pien-api",
|
||||
status: "ok",
|
||||
}))
|
||||
.get("/health", () => ({ ok: true, service: "pien-api" }))
|
||||
.post("/auth/device", ({ body }) => {
|
||||
const parsed = DeviceSessionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify({ error: "invalid_device_payload" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return {
|
||||
token: `dev_${parsed.data.deviceId}`,
|
||||
scope: "local-sync",
|
||||
};
|
||||
})
|
||||
.get("/sync/bootstrap", () => ({
|
||||
replication: {
|
||||
pull: "/sync/pull",
|
||||
push: "/sync/push",
|
||||
strategy: "couch-compatible",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
createApp().listen(4000);
|
||||
console.log("pien api listening on http://localhost:4000");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react";
|
||||
import { useEditorStore } from "../../../store/editor-store";
|
||||
import { useUiStore } from "../../../store/ui-store";
|
||||
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
||||
import { EditorCanvasStage } from "../../../components/editor/editor-canvas-stage";
|
||||
import { EditorHeader } from "../../../components/editor/editor-header";
|
||||
import { EditorMobileSection } from "../../../components/editor/editor-mobile-section";
|
||||
import { EditorSidebar } from "../../../components/editor/editor-sidebar";
|
||||
import { ToolRail } from "../../../components/editor/tool-rail";
|
||||
import { exportProjectAsPng } from "../../../lib/export-png";
|
||||
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
|
||||
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
||||
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
||||
import { useEditorAutosave } from "../../../hooks/use-editor-autosave";
|
||||
import { useEditorBindings } from "../../../hooks/use-editor-bindings";
|
||||
import { useEditorContextMenu } from "../../../hooks/use-editor-context-menu";
|
||||
import { useEditorLabels } from "../../../hooks/use-editor-labels";
|
||||
import { useEditorProjectLifecycle } from "../../../hooks/use-editor-project-lifecycle";
|
||||
import { useEditorShortcuts } from "../../../hooks/use-editor-shortcuts";
|
||||
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 }>> = {
|
||||
pointer: MousePointer2,
|
||||
hand: Hand,
|
||||
face: ScanFace,
|
||||
"add-text": Type,
|
||||
"import-image": ImagePlus,
|
||||
};
|
||||
|
||||
export default function EditorPage() {
|
||||
useAssetCleanupJob();
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const { state, actions } = useEditorBindings();
|
||||
const {
|
||||
project,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
canUndo,
|
||||
canRedo,
|
||||
history,
|
||||
isDirty,
|
||||
tool,
|
||||
} = state;
|
||||
const {
|
||||
loadProjectById,
|
||||
setProject,
|
||||
saveCurrentProject,
|
||||
selectLayer,
|
||||
setSelectedLayerPosition,
|
||||
setSelectedLayerPositionDraft,
|
||||
setSelectedLayerSize,
|
||||
setSelectedLayerSizeDraft,
|
||||
setSelectedLayerRotation,
|
||||
setSelectedLayerRotationDraft,
|
||||
startTransaction,
|
||||
commitTransaction,
|
||||
removeSelectedLayer,
|
||||
addLayerByType,
|
||||
moveSelectedLayerOrder,
|
||||
importImageFromFile,
|
||||
setImageLayerFaceBlur,
|
||||
setCanvasSize,
|
||||
setTool,
|
||||
undo,
|
||||
redo,
|
||||
exportProjectToJson,
|
||||
jumpToPast,
|
||||
jumpToFuture,
|
||||
copySelectedLayer,
|
||||
cutSelectedLayer,
|
||||
pasteLayer,
|
||||
} = actions;
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
|
||||
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
||||
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
||||
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedImageLayer = React.useMemo(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: selectedLayer.id,
|
||||
sourceUri: selectedLayer.sourceUri,
|
||||
width: selectedLayer.width,
|
||||
height: selectedLayer.height,
|
||||
};
|
||||
}, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]);
|
||||
|
||||
const isLayerStillSelected = React.useCallback((layerId: string) => {
|
||||
const state = useEditorStore.getState();
|
||||
return state.selectedLayerId === layerId;
|
||||
}, []);
|
||||
|
||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
||||
tool,
|
||||
selectedLayerId,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected: isLayerStillSelected,
|
||||
});
|
||||
|
||||
const {
|
||||
blurMethod,
|
||||
setBlurMethod,
|
||||
blurAmount,
|
||||
setBlurAmount,
|
||||
censorColor,
|
||||
setCensorColor,
|
||||
selectedFaceIndices,
|
||||
faceBlurPreview,
|
||||
toggleFaceIndex,
|
||||
clearBlur,
|
||||
blurFaces,
|
||||
} = useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId,
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isDirty]);
|
||||
|
||||
useEditorProjectLifecycle({
|
||||
projectId,
|
||||
hydrate,
|
||||
loadProjectById,
|
||||
setProject,
|
||||
t,
|
||||
});
|
||||
|
||||
const isDark = theme === "dark";
|
||||
const { width: cw, height: ch } = project.canvas;
|
||||
|
||||
const handleSave = React.useCallback(async () => {
|
||||
await saveCurrentProject();
|
||||
}, [saveCurrentProject]);
|
||||
|
||||
const handleExportPng = React.useCallback(async () => {
|
||||
await exportProjectAsPng(project, { isDark });
|
||||
}, [isDark, project, exportProjectAsPng]);
|
||||
|
||||
const handleExportProjectFile = React.useCallback(() => {
|
||||
const json = exportProjectToJson();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = `${project.title || "project"}.pien.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}, [exportProjectToJson, project.title]);
|
||||
|
||||
useEditorShortcuts({
|
||||
onSave: handleSave,
|
||||
onCopy: copySelectedLayer,
|
||||
onCut: cutSelectedLayer,
|
||||
onPaste: pasteLayer,
|
||||
onDelete: removeSelectedLayer,
|
||||
});
|
||||
|
||||
useEditorAutosave({
|
||||
isDirty,
|
||||
projectUpdatedAt: project.updatedAt,
|
||||
saveCurrentProject,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") {
|
||||
setFaceMlErrorModalOpen(true);
|
||||
}
|
||||
previousFaceStatusRef.current = faceStatus;
|
||||
}, [faceStatus]);
|
||||
|
||||
async function handleImageImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await importImageFromFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
||||
setCanvasSize(width, height);
|
||||
}
|
||||
|
||||
const toolControllers = React.useMemo(
|
||||
() =>
|
||||
createEditorToolControllers({
|
||||
onAddTextLayer: () => addLayerByType("text"),
|
||||
onImportImage: () => imageInputRef.current?.click(),
|
||||
labels: {
|
||||
pointer: t("editor.toolPointer"),
|
||||
pan: t("editor.toolPan"),
|
||||
face: t("editor.toolFace"),
|
||||
text: t("editor.toolText"),
|
||||
image: t("editor.toolImage"),
|
||||
},
|
||||
}),
|
||||
[addLayerByType, 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),
|
||||
onInteractionStart: startTransaction,
|
||||
onInteractionEnd: commitTransaction,
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`h-screen p-0 overflow-hidden ${isDark ? "bg-[#202124] text-[#e8eaed]" : "bg-[#f2f4f8] text-[#1f2430]"}`}
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<EditorHeader
|
||||
isDark={isDark}
|
||||
projectTitle={project.title}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
isDirty={isDirty}
|
||||
labels={headerLabels}
|
||||
onSave={handleSave}
|
||||
onExportPng={handleExportPng}
|
||||
onExportProjectFile={handleExportProjectFile}
|
||||
onImportImage={() => imageInputRef.current?.click()}
|
||||
onOpenCanvasSize={() => setCanvasModalOpen(true)}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
onSetHandTool={() => setTool("hand")}
|
||||
onSetPointerTool={() => setTool("pointer")}
|
||||
/>
|
||||
|
||||
<section className="hidden h-[calc(100vh-56px)] grid-cols-[68px_1fr_300px] lg:grid">
|
||||
<ToolRail
|
||||
controllers={toolControllers}
|
||||
selectedTool={tool}
|
||||
isDark={isDark}
|
||||
icons={TOOL_ICONS}
|
||||
onSetTool={setTool}
|
||||
/>
|
||||
|
||||
<EditorCanvasStage
|
||||
isDark={isDark}
|
||||
layers={project.layers}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
contextMenu={contextMenu}
|
||||
labels={contextMenuLabels}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayer={canvasBindings.onMoveLayer}
|
||||
onMoveLayerEnd={canvasBindings.onMoveLayerEnd}
|
||||
onResizeLayer={canvasBindings.onResizeLayer}
|
||||
onResizeLayerEnd={canvasBindings.onResizeLayerEnd}
|
||||
onRotateLayer={canvasBindings.onRotateLayer}
|
||||
onRotateLayerEnd={canvasBindings.onRotateLayerEnd}
|
||||
onInteractionStart={canvasBindings.onInteractionStart}
|
||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||
onContextMenu={openContextMenu}
|
||||
onCloseContextMenu={closeContextMenu}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
/>
|
||||
|
||||
<EditorSidebar
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayer={selectedLayer}
|
||||
history={history}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayerOrder={moveSelectedLayerOrder}
|
||||
onRemoveSelectedLayer={removeSelectedLayer}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onJumpToPast={jumpToPast}
|
||||
onJumpToFuture={jumpToFuture}
|
||||
onSetBlurMethod={setBlurMethod}
|
||||
onSetBlurAmount={setBlurAmount}
|
||||
onSetCensorColor={setCensorColor}
|
||||
onToggleFaceIndex={toggleFaceIndex}
|
||||
onBlur={(indices) => void blurFaces(indices)}
|
||||
onClearBlur={clearBlur}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<EditorMobileSection
|
||||
isDark={isDark}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceStatus={faceStatus}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
labels={mobileLabels}
|
||||
onOpenCanvasSize={() => setCanvasModalOpen(true)}
|
||||
onImportImage={() => imageInputRef.current?.click()}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayer={canvasBindings.onMoveLayer}
|
||||
onMoveLayerEnd={canvasBindings.onMoveLayerEnd}
|
||||
onResizeLayer={canvasBindings.onResizeLayer}
|
||||
onResizeLayerEnd={canvasBindings.onResizeLayerEnd}
|
||||
onRotateLayer={canvasBindings.onRotateLayer}
|
||||
onRotateLayerEnd={canvasBindings.onRotateLayerEnd}
|
||||
onInteractionStart={canvasBindings.onInteractionStart}
|
||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||
/>
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
|
||||
|
||||
<CanvasSizeModal
|
||||
isOpen={canvasModalOpen}
|
||||
onClose={() => setCanvasModalOpen(false)}
|
||||
currentWidth={cw}
|
||||
currentHeight={ch}
|
||||
currentAspect={project.aspectRatio}
|
||||
onApply={handleCanvasApply}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{faceMlErrorModalOpen ? (
|
||||
<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"
|
||||
}`}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{t("editor.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
:root {
|
||||
--color-cream: #fff7ef;
|
||||
--color-berry: #cc5d8f;
|
||||
--color-wine: #5a2339;
|
||||
--color-mint: #c9f0df;
|
||||
--color-ink: #1f1b24;
|
||||
--color-accent: #f5a3c7;
|
||||
--color-accent-strong: #e782b1;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f5f6f8;
|
||||
--bg-a: #f5f6f8;
|
||||
--bg-b: #f5f6f8;
|
||||
--bg-c: #f5f6f8;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color-cream: #1f2126;
|
||||
--color-berry: #d979a9;
|
||||
--color-wine: #f1d9e7;
|
||||
--color-mint: #2d3a35;
|
||||
--color-ink: #eef1f5;
|
||||
--color-accent: #f3a9cc;
|
||||
--color-accent-strong: #e88bb8;
|
||||
--color-surface: #2a2c31;
|
||||
--color-surface-2: #23252a;
|
||||
--bg-a: #1b1d21;
|
||||
--bg-b: #1b1d21;
|
||||
--bg-c: #1b1d21;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
background: var(--bg-a), var(--bg-b), var(--bg-c);
|
||||
min-height: 100vh;
|
||||
font-family: "Zen Kaku Gothic New", "Noto Sans Thai", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "pien.studio",
|
||||
description: "Local-first expressive image editor",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { useEditorStore } from "../store/editor-store";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
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";
|
||||
|
||||
export default function HomePage() {
|
||||
useAssetCleanupJob();
|
||||
const router = useRouter();
|
||||
const setProject = useEditorStore((s) => s.setProject);
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [showWipModal, setShowWipModal] = React.useState(true);
|
||||
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
|
||||
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const refreshProjects = React.useCallback(async () => {
|
||||
setProjects(await loadProjects());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
void refreshProjects();
|
||||
}, [hydrate, refreshProjects]);
|
||||
|
||||
function openProject(project: Project) {
|
||||
setProject(project);
|
||||
router.push(`/editor/${project.id}`);
|
||||
}
|
||||
|
||||
async function confirmDeleteProject() {
|
||||
if (!projectPendingDelete) return;
|
||||
await deleteProject(projectPendingDelete.id);
|
||||
await refreshProjects();
|
||||
setProjectPendingDelete(null);
|
||||
}
|
||||
|
||||
async function handleNewProject() {
|
||||
const project = createProject(t("home.untitledProject"));
|
||||
await upsertProject(project);
|
||||
openProject(project);
|
||||
}
|
||||
|
||||
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 refreshProjects();
|
||||
openProject(parsed.project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const title = file.name.replace(/\.[^/.]+$/, "") || t("home.imageProject");
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
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 base = createProject(title, "free");
|
||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
||||
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("image", {
|
||||
name: file.name,
|
||||
sourceUri,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
}));
|
||||
|
||||
await upsertProject(project);
|
||||
await refreshProjects();
|
||||
openProject(project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showWipModal ? (
|
||||
<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))}
|
||||
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")} {" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/YuzuZensai"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="font-semibold text-[var(--color-accent-strong)] underline underline-offset-2"
|
||||
>
|
||||
github.com/sponsors/YuzuZensai
|
||||
</a>
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWipModal(false)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
|
||||
>
|
||||
{t("home.wipAcknowledge")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projectPendingDelete ? (
|
||||
<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))}
|
||||
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>
|
||||
<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))}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void confirmDeleteProject();
|
||||
}}
|
||||
className="rounded border border-red-500/40 bg-red-500/15 px-3 py-1.5 text-sm font-semibold text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main
|
||||
className={`min-h-screen w-full px-4 py-5 sm:px-6 ${
|
||||
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())}
|
||||
>
|
||||
{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]")}>{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())}
|
||||
>
|
||||
{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))}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import NextImage from "next/image";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { CANVAS_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE } from "../lib/editor-constants";
|
||||
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
||||
import { renderFaceBlurRegions } from "../lib/face-blur-renderer";
|
||||
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
|
||||
interface CanvasRendererProps {
|
||||
layers: Layer[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer?: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd?: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer?: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd?: (id: string, rotation: number) => void;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
isDark: boolean;
|
||||
tool?: "pointer" | "hand" | "face";
|
||||
faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[];
|
||||
faceOverlayLayerId?: string | null;
|
||||
faceBlurPreview?: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
function BlurredImageLayer({
|
||||
layer,
|
||||
width,
|
||||
height,
|
||||
faceBlurOverride,
|
||||
}: {
|
||||
layer: Layer;
|
||||
width: number;
|
||||
height: number;
|
||||
faceBlurOverride?: {
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number; censorColor?: string }[];
|
||||
censorColor?: string;
|
||||
} | null;
|
||||
}) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const draw = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const cw = canvas.width;
|
||||
const ch = canvas.height;
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
ctx.drawImage(image, 0, 0, cw, ch);
|
||||
const blur = faceBlurOverride ?? layer.faceBlur;
|
||||
if (!blur || blur.regions.length === 0) return;
|
||||
renderFaceBlurRegions(ctx, image, blur, cw, ch);
|
||||
}, [faceBlurOverride, layer.faceBlur]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layer.sourceUri) return;
|
||||
let canceled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
imageRef.current = image;
|
||||
draw();
|
||||
};
|
||||
image.src = layer.sourceUri;
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [draw, layer.sourceUri]);
|
||||
|
||||
React.useEffect(() => {
|
||||
draw();
|
||||
}, [draw, width, height]);
|
||||
|
||||
return <canvas ref={canvasRef} width={Math.max(1, Math.round(width))} height={Math.max(1, Math.round(height))} className="pointer-events-none h-full w-full rounded object-cover" />;
|
||||
}
|
||||
|
||||
export function CanvasRenderer({
|
||||
layers,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
selectedLayerId,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
isDark,
|
||||
tool = "pointer",
|
||||
faceDetections = [],
|
||||
faceOverlayLayerId = null,
|
||||
faceBlurPreview = null,
|
||||
}: CanvasRendererProps) {
|
||||
const { t } = useTranslations();
|
||||
const {
|
||||
containerRef,
|
||||
viewport,
|
||||
isSpacePan,
|
||||
onContainerPointerDown,
|
||||
onContainerPointerMove,
|
||||
onContainerPointerUp,
|
||||
onLayerPointerDown,
|
||||
onResizeHandleDown,
|
||||
onRotateHandleDown,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onContextMenuOpen,
|
||||
} = useCanvasInteractions({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
tool,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
});
|
||||
|
||||
const faceLabelOverlays = React.useMemo(() => {
|
||||
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return [];
|
||||
return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport);
|
||||
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
|
||||
onPointerDown={(e) => {
|
||||
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
||||
onContainerPointerDown(e);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onPointerMove={onContainerPointerMove}
|
||||
onPointerUp={onContainerPointerUp}
|
||||
onPointerCancel={onContainerPointerUp}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={onContextMenuOpen}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
willChange: "transform",
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||
transformOrigin: "0 0",
|
||||
boxShadow: "0 0 0 1px var(--color-accent-strong)",
|
||||
background: isDark ? "#17181b" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
{layers.map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
||||
const handleSizePx = `${handleSize}px`;
|
||||
const largeHandleSize = CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
|
||||
const largeHandleSizePx = `${largeHandleSize}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: layer.x,
|
||||
top: layer.y,
|
||||
width: layerWidth,
|
||||
height: layerHeight,
|
||||
transform: `rotate(${layer.rotation}deg)`,
|
||||
opacity: layer.opacity,
|
||||
cursor: "move",
|
||||
border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent",
|
||||
outline: isSelected ? "2px solid var(--color-accent-strong)" : "none",
|
||||
outlineOffset: "2px",
|
||||
zIndex: idx,
|
||||
}}
|
||||
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
>
|
||||
{isImage && layer.sourceUri ? (
|
||||
(faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) ||
|
||||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
||||
<BlurredImageLayer
|
||||
layer={layer}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
src={layer.sourceUri}
|
||||
alt={layer.name ?? t("editor.layer")}
|
||||
width={layerWidth ?? 1}
|
||||
height={layerHeight ?? 1}
|
||||
unoptimized
|
||||
className="pointer-events-none h-full w-full rounded object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded border px-2 py-1 text-xs font-semibold ${
|
||||
isSelected
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/20 bg-[#2d3036] text-[#d7dae0]"
|
||||
: "border-black/15 bg-white text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
{tool === "face" && faceOverlayLayerId === layer.id
|
||||
? faceDetections.map((face, index) => (
|
||||
<div
|
||||
key={`${layer.id}-face-${index}`}
|
||||
className="absolute pointer-events-none border-2 border-[#00d2ff]"
|
||||
style={{
|
||||
left: face.x,
|
||||
top: face.y,
|
||||
width: face.width,
|
||||
height: face.height,
|
||||
zIndex: layers.length + 20 + index,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.35)",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{isSelected && tool === "pointer" && isImage && onResizeLayer ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, left: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, right: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, left: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, right: -handleSize / 2 }}
|
||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
|
||||
title={t("editor.resize")}
|
||||
/>
|
||||
<div className="absolute pointer-events-none" style={{ top: -largeHandleSize, left: "50%", transform: "translateX(-50%)", height: largeHandleSize }}>
|
||||
<div className="w-px bg-[var(--color-accent-strong)]" style={{ width: "1px", height: "100%", marginLeft: "0px" }} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
|
||||
style={{ width: largeHandleSizePx, height: largeHandleSizePx }}
|
||||
onPointerDown={(e) => onRotateHandleDown(e, layer)}
|
||||
title={t("editor.rotate")}
|
||||
>
|
||||
<RotateCw className="text-white" style={{ width: handleSize * 0.6, height: handleSize * 0.6 }} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{faceLabelOverlays.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="pointer-events-none absolute rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap text-white"
|
||||
style={{
|
||||
left: label.left,
|
||||
top: label.top,
|
||||
background: "rgba(0, 210, 255, 0.9)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.22)",
|
||||
textShadow: "0 1px 1px rgba(0,0,0,0.35)",
|
||||
zIndex: layers.length + 100,
|
||||
}}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { AspectRatio } from "@pien-studio/types";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
interface CanvasSizeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentWidth: number;
|
||||
currentHeight: number;
|
||||
currentAspect: AspectRatio;
|
||||
onApply: (width: number, height: number, aspect: AspectRatio) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ labelKey: "editor.presetSquare", sublabel: "1080 × 1080", aspect: "1:1" as AspectRatio, width: 1080, height: 1080 },
|
||||
{ labelKey: "editor.presetPortrait45", sublabel: "1080 × 1350", aspect: "4:5" as AspectRatio, width: 1080, height: 1350 },
|
||||
{ labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 },
|
||||
{ labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 },
|
||||
{ labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 },
|
||||
{ labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 1080 },
|
||||
];
|
||||
|
||||
export function CanvasSizeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentWidth,
|
||||
currentHeight,
|
||||
currentAspect,
|
||||
onApply,
|
||||
isDark,
|
||||
}: CanvasSizeModalProps) {
|
||||
const { t } = useTranslations();
|
||||
const [mode, setMode] = React.useState<"preset" | "custom">(
|
||||
PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom",
|
||||
);
|
||||
const [customWidth, setCustomWidth] = React.useState(currentWidth.toString());
|
||||
const [customHeight, setCustomHeight] = React.useState(currentHeight.toString());
|
||||
const [selectedPreset, setSelectedPreset] = React.useState<AspectRatio>(currentAspect);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function handleApply() {
|
||||
if (mode === "preset") {
|
||||
const preset = PRESETS.find((p) => p.aspect === selectedPreset)!;
|
||||
onApply(preset.width, preset.height, preset.aspect);
|
||||
} else {
|
||||
const w = parseInt(customWidth, 10);
|
||||
const h = parseInt(customHeight, 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
|
||||
onApply(w, h, "free");
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||
const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<div className={panel} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>
|
||||
{t("editor.canvasSizeTitle")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["preset", "custom"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded border py-1.5 text-xs font-semibold capitalize ${
|
||||
mode === m
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/20 text-[#d7dae0]"
|
||||
: "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{m === "preset" ? t("editor.modePreset") : t("editor.modeCustom")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "preset" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.aspect}
|
||||
onClick={() => setSelectedPreset(p.aspect)}
|
||||
className={`rounded border p-3 text-left ${
|
||||
selectedPreset === p.aspect
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#343841] text-[#d7dae0]"
|
||||
: "border-black/10 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
|
||||
<div className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}>
|
||||
{p.sublabel}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.widthShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => setCustomWidth(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.heightShort")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => setCustomHeight(e.target.value)}
|
||||
min={1}
|
||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
||||
</div>
|
||||
<div className="rounded border border-dashed border-black/20 p-2 text-center">
|
||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
||||
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`rounded border px-3 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{t("editor.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type CanvasContextMenuProps = {
|
||||
isDark: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
labels: {
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
};
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`}
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.copy}
|
||||
</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.cut}
|
||||
</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
||||
{labels.paste}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorCanvasStage } from "./editor-canvas-stage";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => (
|
||||
<button type="button" data-testid="canvas-renderer" onClick={() => onContextMenu?.(10, 12)}>
|
||||
canvas
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EditorCanvasStage", () => {
|
||||
it("wires context menu actions", () => {
|
||||
const onCopy = vi.fn();
|
||||
const onCloseContextMenu = vi.fn();
|
||||
|
||||
render(
|
||||
<EditorCanvasStage
|
||||
isDark={false}
|
||||
layers={[]}
|
||||
canvasWidth={100}
|
||||
canvasHeight={100}
|
||||
selectedLayerId={null}
|
||||
tool="pointer"
|
||||
faceDetections={[]}
|
||||
faceOverlayLayerId={null}
|
||||
faceBlurPreview={null}
|
||||
contextMenu={{ x: 10, y: 20 }}
|
||||
labels={{ copy: "Copy", cut: "Cut", paste: "Paste" }}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
onCloseContextMenu={onCloseContextMenu}
|
||||
onCopy={onCopy}
|
||||
onCut={() => undefined}
|
||||
onPaste={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import { CanvasContextMenu } from "./canvas-context-menu";
|
||||
|
||||
type EditorCanvasStageProps = {
|
||||
isDark: boolean;
|
||||
layers: Layer[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
contextMenu: { x: number; y: number } | null;
|
||||
labels: { copy: string; cut: string; paste: string };
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd: (id: string, rotation: number) => void;
|
||||
onInteractionStart: () => void;
|
||||
onInteractionEnd: () => void;
|
||||
onContextMenu: (x: number, y: number) => void;
|
||||
onCloseContextMenu: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
};
|
||||
|
||||
export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
||||
const {
|
||||
isDark,
|
||||
layers,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceBlurPreview,
|
||||
contextMenu,
|
||||
labels,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
onCloseContextMenu,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? "linear-gradient(#2b2d31 1px, transparent 1px), linear-gradient(90deg, #2b2d31 1px, transparent 1px)"
|
||||
: "linear-gradient(#e8eaed 1px, transparent 1px), linear-gradient(90deg, #e8eaed 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden shadow-2xl ring-1 ring-black/10">
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
onContextMenu={onContextMenu}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
{contextMenu ? (
|
||||
<CanvasContextMenu
|
||||
isDark={isDark}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
labels={labels}
|
||||
onCopy={() => {
|
||||
onCopy();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onCut={() => {
|
||||
onCut();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
onPaste={() => {
|
||||
onPaste();
|
||||
onCloseContextMenu();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { UiPreferences } from "../ui-preferences";
|
||||
import { dividerClass, hoverSubtleClass } from "../../lib/theme";
|
||||
|
||||
type EditorHeaderProps = {
|
||||
isDark: boolean;
|
||||
projectTitle: string;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isDirty: boolean;
|
||||
labels: {
|
||||
file: string;
|
||||
edit: string;
|
||||
view: string;
|
||||
settings: string;
|
||||
save: string;
|
||||
exportPng: string;
|
||||
exportProjectFile: string;
|
||||
importImage: string;
|
||||
canvasSize: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
copy: string;
|
||||
cut: string;
|
||||
paste: string;
|
||||
preferences: string;
|
||||
panTool: string;
|
||||
pointerTool: string;
|
||||
unsavedChanges: string;
|
||||
saved: string;
|
||||
};
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onSetHandTool: () => void;
|
||||
onSetPointerTool: () => void;
|
||||
};
|
||||
|
||||
export function EditorHeader({
|
||||
isDark,
|
||||
projectTitle,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isDirty,
|
||||
labels,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
onSetHandTool,
|
||||
onSetPointerTool,
|
||||
}: EditorHeaderProps) {
|
||||
const menuClass = `absolute left-0 top-full z-30 min-w-[180px] rounded border p-1 text-[11px] opacity-0 shadow-xl transition-opacity pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 hover:pointer-events-auto hover:opacity-100 ${
|
||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" className="group">
|
||||
<p className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
pien.studio
|
||||
</p>
|
||||
<h1 className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{projectTitle}</h1>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2 text-xs font-medium">
|
||||
<MenuShell isDark={isDark} label={labels.file} menuClass={menuClass}>
|
||||
<FileMenu
|
||||
isDark={isDark}
|
||||
labels={labels}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
onSave={onSave}
|
||||
onExportPng={onExportPng}
|
||||
onExportProjectFile={onExportProjectFile}
|
||||
onImportImage={onImportImage}
|
||||
onOpenCanvasSize={onOpenCanvasSize}
|
||||
/>
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
|
||||
<EditMenu isDark={isDark} labels={labels} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onCopy={onCopy} onCut={onCut} onPaste={onPaste} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
|
||||
<ViewMenu isDark={isDark} labels={labels} onSetHandTool={onSetHandTool} onSetPointerTool={onSetPointerTool} />
|
||||
</MenuShell>
|
||||
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
|
||||
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
||||
</MenuShell>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={labels.undo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={labels.redo}
|
||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
||||
{isDirty ? labels.unsavedChanges : labels.saved}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button type="button" className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}>
|
||||
{label}
|
||||
</button>
|
||||
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
onSave,
|
||||
onExportPng,
|
||||
onExportProjectFile,
|
||||
onImportImage,
|
||||
onOpenCanvasSize,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
onSave: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportProjectFile: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenCanvasSize: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
|
||||
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
||||
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditMenu({
|
||||
isDark,
|
||||
labels,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
labels: EditorHeaderProps["labels"];
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} className={`w-full rounded px-2 py-1 text-left ${!canUndo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.undo}</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} className={`w-full rounded px-2 py-1 text-left ${!canRedo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.redo}</button>
|
||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.copy}</button>
|
||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.cut}</button>
|
||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.paste}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button type="button" onClick={onSetHandTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.panTool}</button>
|
||||
<button type="button" onClick={onSetPointerTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.pointerTool}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<p className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}>{preferences}</p>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorMobileSection } from "./editor-mobile-section";
|
||||
|
||||
vi.mock("../canvas-renderer", () => ({
|
||||
CanvasRenderer: () => <div data-testid="canvas-renderer" />,
|
||||
}));
|
||||
|
||||
describe("EditorMobileSection", () => {
|
||||
it("shows face detection helper message in face mode", () => {
|
||||
render(
|
||||
<EditorMobileSection
|
||||
isDark={false}
|
||||
canvasWidth={100}
|
||||
canvasHeight={120}
|
||||
layers={[]}
|
||||
selectedLayerId={null}
|
||||
tool="face"
|
||||
faceDetections={[{ x: 1, y: 1, width: 10, height: 10, label: "f" }]}
|
||||
faceOverlayLayerId={null}
|
||||
faceStatus="idle"
|
||||
faceBlurPreview={null}
|
||||
labels={{
|
||||
resize: "Resize",
|
||||
faceMlFailedShort: "Face failed",
|
||||
detectingFacesShort: "Detecting",
|
||||
faceDetectionTip: (count) => `Faces ${count}`,
|
||||
import: "Import",
|
||||
mood: "Mood",
|
||||
quick: "Quick",
|
||||
face: "Face",
|
||||
decor: "Decor",
|
||||
}}
|
||||
onOpenCanvasSize={() => undefined}
|
||||
onImportImage={() => undefined}
|
||||
onSelectLayer={() => undefined}
|
||||
onMoveLayer={() => undefined}
|
||||
onMoveLayerEnd={() => undefined}
|
||||
onResizeLayer={() => undefined}
|
||||
onResizeLayerEnd={() => undefined}
|
||||
onRotateLayer={() => undefined}
|
||||
onRotateLayerEnd={() => undefined}
|
||||
onInteractionStart={() => undefined}
|
||||
onInteractionEnd={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Faces 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Import")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { CanvasRenderer } from "../canvas-renderer";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorMobileSectionProps = {
|
||||
isDark: boolean;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
faceOverlayLayerId: string | null;
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
faceBlurPreview: {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number }[];
|
||||
} | null;
|
||||
labels: {
|
||||
resize: string;
|
||||
faceMlFailedShort: string;
|
||||
detectingFacesShort: string;
|
||||
faceDetectionTip: (count: number) => string;
|
||||
import: string;
|
||||
mood: string;
|
||||
quick: string;
|
||||
face: string;
|
||||
decor: string;
|
||||
};
|
||||
onOpenCanvasSize: () => void;
|
||||
onImportImage: () => void;
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd: (id: string, rotation: number) => void;
|
||||
onInteractionStart: () => void;
|
||||
onInteractionEnd: () => void;
|
||||
};
|
||||
|
||||
export function EditorMobileSection(props: EditorMobileSectionProps) {
|
||||
const {
|
||||
isDark,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
tool,
|
||||
faceDetections,
|
||||
faceOverlayLayerId,
|
||||
faceStatus,
|
||||
faceBlurPreview,
|
||||
labels,
|
||||
onOpenCanvasSize,
|
||||
onImportImage,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<section className="lg:hidden">
|
||||
<div className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}>
|
||||
{canvasWidth} x {canvasHeight}px
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenCanvasSize}
|
||||
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
|
||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.resize}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border ${isDark ? "border-white/10 bg-[#17181b]" : "border-black/10 bg-[#f7f8fa]"}`}
|
||||
style={{ height: "55vw", maxHeight: "70vh" }}
|
||||
>
|
||||
<CanvasRenderer
|
||||
layers={layers}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
selectedLayerId={selectedLayerId}
|
||||
onSelectLayer={onSelectLayer}
|
||||
onMoveLayer={onMoveLayer}
|
||||
onMoveLayerEnd={onMoveLayerEnd}
|
||||
onResizeLayer={onResizeLayer}
|
||||
onResizeLayerEnd={onResizeLayerEnd}
|
||||
onRotateLayer={onRotateLayer}
|
||||
onRotateLayerEnd={onRotateLayerEnd}
|
||||
onInteractionStart={onInteractionStart}
|
||||
onInteractionEnd={onInteractionEnd}
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceOverlayLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
/>
|
||||
</div>
|
||||
{tool === "face" ? (
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? labels.faceMlFailedShort
|
||||
: faceStatus === "detecting"
|
||||
? labels.detectingFacesShort
|
||||
: labels.faceDetectionTip(faceDetections.length)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportImage}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{labels.import}
|
||||
</button>
|
||||
{[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => (
|
||||
<button
|
||||
key={toolLabel}
|
||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{toolLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
||||
import { FacePanel } from "./face-panel";
|
||||
import { HistoryPanel } from "./history-panel";
|
||||
import { LayersPanel } from "./layers-panel";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
|
||||
type EditorSidebarProps = {
|
||||
isDark: boolean;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayer: Layer | null;
|
||||
history: { past: Project[]; future: Project[] };
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: FaceBlurMethod;
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
onSelectLayer: (layerId: string | null) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
onSetBlurMethod: (method: FaceBlurMethod) => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function EditorSidebar({
|
||||
isDark,
|
||||
tool,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
history,
|
||||
canUndo,
|
||||
canRedo,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
onSelectLayer,
|
||||
onMoveLayerOrder,
|
||||
onRemoveSelectedLayer,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onJumpToPast,
|
||||
onJumpToFuture,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: EditorSidebarProps) {
|
||||
return (
|
||||
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="space-y-3">
|
||||
<LayersPanel
|
||||
layers={layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
isDark={isDark}
|
||||
onSelectLayer={(layerId) => onSelectLayer(layerId)}
|
||||
onMoveLayerOrder={onMoveLayerOrder}
|
||||
onRemoveSelectedLayer={onRemoveSelectedLayer}
|
||||
/>
|
||||
|
||||
{tool === "face" ? (
|
||||
<FacePanel
|
||||
isDark={isDark}
|
||||
selectedLayer={selectedLayer}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
|
||||
onSetBlurMethod={onSetBlurMethod}
|
||||
onSetBlurAmount={onSetBlurAmount}
|
||||
onSetCensorColor={onSetCensorColor}
|
||||
onToggleFaceIndex={onToggleFaceIndex}
|
||||
onBlur={onBlur}
|
||||
onClearBlur={onClearBlur}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HistoryPanel
|
||||
history={history}
|
||||
isDark={isDark}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
onJumpToPast={onJumpToPast}
|
||||
onJumpToFuture={onJumpToFuture}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
isDark: boolean;
|
||||
selectedLayer: Layer | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
facePreviews: FacePreview[];
|
||||
faceStatus: "idle" | "detecting" | "unsupported";
|
||||
blurMethod: "gaussian" | "pixelate" | "censor";
|
||||
blurAmount: number;
|
||||
censorColor: string;
|
||||
selectedFaceIndices: number[];
|
||||
hasActiveBlur: boolean;
|
||||
onSetBlurMethod: (method: "gaussian" | "pixelate" | "censor") => void;
|
||||
onSetBlurAmount: (amount: number) => void;
|
||||
onSetCensorColor: (color: string) => void;
|
||||
onToggleFaceIndex: (index: number) => void;
|
||||
onBlur: (indices: number[]) => void;
|
||||
onClearBlur: () => void;
|
||||
};
|
||||
|
||||
export function FacePanel({
|
||||
isDark,
|
||||
selectedLayer,
|
||||
faceDetections,
|
||||
facePreviews,
|
||||
faceStatus,
|
||||
blurMethod,
|
||||
blurAmount,
|
||||
censorColor,
|
||||
selectedFaceIndices,
|
||||
hasActiveBlur,
|
||||
onSetBlurMethod,
|
||||
onSetBlurAmount,
|
||||
onSetCensorColor,
|
||||
onToggleFaceIndex,
|
||||
onBlur,
|
||||
onClearBlur,
|
||||
}: Props) {
|
||||
const { t } = useTranslations();
|
||||
const hasFaces = faceDetections.length > 0;
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.faceTool")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>{faceDetections.length}</span>
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
||||
{faceStatus === "unsupported"
|
||||
? t("editor.faceMlFailed")
|
||||
: faceStatus === "detecting"
|
||||
? t("editor.detectingFaces")
|
||||
: selectedLayer?.type !== "image"
|
||||
? t("editor.selectImageLayer")
|
||||
: faceDetections.length === 0
|
||||
? t("editor.noFacesFound")
|
||||
: t("editor.facesDetected")}
|
||||
</p>
|
||||
{hasFaces ? (
|
||||
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{faceDetections.map((face, index) => (
|
||||
<button
|
||||
key={`face-result-${index}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] ${isDark ? "bg-white/5 text-[#d7dae0]" : "bg-white text-[#1f2430]"} ${
|
||||
selectedFaceIndices.includes(index)
|
||||
? isDark
|
||||
? "ring-1 ring-[#7cdcff]"
|
||||
: "ring-1 ring-[#00b7f0]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onToggleFaceIndex(index)}
|
||||
>
|
||||
{facePreviews[index]?.src ? (
|
||||
<Image src={facePreviews[index].src} alt={`Face preview ${index + 1}`} width={40} height={40} unoptimized className="h-10 w-10 rounded object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold">{t("editor.person")} {index + 1}</p>
|
||||
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`mt-3 space-y-2 rounded-md border p-2 ${panelInsetClass(isDark)}`}>
|
||||
<label className="block text-[11px] font-semibold">{t("editor.blurMethod")}</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{[
|
||||
{ id: "gaussian", label: t("editor.soft") },
|
||||
{ id: "pixelate", label: t("editor.pixelate") },
|
||||
{ id: "censor", label: t("editor.censor") },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
blurMethod === option.id
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "bg-white/10 text-[#d7dae0]"
|
||||
: "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{blurMethod !== "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.strength")}</span>
|
||||
<span>{blurAmount}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={40}
|
||||
value={blurAmount}
|
||||
onChange={(event) => onSetBlurAmount(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{blurMethod === "censor" ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span>{t("editor.color")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className="h-6 w-6 cursor-pointer rounded border-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={censorColor}
|
||||
onChange={(event) => onSetCensorColor(event.target.value)}
|
||||
className={`flex-1 rounded border px-1 py-0.5 text-[11px] ${isDark ? "border-white/20 bg-white/10 text-white" : "border-black/20 bg-white text-black"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBlur(selectedFaceIndices)}
|
||||
disabled={!hasFaces || selectedFaceIndices.length === 0}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
!hasFaces || selectedFaceIndices.length === 0
|
||||
? "opacity-50"
|
||||
: "bg-[var(--color-accent-strong)] text-white"
|
||||
}`}
|
||||
>
|
||||
{hasFaces && selectedFaceIndices.length === 0
|
||||
? t("editor.selectFacesToBlur")
|
||||
: t("editor.blurFaces", { count: selectedFaceIndices.length })}
|
||||
</button>
|
||||
{hasActiveBlur ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearBlur}
|
||||
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
|
||||
isDark ? "bg-white/10 text-[#d7dae0]" : "bg-white text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
{t("editor.clearBlur")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
history: { past: Project[]; future: Project[] };
|
||||
isDark: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onJumpToPast: (idx: number) => void;
|
||||
onJumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.history")}</h2>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={onUndo} disabled={!canUndo} title={t("editor.undo")} className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={onRedo} disabled={!canRedo} title={t("editor.redo")} className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}>
|
||||
{[...history.past].reverse().map((_, i) => {
|
||||
const idx = history.past.length - i;
|
||||
return (
|
||||
<button
|
||||
key={`past-${idx}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToPast(idx)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{idx > history.past.length - 2 ? t("editor.beforeLastAction") : `${t("editor.step")} ${idx}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button" className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`} disabled>
|
||||
{t("editor.now")}
|
||||
</button>
|
||||
{[...history.future].map((_, i) => (
|
||||
<button
|
||||
key={`future-${i}`}
|
||||
type="button"
|
||||
onClick={() => onJumpToFuture(i)}
|
||||
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||
>
|
||||
{t("editor.undoneStep")} {i + 1}
|
||||
</button>
|
||||
))}
|
||||
{history.past.length === 0 && history.future.length === 0 ? <p className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}>{t("editor.noHistoryYet")}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "../../hooks/use-translations";
|
||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
||||
|
||||
type Props = {
|
||||
layers: Layer[];
|
||||
selectedLayerId: string | null;
|
||||
isDark: boolean;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||
onRemoveSelectedLayer: () => void;
|
||||
};
|
||||
|
||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className={panelClass(isDark)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
||||
{layers.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
||||
{layers.slice().reverse().map((layer, idx) => {
|
||||
const isSelected = layer.id === selectedLayerId;
|
||||
const isImageLayer = layer.type === "image" || layer.type === "sticker";
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer.id)}
|
||||
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "text-[#d7dae0] hover:bg-white/10"
|
||||
: "text-[#1f2430] hover:bg-black/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span>
|
||||
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}>
|
||||
{isImageLayer && layer.sourceUri ? (
|
||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
||||
{layer.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{layer.name ?? layer.type}</p>
|
||||
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
|
||||
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
|
||||
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { MousePointer2 } from "lucide-react";
|
||||
import type { EditorToolController } from "../../lib/editor-tool-controller";
|
||||
import type { EditorToolId } from "../../store/editor-store";
|
||||
|
||||
type Props = {
|
||||
controllers: EditorToolController[];
|
||||
selectedTool: EditorToolId;
|
||||
isDark: boolean;
|
||||
icons: Record<string, React.ComponentType<{ className?: string }>>;
|
||||
onSetTool: (tool: EditorToolId) => void;
|
||||
};
|
||||
|
||||
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
|
||||
return (
|
||||
<aside className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{controllers.map((controller) => {
|
||||
const Icon = icons[controller.id] ?? MousePointer2;
|
||||
const isSelected = controller.kind === "mode" && controller.id === selectedTool;
|
||||
return (
|
||||
<button
|
||||
key={controller.id}
|
||||
type="button"
|
||||
title={controller.label}
|
||||
onClick={() => (controller.kind === "mode" ? onSetTool(controller.id) : controller.run())}
|
||||
className={`rounded border p-2 text-[11px] font-medium ${
|
||||
isSelected
|
||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||
: isDark
|
||||
? "border-white/10 bg-[#2d3036] text-[#d7dae0] hover:bg-[#353942]"
|
||||
: "border-black/10 bg-white text-[#1f2430] hover:bg-[#f4f6f9]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="mx-auto h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import { cx, subtleButtonClass } from "../lib/theme";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
||||
const { theme, locale, setTheme, setLocale } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const isDark = theme === "dark";
|
||||
const wrapperHeight = compact ? "h-7" : "h-9";
|
||||
const textSize = compact ? "text-[10px]" : "text-xs";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className={cx("inline-flex items-center rounded border px-2", subtleButtonClass(isDark), wrapperHeight)}>
|
||||
<span className={cx("mr-2 font-semibold opacity-70", textSize)}>{t("ui.locale")}</span>
|
||||
<select
|
||||
aria-label={t("ui.locale")}
|
||||
value={locale}
|
||||
onChange={(event) => setLocale(event.target.value as "en" | "th" | "ja")}
|
||||
className={cx(
|
||||
"bg-transparent font-semibold outline-none",
|
||||
textSize,
|
||||
isDark ? "text-[#f5f7fa]" : "text-[#1f2430]",
|
||||
)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="th">Thai</option>
|
||||
<option value="ja">Japanese</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
|
||||
>
|
||||
{theme === "dark" ? t("ui.darkMode") : t("ui.lightMode")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { startAssetCleanupJob } from "@pien-studio/storage";
|
||||
|
||||
export function useAssetCleanupJob() {
|
||||
React.useEffect(() => {
|
||||
const stop = startAssetCleanupJob();
|
||||
return () => stop();
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import React from "react";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
type Viewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
type UseCanvasInteractionsOptions = {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
tool: "pointer" | "hand" | "face";
|
||||
onSelectLayer: (id: string | null) => void;
|
||||
onMoveLayer: (id: string, x: number, y: number) => void;
|
||||
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
|
||||
onResizeLayer?: (id: string, width: number, height: number) => void;
|
||||
onResizeLayerEnd?: (id: string, width: number, height: number) => void;
|
||||
onRotateLayer?: (id: string, rotation: number) => void;
|
||||
onRotateLayerEnd?: (id: string, rotation: number) => void;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
onContextMenu?: (x: number, y: number) => void;
|
||||
};
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 10;
|
||||
const ZOOM_FACTOR = 0.001;
|
||||
const FIT_PADDING = 32;
|
||||
|
||||
export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
||||
const {
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
tool,
|
||||
onSelectLayer,
|
||||
onMoveLayer,
|
||||
onMoveLayerEnd,
|
||||
onResizeLayer,
|
||||
onResizeLayerEnd,
|
||||
onRotateLayer,
|
||||
onRotateLayerEnd,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
onContextMenu,
|
||||
} = options;
|
||||
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 });
|
||||
const isMiddleMousePan = React.useRef(false);
|
||||
const [isSpacePan, setIsSpacePan] = React.useState(false);
|
||||
const [, setIsShiftPressed] = React.useState(false);
|
||||
const didFitRef = React.useRef(false);
|
||||
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 dragRef = React.useRef<{
|
||||
id: string;
|
||||
startLayerX: number;
|
||||
startLayerY: number;
|
||||
startEventX: number;
|
||||
startEventY: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
} | null>(null);
|
||||
const resizeRef = React.useRef<{
|
||||
id: string;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
aspect: number;
|
||||
lastWidth: number;
|
||||
lastHeight: number;
|
||||
corner: string;
|
||||
startLayerX: number;
|
||||
startLayerY: number;
|
||||
} | null>(null);
|
||||
const rotateRef = React.useRef<{
|
||||
id: string;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
startAngle: number;
|
||||
startRotation: number;
|
||||
lastRotation: number;
|
||||
} | null>(null);
|
||||
const pinchRef = React.useRef<{
|
||||
active: boolean;
|
||||
initialPinchPx: number;
|
||||
initialScale: number;
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
pivotX: number;
|
||||
pivotY: number;
|
||||
} | null>(null);
|
||||
|
||||
function beginInteraction() {
|
||||
if (interactionActiveRef.current) return;
|
||||
interactionActiveRef.current = true;
|
||||
onInteractionStart?.();
|
||||
}
|
||||
|
||||
function endInteraction() {
|
||||
if (!interactionActiveRef.current) return;
|
||||
interactionActiveRef.current = false;
|
||||
onInteractionEnd?.();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function wheelZoom(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
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;
|
||||
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 scaleChange = nextScale / vp.scale;
|
||||
return {
|
||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||
y: pivotY - (pivotY - vp.y) * scaleChange,
|
||||
scale: nextScale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function zoomBy(factor: number, pivotX: number, pivotY: number) {
|
||||
setViewport((vp) => {
|
||||
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,
|
||||
y: pivotY - (pivotY - vp.y) * scaleChange,
|
||||
scale: newScale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = (event: WheelEvent) => {
|
||||
wheelZoom(event);
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
didFitRef.current = false;
|
||||
}, [canvasWidth, canvasHeight]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
if (didFitRef.current) return;
|
||||
const maxW = Math.max(1, rect.width - FIT_PADDING * 2);
|
||||
const maxH = Math.max(1, rect.height - FIT_PADDING * 2);
|
||||
const fitScale = Math.min(maxW / canvasWidth, maxH / canvasHeight, 1);
|
||||
const nextX = (rect.width - canvasWidth * fitScale) / 2;
|
||||
const nextY = (rect.height - canvasHeight * fitScale) / 2;
|
||||
setViewport({ x: nextX, y: nextY, scale: fitScale });
|
||||
didFitRef.current = true;
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [canvasWidth, canvasHeight]);
|
||||
|
||||
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 (!isSpacePan) setIsSpacePan(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Shift") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
setIsShiftPressed(true);
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
if (e.key === "-" || e.key === "_") {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
|
||||
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
|
||||
zoomBy(0.8, cx, cy);
|
||||
} else if (e.key === "+" || e.key === "=") {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
|
||||
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
|
||||
zoomBy(1.25, cx, cy);
|
||||
} else if (e.key === "0") {
|
||||
e.preventDefault();
|
||||
setViewport({ x: 0, y: 0, scale: 1 });
|
||||
}
|
||||
}
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
if (e.code === "Space") setIsSpacePan(false);
|
||||
if (e.key === "Shift") setIsShiftPressed(false);
|
||||
}
|
||||
function handleWheelCaptured(e: WheelEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
|
||||
};
|
||||
}, [isSpacePan]);
|
||||
|
||||
function onContainerPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isMiddleMousePan.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (tool !== "hand" && tool !== "face" && !isSpacePan) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
isPanning.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function onContainerPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (isMiddleMousePan.current || isPanning.current) {
|
||||
const dx = e.clientX - lastPos.current.x;
|
||||
const dy = e.clientY - lastPos.current.y;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy }));
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && rotateRef.current && onRotateLayer) {
|
||||
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;
|
||||
rotateRef.current.lastRotation = nextRotation;
|
||||
onRotateLayer(id, nextRotation);
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && resizeRef.current && onResizeLayer) {
|
||||
const dx = (e.clientX - resizeRef.current.startX) / viewport.scale;
|
||||
const dy = (e.clientY - resizeRef.current.startY) / viewport.scale;
|
||||
const corner = resizeRef.current.corner;
|
||||
let nextWidth = resizeRef.current.startWidth;
|
||||
let nextHeight = resizeRef.current.startHeight;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (corner === "br") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth + dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight + dy);
|
||||
} else if (corner === "bl") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth - dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight + dy);
|
||||
} else if (corner === "tr") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth + dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||
} else if (corner === "tl") {
|
||||
nextWidth = Math.max(8, resizeRef.current.startWidth - dx);
|
||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
nextWidth = Math.max(8, nextHeight * aspect);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
resizeMovePendingRef.current = {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: resizeRef.current.startLayerX + offsetX,
|
||||
y: resizeRef.current.startLayerY + offsetY,
|
||||
};
|
||||
if (resizeMoveRafRef.current !== null) return;
|
||||
resizeMoveRafRef.current = window.requestAnimationFrame(() => {
|
||||
resizeMoveRafRef.current = null;
|
||||
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) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tool === "pointer" && dragRef.current) {
|
||||
const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale;
|
||||
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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerPointerUp() {
|
||||
if (dragMoveRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(dragMoveRafRef.current);
|
||||
dragMoveRafRef.current = null;
|
||||
}
|
||||
if (resizeMoveRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(resizeMoveRafRef.current);
|
||||
resizeMoveRafRef.current = null;
|
||||
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) {
|
||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
isPanning.current = false;
|
||||
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 (resizeRef.current && onResizeLayerEnd) {
|
||||
const { id, lastWidth, lastHeight } = resizeRef.current;
|
||||
if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight);
|
||||
}
|
||||
if (rotateRef.current && onRotateLayerEnd) {
|
||||
const { id, lastRotation } = rotateRef.current;
|
||||
if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation);
|
||||
}
|
||||
dragRef.current = null;
|
||||
resizeRef.current = null;
|
||||
resizeMovePendingRef.current = null;
|
||||
rotateRef.current = null;
|
||||
endInteraction();
|
||||
}
|
||||
|
||||
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
|
||||
if (isSpacePan) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
id: layer.id,
|
||||
startLayerX: layer.x,
|
||||
startLayerY: layer.y,
|
||||
startEventX: e.clientX,
|
||||
startEventY: e.clientY,
|
||||
lastX: layer.x,
|
||||
lastY: layer.y,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
|
||||
if (tool !== "pointer" || !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);
|
||||
resizeRef.current = {
|
||||
id: layer.id,
|
||||
startWidth: width,
|
||||
startHeight: height,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
aspect: width > 0 && height > 0 ? width / height : 1,
|
||||
lastWidth: width,
|
||||
lastHeight: height,
|
||||
corner,
|
||||
startLayerX: layer.x,
|
||||
startLayerY: layer.y,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
|
||||
if (tool !== "pointer" || !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 startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
||||
rotateRef.current = {
|
||||
id: layer.id,
|
||||
centerX,
|
||||
centerY,
|
||||
startAngle,
|
||||
startRotation: layer.rotation,
|
||||
lastRotation: layer.rotation,
|
||||
};
|
||||
beginInteraction();
|
||||
onSelectLayer(layer.id);
|
||||
}
|
||||
|
||||
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||||
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 midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||
pinchRef.current = {
|
||||
active: true,
|
||||
initialPinchPx: clientDist(e.touches[0], e.touches[1]),
|
||||
initialScale: viewport.scale,
|
||||
initialX: viewport.x,
|
||||
initialY: viewport.y,
|
||||
pivotX: midX,
|
||||
pivotY: midY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||||
if (pinchRef.current?.active && e.touches.length === 2) {
|
||||
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 scaleChange = nextScale / pinchRef.current.initialScale;
|
||||
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
|
||||
setViewport({
|
||||
x: pivotX - (pivotX - initialX) * scaleChange,
|
||||
y: pivotY - (pivotY - initialY) * scaleChange,
|
||||
scale: nextScale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (pinchRef.current) pinchRef.current.active = false;
|
||||
}
|
||||
|
||||
function onContextMenuOpen(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!onContextMenu) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onContextMenu(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewport,
|
||||
isSpacePan,
|
||||
onContainerPointerDown,
|
||||
onContainerPointerMove,
|
||||
onContainerPointerUp,
|
||||
onLayerPointerDown,
|
||||
onResizeHandleDown,
|
||||
onRotateHandleDown,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onContextMenuOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { AUTOSAVE_DELAY_MS } from "../lib/editor-constants";
|
||||
|
||||
type UseEditorAutosaveOptions = {
|
||||
isDirty: boolean;
|
||||
projectUpdatedAt: string;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useEditorAutosave(options: UseEditorAutosaveOptions) {
|
||||
const { isDirty, projectUpdatedAt, saveCurrentProject } = options;
|
||||
const autosaveInFlightRef = React.useRef(false);
|
||||
const autosaveVersionRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
if (autosaveVersionRef.current === projectUpdatedAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (autosaveInFlightRef.current) return;
|
||||
autosaveInFlightRef.current = true;
|
||||
autosaveVersionRef.current = projectUpdatedAt;
|
||||
void saveCurrentProject().finally(() => {
|
||||
autosaveInFlightRef.current = false;
|
||||
});
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isDirty, projectUpdatedAt, saveCurrentProject]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
import { useEditorBindings } from "./use-editor-bindings";
|
||||
|
||||
describe("useEditorBindings", () => {
|
||||
it("returns selected layer derived from store state", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
|
||||
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(typeof result.current.actions.undo).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
|
||||
export function useEditorBindings() {
|
||||
const state = useEditorStore(
|
||||
useShallow((s) => ({
|
||||
project: s.project,
|
||||
selectedLayerId: s.selectedLayerId,
|
||||
canUndo: s.canUndo,
|
||||
canRedo: s.canRedo,
|
||||
history: s.history,
|
||||
isDirty: s.isDirty,
|
||||
tool: s.tool,
|
||||
})),
|
||||
);
|
||||
|
||||
const bindings = useEditorStore(
|
||||
useShallow((s) => ({
|
||||
loadProjectById: s.loadProjectById,
|
||||
setProject: s.setProject,
|
||||
saveCurrentProject: s.saveCurrentProject,
|
||||
selectLayer: s.selectLayer,
|
||||
setSelectedLayerPosition: s.setSelectedLayerPosition,
|
||||
setSelectedLayerPositionDraft: s.setSelectedLayerPositionDraft,
|
||||
setSelectedLayerSize: s.setSelectedLayerSize,
|
||||
setSelectedLayerSizeDraft: s.setSelectedLayerSizeDraft,
|
||||
setSelectedLayerRotation: s.setSelectedLayerRotation,
|
||||
setSelectedLayerRotationDraft: s.setSelectedLayerRotationDraft,
|
||||
startTransaction: s.startTransaction,
|
||||
commitTransaction: s.commitTransaction,
|
||||
removeSelectedLayer: s.removeSelectedLayer,
|
||||
addLayerByType: s.addLayerByType,
|
||||
moveSelectedLayerOrder: s.moveSelectedLayerOrder,
|
||||
importImageFromFile: s.importImageFromFile,
|
||||
setImageLayerFaceBlur: s.setImageLayerFaceBlur,
|
||||
setCanvasSize: s.setCanvasSize,
|
||||
setTool: s.setTool,
|
||||
undo: s.undo,
|
||||
redo: s.redo,
|
||||
exportProjectToJson: s.exportProjectToJson,
|
||||
jumpToPast: s.jumpToPast,
|
||||
jumpToFuture: s.jumpToFuture,
|
||||
copySelectedLayer: s.copySelectedLayer,
|
||||
cutSelectedLayer: s.cutSelectedLayer,
|
||||
pasteLayer: s.pasteLayer,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedLayer = React.useMemo(
|
||||
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
|
||||
[state.project.layers, state.selectedLayerId],
|
||||
);
|
||||
|
||||
return { state: { ...state, selectedLayer }, actions: bindings };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
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 openContextMenu = React.useCallback((x: number, y: number) => {
|
||||
const rect = document.documentElement.getBoundingClientRect();
|
||||
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)) });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = React.useCallback(() => {
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
return { contextMenu, openContextMenu, closeContextMenu };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function useEditorLabels(t: Translator) {
|
||||
const headerLabels = React.useMemo(
|
||||
() => ({
|
||||
file: t("editor.file"),
|
||||
edit: t("editor.edit"),
|
||||
view: t("editor.view"),
|
||||
settings: t("editor.settings"),
|
||||
save: t("editor.save"),
|
||||
exportPng: t("editor.exportPng"),
|
||||
exportProjectFile: t("editor.exportProjectFile"),
|
||||
importImage: t("editor.importImage"),
|
||||
canvasSize: t("editor.canvasSize"),
|
||||
undo: t("editor.undo"),
|
||||
redo: t("editor.redo"),
|
||||
copy: t("editor.copy"),
|
||||
cut: t("editor.cut"),
|
||||
paste: t("editor.paste"),
|
||||
preferences: t("editor.preferences"),
|
||||
panTool: t("editor.panTool"),
|
||||
pointerTool: t("editor.pointerTool"),
|
||||
unsavedChanges: t("editor.unsavedChanges"),
|
||||
saved: t("editor.saved"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const contextMenuLabels = React.useMemo(
|
||||
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const mobileLabels = React.useMemo(
|
||||
() => ({
|
||||
resize: t("editor.resize"),
|
||||
faceMlFailedShort: t("editor.faceMlFailedShort"),
|
||||
detectingFacesShort: t("editor.detectingFacesShort"),
|
||||
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
|
||||
import: t("editor.import"),
|
||||
mood: t("editor.mood"),
|
||||
quick: t("editor.quick"),
|
||||
face: t("editor.face"),
|
||||
decor: t("editor.decor"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return { headerLabels, contextMenuLabels, mobileLabels };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { createProject } from "@pien-studio/editor-core";
|
||||
import { upsertProject } from "@pien-studio/storage";
|
||||
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
type UseEditorProjectLifecycleOptions = {
|
||||
projectId: string;
|
||||
hydrate: () => void;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: ReturnType<typeof createProject>) => void;
|
||||
t: Translator;
|
||||
};
|
||||
|
||||
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
|
||||
const { projectId, hydrate, loadProjectById, setProject, t } = options;
|
||||
const initializedProjectId = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initializedProjectId.current === projectId) return;
|
||||
initializedProjectId.current = projectId;
|
||||
|
||||
if (projectId === "new") {
|
||||
const nextProject = createProject(t("home.untitledProject"));
|
||||
setProject(nextProject);
|
||||
void upsertProject(nextProject);
|
||||
return;
|
||||
}
|
||||
void loadProjectById(projectId);
|
||||
}, [loadProjectById, projectId, setProject, t]);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
|
||||
type UseEditorShortcutsOptions = {
|
||||
onSave: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||
const { onSave, onCopy, onCut, onPaste, onDelete } = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
onCopy();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "x") {
|
||||
e.preventDefault();
|
||||
onCut();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === "v") {
|
||||
e.preventDefault();
|
||||
onPaste();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCopy, onCut, onDelete, onPaste, onSave]);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import { useFaceBlurWorkflow } from "./use-face-blur-workflow";
|
||||
|
||||
function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
sourceUri: "data:image/png;base64,abc",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFaceBlurWorkflow", () => {
|
||||
it("selects all detected faces by default when no existing face blur", async () => {
|
||||
const setImageLayerFaceBlur = 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 },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
expect(result.current.faceBlurPreview?.regions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps selection empty when selected image already has blur", async () => {
|
||||
const setImageLayerFaceBlur = vi.fn();
|
||||
const selectedLayer = makeImageLayer({
|
||||
faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([]);
|
||||
expect(result.current.faceBlurPreview).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles face selection and applies blur to selected regions", async () => {
|
||||
const setImageLayerFaceBlur = 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 },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId: "layer-1",
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFaceIndex(1);
|
||||
});
|
||||
|
||||
expect(result.current.selectedFaceIndices).toEqual([0]);
|
||||
|
||||
act(() => {
|
||||
result.current.blurFaces(result.current.selectedFaceIndices);
|
||||
});
|
||||
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1);
|
||||
expect(setImageLayerFaceBlur).toHaveBeenCalledWith(
|
||||
"layer-1",
|
||||
expect.objectContaining({
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: expect.arrayContaining([
|
||||
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(result.current.selectedFaceIndices).toEqual([]);
|
||||
expect(result.current.faceBlurPreview).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||
import type { FaceDetectionOverlay } from "./use-face-detection";
|
||||
|
||||
type FaceBlurPreview = {
|
||||
layerId: string;
|
||||
method: FaceBlurMethod;
|
||||
amount: number;
|
||||
regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[];
|
||||
censorColor?: string;
|
||||
};
|
||||
|
||||
type UseFaceBlurWorkflowOptions = {
|
||||
selectedLayer: Layer | null;
|
||||
faceDetectionsLayerId: string | null;
|
||||
faceDetections: FaceDetectionOverlay[];
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void;
|
||||
};
|
||||
|
||||
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options;
|
||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
|
||||
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
|
||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id);
|
||||
|
||||
const buildBlurRegions = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") 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 scaleX = face.sourceWidth / baseWidth;
|
||||
const scaleY = face.sourceHeight / baseHeight;
|
||||
return {
|
||||
x: Math.max(0, Math.floor(face.x * scaleX)),
|
||||
y: Math.max(0, Math.floor(face.y * scaleY)),
|
||||
width: Math.max(1, Math.floor(face.width * scaleX)),
|
||||
height: Math.max(1, Math.floor(face.height * scaleY)),
|
||||
sourceWidth: face.sourceWidth,
|
||||
sourceHeight: face.sourceHeight,
|
||||
censorColor,
|
||||
};
|
||||
});
|
||||
},
|
||||
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
|
||||
);
|
||||
|
||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
||||
setSelectedFaceIndices((prev) => (prev.includes(index) ? prev.filter((item) => item !== index) : [...prev, index]));
|
||||
}, []);
|
||||
|
||||
const clearBlur = React.useCallback(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image") return;
|
||||
setImageLayerFaceBlur(selectedLayer.id, undefined);
|
||||
setFaceBlurPreview(null);
|
||||
}, [selectedLayer, setImageLayerFaceBlur]);
|
||||
|
||||
const blurFaces = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return;
|
||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
|
||||
const regions = buildBlurRegions(indices);
|
||||
setImageLayerFaceBlur(selectedLayer.id, {
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions,
|
||||
censorColor,
|
||||
});
|
||||
setSelectedFaceIndices([]);
|
||||
setFaceBlurPreview(null);
|
||||
},
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
if (selectedLayer?.faceBlur) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
setSelectedFaceIndices((prev) => {
|
||||
const next = faceDetections.map((_, i) => i);
|
||||
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFaceIndices.length === 0) {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFaceBlurPreview({
|
||||
layerId: selectedLayer.id,
|
||||
method: blurMethod,
|
||||
amount: blurAmount,
|
||||
regions: buildBlurRegions(selectedFaceIndices),
|
||||
censorColor,
|
||||
});
|
||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
||||
|
||||
return {
|
||||
blurMethod,
|
||||
setBlurMethod,
|
||||
blurAmount,
|
||||
setBlurAmount,
|
||||
censorColor,
|
||||
setCensorColor,
|
||||
selectedFaceIndices,
|
||||
faceBlurPreview,
|
||||
toggleFaceIndex,
|
||||
clearBlur,
|
||||
blurFaces,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from "react";
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
import { buildFacePreviews, loadImageFromUri, toFaceDetectionOverlays } from "../lib/face-detection-utils";
|
||||
|
||||
export type FaceDetectionOverlay = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
label: string;
|
||||
gender?: string;
|
||||
genderScore?: number;
|
||||
};
|
||||
|
||||
export type FacePreview = {
|
||||
id: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
type SelectedImageLayer = {
|
||||
id: string;
|
||||
sourceUri: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type UseFaceDetectionOptions = {
|
||||
tool: EditorToolId;
|
||||
selectedLayerId: string | null;
|
||||
selectedImageLayer: SelectedImageLayer | null;
|
||||
activeLayerStillSelected: (layerId: string) => boolean;
|
||||
};
|
||||
|
||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
const { tool, selectedLayerId, selectedImageLayer, activeLayerStillSelected } = options;
|
||||
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
||||
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
||||
const selectedImageWidth = selectedImageLayer?.width;
|
||||
const selectedImageHeight = selectedImageLayer?.height;
|
||||
const [faceDetections, setFaceDetections] = React.useState<FaceDetectionOverlay[]>([]);
|
||||
const [faceDetectionsLayerId, setFaceDetectionsLayerId] = React.useState<string | null>(null);
|
||||
const [faceStatus, setFaceStatus] = React.useState<"idle" | "detecting" | "unsupported">("idle");
|
||||
const [facePreviews, setFacePreviews] = React.useState<FacePreview[]>([]);
|
||||
|
||||
const resetFaceState = React.useCallback((status: "idle" | "detecting" | "unsupported" = "idle") => {
|
||||
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
||||
setFaceStatus((prev) => (prev === status ? prev : status));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
async function detectFaces() {
|
||||
const layerId = selectedImageLayerId;
|
||||
if (tool !== "face" || !layerId || !selectedImageSourceUri) {
|
||||
resetFaceState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
resetFaceState("detecting");
|
||||
const image = await loadImageFromUri(selectedImageSourceUri);
|
||||
if (!image) {
|
||||
if (!canceled) {
|
||||
resetFaceState("idle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { detectFaceBoxes } = await import("../lib/face-ml");
|
||||
const faces = await detectFaceBoxes(image);
|
||||
if (canceled) return;
|
||||
if (!activeLayerStillSelected(layerId) || tool !== "face") return;
|
||||
const overlays = toFaceDetectionOverlays(faces, image, selectedImageWidth, selectedImageHeight);
|
||||
setFaceDetections(overlays);
|
||||
setFaceDetectionsLayerId(layerId);
|
||||
setFaceStatus((prev) => (prev === "idle" ? prev : "idle"));
|
||||
} catch (err) {
|
||||
console.error("[face-detection] detectFaces error:", err);
|
||||
if (!canceled) {
|
||||
resetFaceState("unsupported");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detectFaces();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [activeLayerStillSelected, resetFaceState, selectedImageHeight, selectedImageLayerId, selectedImageSourceUri, selectedImageWidth, tool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tool !== "face") {
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
}, [tool, selectedLayerId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
async function generateFacePreviews() {
|
||||
if (tool !== "face" || !selectedImageSourceUri || faceDetections.length === 0) {
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await loadImageFromUri(selectedImageSourceUri);
|
||||
if (!image || canceled) {
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const previews = buildFacePreviews(image, faceDetections, selectedImageWidth, selectedImageHeight);
|
||||
|
||||
if (!canceled) setFacePreviews(previews);
|
||||
}
|
||||
|
||||
generateFacePreviews();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [faceDetections, selectedImageHeight, selectedImageSourceUri, selectedImageWidth, tool]);
|
||||
|
||||
return { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import en from "../messages/en.json";
|
||||
import ja from "../messages/ja.json";
|
||||
import th from "../messages/th.json";
|
||||
|
||||
type Messages = typeof en;
|
||||
|
||||
const messages: Record<string, Messages> = { en, ja, th };
|
||||
|
||||
type TranslationKey = string;
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||
const keys = path.split(".");
|
||||
let result: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (result && typeof result === "object" && key in result) {
|
||||
result = (result as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return typeof result === "string" ? result : path;
|
||||
}
|
||||
|
||||
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 {
|
||||
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
value = value.replace(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return { t, locale };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = "en";
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 });
|
||||
expect(overlays).toEqual([]);
|
||||
});
|
||||
|
||||
it("stacks overlapping labels to avoid collisions", () => {
|
||||
const layers = [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image" as const,
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 180,
|
||||
height: 120,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const overlays = buildFaceLabelOverlays(
|
||||
layers,
|
||||
"layer-1",
|
||||
[
|
||||
{ x: 16, y: 20, width: 30, height: 30, label: "A" },
|
||||
{ x: 17, y: 21, width: 30, height: 30, label: "B" },
|
||||
],
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
);
|
||||
|
||||
expect(overlays).toHaveLength(2);
|
||||
expect(overlays[0]?.top).toBeGreaterThan(overlays[1]?.top ?? 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
|
||||
|
||||
type Viewport = { x: number; y: number; scale: number };
|
||||
|
||||
export function buildFaceLabelOverlays(
|
||||
layers: Layer[],
|
||||
faceOverlayLayerId: string,
|
||||
faceDetections: FaceDetection[],
|
||||
viewport: Viewport,
|
||||
) {
|
||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||
if (!layer) return [];
|
||||
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
if (!layerWidth || !layerHeight) return [];
|
||||
|
||||
const centerX = layer.x + layerWidth / 2;
|
||||
const centerY = layer.y + layerHeight / 2;
|
||||
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 }> = [];
|
||||
|
||||
return faceDetections.map((face, index) => {
|
||||
const worldX = layer.x + face.x;
|
||||
const worldY = layer.y + face.y;
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
const rotatedWorldX = centerX + dx * cos - dy * sin;
|
||||
const rotatedWorldY = centerY + dx * sin + dy * cos;
|
||||
|
||||
const text = face.label ?? `Person ${index + 1}`;
|
||||
const estimatedWidth = Math.max(72, Math.min(260, text.length * 6 + 14));
|
||||
const estimatedHeight = 18;
|
||||
const left = viewport.x + rotatedWorldX * viewport.scale;
|
||||
let top = viewport.y + rotatedWorldY * viewport.scale - 22;
|
||||
|
||||
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;
|
||||
return intersectsX && intersectsY;
|
||||
})
|
||||
) {
|
||||
top -= estimatedHeight + 4;
|
||||
}
|
||||
|
||||
placed.push({ left, top, width: estimatedWidth, height: estimatedHeight });
|
||||
return { id: `${layer.id}-face-label-${index}`, text, left, top };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const AUTOSAVE_DELAY_MS = 1200;
|
||||
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
width: 170,
|
||||
height: 110,
|
||||
viewportPadding: 8,
|
||||
} as const;
|
||||
|
||||
export const CANVAS_HANDLE_BASE_SIZE = 12;
|
||||
export const CANVAS_ROTATE_HANDLE_BASE_SIZE = 18;
|
||||
|
||||
export const MIN_LAYER_SIZE = 8;
|
||||
|
||||
export const DEFAULT_IMAGE_IMPORT = {
|
||||
fallbackWidth: 200,
|
||||
fallbackHeight: 150,
|
||||
offsetX: 60,
|
||||
offsetY: 60,
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
|
||||
export type ToolModeController = {
|
||||
kind: "mode";
|
||||
id: EditorToolId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ToolActionController = {
|
||||
kind: "action";
|
||||
id: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export type EditorToolController = ToolModeController | ToolActionController;
|
||||
|
||||
type CreateEditorToolControllersOptions = {
|
||||
onAddTextLayer: () => void;
|
||||
onImportImage: () => void;
|
||||
labels: {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
|
||||
return [
|
||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
||||
{ kind: "mode", id: "face", label: options.labels.face },
|
||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
type ExportOptions = {
|
||||
isDark: boolean;
|
||||
pixelRatio?: number;
|
||||
};
|
||||
|
||||
function clampOpacity(value: number | undefined) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return 1;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = reject;
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
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 radius = 8;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.lineTo(width - radius, 0);
|
||||
ctx.quadraticCurveTo(width, 0, width, radius);
|
||||
ctx.lineTo(width, height - radius);
|
||||
ctx.quadraticCurveTo(width, height, width - radius, height);
|
||||
ctx.lineTo(radius, height);
|
||||
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = isDark ? "#2d3036" : "#ffffff";
|
||||
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||
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 === "image" ? Math.round(200 * layer.scale) : 120);
|
||||
const height = layer.height ?? (layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||
ctx.translate(layer.x + width / 2, layer.y + height / 2);
|
||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
|
||||
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
||||
try {
|
||||
const image = await loadImage(layer.sourceUri);
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
if (layer.faceBlur && layer.faceBlur.regions.length > 0) {
|
||||
renderFaceBlurRegions(ctx, image, layer.faceBlur, width, height);
|
||||
}
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
} else {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
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;
|
||||
canvas.height = height * pixelRatio;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create export canvas context");
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.fillStyle = options.isDark ? "#17181b" : "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const layer of project.layers) {
|
||||
await drawLayer(ctx, layer, options.isDark);
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `${project.title || "pien-project"}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
fillStyle: "",
|
||||
filter: "none",
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function makeImage(width = 1200, height = 800) {
|
||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||
}
|
||||
|
||||
describe("renderFaceBlurRegions", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
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.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders pixelate using sampled offscreen canvas", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
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 nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
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);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("renders censor with region color priority", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25);
|
||||
});
|
||||
|
||||
it("falls back to legacy region scaling when source dimensions are missing", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage(2400, 1600);
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FaceBlurSettings } from "@pien-studio/types";
|
||||
|
||||
type BlurRegion = FaceBlurSettings["regions"][number];
|
||||
|
||||
function drawPixelatedRegion(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
blockSize: number,
|
||||
) {
|
||||
const sampleCanvas = document.createElement("canvas");
|
||||
sampleCanvas.width = Math.max(1, Math.round(targetWidth / blockSize));
|
||||
sampleCanvas.height = Math.max(1, Math.round(targetHeight / blockSize));
|
||||
const sampleCtx = sampleCanvas.getContext("2d");
|
||||
if (!sampleCtx) return;
|
||||
sampleCtx.imageSmoothingEnabled = false;
|
||||
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.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
export function renderFaceBlurRegions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string },
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur.regions.length) return;
|
||||
const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth);
|
||||
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight);
|
||||
|
||||
for (const region of blur.regions) {
|
||||
const hasSourceDims = typeof region.sourceWidth === "number" && typeof region.sourceHeight === "number" && region.sourceWidth > 0 && region.sourceHeight > 0;
|
||||
const scaleX = hasSourceDims ? targetWidth / region.sourceWidth : 1;
|
||||
const scaleY = hasSourceDims ? targetHeight / region.sourceHeight : 1;
|
||||
|
||||
const x = Math.max(0, Math.floor(region.x * scaleX));
|
||||
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));
|
||||
|
||||
if (blur.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blur.method === "pixelate") {
|
||||
const pixelSize = Math.max(4, Math.round(blur.amount / 2));
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../hooks/use-face-detection";
|
||||
|
||||
export async function loadImageFromUri(uri: string): Promise<HTMLImageElement | null> {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = uri;
|
||||
});
|
||||
if (!image.naturalWidth || !image.naturalHeight) return null;
|
||||
return image;
|
||||
}
|
||||
|
||||
export function toFaceDetectionOverlays(
|
||||
faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>,
|
||||
image: HTMLImageElement,
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FaceDetectionOverlay[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const scaleX = width / image.naturalWidth;
|
||||
const scaleY = height / image.naturalHeight;
|
||||
|
||||
return faces.map((face, index) => {
|
||||
const genderLabel = face.gender ?? "unknown";
|
||||
const scoreLabel = face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
||||
const label = scoreLabel ? `Person ${index + 1} - ${genderLabel} ${scoreLabel}` : `Person ${index + 1} - ${genderLabel}`;
|
||||
|
||||
return {
|
||||
x: face.x * scaleX,
|
||||
y: face.y * scaleY,
|
||||
width: face.width * scaleX,
|
||||
height: face.height * scaleY,
|
||||
sourceWidth: image.naturalWidth,
|
||||
sourceHeight: image.naturalHeight,
|
||||
label,
|
||||
gender: face.gender,
|
||||
genderScore: face.genderScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFacePreviews(
|
||||
image: HTMLImageElement,
|
||||
faceDetections: FaceDetectionOverlay[],
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FacePreview[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const toImageScaleX = image.naturalWidth / Math.max(1, width);
|
||||
const toImageScaleY = image.naturalHeight / Math.max(1, height);
|
||||
|
||||
return faceDetections
|
||||
.map((face, index) => {
|
||||
const sx = Math.max(0, Math.floor(face.x * toImageScaleX));
|
||||
const sy = Math.max(0, Math.floor(face.y * toImageScaleY));
|
||||
const sw = Math.max(1, Math.floor(face.width * toImageScaleX));
|
||||
const sh = Math.max(1, Math.floor(face.height * toImageScaleY));
|
||||
const ex = Math.min(image.naturalWidth, sx + sw);
|
||||
const ey = Math.min(image.naturalHeight, sy + sh);
|
||||
const cw = Math.max(1, ex - sx);
|
||||
const ch = Math.max(1, ey - sy);
|
||||
const canvas = document.createElement("canvas");
|
||||
const targetWidth = 84;
|
||||
const scale = targetWidth / cw;
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = Math.max(1, Math.round(ch * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return { id: `face-${index + 1}`, src: "" };
|
||||
ctx.drawImage(image, sx, sy, cw, ch, 0, 0, canvas.width, canvas.height);
|
||||
return { id: `face-${index + 1}`, src: canvas.toDataURL("image/jpeg", 0.9) };
|
||||
})
|
||||
.filter((preview) => preview.src);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type FaceBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
gender?: "male" | "female";
|
||||
genderScore?: number;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[]> {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
await tf.ready();
|
||||
|
||||
const faceapi = await import("@vladmandic/face-api");
|
||||
await Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
faceapi.nets.faceLandmark68TinyNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
faceapi.nets.ageGenderNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
||||
]);
|
||||
|
||||
const TinyFaceDetectorOptions = (faceapi as unknown as { TinyFaceDetectorOptions: new (o: object) => object }).TinyFaceDetectorOptions;
|
||||
|
||||
const passes = [
|
||||
{ inputSize: 320, scoreThreshold: 0.5 },
|
||||
{ inputSize: 416, scoreThreshold: 0.45 },
|
||||
{ inputSize: 512, scoreThreshold: 0.5 },
|
||||
{ inputSize: 608, scoreThreshold: 0.45 },
|
||||
{ inputSize: 736, scoreThreshold: 0.4 },
|
||||
{ inputSize: 864, scoreThreshold: 0.35 },
|
||||
];
|
||||
|
||||
type FaceApiDet = {
|
||||
gender: string;
|
||||
genderProbability: number;
|
||||
detection: { box: { x: number; y: number; width: number; height: number } };
|
||||
};
|
||||
|
||||
const allDets = await Promise.all(
|
||||
passes.map(({ inputSize, scoreThreshold }) =>
|
||||
(faceapi as unknown as {
|
||||
detectAllFaces: (
|
||||
img: HTMLImageElement,
|
||||
options: { inputSize: number; scoreThreshold: number }
|
||||
) => Promise<FaceApiDet[]>
|
||||
}).detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold }))
|
||||
.withFaceLandmarks(true)
|
||||
.withAgeAndGender()
|
||||
)
|
||||
);
|
||||
|
||||
const flat = allDets.flat();
|
||||
if (flat.length === 0) return [];
|
||||
|
||||
function iou(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) {
|
||||
const ix = Math.max(a.x, b.x);
|
||||
const iy = Math.max(a.y, b.y);
|
||||
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
||||
const iy2 = Math.min(a.y + b.height, b.y + b.height);
|
||||
const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
|
||||
const union = a.width * a.height + b.width * b.height - inter;
|
||||
return union > 0 ? inter / union : 0;
|
||||
}
|
||||
|
||||
function avgGender(dets: FaceApiDet[]): { gender: "male" | "female" | undefined; score: number } {
|
||||
let maleScore = 0;
|
||||
let femaleScore = 0;
|
||||
let count = 0;
|
||||
for (const det of dets) {
|
||||
if (det.gender === "male") maleScore += det.genderProbability;
|
||||
else if (det.gender === "female") femaleScore += det.genderProbability;
|
||||
count++;
|
||||
}
|
||||
if (count === 0) return { gender: undefined, score: 0 };
|
||||
const avgMale = maleScore / count;
|
||||
const avgFemale = femaleScore / count;
|
||||
if (avgMale > avgFemale) return { gender: "male", score: avgMale };
|
||||
if (avgFemale > avgMale) return { gender: "female", score: avgFemale };
|
||||
return { gender: undefined, score: 0 };
|
||||
}
|
||||
|
||||
const clusters: FaceApiDet[][] = [];
|
||||
for (const det of flat) {
|
||||
const b = det.detection?.box;
|
||||
if (!b) continue;
|
||||
let matched = false;
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.some((c) => iou(c.detection.box, b) > 0.4)) {
|
||||
cluster.push(det);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) clusters.push([det]);
|
||||
}
|
||||
|
||||
return clusters.map((group) => {
|
||||
const largest = [...group].sort(
|
||||
(a, b) =>
|
||||
(b.detection?.box?.width ?? 0) * (b.detection?.box?.height ?? 0) -
|
||||
(a.detection?.box?.width ?? 0) * (a.detection?.box?.height ?? 0)
|
||||
)[0];
|
||||
const box = largest.detection.box;
|
||||
const { gender, score: genderScore } = avgGender(group);
|
||||
|
||||
return {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
gender,
|
||||
genderScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { hasProjectChanged } from "./project-equality";
|
||||
|
||||
function makeProject(): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
title: "Project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
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 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasProjectChanged", () => {
|
||||
it("returns false for equal projects", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects canvas changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
b.canvas.width = 101;
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects face blur region changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
b.layers[0].faceBlur = { 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 });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers = [b.layers[1], b.layers[0]];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats missing optional fields and undefined as equal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].name = undefined;
|
||||
b.layers[0].name = undefined;
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects face blur removal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
if (left.id !== right.id) return true;
|
||||
if (left.title !== right.title) return true;
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
if (left.layers.length !== right.layers.length) return true;
|
||||
|
||||
for (let index = 0; index < left.layers.length; index += 1) {
|
||||
const a = left.layers[index];
|
||||
const b = right.layers[index];
|
||||
if (!a || !b) return true;
|
||||
if (
|
||||
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 ||
|
||||
a.height !== b.height ||
|
||||
a.scale !== b.scale ||
|
||||
a.rotation !== b.rotation ||
|
||||
a.opacity !== b.opacity
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blurA = a.faceBlur;
|
||||
const blurB = b.faceBlur;
|
||||
if (!blurA && !blurB) continue;
|
||||
if (!blurA || !blurB) return true;
|
||||
if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true;
|
||||
if (blurA.regions.length !== blurB.regions.length) return true;
|
||||
for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) {
|
||||
const regionA = blurA.regions[regionIndex];
|
||||
const regionB = blurB.regions[regionIndex];
|
||||
if (!regionA || !regionB) return true;
|
||||
if (
|
||||
regionA.x !== regionB.x ||
|
||||
regionA.y !== regionB.y ||
|
||||
regionA.width !== regionB.width ||
|
||||
regionA.height !== regionB.height ||
|
||||
regionA.censorColor !== regionB.censorColor
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function surfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white";
|
||||
}
|
||||
|
||||
export function mutedSurfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
|
||||
}
|
||||
|
||||
export function subtleButtonClass(isDark: boolean): string {
|
||||
return isDark
|
||||
? "border-white/15 bg-[#25272b] text-[#e8eaed]"
|
||||
: "border-black/15 bg-[#f2f4f8] text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function accentButtonClass(): string {
|
||||
return "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white";
|
||||
}
|
||||
|
||||
export function hoverSubtleClass(isDark: boolean): string {
|
||||
return isDark ? "hover:bg-white/10" : "hover:bg-black/5";
|
||||
}
|
||||
|
||||
export function dividerClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10" : "bg-black/10";
|
||||
}
|
||||
|
||||
export function panelClass(isDark: boolean): string {
|
||||
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
|
||||
}
|
||||
|
||||
export function panelTitleClass(isDark: boolean): string {
|
||||
return isDark ? "text-[#dfe3ea]" : "text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function panelCounterClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10 text-[#cfd4dd]" : "bg-black/5 text-[#586071]";
|
||||
}
|
||||
|
||||
export function panelInsetClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "Make your mood visible",
|
||||
"subtitle": "Local-first editing, expressive presets, share when you want.",
|
||||
"start": "Start New Project",
|
||||
"moods": "Mood presets",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "Open",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete",
|
||||
"projectHub": "Project Hub",
|
||||
"createOpenManage": "Create, open, and manage local projects.",
|
||||
"newProject": "New Project",
|
||||
"openProjectFile": "Open Project File",
|
||||
"openImage": "Open Image",
|
||||
"myProjects": "My Projects",
|
||||
"noProjectsYet": "No projects yet",
|
||||
"untitledProject": "Untitled Project",
|
||||
"imageProject": "Image Project",
|
||||
"wipTitle": "Heavy WIP Warning",
|
||||
"wipBody": "This app is in very early development and heavily work in progress. Features may be broken, missing, unstable, or changed at any time.",
|
||||
"wipSupportPrefix": "If you want to support development, you can sponsor the project on GitHub:",
|
||||
"wipAcknowledge": "I Understand"
|
||||
},
|
||||
"editor": {
|
||||
"file": "File",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"settings": "Settings",
|
||||
"save": "Save",
|
||||
"exportPng": "Export PNG",
|
||||
"exportProjectFile": "Export Project File",
|
||||
"importImage": "Import image",
|
||||
"canvasSize": "Canvas size",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"copy": "Copy",
|
||||
"cut": "Cut",
|
||||
"paste": "Paste",
|
||||
"preferences": "Preferences",
|
||||
"panTool": "Pan tool",
|
||||
"pointerTool": "Pointer tool",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"saved": "Saved",
|
||||
"layers": "Layers",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"delete": "Delete",
|
||||
"noLayersYet": "No layers yet. Add text or image.",
|
||||
"faceTool": "Face Tool",
|
||||
"faceMlFailed": "Face ML model failed to load on this device/browser.",
|
||||
"detectingFaces": "Detecting faces on selected image...",
|
||||
"selectImageLayer": "Select an image layer to scan faces.",
|
||||
"noFacesFound": "No faces found in selected image.",
|
||||
"facesDetected": "Faces detected and highlighted on canvas.",
|
||||
"person": "Person",
|
||||
"unknown": "unknown",
|
||||
"neutral": "neutral",
|
||||
"blurMethod": "Blur method",
|
||||
"soft": "Soft",
|
||||
"pixelate": "Pixelate",
|
||||
"censor": "Censor",
|
||||
"strength": "Strength",
|
||||
"color": "Color",
|
||||
"selectFacesToBlur": "Select faces to blur",
|
||||
"blurFaces": "Blur {count} face(s)",
|
||||
"clearBlur": "Clear blur",
|
||||
"history": "History",
|
||||
"beforeLastAction": "Before last action",
|
||||
"step": "Step",
|
||||
"now": "Now",
|
||||
"undoneStep": "Undone step",
|
||||
"noHistoryYet": "No history yet",
|
||||
"resize": "Resize",
|
||||
"import": "Import",
|
||||
"mood": "Mood",
|
||||
"quick": "Quick",
|
||||
"face": "Face",
|
||||
"decor": "Decor",
|
||||
"faceMlFailedShort": "Face ML model failed to load on this device/browser.",
|
||||
"detectingFacesShort": "Detecting faces on-device...",
|
||||
"faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.",
|
||||
"toolPointer": "Pointer",
|
||||
"toolPan": "Pan",
|
||||
"toolFace": "Face",
|
||||
"toolText": "Text",
|
||||
"toolImage": "Image",
|
||||
"canvasSizeTitle": "Canvas Size",
|
||||
"modePreset": "Preset",
|
||||
"modeCustom": "Custom",
|
||||
"presetSquare": "Square",
|
||||
"presetPortrait45": "Portrait 4:5",
|
||||
"presetStory916": "Story 9:16",
|
||||
"presetWidescreen": "Widescreen",
|
||||
"presetPhoto43": "Photo 4:3",
|
||||
"presetClassic32": "Classic 3:2",
|
||||
"widthShort": "W",
|
||||
"heightShort": "H",
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
"apply": "Apply",
|
||||
"rotate": "Rotate",
|
||||
"layer": "layer"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "Locale",
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "気分をビジュアルに",
|
||||
"subtitle": "ローカル優先で編集。感情に合うプリセット。共有は好きな時に。",
|
||||
"start": "新しいプロジェクトを開始",
|
||||
"moods": "ムードプリセット",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "開く",
|
||||
"duplicate": "複製",
|
||||
"delete": "削除",
|
||||
"projectHub": "プロジェクトハブ",
|
||||
"createOpenManage": "ローカルプロジェクトの作成・読み込み・管理",
|
||||
"newProject": "新規プロジェクト",
|
||||
"openProjectFile": "プロジェクトファイルを開く",
|
||||
"openImage": "画像を開く",
|
||||
"myProjects": "マイプロジェクト",
|
||||
"noProjectsYet": "プロジェクトがありません",
|
||||
"untitledProject": "無題のプロジェクト",
|
||||
"imageProject": "画像プロジェクト",
|
||||
"wipTitle": "開発初期の警告",
|
||||
"wipBody": "このアプリは開発のかなり初期段階で、現在も大幅に作業中です。機能が壊れていたり、未実装だったり、不安定だったり、予告なく変更される場合があります。",
|
||||
"wipSupportPrefix": "開発を支援したい場合は、GitHub Sponsors からスポンサーできます:",
|
||||
"wipAcknowledge": "理解しました"
|
||||
},
|
||||
"editor": {
|
||||
"file": "ファイル",
|
||||
"edit": "編集",
|
||||
"view": "表示",
|
||||
"settings": "設定",
|
||||
"save": "保存",
|
||||
"exportPng": "PNGでエクスポート",
|
||||
"exportProjectFile": "プロジェクトファイルをエクスポート",
|
||||
"importImage": "画像をインポート",
|
||||
"canvasSize": "キャンバスサイズ",
|
||||
"undo": "元に戻す",
|
||||
"redo": "やり直す",
|
||||
"copy": "コピー",
|
||||
"cut": "切り取り",
|
||||
"paste": "貼り付け",
|
||||
"preferences": "環境設定",
|
||||
"panTool": "パン工具",
|
||||
"pointerTool": "ポインター工具",
|
||||
"unsavedChanges": "未保存の変更",
|
||||
"saved": "保存済み",
|
||||
"layers": "レイヤー",
|
||||
"up": "上へ",
|
||||
"down": "下へ",
|
||||
"delete": "削除",
|
||||
"noLayersYet": "レイヤーがありません。テキストまたは画像を追加してください。",
|
||||
"faceTool": "顔ツール",
|
||||
"faceMlFailed": "このデバイス/ブラウザでは顔認識MLモデルの読み込みに失敗しました。",
|
||||
"detectingFaces": "選択した画像から顔を検出中...",
|
||||
"selectImageLayer": "顔をスキャンするには画像レイヤーを選択してください。",
|
||||
"noFacesFound": "選択した画像内で顔が見つかりませんでした。",
|
||||
"facesDetected": "顔が検出されキャンバスにハイライトされました。",
|
||||
"person": "人物",
|
||||
"unknown": "不明",
|
||||
"neutral": "中立",
|
||||
"blurMethod": "ぼかし方法",
|
||||
"soft": "ソフト",
|
||||
"pixelate": "モザイク",
|
||||
"censor": "センソル",
|
||||
"strength": "強度",
|
||||
"color": "色",
|
||||
"selectFacesToBlur": "ぼかす顔を選択してください",
|
||||
"blurFaces": "{count}件の顔をぼかす",
|
||||
"clearBlur": "ぼかしをクリア",
|
||||
"history": "履歴",
|
||||
"beforeLastAction": "最後の操作の前",
|
||||
"step": "ステップ",
|
||||
"now": "現在",
|
||||
"undoneStep": "取り消したステップ",
|
||||
"noHistoryYet": "履歴がありません",
|
||||
"resize": "サイズ変更",
|
||||
"import": "インポート",
|
||||
"mood": "ムード",
|
||||
"quick": "クイック",
|
||||
"face": "顔",
|
||||
"decor": "デコル",
|
||||
"faceMlFailedShort": "このデバイス/ブラウザでは顔MLモデルの読み込みに失敗しました。",
|
||||
"detectingFacesShort": "デバイス上で顔を検出中...",
|
||||
"faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。",
|
||||
"toolPointer": "ポインター",
|
||||
"toolPan": "パン",
|
||||
"toolFace": "顔",
|
||||
"toolText": "テキスト",
|
||||
"toolImage": "画像",
|
||||
"canvasSizeTitle": "キャンバスサイズ",
|
||||
"modePreset": "プリセット",
|
||||
"modeCustom": "カスタム",
|
||||
"presetSquare": "正方形",
|
||||
"presetPortrait45": "縦長 4:5",
|
||||
"presetStory916": "ストーリー 9:16",
|
||||
"presetWidescreen": "ワイド 16:9",
|
||||
"presetPhoto43": "写真 4:3",
|
||||
"presetClassic32": "クラシック 3:2",
|
||||
"widthShort": "幅",
|
||||
"heightShort": "高",
|
||||
"cancel": "キャンセル",
|
||||
"dismiss": "閉じる",
|
||||
"apply": "適用",
|
||||
"rotate": "回転",
|
||||
"layer": "レイヤー"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "言語",
|
||||
"darkMode": "ダークモード",
|
||||
"lightMode": "ライトモード"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "ทำให้อารมณ์ของคุณมองเห็นได้",
|
||||
"subtitle": "แต่งภาพแบบ local-first พร้อมพรีเซ็ตสายอารมณ์ แชร์เมื่อพร้อม",
|
||||
"start": "เริ่มโปรเจกต์ใหม่",
|
||||
"moods": "พรีเซ็ตอารมณ์",
|
||||
"soft": "Soft",
|
||||
"pien": "Pien",
|
||||
"darkCute": "Dark Cute",
|
||||
"stage": "Stage",
|
||||
"custom": "Custom",
|
||||
"open": "เปิด",
|
||||
"duplicate": "ทำซ้ำ",
|
||||
"delete": "ลบ",
|
||||
"projectHub": "ศูนย์รวมโปรเจกต์",
|
||||
"createOpenManage": "สร้าง เปิด และจัดการโปรเจกต์ในเครื่อง",
|
||||
"newProject": "โปรเจกต์ใหม่",
|
||||
"openProjectFile": "เปิดไฟล์โปรเจกต์",
|
||||
"openImage": "เปิดรูปภาพ",
|
||||
"myProjects": "โปรเจกต์ของฉัน",
|
||||
"noProjectsYet": "ยังไม่มีโปรเจกต์",
|
||||
"untitledProject": "โปรเจกต์ไม่มีชื่อ",
|
||||
"imageProject": "โปรเจกต์รูปภาพ",
|
||||
"wipTitle": "คำเตือน: ยังอยู่ระหว่างพัฒนาอย่างหนัก",
|
||||
"wipBody": "แอปนี้ยังอยู่ในช่วงพัฒนาเริ่มต้นมาก ๆ และยังอยู่ระหว่างทำงานอย่างต่อเนื่อง ฟีเจอร์บางส่วนอาจใช้งานไม่ได้ หายไป ไม่เสถียร หรือเปลี่ยนแปลงได้ตลอดเวลา",
|
||||
"wipSupportPrefix": "ถ้าคุณอยากสนับสนุนการพัฒนา สามารถสปอนเซอร์ผ่าน GitHub ได้ที่:",
|
||||
"wipAcknowledge": "เข้าใจแล้ว"
|
||||
},
|
||||
"editor": {
|
||||
"file": "ไฟล์",
|
||||
"edit": "แก้ไข",
|
||||
"view": "มุมมอง",
|
||||
"settings": "การตั้งค่า",
|
||||
"save": "บันทึก",
|
||||
"exportPng": "ส่งออก PNG",
|
||||
"exportProjectFile": "ส่งออกไฟล์โปรเจกต์",
|
||||
"importImage": "นำเข้ารูปภาพ",
|
||||
"canvasSize": "ขนาด Canvas",
|
||||
"undo": "เลิกทำ",
|
||||
"redo": "ทำซ้ำ",
|
||||
"copy": "คัดลอก",
|
||||
"cut": "ตัด",
|
||||
"paste": "วาง",
|
||||
"preferences": "การตั้งค่า",
|
||||
"panTool": "เครื่องมือเลื่อน",
|
||||
"pointerTool": "เครื่องมือชี้",
|
||||
"unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก",
|
||||
"saved": "บันทึกแล้ว",
|
||||
"layers": "เลเยอร์",
|
||||
"up": "ขึ้น",
|
||||
"down": "ลง",
|
||||
"delete": "ลบ",
|
||||
"noLayersYet": "ยังไม่มีเลเยอร์ เพิ่มข้อความหรือรูปภาพ",
|
||||
"faceTool": "เครื่องมือใบหน้า",
|
||||
"faceMlFailed": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้",
|
||||
"detectingFaces": "กำลังตรวจจับใบหน้าในรูปภาพที่เลือก...",
|
||||
"selectImageLayer": "เลือกเลเยอร์รูปภาพเพื่อสแกนใบหน้า",
|
||||
"noFacesFound": "ไม่พบใบหน้าในรูปภาพที่เลือก",
|
||||
"facesDetected": "ตรวจพบใบหน้าและไฮไลท์บน Canvas",
|
||||
"person": "บุคคล",
|
||||
"unknown": "ไม่รู้จัก",
|
||||
"neutral": "เป็นกลาง",
|
||||
"blurMethod": "วิธีเบลอ",
|
||||
"soft": "นุ่ม",
|
||||
"pixelate": "ปิกเซล",
|
||||
"censor": "เซ็นเซอร์",
|
||||
"strength": "ความแรง",
|
||||
"color": "สี",
|
||||
"selectFacesToBlur": "เลือกใบหน้าที่จะเบลอ",
|
||||
"blurFaces": "เบลอ {count} ใบหน้า",
|
||||
"clearBlur": "ล้างการเบลอ",
|
||||
"history": "ประวัติ",
|
||||
"beforeLastAction": "ก่อนการทำงานล่าสุด",
|
||||
"step": "ขั้นตอน",
|
||||
"now": "ตอนนี้",
|
||||
"undoneStep": "ขั้นตอนที่เลิกทำ",
|
||||
"noHistoryYet": "ยังไม่มีประวัติ",
|
||||
"resize": "ปรับขนาด",
|
||||
"import": "นำเข้า",
|
||||
"mood": "อารมณ์",
|
||||
"quick": "ด่วน",
|
||||
"face": "ใบหน้า",
|
||||
"decor": "ตกแต่ง",
|
||||
"faceMlFailedShort": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้",
|
||||
"detectingFacesShort": "กำลังตรวจจับใบหน้าบนอุปกรณ์...",
|
||||
"faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด",
|
||||
"toolPointer": "ตัวชี้",
|
||||
"toolPan": "เลื่อน",
|
||||
"toolFace": "ใบหน้า",
|
||||
"toolText": "ข้อความ",
|
||||
"toolImage": "รูปภาพ",
|
||||
"canvasSizeTitle": "ขนาดแคนวาส",
|
||||
"modePreset": "พรีเซ็ต",
|
||||
"modeCustom": "กำหนดเอง",
|
||||
"presetSquare": "สี่เหลี่ยมจัตุรัส",
|
||||
"presetPortrait45": "แนวตั้ง 4:5",
|
||||
"presetStory916": "สตอรี่ 9:16",
|
||||
"presetWidescreen": "จอกว้าง 16:9",
|
||||
"presetPhoto43": "ภาพถ่าย 4:3",
|
||||
"presetClassic32": "คลาสสิก 3:2",
|
||||
"widthShort": "กว้าง",
|
||||
"heightShort": "สูง",
|
||||
"cancel": "ยกเลิก",
|
||||
"dismiss": "ปิด",
|
||||
"apply": "นำไปใช้",
|
||||
"rotate": "หมุน",
|
||||
"layer": "เลเยอร์"
|
||||
},
|
||||
"ui": {
|
||||
"locale": "ภาษา",
|
||||
"darkMode": "โหมดมืด",
|
||||
"lightMode": "โหมดสว่าง"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
export default withNextIntl({
|
||||
reactStrictMode: true,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@pien-studio/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mediapipe/face_detection": "~0.4.0",
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/storage": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tensorflow-models/face-detection": "^1.0.3",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-cpu": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
|
||||
"@tensorflow/tfjs-converter": "^4.22.0",
|
||||
"@tensorflow/tfjs-core": "^4.22.0",
|
||||
"@vladmandic/face-api": "^1.7.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^16.2.4",
|
||||
"next-intl": "^4.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zustand": "^5.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEditorStore } from "./editor-store";
|
||||
|
||||
describe("editor store", () => {
|
||||
it("saves and loads project from indexeddb", async () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("sticker");
|
||||
const savedId = useEditorStore.getState().project.id;
|
||||
|
||||
await useEditorStore.getState().saveCurrentProject();
|
||||
useEditorStore.getState().resetProject();
|
||||
expect(useEditorStore.getState().project.id).not.toBe(savedId);
|
||||
|
||||
await useEditorStore.getState().loadProjectById(savedId);
|
||||
expect(useEditorStore.getState().project.id).toBe(savedId);
|
||||
});
|
||||
|
||||
it("imports and exports project json", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const json = useEditorStore.getState().exportProjectToJson();
|
||||
|
||||
useEditorStore.getState().resetProject();
|
||||
const result = useEditorStore.getState().importProjectFromJson(json);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useEditorStore.getState().project.layers.length).toBe(1);
|
||||
});
|
||||
|
||||
it("adds layer by type and removes selected layer", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
expect(useEditorStore.getState().project.layers[0]?.type).toBe("text");
|
||||
useEditorStore.getState().removeSelectedLayer();
|
||||
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates selected layer position directly", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
useEditorStore.getState().setSelectedLayerPosition(220, 140);
|
||||
const layer = useEditorStore.getState().project.layers[0];
|
||||
expect(layer?.x).toBe(220);
|
||||
expect(layer?.y).toBe(140);
|
||||
});
|
||||
|
||||
it("records pointer draft transforms in undo history on commit", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const initial = useEditorStore.getState().project.layers[0];
|
||||
expect(initial).toBeDefined();
|
||||
|
||||
useEditorStore.getState().setSelectedLayerPositionDraft(180, 150);
|
||||
useEditorStore.getState().setSelectedLayerPosition(180, 150);
|
||||
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||
|
||||
useEditorStore.getState().undo();
|
||||
const movedBack = useEditorStore.getState().project.layers[0];
|
||||
expect(movedBack?.x).toBe(initial?.x);
|
||||
expect(movedBack?.y).toBe(initial?.y);
|
||||
});
|
||||
|
||||
it("reorders layers up and down", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
useEditorStore.getState().addLayerByType("image");
|
||||
const layers = useEditorStore.getState().project.layers;
|
||||
const [first, second] = layers;
|
||||
expect(first.id).not.toBe(second.id);
|
||||
|
||||
useEditorStore.getState().selectLayer(first.id);
|
||||
useEditorStore.getState().moveSelectedLayerOrder("up");
|
||||
const after = useEditorStore.getState().project.layers;
|
||||
expect(after[0].id).toBe(second.id);
|
||||
expect(after[1].id).toBe(first.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
addLayer,
|
||||
createLayer,
|
||||
createProject,
|
||||
parseProjectFile,
|
||||
removeLayer,
|
||||
reorderLayer,
|
||||
serializeProjectFile,
|
||||
setCanvasSize as applyCanvasSize,
|
||||
updateLayerTransform,
|
||||
normalizeProject,
|
||||
} from "@pien-studio/editor-core";
|
||||
import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types";
|
||||
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
|
||||
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
||||
import { hasProjectChanged } from "../lib/project-equality";
|
||||
import {
|
||||
capHistory,
|
||||
cloneLayer,
|
||||
cloneProject,
|
||||
computeHistoryFlags,
|
||||
makeHistory,
|
||||
resolveSelectedLayerId,
|
||||
type HistoryState,
|
||||
} from "./editor-store-helpers";
|
||||
|
||||
const DRAFT_TRANSFORM_EPSILON = 0.01;
|
||||
|
||||
export const EDITOR_TOOLS = {
|
||||
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
|
||||
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
|
||||
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
|
||||
} as const;
|
||||
|
||||
export type EditorToolId = keyof typeof EDITOR_TOOLS;
|
||||
|
||||
type TransactionState = {
|
||||
baselineProject: Project;
|
||||
baselineSelectedLayerId: string | null;
|
||||
};
|
||||
|
||||
type EditorState = {
|
||||
project: Project;
|
||||
selectedLayerId: string | null;
|
||||
tool: EditorToolId;
|
||||
history: HistoryState;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
clipboardLayer: Layer | null;
|
||||
isDirty: boolean;
|
||||
transaction: TransactionState | null;
|
||||
tools: typeof EDITOR_TOOLS;
|
||||
startTransaction: () => void;
|
||||
commitTransaction: () => void;
|
||||
cancelTransaction: () => void;
|
||||
applyProjectDraft: (project: Project) => void;
|
||||
setTool: (tool: EditorToolId) => void;
|
||||
addLayerByType: (type: Layer["type"]) => void;
|
||||
setSelectedLayerPosition: (x: number, y: number) => void;
|
||||
setSelectedLayerPositionDraft: (x: number, y: number) => void;
|
||||
setSelectedLayerSize: (width: number, height: number) => void;
|
||||
setSelectedLayerSizeDraft: (width: number, height: number) => void;
|
||||
setSelectedLayerRotation: (rotation: number) => void;
|
||||
setSelectedLayerRotationDraft: (rotation: number) => void;
|
||||
removeSelectedLayer: () => void;
|
||||
moveSelectedLayerOrder: (direction: "up" | "down") => void;
|
||||
selectLayer: (layerId: string | null) => void;
|
||||
copySelectedLayer: () => void;
|
||||
cutSelectedLayer: () => void;
|
||||
pasteLayer: () => void;
|
||||
resetProject: () => void;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: Project) => void;
|
||||
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
|
||||
importImageFromFile: (file: File) => Promise<void>;
|
||||
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void;
|
||||
setCanvasSize: (width: number, height: number) => void;
|
||||
exportProjectToJson: () => string;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
jumpToPast: (idx: number) => void;
|
||||
jumpToFuture: (idx: number) => void;
|
||||
};
|
||||
|
||||
const initialProject = createProject("Untitled Project");
|
||||
|
||||
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 {
|
||||
project: nextProject,
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(nextProject, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
...extras,
|
||||
} satisfies Partial<EditorState>;
|
||||
}
|
||||
|
||||
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
|
||||
const history = makeHistory(project);
|
||||
return {
|
||||
project,
|
||||
selectedLayerId: resolveSelectedLayerId(project, previousSelectedLayerId),
|
||||
history,
|
||||
...computeHistoryFlags(history),
|
||||
clipboardLayer: null,
|
||||
transaction: null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getProjectAssetIds(project: Project): string[] {
|
||||
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
project: initialProject,
|
||||
selectedLayerId: null,
|
||||
tool: "pointer",
|
||||
history: makeHistory(initialProject),
|
||||
...computeHistoryFlags(makeHistory(initialProject)),
|
||||
clipboardLayer: null,
|
||||
isDirty: false,
|
||||
transaction: null,
|
||||
tools: EDITOR_TOOLS,
|
||||
|
||||
startTransaction: () =>
|
||||
set((state) => {
|
||||
if (state.transaction) return state;
|
||||
return {
|
||||
transaction: {
|
||||
baselineProject: cloneProject(state.project),
|
||||
baselineSelectedLayerId: state.selectedLayerId,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
commitTransaction: () =>
|
||||
set((state) => {
|
||||
if (!state.transaction) return state;
|
||||
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: [] };
|
||||
return {
|
||||
history,
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
cancelTransaction: () =>
|
||||
set((state) => {
|
||||
if (!state.transaction) return state;
|
||||
return {
|
||||
project: cloneProject(state.transaction.baselineProject),
|
||||
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
|
||||
transaction: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyProjectDraft: (project) => set(() => ({ project })),
|
||||
|
||||
setTool: (tool) => {
|
||||
if (!(tool in EDITOR_TOOLS)) return;
|
||||
set({ tool });
|
||||
},
|
||||
|
||||
addLayerByType: (type) =>
|
||||
set((state) => {
|
||||
const layer = createLayer(type);
|
||||
const nextProject = addLayer(state.project, layer);
|
||||
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);
|
||||
if (committed && committed.x === x && committed.y === y) return state;
|
||||
const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setSelectedLayerPositionDraft: (x, y) =>
|
||||
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);
|
||||
if (current && current.x === x && current.y === y) return state;
|
||||
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 }));
|
||||
}),
|
||||
|
||||
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 nextWidth = Math.max(MIN_LAYER_SIZE, width);
|
||||
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
||||
|
||||
if (current) {
|
||||
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined);
|
||||
|
||||
if (
|
||||
typeof currentWidth === "number" &&
|
||||
typeof currentHeight === "number" &&
|
||||
Math.abs(currentWidth - nextWidth) < DRAFT_TRANSFORM_EPSILON &&
|
||||
Math.abs(currentHeight - nextHeight) < DRAFT_TRANSFORM_EPSILON
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
|
||||
}),
|
||||
|
||||
removeSelectedLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
moveSelectedLayerOrder: (direction) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
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));
|
||||
}),
|
||||
|
||||
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);
|
||||
if (committed && committed.rotation === rotation) return state;
|
||||
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);
|
||||
if (current && current.rotation === rotation) return state;
|
||||
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);
|
||||
if (!layer) return state;
|
||||
return { clipboardLayer: cloneLayer(layer) };
|
||||
}),
|
||||
|
||||
cutSelectedLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
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) });
|
||||
}),
|
||||
|
||||
pasteLayer: () =>
|
||||
set((state) => {
|
||||
if (!state.clipboardLayer) return state;
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
const nextProject = addLayer(state.project, pasted);
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: pasted.id });
|
||||
}),
|
||||
|
||||
resetProject: () => {
|
||||
releaseProjectObjectUrls(get().project);
|
||||
const project = createProject("Untitled Project");
|
||||
const history = makeHistory(project);
|
||||
set({
|
||||
project,
|
||||
selectedLayerId: null,
|
||||
history,
|
||||
...computeHistoryFlags(history),
|
||||
clipboardLayer: null,
|
||||
transaction: null,
|
||||
isDirty: false,
|
||||
});
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
await upsertProject(normalizeProject(get().project));
|
||||
set({ isDirty: false });
|
||||
},
|
||||
|
||||
loadProjectById: async (projectId) => {
|
||||
const project = await getProjectById(projectId);
|
||||
if (!project) return false;
|
||||
const normalized = normalizeProject(project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
return true;
|
||||
},
|
||||
|
||||
setProject: (project) => {
|
||||
const normalized = normalizeProject(project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
},
|
||||
|
||||
importProjectFromJson: (raw) => {
|
||||
const parsed = parseProjectFile(raw);
|
||||
if (!parsed.ok) return { ok: false, error: parsed.error };
|
||||
const normalized = normalizeProject(parsed.project);
|
||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
exportProjectToJson: () => {
|
||||
const project = normalizeProject(get().project);
|
||||
const history = get().history;
|
||||
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.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 layer = createLayer("image", {
|
||||
name,
|
||||
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((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id }));
|
||||
},
|
||||
|
||||
updateImageLayerSource: (layerId, sourceUri) =>
|
||||
set((state) => {
|
||||
if (!sourceUri) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
if (layer.sourceUri === sourceUri) return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setImageLayerFaceBlur: (layerId, faceBlur) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
|
||||
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
if (state.history.past.length === 0) return state;
|
||||
const past = [...state.history.past];
|
||||
const previous = past.pop();
|
||||
if (!previous) return state;
|
||||
const future = [state.history.present, ...state.history.future];
|
||||
const history = { past, present: cloneProject(previous), future };
|
||||
return {
|
||||
project: cloneProject(previous),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
if (state.history.future.length === 0) return state;
|
||||
const future = [...state.history.future];
|
||||
const next = future.shift();
|
||||
if (!next) return state;
|
||||
const past = capHistory([...state.history.past, state.history.present]);
|
||||
const history = { past, present: cloneProject(next), future };
|
||||
return {
|
||||
project: cloneProject(next),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
jumpToPast: (idx) =>
|
||||
set((state) => {
|
||||
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 history = { past, present: cloneProject(previous), future };
|
||||
return {
|
||||
project: cloneProject(previous),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
jumpToFuture: (idx) =>
|
||||
set((state) => {
|
||||
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 history = { past, present: cloneProject(next), future };
|
||||
return {
|
||||
project: cloneProject(next),
|
||||
history,
|
||||
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
|
||||
transaction: null,
|
||||
...computeHistoryFlags(history),
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useUiStore } from "./ui-store";
|
||||
|
||||
describe("ui store", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
useUiStore.setState({ theme: "dark", locale: "en" });
|
||||
});
|
||||
|
||||
it("hydrates from local storage", () => {
|
||||
window.localStorage.setItem("pien.ui.theme", "light");
|
||||
window.localStorage.setItem("pien.ui.locale", "ja");
|
||||
useUiStore.getState().hydrate();
|
||||
expect(useUiStore.getState().theme).toBe("light");
|
||||
expect(useUiStore.getState().locale).toBe("ja");
|
||||
});
|
||||
|
||||
it("updates and persists theme", () => {
|
||||
useUiStore.getState().setTheme("light");
|
||||
expect(useUiStore.getState().theme).toBe("light");
|
||||
expect(window.localStorage.getItem("pien.ui.theme")).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("updates and persists locale", () => {
|
||||
useUiStore.getState().setLocale("th");
|
||||
expect(useUiStore.getState().locale).toBe("th");
|
||||
expect(window.localStorage.getItem("pien.ui.locale")).toBe("th");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type AppTheme = "light" | "dark";
|
||||
export type AppLocale = "en" | "th" | "ja";
|
||||
|
||||
const THEME_KEY = "pien.ui.theme";
|
||||
const LOCALE_KEY = "pien.ui.locale";
|
||||
|
||||
function readTheme(): AppTheme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const value = window.localStorage.getItem(THEME_KEY);
|
||||
return value === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function readLocale(): AppLocale {
|
||||
if (typeof window === "undefined") return "en";
|
||||
const value = window.localStorage.getItem(LOCALE_KEY);
|
||||
if (value === "th" || value === "ja" || value === "en") return value;
|
||||
return "en";
|
||||
}
|
||||
|
||||
function applyTheme(theme: AppTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
type UiState = {
|
||||
theme: AppTheme;
|
||||
locale: AppLocale;
|
||||
hydrate: () => void;
|
||||
setTheme: (theme: AppTheme) => void;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
};
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
theme: "light",
|
||||
locale: "en",
|
||||
hydrate: () => {
|
||||
const theme = readTheme();
|
||||
const locale = readLocale();
|
||||
applyTheme(theme);
|
||||
set((state) => {
|
||||
if (state.theme === theme && state.locale === locale) return state;
|
||||
return { theme, locale };
|
||||
});
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
}
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
setLocale: (locale) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(LOCALE_KEY, locale);
|
||||
}
|
||||
set({ locale });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
cream: "var(--color-cream)",
|
||||
berry: "var(--color-berry)",
|
||||
wine: "var(--color-wine)",
|
||||
mint: "var(--color-mint)",
|
||||
ink: "var(--color-ink)",
|
||||
accent: "var(--color-accent)",
|
||||
"accent-strong": "var(--color-accent-strong)",
|
||||
surface: "var(--color-surface)",
|
||||
"surface-2": "var(--color-surface-2)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["'M PLUS Rounded 1c'", "'Noto Sans Thai'", "sans-serif"],
|
||||
body: ["'Zen Kaku Gothic New'", "'Noto Sans Thai'", "sans-serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
float: "0 20px 55px rgba(48, 13, 22, 0.18)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["**/*.test.ts", "**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,976 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "pien",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"turbo": "^2.9.9",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5",
|
||||
},
|
||||
},
|
||||
"apps/api": {
|
||||
"name": "@pien-studio/api",
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"elysia": "^1.1.25",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5",
|
||||
},
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@pien-studio/web",
|
||||
"dependencies": {
|
||||
"@mediapipe/face_detection": "~0.4.0",
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/storage": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tensorflow-models/face-detection": "^1.0.3",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-cpu": "^4.22.0",
|
||||
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
|
||||
"@tensorflow/tfjs-converter": "^4.22.0",
|
||||
"@tensorflow/tfjs-core": "^4.22.0",
|
||||
"@vladmandic/face-api": "^1.7.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^16.2.4",
|
||||
"next-intl": "^4.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"zustand": "^5.0.13",
|
||||
},
|
||||
},
|
||||
"packages/config": {
|
||||
"name": "@pien-studio/config",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
"packages/editor-core": {
|
||||
"name": "@pien-studio/editor-core",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/storage": {
|
||||
"name": "@pien-studio/storage",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"fake-indexeddb": "^6.2.2",
|
||||
"vitest": "^4.1.5",
|
||||
},
|
||||
},
|
||||
"packages/types": {
|
||||
"name": "@pien-studio/types",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@pien-studio/ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
|
||||
|
||||
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.4", "", {}, "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.7", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.7" } }, "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.7", "", {}, "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.6", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.4" } }, "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@mediapipe/face_detection": ["@mediapipe/face_detection@0.4.1646425229", "", {}, "sha512-aeCN+fRAojv9ch3NXorP6r5tcGVLR3/gC1HmtqB0WEZBRXrdP6/3W/sGR0dHr1iT6ueiK95G9PVjbzFosf/hrg=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||
|
||||
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
|
||||
|
||||
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
|
||||
|
||||
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
|
||||
|
||||
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
|
||||
|
||||
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pien-studio/api": ["@pien-studio/api@workspace:apps/api"],
|
||||
|
||||
"@pien-studio/config": ["@pien-studio/config@workspace:packages/config"],
|
||||
|
||||
"@pien-studio/editor-core": ["@pien-studio/editor-core@workspace:packages/editor-core"],
|
||||
|
||||
"@pien-studio/storage": ["@pien-studio/storage@workspace:packages/storage"],
|
||||
|
||||
"@pien-studio/types": ["@pien-studio/types@workspace:packages/types"],
|
||||
|
||||
"@pien-studio/ui": ["@pien-studio/ui@workspace:packages/ui"],
|
||||
|
||||
"@pien-studio/web": ["@pien-studio/web@workspace:apps/web"],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
|
||||
|
||||
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.15.33", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.33", "@swc/core-darwin-x64": "1.15.33", "@swc/core-linux-arm-gnueabihf": "1.15.33", "@swc/core-linux-arm64-gnu": "1.15.33", "@swc/core-linux-arm64-musl": "1.15.33", "@swc/core-linux-ppc64-gnu": "1.15.33", "@swc/core-linux-s390x-gnu": "1.15.33", "@swc/core-linux-x64-gnu": "1.15.33", "@swc/core-linux-x64-musl": "1.15.33", "@swc/core-win32-arm64-msvc": "1.15.33", "@swc/core-win32-ia32-msvc": "1.15.33", "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA=="],
|
||||
|
||||
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA=="],
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.33", "", { "os": "linux", "cpu": "arm" }, "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ=="],
|
||||
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw=="],
|
||||
|
||||
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og=="],
|
||||
|
||||
"@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.33", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog=="],
|
||||
|
||||
"@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.33", "", { "os": "linux", "cpu": "s390x" }, "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA=="],
|
||||
|
||||
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw=="],
|
||||
|
||||
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ=="],
|
||||
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g=="],
|
||||
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.33", "", { "os": "win32", "cpu": "ia32" }, "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ=="],
|
||||
|
||||
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.33", "", { "os": "win32", "cpu": "x64" }, "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="],
|
||||
|
||||
"@tensorflow-models/face-detection": ["@tensorflow-models/face-detection@1.0.3", "", { "dependencies": { "rimraf": "^3.0.2", "tslib": "2.4.0" }, "peerDependencies": { "@mediapipe/face_detection": "~0.4.0", "@tensorflow/tfjs-backend-webgl": "^4.21.0", "@tensorflow/tfjs-converter": "^4.21.0", "@tensorflow/tfjs-core": "^4.21.0" } }, "sha512-4Ld/vFF8MrdFdrMWhlLKZD4hMW0PNY9OkYeqoCPNZ+LwFyenxAqVaNaWrR8JKp37vw9Nuzp4ILbkal5zPUnA0g=="],
|
||||
|
||||
"@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="],
|
||||
|
||||
"@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="],
|
||||
|
||||
"@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="],
|
||||
|
||||
"@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="],
|
||||
|
||||
"@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="],
|
||||
|
||||
"@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="],
|
||||
|
||||
"@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-hTEiNu2ABZZOO1qbjnKASI8eF3BdOOzU6iKv5w5uGOK65DDMc10cS40N1kqM99YT0uSAGUwNu6GdFctRPeEeVA=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MinO40EEcP5mJiTVpfjtEulsEBhVeryfq21QhYtJZ8hQJLHGgy459rcmDVAY8/JERe4dkVU4KW+zoLF22o01EA=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.9", "", { "os": "linux", "cpu": "x64" }, "sha512-7JNLw88Isk+gMlbsC8pulLDkrqe2B827ZsKFEHilb17AC6Xn/62pzH7afjY7fEU6Ayp4XP/vGhlRWOzqBvBvIQ=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0pnXDwPw1rHii98JZPRg7SvsjIzy7jrhkwGU9Jy5fVYoMdYd3P2vbtLfII+OJ0Mm4Ar5yykdHDTz3RWiRI1o9g=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.9", "", { "os": "win32", "cpu": "x64" }, "sha512-vjDQycz4gQVvIq4n2rPtiiIESwJlAc406qtkiZlqyL+fHZEd9SxYNlBIFYtc5cuMuwrk+sIKrhN7XvwjmvS9YQ=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-V6NiH43oCctepbOdQFp7UjqLyK8p6Tt824QA+G4TE+B1BBHu80A0W8OCL+H7uBJ3XZjAj/hvPDw3k3l65DoDGw=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
|
||||
|
||||
"@vladmandic/face-api": ["@vladmandic/face-api@1.7.15", "", {}, "sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw=="],
|
||||
|
||||
"@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"icu-minify": ["icu-minify@4.11.1", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-C0tsPVuvyNp+++qWJP+mty/KLLStjerOZqu3W1xWLJkChEDbDi9Taoj6blK7L/onxbuVzwgH6k9Sf+rOV6lOvw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@11.2.4", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.4", "@formatjs/icu-messageformat-parser": "3.5.7" } }, "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="],
|
||||
|
||||
"next-intl": ["next-intl@4.11.1", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", "icu-minify": "^4.11.1", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.11.1", "po-parser": "^2.1.1", "use-intl": "^4.11.1" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-s32lFFLXkxrW6fy+4IVaGD5J8xPpbEDFLfBbXV73CTbHAGhOGMjYN4/rftdsKOQ44AnPhnZ5Et+ZNMr5tRpsqA=="],
|
||||
|
||||
"next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.11.1", "", {}, "sha512-jHKGij7NoYccy2y54+e/wHVMoRgNt4h/Kn0XS9c4GbKu3KgJyANLUN8sFcDixv6sqz4V2kh6CTWgrkIidQksUg=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"po-parser": ["po-parser@2.1.1", "", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="],
|
||||
|
||||
"postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="],
|
||||
|
||||
"turbo": ["turbo@2.9.9", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.9", "@turbo/darwin-arm64": "2.9.9", "@turbo/linux-64": "2.9.9", "@turbo/linux-arm64": "2.9.9", "@turbo/windows-64": "2.9.9", "@turbo/windows-arm64": "2.9.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3xfzXE/yTjhh0S5dIWlE+3E+J9A09REpLI1ZqVh2+HrNZoVzZn0pkvjiRgVK/Ev3PF9XnaTwCntTx+CADWXcyA=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-intl": ["use-intl@4.11.1", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.11.1", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-/dqWSqUSbVMzC+fdy7io8enhGYHeGeHK1bFhTLrp0ZblqdzY4FkE+tkffW6IfCauqaIA2/z4DQae4XEn93+raw=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
|
||||
|
||||
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
|
||||
|
||||
"aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
|
||||
|
||||
"node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"vite/postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[install]
|
||||
minimumReleaseAge = 259200
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "pien-studio",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.1.38",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"test:unit": "turbo run test",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"turbo": "^2.9.9",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@pien-studio/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pien-studio/editor-core",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addLayer,
|
||||
createProject,
|
||||
moveLayer,
|
||||
reorderLayer,
|
||||
updateLayerTransform,
|
||||
} from "./index";
|
||||
|
||||
describe("editor-core", () => {
|
||||
it("creates project with defaults", () => {
|
||||
const project = createProject("new");
|
||||
expect(project.title).toBe("new");
|
||||
expect(project.aspectRatio).toBe("4:5");
|
||||
expect(project.layers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adds a new layer and updates timestamp", () => {
|
||||
const project = createProject("new");
|
||||
const updated = addLayer(project, {
|
||||
id: "l1",
|
||||
type: "image",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
expect(updated.layers).toHaveLength(1);
|
||||
expect(updated.layers[0]?.id).toBe("l1");
|
||||
expect(updated.updatedAt >= project.updatedAt).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a layer by delta", () => {
|
||||
const project = addLayer(createProject("move"), {
|
||||
id: "l1",
|
||||
type: "sticker",
|
||||
x: 10,
|
||||
y: 20,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
||||
expect(moved.layers[0]?.x).toBe(25);
|
||||
expect(moved.layers[0]?.y).toBe(15);
|
||||
});
|
||||
|
||||
it("updates transform fields", () => {
|
||||
const project = addLayer(createProject("transform"), {
|
||||
id: "l1",
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
|
||||
expect(updated.layers[0]?.scale).toBe(1.35);
|
||||
expect(updated.layers[0]?.rotation).toBe(22);
|
||||
});
|
||||
|
||||
it("reorders layer to the front", () => {
|
||||
const base = createProject("reorder");
|
||||
const withFirst = addLayer(base, {
|
||||
id: "l1",
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
const withSecond = addLayer(withFirst, {
|
||||
id: "l2",
|
||||
type: "sticker",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const reordered = reorderLayer(withSecond, "l1", 1);
|
||||
expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
PRESET_CANVAS_SIZES,
|
||||
ProjectFileSchema,
|
||||
type AspectRatio,
|
||||
type FaceBlurSettings,
|
||||
type Layer,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
} from "@pien-studio/types";
|
||||
|
||||
const DEFAULT_ASPECT: AspectRatio = "4:5";
|
||||
|
||||
function defaultCanvas(aspect: AspectRatio) {
|
||||
const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1];
|
||||
return { width: preset.width, height: preset.height, unit: "px" as const };
|
||||
}
|
||||
|
||||
export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
canvas: defaultCanvas(aspect),
|
||||
aspectRatio: aspect,
|
||||
layers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function withUpdatedTimestamp(project: Project, layers: Layer[]): Project {
|
||||
return { ...project, layers, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
type Delta = { dx: number; dy: number };
|
||||
export type TransformPatch = {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
sourceUri?: string;
|
||||
faceBlur?: FaceBlurSettings;
|
||||
};
|
||||
|
||||
export type LayerFactoryOptions = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
sourceUri?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export type EditorOperation =
|
||||
| { type: "addLayer"; layer: Layer }
|
||||
| { type: "removeLayer"; layerId: string }
|
||||
| { type: "moveLayer"; layerId: string; delta: Delta }
|
||||
| { type: "updateLayerTransform"; layerId: string; patch: TransformPatch }
|
||||
| { type: "reorderLayer"; layerId: string; toIndex: number }
|
||||
| { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" };
|
||||
|
||||
export function normalizeProject(project: Project): Project {
|
||||
const normalizedLayers = project.layers.map((layer) => ({
|
||||
...layer,
|
||||
width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined,
|
||||
height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined,
|
||||
scale: Number.isFinite(layer.scale) ? layer.scale : 1,
|
||||
rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0,
|
||||
opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1,
|
||||
faceBlur:
|
||||
layer.faceBlur && Array.isArray(layer.faceBlur.regions)
|
||||
? {
|
||||
method: layer.faceBlur.method,
|
||||
amount: Math.max(4, Math.min(40, Math.round(layer.faceBlur.amount))),
|
||||
regions: layer.faceBlur.regions.map((region) => ({
|
||||
x: Number.isFinite(region.x) ? region.x : 0,
|
||||
y: Number.isFinite(region.y) ? region.y : 0,
|
||||
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
|
||||
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
|
||||
sourceWidth: Number.isFinite(region.sourceWidth) ? region.sourceWidth : undefined,
|
||||
sourceHeight: Number.isFinite(region.sourceHeight) ? region.sourceHeight : undefined,
|
||||
censorColor: region.censorColor,
|
||||
})),
|
||||
censorColor: layer.faceBlur.censorColor,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
...project,
|
||||
canvas: {
|
||||
width: Math.max(1, Math.round(project.canvas.width)),
|
||||
height: Math.max(1, Math.round(project.canvas.height)),
|
||||
unit: project.canvas.unit,
|
||||
},
|
||||
layers: normalizedLayers,
|
||||
};
|
||||
}
|
||||
|
||||
export function addLayer(project: Project, layer: Layer): Project {
|
||||
return withUpdatedTimestamp(project, [...project.layers, layer]);
|
||||
}
|
||||
|
||||
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
|
||||
return {
|
||||
id: options.id ?? crypto.randomUUID(),
|
||||
type,
|
||||
name: options.name,
|
||||
sourceUri: options.sourceUri,
|
||||
x: options.x ?? 110,
|
||||
y: options.y ?? 90,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeLayer(project: Project, layerId: string): Project {
|
||||
const layers = project.layers.filter((layer) => layer.id !== layerId);
|
||||
return withUpdatedTimestamp(project, layers);
|
||||
}
|
||||
|
||||
export function moveLayer(project: Project, layerId: string, delta: Delta): Project {
|
||||
const layers = project.layers.map((layer) => {
|
||||
if (layer.id !== layerId) return layer;
|
||||
return { ...layer, x: layer.x + delta.dx, y: layer.y + delta.dy };
|
||||
});
|
||||
return withUpdatedTimestamp(project, layers);
|
||||
}
|
||||
|
||||
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
|
||||
const layers = project.layers.map((layer) => (layer.id === layerId ? { ...layer, ...patch } : layer));
|
||||
return withUpdatedTimestamp(project, layers);
|
||||
}
|
||||
|
||||
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
|
||||
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
|
||||
if (fromIndex < 0) return project;
|
||||
const layers = [...project.layers];
|
||||
const [picked] = layers.splice(fromIndex, 1);
|
||||
if (!picked) return project;
|
||||
const bounded = Math.max(0, Math.min(toIndex, layers.length));
|
||||
layers.splice(bounded, 0, picked);
|
||||
return withUpdatedTimestamp(project, layers);
|
||||
}
|
||||
|
||||
export function setCanvasSize(project: Project, width: number, height: number, unit: "px" | "in" | "cm" = "px"): Project {
|
||||
return {
|
||||
...project,
|
||||
canvas: {
|
||||
width: Math.max(1, Math.round(width)),
|
||||
height: Math.max(1, Math.round(height)),
|
||||
unit,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOperation(project: Project, operation: EditorOperation): Project {
|
||||
switch (operation.type) {
|
||||
case "addLayer":
|
||||
return addLayer(project, operation.layer);
|
||||
case "removeLayer":
|
||||
return removeLayer(project, operation.layerId);
|
||||
case "moveLayer":
|
||||
return moveLayer(project, operation.layerId, operation.delta);
|
||||
case "updateLayerTransform":
|
||||
return updateLayerTransform(project, operation.layerId, operation.patch);
|
||||
case "reorderLayer":
|
||||
return reorderLayer(project, operation.layerId, operation.toIndex);
|
||||
case "setCanvasSize":
|
||||
return setCanvasSize(project, operation.width, operation.height, operation.unit);
|
||||
default:
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
|
||||
const document: ProjectFile = {
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project,
|
||||
assets: [],
|
||||
history: {
|
||||
checkpointCount: options?.checkpointCount ?? 0,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(document, null, 2);
|
||||
}
|
||||
|
||||
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown;
|
||||
const parsedEnvelope = ProjectFileSchema.safeParse(data);
|
||||
if (parsedEnvelope.success) {
|
||||
return { ok: true, project: normalizeProject(parsedEnvelope.data.project) };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Invalid project format" };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid JSON" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@pien-studio/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fake-indexeddb": "^6.2.2",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { getProjectById, loadProjects } from "./index";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
const DB_VERSION = 4;
|
||||
const PROJECTS_STORE = "projects";
|
||||
const ASSETS_STORE = "assets";
|
||||
const ASSETS_BY_HASH_INDEX = "byHash";
|
||||
const ASSET_LINKS_STORE = "assetLinks";
|
||||
const LINKS_BY_PROJECT_INDEX = "byProjectId";
|
||||
const LINKS_BY_ASSET_INDEX = "byAssetId";
|
||||
|
||||
function ensureSchema(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains(PROJECTS_STORE)) {
|
||||
db.createObjectStore(PROJECTS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
||||
}
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function seedProject(project: Project): Promise<void> {
|
||||
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||
const db = await requestToPromise(openRequest);
|
||||
const tx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(tx.objectStore(PROJECTS_STORE).put(project));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readRawProject(projectId: string): Promise<Project | undefined> {
|
||||
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||
const db = await requestToPromise(openRequest);
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = (await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId))) as Project | undefined;
|
||||
db.close();
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeMalformedProject(id: string): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
title: "seed",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1200, height: 1200, unit: "px" },
|
||||
layers: [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 10.4,
|
||||
height: 11.6,
|
||||
scale: 0,
|
||||
rotation: 720.4,
|
||||
opacity: 0.75,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeLegacyFaceBlurProject(id: string): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
title: "legacy",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1200, height: 1500, unit: "px" },
|
||||
layers: [
|
||||
{
|
||||
id: "image-legacy",
|
||||
type: "image",
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 600,
|
||||
height: 800,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
faceBlur: {
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: [{ x: 120, y: 160, width: 180, height: 200 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage read flows", () => {
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("loadProjects hydrates normalized values without persisting writes", async () => {
|
||||
const source = makeMalformedProject("project-load");
|
||||
await seedProject(source);
|
||||
|
||||
const byId = await getProjectById(source.id);
|
||||
expect(byId?.canvas.height).toBe(1200);
|
||||
expect(byId?.layers[0]?.height).toBe(12);
|
||||
|
||||
const rawAfterById = await readRawProject(source.id);
|
||||
expect(rawAfterById?.layers[0]?.height).toBe(11.6);
|
||||
|
||||
const loaded = await loadProjects();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]?.canvas.width).toBe(1200);
|
||||
expect(loaded[0]?.layers[0]?.width).toBe(10);
|
||||
expect(loaded[0]?.layers[0]?.height).toBe(12);
|
||||
|
||||
const raw = await readRawProject(source.id);
|
||||
expect(raw?.canvas.width).toBe(1200);
|
||||
expect(raw?.layers[0]?.width).toBe(10.4);
|
||||
expect(raw?.layers[0]?.height).toBe(11.6);
|
||||
});
|
||||
|
||||
it("loads legacy projects without source dimensions through storage and normalize flow", async () => {
|
||||
const source = makeLegacyFaceBlurProject("project-legacy-faceblur");
|
||||
await seedProject(source);
|
||||
|
||||
const loaded = await getProjectById(source.id);
|
||||
const region = loaded?.layers[0]?.faceBlur?.regions[0];
|
||||
expect(region).toBeDefined();
|
||||
expect(region?.x).toBe(120);
|
||||
expect(region?.sourceWidth).toBeUndefined();
|
||||
expect(region?.sourceHeight).toBeUndefined();
|
||||
|
||||
const listed = await loadProjects();
|
||||
const listedRegion = listed[0]?.layers[0]?.faceBlur?.regions[0];
|
||||
expect(listedRegion?.width).toBe(180);
|
||||
expect(listedRegion?.sourceWidth).toBeUndefined();
|
||||
expect(listedRegion?.sourceHeight).toBeUndefined();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { normalizeProject } from "@pien-studio/editor-core";
|
||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
const DB_VERSION = 4;
|
||||
const PROJECTS_STORE = "projects";
|
||||
const ASSETS_STORE = "assets";
|
||||
const ASSETS_BY_HASH_INDEX = "byHash";
|
||||
const ASSET_LINKS_STORE = "assetLinks";
|
||||
const LINKS_BY_PROJECT_INDEX = "byProjectId";
|
||||
const LINKS_BY_ASSET_INDEX = "byAssetId";
|
||||
|
||||
type AssetRecord = {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AssetLinkRecord = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
layerId: string;
|
||||
assetId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const objectUrlByAssetId = new Map<string, string>();
|
||||
|
||||
function getIndexedDb(): IDBFactory | null {
|
||||
if (typeof indexedDB === "undefined") return null;
|
||||
return indexedDB;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase | null> {
|
||||
const idb = getIndexedDb();
|
||||
if (!idb) return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = idb.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(PROJECTS_STORE)) {
|
||||
db.createObjectStore(PROJECTS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
||||
} else {
|
||||
const tx = request.transaction;
|
||||
if (tx) {
|
||||
const assetsStore = tx.objectStore(ASSETS_STORE);
|
||||
if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) {
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const response = await fetch(dataUrl);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function hashBlob(blob: Blob): Promise<string> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
if (typeof crypto !== "undefined" && crypto.subtle) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
||||
}
|
||||
|
||||
function makeLinkId(projectId: string, layerId: string): string {
|
||||
return `${projectId}:${layerId}`;
|
||||
}
|
||||
|
||||
function isBinaryLayer(layer: Layer): boolean {
|
||||
return layer.type === "image" || layer.type === "sticker";
|
||||
}
|
||||
|
||||
function inferMimeType(layer: Layer): string {
|
||||
if (layer.sourceUri?.startsWith("data:image/png")) return "image/png";
|
||||
if (layer.sourceUri?.startsWith("data:image/webp")) return "image/webp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
||||
|
||||
if (layer.sourceUri?.startsWith("data:image/")) {
|
||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
||||
const hash = await hashBlob(blob);
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === blob.size);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
blob,
|
||||
hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
return layer.assetId;
|
||||
}
|
||||
|
||||
type PreparedLayerAsset = {
|
||||
layerIndex: number;
|
||||
blob: Blob;
|
||||
hash: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]> {
|
||||
const prepared: PreparedLayerAsset[] = [];
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer || !isBinaryLayer(layer) || !layer.sourceUri?.startsWith("data:image/")) continue;
|
||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
||||
const hash = await hashBlob(blob);
|
||||
prepared.push({
|
||||
layerIndex: index,
|
||||
blob,
|
||||
hash,
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<Set<string>> {
|
||||
const tx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||
const store = tx.objectStore(ASSET_LINKS_STORE);
|
||||
const byProject = store.index(LINKS_BY_PROJECT_INDEX);
|
||||
const existing = (await requestToPromise(byProject.getAll(project.id))) as AssetLinkRecord[];
|
||||
const existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const layer of project.layers) {
|
||||
if (!layer.assetId || !isBinaryLayer(layer)) continue;
|
||||
const id = makeLinkId(project.id, layer.id);
|
||||
referenced.add(layer.assetId);
|
||||
await requestToPromise(
|
||||
store.put({
|
||||
id,
|
||||
projectId: project.id,
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId,
|
||||
updatedAt: now,
|
||||
} satisfies AssetLinkRecord),
|
||||
);
|
||||
existingMap.delete(id);
|
||||
}
|
||||
|
||||
const removedAssetIds = new Set<string>();
|
||||
for (const stale of existingMap.values()) {
|
||||
removedAssetIds.add(stale.assetId);
|
||||
await requestToPromise(store.delete(stale.id));
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
|
||||
for (const id of removedAssetIds) referenced.add(id);
|
||||
return referenced;
|
||||
}
|
||||
|
||||
async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Iterable<string>): Promise<number> {
|
||||
const assetsTx = db.transaction([ASSETS_STORE, ASSET_LINKS_STORE], "readwrite");
|
||||
const assetsStore = assetsTx.objectStore(ASSETS_STORE);
|
||||
const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE);
|
||||
const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX);
|
||||
|
||||
const candidates = candidateAssetIds
|
||||
? Array.from(new Set(candidateAssetIds)).filter(Boolean)
|
||||
: ((await requestToPromise(assetsStore.getAllKeys())) as string[]);
|
||||
|
||||
let removed = 0;
|
||||
for (const assetId of candidates) {
|
||||
const links = (await requestToPromise(byAsset.getAll(assetId))) as AssetLinkRecord[];
|
||||
if (links.length > 0) continue;
|
||||
await requestToPromise(assetsStore.delete(assetId));
|
||||
const url = objectUrlByAssetId.get(assetId);
|
||||
if (url?.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(assetId);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
assetsTx.oncomplete = () => resolve();
|
||||
assetsTx.onerror = () => reject(assetsTx.error);
|
||||
assetsTx.onabort = () => reject(assetsTx.error);
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function persistProject(db: IDBDatabase, project: Project): Promise<Project> {
|
||||
const normalized = normalizeProject(project);
|
||||
const preparedAssets = await prepareLayerAssets(normalized.layers);
|
||||
const assetTx = db.transaction(ASSETS_STORE, "readwrite");
|
||||
const assetStore = assetTx.objectStore(ASSETS_STORE);
|
||||
const layers = [...normalized.layers];
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
|
||||
for (const prepared of preparedAssets) {
|
||||
const layer = layers[prepared.layerIndex];
|
||||
if (!layer) continue;
|
||||
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === prepared.blob.size);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: prepared.mimeType,
|
||||
blob: prepared.blob,
|
||||
hash: prepared.hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
|
||||
layers[prepared.layerIndex] = {
|
||||
...layer,
|
||||
assetId: id,
|
||||
sourceUri: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer || !isBinaryLayer(layer)) continue;
|
||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||
if (!assetId) continue;
|
||||
layers[index] = {
|
||||
...layer,
|
||||
assetId,
|
||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
assetTx.oncomplete = () => resolve();
|
||||
assetTx.onerror = () => reject(assetTx.error);
|
||||
assetTx.onabort = () => reject(assetTx.error);
|
||||
});
|
||||
|
||||
const persisted = { ...normalized, layers };
|
||||
const candidateAssets = await syncLinksForProject(db, persisted);
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).put(persisted));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
projectTx.oncomplete = () => resolve();
|
||||
projectTx.onerror = () => reject(projectTx.error);
|
||||
projectTx.onabort = () => reject(projectTx.error);
|
||||
});
|
||||
|
||||
await cleanupOrphansInternal(db, candidateAssets);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
async function hydrateProject(db: IDBDatabase, project: Project): Promise<Project> {
|
||||
const tx = db.transaction(ASSETS_STORE, "readonly");
|
||||
const store = tx.objectStore(ASSETS_STORE);
|
||||
|
||||
const layers = await Promise.all(
|
||||
project.layers.map(async (layer) => {
|
||||
if (!layer.assetId) return layer;
|
||||
const record = (await requestToPromise(store.get(layer.assetId))) as AssetRecord | undefined;
|
||||
if (!record?.blob) return layer;
|
||||
const existing = objectUrlByAssetId.get(layer.assetId);
|
||||
if (existing) return { ...layer, sourceUri: existing };
|
||||
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
|
||||
const objectUrl = URL.createObjectURL(record.blob);
|
||||
objectUrlByAssetId.set(layer.assetId, objectUrl);
|
||||
return { ...layer, sourceUri: objectUrl };
|
||||
}
|
||||
return layer;
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...project, layers };
|
||||
}
|
||||
|
||||
export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterable<string>): void {
|
||||
const keep = keepAssetIds ? new Set(Array.from(keepAssetIds).filter(Boolean)) : null;
|
||||
for (const layer of project.layers) {
|
||||
if (!layer.assetId) continue;
|
||||
if (keep?.has(layer.assetId)) continue;
|
||||
const url = objectUrlByAssetId.get(layer.assetId);
|
||||
if (!url) continue;
|
||||
if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(layer.assetId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOrphanAssets(): Promise<number> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return 0;
|
||||
return cleanupOrphansInternal(db);
|
||||
}
|
||||
|
||||
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const id = window.setInterval(() => {
|
||||
void cleanupOrphanAssets();
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}
|
||||
|
||||
export async function saveProjects(projects: Project[]): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
const existing = await loadProjects();
|
||||
const keep = new Set(projects.map((project) => project.id));
|
||||
for (const project of existing) {
|
||||
if (!keep.has(project.id)) {
|
||||
await deleteProject(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
await persistProject(db, project);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjects(): Promise<Project[]> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return [];
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
|
||||
const parsed = (records as unknown[])
|
||||
.map((record) => ProjectSchema.safeParse(record))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => normalizeProject(result.data));
|
||||
|
||||
const hydrated = await Promise.all(parsed.map((project) => hydrateProject(db, project)));
|
||||
void cleanupOrphansInternal(db);
|
||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return null;
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
||||
const parsed = ProjectSchema.safeParse(record);
|
||||
if (!parsed.success) return null;
|
||||
return hydrateProject(db, normalizeProject(parsed.data));
|
||||
}
|
||||
|
||||
export async function upsertProject(project: Project): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
||||
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
||||
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
|
||||
for (const link of links) {
|
||||
await requestToPromise(linksStore.delete(link.id));
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
linksTx.oncomplete = () => resolve();
|
||||
linksTx.onerror = () => reject(linksTx.error);
|
||||
linksTx.onabort = () => reject(linksTx.error);
|
||||
});
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
||||
}
|
||||
|
||||
export async function duplicateProject(projectId: string): Promise<Project | null> {
|
||||
const source = await getProjectById(projectId);
|
||||
if (!source) return null;
|
||||
const now = new Date().toISOString();
|
||||
const copy: Project = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
title: `${source.title} Copy`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })),
|
||||
};
|
||||
await upsertProject(copy);
|
||||
return getProjectById(copy.id);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pien-studio/types",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DeviceSessionSchema, ProjectFileSchema, ProjectSchema } from "./index";
|
||||
|
||||
describe("types schemas", () => {
|
||||
it("accepts valid device session payload", () => {
|
||||
const result = DeviceSessionSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "ja",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown locale", () => {
|
||||
const result = DeviceSessionSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "fr",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates project shape", () => {
|
||||
const result = ProjectSchema.safeParse({
|
||||
id: "p1",
|
||||
title: "test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1080, height: 1350, unit: "px" },
|
||||
layers: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates versioned project file envelope", () => {
|
||||
const now = new Date().toISOString();
|
||||
const result = ProjectFileSchema.safeParse({
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
exportedAt: now,
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project: {
|
||||
id: "p1",
|
||||
title: "test",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1080, height: 1350, unit: "px" },
|
||||
layers: [],
|
||||
},
|
||||
assets: [],
|
||||
history: { checkpointCount: 0 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LayerTypeSchema = z.enum(["image", "text", "sticker"]);
|
||||
|
||||
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
|
||||
|
||||
export const FaceBlurRegionSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
sourceWidth: z.number().optional(),
|
||||
sourceHeight: z.number().optional(),
|
||||
censorColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FaceBlurSettingsSchema = z.object({
|
||||
method: FaceBlurMethodSchema,
|
||||
amount: z.number().int().min(4).max(40),
|
||||
regions: z.array(FaceBlurRegionSchema),
|
||||
censorColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LayerSchema = z.object({
|
||||
id: z.string(),
|
||||
type: LayerTypeSchema,
|
||||
name: z.string().optional(),
|
||||
assetId: z.string().optional(),
|
||||
sourceUri: z.string().optional(),
|
||||
faceBlur: FaceBlurSettingsSchema.optional(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
scale: z.number().default(1),
|
||||
rotation: z.number().default(0),
|
||||
opacity: z.number().min(0).max(1).default(1),
|
||||
});
|
||||
|
||||
export const AspectRatioSchema = z.enum([
|
||||
"1:1", // square feed
|
||||
"4:5", // portrait 4:5
|
||||
"9:16", // story / vertical
|
||||
"16:9", // widescreen
|
||||
"4:3", // classic photo
|
||||
"3:2", // landscape photo
|
||||
"free", // custom
|
||||
]);
|
||||
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
|
||||
export const CanvasSizeSchema = z.object({
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
unit: z.enum(["px", "in", "cm"]).default("px"),
|
||||
});
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
canvas: CanvasSizeSchema,
|
||||
aspectRatio: AspectRatioSchema,
|
||||
layers: z.array(LayerSchema),
|
||||
});
|
||||
|
||||
export const PresetAspectRatioSchema = z.object({
|
||||
label: z.string(),
|
||||
value: AspectRatioSchema,
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
});
|
||||
|
||||
export type PresetAspectRatio = z.infer<typeof PresetAspectRatioSchema>;
|
||||
|
||||
export const PRESET_CANVAS_SIZES: PresetAspectRatio[] = [
|
||||
{ label: "Square (1:1)", value: "1:1", width: 1080, height: 1080 },
|
||||
{ label: "Portrait (4:5)", value: "4:5", width: 1080, height: 1350 },
|
||||
{ label: "Story (9:16)", value: "9:16", width: 1080, height: 1920 },
|
||||
{ label: "Widescreen (16:9)", value: "16:9", width: 1920, height: 1080 },
|
||||
{ label: "Photo (4:3)", value: "4:3", width: 1440, height: 1080 },
|
||||
{ label: "Classic (3:2)", value: "3:2", width: 1620, height: 1080 },
|
||||
];
|
||||
|
||||
export const DeviceSessionSchema = z.object({
|
||||
deviceId: z.string().min(4),
|
||||
locale: z.enum(["en", "th", "ja"]),
|
||||
});
|
||||
|
||||
export const ProjectFileV1Schema = z.object({
|
||||
format: z.literal("pien.project"),
|
||||
version: z.literal(1),
|
||||
exportedAt: z.string(),
|
||||
app: z.object({
|
||||
name: z.literal("pien.studio"),
|
||||
platform: z.enum(["web", "mobile", "desktop"]).default("web"),
|
||||
}),
|
||||
project: ProjectSchema,
|
||||
assets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["image", "sticker", "font", "frame"]),
|
||||
name: z.string(),
|
||||
uri: z.string(),
|
||||
checksum: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
history: z.object({
|
||||
checkpointCount: z.number().int().nonnegative().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectFileSchema = ProjectFileV1Schema;
|
||||
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
export type Layer = z.infer<typeof LayerSchema>;
|
||||
export type LayerType = z.infer<typeof LayerTypeSchema>;
|
||||
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
||||
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
||||
export type FaceBlurSettings = z.infer<typeof FaceBlurSettingsSchema>;
|
||||
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@pien-studio/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./tokens";
|
||||
@@ -0,0 +1,13 @@
|
||||
export const themeTokens = {
|
||||
radius: {
|
||||
sm: "10px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
},
|
||||
spacing: {
|
||||
xs: "4px",
|
||||
sm: "8px",
|
||||
md: "12px",
|
||||
lg: "20px",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "tests/e2e",
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: "bun run dev",
|
||||
cwd: "apps/web",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.clear();
|
||||
if (typeof indexedDB !== "undefined") {
|
||||
indexedDB.deleteDatabase("pien.db");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("tools switch modes and action tools add layers", async ({ page }) => {
|
||||
await page.goto("/editor/new");
|
||||
|
||||
await page.locator('button[title="Text"]').click();
|
||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
||||
|
||||
await page.locator('button[title="Face"]').click();
|
||||
await expect(page.getByText("Face Tool")).toBeVisible();
|
||||
|
||||
await page.locator('button[title="Pointer"]').click();
|
||||
await expect(page.getByText("Face Tool")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("undo and redo restore layer changes", async ({ page }) => {
|
||||
await page.goto("/editor/new");
|
||||
|
||||
await page.locator('button[title="Text"]').click();
|
||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
||||
|
||||
await page.locator('header button[title="Undo"]').click();
|
||||
await expect(page.getByText("No layers yet. Add text or image.")).toBeVisible();
|
||||
|
||||
await page.locator('header button[title="Redo"]').click();
|
||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("exports and imports project file", async ({ page }) => {
|
||||
await page.goto("/editor/new");
|
||||
await page.locator('button[title="Text"]').click();
|
||||
|
||||
await page.getByRole("button", { name: "File", exact: true }).hover();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export Project File" }).click();
|
||||
const download = await downloadPromise;
|
||||
const suggested = download.suggestedFilename();
|
||||
expect(suggested.endsWith(".pien.json")).toBe(true);
|
||||
|
||||
const filePath = test.info().outputPath("import-project.pien.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project: {
|
||||
id: randomUUID(),
|
||||
title: "Imported Project",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
canvas: { width: 1080, height: 1350, unit: "px" },
|
||||
aspectRatio: "4:5",
|
||||
layers: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "text",
|
||||
x: 110,
|
||||
y: 90,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
assets: [],
|
||||
history: { checkpointCount: 0 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(filePath);
|
||||
|
||||
await expect(page).toHaveURL(/\/editor\//);
|
||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("persists project list to indexeddb across reload", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "New Project" }).click();
|
||||
|
||||
await page.goto("/");
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open" }).first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("home page loads and shows mood presets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Project Hub")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Project" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@pien-studio/types": ["packages/types/src/index.ts"],
|
||||
"@pien-studio/editor-core": ["packages/editor-core/src/index.ts"],
|
||||
"@pien-studio/storage": ["packages/storage/src/index.ts"],
|
||||
"@pien-studio/ui/*": ["packages/ui/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user