mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
♻️ refactor!: massive refactor
This commit is contained in:
@@ -15,9 +15,42 @@ env:
|
|||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
validate:
|
||||||
name: Run tests
|
name: ${{ matrix.check.name }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
check:
|
||||||
|
- name: Lint
|
||||||
|
command: bun run lint
|
||||||
|
- name: Typecheck
|
||||||
|
command: bun run typecheck
|
||||||
|
- name: Tests
|
||||||
|
command: bun run test
|
||||||
|
- name: Format
|
||||||
|
command: bun run format
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.1.38
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.check.name }}
|
||||||
|
run: ${{ matrix.check.command }}
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: validate
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -32,13 +65,13 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Build
|
||||||
run: bun run test
|
run: bun run build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Build and publish Docker images - ${{ matrix.app }}
|
name: Build and publish Docker images - ${{ matrix.app }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: build
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.turbo
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
docs
|
||||||
|
bun.lock
|
||||||
|
next-env.d.ts
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pien-studio/types": "workspace:*",
|
"@pien-studio/contracts": "workspace:*",
|
||||||
"elysia": "^1.1.25",
|
"elysia": "^1.1.25",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("api endpoints", () => {
|
|||||||
it("returns token for valid device payload", async () => {
|
it("returns token for valid device payload", async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = await app.handle(
|
const response = await app.handle(
|
||||||
new Request("http://localhost/auth/device", {
|
new Request("http://localhost/v1/auth/device", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ deviceId: "dev1234", locale: "en" }),
|
body: JSON.stringify({ deviceId: "dev1234", locale: "en" }),
|
||||||
|
|||||||
+34
-11
@@ -1,5 +1,12 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { DeviceSessionSchema } from "@pien-studio/types";
|
import { DeviceSessionRequestSchema } from "@pien-studio/contracts";
|
||||||
|
|
||||||
|
function errorResponse(code: string, message: string, status = 400) {
|
||||||
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
return new Elysia()
|
return new Elysia()
|
||||||
@@ -8,25 +15,41 @@ export function createApp() {
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
}))
|
}))
|
||||||
.get("/health", () => ({ ok: true, service: "pien-api" }))
|
.get("/health", () => ({ ok: true, service: "pien-api" }))
|
||||||
.post("/auth/device", ({ body }) => {
|
.get("/v1/health", () => ({ ok: true, service: "pien-api" }))
|
||||||
const parsed = DeviceSessionSchema.safeParse(body);
|
.post("/v1/auth/device", ({ body }) => {
|
||||||
|
const parsed = DeviceSessionRequestSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return new Response(JSON.stringify({ error: "invalid_device_payload" }), {
|
return errorResponse(
|
||||||
status: 400,
|
"invalid_device_payload",
|
||||||
});
|
"Device payload is invalid",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
token: `dev_${parsed.data.deviceId}`,
|
token: `dev_${parsed.data.deviceId}`,
|
||||||
scope: "local-sync",
|
scope: "local-sync",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.get("/sync/bootstrap", () => ({
|
.get("/v1/sync/bootstrap", () => ({
|
||||||
replication: {
|
replication: {
|
||||||
pull: "/sync/pull",
|
pull: "/v1/sync/pull",
|
||||||
push: "/sync/push",
|
push: "/v1/sync/push",
|
||||||
strategy: "couch-compatible",
|
strategy: "operation-log",
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
.post("/v1/sync/pull", () =>
|
||||||
|
errorResponse(
|
||||||
|
"sync_not_implemented",
|
||||||
|
"Sync pull is not implemented yet",
|
||||||
|
501,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post("/v1/sync/push", () =>
|
||||||
|
errorResponse(
|
||||||
|
"sync_not_implemented",
|
||||||
|
"Sync push is not implemented yet",
|
||||||
|
501,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export function GET() {
|
export function GET() {
|
||||||
if (process.env.NODE_ENV !== "development") {
|
if (process.env.NODE_ENV !== "development") {
|
||||||
return NextResponse.json({ hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown" });
|
return NextResponse.json({
|
||||||
|
hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() }).toString().trim();
|
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
return NextResponse.json({ hash });
|
return NextResponse.json({ hash });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ hash: "unknown" });
|
return NextResponse.json({ hash: "unknown" });
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react";
|
import { getLayerRuntimeSource } from "@pien-studio/types";
|
||||||
|
import {
|
||||||
|
MousePointer2,
|
||||||
|
Hand,
|
||||||
|
ScanFace,
|
||||||
|
PaintBucket,
|
||||||
|
Brush,
|
||||||
|
Type,
|
||||||
|
ImagePlus,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEditorStore } from "../../../store/editor-store";
|
import { useEditorStore } from "../../../store/editor-store";
|
||||||
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
|
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
|
||||||
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
||||||
@@ -27,7 +36,10 @@ import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job";
|
|||||||
import { useTranslations } from "../../../hooks/use-translations";
|
import { useTranslations } from "../../../hooks/use-translations";
|
||||||
import type { AspectRatio } from "@pien-studio/types";
|
import type { AspectRatio } from "@pien-studio/types";
|
||||||
|
|
||||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const TOOL_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
pointer: MousePointer2,
|
pointer: MousePointer2,
|
||||||
hand: Hand,
|
hand: Hand,
|
||||||
face: ScanFace,
|
face: ScanFace,
|
||||||
@@ -89,7 +101,8 @@ export default function EditorPage() {
|
|||||||
const { theme, hydrate } = useUiStore((s) => s);
|
const { theme, hydrate } = useUiStore((s) => s);
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
|
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
|
||||||
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
|
const { contextMenu, openContextMenu, closeContextMenu } =
|
||||||
|
useEditorContextMenu();
|
||||||
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
||||||
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
||||||
const [fillColor, setFillColor] = React.useState("#ff0000");
|
const [fillColor, setFillColor] = React.useState("#ff0000");
|
||||||
@@ -98,16 +111,21 @@ export default function EditorPage() {
|
|||||||
const [brushSize, setBrushSize] = React.useState(20);
|
const [brushSize, setBrushSize] = React.useState(20);
|
||||||
const [brushOpacity, setBrushOpacity] = React.useState(1);
|
const [brushOpacity, setBrushOpacity] = React.useState(1);
|
||||||
const [brushHardness, setBrushHardness] = React.useState(0.8);
|
const [brushHardness, setBrushHardness] = React.useState(0.8);
|
||||||
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
|
const previousFaceStatusRef = React.useRef<
|
||||||
|
"idle" | "detecting" | "unsupported"
|
||||||
|
>("idle");
|
||||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const selectedImageLayer = React.useMemo(() => {
|
const selectedImageLayer = React.useMemo(() => {
|
||||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
|
const sourceUri = selectedLayer
|
||||||
|
? getLayerRuntimeSource(selectedLayer)
|
||||||
|
: undefined;
|
||||||
|
if (!selectedLayer || selectedLayer.type !== "raster" || !sourceUri) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: selectedLayer.id,
|
id: selectedLayer.id,
|
||||||
sourceUri: selectedLayer.sourceUri,
|
sourceUri,
|
||||||
width: selectedLayer.width,
|
width: selectedLayer.width,
|
||||||
height: selectedLayer.height,
|
height: selectedLayer.height,
|
||||||
};
|
};
|
||||||
@@ -118,7 +136,8 @@ export default function EditorPage() {
|
|||||||
return state.selectedLayerId === layerId;
|
return state.selectedLayerId === layerId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } =
|
||||||
|
useFaceDetection({
|
||||||
tool,
|
tool,
|
||||||
selectedImageLayer,
|
selectedImageLayer,
|
||||||
activeLayerStillSelected: isLayerStillSelected,
|
activeLayerStillSelected: isLayerStillSelected,
|
||||||
@@ -204,7 +223,10 @@ export default function EditorPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") {
|
if (
|
||||||
|
faceStatus === "unsupported" &&
|
||||||
|
previousFaceStatusRef.current !== "unsupported"
|
||||||
|
) {
|
||||||
setFaceMlErrorModalOpen(true);
|
setFaceMlErrorModalOpen(true);
|
||||||
}
|
}
|
||||||
previousFaceStatusRef.current = faceStatus;
|
previousFaceStatusRef.current = faceStatus;
|
||||||
@@ -217,19 +239,23 @@ export default function EditorPage() {
|
|||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
function handleCanvasApply(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
aspect: AspectRatio,
|
||||||
|
) {
|
||||||
void aspect;
|
void aspect;
|
||||||
setCanvasSize(width, height);
|
setCanvasSize(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => {
|
const handleFillLayer = React.useCallback(
|
||||||
|
async (layerId: string, x: number, y: number) => {
|
||||||
const layer = project.layers.find((l) => l.id === layerId);
|
const layer = project.layers.find((l) => l.id === layerId);
|
||||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
const uri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||||
const layerWidth = layer.width ?? Math.round(200 * layer.scale);
|
if (!layer || layer.type !== "raster" || !uri) return;
|
||||||
const layerHeight = layer.height ?? Math.round(150 * layer.scale);
|
const layerWidth = layer.width;
|
||||||
// x/y are in layer CSS-pixel space; scale to image pixel space
|
const layerHeight = layer.height;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const uri = layer.sourceUri;
|
|
||||||
const color = fillColor;
|
const color = fillColor;
|
||||||
const tolerance = fillTolerance;
|
const tolerance = fillTolerance;
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -237,21 +263,29 @@ export default function EditorPage() {
|
|||||||
const scaleY = img.naturalHeight / layerHeight;
|
const scaleY = img.naturalHeight / layerHeight;
|
||||||
const pixelX = x * scaleX;
|
const pixelX = x * scaleX;
|
||||||
const pixelY = y * scaleY;
|
const pixelY = y * scaleY;
|
||||||
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => {
|
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance)
|
||||||
|
.then((nextUri) => {
|
||||||
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
|
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
img.src = uri;
|
img.src = uri;
|
||||||
}, [project.layers, fillColor, fillTolerance, updateImageLayerSource]);
|
},
|
||||||
|
[project.layers, fillColor, fillTolerance, updateImageLayerSource],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => {
|
const handleBrushCommit = React.useCallback(
|
||||||
|
async (layerId: string, stroke: BrushStroke) => {
|
||||||
const layer = project.layers.find((l) => l.id === layerId);
|
const layer = project.layers.find((l) => l.id === layerId);
|
||||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||||
|
if (!layer || layer.type !== "raster" || !sourceUri) return;
|
||||||
try {
|
try {
|
||||||
const nextUri = await commitStroke(layer.sourceUri, stroke);
|
const nextUri = await commitStroke(sourceUri, stroke);
|
||||||
updateImageLayerSource(layerId, nextUri);
|
updateImageLayerSource(layerId, nextUri);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [project.layers, updateImageLayerSource]);
|
},
|
||||||
|
[project.layers, updateImageLayerSource],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateFillLayer = React.useCallback(() => {
|
const handleCreateFillLayer = React.useCallback(() => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -275,23 +309,42 @@ export default function EditorPage() {
|
|||||||
imageInputRef.current?.click();
|
imageInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toolControllers = React.useMemo<EditorToolController[]>(() => [
|
const toolControllers = React.useMemo<EditorToolController[]>(
|
||||||
|
() => [
|
||||||
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
||||||
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
||||||
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
||||||
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
||||||
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
||||||
{ kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") },
|
{
|
||||||
{ kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick },
|
kind: "action",
|
||||||
], [addLayerByType, handleImportImageClick, t]);
|
id: "add-text",
|
||||||
|
label: t("editor.toolText"),
|
||||||
|
run: () => addLayerByType("text"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "action",
|
||||||
|
id: "import-image",
|
||||||
|
label: t("editor.toolImage"),
|
||||||
|
run: handleImportImageClick,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[addLayerByType, handleImportImageClick, t],
|
||||||
|
);
|
||||||
|
|
||||||
const canvasBindings = {
|
const canvasBindings = {
|
||||||
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
onMoveLayer: (_id: string, x: number, y: number) =>
|
||||||
onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y),
|
setSelectedLayerPositionDraft(x, y),
|
||||||
onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height),
|
onMoveLayerEnd: (_id: string, x: number, y: number) =>
|
||||||
onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height),
|
setSelectedLayerPosition(x, y),
|
||||||
onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation),
|
onResizeLayer: (_id: string, width: number, height: number) =>
|
||||||
onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation),
|
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,
|
onInteractionStart: startTransaction,
|
||||||
onInteractionEnd: commitTransaction,
|
onInteractionEnd: commitTransaction,
|
||||||
};
|
};
|
||||||
@@ -361,7 +414,12 @@ export default function EditorPage() {
|
|||||||
onCut={cutSelectedLayer}
|
onCut={cutSelectedLayer}
|
||||||
onPaste={pasteLayer}
|
onPaste={pasteLayer}
|
||||||
onFillLayer={handleFillLayer}
|
onFillLayer={handleFillLayer}
|
||||||
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
|
brushOptions={{
|
||||||
|
color: brushColor,
|
||||||
|
size: brushSize,
|
||||||
|
opacity: brushOpacity,
|
||||||
|
hardness: brushHardness,
|
||||||
|
}}
|
||||||
onBrushCommit={handleBrushCommit}
|
onBrushCommit={handleBrushCommit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -397,7 +455,7 @@ export default function EditorPage() {
|
|||||||
selectedFaceIndices={selectedFaceIndices}
|
selectedFaceIndices={selectedFaceIndices}
|
||||||
onSelectLayer={selectLayer}
|
onSelectLayer={selectLayer}
|
||||||
onSetLayerVisible={setLayerVisible}
|
onSetLayerVisible={setLayerVisible}
|
||||||
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
|
onSetEffectEnabled={setEffectEnabled}
|
||||||
onMoveLayerOrder={moveSelectedLayerOrder}
|
onMoveLayerOrder={moveSelectedLayerOrder}
|
||||||
onRemoveSelectedLayer={removeSelectedLayer}
|
onRemoveSelectedLayer={removeSelectedLayer}
|
||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
@@ -437,7 +495,13 @@ export default function EditorPage() {
|
|||||||
onInteractionStart={canvasBindings.onInteractionStart}
|
onInteractionStart={canvasBindings.onInteractionStart}
|
||||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||||
/>
|
/>
|
||||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageImport}
|
||||||
|
/>
|
||||||
|
|
||||||
<CanvasSizeModal
|
<CanvasSizeModal
|
||||||
isOpen={canvasModalOpen}
|
isOpen={canvasModalOpen}
|
||||||
@@ -450,15 +514,24 @@ export default function EditorPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{faceMlErrorModalOpen ? (
|
{faceMlErrorModalOpen ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setFaceMlErrorModalOpen(false)}>
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
className={`w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
isDark
|
||||||
|
? "border-white/15 bg-[#2b2d31]"
|
||||||
|
: "border-black/15 bg-white"
|
||||||
}`}
|
}`}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{t("editor.faceTool")}</h2>
|
<h2
|
||||||
|
className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
{t("editor.faceTool")}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||||
@@ -467,7 +540,11 @@ export default function EditorPage() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
|
<p
|
||||||
|
className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}
|
||||||
|
>
|
||||||
|
{t("editor.faceMlFailed")}
|
||||||
|
</p>
|
||||||
<div className="mt-5 flex justify-end">
|
<div className="mt-5 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+198
-46
@@ -2,12 +2,24 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core";
|
import {
|
||||||
|
addLayer,
|
||||||
|
createLayer,
|
||||||
|
createProject,
|
||||||
|
parseProjectFile,
|
||||||
|
setCanvasSize,
|
||||||
|
} from "@pien-studio/editor-core";
|
||||||
import type { Project } from "@pien-studio/types";
|
import type { Project } from "@pien-studio/types";
|
||||||
import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage";
|
import { localProjectRepository } from "../lib/project-repository";
|
||||||
import { useEditorStore } from "../store/editor-store";
|
import { useEditorStore } from "../store/editor-store";
|
||||||
import { useUiStore, isDarkTheme } from "../store/ui-store";
|
import { useUiStore, isDarkTheme } from "../store/ui-store";
|
||||||
import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme";
|
import {
|
||||||
|
accentButtonClass,
|
||||||
|
cx,
|
||||||
|
mutedSurfaceClass,
|
||||||
|
subtleButtonClass,
|
||||||
|
surfaceClass,
|
||||||
|
} from "../lib/theme";
|
||||||
import { UiPreferences } from "../components/ui-preferences";
|
import { UiPreferences } from "../components/ui-preferences";
|
||||||
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
|
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
|
||||||
import { useTranslations } from "../hooks/use-translations";
|
import { useTranslations } from "../hooks/use-translations";
|
||||||
@@ -20,19 +32,20 @@ export default function HomePage() {
|
|||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||||
const [showWipModal, setShowWipModal] = React.useState(true);
|
const [showWipModal, setShowWipModal] = React.useState(true);
|
||||||
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
|
const [projectPendingDelete, setProjectPendingDelete] =
|
||||||
|
React.useState<Project | null>(null);
|
||||||
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const isDark = isDarkTheme(theme);
|
const isDark = isDarkTheme(theme);
|
||||||
|
|
||||||
const refreshProjects = React.useCallback(async () => {
|
const refreshProjects = React.useCallback(async () => {
|
||||||
setProjects(await loadProjects());
|
setProjects(await localProjectRepository.listProjects());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
hydrate();
|
hydrate();
|
||||||
loadProjects().then((loadedProjects) => {
|
localProjectRepository.listProjects().then((loadedProjects) => {
|
||||||
if (!canceled) setProjects(loadedProjects);
|
if (!canceled) setProjects(loadedProjects);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -47,25 +60,27 @@ export default function HomePage() {
|
|||||||
|
|
||||||
async function confirmDeleteProject() {
|
async function confirmDeleteProject() {
|
||||||
if (!projectPendingDelete) return;
|
if (!projectPendingDelete) return;
|
||||||
await deleteProject(projectPendingDelete.id);
|
await localProjectRepository.deleteProject(projectPendingDelete.id);
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
setProjectPendingDelete(null);
|
setProjectPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNewProject() {
|
async function handleNewProject() {
|
||||||
const project = createProject(t("home.untitledProject"));
|
const project = createProject(t("home.untitledProject"));
|
||||||
await upsertProject(project);
|
await localProjectRepository.upsertProject(project);
|
||||||
openProject(project);
|
openProject(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImportProjectFile(event: React.ChangeEvent<HTMLInputElement>) {
|
async function handleImportProjectFile(
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
try {
|
try {
|
||||||
const parsed = parseProjectFile(raw);
|
const parsed = parseProjectFile(raw);
|
||||||
if (!parsed.ok) return;
|
if (!parsed.ok) return;
|
||||||
await upsertProject(parsed.project);
|
await localProjectRepository.upsertProject(parsed.project);
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
openProject(parsed.project);
|
openProject(parsed.project);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,10 +95,12 @@ export default function HomePage() {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
try {
|
try {
|
||||||
const sourceUri = typeof reader.result === "string" ? reader.result : undefined;
|
const sourceUri =
|
||||||
|
typeof reader.result === "string" ? reader.result : undefined;
|
||||||
if (!sourceUri) return;
|
if (!sourceUri) return;
|
||||||
|
|
||||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
const imageSize = await new Promise<{ width: number; height: number }>(
|
||||||
|
(resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
resolve({
|
resolve({
|
||||||
@@ -93,21 +110,29 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
image.onerror = () => resolve({ width: 1, height: 1 });
|
image.onerror = () => resolve({ width: 1, height: 1 });
|
||||||
image.src = sourceUri;
|
image.src = sourceUri;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const base = createProject(title, "free");
|
const base = createProject(title, "free");
|
||||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
const projectWithImageCanvas = setCanvasSize(
|
||||||
|
base,
|
||||||
|
imageSize.width,
|
||||||
|
imageSize.height,
|
||||||
|
);
|
||||||
|
|
||||||
const project = addLayer(projectWithImageCanvas, createLayer("raster", {
|
const project = addLayer(
|
||||||
|
projectWithImageCanvas,
|
||||||
|
createLayer("raster", {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
sourceUri,
|
asset: { kind: "inline", uri: sourceUri },
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: imageSize.width,
|
width: imageSize.width,
|
||||||
height: imageSize.height,
|
height: imageSize.height,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await upsertProject(project);
|
await localProjectRepository.upsertProject(project);
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
openProject(project);
|
openProject(project);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,15 +145,33 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showWipModal ? (
|
{showWipModal ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setShowWipModal(false)}>
|
|
||||||
<div
|
<div
|
||||||
className={cx("w-full max-w-lg rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold">{t("home.wipTitle")}</h2>
|
<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
|
||||||
<p className={cx("mt-3 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
className={cx(
|
||||||
{t("home.wipSupportPrefix")} {" "}
|
"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
|
<a
|
||||||
href="https://github.com/sponsors/YuzuZensai"
|
href="https://github.com/sponsors/YuzuZensai"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -142,7 +185,10 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowWipModal(false)}
|
onClick={() => setShowWipModal(false)}
|
||||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
|
className={cx(
|
||||||
|
"rounded border px-3 py-1.5 text-sm font-semibold",
|
||||||
|
accentButtonClass(),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("home.wipAcknowledge")}
|
{t("home.wipAcknowledge")}
|
||||||
</button>
|
</button>
|
||||||
@@ -152,20 +198,38 @@ export default function HomePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{projectPendingDelete ? (
|
{projectPendingDelete ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setProjectPendingDelete(null)}>
|
|
||||||
<div
|
<div
|
||||||
className={cx("w-full max-w-md rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold">Delete project?</h2>
|
<h2 className="text-lg font-semibold">Delete project?</h2>
|
||||||
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
<p
|
||||||
This will permanently delete <span className="font-semibold">{projectPendingDelete.title}</span> from local storage.
|
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>
|
</p>
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setProjectPendingDelete(null)}
|
onClick={() => setProjectPendingDelete(null)}
|
||||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", subtleButtonClass(isDark))}
|
className={cx(
|
||||||
|
"rounded border px-3 py-1.5 text-sm font-semibold",
|
||||||
|
subtleButtonClass(isDark),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -188,12 +252,28 @@ export default function HomePage() {
|
|||||||
isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]"
|
isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<section className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}>
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className={cx("text-[10px] uppercase tracking-[0.2em]", isDark ? "text-[#a8abb2]" : "text-[#6c7382]")}>pien.studio</p>
|
<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>
|
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
|
||||||
<p className={cx("text-sm", isDark ? "text-[#b9bec8]" : "text-[#5f6672]")}>{t("home.createOpenManage")}</p>
|
<p
|
||||||
|
className={cx(
|
||||||
|
"text-sm",
|
||||||
|
isDark ? "text-[#b9bec8]" : "text-[#5f6672]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("home.createOpenManage")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UiPreferences />
|
<UiPreferences />
|
||||||
</div>
|
</div>
|
||||||
@@ -203,42 +283,114 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNewProject}
|
onClick={handleNewProject}
|
||||||
className={cx("rounded border px-3 py-2 text-sm font-semibold", accentButtonClass())}
|
className={cx(
|
||||||
|
"rounded border px-3 py-2 text-sm font-semibold",
|
||||||
|
accentButtonClass(),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("home.newProject")}
|
{t("home.newProject")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => projectInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => projectInputRef.current?.click()}
|
||||||
|
className={cx(
|
||||||
|
"rounded border px-3 py-2 text-sm font-semibold",
|
||||||
|
subtleButtonClass(isDark),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t("home.openProjectFile")}
|
{t("home.openProjectFile")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => imageInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
className={cx(
|
||||||
|
"rounded border px-3 py-2 text-sm font-semibold",
|
||||||
|
subtleButtonClass(isDark),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t("home.openImage")}
|
{t("home.openImage")}
|
||||||
</button>
|
</button>
|
||||||
<input ref={projectInputRef} type="file" accept=".json,.pien.json,application/json" className="hidden" onChange={handleImportProjectFile} />
|
<input
|
||||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleOpenImage} />
|
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>
|
||||||
|
|
||||||
<section className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}>
|
<section
|
||||||
<h2 className={cx("mb-3 text-sm font-semibold uppercase tracking-wide", isDark ? "text-[#c5cad3]" : "text-[#6c7382]")}>{t("home.myProjects")}</h2>
|
className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}
|
||||||
{projects.length === 0 ? <p className={cx("text-sm", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{t("home.noProjectsYet")}</p> : null}
|
>
|
||||||
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<article key={project.id} className={cx("rounded border p-3", mutedSurfaceClass(isDark))}>
|
<article
|
||||||
<p className={cx("truncate text-sm font-semibold", isDark ? "text-[#f3f5f8]" : "text-[#1f2430]")}>{project.title}</p>
|
key={project.id}
|
||||||
<p className={cx("mt-1 text-xs", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{new Date(project.updatedAt).toLocaleString()}</p>
|
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">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openProject(project)}
|
onClick={() => openProject(project)}
|
||||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", accentButtonClass())}
|
className={cx(
|
||||||
|
"rounded border px-2 py-1 text-xs font-semibold",
|
||||||
|
accentButtonClass(),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("home.open")}
|
{t("home.open")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void duplicateProject(project.id).then(refreshProjects);
|
void localProjectRepository
|
||||||
|
.duplicateProject(project.id)
|
||||||
|
.then(refreshProjects);
|
||||||
}}
|
}}
|
||||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", subtleButtonClass(isDark))}
|
className={cx(
|
||||||
|
"rounded border px-2 py-1 text-xs font-semibold",
|
||||||
|
subtleButtonClass(isDark),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("home.duplicate")}
|
{t("home.duplicate")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ import { CanvasRenderer } from "./canvas-renderer";
|
|||||||
import type { Layer } from "@pien-studio/types";
|
import type { Layer } from "@pien-studio/types";
|
||||||
|
|
||||||
vi.mock("next/image", () => ({
|
vi.mock("next/image", () => ({
|
||||||
default: ({ alt, src, unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
default: ({
|
||||||
React.createElement("img", { alt, src, "data-unoptimized": unoptimized ? "true" : undefined, ...props }),
|
alt,
|
||||||
|
src,
|
||||||
|
unoptimized,
|
||||||
|
...props
|
||||||
|
}: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
||||||
|
React.createElement("img", {
|
||||||
|
alt,
|
||||||
|
src,
|
||||||
|
"data-unoptimized": unoptimized ? "true" : undefined,
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/use-translations", () => ({
|
vi.mock("../hooks/use-translations", () => ({
|
||||||
@@ -21,14 +31,17 @@ class ResizeObserverMock {
|
|||||||
describe("CanvasRenderer", () => {
|
describe("CanvasRenderer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: vi.fn() });
|
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the rotation handle interactive", () => {
|
it("keeps the rotation handle interactive", () => {
|
||||||
const layer: Layer = {
|
const layer: Layer = {
|
||||||
id: "layer-1",
|
id: "layer-1",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
sourceUri: "data:image/png;base64,test",
|
asset: { kind: "inline", uri: "data:image/png;base64,test" },
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -58,8 +71,23 @@ describe("CanvasRenderer", () => {
|
|||||||
const rotateHandle = screen.getByTitle("editor.rotate");
|
const rotateHandle = screen.getByTitle("editor.rotate");
|
||||||
expect(rotateHandle).toHaveClass("pointer-events-auto");
|
expect(rotateHandle).toHaveClass("pointer-events-auto");
|
||||||
|
|
||||||
fireEvent(rotateHandle, new MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 50, clientY: 0 }));
|
fireEvent(
|
||||||
fireEvent(rotateHandle, new MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 50 }));
|
rotateHandle,
|
||||||
|
new MouseEvent("pointerdown", {
|
||||||
|
bubbles: true,
|
||||||
|
button: 0,
|
||||||
|
clientX: 50,
|
||||||
|
clientY: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fireEvent(
|
||||||
|
rotateHandle,
|
||||||
|
new MouseEvent("pointermove", {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90);
|
expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ import { getToolUiDefinition } from "../lib/tools/registry";
|
|||||||
import { getToolDefinition } from "@pien-studio/editor-core";
|
import { getToolDefinition } from "@pien-studio/editor-core";
|
||||||
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
|
||||||
import { useTranslations } from "../hooks/use-translations";
|
import { useTranslations } from "../hooks/use-translations";
|
||||||
import { createStroke, paintSegment, type BrushStroke, type BrushOptions } from "../lib/brush-painter";
|
import {
|
||||||
import type { Layer, LayerEffect } from "@pien-studio/types";
|
createStroke,
|
||||||
|
paintSegment,
|
||||||
|
type BrushStroke,
|
||||||
|
type BrushOptions,
|
||||||
|
} from "../lib/brush-painter";
|
||||||
|
import {
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type Layer,
|
||||||
|
type LayerEffect,
|
||||||
|
} from "@pien-studio/types";
|
||||||
|
|
||||||
interface CanvasRendererProps {
|
interface CanvasRendererProps {
|
||||||
layers: Layer[];
|
layers: Layer[];
|
||||||
@@ -50,7 +59,15 @@ interface CanvasRendererProps {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) {
|
function BrushOverlayCanvas({
|
||||||
|
stroke,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
stroke: HTMLCanvasElement;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -87,6 +104,7 @@ function EffectImageLayer({
|
|||||||
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
const imageRef = React.useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
const activeEffects = effectsOverride ?? layer.effects;
|
const activeEffects = effectsOverride ?? layer.effects;
|
||||||
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
|
|
||||||
const draw = React.useCallback(() => {
|
const draw = React.useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -95,11 +113,17 @@ function EffectImageLayer({
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height);
|
renderLayerWithEffects(
|
||||||
|
ctx,
|
||||||
|
image,
|
||||||
|
activeEffects,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
}, [activeEffects]);
|
}, [activeEffects]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!layer.sourceUri) return;
|
if (!sourceUri) return;
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.crossOrigin = "anonymous";
|
image.crossOrigin = "anonymous";
|
||||||
@@ -108,11 +132,11 @@ function EffectImageLayer({
|
|||||||
imageRef.current = image;
|
imageRef.current = image;
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
image.src = layer.sourceUri;
|
image.src = sourceUri;
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}, [draw, layer.sourceUri]);
|
}, [draw, sourceUri]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
draw();
|
draw();
|
||||||
@@ -156,32 +180,51 @@ export function CanvasRenderer({
|
|||||||
|
|
||||||
const brushStrokeRef = React.useRef<BrushStroke | null>(null);
|
const brushStrokeRef = React.useRef<BrushStroke | null>(null);
|
||||||
const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null);
|
const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||||
const [brushOverlay, setBrushOverlay] = React.useState<{ layerId: string; canvas: HTMLCanvasElement } | null>(null);
|
const [brushOverlay, setBrushOverlay] = React.useState<{
|
||||||
|
layerId: string;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleBrushStrokeStart = React.useCallback((layerId: string, x: number, y: number, layerWidth: number, layerHeight: number): void => {
|
const handleBrushStrokeStart = React.useCallback(
|
||||||
|
(
|
||||||
|
layerId: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
layerWidth: number,
|
||||||
|
layerHeight: number,
|
||||||
|
): void => {
|
||||||
if (!brushOptions) return;
|
if (!brushOptions) return;
|
||||||
const stroke = createStroke(layerWidth, layerHeight);
|
const stroke = createStroke(layerWidth, layerHeight);
|
||||||
brushStrokeRef.current = stroke;
|
brushStrokeRef.current = stroke;
|
||||||
brushLastPosRef.current = { x, y };
|
brushLastPosRef.current = { x, y };
|
||||||
paintSegment(stroke, x, y, x, y, brushOptions);
|
paintSegment(stroke, x, y, x, y, brushOptions);
|
||||||
setBrushOverlay({ layerId, canvas: stroke.canvas });
|
setBrushOverlay({ layerId, canvas: stroke.canvas });
|
||||||
}, [brushOptions]);
|
},
|
||||||
|
[brushOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBrushStrokeMove = React.useCallback((_layerId: string, x: number, y: number) => {
|
const handleBrushStrokeMove = React.useCallback(
|
||||||
if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) return;
|
(_layerId: string, x: number, y: number) => {
|
||||||
|
if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions)
|
||||||
|
return;
|
||||||
const { x: lx, y: ly } = brushLastPosRef.current;
|
const { x: lx, y: ly } = brushLastPosRef.current;
|
||||||
paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions);
|
paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions);
|
||||||
brushLastPosRef.current = { x, y };
|
brushLastPosRef.current = { x, y };
|
||||||
setBrushOverlay((prev) => prev ? { ...prev } : prev);
|
setBrushOverlay((prev) => (prev ? { ...prev } : prev));
|
||||||
}, [brushOptions]);
|
},
|
||||||
|
[brushOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBrushStrokeEnd = React.useCallback((layerId: string) => {
|
const handleBrushStrokeEnd = React.useCallback(
|
||||||
|
(layerId: string) => {
|
||||||
const stroke = brushStrokeRef.current;
|
const stroke = brushStrokeRef.current;
|
||||||
brushStrokeRef.current = null;
|
brushStrokeRef.current = null;
|
||||||
brushLastPosRef.current = null;
|
brushLastPosRef.current = null;
|
||||||
setBrushOverlay(null);
|
setBrushOverlay(null);
|
||||||
if (stroke && onBrushCommit) onBrushCommit(layerId, stroke);
|
if (stroke && onBrushCommit) onBrushCommit(layerId, stroke);
|
||||||
}, [onBrushCommit]);
|
},
|
||||||
|
[onBrushCommit],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -226,13 +269,7 @@ export function CanvasRenderer({
|
|||||||
faceDetections,
|
faceDetections,
|
||||||
viewport,
|
viewport,
|
||||||
);
|
);
|
||||||
}, [
|
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport]);
|
||||||
faceDetections,
|
|
||||||
faceOverlayLayerId,
|
|
||||||
layers,
|
|
||||||
tool,
|
|
||||||
viewport,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -243,10 +280,16 @@ export function CanvasRenderer({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"),
|
cursor: isSpacePan
|
||||||
|
? "grab"
|
||||||
|
: (getToolUiDefinition(tool)?.cursor ?? "default"),
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null);
|
if (
|
||||||
|
getToolDefinition(tool)?.interactionMode === "select" &&
|
||||||
|
e.button === 0
|
||||||
|
)
|
||||||
|
onSelectLayer(null);
|
||||||
onContainerPointerDown(e);
|
onContainerPointerDown(e);
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
@@ -282,12 +325,9 @@ export function CanvasRenderer({
|
|||||||
{layers.map((layer, idx) => {
|
{layers.map((layer, idx) => {
|
||||||
const isSelected = layer.id === selectedLayerId;
|
const isSelected = layer.id === selectedLayerId;
|
||||||
const isImage = layer.type === "raster";
|
const isImage = layer.type === "raster";
|
||||||
const layerWidth =
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
layer.width ??
|
const layerWidth = layer.width;
|
||||||
(isImage ? Math.round(200 * layer.scale) : undefined);
|
const layerHeight = layer.height;
|
||||||
const layerHeight =
|
|
||||||
layer.height ??
|
|
||||||
(isImage ? Math.round(150 * layer.scale) : undefined);
|
|
||||||
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
||||||
const handleSizePx = `${handleSize}px`;
|
const handleSizePx = `${handleSize}px`;
|
||||||
const largeHandleSize =
|
const largeHandleSize =
|
||||||
@@ -319,8 +359,9 @@ export function CanvasRenderer({
|
|||||||
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
onPointerDown={(e) => onLayerPointerDown(e, layer)}
|
||||||
onClick={() => onSelectLayer(layer.id)}
|
onClick={() => onSelectLayer(layer.id)}
|
||||||
>
|
>
|
||||||
{isImage && layer.sourceUri ? (
|
{isImage && sourceUri ? (
|
||||||
layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
layer.effects.length > 0 ||
|
||||||
|
(faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
|
||||||
<EffectImageLayer
|
<EffectImageLayer
|
||||||
layer={layer}
|
layer={layer}
|
||||||
width={layerWidth ?? 1}
|
width={layerWidth ?? 1}
|
||||||
@@ -333,7 +374,7 @@ export function CanvasRenderer({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NextImage
|
<NextImage
|
||||||
src={layer.sourceUri}
|
src={sourceUri}
|
||||||
alt={layer.name ?? t("editor.layer")}
|
alt={layer.name ?? t("editor.layer")}
|
||||||
width={layerWidth ?? 1}
|
width={layerWidth ?? 1}
|
||||||
height={layerHeight ?? 1}
|
height={layerHeight ?? 1}
|
||||||
@@ -356,7 +397,11 @@ export function CanvasRenderer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{brushOverlay && brushOverlay.layerId === layer.id ? (
|
{brushOverlay && brushOverlay.layerId === layer.id ? (
|
||||||
<BrushOverlayCanvas stroke={brushOverlay.canvas} width={layerWidth ?? 1} height={layerHeight ?? 1} />
|
<BrushOverlayCanvas
|
||||||
|
stroke={brushOverlay.canvas}
|
||||||
|
width={layerWidth ?? 1}
|
||||||
|
height={layerHeight ?? 1}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{tool === "face" && faceOverlayLayerId === layer.id
|
{tool === "face" && faceOverlayLayerId === layer.id
|
||||||
? faceDetections.map((face, index) => (
|
? faceDetections.map((face, index) => (
|
||||||
|
|||||||
@@ -15,12 +15,48 @@ interface CanvasSizeModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS = [
|
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.presetSquare",
|
||||||
{ labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 },
|
sublabel: "1080 × 1080",
|
||||||
{ labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 },
|
aspect: "1:1" as AspectRatio,
|
||||||
{ labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 },
|
width: 1080,
|
||||||
{ labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 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({
|
export function CanvasSizeModal({
|
||||||
@@ -37,8 +73,11 @@ export function CanvasSizeModal({
|
|||||||
PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom",
|
PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom",
|
||||||
);
|
);
|
||||||
const [customWidth, setCustomWidth] = React.useState(currentWidth.toString());
|
const [customWidth, setCustomWidth] = React.useState(currentWidth.toString());
|
||||||
const [customHeight, setCustomHeight] = React.useState(currentHeight.toString());
|
const [customHeight, setCustomHeight] = React.useState(
|
||||||
const [selectedPreset, setSelectedPreset] = React.useState<AspectRatio>(currentAspect);
|
currentHeight.toString(),
|
||||||
|
);
|
||||||
|
const [selectedPreset, setSelectedPreset] =
|
||||||
|
React.useState<AspectRatio>(currentAspect);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -56,7 +95,8 @@ export function CanvasSizeModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
const overlay =
|
||||||
|
"fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||||
const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
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"
|
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||||
}`;
|
}`;
|
||||||
@@ -65,7 +105,9 @@ export function CanvasSizeModal({
|
|||||||
<div className={overlay} onClick={onClose}>
|
<div className={overlay} onClick={onClose}>
|
||||||
<div className={panel} onClick={(e) => e.stopPropagation()}>
|
<div className={panel} onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>
|
<h2
|
||||||
|
className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
{t("editor.canvasSizeTitle")}
|
{t("editor.canvasSizeTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -109,7 +151,9 @@ export function CanvasSizeModal({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
|
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
|
||||||
<div className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}>
|
<div
|
||||||
|
className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}
|
||||||
|
>
|
||||||
{p.sublabel}
|
{p.sublabel}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -118,33 +162,55 @@ export function CanvasSizeModal({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.widthShort")}</label>
|
<label
|
||||||
|
className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
|
{t("editor.widthShort")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={customWidth}
|
value={customWidth}
|
||||||
onChange={(e) => setCustomWidth(e.target.value)}
|
onChange={(e) => setCustomWidth(e.target.value)}
|
||||||
min={1}
|
min={1}
|
||||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 bg-[#25272b] text-[#e8eaed]"
|
||||||
|
: "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
<span
|
||||||
|
className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
|
px
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.heightShort")}</label>
|
<label
|
||||||
|
className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
|
{t("editor.heightShort")}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={customHeight}
|
value={customHeight}
|
||||||
onChange={(e) => setCustomHeight(e.target.value)}
|
onChange={(e) => setCustomHeight(e.target.value)}
|
||||||
min={1}
|
min={1}
|
||||||
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
className={`flex-1 rounded border px-2 py-1.5 text-sm ${
|
||||||
isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 bg-[#25272b] text-[#e8eaed]"
|
||||||
|
: "border-black/20 bg-[#f6f8fb] text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
|
<span
|
||||||
|
className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
|
px
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-dashed border-black/20 p-2 text-center">
|
<div className="rounded border border-dashed border-black/20 p-2 text-center">
|
||||||
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
<span
|
||||||
|
className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +221,9 @@ export function CanvasSizeModal({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={`rounded border px-3 py-1.5 text-xs font-medium ${
|
className={`rounded border px-3 py-1.5 text-xs font-medium ${
|
||||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 text-[#d7dae0]"
|
||||||
|
: "border-black/20 text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t("editor.cancel")}
|
{t("editor.cancel")}
|
||||||
|
|||||||
@@ -15,22 +15,44 @@ type CanvasContextMenuProps = {
|
|||||||
onPaste: () => void;
|
onPaste: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
|
export function CanvasContextMenu({
|
||||||
|
isDark,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
labels,
|
||||||
|
onCopy,
|
||||||
|
onCut,
|
||||||
|
onPaste,
|
||||||
|
}: CanvasContextMenuProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
|
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
|
||||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]"
|
||||||
|
: "border-black/10 bg-white text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
style={{ left: x, top: y }}
|
style={{ left: x, top: y }}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
{labels.copy}
|
{labels.copy}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCut}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
{labels.cut}
|
{labels.cut}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPaste}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
{labels.paste}
|
{labels.paste}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { EditorCanvasStage } from "./editor-canvas-stage";
|
import { EditorCanvasStage } from "./editor-canvas-stage";
|
||||||
|
|
||||||
vi.mock("../canvas-renderer", () => ({
|
vi.mock("../canvas-renderer", () => ({
|
||||||
CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => (
|
CanvasRenderer: ({
|
||||||
|
onContextMenu,
|
||||||
|
}: {
|
||||||
|
onContextMenu?: (x: number, y: number) => void;
|
||||||
|
}) =>
|
||||||
React.createElement(
|
React.createElement(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", "data-testid": "canvas-renderer", onClick: () => onContextMenu?.(10, 12) },
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": "canvas-renderer",
|
||||||
|
onClick: () => onContextMenu?.(10, 12),
|
||||||
|
},
|
||||||
"canvas",
|
"canvas",
|
||||||
)
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
|
<div
|
||||||
|
className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="flex h-full items-center justify-center"
|
className="flex h-full items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -70,17 +70,27 @@ export function EditorHeader({
|
|||||||
onSetPointerTool,
|
onSetPointerTool,
|
||||||
}: EditorHeaderProps) {
|
}: 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 ${
|
const menuClass = `absolute left-0 top-full z-30 min-w-[180px] rounded border p-1 text-[11px] opacity-0 shadow-xl transition-opacity pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 hover:pointer-events-auto hover:opacity-100 ${
|
||||||
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]"
|
||||||
|
: "border-black/10 bg-white text-[#1f2430]"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}>
|
<header
|
||||||
|
className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/" className="group">
|
<Link href="/" className="group">
|
||||||
<p className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
<p
|
||||||
|
className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}
|
||||||
|
>
|
||||||
pien.studio
|
pien.studio
|
||||||
</p>
|
</p>
|
||||||
<h1 className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{projectTitle}</h1>
|
<h1
|
||||||
|
className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
{projectTitle}
|
||||||
|
</h1>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-2 text-xs font-medium">
|
<nav className="flex items-center gap-2 text-xs font-medium">
|
||||||
@@ -98,12 +108,31 @@ export function EditorHeader({
|
|||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
|
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
|
||||||
<EditMenu isDark={isDark} labels={labels} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onCopy={onCopy} onCut={onCut} onPaste={onPaste} />
|
<EditMenu
|
||||||
|
isDark={isDark}
|
||||||
|
labels={labels}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canRedo={canRedo}
|
||||||
|
onUndo={onUndo}
|
||||||
|
onRedo={onRedo}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onCut={onCut}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
|
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
|
||||||
<ViewMenu isDark={isDark} labels={labels} onSetHandTool={onSetHandTool} onSetPointerTool={onSetPointerTool} />
|
<ViewMenu
|
||||||
|
isDark={isDark}
|
||||||
|
labels={labels}
|
||||||
|
onSetHandTool={onSetHandTool}
|
||||||
|
onSetPointerTool={onSetPointerTool}
|
||||||
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
|
<MenuShell
|
||||||
|
isDark={isDark}
|
||||||
|
label={labels.settings}
|
||||||
|
menuClass={menuClass}
|
||||||
|
>
|
||||||
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -116,7 +145,9 @@ export function EditorHeader({
|
|||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
title={labels.undo}
|
title={labels.undo}
|
||||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/15 bg-[#25272b] text-[#d7dae0]"
|
||||||
|
: "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||||
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
|
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
|
||||||
>
|
>
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
@@ -127,12 +158,16 @@ export function EditorHeader({
|
|||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
title={labels.redo}
|
title={labels.redo}
|
||||||
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
|
||||||
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/15 bg-[#25272b] text-[#d7dae0]"
|
||||||
|
: "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
|
||||||
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
|
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
|
||||||
>
|
>
|
||||||
<Redo2 className="h-3.5 w-3.5" />
|
<Redo2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
|
<span
|
||||||
|
className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}
|
||||||
|
>
|
||||||
{isDirty ? labels.unsavedChanges : labels.saved}
|
{isDirty ? labels.unsavedChanges : labels.saved}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,10 +175,23 @@ export function EditorHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
|
function MenuShell({
|
||||||
|
isDark,
|
||||||
|
label,
|
||||||
|
menuClass,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
isDark: boolean;
|
||||||
|
label: string;
|
||||||
|
menuClass: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<button type="button" className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
|
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
|
||||||
@@ -176,13 +224,43 @@ function FileMenu({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
<button
|
||||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
type="button"
|
||||||
|
onClick={onImportImage}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{labels.importImage}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{labels.save}
|
||||||
|
</button>
|
||||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
<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
|
||||||
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</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)}`} />
|
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||||
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCanvasSize}
|
||||||
|
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{labels.canvasSize} ({canvasWidth} x {canvasHeight})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,29 +288,93 @@ function EditMenu({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<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
|
||||||
<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>
|
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)}`} />
|
<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
|
||||||
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.cut}</button>
|
type="button"
|
||||||
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.paste}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) {
|
function ViewMenu({
|
||||||
|
isDark,
|
||||||
|
labels,
|
||||||
|
onSetHandTool,
|
||||||
|
onSetPointerTool,
|
||||||
|
}: {
|
||||||
|
isDark: boolean;
|
||||||
|
labels: EditorHeaderProps["labels"];
|
||||||
|
onSetHandTool: () => void;
|
||||||
|
onSetPointerTool: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<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
|
||||||
<button type="button" onClick={onSetPointerTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.pointerTool}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) {
|
function SettingsMenu({
|
||||||
|
isDark,
|
||||||
|
preferences,
|
||||||
|
}: {
|
||||||
|
isDark: boolean;
|
||||||
|
preferences: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 p-1">
|
<div className="space-y-2 p-1">
|
||||||
<p className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}>{preferences}</p>
|
<p
|
||||||
|
className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}
|
||||||
|
>
|
||||||
|
{preferences}
|
||||||
|
</p>
|
||||||
<UiPreferences />
|
<UiPreferences />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { EditorMobileSection } from "./editor-mobile-section";
|
import { EditorMobileSection } from "./editor-mobile-section";
|
||||||
|
|
||||||
vi.mock("../canvas-renderer", () => ({
|
vi.mock("../canvas-renderer", () => ({
|
||||||
CanvasRenderer: () => React.createElement("div", { "data-testid": "canvas-renderer" }),
|
CanvasRenderer: () =>
|
||||||
|
React.createElement("div", { "data-testid": "canvas-renderer" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("EditorMobileSection", () => {
|
describe("EditorMobileSection", () => {
|
||||||
@@ -17,7 +18,17 @@ describe("EditorMobileSection", () => {
|
|||||||
layers: [],
|
layers: [],
|
||||||
selectedLayerId: null,
|
selectedLayerId: null,
|
||||||
tool: "face",
|
tool: "face",
|
||||||
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }],
|
faceDetections: [
|
||||||
|
{
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
label: "f",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
faceOverlayLayerId: null,
|
faceOverlayLayerId: null,
|
||||||
faceStatus: "idle",
|
faceStatus: "idle",
|
||||||
faceBlurPreview: null,
|
faceBlurPreview: null,
|
||||||
|
|||||||
@@ -66,15 +66,21 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="lg:hidden">
|
<section className="lg:hidden">
|
||||||
<div className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
<div
|
||||||
|
className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}
|
||||||
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}>
|
<p
|
||||||
|
className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
{canvasWidth} x {canvasHeight}px
|
{canvasWidth} x {canvasHeight}px
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenCanvasSize}
|
onClick={onOpenCanvasSize}
|
||||||
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
|
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
|
||||||
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 text-[#d7dae0]"
|
||||||
|
: "border-black/20 text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{labels.resize}
|
{labels.resize}
|
||||||
@@ -106,7 +112,9 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{tool === "face" && (
|
{tool === "face" && (
|
||||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
<p
|
||||||
|
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
{faceStatus === "unsupported"
|
{faceStatus === "unsupported"
|
||||||
? labels.faceMlFailedShort
|
? labels.faceMlFailedShort
|
||||||
: faceStatus === "detecting"
|
: faceStatus === "detecting"
|
||||||
@@ -116,27 +124,35 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
|
<div
|
||||||
|
className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onImportImage}
|
onClick={onImportImage}
|
||||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 bg-[#25272b] text-[#dfe3ea]"
|
||||||
|
: "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{labels.import}
|
{labels.import}
|
||||||
</button>
|
</button>
|
||||||
{[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => (
|
{[labels.mood, labels.quick, labels.face, labels.decor].map(
|
||||||
|
(toolLabel) => (
|
||||||
<button
|
<button
|
||||||
key={toolLabel}
|
key={toolLabel}
|
||||||
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
|
||||||
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
isDark
|
||||||
|
? "border-white/20 bg-[#25272b] text-[#dfe3ea]"
|
||||||
|
: "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{toolLabel}
|
{toolLabel}
|
||||||
</button>
|
</button>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
import type {
|
||||||
|
FaceBlurMethod,
|
||||||
|
Layer,
|
||||||
|
LayerEffect,
|
||||||
|
Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import { FacePanel } from "./face-panel";
|
import { FacePanel } from "./face-panel";
|
||||||
import { HistoryPanel } from "./history-panel";
|
import { HistoryPanel } from "./history-panel";
|
||||||
import { LayersPanel } from "./layers-panel";
|
import { LayersPanel } from "./layers-panel";
|
||||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
import type {
|
||||||
|
FaceDetectionOverlay,
|
||||||
|
FacePreview,
|
||||||
|
} from "../../hooks/use-face-detection";
|
||||||
|
|
||||||
type EditorSidebarProps = {
|
type EditorSidebarProps = {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
@@ -37,7 +45,11 @@ type EditorSidebarProps = {
|
|||||||
selectedFaceIndices: number[];
|
selectedFaceIndices: number[];
|
||||||
onSelectLayer: (layerId: string | null) => void;
|
onSelectLayer: (layerId: string | null) => void;
|
||||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
|
onSetEffectEnabled: (
|
||||||
|
layerId: string,
|
||||||
|
kind: LayerEffect["kind"],
|
||||||
|
enabled: boolean,
|
||||||
|
) => void;
|
||||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||||
onRemoveSelectedLayer: () => void;
|
onRemoveSelectedLayer: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
@@ -99,7 +111,9 @@ export function EditorSidebar({
|
|||||||
onClearBlur,
|
onClearBlur,
|
||||||
}: EditorSidebarProps) {
|
}: EditorSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
<aside
|
||||||
|
className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<LayersPanel
|
<LayersPanel
|
||||||
layers={layers}
|
layers={layers}
|
||||||
@@ -114,24 +128,44 @@ export function EditorSidebar({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
|
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
|
||||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
<div
|
||||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
Fill
|
Fill
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
<label
|
||||||
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={fillColor ?? "#ff0000"}
|
value={fillColor ?? "#ff0000"}
|
||||||
onChange={(e) => onSetFillColor(e.target.value)}
|
onChange={(e) => onSetFillColor(e.target.value)}
|
||||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
{fillColor ?? "#ff0000"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
|
<label
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Tolerance
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
|
{fillTolerance ?? 32}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -154,42 +188,107 @@ export function EditorSidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
|
{tool === "brush" &&
|
||||||
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
|
onSetBrushColor &&
|
||||||
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
|
onSetBrushSize &&
|
||||||
|
onSetBrushOpacity &&
|
||||||
|
onSetBrushHardness ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
Brush
|
Brush
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
|
<label
|
||||||
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={brushColor ?? "#000000"}
|
value={brushColor ?? "#000000"}
|
||||||
onChange={(e) => onSetBrushColor(e.target.value)}
|
onChange={(e) => onSetBrushColor(e.target.value)}
|
||||||
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
{brushColor ?? "#000000"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
|
<label
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
|
{brushSize ?? 20}px
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={brushSize ?? 20}
|
||||||
|
onChange={(e) => onSetBrushSize(Number(e.target.value))}
|
||||||
|
className="w-full accent-[var(--color-accent-strong)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
|
<label
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Opacity
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
|
{Math.round((brushOpacity ?? 1) * 100)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round((brushOpacity ?? 1) * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSetBrushOpacity(Number(e.target.value) / 100)
|
||||||
|
}
|
||||||
|
className="w-full accent-[var(--color-accent-strong)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
|
<label
|
||||||
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
|
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
|
||||||
|
>
|
||||||
|
Hardness
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
|
{Math.round((brushHardness ?? 0.8) * 100)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round((brushHardness ?? 0.8) * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSetBrushHardness(Number(e.target.value) / 100)
|
||||||
|
}
|
||||||
|
className="w-full accent-[var(--color-accent-strong)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +305,10 @@ export function EditorSidebar({
|
|||||||
blurAmount={blurAmount}
|
blurAmount={blurAmount}
|
||||||
censorColor={censorColor}
|
censorColor={censorColor}
|
||||||
selectedFaceIndices={selectedFaceIndices}
|
selectedFaceIndices={selectedFaceIndices}
|
||||||
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
|
hasActiveBlur={Boolean(
|
||||||
|
selectedLayer &&
|
||||||
|
selectedLayer.effects.some((e) => e.kind === "face-blur"),
|
||||||
|
)}
|
||||||
onSetBlurMethod={onSetBlurMethod}
|
onSetBlurMethod={onSetBlurMethod}
|
||||||
onSetBlurAmount={onSetBlurAmount}
|
onSetBlurAmount={onSetBlurAmount}
|
||||||
onSetCensorColor={onSetCensorColor}
|
onSetCensorColor={onSetCensorColor}
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
|
|
||||||
import type { Layer } from "@pien-studio/types";
|
import type { Layer } from "@pien-studio/types";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
import type {
|
||||||
|
FaceDetectionOverlay,
|
||||||
|
FacePreview,
|
||||||
|
} from "../../hooks/use-face-detection";
|
||||||
import { useTranslations } from "../../hooks/use-translations";
|
import { useTranslations } from "../../hooks/use-translations";
|
||||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
import {
|
||||||
|
panelClass,
|
||||||
|
panelCounterClass,
|
||||||
|
panelInsetClass,
|
||||||
|
panelTitleClass,
|
||||||
|
} from "../../lib/theme";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
@@ -49,10 +57,20 @@ export function FacePanel({
|
|||||||
return (
|
return (
|
||||||
<div className={panelClass(isDark)}>
|
<div className={panelClass(isDark)}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.faceTool")}</h2>
|
<h2
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>{faceDetections.length}</span>
|
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>
|
</div>
|
||||||
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
<p
|
||||||
|
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
{faceStatus === "unsupported"
|
{faceStatus === "unsupported"
|
||||||
? t("editor.faceMlFailed")
|
? t("editor.faceMlFailed")
|
||||||
: faceStatus === "detecting"
|
: faceStatus === "detecting"
|
||||||
@@ -64,7 +82,9 @@ export function FacePanel({
|
|||||||
: t("editor.facesDetected")}
|
: t("editor.facesDetected")}
|
||||||
</p>
|
</p>
|
||||||
{hasFaces ? (
|
{hasFaces ? (
|
||||||
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
<div
|
||||||
|
className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}
|
||||||
|
>
|
||||||
{faceDetections.map((face, index) => (
|
{faceDetections.map((face, index) => (
|
||||||
<button
|
<button
|
||||||
key={`face-result-${index}`}
|
key={`face-result-${index}`}
|
||||||
@@ -79,12 +99,24 @@ export function FacePanel({
|
|||||||
onClick={() => onToggleFaceIndex(index)}
|
onClick={() => onToggleFaceIndex(index)}
|
||||||
>
|
>
|
||||||
{facePreviews[index]?.src ? (
|
{facePreviews[index]?.src ? (
|
||||||
<Image src={facePreviews[index].src} alt={`Face preview ${index + 1}`} width={40} height={40} unoptimized className="h-10 w-10 rounded object-cover" draggable={false} />
|
<Image
|
||||||
|
src={facePreviews[index].src}
|
||||||
|
alt={`Face preview ${index + 1}`}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
unoptimized
|
||||||
|
className="h-10 w-10 rounded object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`} />
|
<div
|
||||||
|
className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{t("editor.person")} {index + 1}</p>
|
<p className="font-semibold">
|
||||||
|
{t("editor.person")} {index + 1}
|
||||||
|
</p>
|
||||||
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
|
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -92,8 +124,12 @@ export function FacePanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={`mt-3 space-y-2 rounded-md border p-2 ${panelInsetClass(isDark)}`}>
|
<div
|
||||||
<label className="block text-[11px] font-semibold">{t("editor.blurMethod")}</label>
|
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">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{[
|
{[
|
||||||
{ id: "gaussian", label: t("editor.soft") },
|
{ id: "gaussian", label: t("editor.soft") },
|
||||||
@@ -110,7 +146,9 @@ export function FacePanel({
|
|||||||
? "bg-white/10 text-[#d7dae0]"
|
? "bg-white/10 text-[#d7dae0]"
|
||||||
: "bg-white text-[#1f2430]"
|
: "bg-white text-[#1f2430]"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
|
onClick={() =>
|
||||||
|
onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,23 +16,50 @@ type Props = {
|
|||||||
onJumpToFuture: (idx: number) => void;
|
onJumpToFuture: (idx: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) {
|
export function HistoryPanel({
|
||||||
|
history,
|
||||||
|
isDark,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onJumpToPast,
|
||||||
|
onJumpToFuture,
|
||||||
|
}: Props) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={panelClass(isDark)}>
|
<div className={panelClass(isDark)}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.history")}</h2>
|
<h2
|
||||||
|
className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{t("editor.history")}
|
||||||
|
</h2>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button type="button" onClick={onUndo} disabled={!canUndo} title={t("editor.undo")} className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
title={t("editor.undo")}
|
||||||
|
className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}
|
||||||
|
>
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onRedo} disabled={!canRedo} title={t("editor.redo")} className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
title={t("editor.redo")}
|
||||||
|
className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}
|
||||||
|
>
|
||||||
<Redo2 className="h-3.5 w-3.5" />
|
<Redo2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}>
|
<div
|
||||||
|
className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}
|
||||||
|
>
|
||||||
{[...history.past].reverse().map((_, i) => {
|
{[...history.past].reverse().map((_, i) => {
|
||||||
const idx = history.past.length - i;
|
const idx = history.past.length - i;
|
||||||
return (
|
return (
|
||||||
@@ -42,11 +69,17 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
|
|||||||
onClick={() => onJumpToPast(idx)}
|
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"}`}
|
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
|
||||||
>
|
>
|
||||||
{idx > history.past.length - 2 ? t("editor.beforeLastAction") : `${t("editor.step")} ${idx}`}
|
{idx > history.past.length - 2
|
||||||
|
? t("editor.beforeLastAction")
|
||||||
|
: `${t("editor.step")} ${idx}`}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button type="button" className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`} disabled>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
{t("editor.now")}
|
{t("editor.now")}
|
||||||
</button>
|
</button>
|
||||||
{[...history.future].map((_, i) => (
|
{[...history.future].map((_, i) => (
|
||||||
@@ -59,7 +92,13 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
|
|||||||
{t("editor.undoneStep")} {i + 1}
|
{t("editor.undoneStep")} {i + 1}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{history.past.length === 0 && history.future.length === 0 ? <p className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}>{t("editor.noHistoryYet")}</p> : null}
|
{history.past.length === 0 && history.future.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}
|
||||||
|
>
|
||||||
|
{t("editor.noHistoryYet")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Layer } from "@pien-studio/types";
|
import {
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type Layer,
|
||||||
|
type LayerEffect,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { useTranslations } from "../../hooks/use-translations";
|
import { useTranslations } from "../../hooks/use-translations";
|
||||||
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
|
import {
|
||||||
|
panelClass,
|
||||||
|
panelCounterClass,
|
||||||
|
panelInsetClass,
|
||||||
|
panelTitleClass,
|
||||||
|
} from "../../lib/theme";
|
||||||
|
|
||||||
const EFFECT_LABELS: Record<string, string> = {
|
const EFFECT_LABELS: Record<string, string> = {
|
||||||
"face-blur": "Face Blur",
|
"face-blur": "Face Blur",
|
||||||
@@ -16,19 +25,37 @@ type Props = {
|
|||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
onSelectLayer: (layerId: string) => void;
|
onSelectLayer: (layerId: string) => void;
|
||||||
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
||||||
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
|
onSetEffectEnabled: (
|
||||||
|
layerId: string,
|
||||||
|
kind: LayerEffect["kind"],
|
||||||
|
enabled: boolean,
|
||||||
|
) => void;
|
||||||
onMoveLayerOrder: (direction: "up" | "down") => void;
|
onMoveLayerOrder: (direction: "up" | "down") => void;
|
||||||
onRemoveSelectedLayer: () => void;
|
onRemoveSelectedLayer: () => void;
|
||||||
onAddLayer?: () => void;
|
onAddLayer?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) {
|
export function LayersPanel({
|
||||||
|
layers,
|
||||||
|
selectedLayerId,
|
||||||
|
isDark,
|
||||||
|
onSelectLayer,
|
||||||
|
onSetLayerVisible,
|
||||||
|
onSetEffectEnabled,
|
||||||
|
onMoveLayerOrder,
|
||||||
|
onRemoveSelectedLayer,
|
||||||
|
onAddLayer,
|
||||||
|
}: Props) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={panelClass(isDark)}>
|
<div className={panelClass(isDark)}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
|
<h2
|
||||||
|
className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{t("editor.layers")}
|
||||||
|
</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{onAddLayer ? (
|
{onAddLayer ? (
|
||||||
<button
|
<button
|
||||||
@@ -40,71 +67,106 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
|
|||||||
+ New
|
+ New
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}
|
||||||
|
>
|
||||||
{layers.length}
|
{layers.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
|
<div
|
||||||
{layers.slice().reverse().map((layer, idx) => {
|
className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}
|
||||||
|
>
|
||||||
|
{layers
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((layer, idx) => {
|
||||||
const isSelected = layer.id === selectedLayerId;
|
const isSelected = layer.id === selectedLayerId;
|
||||||
const isRaster = layer.type === "raster" || layer.type === "sticker";
|
const isRaster =
|
||||||
|
layer.type === "raster" || layer.type === "sticker";
|
||||||
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
const hasEffects = layer.effects.length > 0;
|
const hasEffects = layer.effects.length > 0;
|
||||||
const isHidden = layer.visible === false;
|
const isHidden = layer.visible === false;
|
||||||
return (
|
return (
|
||||||
<div key={layer.id}>
|
<div key={layer.id}>
|
||||||
<div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
|
<div
|
||||||
|
className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[var(--color-accent-strong)] text-white"
|
? "bg-[var(--color-accent-strong)] text-white"
|
||||||
: isDark
|
: isDark
|
||||||
? "text-[#d7dae0] hover:bg-white/10"
|
? "text-[#d7dae0] hover:bg-white/10"
|
||||||
: "text-[#1f2430] hover:bg-black/5"
|
: "text-[#1f2430] hover:bg-black/5"
|
||||||
} ${isHidden ? "opacity-40" : ""}`}>
|
} ${isHidden ? "opacity-40" : ""}`}
|
||||||
{/* Thumbnail */}
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectLayer(layer.id)}
|
onClick={() => onSelectLayer(layer.id)}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
|
<div
|
||||||
{isRaster && layer.sourceUri ? (
|
className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}
|
||||||
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
|
>
|
||||||
|
{isRaster && sourceUri ? (
|
||||||
|
<Image
|
||||||
|
src={sourceUri}
|
||||||
|
alt={layer.name ?? layer.type}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
unoptimized
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
|
<div
|
||||||
|
className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}
|
||||||
|
>
|
||||||
{layer.type}
|
{layer.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Info */}
|
<button
|
||||||
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
|
type="button"
|
||||||
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
|
onClick={() => onSelectLayer(layer.id)}
|
||||||
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
|
className="min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<p className="truncate font-semibold leading-tight">
|
||||||
|
{layer.name ?? layer.type}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}
|
||||||
|
>
|
||||||
{layer.type}
|
{layer.type}
|
||||||
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
|
{layer.opacity < 1
|
||||||
|
? ` · ${Math.round(layer.opacity * 100)}%`
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Visibility toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={isHidden ? "Show layer" : "Hide layer"}
|
title={isHidden ? "Show layer" : "Hide layer"}
|
||||||
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSetLayerVisible(layer.id, !isHidden);
|
||||||
|
}}
|
||||||
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
|
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
|
||||||
>
|
>
|
||||||
{isHidden
|
{isHidden ? (
|
||||||
? <EyeOff className="h-3.5 w-3.5" />
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
: <Eye className="h-3.5 w-3.5" />}
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Layer index */}
|
<span
|
||||||
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
|
className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
||||||
|
>
|
||||||
{layers.length - idx}
|
{layers.length - idx}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Effects chips */}
|
|
||||||
{hasEffects ? (
|
{hasEffects ? (
|
||||||
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
|
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
|
||||||
{layer.effects.map((effect) => {
|
{layer.effects.map((effect) => {
|
||||||
@@ -113,11 +175,21 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
|
|||||||
<button
|
<button
|
||||||
key={effect.kind}
|
key={effect.kind}
|
||||||
type="button"
|
type="button"
|
||||||
title={isDisabled ? "Enable effect" : "Disable effect"}
|
title={
|
||||||
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
|
isDisabled ? "Enable effect" : "Disable effect"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
onSetEffectEnabled(
|
||||||
|
layer.id,
|
||||||
|
effect.kind,
|
||||||
|
isDisabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
|
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
|
||||||
isDisabled
|
isDisabled
|
||||||
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
|
? isDark
|
||||||
|
? "bg-white/10 text-[#6b7280] line-through"
|
||||||
|
: "bg-black/5 text-[#9ca3af] line-through"
|
||||||
: isSelected
|
: isSelected
|
||||||
? "bg-white/20 text-white"
|
? "bg-white/20 text-white"
|
||||||
: isDark
|
: isDark
|
||||||
@@ -125,7 +197,11 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
|
|||||||
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
|
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
|
{isDisabled ? (
|
||||||
|
<EyeOff className="h-2.5 w-2.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
{EFFECT_LABELS[effect.kind] ?? effect.kind}
|
{EFFECT_LABELS[effect.kind] ?? effect.kind}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -136,15 +212,35 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{layers.length === 0 ? (
|
{layers.length === 0 ? (
|
||||||
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
|
<div
|
||||||
|
className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
||||||
|
>
|
||||||
{t("editor.noLayersYet")}
|
{t("editor.noLayersYet")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-1.5">
|
<div className="mt-2 flex gap-1.5">
|
||||||
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
|
<button
|
||||||
<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>
|
type="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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,19 +11,32 @@ type Props = {
|
|||||||
onSetTool: (tool: EditorToolId) => void;
|
onSetTool: (tool: EditorToolId) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
|
export function ToolRail({
|
||||||
|
controllers,
|
||||||
|
selectedTool,
|
||||||
|
isDark,
|
||||||
|
icons,
|
||||||
|
onSetTool,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<aside className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
<aside
|
||||||
|
className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{controllers.map((controller) => {
|
{controllers.map((controller) => {
|
||||||
const Icon = icons[controller.id] ?? MousePointer2;
|
const Icon = icons[controller.id] ?? MousePointer2;
|
||||||
const isSelected = controller.kind === "mode" && controller.id === selectedTool;
|
const isSelected =
|
||||||
|
controller.kind === "mode" && controller.id === selectedTool;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={controller.id}
|
key={controller.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={controller.label}
|
title={controller.label}
|
||||||
onClick={() => (controller.kind === "mode" ? onSetTool(controller.id) : controller.run())}
|
onClick={() =>
|
||||||
|
controller.kind === "mode"
|
||||||
|
? onSetTool(controller.id)
|
||||||
|
: controller.run()
|
||||||
|
}
|
||||||
className={`rounded border p-2 text-[11px] font-medium ${
|
className={`rounded border p-2 text-[11px] font-medium ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const BUILD_HASH = process.env.NEXT_PUBLIC_GIT_HASH || "unknown";
|
const BUILD_HASH = process.env.NEXT_PUBLIC_GIT_HASH || "unknown";
|
||||||
const SHOULD_LOAD_DEV_HASH = process.env.NODE_ENV === "development" && BUILD_HASH === "unknown";
|
const SHOULD_LOAD_DEV_HASH =
|
||||||
|
process.env.NODE_ENV === "development" && BUILD_HASH === "unknown";
|
||||||
|
|
||||||
function formatHash(hash: string) {
|
function formatHash(hash: string) {
|
||||||
return hash === "unknown" ? hash : hash.slice(0, 7);
|
return hash === "unknown" ? hash : hash.slice(0, 7);
|
||||||
@@ -17,7 +18,9 @@ export function Footer() {
|
|||||||
|
|
||||||
fetch("/api/commit-hash")
|
fetch("/api/commit-hash")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data: { hash?: string }) => setHash(formatHash(data.hash || "unknown")))
|
.then((data: { hash?: string }) =>
|
||||||
|
setHash(formatHash(data.hash || "unknown")),
|
||||||
|
)
|
||||||
.catch(() => setHash("unknown"));
|
.catch(() => setHash("unknown"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,22 @@ export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<label className={cx("inline-flex items-center rounded border px-2", subtleButtonClass(isDark), wrapperHeight)}>
|
<label
|
||||||
<span className={cx("mr-2 font-semibold opacity-70", textSize)}>{t("ui.locale")}</span>
|
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
|
<select
|
||||||
aria-label={t("ui.locale")}
|
aria-label={t("ui.locale")}
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={(event) => setLocale(event.target.value as "en" | "th" | "ja")}
|
onChange={(event) =>
|
||||||
|
setLocale(event.target.value as "en" | "th" | "ja")
|
||||||
|
}
|
||||||
className={cx(
|
className={cx(
|
||||||
"bg-transparent font-semibold outline-none",
|
"bg-transparent font-semibold outline-none",
|
||||||
textSize,
|
textSize,
|
||||||
@@ -35,12 +45,22 @@ export function UiPreferences({ compact = false }: { compact?: boolean }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
const next =
|
||||||
|
theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
}}
|
}}
|
||||||
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
|
className={cx(
|
||||||
|
"rounded border px-3 font-semibold",
|
||||||
|
subtleButtonClass(isDark),
|
||||||
|
wrapperHeight,
|
||||||
|
textSize,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? t("ui.darkMode") : theme === "system" ? t("ui.systemMode") : t("ui.lightMode")}
|
{theme === "dark"
|
||||||
|
? t("ui.darkMode")
|
||||||
|
: theme === "system"
|
||||||
|
? t("ui.systemMode")
|
||||||
|
: t("ui.lightMode")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { startAssetCleanupJob } from "@pien-studio/storage";
|
import { localProjectRepository } from "../lib/project-repository";
|
||||||
|
|
||||||
export function useAssetCleanupJob() {
|
export function useAssetCleanupJob() {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const stop = startAssetCleanupJob();
|
if (typeof window === "undefined") return undefined;
|
||||||
return () => stop();
|
const id = window.setInterval(() => {
|
||||||
|
void localProjectRepository.cleanupAssets();
|
||||||
|
}, 45_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ type UseCanvasInteractionsOptions = {
|
|||||||
onInteractionEnd?: () => void;
|
onInteractionEnd?: () => void;
|
||||||
onContextMenu?: (x: number, y: number) => void;
|
onContextMenu?: (x: number, y: number) => void;
|
||||||
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
onFillLayer?: (layerId: string, x: number, y: number) => void;
|
||||||
onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void;
|
onBrushStrokeStart?: (
|
||||||
|
layerId: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
layerWidth: number,
|
||||||
|
layerHeight: number,
|
||||||
|
) => void;
|
||||||
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
|
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
|
||||||
onBrushStrokeEnd?: (layerId: string) => void;
|
onBrushStrokeEnd?: (layerId: string) => void;
|
||||||
};
|
};
|
||||||
@@ -53,7 +59,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
onBrushStrokeMove,
|
onBrushStrokeMove,
|
||||||
onBrushStrokeEnd,
|
onBrushStrokeEnd,
|
||||||
} = options;
|
} = options;
|
||||||
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
|
const [viewport, setViewport] = React.useState<Viewport>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const isPanning = React.useRef(false);
|
const isPanning = React.useRef(false);
|
||||||
const lastPos = React.useRef({ x: 0, y: 0 });
|
const lastPos = React.useRef({ x: 0, y: 0 });
|
||||||
@@ -64,7 +74,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
const interactionActiveRef = React.useRef(false);
|
const interactionActiveRef = React.useRef(false);
|
||||||
const dragMoveRafRef = React.useRef<number | null>(null);
|
const dragMoveRafRef = React.useRef<number | null>(null);
|
||||||
const resizeMoveRafRef = React.useRef<number | null>(null);
|
const resizeMoveRafRef = React.useRef<number | null>(null);
|
||||||
const resizeMovePendingRef = React.useRef<{ width: number; height: number; x: number; y: number } | null>(null);
|
const resizeMovePendingRef = React.useRef<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
const dragRef = React.useRef<{
|
const dragRef = React.useRef<{
|
||||||
id: string;
|
id: string;
|
||||||
startLayerX: number;
|
startLayerX: number;
|
||||||
@@ -95,7 +110,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
startRotation: number;
|
startRotation: number;
|
||||||
lastRotation: number;
|
lastRotation: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
|
const brushRef = React.useRef<{
|
||||||
|
id: string;
|
||||||
|
lastX: number;
|
||||||
|
lastY: number;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const pinchRef = React.useRef<{
|
const pinchRef = React.useRef<{
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -119,7 +139,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
onInteractionEnd?.();
|
onInteractionEnd?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientDist(t0: Pick<Touch, "clientX" | "clientY">, t1: Pick<Touch, "clientX" | "clientY">) {
|
function clientDist(
|
||||||
|
t0: Pick<Touch, "clientX" | "clientY">,
|
||||||
|
t1: Pick<Touch, "clientX" | "clientY">,
|
||||||
|
) {
|
||||||
const dx = t0.clientX - t1.clientX;
|
const dx = t0.clientX - t1.clientX;
|
||||||
const dy = t0.clientY - t1.clientY;
|
const dy = t0.clientY - t1.clientY;
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
@@ -130,12 +153,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
const factor = e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
|
const factor =
|
||||||
|
e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
|
||||||
if (!Number.isFinite(factor) || factor === 0) return;
|
if (!Number.isFinite(factor) || factor === 0) return;
|
||||||
const pivotX = e.clientX - rect.left;
|
const pivotX = e.clientX - rect.left;
|
||||||
const pivotY = e.clientY - rect.top;
|
const pivotY = e.clientY - rect.top;
|
||||||
setViewport((vp) => {
|
setViewport((vp) => {
|
||||||
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
|
const nextScale = Math.max(
|
||||||
|
MIN_SCALE,
|
||||||
|
Math.min(MAX_SCALE, vp.scale * factor),
|
||||||
|
);
|
||||||
const scaleChange = nextScale / vp.scale;
|
const scaleChange = nextScale / vp.scale;
|
||||||
return {
|
return {
|
||||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||||
@@ -147,7 +174,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
|
|
||||||
function zoomBy(factor: number, pivotX: number, pivotY: number) {
|
function zoomBy(factor: number, pivotX: number, pivotY: number) {
|
||||||
setViewport((vp) => {
|
setViewport((vp) => {
|
||||||
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
|
const newScale = Math.max(
|
||||||
|
MIN_SCALE,
|
||||||
|
Math.min(MAX_SCALE, vp.scale * factor),
|
||||||
|
);
|
||||||
const scaleChange = newScale / vp.scale;
|
const scaleChange = newScale / vp.scale;
|
||||||
return {
|
return {
|
||||||
x: pivotX - (pivotX - vp.x) * scaleChange,
|
x: pivotX - (pivotX - vp.x) * scaleChange,
|
||||||
@@ -193,13 +223,21 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") {
|
||||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
if (
|
||||||
|
!(e.target instanceof HTMLElement) ||
|
||||||
|
/^(input|textarea|select)$/i.test(e.target.tagName)
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!isSpacePan) setIsSpacePan(true);
|
if (!isSpacePan) setIsSpacePan(true);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Shift") {
|
if (e.key === "Shift") {
|
||||||
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
|
if (
|
||||||
|
!(e.target instanceof HTMLElement) ||
|
||||||
|
/^(input|textarea|select)$/i.test(e.target.tagName)
|
||||||
|
)
|
||||||
|
return;
|
||||||
setIsShiftPressed(true);
|
setIsShiftPressed(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,11 +269,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
|
window.addEventListener("wheel", handleWheelCaptured, {
|
||||||
|
capture: true,
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
|
window.removeEventListener("wheel", handleWheelCaptured, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [isSpacePan]);
|
}, [isSpacePan]);
|
||||||
|
|
||||||
@@ -281,7 +324,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
}
|
}
|
||||||
const toolDef = getToolDefinition(tool);
|
const toolDef = getToolDefinition(tool);
|
||||||
if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) {
|
if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) {
|
||||||
const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
|
const { centerX, centerY, startAngle, startRotation, id } =
|
||||||
|
rotateRef.current;
|
||||||
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
||||||
const delta = currentAngle - startAngle;
|
const delta = currentAngle - startAngle;
|
||||||
const nextRotation = startRotation + (delta * 180) / Math.PI;
|
const nextRotation = startRotation + (delta * 180) / Math.PI;
|
||||||
@@ -312,7 +356,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) {
|
if (
|
||||||
|
!e.shiftKey &&
|
||||||
|
(corner === "br" ||
|
||||||
|
corner === "bl" ||
|
||||||
|
corner === "tr" ||
|
||||||
|
corner === "tl")
|
||||||
|
) {
|
||||||
const aspect = resizeRef.current.aspect || 1;
|
const aspect = resizeRef.current.aspect || 1;
|
||||||
if (Math.abs(dx) > Math.abs(dy)) {
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
nextHeight = Math.max(8, nextWidth / aspect);
|
nextHeight = Math.max(8, nextWidth / aspect);
|
||||||
@@ -321,8 +371,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth;
|
if (corner === "bl" || corner === "tl")
|
||||||
if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight;
|
offsetX = resizeRef.current.startWidth - nextWidth;
|
||||||
|
if (corner === "tr" || corner === "tl")
|
||||||
|
offsetY = resizeRef.current.startHeight - nextHeight;
|
||||||
|
|
||||||
resizeRef.current.lastWidth = nextWidth;
|
resizeRef.current.lastWidth = nextWidth;
|
||||||
resizeRef.current.lastHeight = nextHeight;
|
resizeRef.current.lastHeight = nextHeight;
|
||||||
@@ -338,7 +390,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
if (!resizeRef.current || !resizeMovePendingRef.current) return;
|
if (!resizeRef.current || !resizeMovePendingRef.current) return;
|
||||||
const pending = resizeMovePendingRef.current;
|
const pending = resizeMovePendingRef.current;
|
||||||
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
||||||
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
|
if (
|
||||||
|
(pending.x !== resizeRef.current.startLayerX ||
|
||||||
|
pending.y !== resizeRef.current.startLayerY) &&
|
||||||
|
onMoveLayer
|
||||||
|
) {
|
||||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -349,14 +405,19 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
|
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
|
||||||
const nextX = dragRef.current.startLayerX + dx;
|
const nextX = dragRef.current.startLayerX + dx;
|
||||||
const nextY = dragRef.current.startLayerY + dy;
|
const nextY = dragRef.current.startLayerY + dy;
|
||||||
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY) return;
|
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY)
|
||||||
|
return;
|
||||||
dragRef.current.lastX = nextX;
|
dragRef.current.lastX = nextX;
|
||||||
dragRef.current.lastY = nextY;
|
dragRef.current.lastY = nextY;
|
||||||
if (dragMoveRafRef.current !== null) return;
|
if (dragMoveRafRef.current !== null) return;
|
||||||
dragMoveRafRef.current = window.requestAnimationFrame(() => {
|
dragMoveRafRef.current = window.requestAnimationFrame(() => {
|
||||||
dragMoveRafRef.current = null;
|
dragMoveRafRef.current = null;
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
onMoveLayer(dragRef.current.id, dragRef.current.lastX, dragRef.current.lastY);
|
onMoveLayer(
|
||||||
|
dragRef.current.id,
|
||||||
|
dragRef.current.lastX,
|
||||||
|
dragRef.current.lastY,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +433,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) {
|
if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) {
|
||||||
const pending = resizeMovePendingRef.current;
|
const pending = resizeMovePendingRef.current;
|
||||||
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
|
||||||
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
|
if (
|
||||||
|
(pending.x !== resizeRef.current.startLayerX ||
|
||||||
|
pending.y !== resizeRef.current.startLayerY) &&
|
||||||
|
onMoveLayer
|
||||||
|
) {
|
||||||
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,11 +446,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
isMiddleMousePan.current = false;
|
isMiddleMousePan.current = false;
|
||||||
if (dragRef.current && onMoveLayerEnd) {
|
if (dragRef.current && onMoveLayerEnd) {
|
||||||
const { id, lastX, lastY } = dragRef.current;
|
const { id, lastX, lastY } = dragRef.current;
|
||||||
if (typeof lastX === "number" && typeof lastY === "number") onMoveLayerEnd(id, lastX, lastY);
|
if (typeof lastX === "number" && typeof lastY === "number")
|
||||||
|
onMoveLayerEnd(id, lastX, lastY);
|
||||||
}
|
}
|
||||||
if (resizeRef.current && onResizeLayerEnd) {
|
if (resizeRef.current && onResizeLayerEnd) {
|
||||||
const { id, lastWidth, lastHeight } = resizeRef.current;
|
const { id, lastWidth, lastHeight } = resizeRef.current;
|
||||||
if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight);
|
if (typeof lastWidth === "number" && typeof lastHeight === "number")
|
||||||
|
onResizeLayerEnd(id, lastWidth, lastHeight);
|
||||||
}
|
}
|
||||||
if (rotateRef.current && onRotateLayerEnd) {
|
if (rotateRef.current && onRotateLayerEnd) {
|
||||||
const { id, lastRotation } = rotateRef.current;
|
const { id, lastRotation } = rotateRef.current;
|
||||||
@@ -402,7 +469,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
endInteraction();
|
endInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
|
function onLayerPointerDown(
|
||||||
|
e: React.PointerEvent<HTMLDivElement>,
|
||||||
|
layer: Layer,
|
||||||
|
) {
|
||||||
if (isSpacePan) return;
|
if (isSpacePan) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const toolDef = getToolDefinition(tool);
|
const toolDef = getToolDefinition(tool);
|
||||||
@@ -413,9 +483,14 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
onSelectLayer(layer.id);
|
onSelectLayer(layer.id);
|
||||||
if (tool === "brush" && onBrushStrokeStart) {
|
if (tool === "brush" && onBrushStrokeStart) {
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
brushRef.current = { id: layer.id, lastX: localX, lastY: localY, rect: e.currentTarget.getBoundingClientRect() };
|
brushRef.current = {
|
||||||
const lw = layer.width ?? Math.round(200 * layer.scale);
|
id: layer.id,
|
||||||
const lh = layer.height ?? Math.round(150 * layer.scale);
|
lastX: localX,
|
||||||
|
lastY: localY,
|
||||||
|
rect: e.currentTarget.getBoundingClientRect(),
|
||||||
|
};
|
||||||
|
const lw = layer.width;
|
||||||
|
const lh = layer.height;
|
||||||
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
|
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
|
||||||
beginInteraction();
|
beginInteraction();
|
||||||
} else if (onFillLayer) {
|
} else if (onFillLayer) {
|
||||||
@@ -437,13 +512,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
onSelectLayer(layer.id);
|
onSelectLayer(layer.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
|
function onResizeHandleDown(
|
||||||
|
e: React.PointerEvent<HTMLButtonElement>,
|
||||||
|
layer: Layer,
|
||||||
|
corner: string,
|
||||||
|
) {
|
||||||
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
|
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
rotateRef.current = null;
|
rotateRef.current = null;
|
||||||
const width = layer.width ?? Math.round(200 * layer.scale);
|
const width = layer.width;
|
||||||
const height = layer.height ?? Math.round(150 * layer.scale);
|
const height = layer.height;
|
||||||
resizeRef.current = {
|
resizeRef.current = {
|
||||||
id: layer.id,
|
id: layer.id,
|
||||||
startWidth: width,
|
startWidth: width,
|
||||||
@@ -461,16 +540,27 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
onSelectLayer(layer.id);
|
onSelectLayer(layer.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
|
function onRotateHandleDown(
|
||||||
|
e: React.PointerEvent<HTMLButtonElement>,
|
||||||
|
layer: Layer,
|
||||||
|
) {
|
||||||
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
|
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
resizeRef.current = null;
|
resizeRef.current = null;
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
const width = layer.width ?? Math.round(200 * layer.scale);
|
const width = layer.width;
|
||||||
const height = layer.height ?? Math.round(150 * layer.scale);
|
const height = layer.height;
|
||||||
const centerX = (rect?.left ?? 0) + viewport.x + layer.x * viewport.scale + (width * viewport.scale) / 2;
|
const centerX =
|
||||||
const centerY = (rect?.top ?? 0) + viewport.y + layer.y * viewport.scale + (height * viewport.scale) / 2;
|
(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);
|
const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
|
||||||
rotateRef.current = {
|
rotateRef.current = {
|
||||||
id: layer.id,
|
id: layer.id,
|
||||||
@@ -488,7 +578,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
const midX =
|
||||||
|
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||||
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||||
pinchRef.current = {
|
pinchRef.current = {
|
||||||
active: true,
|
active: true,
|
||||||
@@ -507,7 +598,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const px = clientDist(e.touches[0], e.touches[1]);
|
const px = clientDist(e.touches[0], e.touches[1]);
|
||||||
const ratio = px / pinchRef.current.initialPinchPx;
|
const ratio = px / pinchRef.current.initialPinchPx;
|
||||||
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio));
|
const nextScale = Math.max(
|
||||||
|
MIN_SCALE,
|
||||||
|
Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio),
|
||||||
|
);
|
||||||
const scaleChange = nextScale / pinchRef.current.initialScale;
|
const scaleChange = nextScale / pinchRef.current.initialScale;
|
||||||
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
|
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
|
||||||
setViewport({
|
setViewport({
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ describe("useEditorBindings", () => {
|
|||||||
const { result } = renderHook(() => useEditorBindings());
|
const { result } = renderHook(() => useEditorBindings());
|
||||||
|
|
||||||
expect(result.current.state.project.layers.length).toBe(1);
|
expect(result.current.state.project.layers.length).toBe(1);
|
||||||
expect(result.current.state.selectedLayer?.id).toBe(result.current.state.selectedLayerId);
|
expect(result.current.state.selectedLayer?.id).toBe(
|
||||||
|
result.current.state.selectedLayerId,
|
||||||
|
);
|
||||||
expect(typeof result.current.actions.undo).toBe("function");
|
expect(typeof result.current.actions.undo).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function useEditorBindings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedLayer = React.useMemo(
|
const selectedLayer = React.useMemo(
|
||||||
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
|
() =>
|
||||||
|
state.project.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
) ?? null,
|
||||||
[state.project.layers, state.selectedLayerId],
|
[state.project.layers, state.selectedLayerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import { CONTEXT_MENU_SIZE } from "../lib/editor-constants";
|
|||||||
type ContextMenuPosition = { x: number; y: number };
|
type ContextMenuPosition = { x: number; y: number };
|
||||||
|
|
||||||
export function useEditorContextMenu() {
|
export function useEditorContextMenu() {
|
||||||
const [contextMenu, setContextMenu] = React.useState<ContextMenuPosition | null>(null);
|
const [contextMenu, setContextMenu] =
|
||||||
|
React.useState<ContextMenuPosition | null>(null);
|
||||||
|
|
||||||
const openContextMenu = React.useCallback((x: number, y: number) => {
|
const openContextMenu = React.useCallback((x: number, y: number) => {
|
||||||
const rect = document.documentElement.getBoundingClientRect();
|
const rect = document.documentElement.getBoundingClientRect();
|
||||||
const { width: menuWidth, height: menuHeight, viewportPadding: pad } = CONTEXT_MENU_SIZE;
|
const {
|
||||||
|
width: menuWidth,
|
||||||
|
height: menuHeight,
|
||||||
|
viewportPadding: pad,
|
||||||
|
} = CONTEXT_MENU_SIZE;
|
||||||
const maxX = rect.width - menuWidth - pad;
|
const maxX = rect.width - menuWidth - pad;
|
||||||
const maxY = rect.height - menuHeight - pad;
|
const maxY = rect.height - menuHeight - pad;
|
||||||
setContextMenu({ x: Math.max(pad, Math.min(x, maxX)), y: Math.max(pad, Math.min(y, maxY)) });
|
setContextMenu({
|
||||||
|
x: Math.max(pad, Math.min(x, maxX)),
|
||||||
|
y: Math.max(pad, Math.min(y, maxY)),
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeContextMenu = React.useCallback(() => {
|
const closeContextMenu = React.useCallback(() => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
type Translator = (
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
export function useEditorLabels(t: Translator) {
|
export function useEditorLabels(t: Translator) {
|
||||||
const headerLabels = React.useMemo(
|
const headerLabels = React.useMemo(
|
||||||
@@ -29,7 +32,11 @@ export function useEditorLabels(t: Translator) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contextMenuLabels = React.useMemo(
|
const contextMenuLabels = React.useMemo(
|
||||||
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
|
() => ({
|
||||||
|
copy: t("editor.copy"),
|
||||||
|
cut: t("editor.cut"),
|
||||||
|
paste: t("editor.paste"),
|
||||||
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,7 +45,8 @@ export function useEditorLabels(t: Translator) {
|
|||||||
resize: t("editor.resize"),
|
resize: t("editor.resize"),
|
||||||
faceMlFailedShort: t("editor.faceMlFailedShort"),
|
faceMlFailedShort: t("editor.faceMlFailedShort"),
|
||||||
detectingFacesShort: t("editor.detectingFacesShort"),
|
detectingFacesShort: t("editor.detectingFacesShort"),
|
||||||
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
|
faceDetectionTip: (count: number) =>
|
||||||
|
t("editor.faceDetectionTip", { count }),
|
||||||
import: t("editor.import"),
|
import: t("editor.import"),
|
||||||
mood: t("editor.mood"),
|
mood: t("editor.mood"),
|
||||||
quick: t("editor.quick"),
|
quick: t("editor.quick"),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createProject } from "@pien-studio/editor-core";
|
import { createProject } from "@pien-studio/editor-core";
|
||||||
import { upsertProject } from "@pien-studio/storage";
|
import { localProjectRepository } from "../lib/project-repository";
|
||||||
|
|
||||||
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
type Translator = (
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
type UseEditorProjectLifecycleOptions = {
|
type UseEditorProjectLifecycleOptions = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -12,7 +15,9 @@ type UseEditorProjectLifecycleOptions = {
|
|||||||
t: Translator;
|
t: Translator;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
|
export function useEditorProjectLifecycle(
|
||||||
|
options: UseEditorProjectLifecycleOptions,
|
||||||
|
) {
|
||||||
const { projectId, hydrate, loadProjectById, setProject, t } = options;
|
const { projectId, hydrate, loadProjectById, setProject, t } = options;
|
||||||
const initializedProjectId = React.useRef<string | null>(null);
|
const initializedProjectId = React.useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOpti
|
|||||||
if (projectId === "new") {
|
if (projectId === "new") {
|
||||||
const nextProject = createProject(t("home.untitledProject"));
|
const nextProject = createProject(t("home.untitledProject"));
|
||||||
setProject(nextProject);
|
setProject(nextProject);
|
||||||
void upsertProject(nextProject);
|
void localProjectRepository.upsertProject(nextProject);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void loadProjectById(projectId);
|
void loadProjectById(projectId);
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ type UseEditorShortcutsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isEditableTarget(e: Event) {
|
function isEditableTarget(e: Event) {
|
||||||
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
|
return (
|
||||||
|
e.target instanceof HTMLElement &&
|
||||||
|
/^(input|textarea|select)$/i.test(e.target.tagName)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
|
|||||||
return {
|
return {
|
||||||
id: "layer-1",
|
id: "layer-1",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
sourceUri: "data:image/png;base64,abc",
|
asset: { kind: "inline", uri: "data:image/png;base64,abc" },
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
effects: [],
|
effects: [],
|
||||||
visible: true,
|
visible: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
} as Layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useFaceBlurWorkflow", () => {
|
describe("useFaceBlurWorkflow", () => {
|
||||||
@@ -25,8 +27,24 @@ describe("useFaceBlurWorkflow", () => {
|
|||||||
const removeLayerEffect = vi.fn();
|
const removeLayerEffect = vi.fn();
|
||||||
const selectedLayer = makeImageLayer();
|
const selectedLayer = makeImageLayer();
|
||||||
const faceDetections = [
|
const faceDetections = [
|
||||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
{
|
||||||
{ x: 5, y: 8, width: 7, height: 9, label: "b", sourceWidth: 100, sourceHeight: 100 },
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 10,
|
||||||
|
height: 12,
|
||||||
|
label: "a",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 5,
|
||||||
|
y: 8,
|
||||||
|
width: 7,
|
||||||
|
height: 9,
|
||||||
|
label: "b",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
@@ -41,8 +59,14 @@ describe("useFaceBlurWorkflow", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
|
||||||
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
|
const faceBlurEffect = result.current.faceBlurPreview?.effects.find(
|
||||||
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2);
|
(e) => e.kind === "face-blur",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
faceBlurEffect?.kind === "face-blur"
|
||||||
|
? faceBlurEffect.regions
|
||||||
|
: undefined,
|
||||||
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +86,17 @@ describe("useFaceBlurWorkflow", () => {
|
|||||||
useFaceBlurWorkflow({
|
useFaceBlurWorkflow({
|
||||||
selectedLayer,
|
selectedLayer,
|
||||||
faceDetectionsLayerId: "layer-1",
|
faceDetectionsLayerId: "layer-1",
|
||||||
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
|
faceDetections: [
|
||||||
|
{
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 10,
|
||||||
|
height: 12,
|
||||||
|
label: "a",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
setLayerEffect,
|
setLayerEffect,
|
||||||
removeLayerEffect,
|
removeLayerEffect,
|
||||||
}),
|
}),
|
||||||
@@ -79,8 +113,24 @@ describe("useFaceBlurWorkflow", () => {
|
|||||||
const removeLayerEffect = vi.fn();
|
const removeLayerEffect = vi.fn();
|
||||||
const selectedLayer = makeImageLayer();
|
const selectedLayer = makeImageLayer();
|
||||||
const faceDetections = [
|
const faceDetections = [
|
||||||
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
|
{
|
||||||
{ x: 50, y: 60, width: 20, height: 22, label: "b", sourceWidth: 100, sourceHeight: 100 },
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 10,
|
||||||
|
height: 12,
|
||||||
|
label: "a",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 50,
|
||||||
|
y: 60,
|
||||||
|
width: 20,
|
||||||
|
height: 22,
|
||||||
|
label: "b",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
@@ -115,7 +165,15 @@ describe("useFaceBlurWorkflow", () => {
|
|||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 14,
|
amount: 14,
|
||||||
regions: expect.arrayContaining([
|
regions: expect.arrayContaining([
|
||||||
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
|
expect.objectContaining({
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
width: 10,
|
||||||
|
height: 12,
|
||||||
|
censorColor: "#111111",
|
||||||
|
sourceWidth: 100,
|
||||||
|
sourceHeight: 100,
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
|
import {
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type FaceBlurMethod,
|
||||||
|
type Layer,
|
||||||
|
type LayerEffect,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import type { FaceDetectionOverlay } from "./use-face-detection";
|
import type { FaceDetectionOverlay } from "./use-face-detection";
|
||||||
|
|
||||||
type FaceBlurPreview = {
|
type FaceBlurPreview = {
|
||||||
@@ -21,15 +26,31 @@ type UseFaceBlurWorkflowOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||||
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
|
const {
|
||||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
selectedLayer,
|
||||||
|
faceDetectionsLayerId,
|
||||||
|
faceDetections,
|
||||||
|
setLayerEffect,
|
||||||
|
removeLayerEffect,
|
||||||
|
} = options;
|
||||||
|
const [blurMethod, setBlurMethod] =
|
||||||
|
React.useState<FaceBlurMethod>("gaussian");
|
||||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||||
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
|
const [faceSelection, setFaceSelection] =
|
||||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
|
React.useState<FaceSelectionState | null>(null);
|
||||||
|
const hasDetectableSelection = Boolean(
|
||||||
|
selectedLayer &&
|
||||||
|
selectedLayer.type === "raster" &&
|
||||||
|
faceDetectionsLayerId === selectedLayer.id,
|
||||||
|
);
|
||||||
|
|
||||||
const faceBlurEffect = React.useMemo(
|
const faceBlurEffect = React.useMemo(
|
||||||
() => selectedLayer?.effects.find((e): e is Extract<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
|
() =>
|
||||||
|
selectedLayer?.effects.find(
|
||||||
|
(e): e is Extract<LayerEffect, { kind: "face-blur" }> =>
|
||||||
|
e.kind === "face-blur",
|
||||||
|
) ?? null,
|
||||||
[selectedLayer?.effects],
|
[selectedLayer?.effects],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -40,18 +61,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
return faceDetections.map((_, index) => index);
|
return faceDetections.map((_, index) => index);
|
||||||
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
|
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
|
||||||
|
|
||||||
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
|
const selectedFaceIndices =
|
||||||
|
faceSelection?.key === selectionKey
|
||||||
|
? faceSelection.indices
|
||||||
|
: defaultSelectedFaceIndices;
|
||||||
|
|
||||||
const buildBlurRegions = React.useCallback(
|
const buildBlurRegions = React.useCallback(
|
||||||
(indices: number[]) => {
|
(indices: number[]) => {
|
||||||
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
||||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
|
if (
|
||||||
|
faceDetectionsLayerId !== selectedLayer.id ||
|
||||||
|
faceDetections.length === 0
|
||||||
|
)
|
||||||
|
return [];
|
||||||
const indexSet = new Set(indices);
|
const indexSet = new Set(indices);
|
||||||
return faceDetections
|
return faceDetections
|
||||||
.filter((_, index) => indexSet.has(index))
|
.filter((_, index) => indexSet.has(index))
|
||||||
.map((face) => {
|
.map((face) => {
|
||||||
const baseWidth = Math.max(1, selectedLayer.width ?? face.sourceWidth);
|
const baseWidth = Math.max(1, selectedLayer.width);
|
||||||
const baseHeight = Math.max(1, selectedLayer.height ?? face.sourceHeight);
|
const baseHeight = Math.max(1, selectedLayer.height);
|
||||||
const scaleX = face.sourceWidth / baseWidth;
|
const scaleX = face.sourceWidth / baseWidth;
|
||||||
const scaleY = face.sourceHeight / baseHeight;
|
const scaleY = face.sourceHeight / baseHeight;
|
||||||
return {
|
return {
|
||||||
@@ -68,13 +96,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
|
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
const toggleFaceIndex = React.useCallback(
|
||||||
|
(index: number) => {
|
||||||
setFaceSelection((prev) => {
|
setFaceSelection((prev) => {
|
||||||
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
|
const current =
|
||||||
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
|
prev?.key === selectionKey
|
||||||
|
? prev.indices
|
||||||
|
: defaultSelectedFaceIndices;
|
||||||
|
const indices = current.includes(index)
|
||||||
|
? current.filter((item) => item !== index)
|
||||||
|
: [...current, index];
|
||||||
return { key: selectionKey, indices };
|
return { key: selectionKey, indices };
|
||||||
});
|
});
|
||||||
}, [defaultSelectedFaceIndices, selectionKey]);
|
},
|
||||||
|
[defaultSelectedFaceIndices, selectionKey],
|
||||||
|
);
|
||||||
|
|
||||||
const clearBlur = React.useCallback(() => {
|
const clearBlur = React.useCallback(() => {
|
||||||
if (!selectedLayer) return;
|
if (!selectedLayer) return;
|
||||||
@@ -84,8 +120,17 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
|
|
||||||
const blurFaces = React.useCallback(
|
const blurFaces = React.useCallback(
|
||||||
(indices: number[]) => {
|
(indices: number[]) => {
|
||||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
|
if (
|
||||||
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
|
!selectedLayer ||
|
||||||
|
selectedLayer.type !== "raster" ||
|
||||||
|
!getLayerRuntimeSource(selectedLayer)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (
|
||||||
|
faceDetectionsLayerId !== selectedLayer.id ||
|
||||||
|
faceDetections.length === 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
const regions = buildBlurRegions(indices);
|
const regions = buildBlurRegions(indices);
|
||||||
setLayerEffect(selectedLayer.id, {
|
setLayerEffect(selectedLayer.id, {
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
@@ -97,11 +142,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
});
|
});
|
||||||
setFaceSelection({ key: selectionKey, indices: [] });
|
setFaceSelection({ key: selectionKey, indices: [] });
|
||||||
},
|
},
|
||||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
|
[
|
||||||
|
blurAmount,
|
||||||
|
blurMethod,
|
||||||
|
buildBlurRegions,
|
||||||
|
censorColor,
|
||||||
|
faceDetections.length,
|
||||||
|
faceDetectionsLayerId,
|
||||||
|
selectedLayer,
|
||||||
|
selectionKey,
|
||||||
|
setLayerEffect,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
|
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
|
||||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
if (
|
||||||
|
!hasDetectableSelection ||
|
||||||
|
!selectedLayer ||
|
||||||
|
selectedLayer.type !== "raster"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +170,26 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
layerId: selectedLayer.id,
|
layerId: selectedLayer.id,
|
||||||
effects: [{
|
effects: [
|
||||||
|
{
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
method: blurMethod,
|
method: blurMethod,
|
||||||
amount: blurAmount,
|
amount: blurAmount,
|
||||||
regions: buildBlurRegions(selectedFaceIndices),
|
regions: buildBlurRegions(selectedFaceIndices),
|
||||||
censorColor,
|
censorColor,
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
}, [
|
||||||
|
blurAmount,
|
||||||
|
blurMethod,
|
||||||
|
buildBlurRegions,
|
||||||
|
censorColor,
|
||||||
|
hasDetectableSelection,
|
||||||
|
selectedFaceIndices,
|
||||||
|
selectedLayer,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blurMethod,
|
blurMethod,
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export type FacePreview = {
|
|||||||
type SelectedImageLayer = {
|
type SelectedImageLayer = {
|
||||||
id: string;
|
id: string;
|
||||||
sourceUri: string;
|
sourceUri: string;
|
||||||
width?: number;
|
width: number;
|
||||||
height?: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseFaceDetectionOptions = {
|
type UseFaceDetectionOptions = {
|
||||||
@@ -37,11 +37,7 @@ type UseFaceDetectionOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||||
const {
|
const { tool, selectedImageLayer, activeLayerStillSelected } = options;
|
||||||
tool,
|
|
||||||
selectedImageLayer,
|
|
||||||
activeLayerStillSelected,
|
|
||||||
} = options;
|
|
||||||
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
||||||
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
||||||
const selectedImageWidth = selectedImageLayer?.width;
|
const selectedImageWidth = selectedImageLayer?.width;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function useTranslations() {
|
|||||||
const locale = useUiStore((s) => s.locale);
|
const locale = useUiStore((s) => s.locale);
|
||||||
const msg = messages[locale] ?? messages.en;
|
const msg = messages[locale] ?? messages.en;
|
||||||
|
|
||||||
function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
function t(
|
||||||
|
key: TranslationKey,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
|
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
|||||||
@@ -9,13 +9,22 @@ describe("brush painter pure helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clamps invalid brush options", () => {
|
it("clamps invalid brush options", () => {
|
||||||
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
|
expect(
|
||||||
|
clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 }),
|
||||||
|
).toEqual({
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
size: 1,
|
size: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
hardness: 0,
|
hardness: 0,
|
||||||
});
|
});
|
||||||
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
|
expect(
|
||||||
|
clampBrushOptions({
|
||||||
|
color: "#000",
|
||||||
|
size: Number.NaN,
|
||||||
|
opacity: Number.NaN,
|
||||||
|
hardness: Number.NaN,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
color: "#000",
|
color: "#000",
|
||||||
size: 1,
|
size: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -31,6 +40,9 @@ describe("brush painter pure helpers", () => {
|
|||||||
{ x: 3, y: 0 },
|
{ x: 3, y: 0 },
|
||||||
{ x: 4, y: 0 },
|
{ x: 4, y: 0 },
|
||||||
]);
|
]);
|
||||||
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
|
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([
|
||||||
|
{ x: 2, y: 3 },
|
||||||
|
{ x: 2, y: 3 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type BrushOptions = {
|
|||||||
color: string;
|
color: string;
|
||||||
size: number;
|
size: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
hardness: number; // 0–1: 0 = fully soft, 1 = hard edge
|
hardness: number; // 0 to 1: 0 is fully soft, 1 is a hard edge.
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrushStroke = {
|
export type BrushStroke = {
|
||||||
@@ -31,12 +31,24 @@ export function clampBrushOptions(options: BrushOptions): BrushOptions {
|
|||||||
return {
|
return {
|
||||||
color: options.color,
|
color: options.color,
|
||||||
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
|
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
|
||||||
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
|
opacity: Math.max(
|
||||||
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
|
0,
|
||||||
|
Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1),
|
||||||
|
),
|
||||||
|
hardness: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
|
export function buildBrushDabs(
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
size: number,
|
||||||
|
): BrushDab[] {
|
||||||
const dx = x1 - x0;
|
const dx = x1 - x0;
|
||||||
const dy = y1 - y0;
|
const dy = y1 - y0;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
@@ -65,7 +77,10 @@ function drawDab(
|
|||||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||||
|
|
||||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
||||||
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
gradient.addColorStop(
|
||||||
|
normalized.hardness,
|
||||||
|
`rgba(${cr},${cg},${cb},${normalized.opacity})`,
|
||||||
|
);
|
||||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -74,7 +89,6 @@ function drawDab(
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a fresh stroke canvas sized to the layer. */
|
|
||||||
export function createStroke(width: number, height: number): BrushStroke {
|
export function createStroke(width: number, height: number): BrushStroke {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = Math.max(1, Math.round(width));
|
canvas.width = Math.max(1, Math.round(width));
|
||||||
@@ -84,7 +98,6 @@ export function createStroke(width: number, height: number): BrushStroke {
|
|||||||
return { canvas, ctx, width: canvas.width, height: canvas.height };
|
return { canvas, ctx, width: canvas.width, height: canvas.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */
|
|
||||||
export function paintSegment(
|
export function paintSegment(
|
||||||
stroke: BrushStroke,
|
stroke: BrushStroke,
|
||||||
x0: number,
|
x0: number,
|
||||||
@@ -99,8 +112,10 @@ export function paintSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges stroke canvas on top of the source image and returns a data URL. */
|
export function commitStroke(
|
||||||
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
|
sourceUri: string,
|
||||||
|
stroke: BrushStroke,
|
||||||
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.crossOrigin = "anonymous";
|
image.crossOrigin = "anonymous";
|
||||||
@@ -114,7 +129,6 @@ export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<st
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
// Scale stroke canvas to match image natural size
|
|
||||||
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
|
||||||
resolve(canvas.toDataURL("image/png"));
|
resolve(canvas.toDataURL("image/png"));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { buildFaceLabelOverlays } from "./canvas-geometry";
|
|||||||
|
|
||||||
describe("buildFaceLabelOverlays", () => {
|
describe("buildFaceLabelOverlays", () => {
|
||||||
it("returns no overlays when target layer is missing", () => {
|
it("returns no overlays when target layer is missing", () => {
|
||||||
const overlays = buildFaceLabelOverlays([], "missing", [{ x: 10, y: 10, width: 20, height: 20 }], { x: 0, y: 0, scale: 1 });
|
const overlays = buildFaceLabelOverlays(
|
||||||
|
[],
|
||||||
|
"missing",
|
||||||
|
[{ x: 10, y: 10, width: 20, height: 20 }],
|
||||||
|
{ x: 0, y: 0, scale: 1 },
|
||||||
|
);
|
||||||
expect(overlays).toEqual([]);
|
expect(overlays).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -12,6 +17,7 @@ describe("buildFaceLabelOverlays", () => {
|
|||||||
{
|
{
|
||||||
id: "layer-1",
|
id: "layer-1",
|
||||||
type: "raster" as const,
|
type: "raster" as const,
|
||||||
|
asset: null,
|
||||||
x: 20,
|
x: 20,
|
||||||
y: 30,
|
y: 30,
|
||||||
width: 180,
|
width: 180,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Layer } from "@pien-studio/types";
|
import type { Layer } from "@pien-studio/types";
|
||||||
|
|
||||||
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
|
type FaceDetection = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Viewport = { x: number; y: number; scale: number };
|
type Viewport = { x: number; y: number; scale: number };
|
||||||
|
|
||||||
@@ -13,9 +19,8 @@ export function buildFaceLabelOverlays(
|
|||||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||||
if (!layer) return [];
|
if (!layer) return [];
|
||||||
|
|
||||||
const isImage = layer.type === "raster";
|
const layerWidth = layer.width;
|
||||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
const layerHeight = layer.height;
|
||||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
|
||||||
if (!layerWidth || !layerHeight) return [];
|
if (!layerWidth || !layerHeight) return [];
|
||||||
|
|
||||||
const centerX = layer.x + layerWidth / 2;
|
const centerX = layer.x + layerWidth / 2;
|
||||||
@@ -23,7 +28,12 @@ export function buildFaceLabelOverlays(
|
|||||||
const radians = (layer.rotation * Math.PI) / 180;
|
const radians = (layer.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(radians);
|
const cos = Math.cos(radians);
|
||||||
const sin = Math.sin(radians);
|
const sin = Math.sin(radians);
|
||||||
const placed: Array<{ left: number; top: number; width: number; height: number }> = [];
|
const placed: Array<{
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
return faceDetections.map((face, index) => {
|
return faceDetections.map((face, index) => {
|
||||||
const worldX = layer.x + face.x;
|
const worldX = layer.x + face.x;
|
||||||
@@ -41,8 +51,10 @@ export function buildFaceLabelOverlays(
|
|||||||
|
|
||||||
while (
|
while (
|
||||||
placed.some((rect) => {
|
placed.some((rect) => {
|
||||||
const intersectsX = left < rect.left + rect.width && left + estimatedWidth > rect.left;
|
const intersectsX =
|
||||||
const intersectsY = top < rect.top + rect.height && top + estimatedHeight > rect.top;
|
left < rect.left + rect.width && left + estimatedWidth > rect.left;
|
||||||
|
const intersectsY =
|
||||||
|
top < rect.top + rect.height && top + estimatedHeight > rect.top;
|
||||||
return intersectsX && intersectsY;
|
return intersectsX && intersectsY;
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ function makeImage(width = 1200, height = 800) {
|
|||||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) {
|
function makeContext2d(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
image: HTMLImageElement,
|
||||||
|
tw = 600,
|
||||||
|
th = 400,
|
||||||
|
) {
|
||||||
return { ctx, image, targetWidth: tw, targetHeight: th };
|
return { ctx, image, targetWidth: tw, targetHeight: th };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,13 +36,32 @@ describe("faceBlurRenderer.render (regions)", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 24,
|
amount: 24,
|
||||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
expect(ctx.save).toHaveBeenCalledOnce();
|
expect(ctx.save).toHaveBeenCalledOnce();
|
||||||
expect(ctx.filter).toBe("blur(24px)");
|
expect(ctx.filter).toBe("blur(24px)");
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
image,
|
||||||
|
120,
|
||||||
|
80,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
150,
|
||||||
|
100,
|
||||||
|
);
|
||||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,10 +72,19 @@ describe("faceBlurRenderer.render (regions)", () => {
|
|||||||
const ctx = makeContext();
|
const ctx = makeContext();
|
||||||
const image = makeImage();
|
const image = makeImage();
|
||||||
const sampleDrawImage = vi.fn();
|
const sampleDrawImage = vi.fn();
|
||||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
const sampleCtx = {
|
||||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
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 nativeCreateElement = doc.createElement.bind(doc);
|
||||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
const createElement = vi
|
||||||
|
.spyOn(doc, "createElement")
|
||||||
|
.mockImplementation((tagName: string) => {
|
||||||
if (tagName === "canvas") return sampleCanvas;
|
if (tagName === "canvas") return sampleCanvas;
|
||||||
return nativeCreateElement(tagName);
|
return nativeCreateElement(tagName);
|
||||||
});
|
});
|
||||||
@@ -61,14 +94,43 @@ describe("faceBlurRenderer.render (regions)", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
method: "pixelate",
|
method: "pixelate",
|
||||||
amount: 10,
|
amount: 10,
|
||||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
expect(sampleCanvas.width).toBe(16);
|
expect(sampleCanvas.width).toBe(16);
|
||||||
expect(sampleCanvas.height).toBe(12);
|
expect(sampleCanvas.height).toBe(12);
|
||||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
expect(sampleDrawImage).toHaveBeenCalledWith(
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
image,
|
||||||
|
200,
|
||||||
|
100,
|
||||||
|
160,
|
||||||
|
120,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
sampleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
100,
|
||||||
|
50,
|
||||||
|
80,
|
||||||
|
60,
|
||||||
|
);
|
||||||
createElement.mockRestore();
|
createElement.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +143,17 @@ describe("faceBlurRenderer.render (regions)", () => {
|
|||||||
method: "censor",
|
method: "censor",
|
||||||
amount: 20,
|
amount: 20,
|
||||||
censorColor: "#ff0000",
|
censorColor: "#ff0000",
|
||||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 40,
|
||||||
|
height: 50,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
censorColor: "#00ff00",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
@@ -101,7 +173,17 @@ describe("faceBlurRenderer.render (regions)", () => {
|
|||||||
};
|
};
|
||||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
image,
|
||||||
|
400,
|
||||||
|
480,
|
||||||
|
1200,
|
||||||
|
800,
|
||||||
|
100,
|
||||||
|
120,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,10 +195,19 @@ describe("faceBlurRenderer.renderLayer", () => {
|
|||||||
const ctx = makeContext();
|
const ctx = makeContext();
|
||||||
const image = makeImage();
|
const image = makeImage();
|
||||||
const sourceDrawImage = vi.fn();
|
const sourceDrawImage = vi.fn();
|
||||||
const sourceCtx = { ...makeContext(), drawImage: sourceDrawImage } as unknown as CanvasRenderingContext2D;
|
const sourceCtx = {
|
||||||
const sourceCanvas = { width: 0, height: 0, getContext: vi.fn(() => sourceCtx) } as unknown as HTMLCanvasElement;
|
...makeContext(),
|
||||||
|
drawImage: sourceDrawImage,
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
const sourceCanvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext: vi.fn(() => sourceCtx),
|
||||||
|
} as unknown as HTMLCanvasElement;
|
||||||
const nativeCreateElement = doc.createElement.bind(doc);
|
const nativeCreateElement = doc.createElement.bind(doc);
|
||||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
const createElement = vi
|
||||||
|
.spyOn(doc, "createElement")
|
||||||
|
.mockImplementation((tagName: string) => {
|
||||||
if (tagName === "canvas") return sourceCanvas;
|
if (tagName === "canvas") return sourceCanvas;
|
||||||
return nativeCreateElement(tagName);
|
return nativeCreateElement(tagName);
|
||||||
});
|
});
|
||||||
@@ -126,15 +217,45 @@ describe("faceBlurRenderer.renderLayer", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 24,
|
amount: 24,
|
||||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
expect(sourceCanvas.width).toBe(1200);
|
expect(sourceCanvas.width).toBe(1200);
|
||||||
expect(sourceCanvas.height).toBe(800);
|
expect(sourceCanvas.height).toBe(800);
|
||||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800);
|
expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800);
|
||||||
expect(sourceDrawImage).toHaveBeenNthCalledWith(2, image, 120, 80, 300, 200, 120, 80, 300, 200);
|
expect(sourceDrawImage).toHaveBeenNthCalledWith(
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(sourceCanvas, 0, 0, 1200, 800, 0, 0, 600, 400);
|
2,
|
||||||
|
image,
|
||||||
|
120,
|
||||||
|
80,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
120,
|
||||||
|
80,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
sourceCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1200,
|
||||||
|
800,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
600,
|
||||||
|
400,
|
||||||
|
);
|
||||||
createElement.mockRestore();
|
createElement.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,14 +268,34 @@ describe("faceBlurRenderer.renderLayer", () => {
|
|||||||
const image = makeImage();
|
const image = makeImage();
|
||||||
const regionDrawImage = vi.fn();
|
const regionDrawImage = vi.fn();
|
||||||
const blurDrawImage = vi.fn();
|
const blurDrawImage = vi.fn();
|
||||||
const regionCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: regionDrawImage } as unknown as CanvasRenderingContext2D;
|
const regionCtx = {
|
||||||
const blurCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: blurDrawImage } as unknown as CanvasRenderingContext2D;
|
...makeContext(),
|
||||||
const regionCanvas = { width: 0, height: 0, getContext: vi.fn(() => regionCtx) } as unknown as HTMLCanvasElement;
|
clearRect: vi.fn(),
|
||||||
const blurCanvas = { width: 0, height: 0, getContext: vi.fn(() => blurCtx) } as unknown as HTMLCanvasElement;
|
drawImage: regionDrawImage,
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
const blurCtx = {
|
||||||
|
...makeContext(),
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
drawImage: blurDrawImage,
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
const regionCanvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext: vi.fn(() => regionCtx),
|
||||||
|
} as unknown as HTMLCanvasElement;
|
||||||
|
const blurCanvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext: vi.fn(() => blurCtx),
|
||||||
|
} as unknown as HTMLCanvasElement;
|
||||||
const nativeCreateElement = doc.createElement.bind(doc);
|
const nativeCreateElement = doc.createElement.bind(doc);
|
||||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
const createElement = vi
|
||||||
|
.spyOn(doc, "createElement")
|
||||||
|
.mockImplementation((tagName: string) => {
|
||||||
if (tagName !== "canvas") return nativeCreateElement(tagName);
|
if (tagName !== "canvas") return nativeCreateElement(tagName);
|
||||||
return createElement.mock.calls.length === 1 ? regionCanvas : blurCanvas;
|
return createElement.mock.calls.length === 1
|
||||||
|
? regionCanvas
|
||||||
|
: blurCanvas;
|
||||||
});
|
});
|
||||||
|
|
||||||
const effect: FaceBlurEffect = {
|
const effect: FaceBlurEffect = {
|
||||||
@@ -162,15 +303,44 @@ describe("faceBlurRenderer.renderLayer", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 24,
|
amount: 24,
|
||||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||||
|
|
||||||
expect(ctx.save).not.toHaveBeenCalled();
|
expect(ctx.save).not.toHaveBeenCalled();
|
||||||
expect(regionCanvas.width).toBe(150);
|
expect(regionCanvas.width).toBe(150);
|
||||||
expect(regionCanvas.height).toBe(100);
|
expect(regionCanvas.height).toBe(100);
|
||||||
expect(regionDrawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 0, 0, 150, 100);
|
expect(regionDrawImage).toHaveBeenCalledWith(
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(regionCanvas, 0, 0, 150, 100, 60, 40, 150, 100);
|
image,
|
||||||
|
120,
|
||||||
|
80,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
150,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
regionCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
150,
|
||||||
|
100,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
150,
|
||||||
|
100,
|
||||||
|
);
|
||||||
createElement.mockRestore();
|
createElement.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,9 +20,29 @@ function drawPixelatedRegion(
|
|||||||
const sampleCtx = sampleCanvas.getContext("2d");
|
const sampleCtx = sampleCanvas.getContext("2d");
|
||||||
if (!sampleCtx) return;
|
if (!sampleCtx) return;
|
||||||
sampleCtx.imageSmoothingEnabled = false;
|
sampleCtx.imageSmoothingEnabled = false;
|
||||||
sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
|
sampleCtx.drawImage(
|
||||||
|
source,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sampleCanvas.width,
|
||||||
|
sampleCanvas.height,
|
||||||
|
);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
ctx.drawImage(
|
||||||
|
sampleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sampleCanvas.width,
|
||||||
|
sampleCanvas.height,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +64,17 @@ function drawBlurredRegionFallback(
|
|||||||
regionCanvas.height = Math.max(1, Math.round(targetHeight));
|
regionCanvas.height = Math.max(1, Math.round(targetHeight));
|
||||||
const regionCtx = regionCanvas.getContext("2d");
|
const regionCtx = regionCanvas.getContext("2d");
|
||||||
if (!regionCtx) return;
|
if (!regionCtx) return;
|
||||||
regionCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, regionCanvas.width, regionCanvas.height);
|
regionCtx.drawImage(
|
||||||
|
source,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
);
|
||||||
const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
|
const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
|
||||||
const blurCanvas = document.createElement("canvas");
|
const blurCanvas = document.createElement("canvas");
|
||||||
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
|
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
|
||||||
@@ -55,11 +85,41 @@ function drawBlurredRegionFallback(
|
|||||||
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
|
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
|
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
|
||||||
regionCtx.drawImage(blurCanvas, 0, 0, blurCanvas.width, blurCanvas.height, 0, 0, regionCanvas.width, regionCanvas.height);
|
regionCtx.drawImage(
|
||||||
|
blurCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurCanvas.width,
|
||||||
|
blurCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
);
|
||||||
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
|
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
|
||||||
blurCtx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, 0, 0, blurCanvas.width, blurCanvas.height);
|
blurCtx.drawImage(
|
||||||
|
regionCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurCanvas.width,
|
||||||
|
blurCanvas.height,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
ctx.drawImage(
|
||||||
|
regionCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRegions(
|
function renderRegions(
|
||||||
@@ -84,10 +144,18 @@ function renderRegions(
|
|||||||
const y = Math.max(0, Math.floor(region.y * scaleY));
|
const y = Math.max(0, Math.floor(region.y * scaleY));
|
||||||
const w = Math.max(1, Math.floor(region.width * scaleX));
|
const w = Math.max(1, Math.floor(region.width * scaleX));
|
||||||
const h = Math.max(1, Math.floor(region.height * scaleY));
|
const h = Math.max(1, Math.floor(region.height * scaleY));
|
||||||
const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
|
const sx0 = hasSourceDims
|
||||||
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
|
? region.x
|
||||||
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
|
: Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||||
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
|
const sy0 = hasSourceDims
|
||||||
|
? region.y
|
||||||
|
: Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||||
|
const sw = hasSourceDims
|
||||||
|
? region.width
|
||||||
|
: Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||||
|
const sh = hasSourceDims
|
||||||
|
? region.height
|
||||||
|
: Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||||
|
|
||||||
if (effect.method === "censor") {
|
if (effect.method === "censor") {
|
||||||
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
|
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
|
||||||
@@ -95,7 +163,19 @@ function renderRegions(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (effect.method === "pixelate") {
|
if (effect.method === "pixelate") {
|
||||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
|
drawPixelatedRegion(
|
||||||
|
ctx,
|
||||||
|
image,
|
||||||
|
sx0,
|
||||||
|
sy0,
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
Math.max(4, Math.round(effect.amount / 2)),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ("filter" in ctx && typeof ctx.filter === "string") {
|
if ("filter" in ctx && typeof ctx.filter === "string") {
|
||||||
@@ -105,7 +185,19 @@ function renderRegions(
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount);
|
drawBlurredRegionFallback(
|
||||||
|
ctx,
|
||||||
|
image,
|
||||||
|
sx0,
|
||||||
|
sy0,
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
effect.amount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +208,9 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
|
const allHaveSourceDims = effect.regions.every(
|
||||||
|
(r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0,
|
||||||
|
);
|
||||||
if (!allHaveSourceDims) {
|
if (!allHaveSourceDims) {
|
||||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||||
@@ -132,8 +226,24 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||||
renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height);
|
renderRegions(
|
||||||
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
sourceCtx,
|
||||||
|
image,
|
||||||
|
effect,
|
||||||
|
sourceCanvas.width,
|
||||||
|
sourceCanvas.height,
|
||||||
|
);
|
||||||
|
ctx.drawImage(
|
||||||
|
sourceCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sourceCanvas.width,
|
||||||
|
sourceCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const faceBlurRenderer: EffectRenderer = {
|
export const faceBlurRenderer: EffectRenderer = {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export function getEffectRenderer(kind: string): EffectRenderer | undefined {
|
|||||||
return effectRendererRegistry.get(kind);
|
return effectRendererRegistry.get(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draws a layer image applying all its effects in order. */
|
|
||||||
export function renderLayerWithEffects(
|
export function renderLayerWithEffects(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
image: HTMLImageElement,
|
image: HTMLImageElement,
|
||||||
@@ -30,14 +29,18 @@ export function renderLayerWithEffects(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight };
|
const context: EffectRenderContext = {
|
||||||
|
ctx,
|
||||||
|
image,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
};
|
||||||
|
|
||||||
// The first effect owns the full layer render (draws base image + applies itself)
|
// The first effect owns the base image draw; later effects only overlay their changes.
|
||||||
const first = activeEffects[0];
|
const first = activeEffects[0];
|
||||||
const firstRenderer = effectRendererRegistry.get(first.kind);
|
const firstRenderer = effectRendererRegistry.get(first.kind);
|
||||||
firstRenderer?.renderLayer(context, first);
|
firstRenderer?.renderLayer(context, first);
|
||||||
|
|
||||||
// Subsequent effects render on top (overlay only, no re-draw of base)
|
|
||||||
for (let i = 1; i < activeEffects.length; i++) {
|
for (let i = 1; i < activeEffects.length; i++) {
|
||||||
const effect = activeEffects[i];
|
const effect = activeEffects[i];
|
||||||
const renderer = effectRendererRegistry.get(effect.kind);
|
const renderer = effectRendererRegistry.get(effect.kind);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export type EffectRenderContext = {
|
|||||||
|
|
||||||
export type EffectRenderer = {
|
export type EffectRenderer = {
|
||||||
kind: LayerEffect["kind"];
|
kind: LayerEffect["kind"];
|
||||||
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
|
||||||
render: (context: EffectRenderContext, effect: LayerEffect) => void;
|
render: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||||
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
|
||||||
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
|
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-12
@@ -1,4 +1,8 @@
|
|||||||
import type { Layer, Project } from "@pien-studio/types";
|
import {
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type Layer,
|
||||||
|
type Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import { renderLayerWithEffects } from "./effects/registry";
|
import { renderLayerWithEffects } from "./effects/registry";
|
||||||
|
|
||||||
type ExportOptions = {
|
type ExportOptions = {
|
||||||
@@ -20,10 +24,14 @@ function loadImage(src: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
function drawFallbackLayer(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
layer: Layer,
|
||||||
|
isDark: boolean,
|
||||||
|
) {
|
||||||
const text = layer.name ?? layer.type;
|
const text = layer.name ?? layer.type;
|
||||||
const width = Math.max(80, layer.width ?? 120);
|
const width = Math.max(80, layer.width);
|
||||||
const height = Math.max(34, layer.height ?? 40);
|
const height = Math.max(34, layer.height);
|
||||||
const radius = 8;
|
const radius = 8;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -45,15 +53,21 @@ function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark:
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||||
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
ctx.font =
|
||||||
|
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(text, width / 2, height / 2);
|
ctx.fillText(text, width / 2, height / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
async function drawLayer(
|
||||||
const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120);
|
ctx: CanvasRenderingContext2D,
|
||||||
const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
|
layer: Layer,
|
||||||
|
isDark: boolean,
|
||||||
|
) {
|
||||||
|
const width = layer.width;
|
||||||
|
const height = layer.height;
|
||||||
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||||
@@ -61,9 +75,9 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
|
|||||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||||
ctx.translate(-width / 2, -height / 2);
|
ctx.translate(-width / 2, -height / 2);
|
||||||
|
|
||||||
if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) {
|
if ((layer.type === "raster" || layer.type === "sticker") && sourceUri) {
|
||||||
try {
|
try {
|
||||||
const image = await loadImage(layer.sourceUri);
|
const image = await loadImage(sourceUri);
|
||||||
renderLayerWithEffects(ctx, image, layer.effects, width, height);
|
renderLayerWithEffects(ctx, image, layer.effects, width, height);
|
||||||
} catch {
|
} catch {
|
||||||
drawFallbackLayer(ctx, layer, isDark);
|
drawFallbackLayer(ctx, layer, isDark);
|
||||||
@@ -75,8 +89,14 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
|
export async function exportProjectAsPng(
|
||||||
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
|
project: Project,
|
||||||
|
options: ExportOptions,
|
||||||
|
) {
|
||||||
|
const pixelRatio = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
|
||||||
|
);
|
||||||
const { width, height } = project.canvas;
|
const { width, height } = project.canvas;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = width * pixelRatio;
|
canvas.width = width * pixelRatio;
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ type RGBA = [number, number, number, number];
|
|||||||
|
|
||||||
function colorDistance(a: RGBA, b: RGBA): number {
|
function colorDistance(a: RGBA, b: RGBA): number {
|
||||||
// Weight alpha at 25% so transparent regions fill correctly
|
// Weight alpha at 25% so transparent regions fill correctly
|
||||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]) * 0.25;
|
return (
|
||||||
|
Math.abs(a[0] - b[0]) +
|
||||||
|
Math.abs(a[1] - b[1]) +
|
||||||
|
Math.abs(a[2] - b[2]) +
|
||||||
|
Math.abs(a[3] - b[3]) * 0.25
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
|
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
|
||||||
@@ -53,7 +58,12 @@ export function floodFillDataUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const idx = (y * width + x) * 4;
|
const idx = (y * width + x) * 4;
|
||||||
const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
const target: RGBA = [
|
||||||
|
pixels[idx],
|
||||||
|
pixels[idx + 1],
|
||||||
|
pixels[idx + 2],
|
||||||
|
pixels[idx + 3],
|
||||||
|
];
|
||||||
const fill = hexToRgba(fillColor);
|
const fill = hexToRgba(fillColor);
|
||||||
|
|
||||||
if (matchesTarget(target, fill, 0)) {
|
if (matchesTarget(target, fill, 0)) {
|
||||||
@@ -72,7 +82,12 @@ export function floodFillDataUrl(
|
|||||||
const cx = pos % width;
|
const cx = pos % width;
|
||||||
const cy = Math.floor(pos / width);
|
const cy = Math.floor(pos / width);
|
||||||
const ci = pos * 4;
|
const ci = pos * 4;
|
||||||
const current: RGBA = [pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3]];
|
const current: RGBA = [
|
||||||
|
pixels[ci],
|
||||||
|
pixels[ci + 1],
|
||||||
|
pixels[ci + 2],
|
||||||
|
pixels[ci + 3],
|
||||||
|
];
|
||||||
|
|
||||||
if (!matchesTarget(current, target, tolerance)) continue;
|
if (!matchesTarget(current, target, tolerance)) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,44 @@ function makeProject(): Project {
|
|||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
aspectRatio: "1:1",
|
aspectRatio: "1:1",
|
||||||
canvas: { width: 100, height: 100, unit: "px" },
|
canvas: { width: 100, height: 100, unit: "px" },
|
||||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }],
|
layers: [textLayer("l1")],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLayer(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "text" as const,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 120,
|
||||||
|
height: 48,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
effects: [],
|
||||||
|
visible: true,
|
||||||
|
text: "Text",
|
||||||
|
fontFamily: "system-ui",
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#000",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rasterLayer(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "raster" as const,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
effects: [],
|
||||||
|
visible: true,
|
||||||
|
asset: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,18 +68,34 @@ describe("hasProjectChanged", () => {
|
|||||||
it("detects face blur region changes", () => {
|
it("detects face blur region changes", () => {
|
||||||
const a = makeProject();
|
const a = makeProject();
|
||||||
const b = makeProject();
|
const b = makeProject();
|
||||||
a.layers[0].type = "raster";
|
a.layers[0] = rasterLayer("l1");
|
||||||
b.layers[0].type = "raster";
|
b.layers[0] = rasterLayer("l1");
|
||||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
a.layers[0].effects = [
|
||||||
b.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }];
|
{
|
||||||
|
kind: "face-blur",
|
||||||
|
enabled: true,
|
||||||
|
method: "gaussian",
|
||||||
|
amount: 14,
|
||||||
|
regions: [{ x: 1, y: 1, width: 10, height: 10 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
b.layers[0].effects = [
|
||||||
|
{
|
||||||
|
kind: "face-blur",
|
||||||
|
enabled: true,
|
||||||
|
method: "gaussian",
|
||||||
|
amount: 14,
|
||||||
|
regions: [{ x: 1, y: 1, width: 11, height: 10 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
expect(hasProjectChanged(a, b)).toBe(true);
|
expect(hasProjectChanged(a, b)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects layer order changes", () => {
|
it("detects layer order changes", () => {
|
||||||
const a = makeProject();
|
const a = makeProject();
|
||||||
const b = makeProject();
|
const b = makeProject();
|
||||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
a.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
|
||||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
b.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
|
||||||
b.layers = [b.layers[1], b.layers[0]];
|
b.layers = [b.layers[1], b.layers[0]];
|
||||||
expect(hasProjectChanged(a, b)).toBe(true);
|
expect(hasProjectChanged(a, b)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -65,9 +118,17 @@ describe("hasProjectChanged", () => {
|
|||||||
it("detects face blur removal", () => {
|
it("detects face blur removal", () => {
|
||||||
const a = makeProject();
|
const a = makeProject();
|
||||||
const b = makeProject();
|
const b = makeProject();
|
||||||
a.layers[0].type = "raster";
|
a.layers[0] = rasterLayer("l1");
|
||||||
b.layers[0].type = "raster";
|
b.layers[0] = rasterLayer("l1");
|
||||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
a.layers[0].effects = [
|
||||||
|
{
|
||||||
|
kind: "face-blur",
|
||||||
|
enabled: true,
|
||||||
|
method: "gaussian",
|
||||||
|
amount: 14,
|
||||||
|
regions: [{ x: 1, y: 1, width: 10, height: 10 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
expect(hasProjectChanged(a, b)).toBe(true);
|
expect(hasProjectChanged(a, b)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Project } from "@pien-studio/types";
|
import { getLayerAssetRef, type Project } from "@pien-studio/types";
|
||||||
|
|
||||||
export function hasProjectChanged(left: Project, right: Project): boolean {
|
export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||||
if (left.id !== right.id) return true;
|
if (left.id !== right.id) return true;
|
||||||
@@ -6,7 +6,11 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
|||||||
if (left.createdAt !== right.createdAt) return true;
|
if (left.createdAt !== right.createdAt) return true;
|
||||||
if (left.updatedAt !== right.updatedAt) return true;
|
if (left.updatedAt !== right.updatedAt) return true;
|
||||||
if (left.aspectRatio !== right.aspectRatio) return true;
|
if (left.aspectRatio !== right.aspectRatio) return true;
|
||||||
if (left.canvas.width !== right.canvas.width || left.canvas.height !== right.canvas.height || left.canvas.unit !== right.canvas.unit) {
|
if (
|
||||||
|
left.canvas.width !== right.canvas.width ||
|
||||||
|
left.canvas.height !== right.canvas.height ||
|
||||||
|
left.canvas.unit !== right.canvas.unit
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (left.layers.length !== right.layers.length) return true;
|
if (left.layers.length !== right.layers.length) return true;
|
||||||
@@ -19,8 +23,6 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
|||||||
a.id !== b.id ||
|
a.id !== b.id ||
|
||||||
a.type !== b.type ||
|
a.type !== b.type ||
|
||||||
a.name !== b.name ||
|
a.name !== b.name ||
|
||||||
a.assetId !== b.assetId ||
|
|
||||||
a.sourceUri !== b.sourceUri ||
|
|
||||||
a.x !== b.x ||
|
a.x !== b.x ||
|
||||||
a.y !== b.y ||
|
a.y !== b.y ||
|
||||||
a.width !== b.width ||
|
a.width !== b.width ||
|
||||||
@@ -33,6 +35,27 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
JSON.stringify(getLayerAssetRef(a)) !==
|
||||||
|
JSON.stringify(getLayerAssetRef(b))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
(a.type === "raster" || a.type === "sticker") &&
|
||||||
|
(b.type === "raster" || b.type === "sticker") &&
|
||||||
|
a.runtimeSourceUri !== b.runtimeSourceUri
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
a.type === "text" &&
|
||||||
|
b.type === "text" &&
|
||||||
|
(a.text !== b.text ||
|
||||||
|
a.fontFamily !== b.fontFamily ||
|
||||||
|
a.fontSize !== b.fontSize ||
|
||||||
|
a.color !== b.color)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
|
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Project } from "@pien-studio/types";
|
||||||
|
import {
|
||||||
|
cleanupOrphanAssets,
|
||||||
|
deleteProject,
|
||||||
|
duplicateProject,
|
||||||
|
getProjectById,
|
||||||
|
loadProjects,
|
||||||
|
releaseProjectObjectUrls,
|
||||||
|
upsertProject,
|
||||||
|
} from "@pien-studio/storage";
|
||||||
|
|
||||||
|
export type ProjectRepository = {
|
||||||
|
listProjects: () => Promise<Project[]>;
|
||||||
|
getProject: (projectId: string) => Promise<Project | null>;
|
||||||
|
upsertProject: (project: Project) => Promise<void>;
|
||||||
|
deleteProject: (projectId: string) => Promise<void>;
|
||||||
|
duplicateProject: (projectId: string) => Promise<Project | null>;
|
||||||
|
cleanupAssets: () => Promise<number>;
|
||||||
|
releaseObjectUrls: (
|
||||||
|
project: Project,
|
||||||
|
keepAssetIds?: Iterable<string>,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localProjectRepository: ProjectRepository = {
|
||||||
|
listProjects: loadProjects,
|
||||||
|
getProject: getProjectById,
|
||||||
|
upsertProject,
|
||||||
|
deleteProject,
|
||||||
|
duplicateProject,
|
||||||
|
cleanupAssets: cleanupOrphanAssets,
|
||||||
|
releaseObjectUrls: releaseProjectObjectUrls,
|
||||||
|
};
|
||||||
+10
-3
@@ -7,7 +7,9 @@ export function surfaceClass(isDark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mutedSurfaceClass(isDark: boolean): string {
|
export function mutedSurfaceClass(isDark: boolean): string {
|
||||||
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
|
return isDark
|
||||||
|
? "border-white/10 bg-[#23252a]"
|
||||||
|
: "border-black/10 bg-[#f7f8fa]";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subtleButtonClass(isDark: boolean): string {
|
export function subtleButtonClass(isDark: boolean): string {
|
||||||
@@ -29,7 +31,10 @@ export function dividerClass(isDark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function panelClass(isDark: boolean): string {
|
export function panelClass(isDark: boolean): string {
|
||||||
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
|
return cx(
|
||||||
|
"rounded border p-3",
|
||||||
|
isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function panelTitleClass(isDark: boolean): string {
|
export function panelTitleClass(isDark: boolean): string {
|
||||||
@@ -41,5 +46,7 @@ export function panelCounterClass(isDark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function panelInsetClass(isDark: boolean): string {
|
export function panelInsetClass(isDark: boolean): string {
|
||||||
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
|
return isDark
|
||||||
|
? "border-white/10 bg-[#24262b]"
|
||||||
|
: "border-black/10 bg-[#f6f7f9]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { ToolDefinition } from "@pien-studio/editor-core";
|
import type { ToolDefinition } from "@pien-studio/editor-core";
|
||||||
|
|
||||||
export type ToolUiDefinition = ToolDefinition & {
|
export type ToolUiDefinition = ToolDefinition & {
|
||||||
/** Lucide icon component name (resolved at render time) */
|
|
||||||
iconName: string;
|
iconName: string;
|
||||||
/** CSS cursor when this tool is active */
|
|
||||||
cursor: string;
|
cursor: string;
|
||||||
/** i18n key for the toolbar label */
|
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
import type { Layer, Project } from "@pien-studio/types";
|
export {
|
||||||
|
HISTORY_LIMIT,
|
||||||
export const HISTORY_LIMIT = 120;
|
capHistory,
|
||||||
|
cloneLayer,
|
||||||
export type HistoryState = {
|
cloneProject,
|
||||||
past: Project[];
|
computeHistoryFlags,
|
||||||
present: Project;
|
deepClone,
|
||||||
future: Project[];
|
makeHistory,
|
||||||
};
|
resolveSelectedLayerId,
|
||||||
|
} from "@pien-studio/editor-core";
|
||||||
export function deepClone<T>(value: T): T {
|
export type { HistoryState } from "@pien-studio/editor-core";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ describe("editor store", () => {
|
|||||||
|
|
||||||
const layer = useEditorStore.getState().project.layers[0];
|
const layer = useEditorStore.getState().project.layers[0];
|
||||||
expect(layer?.visible).toBe(false);
|
expect(layer?.visible).toBe(false);
|
||||||
expect(layer?.effects[0]).toMatchObject({ amount: 40, enabled: false, regions: [{ x: 0, y: 1, width: 1, height: 2 }] });
|
expect(layer?.effects[0]).toMatchObject({
|
||||||
|
amount: 40,
|
||||||
|
enabled: false,
|
||||||
|
regions: [{ x: 0, y: 1, width: 1, height: 2 }],
|
||||||
|
});
|
||||||
expect(useEditorStore.getState().canUndo).toBe(true);
|
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||||
|
|
||||||
useEditorStore.getState().undo();
|
useEditorStore.getState().undo();
|
||||||
@@ -123,7 +127,10 @@ describe("editor store", () => {
|
|||||||
useEditorStore.getState().resetProject();
|
useEditorStore.getState().resetProject();
|
||||||
useEditorStore.getState().setCanvasSize(222.4, 333.6);
|
useEditorStore.getState().setCanvasSize(222.4, 333.6);
|
||||||
useEditorStore.getState().addLayerByType("text");
|
useEditorStore.getState().addLayerByType("text");
|
||||||
expect(useEditorStore.getState().project.canvas).toMatchObject({ width: 222, height: 334 });
|
expect(useEditorStore.getState().project.canvas).toMatchObject({
|
||||||
|
width: 222,
|
||||||
|
height: 334,
|
||||||
|
});
|
||||||
|
|
||||||
useEditorStore.getState().undo();
|
useEditorStore.getState().undo();
|
||||||
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||||
@@ -142,7 +149,10 @@ describe("editor store", () => {
|
|||||||
it("rejects invalid project json and replaces projects", () => {
|
it("rejects invalid project json and replaces projects", () => {
|
||||||
useEditorStore.getState().resetProject();
|
useEditorStore.getState().resetProject();
|
||||||
const originalId = useEditorStore.getState().project.id;
|
const originalId = useEditorStore.getState().project.id;
|
||||||
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({ ok: false, error: "Invalid JSON" });
|
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid JSON",
|
||||||
|
});
|
||||||
expect(useEditorStore.getState().project.id).toBe(originalId);
|
expect(useEditorStore.getState().project.id).toBe(originalId);
|
||||||
|
|
||||||
const project = createProject("replacement");
|
const project = createProject("replacement");
|
||||||
|
|||||||
+274
-78
@@ -19,8 +19,16 @@ import {
|
|||||||
getAllTools,
|
getAllTools,
|
||||||
type EditorToolId,
|
type EditorToolId,
|
||||||
} from "@pien-studio/editor-core";
|
} from "@pien-studio/editor-core";
|
||||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
import {
|
||||||
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
|
getAssetRefId,
|
||||||
|
getLayerAssetRef,
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type Layer,
|
||||||
|
type LayerEffect,
|
||||||
|
type LayerType,
|
||||||
|
type Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
|
import { localProjectRepository } from "../lib/project-repository";
|
||||||
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
||||||
import { hasProjectChanged } from "../lib/project-equality";
|
import { hasProjectChanged } from "../lib/project-equality";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +46,9 @@ const DRAFT_TRANSFORM_EPSILON = 0.01;
|
|||||||
export { EditorToolId };
|
export { EditorToolId };
|
||||||
|
|
||||||
const allTools = getAllTools();
|
const allTools = getAllTools();
|
||||||
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
|
export const EDITOR_TOOLS = Object.fromEntries(
|
||||||
|
allTools.map((t) => [t.id, t]),
|
||||||
|
) as Record<string, (typeof allTools)[number]>;
|
||||||
|
|
||||||
type TransactionState = {
|
type TransactionState = {
|
||||||
baselineProject: Project;
|
baselineProject: Project;
|
||||||
@@ -60,7 +70,7 @@ type EditorState = {
|
|||||||
cancelTransaction: () => void;
|
cancelTransaction: () => void;
|
||||||
applyProjectDraft: (project: Project) => void;
|
applyProjectDraft: (project: Project) => void;
|
||||||
setTool: (tool: EditorToolId) => void;
|
setTool: (tool: EditorToolId) => void;
|
||||||
addLayerByType: (type: Layer["type"]) => void;
|
addLayerByType: (type: LayerType) => void;
|
||||||
setSelectedLayerPosition: (x: number, y: number) => void;
|
setSelectedLayerPosition: (x: number, y: number) => void;
|
||||||
setSelectedLayerPositionDraft: (x: number, y: number) => void;
|
setSelectedLayerPositionDraft: (x: number, y: number) => void;
|
||||||
setSelectedLayerSize: (width: number, height: number) => void;
|
setSelectedLayerSize: (width: number, height: number) => void;
|
||||||
@@ -84,7 +94,11 @@ type EditorState = {
|
|||||||
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
||||||
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
||||||
setLayerVisible: (layerId: string, visible: boolean) => void;
|
setLayerVisible: (layerId: string, visible: boolean) => void;
|
||||||
setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void;
|
setEffectEnabled: (
|
||||||
|
layerId: string,
|
||||||
|
kind: LayerEffect["kind"],
|
||||||
|
enabled: boolean,
|
||||||
|
) => void;
|
||||||
setCanvasSize: (width: number, height: number) => void;
|
setCanvasSize: (width: number, height: number) => void;
|
||||||
exportProjectToJson: () => string;
|
exportProjectToJson: () => string;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
@@ -95,7 +109,11 @@ type EditorState = {
|
|||||||
|
|
||||||
const initialProject = createProject("Untitled Project");
|
const initialProject = createProject("Untitled Project");
|
||||||
|
|
||||||
function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial<EditorState>) {
|
function withCommittedProject(
|
||||||
|
state: EditorState,
|
||||||
|
nextProject: Project,
|
||||||
|
extras?: Partial<EditorState>,
|
||||||
|
) {
|
||||||
const past = capHistory([...state.history.past, state.history.present]);
|
const past = capHistory([...state.history.past, state.history.present]);
|
||||||
const history = { past, present: cloneProject(nextProject), future: [] };
|
const history = { past, present: cloneProject(nextProject), future: [] };
|
||||||
return {
|
return {
|
||||||
@@ -109,7 +127,10 @@ function withCommittedProject(state: EditorState, nextProject: Project, extras?:
|
|||||||
} satisfies Partial<EditorState>;
|
} satisfies Partial<EditorState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
|
function makeStableProjectState(
|
||||||
|
previousSelectedLayerId: string | null,
|
||||||
|
project: Project,
|
||||||
|
) {
|
||||||
const history = makeHistory(project);
|
const history = makeHistory(project);
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
@@ -123,7 +144,9 @@ function makeStableProjectState(previousSelectedLayerId: string | null, project:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getProjectAssetIds(project: Project): string[] {
|
function getProjectAssetIds(project: Project): string[] {
|
||||||
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
|
return project.layers
|
||||||
|
.map((layer) => getAssetRefId(getLayerAssetRef(layer)))
|
||||||
|
.filter((assetId): assetId is string => Boolean(assetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
@@ -150,12 +173,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
commitTransaction: () =>
|
commitTransaction: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.transaction) return state;
|
if (!state.transaction) return state;
|
||||||
if (!hasProjectChanged(state.transaction.baselineProject, state.project)) {
|
if (
|
||||||
|
!hasProjectChanged(state.transaction.baselineProject, state.project)
|
||||||
|
) {
|
||||||
return { transaction: null };
|
return { transaction: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const past = capHistory([...state.history.past, cloneProject(state.transaction.baselineProject)]);
|
const past = capHistory([
|
||||||
const history = { past, present: cloneProject(state.project), future: [] };
|
...state.history.past,
|
||||||
|
cloneProject(state.transaction.baselineProject),
|
||||||
|
]);
|
||||||
|
const history = {
|
||||||
|
past,
|
||||||
|
present: cloneProject(state.project),
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
history,
|
history,
|
||||||
transaction: null,
|
transaction: null,
|
||||||
@@ -169,7 +201,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
if (!state.transaction) return state;
|
if (!state.transaction) return state;
|
||||||
return {
|
return {
|
||||||
project: cloneProject(state.transaction.baselineProject),
|
project: cloneProject(state.transaction.baselineProject),
|
||||||
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
|
selectedLayerId: resolveSelectedLayerId(
|
||||||
|
state.transaction.baselineProject,
|
||||||
|
state.transaction.baselineSelectedLayerId,
|
||||||
|
),
|
||||||
transaction: null,
|
transaction: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -185,23 +220,40 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const layer = createLayer(type);
|
const layer = createLayer(type);
|
||||||
const nextProject = addLayer(state.project, layer);
|
const nextProject = addLayer(state.project, layer);
|
||||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
return withCommittedProject(state, nextProject, {
|
||||||
|
selectedLayerId: layer.id,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addCanvasSizedLayer: (sourceUri, name) =>
|
addCanvasSizedLayer: (sourceUri, name) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const { width, height } = state.project.canvas;
|
const { width, height } = state.project.canvas;
|
||||||
const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height });
|
const layer = createLayer("raster", {
|
||||||
|
name: name ?? "Layer",
|
||||||
|
asset: { kind: "inline", uri: sourceUri },
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
const nextProject = addLayer(state.project, layer);
|
const nextProject = addLayer(state.project, layer);
|
||||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
return withCommittedProject(state, nextProject, {
|
||||||
|
selectedLayerId: layer.id,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedLayerPosition: (x, y) =>
|
setSelectedLayerPosition: (x, y) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
const committed = state.history.present.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (committed && committed.x === x && committed.y === y) return state;
|
if (committed && committed.x === x && committed.y === y) return state;
|
||||||
const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y });
|
const nextProject = updateLayerTransform(
|
||||||
|
state.project,
|
||||||
|
state.selectedLayerId,
|
||||||
|
{ x, y },
|
||||||
|
);
|
||||||
return withCommittedProject(state, nextProject);
|
return withCommittedProject(state, nextProject);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -209,30 +261,48 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
|
||||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
const current = state.project.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (current && current.x === x && current.y === y) return state;
|
if (current && current.x === x && current.y === y) return state;
|
||||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { x, y }) };
|
return {
|
||||||
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedLayerSize: (width, height) =>
|
setSelectedLayerSize: (width, height) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
const committed = state.history.present.layers.find(
|
||||||
if (committed && committed.width === width && committed.height === height) return state;
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { width, height }));
|
);
|
||||||
|
if (committed && committed.width === width && committed.height === height)
|
||||||
|
return state;
|
||||||
|
return withCommittedProject(
|
||||||
|
state,
|
||||||
|
updateLayerTransform(state.project, state.selectedLayerId, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedLayerSizeDraft: (width, height) =>
|
setSelectedLayerSizeDraft: (width, height) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
|
||||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
const current = state.project.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
|
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
|
||||||
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
||||||
|
|
||||||
if (current) {
|
if (current) {
|
||||||
const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined);
|
const currentWidth = current.width;
|
||||||
const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
|
const currentHeight = current.height;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof currentWidth === "number" &&
|
typeof currentWidth === "number" &&
|
||||||
@@ -243,7 +313,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
|
return {
|
||||||
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeSelectedLayer: () =>
|
removeSelectedLayer: () =>
|
||||||
@@ -256,34 +331,57 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
moveSelectedLayerOrder: (direction) =>
|
moveSelectedLayerOrder: (direction) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId);
|
const idx = state.project.layers.findIndex(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (idx < 0) return state;
|
if (idx < 0) return state;
|
||||||
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
|
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
|
||||||
return withCommittedProject(state, reorderLayer(state.project, state.selectedLayerId, nextIndex));
|
return withCommittedProject(
|
||||||
|
state,
|
||||||
|
reorderLayer(state.project, state.selectedLayerId, nextIndex),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })),
|
selectLayer: (layerId) =>
|
||||||
|
set((state) => ({
|
||||||
|
selectedLayerId: resolveSelectedLayerId(state.project, layerId),
|
||||||
|
})),
|
||||||
|
|
||||||
setSelectedLayerRotation: (rotation) =>
|
setSelectedLayerRotation: (rotation) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
|
const committed = state.history.present.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (committed && committed.rotation === rotation) return state;
|
if (committed && committed.rotation === rotation) return state;
|
||||||
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { rotation }));
|
return withCommittedProject(
|
||||||
|
state,
|
||||||
|
updateLayerTransform(state.project, state.selectedLayerId, {
|
||||||
|
rotation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedLayerRotationDraft: (rotation) =>
|
setSelectedLayerRotationDraft: (rotation) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
|
const current = state.project.layers.find(
|
||||||
|
(layer) => layer.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (current && current.rotation === rotation) return state;
|
if (current && current.rotation === rotation) return state;
|
||||||
return { project: updateLayerTransform(state.project, state.selectedLayerId, { rotation }) };
|
return {
|
||||||
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
||||||
|
rotation,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
copySelectedLayer: () =>
|
copySelectedLayer: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
|
const layer = state.project.layers.find(
|
||||||
|
(item) => item.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (!layer) return state;
|
if (!layer) return state;
|
||||||
return { clipboardLayer: cloneLayer(layer) };
|
return { clipboardLayer: cloneLayer(layer) };
|
||||||
}),
|
}),
|
||||||
@@ -291,60 +389,89 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
cutSelectedLayer: () =>
|
cutSelectedLayer: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.selectedLayerId) return state;
|
if (!state.selectedLayerId) return state;
|
||||||
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
|
const layer = state.project.layers.find(
|
||||||
|
(item) => item.id === state.selectedLayerId,
|
||||||
|
);
|
||||||
if (!layer) return state;
|
if (!layer) return state;
|
||||||
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
||||||
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
return withCommittedProject(state, nextProject, {
|
||||||
|
clipboardLayer: cloneLayer(layer),
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
pasteLayer: (e?: ClipboardEvent) => {
|
pasteLayer: (e?: ClipboardEvent) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
||||||
// Internal layer clipboard takes priority
|
|
||||||
if (state.clipboardLayer) {
|
if (state.clipboardLayer) {
|
||||||
const base = state.clipboardLayer;
|
const base = state.clipboardLayer;
|
||||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
const pasted: Layer = {
|
||||||
set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
|
...base,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
x: base.x + 20,
|
||||||
|
y: base.y + 20,
|
||||||
|
};
|
||||||
|
set((s) =>
|
||||||
|
withCommittedProject(s, addLayer(s.project, pasted), {
|
||||||
|
selectedLayerId: pasted.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pasteImageBlob(blob: Blob) {
|
async function pasteImageBlob(blob: Blob) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
reader.onload = () =>
|
||||||
|
resolve(typeof reader.result === "string" ? reader.result : "");
|
||||||
reader.onerror = reject;
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
const imageSize = await new Promise<{ width: number; height: number }>(
|
||||||
|
(resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
image.onload = () =>
|
||||||
image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||||
image.src = dataUrl;
|
image.onerror = () =>
|
||||||
|
resolve({
|
||||||
|
width: DEFAULT_IMAGE_IMPORT.fallbackWidth,
|
||||||
|
height: DEFAULT_IMAGE_IMPORT.fallbackHeight,
|
||||||
});
|
});
|
||||||
|
image.src = dataUrl;
|
||||||
|
},
|
||||||
|
);
|
||||||
const layer = createLayer("raster", {
|
const layer = createLayer("raster", {
|
||||||
name: "Image",
|
name: "Image",
|
||||||
sourceUri: dataUrl,
|
asset: { kind: "inline", uri: dataUrl },
|
||||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||||
width: Math.max(1, Math.round(imageSize.width)),
|
width: Math.max(1, Math.round(imageSize.width)),
|
||||||
height: Math.max(1, Math.round(imageSize.height)),
|
height: Math.max(1, Math.round(imageSize.height)),
|
||||||
});
|
});
|
||||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
set((s) =>
|
||||||
|
withCommittedProject(s, addLayer(s.project, layer), {
|
||||||
|
selectedLayerId: layer.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt)
|
// Prefer event clipboard data to avoid permission prompts.
|
||||||
if (e?.clipboardData) {
|
if (e?.clipboardData) {
|
||||||
for (const item of Array.from(e.clipboardData.items)) {
|
for (const item of Array.from(e.clipboardData.items)) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith("image/")) {
|
||||||
const blob = item.getAsFile();
|
const blob = item.getAsFile();
|
||||||
if (blob) { void pasteImageBlob(blob); return; }
|
if (blob) {
|
||||||
|
void pasteImageBlob(blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: async Clipboard API (requires permission, may not work on Mac Safari)
|
// Async Clipboard API is a fallback because browser support and permissions vary.
|
||||||
navigator.clipboard.read().then(async (clipboardItems) => {
|
navigator.clipboard
|
||||||
|
.read()
|
||||||
|
.then(async (clipboardItems) => {
|
||||||
for (const item of clipboardItems) {
|
for (const item of clipboardItems) {
|
||||||
for (const type of item.types) {
|
for (const type of item.types) {
|
||||||
if (type.startsWith("image/")) {
|
if (type.startsWith("image/")) {
|
||||||
@@ -354,11 +481,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
resetProject: () => {
|
resetProject: () => {
|
||||||
releaseProjectObjectUrls(get().project);
|
localProjectRepository.releaseObjectUrls(get().project);
|
||||||
const project = createProject("Untitled Project");
|
const project = createProject("Untitled Project");
|
||||||
const history = makeHistory(project);
|
const history = makeHistory(project);
|
||||||
set({
|
set({
|
||||||
@@ -373,22 +501,28 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
saveCurrentProject: async () => {
|
saveCurrentProject: async () => {
|
||||||
await upsertProject(normalizeProject(get().project));
|
await localProjectRepository.upsertProject(normalizeProject(get().project));
|
||||||
set({ isDirty: false });
|
set({ isDirty: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
loadProjectById: async (projectId) => {
|
loadProjectById: async (projectId) => {
|
||||||
const project = await getProjectById(projectId);
|
const project = await localProjectRepository.getProject(projectId);
|
||||||
if (!project) return false;
|
if (!project) return false;
|
||||||
const normalized = normalizeProject(project);
|
const normalized = normalizeProject(project);
|
||||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
localProjectRepository.releaseObjectUrls(
|
||||||
|
get().project,
|
||||||
|
getProjectAssetIds(normalized),
|
||||||
|
);
|
||||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
setProject: (project) => {
|
setProject: (project) => {
|
||||||
const normalized = normalizeProject(project);
|
const normalized = normalizeProject(project);
|
||||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
localProjectRepository.releaseObjectUrls(
|
||||||
|
get().project,
|
||||||
|
getProjectAssetIds(normalized),
|
||||||
|
);
|
||||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -396,7 +530,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
const parsed = parseProjectFile(raw);
|
const parsed = parseProjectFile(raw);
|
||||||
if (!parsed.ok) return { ok: false, error: parsed.error };
|
if (!parsed.ok) return { ok: false, error: parsed.error };
|
||||||
const normalized = normalizeProject(parsed.project);
|
const normalized = normalizeProject(parsed.project);
|
||||||
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
|
localProjectRepository.releaseObjectUrls(
|
||||||
|
get().project,
|
||||||
|
getProjectAssetIds(normalized),
|
||||||
|
);
|
||||||
set(makeStableProjectState(get().selectedLayerId, normalized));
|
set(makeStableProjectState(get().selectedLayerId, normalized));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
@@ -404,34 +541,47 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
exportProjectToJson: () => {
|
exportProjectToJson: () => {
|
||||||
const project = normalizeProject(get().project);
|
const project = normalizeProject(get().project);
|
||||||
const history = get().history;
|
const history = get().history;
|
||||||
return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length });
|
return serializeProjectFile(project, {
|
||||||
|
checkpointCount: history.past.length + history.future.length,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
importImageFromFile: async (file) => {
|
importImageFromFile: async (file) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
reader.onload = () =>
|
||||||
|
resolve(typeof reader.result === "string" ? reader.result : "");
|
||||||
reader.onerror = reject;
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
|
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
|
||||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
const imageSize = await new Promise<{ width: number; height: number }>(
|
||||||
|
(resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
image.onload = () =>
|
||||||
|
resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||||
image.onerror = () =>
|
image.onerror = () =>
|
||||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
resolve({
|
||||||
image.src = dataUrl;
|
width: DEFAULT_IMAGE_IMPORT.fallbackWidth,
|
||||||
|
height: DEFAULT_IMAGE_IMPORT.fallbackHeight,
|
||||||
});
|
});
|
||||||
|
image.src = dataUrl;
|
||||||
|
},
|
||||||
|
);
|
||||||
const layer = createLayer("raster", {
|
const layer = createLayer("raster", {
|
||||||
name,
|
name,
|
||||||
sourceUri: dataUrl,
|
asset: { kind: "inline", uri: dataUrl },
|
||||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||||
width: Math.max(1, Math.round(imageSize.width)),
|
width: Math.max(1, Math.round(imageSize.width)),
|
||||||
height: Math.max(1, Math.round(imageSize.height)),
|
height: Math.max(1, Math.round(imageSize.height)),
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id }));
|
set((state) =>
|
||||||
|
withCommittedProject(state, addLayer(state.project, layer), {
|
||||||
|
selectedLayerId: layer.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateImageLayerSource: (layerId, sourceUri) =>
|
updateImageLayerSource: (layerId, sourceUri) =>
|
||||||
@@ -439,8 +589,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
if (!sourceUri) return state;
|
if (!sourceUri) return state;
|
||||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||||
if (!layer || layer.type !== "raster") return state;
|
if (!layer || layer.type !== "raster") return state;
|
||||||
if (layer.sourceUri === sourceUri) return state;
|
const currentSource = getLayerRuntimeSource(layer);
|
||||||
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
|
if (currentSource === sourceUri) return state;
|
||||||
|
const nextProject = updateLayerTransform(state.project, layerId, {
|
||||||
|
asset: { kind: "inline", uri: sourceUri },
|
||||||
|
runtimeSourceUri: undefined,
|
||||||
|
});
|
||||||
return withCommittedProject(state, nextProject);
|
return withCommittedProject(state, nextProject);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -448,23 +602,45 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||||
if (!layer) return state;
|
if (!layer) return state;
|
||||||
return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
|
return withCommittedProject(
|
||||||
|
state,
|
||||||
|
setLayerEffect(state.project, layerId, effect),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeLayerEffect: (layerId, kind) =>
|
removeLayerEffect: (layerId, kind) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||||
if (!layer) return state;
|
if (!layer) return state;
|
||||||
return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind));
|
return withCommittedProject(
|
||||||
|
state,
|
||||||
|
removeLayerEffect(state.project, layerId, kind),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setLayerVisible: (layerId, visible) =>
|
setLayerVisible: (layerId, visible) =>
|
||||||
set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))),
|
set((state) =>
|
||||||
|
withCommittedProject(
|
||||||
|
state,
|
||||||
|
setLayerVisible(state.project, layerId, visible),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
setEffectEnabled: (layerId, kind, enabled) =>
|
setEffectEnabled: (layerId, kind, enabled) =>
|
||||||
set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))),
|
set((state) =>
|
||||||
|
withCommittedProject(
|
||||||
|
state,
|
||||||
|
setEffectEnabled(state.project, layerId, kind, enabled),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
|
setCanvasSize: (width, height) =>
|
||||||
|
set((state) =>
|
||||||
|
withCommittedProject(
|
||||||
|
state,
|
||||||
|
applyCanvasSize(state.project, width, height),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
undo: () =>
|
undo: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -477,7 +653,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
project: cloneProject(previous),
|
project: cloneProject(previous),
|
||||||
history,
|
history,
|
||||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
selectedLayerId: resolveSelectedLayerId(
|
||||||
|
previous,
|
||||||
|
state.selectedLayerId,
|
||||||
|
),
|
||||||
transaction: null,
|
transaction: null,
|
||||||
...computeHistoryFlags(history),
|
...computeHistoryFlags(history),
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
@@ -504,19 +683,29 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
|
|
||||||
jumpToPast: (idx) =>
|
jumpToPast: (idx) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1));
|
const targetPastLength = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(state.history.past.length, idx - 1),
|
||||||
|
);
|
||||||
if (state.history.past.length === targetPastLength) return state;
|
if (state.history.past.length === targetPastLength) return state;
|
||||||
const moved = state.history.past.slice(targetPastLength);
|
const moved = state.history.past.slice(targetPastLength);
|
||||||
if (moved.length === 0) return state;
|
if (moved.length === 0) return state;
|
||||||
const previous = moved[0];
|
const previous = moved[0];
|
||||||
if (!previous) return state;
|
if (!previous) return state;
|
||||||
const past = state.history.past.slice(0, targetPastLength);
|
const past = state.history.past.slice(0, targetPastLength);
|
||||||
const future = [state.history.present, ...moved.slice(1), ...state.history.future];
|
const future = [
|
||||||
|
state.history.present,
|
||||||
|
...moved.slice(1),
|
||||||
|
...state.history.future,
|
||||||
|
];
|
||||||
const history = { past, present: cloneProject(previous), future };
|
const history = { past, present: cloneProject(previous), future };
|
||||||
return {
|
return {
|
||||||
project: cloneProject(previous),
|
project: cloneProject(previous),
|
||||||
history,
|
history,
|
||||||
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
|
selectedLayerId: resolveSelectedLayerId(
|
||||||
|
previous,
|
||||||
|
state.selectedLayerId,
|
||||||
|
),
|
||||||
transaction: null,
|
transaction: null,
|
||||||
...computeHistoryFlags(history),
|
...computeHistoryFlags(history),
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
@@ -525,14 +714,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
|
|
||||||
jumpToFuture: (idx) =>
|
jumpToFuture: (idx) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx));
|
const targetFutureLength = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(state.history.future.length, idx),
|
||||||
|
);
|
||||||
if (state.history.future.length === targetFutureLength) return state;
|
if (state.history.future.length === targetFutureLength) return state;
|
||||||
const redoCount = state.history.future.length - targetFutureLength;
|
const redoCount = state.history.future.length - targetFutureLength;
|
||||||
const next = state.history.future[redoCount - 1];
|
const next = state.history.future[redoCount - 1];
|
||||||
if (!next) return state;
|
if (!next) return state;
|
||||||
const consumedFuture = state.history.future.slice(0, redoCount - 1);
|
const consumedFuture = state.history.future.slice(0, redoCount - 1);
|
||||||
const future = state.history.future.slice(redoCount);
|
const future = state.history.future.slice(redoCount);
|
||||||
const past = capHistory([...state.history.past, state.history.present, ...consumedFuture]);
|
const past = capHistory([
|
||||||
|
...state.history.past,
|
||||||
|
state.history.present,
|
||||||
|
...consumedFuture,
|
||||||
|
]);
|
||||||
const history = { past, present: cloneProject(next), future };
|
const history = { past, present: cloneProject(next), future };
|
||||||
return {
|
return {
|
||||||
project: cloneProject(next),
|
project: cloneProject(next),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const LOCALE_KEY = "pien.ui.locale";
|
|||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemTheme(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "light";
|
if (typeof window === "undefined") return "light";
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTheme(): AppTheme {
|
function readTheme(): AppTheme {
|
||||||
|
|||||||
+4
-16
@@ -6,27 +6,15 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"types": [
|
"types": ["node"],
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"isolatedModules": true
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"**/*.ts",
|
"exclude": ["node_modules"]
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"turbo": "^2.9.9",
|
"turbo": "^2.9.15",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"apps/api": {
|
"apps/api": {
|
||||||
"name": "@pien-studio/api",
|
"name": "@pien-studio/api",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pien-studio/types": "workspace:*",
|
"@pien-studio/contracts": "workspace:*",
|
||||||
"elysia": "^1.1.25",
|
"elysia": "^1.1.25",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
@@ -70,6 +70,13 @@
|
|||||||
"name": "@pien-studio/config",
|
"name": "@pien-studio/config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/contracts": {
|
||||||
|
"name": "@pien-studio/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/editor-core": {
|
"packages/editor-core": {
|
||||||
"name": "@pien-studio/editor-core",
|
"name": "@pien-studio/editor-core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -397,6 +404,8 @@
|
|||||||
|
|
||||||
"@pien-studio/config": ["@pien-studio/config@workspace:packages/config"],
|
"@pien-studio/config": ["@pien-studio/config@workspace:packages/config"],
|
||||||
|
|
||||||
|
"@pien-studio/contracts": ["@pien-studio/contracts@workspace:packages/contracts"],
|
||||||
|
|
||||||
"@pien-studio/editor-core": ["@pien-studio/editor-core@workspace:packages/editor-core"],
|
"@pien-studio/editor-core": ["@pien-studio/editor-core@workspace:packages/editor-core"],
|
||||||
|
|
||||||
"@pien-studio/storage": ["@pien-studio/storage@workspace:packages/storage"],
|
"@pien-studio/storage": ["@pien-studio/storage@workspace:packages/storage"],
|
||||||
@@ -609,17 +618,17 @@
|
|||||||
|
|
||||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
"@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-64": ["@turbo/darwin-64@2.9.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-nnDo9R1Df+s2x6jxlERtbg7xRpuicf8p4J2krcnjeaMBt3q9V41pGXa4t9YM2Y4ozozsVJ+CH405CJUrWIQK4Q=="],
|
||||||
|
|
||||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MinO40EEcP5mJiTVpfjtEulsEBhVeryfq21QhYtJZ8hQJLHGgy459rcmDVAY8/JERe4dkVU4KW+zoLF22o01EA=="],
|
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fDSx56oqoFuS+yUQw7hqjQTkjrSLdMcplhuLC8HcSkWC6YrpwEmUUYsPYHPxy4ALvLxnmPQuk6XoSD8tdkjP+g=="],
|
||||||
|
|
||||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.9", "", { "os": "linux", "cpu": "x64" }, "sha512-7JNLw88Isk+gMlbsC8pulLDkrqe2B827ZsKFEHilb17AC6Xn/62pzH7afjY7fEU6Ayp4XP/vGhlRWOzqBvBvIQ=="],
|
"@turbo/linux-64": ["@turbo/linux-64@2.9.15", "", { "os": "linux", "cpu": "x64" }, "sha512-/bmxn+x/xE+oh0VzEXt/zf2zsORAYZPrL3db5/VrXzYt0Z4wxcvffwJBGlSfla2smfS1BLGBiyWldJlWDXJVXA=="],
|
||||||
|
|
||||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0pnXDwPw1rHii98JZPRg7SvsjIzy7jrhkwGU9Jy5fVYoMdYd3P2vbtLfII+OJ0Mm4Ar5yykdHDTz3RWiRI1o9g=="],
|
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-cbOaDe1ijz5As+mimOOHgmRMolZZZO7miNBHHp5xdiYMm2Q/Dwu1JVLx/Kw4s7xjocG/oEoHrpHrxpEAIEfNiw=="],
|
||||||
|
|
||||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.9", "", { "os": "win32", "cpu": "x64" }, "sha512-vjDQycz4gQVvIq4n2rPtiiIESwJlAc406qtkiZlqyL+fHZEd9SxYNlBIFYtc5cuMuwrk+sIKrhN7XvwjmvS9YQ=="],
|
"@turbo/windows-64": ["@turbo/windows-64@2.9.15", "", { "os": "win32", "cpu": "x64" }, "sha512-/Fzm7afui7uK7dFBwrTXKuDhBBTiHk5I+hMVAPMR7cqQyDo2norCNUsN9PdNuYcmzYbhSOxzz498wQYvSAz29w=="],
|
||||||
|
|
||||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-V6NiH43oCctepbOdQFp7UjqLyK8p6Tt824QA+G4TE+B1BBHu80A0W8OCL+H7uBJ3XZjAj/hvPDw3k3l65DoDGw=="],
|
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOHEsLcqVdFXLw2ApWv4gxwfHzkUnpo9rHGml+9+dyHj148m/Bc+556kEvb5+4u6prI1LMd8zEZE2HcO6Jn2VQ=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
|
|
||||||
@@ -795,7 +804,7 @@
|
|||||||
|
|
||||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||||
|
|
||||||
@@ -1471,7 +1480,7 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="],
|
"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=="],
|
"turbo": ["turbo@2.9.15", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.15", "@turbo/darwin-arm64": "2.9.15", "@turbo/linux-64": "2.9.15", "@turbo/linux-arm64": "2.9.15", "@turbo/windows-64": "2.9.15", "@turbo/windows-arm64": "2.9.15" }, "bin": { "turbo": "bin/turbo" } }, "sha512-VpKvD9Z0Hu/xrGUAYX1wnhfpqv835wIwGqeKfulvBPTOcDap0E3nFwyzCAVV85fB1sBcBDEfTP+7FSW7GzwWSQ=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
@@ -1609,8 +1618,6 @@
|
|||||||
|
|
||||||
"aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"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=="],
|
|
||||||
|
|
||||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
|
|
||||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
@@ -1661,8 +1668,6 @@
|
|||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
"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/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|||||||
+3
-2
@@ -16,7 +16,8 @@
|
|||||||
"test:coverage": "turbo run test -- --coverage",
|
"test:coverage": "turbo run test -- --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --check .",
|
||||||
|
"format:fix": "prettier --write .",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"turbo": "^2.9.9",
|
"turbo": "^2.9.15",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@pien-studio/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DeviceSessionRequestSchema } from "./index";
|
||||||
|
|
||||||
|
describe("api contracts", () => {
|
||||||
|
it("accepts valid device session payload", () => {
|
||||||
|
const result = DeviceSessionRequestSchema.safeParse({
|
||||||
|
deviceId: "abcd1234",
|
||||||
|
locale: "ja",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown locale", () => {
|
||||||
|
const result = DeviceSessionRequestSchema.safeParse({
|
||||||
|
deviceId: "abcd1234",
|
||||||
|
locale: "fr",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const LocaleSchema = z.enum(["en", "th", "ja"]);
|
||||||
|
|
||||||
|
export const DeviceSessionRequestSchema = z.object({
|
||||||
|
deviceId: z.string().min(4),
|
||||||
|
locale: LocaleSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeviceSessionResponseSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
scope: z.literal("local-sync"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SyncBootstrapResponseSchema = z.object({
|
||||||
|
replication: z.object({
|
||||||
|
pull: z.string(),
|
||||||
|
push: z.string(),
|
||||||
|
strategy: z.literal("operation-log"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiErrorSchema = z.object({
|
||||||
|
error: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
requestId: z.string().optional(),
|
||||||
|
details: z.unknown().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeviceSessionRequest = z.infer<typeof DeviceSessionRequestSchema>;
|
||||||
|
export type DeviceSessionResponse = z.infer<typeof DeviceSessionResponseSchema>;
|
||||||
|
export type SyncBootstrapResponse = z.infer<typeof SyncBootstrapResponseSchema>;
|
||||||
|
export type ApiError = z.infer<typeof ApiErrorSchema>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
|
|||||||
y: Number.isFinite(region.y) ? region.y : 0,
|
y: Number.isFinite(region.y) ? region.y : 0,
|
||||||
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
|
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
|
||||||
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
|
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
|
||||||
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN) ? region.sourceWidth : undefined,
|
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN)
|
||||||
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN) ? region.sourceHeight : undefined,
|
? region.sourceWidth
|
||||||
|
: undefined,
|
||||||
|
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN)
|
||||||
|
? region.sourceHeight
|
||||||
|
: undefined,
|
||||||
censorColor: region.censorColor,
|
censorColor: region.censorColor,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const effectRegistry = new Map<string, EffectDefinition>(
|
|||||||
definitions.map((def) => [def.kind, def]),
|
definitions.map((def) => [def.kind, def]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getEffectDefinition(kind: string): EffectDefinition | undefined {
|
export function getEffectDefinition(
|
||||||
|
kind: string,
|
||||||
|
): EffectDefinition | undefined {
|
||||||
return effectRegistry.get(kind);
|
return effectRegistry.get(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
addLayer,
|
addLayer,
|
||||||
|
createLayer,
|
||||||
createProject,
|
createProject,
|
||||||
moveLayer,
|
moveLayer,
|
||||||
removeLayer,
|
removeLayer,
|
||||||
@@ -14,20 +15,10 @@ import {
|
|||||||
setCanvasSize,
|
setCanvasSize,
|
||||||
updateLayerTransform,
|
updateLayerTransform,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import type { Layer } from "@pien-studio/types";
|
import type { Layer, LayerType } from "@pien-studio/types";
|
||||||
|
|
||||||
function makeLayer(id: string, type: Layer["type"] = "raster"): Layer {
|
function makeLayer(id: string, type: LayerType = "raster"): Layer {
|
||||||
return {
|
return createLayer(type, { id, x: 0, y: 0 });
|
||||||
id,
|
|
||||||
type,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 1,
|
|
||||||
effects: [],
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("editor-core", () => {
|
describe("editor-core", () => {
|
||||||
@@ -48,7 +39,11 @@ describe("editor-core", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("moves a layer by delta", () => {
|
it("moves a layer by delta", () => {
|
||||||
const project = addLayer(createProject("move"), { ...makeLayer("l1", "sticker"), x: 10, y: 20 });
|
const project = addLayer(createProject("move"), {
|
||||||
|
...makeLayer("l1", "sticker"),
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
|
||||||
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
||||||
expect(moved.layers[0]?.x).toBe(25);
|
expect(moved.layers[0]?.x).toBe(25);
|
||||||
@@ -56,9 +51,15 @@ describe("editor-core", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates transform fields", () => {
|
it("updates transform fields", () => {
|
||||||
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
|
const project = addLayer(
|
||||||
|
createProject("transform"),
|
||||||
|
makeLayer("l1", "text"),
|
||||||
|
);
|
||||||
|
|
||||||
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
|
const updated = updateLayerTransform(project, "l1", {
|
||||||
|
scale: 1.35,
|
||||||
|
rotation: 22,
|
||||||
|
});
|
||||||
expect(updated.layers[0]?.scale).toBe(1.35);
|
expect(updated.layers[0]?.scale).toBe(1.35);
|
||||||
expect(updated.layers[0]?.rotation).toBe(22);
|
expect(updated.layers[0]?.rotation).toBe(22);
|
||||||
});
|
});
|
||||||
@@ -96,18 +97,32 @@ describe("editor-core", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 40,
|
amount: 40,
|
||||||
regions: [{ x: 0, y: 3, width: 1, height: 1, sourceWidth: undefined, sourceHeight: undefined, censorColor: undefined }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: 3,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
sourceWidth: undefined,
|
||||||
|
sourceHeight: undefined,
|
||||||
|
censorColor: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not create changes for unchanged layer visibility or effect enabled state", () => {
|
it("does not create changes for unchanged layer visibility or effect enabled state", () => {
|
||||||
const project = setLayerEffect(addLayer(createProject("visibility"), makeLayer("l1")), "l1", {
|
const project = setLayerEffect(
|
||||||
|
addLayer(createProject("visibility"), makeLayer("l1")),
|
||||||
|
"l1",
|
||||||
|
{
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
method: "pixelate",
|
method: "pixelate",
|
||||||
amount: 12,
|
amount: 12,
|
||||||
regions: [],
|
regions: [],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(setLayerVisible(project, "l1", true)).toBe(project);
|
expect(setLayerVisible(project, "l1", true)).toBe(project);
|
||||||
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
|
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
|
||||||
@@ -122,26 +137,48 @@ describe("editor-core", () => {
|
|||||||
rotation: Number.NaN,
|
rotation: Number.NaN,
|
||||||
opacity: 4,
|
opacity: 4,
|
||||||
});
|
});
|
||||||
const normalized = normalizeProject({ ...project, canvas: { width: 0.2, height: 20.6, unit: "px" } });
|
const normalized = normalizeProject({
|
||||||
|
...project,
|
||||||
|
canvas: { width: 0.2, height: 20.6, unit: "px" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(normalized.canvas).toEqual({ width: 1, height: 21, unit: "px" });
|
expect(normalized.canvas).toEqual({ width: 1, height: 21, unit: "px" });
|
||||||
expect(normalized.layers[0]).toMatchObject({ width: 10, height: 1, scale: 1, rotation: 0, opacity: 1 });
|
expect(normalized.layers[0]).toMatchObject({
|
||||||
|
width: 10,
|
||||||
|
height: 1,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes and parses project files with normalized projects", () => {
|
it("serializes and parses project files with normalized projects", () => {
|
||||||
const project = addLayer(createProject("file"), { ...makeLayer("l1"), width: 3.8 });
|
const project = addLayer(createProject("file"), {
|
||||||
|
...makeLayer("l1"),
|
||||||
|
width: 3.8,
|
||||||
|
});
|
||||||
const raw = serializeProjectFile(project, { checkpointCount: 2 });
|
const raw = serializeProjectFile(project, { checkpointCount: 2 });
|
||||||
const parsed = parseProjectFile(raw);
|
const parsed = parseProjectFile(raw);
|
||||||
|
|
||||||
expect(JSON.parse(raw).history.checkpointCount).toBe(2);
|
expect(JSON.parse(raw).history.checkpointCount).toBe(2);
|
||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
expect(parsed.ok ? parsed.project.layers[0]?.width : undefined).toBe(4);
|
expect(parsed.ok ? parsed.project.layers[0]?.width : undefined).toBe(4);
|
||||||
expect(parseProjectFile("not json")).toEqual({ ok: false, error: "Invalid JSON" });
|
expect(parseProjectFile("not json")).toEqual({
|
||||||
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({ ok: false, error: "Invalid project format" });
|
ok: false,
|
||||||
|
error: "Invalid JSON",
|
||||||
|
});
|
||||||
|
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid project format",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets canvas size with clamped rounded dimensions", () => {
|
it("sets canvas size with clamped rounded dimensions", () => {
|
||||||
const project = createProject("canvas");
|
const project = createProject("canvas");
|
||||||
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({ width: 1, height: 13, unit: "cm" });
|
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({
|
||||||
|
width: 1,
|
||||||
|
height: 13,
|
||||||
|
unit: "cm",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
export { createProject, setCanvasSize } from "./project";
|
export { createProject, setCanvasSize } from "./project";
|
||||||
export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers";
|
export {
|
||||||
|
createLayer,
|
||||||
|
addLayer,
|
||||||
|
removeLayer,
|
||||||
|
moveLayer,
|
||||||
|
updateLayerTransform,
|
||||||
|
reorderLayer,
|
||||||
|
setLayerEffect,
|
||||||
|
removeLayerEffect,
|
||||||
|
setLayerVisible,
|
||||||
|
setEffectEnabled,
|
||||||
|
} from "./layers";
|
||||||
export type { LayerFactoryOptions, TransformPatch } from "./layers";
|
export type { LayerFactoryOptions, TransformPatch } from "./layers";
|
||||||
export { normalizeProject } from "./normalize";
|
export { normalizeProject } from "./normalize";
|
||||||
export { serializeProjectFile, parseProjectFile } from "./serialization";
|
export { serializeProjectFile, parseProjectFile } from "./serialization";
|
||||||
@@ -7,30 +18,77 @@ export { getEffectDefinition, normalizeEffect } from "./effects/registry";
|
|||||||
export type { EffectDefinition } from "./effects/registry";
|
export type { EffectDefinition } from "./effects/registry";
|
||||||
export { faceBlurDefinition } from "./effects/face-blur";
|
export { faceBlurDefinition } from "./effects/face-blur";
|
||||||
export { getToolDefinition, getAllTools } from "./tools/registry";
|
export { getToolDefinition, getAllTools } from "./tools/registry";
|
||||||
export type { ToolDefinition, ToolInteractionMode, EditorToolId } from "./tools/registry";
|
export type {
|
||||||
|
ToolDefinition,
|
||||||
|
ToolInteractionMode,
|
||||||
|
EditorToolId,
|
||||||
|
} from "./tools/registry";
|
||||||
|
export {
|
||||||
|
HISTORY_LIMIT,
|
||||||
|
capHistory,
|
||||||
|
cloneLayer,
|
||||||
|
cloneProject,
|
||||||
|
computeHistoryFlags,
|
||||||
|
deepClone,
|
||||||
|
makeHistory,
|
||||||
|
resolveSelectedLayerId,
|
||||||
|
} from "./history";
|
||||||
|
export type { HistoryState } from "./history";
|
||||||
|
|
||||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||||
import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers";
|
import {
|
||||||
|
addLayer,
|
||||||
|
moveLayer,
|
||||||
|
removeLayer,
|
||||||
|
reorderLayer,
|
||||||
|
setLayerEffect,
|
||||||
|
updateLayerTransform,
|
||||||
|
} from "./layers";
|
||||||
import { setCanvasSize } from "./project";
|
import { setCanvasSize } from "./project";
|
||||||
|
|
||||||
export type EditorOperation =
|
export type EditorOperation =
|
||||||
| { type: "addLayer"; layer: Layer }
|
| { type: "addLayer"; layer: Layer }
|
||||||
| { type: "removeLayer"; layerId: string }
|
| { type: "removeLayer"; layerId: string }
|
||||||
| { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } }
|
| { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } }
|
||||||
| { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch }
|
| {
|
||||||
|
type: "updateLayerTransform";
|
||||||
|
layerId: string;
|
||||||
|
patch: import("./layers").TransformPatch;
|
||||||
|
}
|
||||||
| { type: "reorderLayer"; layerId: string; toIndex: number }
|
| { type: "reorderLayer"; layerId: string; toIndex: number }
|
||||||
| { type: "setLayerEffect"; layerId: string; effect: LayerEffect }
|
| { type: "setLayerEffect"; layerId: string; effect: LayerEffect }
|
||||||
| { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" };
|
| {
|
||||||
|
type: "setCanvasSize";
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unit?: "px" | "in" | "cm";
|
||||||
|
};
|
||||||
|
|
||||||
export function applyOperation(project: Project, operation: EditorOperation): Project {
|
export function applyOperation(
|
||||||
|
project: Project,
|
||||||
|
operation: EditorOperation,
|
||||||
|
): Project {
|
||||||
switch (operation.type) {
|
switch (operation.type) {
|
||||||
case "addLayer": return addLayer(project, operation.layer);
|
case "addLayer":
|
||||||
case "removeLayer": return removeLayer(project, operation.layerId);
|
return addLayer(project, operation.layer);
|
||||||
case "moveLayer": return moveLayer(project, operation.layerId, operation.delta);
|
case "removeLayer":
|
||||||
case "updateLayerTransform": return updateLayerTransform(project, operation.layerId, operation.patch);
|
return removeLayer(project, operation.layerId);
|
||||||
case "reorderLayer": return reorderLayer(project, operation.layerId, operation.toIndex);
|
case "moveLayer":
|
||||||
case "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect);
|
return moveLayer(project, operation.layerId, operation.delta);
|
||||||
case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit);
|
case "updateLayerTransform":
|
||||||
default: return project;
|
return updateLayerTransform(project, operation.layerId, operation.patch);
|
||||||
|
case "reorderLayer":
|
||||||
|
return reorderLayer(project, operation.layerId, operation.toIndex);
|
||||||
|
case "setLayerEffect":
|
||||||
|
return setLayerEffect(project, operation.layerId, operation.effect);
|
||||||
|
case "setCanvasSize":
|
||||||
|
return setCanvasSize(
|
||||||
|
project,
|
||||||
|
operation.width,
|
||||||
|
operation.height,
|
||||||
|
operation.unit,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
import type {
|
||||||
|
AssetRef,
|
||||||
|
Layer,
|
||||||
|
LayerEffect,
|
||||||
|
LayerType,
|
||||||
|
Project,
|
||||||
|
RasterLayer,
|
||||||
|
StickerLayer,
|
||||||
|
TextLayer,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import { normalizeEffect } from "./effects/registry";
|
import { normalizeEffect } from "./effects/registry";
|
||||||
|
|
||||||
export type LayerFactoryOptions = {
|
export type LayerFactoryOptions = {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
sourceUri?: string;
|
asset?: AssetRef | null;
|
||||||
|
runtimeSourceUri?: string;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
text?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
color?: string;
|
||||||
|
stickerId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransformPatch = {
|
export type TransformPatch = {
|
||||||
@@ -19,7 +34,8 @@ export type TransformPatch = {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
sourceUri?: string;
|
asset?: AssetRef | null;
|
||||||
|
runtimeSourceUri?: string;
|
||||||
effects?: LayerEffect[];
|
effects?: LayerEffect[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +43,11 @@ function withUpdatedAt(project: Project, layers: Layer[]): Project {
|
|||||||
return { ...project, layers, updatedAt: new Date().toISOString() };
|
return { ...project, layers, updatedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLayer(project: Project, layerId: string, updater: (layer: Layer) => Layer): Project {
|
function updateLayer(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
updater: (layer: Layer) => Layer,
|
||||||
|
): Project {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const layers = project.layers.map((layer) => {
|
const layers = project.layers.map((layer) => {
|
||||||
if (layer.id !== layerId) return layer;
|
if (layer.id !== layerId) return layer;
|
||||||
@@ -40,25 +60,62 @@ function updateLayer(project: Project, layerId: string, updater: (layer: Layer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
|
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
|
||||||
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => layer[key] !== patch[key]);
|
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => {
|
||||||
|
if (
|
||||||
|
(key === "asset" || key === "runtimeSourceUri") &&
|
||||||
|
layer.type !== "raster" &&
|
||||||
|
layer.type !== "sticker"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return (layer as Layer & TransformPatch)[key] !== patch[key];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
|
export function createLayer(
|
||||||
return {
|
type: LayerType,
|
||||||
|
options: LayerFactoryOptions = {},
|
||||||
|
): Layer {
|
||||||
|
const base = {
|
||||||
id: options.id ?? crypto.randomUUID(),
|
id: options.id ?? crypto.randomUUID(),
|
||||||
type,
|
|
||||||
name: options.name,
|
name: options.name,
|
||||||
sourceUri: options.sourceUri,
|
|
||||||
effects: [],
|
effects: [],
|
||||||
visible: true,
|
visible: true,
|
||||||
x: options.x ?? 110,
|
x: options.x ?? 110,
|
||||||
y: options.y ?? 90,
|
y: options.y ?? 90,
|
||||||
width: options.width,
|
width: options.width ?? 200,
|
||||||
height: options.height,
|
height: options.height ?? 150,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type,
|
||||||
|
text: options.text ?? "Text",
|
||||||
|
fontFamily: options.fontFamily ?? "system-ui",
|
||||||
|
fontSize: options.fontSize ?? 48,
|
||||||
|
color: options.color ?? "#111827",
|
||||||
|
} satisfies TextLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "sticker") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type,
|
||||||
|
asset: options.asset ?? null,
|
||||||
|
runtimeSourceUri: options.runtimeSourceUri,
|
||||||
|
stickerId: options.stickerId,
|
||||||
|
} satisfies StickerLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
type,
|
||||||
|
asset: options.asset ?? null,
|
||||||
|
runtimeSourceUri: options.runtimeSourceUri,
|
||||||
|
} satisfies RasterLayer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addLayer(project: Project, layer: Layer): Project {
|
export function addLayer(project: Project, layer: Layer): Project {
|
||||||
@@ -67,19 +124,47 @@ export function addLayer(project: Project, layer: Layer): Project {
|
|||||||
|
|
||||||
export function removeLayer(project: Project, layerId: string): Project {
|
export function removeLayer(project: Project, layerId: string): Project {
|
||||||
if (!project.layers.some((l) => l.id === layerId)) return project;
|
if (!project.layers.some((l) => l.id === layerId)) return project;
|
||||||
return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId));
|
return withUpdatedAt(
|
||||||
|
project,
|
||||||
|
project.layers.filter((l) => l.id !== layerId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
|
export function moveLayer(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
delta: { dx: number; dy: number },
|
||||||
|
): Project {
|
||||||
if (delta.dx === 0 && delta.dy === 0) return project;
|
if (delta.dx === 0 && delta.dy === 0) return project;
|
||||||
return updateLayer(project, layerId, (l) => ({ ...l, x: l.x + delta.dx, y: l.y + delta.dy }));
|
return updateLayer(project, layerId, (l) => ({
|
||||||
|
...l,
|
||||||
|
x: l.x + delta.dx,
|
||||||
|
y: l.y + delta.dy,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
|
export function updateLayerTransform(
|
||||||
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
patch: TransformPatch,
|
||||||
|
): Project {
|
||||||
|
return updateLayer(project, layerId, (l) => {
|
||||||
|
if (!hasTransformPatchChange(l, patch)) return l;
|
||||||
|
if (l.type === "text") {
|
||||||
|
const { asset, runtimeSourceUri, ...textPatch } = patch;
|
||||||
|
void asset;
|
||||||
|
void runtimeSourceUri;
|
||||||
|
return { ...l, ...textPatch };
|
||||||
|
}
|
||||||
|
return { ...l, ...patch };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
|
export function reorderLayer(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
toIndex: number,
|
||||||
|
): Project {
|
||||||
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
|
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
|
||||||
if (fromIndex < 0) return project;
|
if (fromIndex < 0) return project;
|
||||||
const layers = [...project.layers];
|
const layers = [...project.layers];
|
||||||
@@ -91,31 +176,56 @@ export function reorderLayer(project: Project, layerId: string, toIndex: number)
|
|||||||
return withUpdatedAt(project, layers);
|
return withUpdatedAt(project, layers);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
|
export function setLayerEffect(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
effect: LayerEffect,
|
||||||
|
): Project {
|
||||||
const normalizedEffect = normalizeEffect(effect);
|
const normalizedEffect = normalizeEffect(effect);
|
||||||
return updateLayer(project, layerId, (l) => {
|
return updateLayer(project, layerId, (l) => {
|
||||||
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
|
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
|
||||||
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? normalizedEffect : e)) : [...l.effects, normalizedEffect];
|
const effects =
|
||||||
|
idx >= 0
|
||||||
|
? l.effects.map((e, i) => (i === idx ? normalizedEffect : e))
|
||||||
|
: [...l.effects, normalizedEffect];
|
||||||
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
|
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
|
||||||
return { ...l, effects };
|
return { ...l, effects };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
|
export function removeLayerEffect(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
kind: LayerEffect["kind"],
|
||||||
|
): Project {
|
||||||
return updateLayer(project, layerId, (l) => {
|
return updateLayer(project, layerId, (l) => {
|
||||||
if (!l.effects.some((e) => e.kind === kind)) return l;
|
if (!l.effects.some((e) => e.kind === kind)) return l;
|
||||||
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
|
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
|
export function setLayerVisible(
|
||||||
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
visible: boolean,
|
||||||
|
): Project {
|
||||||
|
return updateLayer(project, layerId, (l) =>
|
||||||
|
l.visible === visible ? l : { ...l, visible },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
|
export function setEffectEnabled(
|
||||||
|
project: Project,
|
||||||
|
layerId: string,
|
||||||
|
kind: LayerEffect["kind"],
|
||||||
|
enabled: boolean,
|
||||||
|
): Project {
|
||||||
return updateLayer(project, layerId, (l) => {
|
return updateLayer(project, layerId, (l) => {
|
||||||
const effect = l.effects.find((e) => e.kind === kind);
|
const effect = l.effects.find((e) => e.kind === kind);
|
||||||
if (!effect || effect.enabled === enabled) return l;
|
if (!effect || effect.enabled === enabled) return l;
|
||||||
return { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) };
|
return {
|
||||||
|
...l,
|
||||||
|
effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1 @@
|
|||||||
import type { Project } from "@pien-studio/types";
|
export { normalizeProject } from "@pien-studio/types";
|
||||||
import { normalizeEffect } from "./effects/registry";
|
|
||||||
|
|
||||||
export function normalizeProject(project: Project): Project {
|
|
||||||
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: 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,
|
|
||||||
effects: Array.isArray(layer.effects) ? layer.effects.map(normalizeEffect) : [],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { PRESET_CANVAS_SIZES, type AspectRatio, type Project } from "@pien-studio/types";
|
import {
|
||||||
|
PRESET_CANVAS_SIZES,
|
||||||
|
type AspectRatio,
|
||||||
|
type Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
|
|
||||||
const DEFAULT_ASPECT: AspectRatio = "4:5";
|
const DEFAULT_ASPECT: AspectRatio = "4:5";
|
||||||
|
|
||||||
function defaultCanvas(aspect: AspectRatio) {
|
function defaultCanvas(aspect: AspectRatio) {
|
||||||
const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1];
|
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 };
|
return { width: preset.width, height: preset.height, unit: "px" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project {
|
export function createProject(
|
||||||
|
title: string,
|
||||||
|
aspect: AspectRatio = DEFAULT_ASPECT,
|
||||||
|
): Project {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|||||||
@@ -1,24 +1,63 @@
|
|||||||
import { ProjectFileSchema, type Project, type ProjectFile } from "@pien-studio/types";
|
import {
|
||||||
|
getLayerAssetRef,
|
||||||
|
ProjectFileSchema,
|
||||||
|
type Project,
|
||||||
|
type ProjectFile,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import { normalizeProject } from "./normalize";
|
import { normalizeProject } from "./normalize";
|
||||||
|
|
||||||
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
|
export function serializeProjectFile(
|
||||||
|
project: Project,
|
||||||
|
options?: { checkpointCount?: number },
|
||||||
|
): string {
|
||||||
|
const normalized = normalizeProject(project);
|
||||||
const document: ProjectFile = {
|
const document: ProjectFile = {
|
||||||
format: "pien.project",
|
format: "pien.project",
|
||||||
version: 1,
|
version: 2,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
app: { name: "pien.studio", platform: "web" },
|
app: { name: "pien.studio", platform: "web" },
|
||||||
project,
|
project: normalized,
|
||||||
assets: [],
|
assets: normalized.layers.flatMap((layer) => {
|
||||||
|
const asset = getLayerAssetRef(layer);
|
||||||
|
if (!asset) return [];
|
||||||
|
if (asset.kind === "stored") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: asset.id,
|
||||||
|
kind:
|
||||||
|
layer.type === "sticker"
|
||||||
|
? ("sticker" as const)
|
||||||
|
: ("image" as const),
|
||||||
|
name: layer.name ?? layer.id,
|
||||||
|
uri: `asset:${asset.id}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: layer.id,
|
||||||
|
kind:
|
||||||
|
layer.type === "sticker"
|
||||||
|
? ("sticker" as const)
|
||||||
|
: ("image" as const),
|
||||||
|
name: layer.name ?? layer.id,
|
||||||
|
uri: asset.uri,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
history: { checkpointCount: options?.checkpointCount ?? 0 },
|
history: { checkpointCount: options?.checkpointCount ?? 0 },
|
||||||
};
|
};
|
||||||
return JSON.stringify(document, null, 2);
|
return JSON.stringify(document, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
|
export function parseProjectFile(
|
||||||
|
raw: string,
|
||||||
|
): { ok: true; project: Project } | { ok: false; error: string } {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw) as unknown;
|
const data = JSON.parse(raw) as unknown;
|
||||||
const result = ProjectFileSchema.safeParse(data);
|
const result = ProjectFileSchema.safeParse(data);
|
||||||
if (result.success) return { ok: true, project: normalizeProject(result.data.project) };
|
if (result.success)
|
||||||
|
return { ok: true, project: normalizeProject(result.data.project) };
|
||||||
return { ok: false, error: "Invalid project format" };
|
return { ok: false, error: "Invalid project format" };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: "Invalid JSON" };
|
return { ok: false, error: "Invalid JSON" };
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export type ToolInteractionMode =
|
export type ToolInteractionMode = "select" | "pan" | "paint" | "annotate";
|
||||||
| "select" // can select and drag layers
|
|
||||||
| "pan" // pans the viewport, no layer interaction
|
|
||||||
| "paint" // pixel-level tool, fires onLayerClick with canvas coords
|
|
||||||
| "annotate"; // select-only, no drag (eg. face-blur region picking)
|
|
||||||
|
|
||||||
export type ToolDefinition = {
|
export type ToolDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -8,22 +8,25 @@ import {
|
|||||||
inferMimeType,
|
inferMimeType,
|
||||||
isBinaryLayer,
|
isBinaryLayer,
|
||||||
makeLinkId,
|
makeLinkId,
|
||||||
stripEmbeddedSourceUri,
|
stripRuntimeSource,
|
||||||
} from "./asset-records";
|
} from "./asset-records";
|
||||||
|
|
||||||
function layer(partial: Partial<Layer> = {}): Layer {
|
function layer(partial: Partial<Layer> = {}): Layer {
|
||||||
return {
|
return {
|
||||||
id: "layer-1",
|
id: "layer-1",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
|
asset: null,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
visible: true,
|
visible: true,
|
||||||
effects: [],
|
effects: [],
|
||||||
...partial,
|
...partial,
|
||||||
};
|
} as Layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function project(layers: Layer[]): Project {
|
function project(layers: Layer[]): Project {
|
||||||
@@ -43,22 +46,62 @@ describe("asset record helpers", () => {
|
|||||||
expect(isBinaryLayer(layer({ type: "raster" }))).toBe(true);
|
expect(isBinaryLayer(layer({ type: "raster" }))).toBe(true);
|
||||||
expect(isBinaryLayer(layer({ type: "sticker" }))).toBe(true);
|
expect(isBinaryLayer(layer({ type: "sticker" }))).toBe(true);
|
||||||
expect(isBinaryLayer(layer({ type: "text" }))).toBe(false);
|
expect(isBinaryLayer(layer({ type: "text" }))).toBe(false);
|
||||||
expect(inferMimeType(layer({ sourceUri: "data:image/png;base64,a" }))).toBe("image/png");
|
expect(
|
||||||
expect(inferMimeType(layer({ sourceUri: "data:image/webp;base64,a" }))).toBe("image/webp");
|
inferMimeType(
|
||||||
expect(inferMimeType(layer({ sourceUri: "https://example.com/image" }))).toBe("image/jpeg");
|
layer({ asset: { kind: "inline", uri: "data:image/png;base64,a" } }),
|
||||||
|
),
|
||||||
|
).toBe("image/png");
|
||||||
|
expect(
|
||||||
|
inferMimeType(
|
||||||
|
layer({ asset: { kind: "inline", uri: "data:image/webp;base64,a" } }),
|
||||||
|
),
|
||||||
|
).toBe("image/webp");
|
||||||
|
expect(
|
||||||
|
inferMimeType(
|
||||||
|
layer({ asset: { kind: "remote", uri: "https://example.com/image" } }),
|
||||||
|
),
|
||||||
|
).toBe("image/jpeg");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("chooses reusable assets by blob size", () => {
|
it("chooses reusable assets by blob size", () => {
|
||||||
const small = new Blob(["a"]);
|
const small = new Blob(["a"]);
|
||||||
const large = new Blob(["larger"]);
|
const large = new Blob(["larger"]);
|
||||||
expect(chooseReusableAsset([{ id: "small", blob: small }, { id: "large", blob: large }], new Blob(["b"]))?.id).toBe("small");
|
expect(
|
||||||
expect(chooseReusableAsset([{ id: "small", blob: small }], large)).toBeUndefined();
|
chooseReusableAsset(
|
||||||
|
[
|
||||||
|
{ id: "small", blob: small },
|
||||||
|
{ id: "large", blob: large },
|
||||||
|
],
|
||||||
|
new Blob(["b"]),
|
||||||
|
)?.id,
|
||||||
|
).toBe("small");
|
||||||
|
expect(
|
||||||
|
chooseReusableAsset([{ id: "small", blob: small }], large),
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds stable asset records with precedence for existing and reusable ids", () => {
|
it("builds stable asset records with precedence for existing and reusable ids", () => {
|
||||||
const blob = new Blob(["image"], { type: "image/png" });
|
const blob = new Blob(["image"], { type: "image/png" });
|
||||||
expect(buildAssetRecord({ existingId: "existing", fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" }).id).toBe("existing");
|
expect(
|
||||||
expect(buildAssetRecord({ reusable: { id: "reused", createdAt: "then" }, fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" })).toMatchObject({
|
buildAssetRecord({
|
||||||
|
existingId: "existing",
|
||||||
|
fallbackId: "new",
|
||||||
|
mimeType: "image/png",
|
||||||
|
blob,
|
||||||
|
hash: "h",
|
||||||
|
now: "now",
|
||||||
|
}).id,
|
||||||
|
).toBe("existing");
|
||||||
|
expect(
|
||||||
|
buildAssetRecord({
|
||||||
|
reusable: { id: "reused", createdAt: "then" },
|
||||||
|
fallbackId: "new",
|
||||||
|
mimeType: "image/png",
|
||||||
|
blob,
|
||||||
|
hash: "h",
|
||||||
|
now: "now",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
id: "reused",
|
id: "reused",
|
||||||
createdAt: "then",
|
createdAt: "then",
|
||||||
updatedAt: "now",
|
updatedAt: "now",
|
||||||
@@ -67,22 +110,51 @@ describe("asset record helpers", () => {
|
|||||||
|
|
||||||
it("builds asset links only for binary layers with assets", () => {
|
it("builds asset links only for binary layers with assets", () => {
|
||||||
const source = project([
|
const source = project([
|
||||||
layer({ id: "raster", assetId: "asset-r" }),
|
layer({ id: "raster", asset: { kind: "stored", id: "asset-r" } }),
|
||||||
layer({ id: "text", type: "text", assetId: "asset-text" }),
|
layer({
|
||||||
layer({ id: "sticker", type: "sticker", assetId: "asset-s" }),
|
id: "text",
|
||||||
|
type: "text",
|
||||||
|
text: "Text",
|
||||||
|
fontFamily: "system-ui",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#000",
|
||||||
|
}),
|
||||||
|
layer({
|
||||||
|
id: "sticker",
|
||||||
|
type: "sticker",
|
||||||
|
asset: { kind: "stored", id: "asset-s" },
|
||||||
|
}),
|
||||||
layer({ id: "empty" }),
|
layer({ id: "empty" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(makeLinkId("project-1", "raster")).toBe("project-1:raster");
|
expect(makeLinkId("project-1", "raster")).toBe("project-1:raster");
|
||||||
expect(buildAssetLinks(source, "now")).toEqual([
|
expect(buildAssetLinks(source, "now")).toEqual([
|
||||||
{ id: "project-1:raster", projectId: "project-1", layerId: "raster", assetId: "asset-r", updatedAt: "now" },
|
{
|
||||||
{ id: "project-1:sticker", projectId: "project-1", layerId: "sticker", assetId: "asset-s", updatedAt: "now" },
|
id: "project-1:raster",
|
||||||
|
projectId: "project-1",
|
||||||
|
layerId: "raster",
|
||||||
|
assetId: "asset-r",
|
||||||
|
updatedAt: "now",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-1:sticker",
|
||||||
|
projectId: "project-1",
|
||||||
|
layerId: "sticker",
|
||||||
|
assetId: "asset-s",
|
||||||
|
updatedAt: "now",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(collectProjectAssetIds(source)).toEqual(new Set(["asset-r", "asset-s"]));
|
expect(collectProjectAssetIds(source)).toEqual(
|
||||||
|
new Set(["asset-r", "asset-s"]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips embedded image source uris and keeps remote uris", () => {
|
it("strips runtime source uris", () => {
|
||||||
expect(stripEmbeddedSourceUri(layer({ sourceUri: "data:image/png;base64,a" })).sourceUri).toBeUndefined();
|
const stripped = stripRuntimeSource(
|
||||||
expect(stripEmbeddedSourceUri(layer({ sourceUri: "blob:local" })).sourceUri).toBe("blob:local");
|
layer({ runtimeSourceUri: "blob:local" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripped.type === "raster" ? stripped.runtimeSourceUri : undefined,
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { Layer, Project } from "@pien-studio/types";
|
import {
|
||||||
|
getAssetRefId,
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
type Layer,
|
||||||
|
type Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
|
|
||||||
export type AssetRecordInput = {
|
export type AssetRecordInput = {
|
||||||
existingId?: string;
|
existingId?: string;
|
||||||
@@ -27,12 +32,16 @@ export function isBinaryLayer(layer: Layer): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function inferMimeType(layer: Layer): string {
|
export function inferMimeType(layer: Layer): string {
|
||||||
if (layer.sourceUri?.startsWith("data:image/png")) return "image/png";
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
if (layer.sourceUri?.startsWith("data:image/webp")) return "image/webp";
|
if (sourceUri?.startsWith("data:image/png")) return "image/png";
|
||||||
|
if (sourceUri?.startsWith("data:image/webp")) return "image/webp";
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chooseReusableAsset<T extends { blob: Blob }>(assets: T[], blob: Blob): T | undefined {
|
export function chooseReusableAsset<T extends { blob: Blob }>(
|
||||||
|
assets: T[],
|
||||||
|
blob: Blob,
|
||||||
|
): T | undefined {
|
||||||
return assets.find((asset) => asset.blob.size === blob.size);
|
return assets.find((asset) => asset.blob.size === blob.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,25 +56,41 @@ export function buildAssetRecord(input: AssetRecordInput) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripEmbeddedSourceUri(layer: Layer): Layer {
|
export function stripRuntimeSource(layer: Layer): Layer {
|
||||||
|
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||||
return {
|
return {
|
||||||
...layer,
|
...layer,
|
||||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
runtimeSourceUri: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAssetLinks(project: Project, updatedAt: string): AssetLinkRecordInput[] {
|
export function buildAssetLinks(
|
||||||
|
project: Project,
|
||||||
|
updatedAt: string,
|
||||||
|
): AssetLinkRecordInput[] {
|
||||||
return project.layers
|
return project.layers
|
||||||
.filter((layer) => layer.assetId && isBinaryLayer(layer))
|
.filter(
|
||||||
|
(layer) => getAssetRefId(getLayerAsset(layer)) && isBinaryLayer(layer),
|
||||||
|
)
|
||||||
.map((layer) => ({
|
.map((layer) => ({
|
||||||
id: makeLinkId(project.id, layer.id),
|
id: makeLinkId(project.id, layer.id),
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
layerId: layer.id,
|
layerId: layer.id,
|
||||||
assetId: layer.assetId as string,
|
assetId: getAssetRefId(getLayerAsset(layer)) as string,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectProjectAssetIds(project: Project): Set<string> {
|
export function collectProjectAssetIds(project: Project): Set<string> {
|
||||||
return new Set(buildAssetLinks(project, new Date(0).toISOString()).map((link) => link.assetId));
|
return new Set(
|
||||||
|
buildAssetLinks(project, new Date(0).toISOString()).map(
|
||||||
|
(link) => link.assetId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayerAsset(layer: Layer) {
|
||||||
|
return layer.type === "raster" || layer.type === "sticker"
|
||||||
|
? layer.asset
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
|
import {
|
||||||
|
deleteProject,
|
||||||
|
duplicateProject,
|
||||||
|
getProjectById,
|
||||||
|
loadProjects,
|
||||||
|
saveProjects,
|
||||||
|
upsertProject,
|
||||||
|
} from "./index";
|
||||||
import type { Project } from "@pien-studio/types";
|
import type { Project } from "@pien-studio/types";
|
||||||
|
|
||||||
const DB_NAME = "pien.db";
|
const DB_NAME = "pien.db";
|
||||||
@@ -23,8 +30,12 @@ function ensureSchema(db: IDBDatabase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
|
||||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
keyPath: "id",
|
||||||
|
});
|
||||||
|
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +66,9 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
|
|||||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||||
const db = await requestToPromise(openRequest);
|
const db = await requestToPromise(openRequest);
|
||||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||||
const record = (await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId))) as Project | undefined;
|
const record = (await requestToPromise(
|
||||||
|
tx.objectStore(PROJECTS_STORE).get(projectId),
|
||||||
|
)) as Project | undefined;
|
||||||
db.close();
|
db.close();
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
@@ -92,6 +105,7 @@ function makeMalformedProject(id: string): Project {
|
|||||||
{
|
{
|
||||||
id: "layer-1",
|
id: "layer-1",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
|
asset: null,
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 10,
|
y: 10,
|
||||||
width: 10.4,
|
width: 10.4,
|
||||||
@@ -119,6 +133,7 @@ function makeFaceBlurProject(id: string): Project {
|
|||||||
{
|
{
|
||||||
id: "image-faceblur",
|
id: "image-faceblur",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
|
asset: null,
|
||||||
x: 30,
|
x: 30,
|
||||||
y: 40,
|
y: 40,
|
||||||
width: 600,
|
width: 600,
|
||||||
@@ -141,7 +156,10 @@ function makeFaceBlurProject(id: string): Project {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8="): Project {
|
function makeAssetProject(
|
||||||
|
id: string,
|
||||||
|
sourceUri = "data:image/png;base64,aGVsbG8=",
|
||||||
|
): Project {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -154,7 +172,7 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
|
|||||||
{
|
{
|
||||||
id: "image-1",
|
id: "image-1",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
sourceUri,
|
asset: { kind: "inline", uri: sourceUri },
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 10,
|
width: 10,
|
||||||
@@ -172,7 +190,10 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
|
|||||||
describe("storage read flows", () => {
|
describe("storage read flows", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
|
vi.spyOn(URL, "createObjectURL").mockImplementation(
|
||||||
|
(obj: Blob | MediaSource) =>
|
||||||
|
`blob:test-${"size" in obj ? obj.size : "media"}`,
|
||||||
|
);
|
||||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,7 +229,9 @@ describe("storage read flows", () => {
|
|||||||
await seedProject(source);
|
await seedProject(source);
|
||||||
|
|
||||||
const loaded = await getProjectById(source.id);
|
const loaded = await getProjectById(source.id);
|
||||||
const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur");
|
const effect = loaded?.layers[0]?.effects.find(
|
||||||
|
(e) => e.kind === "face-blur",
|
||||||
|
);
|
||||||
expect(effect).toBeDefined();
|
expect(effect).toBeDefined();
|
||||||
const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined;
|
const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined;
|
||||||
expect(region?.x).toBe(120);
|
expect(region?.x).toBe(120);
|
||||||
@@ -216,8 +239,11 @@ describe("storage read flows", () => {
|
|||||||
expect(region?.sourceHeight).toBeUndefined();
|
expect(region?.sourceHeight).toBeUndefined();
|
||||||
|
|
||||||
const listed = await loadProjects();
|
const listed = await loadProjects();
|
||||||
const listedEffect = listed.find((p) => p.id === source.id)?.layers[0]?.effects.find((e) => e.kind === "face-blur");
|
const listedEffect = listed
|
||||||
const listedRegion = listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined;
|
.find((p) => p.id === source.id)
|
||||||
|
?.layers[0]?.effects.find((e) => e.kind === "face-blur");
|
||||||
|
const listedRegion =
|
||||||
|
listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined;
|
||||||
expect(listedRegion?.width).toBe(180);
|
expect(listedRegion?.width).toBe(180);
|
||||||
expect(listedRegion?.sourceWidth).toBeUndefined();
|
expect(listedRegion?.sourceWidth).toBeUndefined();
|
||||||
expect(listedRegion?.sourceHeight).toBeUndefined();
|
expect(listedRegion?.sourceHeight).toBeUndefined();
|
||||||
@@ -227,13 +253,23 @@ describe("storage read flows", () => {
|
|||||||
await upsertProject(makeAssetProject("asset-project"));
|
await upsertProject(makeAssetProject("asset-project"));
|
||||||
|
|
||||||
const raw = await readRawProject("asset-project");
|
const raw = await readRawProject("asset-project");
|
||||||
expect(raw?.layers[0]?.assetId).toBeDefined();
|
expect(
|
||||||
expect(raw?.layers[0]?.sourceUri).toBeUndefined();
|
raw?.layers[0]?.type === "raster" ? raw.layers[0].asset?.kind : undefined,
|
||||||
|
).toBe("stored");
|
||||||
|
expect(
|
||||||
|
raw?.layers[0]?.type === "raster"
|
||||||
|
? raw.layers[0].runtimeSourceUri
|
||||||
|
: undefined,
|
||||||
|
).toBeUndefined();
|
||||||
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
||||||
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
|
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
|
||||||
|
|
||||||
const loaded = await getProjectById("asset-project");
|
const loaded = await getProjectById("asset-project");
|
||||||
expect(loaded?.layers[0]?.sourceUri).toMatch(/^blob:test-/);
|
expect(
|
||||||
|
loaded?.layers[0]?.type === "raster"
|
||||||
|
? loaded.layers[0].runtimeSourceUri
|
||||||
|
: undefined,
|
||||||
|
).toMatch(/^blob:test-/);
|
||||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,7 +299,10 @@ describe("storage read flows", () => {
|
|||||||
|
|
||||||
it("saveProjects prunes projects not in the replacement list", async () => {
|
it("saveProjects prunes projects not in the replacement list", async () => {
|
||||||
const keep = makeAssetProject("keep-project");
|
const keep = makeAssetProject("keep-project");
|
||||||
const drop = makeAssetProject("drop-project", "data:image/png;base64,ZHJvcA==");
|
const drop = makeAssetProject(
|
||||||
|
"drop-project",
|
||||||
|
"data:image/png;base64,ZHJvcA==",
|
||||||
|
);
|
||||||
await upsertProject(keep);
|
await upsertProject(keep);
|
||||||
await upsertProject(drop);
|
await upsertProject(drop);
|
||||||
|
|
||||||
|
|||||||
+174
-63
@@ -1,5 +1,12 @@
|
|||||||
import { normalizeProject } from "@pien-studio/editor-core";
|
import {
|
||||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
getAssetRefId,
|
||||||
|
getLayerRuntimeSource,
|
||||||
|
normalizeProject,
|
||||||
|
ProjectSchema,
|
||||||
|
type AssetRef,
|
||||||
|
type Layer,
|
||||||
|
type Project,
|
||||||
|
} from "@pien-studio/types";
|
||||||
import {
|
import {
|
||||||
buildAssetLinks,
|
buildAssetLinks,
|
||||||
buildAssetRecord,
|
buildAssetRecord,
|
||||||
@@ -7,7 +14,7 @@ import {
|
|||||||
inferMimeType,
|
inferMimeType,
|
||||||
isBinaryLayer,
|
isBinaryLayer,
|
||||||
makeLinkId,
|
makeLinkId,
|
||||||
stripEmbeddedSourceUri,
|
stripRuntimeSource,
|
||||||
} from "./asset-records";
|
} from "./asset-records";
|
||||||
|
|
||||||
const DB_NAME = "pien.db";
|
const DB_NAME = "pien.db";
|
||||||
@@ -65,22 +72,34 @@ function openDatabase(): Promise<IDBDatabase | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||||
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
|
const assetsStore = db.createObjectStore(ASSETS_STORE, {
|
||||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
keyPath: "id",
|
||||||
|
});
|
||||||
|
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const tx = request.transaction;
|
const tx = request.transaction;
|
||||||
if (tx) {
|
if (tx) {
|
||||||
const assetsStore = tx.objectStore(ASSETS_STORE);
|
const assetsStore = tx.objectStore(ASSETS_STORE);
|
||||||
if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) {
|
if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) {
|
||||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
|
||||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
keyPath: "id",
|
||||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
});
|
||||||
|
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,18 +123,45 @@ async function hashBlob(blob: Blob): Promise<string> {
|
|||||||
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
function getLayerAsset(layer: Layer): AssetRef | null | undefined {
|
||||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
return layer.type === "raster" || layer.type === "sticker"
|
||||||
|
? layer.asset
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (layer.sourceUri?.startsWith("data:image/")) {
|
function withStoredAsset(layer: Layer, assetId: string): Layer {
|
||||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
asset: { kind: "stored", id: assetId },
|
||||||
|
runtimeSourceUri: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRuntimeSource(layer: Layer, runtimeSourceUri: string): Layer {
|
||||||
|
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||||
|
return { ...layer, runtimeSourceUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putAssetFromLayer(
|
||||||
|
assetStore: IDBObjectStore,
|
||||||
|
layer: Layer,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const asset = getLayerAsset(layer);
|
||||||
|
if (!isBinaryLayer(layer)) return getAssetRefId(asset);
|
||||||
|
const sourceUri = getLayerRuntimeSource(layer);
|
||||||
|
|
||||||
|
if (sourceUri?.startsWith("data:image/")) {
|
||||||
|
const blob = await dataUrlToBlob(sourceUri);
|
||||||
const hash = await hashBlob(blob);
|
const hash = await hashBlob(blob);
|
||||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||||
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
|
const matching = (await requestToPromise(
|
||||||
|
hashIndex.getAll(hash),
|
||||||
|
)) as AssetRecord[];
|
||||||
const reusable = chooseReusableAsset(matching, blob);
|
const reusable = chooseReusableAsset(matching, blob);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const record = buildAssetRecord({
|
const record = buildAssetRecord({
|
||||||
existingId: layer.assetId,
|
existingId: getAssetRefId(asset),
|
||||||
reusable,
|
reusable,
|
||||||
fallbackId: crypto.randomUUID(),
|
fallbackId: crypto.randomUUID(),
|
||||||
mimeType: blob.type || inferMimeType(layer),
|
mimeType: blob.type || inferMimeType(layer),
|
||||||
@@ -129,7 +175,7 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
|
|||||||
return record.id;
|
return record.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return layer.assetId;
|
return getAssetRefId(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreparedLayerAsset = {
|
type PreparedLayerAsset = {
|
||||||
@@ -139,12 +185,20 @@ type PreparedLayerAsset = {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]> {
|
async function prepareLayerAssets(
|
||||||
|
layers: Layer[],
|
||||||
|
): Promise<PreparedLayerAsset[]> {
|
||||||
const prepared: PreparedLayerAsset[] = [];
|
const prepared: PreparedLayerAsset[] = [];
|
||||||
for (let index = 0; index < layers.length; index += 1) {
|
for (let index = 0; index < layers.length; index += 1) {
|
||||||
const layer = layers[index];
|
const layer = layers[index];
|
||||||
if (!layer || !isBinaryLayer(layer) || !layer.sourceUri?.startsWith("data:image/")) continue;
|
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
if (
|
||||||
|
!layer ||
|
||||||
|
!isBinaryLayer(layer) ||
|
||||||
|
!sourceUri?.startsWith("data:image/")
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const blob = await dataUrlToBlob(sourceUri);
|
||||||
const hash = await hashBlob(blob);
|
const hash = await hashBlob(blob);
|
||||||
prepared.push({
|
prepared.push({
|
||||||
layerIndex: index,
|
layerIndex: index,
|
||||||
@@ -156,20 +210,23 @@ async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]
|
|||||||
return prepared;
|
return prepared;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<Set<string>> {
|
async function syncLinksForProject(
|
||||||
|
db: IDBDatabase,
|
||||||
|
project: Project,
|
||||||
|
): Promise<Set<string>> {
|
||||||
const tx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
const tx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||||
const store = tx.objectStore(ASSET_LINKS_STORE);
|
const store = tx.objectStore(ASSET_LINKS_STORE);
|
||||||
const byProject = store.index(LINKS_BY_PROJECT_INDEX);
|
const byProject = store.index(LINKS_BY_PROJECT_INDEX);
|
||||||
const existing = (await requestToPromise(byProject.getAll(project.id))) as AssetLinkRecord[];
|
const existing = (await requestToPromise(
|
||||||
|
byProject.getAll(project.id),
|
||||||
|
)) as AssetLinkRecord[];
|
||||||
const existingMap = new Map(existing.map((link) => [link.id, link]));
|
const existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const links = buildAssetLinks(project, now);
|
const links = buildAssetLinks(project, now);
|
||||||
const referenced = new Set(links.map((link) => link.assetId));
|
const referenced = new Set(links.map((link) => link.assetId));
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
await requestToPromise(
|
await requestToPromise(store.put(link satisfies AssetLinkRecord));
|
||||||
store.put(link satisfies AssetLinkRecord),
|
|
||||||
);
|
|
||||||
existingMap.delete(link.id);
|
existingMap.delete(link.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +246,14 @@ async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<S
|
|||||||
return referenced;
|
return referenced;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Iterable<string>): Promise<number> {
|
async function cleanupOrphansInternal(
|
||||||
const assetsTx = db.transaction([ASSETS_STORE, ASSET_LINKS_STORE], "readwrite");
|
db: IDBDatabase,
|
||||||
|
candidateAssetIds?: Iterable<string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const assetsTx = db.transaction(
|
||||||
|
[ASSETS_STORE, ASSET_LINKS_STORE],
|
||||||
|
"readwrite",
|
||||||
|
);
|
||||||
const assetsStore = assetsTx.objectStore(ASSETS_STORE);
|
const assetsStore = assetsTx.objectStore(ASSETS_STORE);
|
||||||
const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE);
|
const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE);
|
||||||
const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX);
|
const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX);
|
||||||
@@ -201,11 +264,17 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
|
|||||||
|
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const assetId of candidates) {
|
for (const assetId of candidates) {
|
||||||
const links = (await requestToPromise(byAsset.getAll(assetId))) as AssetLinkRecord[];
|
const links = (await requestToPromise(
|
||||||
|
byAsset.getAll(assetId),
|
||||||
|
)) as AssetLinkRecord[];
|
||||||
if (links.length > 0) continue;
|
if (links.length > 0) continue;
|
||||||
await requestToPromise(assetsStore.delete(assetId));
|
await requestToPromise(assetsStore.delete(assetId));
|
||||||
const url = objectUrlByAssetId.get(assetId);
|
const url = objectUrlByAssetId.get(assetId);
|
||||||
if (url?.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
if (
|
||||||
|
url?.startsWith("blob:") &&
|
||||||
|
typeof URL !== "undefined" &&
|
||||||
|
typeof URL.revokeObjectURL === "function"
|
||||||
|
) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
objectUrlByAssetId.delete(assetId);
|
objectUrlByAssetId.delete(assetId);
|
||||||
@@ -221,7 +290,10 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistProject(db: IDBDatabase, project: Project): Promise<Project> {
|
async function persistProject(
|
||||||
|
db: IDBDatabase,
|
||||||
|
project: Project,
|
||||||
|
): Promise<Project> {
|
||||||
const normalized = normalizeProject(project);
|
const normalized = normalizeProject(project);
|
||||||
const preparedAssets = await prepareLayerAssets(normalized.layers);
|
const preparedAssets = await prepareLayerAssets(normalized.layers);
|
||||||
const assetTx = db.transaction(ASSETS_STORE, "readwrite");
|
const assetTx = db.transaction(ASSETS_STORE, "readwrite");
|
||||||
@@ -232,11 +304,13 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
for (const prepared of preparedAssets) {
|
for (const prepared of preparedAssets) {
|
||||||
const layer = layers[prepared.layerIndex];
|
const layer = layers[prepared.layerIndex];
|
||||||
if (!layer) continue;
|
if (!layer) continue;
|
||||||
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
|
const matching = (await requestToPromise(
|
||||||
|
hashIndex.getAll(prepared.hash),
|
||||||
|
)) as AssetRecord[];
|
||||||
const reusable = chooseReusableAsset(matching, prepared.blob);
|
const reusable = chooseReusableAsset(matching, prepared.blob);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const record = buildAssetRecord({
|
const record = buildAssetRecord({
|
||||||
existingId: layer.assetId,
|
existingId: getAssetRefId(getLayerAsset(layer)),
|
||||||
reusable,
|
reusable,
|
||||||
fallbackId: crypto.randomUUID(),
|
fallbackId: crypto.randomUUID(),
|
||||||
mimeType: prepared.mimeType,
|
mimeType: prepared.mimeType,
|
||||||
@@ -247,11 +321,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
|
|
||||||
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||||
|
|
||||||
layers[prepared.layerIndex] = {
|
layers[prepared.layerIndex] = withStoredAsset(layer, record.id);
|
||||||
...layer,
|
|
||||||
assetId: record.id,
|
|
||||||
sourceUri: undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < layers.length; index += 1) {
|
for (let index = 0; index < layers.length; index += 1) {
|
||||||
@@ -260,8 +330,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||||
if (!assetId) continue;
|
if (!assetId) continue;
|
||||||
layers[index] = {
|
layers[index] = {
|
||||||
...stripEmbeddedSourceUri(layer),
|
...withStoredAsset(stripRuntimeSource(layer), assetId),
|
||||||
assetId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,21 +355,30 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
return persisted;
|
return persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateProject(db: IDBDatabase, project: Project): Promise<Project> {
|
async function hydrateProject(
|
||||||
|
db: IDBDatabase,
|
||||||
|
project: Project,
|
||||||
|
): Promise<Project> {
|
||||||
const tx = db.transaction(ASSETS_STORE, "readonly");
|
const tx = db.transaction(ASSETS_STORE, "readonly");
|
||||||
const store = tx.objectStore(ASSETS_STORE);
|
const store = tx.objectStore(ASSETS_STORE);
|
||||||
|
|
||||||
const layers = await Promise.all(
|
const layers = await Promise.all(
|
||||||
project.layers.map(async (layer) => {
|
project.layers.map(async (layer) => {
|
||||||
if (!layer.assetId) return layer;
|
const assetId = getAssetRefId(getLayerAsset(layer));
|
||||||
const record = (await requestToPromise(store.get(layer.assetId))) as AssetRecord | undefined;
|
if (!assetId) return layer;
|
||||||
|
const record = (await requestToPromise(store.get(assetId))) as
|
||||||
|
| AssetRecord
|
||||||
|
| undefined;
|
||||||
if (!record?.blob) return layer;
|
if (!record?.blob) return layer;
|
||||||
const existing = objectUrlByAssetId.get(layer.assetId);
|
const existing = objectUrlByAssetId.get(assetId);
|
||||||
if (existing) return { ...layer, sourceUri: existing };
|
if (existing) return withRuntimeSource(layer, existing);
|
||||||
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
|
if (
|
||||||
|
typeof URL !== "undefined" &&
|
||||||
|
typeof URL.createObjectURL === "function"
|
||||||
|
) {
|
||||||
const objectUrl = URL.createObjectURL(record.blob);
|
const objectUrl = URL.createObjectURL(record.blob);
|
||||||
objectUrlByAssetId.set(layer.assetId, objectUrl);
|
objectUrlByAssetId.set(assetId, objectUrl);
|
||||||
return { ...layer, sourceUri: objectUrl };
|
return withRuntimeSource(layer, objectUrl);
|
||||||
}
|
}
|
||||||
return layer;
|
return layer;
|
||||||
}),
|
}),
|
||||||
@@ -309,17 +387,27 @@ async function hydrateProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
return { ...project, layers };
|
return { ...project, layers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterable<string>): void {
|
export function releaseProjectObjectUrls(
|
||||||
const keep = keepAssetIds ? new Set(Array.from(keepAssetIds).filter(Boolean)) : null;
|
project: Project,
|
||||||
|
keepAssetIds?: Iterable<string>,
|
||||||
|
): void {
|
||||||
|
const keep = keepAssetIds
|
||||||
|
? new Set(Array.from(keepAssetIds).filter(Boolean))
|
||||||
|
: null;
|
||||||
for (const layer of project.layers) {
|
for (const layer of project.layers) {
|
||||||
if (!layer.assetId) continue;
|
const assetId = getAssetRefId(getLayerAsset(layer));
|
||||||
if (keep?.has(layer.assetId)) continue;
|
if (!assetId) continue;
|
||||||
const url = objectUrlByAssetId.get(layer.assetId);
|
if (keep?.has(assetId)) continue;
|
||||||
|
const url = objectUrlByAssetId.get(assetId);
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
if (
|
||||||
|
url.startsWith("blob:") &&
|
||||||
|
typeof URL !== "undefined" &&
|
||||||
|
typeof URL.revokeObjectURL === "function"
|
||||||
|
) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
objectUrlByAssetId.delete(layer.assetId);
|
objectUrlByAssetId.delete(assetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,13 +456,17 @@ export async function loadProjects(): Promise<Project[]> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||||
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
|
const records = await requestToPromise(
|
||||||
|
tx.objectStore(PROJECTS_STORE).getAll(),
|
||||||
|
);
|
||||||
const parsed = (records as unknown[])
|
const parsed = (records as unknown[])
|
||||||
.map((record) => ProjectSchema.safeParse(record))
|
.map((record) => ProjectSchema.safeParse(record))
|
||||||
.filter((result) => result.success)
|
.filter((result) => result.success)
|
||||||
.map((result) => normalizeProject(result.data));
|
.map((result) => normalizeProject(result.data));
|
||||||
|
|
||||||
const hydrated = await Promise.all(parsed.map((project) => hydrateProject(db, project)));
|
const hydrated = await Promise.all(
|
||||||
|
parsed.map((project) => hydrateProject(db, project)),
|
||||||
|
);
|
||||||
await cleanupOrphansInternal(db);
|
await cleanupOrphansInternal(db);
|
||||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -382,13 +474,17 @@ export async function loadProjects(): Promise<Project[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
export async function getProjectById(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<Project | null> {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
const record = await requestToPromise(
|
||||||
|
tx.objectStore(PROJECTS_STORE).get(projectId),
|
||||||
|
);
|
||||||
const parsed = ProjectSchema.safeParse(record);
|
const parsed = ProjectSchema.safeParse(record);
|
||||||
if (!parsed.success) return null;
|
if (!parsed.success) return null;
|
||||||
return await hydrateProject(db, normalizeProject(parsed.data));
|
return await hydrateProject(db, normalizeProject(parsed.data));
|
||||||
@@ -401,7 +497,10 @@ export async function upsertProject(project: Project): Promise<void> {
|
|||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
await persistProject(db, {
|
||||||
|
...project,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
@@ -415,7 +514,9 @@ export async function deleteProject(projectId: string): Promise<void> {
|
|||||||
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||||
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
||||||
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
||||||
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
|
const links = (await requestToPromise(
|
||||||
|
byProject.getAll(projectId),
|
||||||
|
)) as AssetLinkRecord[];
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
await requestToPromise(linksStore.delete(link.id));
|
await requestToPromise(linksStore.delete(link.id));
|
||||||
}
|
}
|
||||||
@@ -426,14 +527,21 @@ export async function deleteProject(projectId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
await requestToPromise(
|
||||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
projectTx.objectStore(PROJECTS_STORE).delete(projectId),
|
||||||
|
);
|
||||||
|
await cleanupOrphansInternal(
|
||||||
|
db,
|
||||||
|
links.map((link) => link.assetId),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicateProject(projectId: string): Promise<Project | null> {
|
export async function duplicateProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<Project | null> {
|
||||||
const source = await getProjectById(projectId);
|
const source = await getProjectById(projectId);
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -443,7 +551,10 @@ export async function duplicateProject(projectId: string): Promise<Project | nul
|
|||||||
title: `${source.title} Copy`,
|
title: `${source.title} Copy`,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })),
|
layers: source.layers.map((layer) => ({
|
||||||
|
...layer,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
await upsertProject(copy);
|
await upsertProject(copy);
|
||||||
return getProjectById(copy.id);
|
return getProjectById(copy.id);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DeviceSessionSchema, ProjectFileSchema, ProjectSchema } from "./index";
|
import { ProjectFileSchema, ProjectSchema } from "./index";
|
||||||
|
|
||||||
describe("types schemas", () => {
|
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", () => {
|
it("validates project shape", () => {
|
||||||
const result = ProjectSchema.safeParse({
|
const result = ProjectSchema.safeParse({
|
||||||
id: "p1",
|
id: "p1",
|
||||||
@@ -35,7 +19,7 @@ describe("types schemas", () => {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const result = ProjectFileSchema.safeParse({
|
const result = ProjectFileSchema.safeParse({
|
||||||
format: "pien.project",
|
format: "pien.project",
|
||||||
version: 1,
|
version: 2,
|
||||||
exportedAt: now,
|
exportedAt: now,
|
||||||
app: { name: "pien.studio", platform: "web" },
|
app: { name: "pien.studio", platform: "web" },
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
+106
-19
@@ -2,6 +2,12 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const LayerTypeSchema = z.enum(["raster", "text", "sticker"]);
|
export const LayerTypeSchema = z.enum(["raster", "text", "sticker"]);
|
||||||
|
|
||||||
|
export const AssetRefSchema = z.discriminatedUnion("kind", [
|
||||||
|
z.object({ kind: z.literal("inline"), uri: z.string() }),
|
||||||
|
z.object({ kind: z.literal("stored"), id: z.string() }),
|
||||||
|
z.object({ kind: z.literal("remote"), uri: z.string() }),
|
||||||
|
]);
|
||||||
|
|
||||||
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
|
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
|
||||||
|
|
||||||
export const FaceBlurRegionSchema = z.object({
|
export const FaceBlurRegionSchema = z.object({
|
||||||
@@ -25,31 +31,55 @@ export const FaceBlurEffectSchema = z.object({
|
|||||||
|
|
||||||
export const LayerEffectSchema = FaceBlurEffectSchema;
|
export const LayerEffectSchema = FaceBlurEffectSchema;
|
||||||
|
|
||||||
export const LayerSchema = z.object({
|
const BaseLayerSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: LayerTypeSchema,
|
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
assetId: z.string().optional(),
|
|
||||||
sourceUri: z.string().optional(),
|
|
||||||
effects: z.array(LayerEffectSchema).default([]),
|
effects: z.array(LayerEffectSchema).default([]),
|
||||||
visible: z.boolean().default(true),
|
visible: z.boolean().default(true),
|
||||||
x: z.number(),
|
x: z.number(),
|
||||||
y: z.number(),
|
y: z.number(),
|
||||||
width: z.number().optional(),
|
width: z.number(),
|
||||||
height: z.number().optional(),
|
height: z.number(),
|
||||||
scale: z.number().default(1),
|
scale: z.number().default(1),
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
opacity: z.number().min(0).max(1).default(1),
|
opacity: z.number().min(0).max(1).default(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const RasterLayerSchema = BaseLayerSchema.extend({
|
||||||
|
type: z.literal("raster"),
|
||||||
|
asset: AssetRefSchema.nullable(),
|
||||||
|
runtimeSourceUri: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TextLayerSchema = BaseLayerSchema.extend({
|
||||||
|
type: z.literal("text"),
|
||||||
|
text: z.string(),
|
||||||
|
fontFamily: z.string().default("system-ui"),
|
||||||
|
fontSize: z.number().positive().default(48),
|
||||||
|
color: z.string().default("#111827"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StickerLayerSchema = BaseLayerSchema.extend({
|
||||||
|
type: z.literal("sticker"),
|
||||||
|
asset: AssetRefSchema.nullable(),
|
||||||
|
runtimeSourceUri: z.string().optional(),
|
||||||
|
stickerId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LayerSchema = z.discriminatedUnion("type", [
|
||||||
|
RasterLayerSchema,
|
||||||
|
TextLayerSchema,
|
||||||
|
StickerLayerSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const AspectRatioSchema = z.enum([
|
export const AspectRatioSchema = z.enum([
|
||||||
"1:1", // square feed
|
"1:1",
|
||||||
"4:5", // portrait 4:5
|
"4:5",
|
||||||
"9:16", // story / vertical
|
"9:16",
|
||||||
"16:9", // widescreen
|
"16:9",
|
||||||
"4:3", // classic photo
|
"4:3",
|
||||||
"3:2", // landscape photo
|
"3:2",
|
||||||
"free", // custom
|
"free",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||||
@@ -88,14 +118,9 @@ export const PRESET_CANVAS_SIZES: PresetAspectRatio[] = [
|
|||||||
{ label: "Classic (3:2)", value: "3:2", width: 1620, 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({
|
export const ProjectFileV1Schema = z.object({
|
||||||
format: z.literal("pien.project"),
|
format: z.literal("pien.project"),
|
||||||
version: z.literal(1),
|
version: z.literal(2),
|
||||||
exportedAt: z.string(),
|
exportedAt: z.string(),
|
||||||
app: z.object({
|
app: z.object({
|
||||||
name: z.literal("pien.studio"),
|
name: z.literal("pien.studio"),
|
||||||
@@ -120,9 +145,71 @@ export const ProjectFileSchema = ProjectFileV1Schema;
|
|||||||
|
|
||||||
export type Project = z.infer<typeof ProjectSchema>;
|
export type Project = z.infer<typeof ProjectSchema>;
|
||||||
export type Layer = z.infer<typeof LayerSchema>;
|
export type Layer = z.infer<typeof LayerSchema>;
|
||||||
|
export type RasterLayer = z.infer<typeof RasterLayerSchema>;
|
||||||
|
export type TextLayer = z.infer<typeof TextLayerSchema>;
|
||||||
|
export type StickerLayer = z.infer<typeof StickerLayerSchema>;
|
||||||
export type LayerType = z.infer<typeof LayerTypeSchema>;
|
export type LayerType = z.infer<typeof LayerTypeSchema>;
|
||||||
|
export type AssetRef = z.infer<typeof AssetRefSchema>;
|
||||||
export type LayerEffect = z.infer<typeof LayerEffectSchema>;
|
export type LayerEffect = z.infer<typeof LayerEffectSchema>;
|
||||||
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
||||||
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
||||||
export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
|
export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
|
||||||
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
||||||
|
|
||||||
|
export function getAssetRefId(
|
||||||
|
asset: AssetRef | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return asset?.kind === "stored" ? asset.id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayerAssetRef(layer: Layer): AssetRef | null | undefined {
|
||||||
|
return layer.type === "raster" || layer.type === "sticker"
|
||||||
|
? layer.asset
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayerRuntimeSource(layer: Layer): string | undefined {
|
||||||
|
if (layer.type !== "raster" && layer.type !== "sticker") return undefined;
|
||||||
|
return (
|
||||||
|
layer.runtimeSourceUri ??
|
||||||
|
(layer.asset?.kind === "inline" || layer.asset?.kind === "remote"
|
||||||
|
? layer.asset.uri
|
||||||
|
: undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLayerEffect(effect: LayerEffect): LayerEffect {
|
||||||
|
if (effect.kind === "face-blur") {
|
||||||
|
return {
|
||||||
|
...effect,
|
||||||
|
enabled: effect.enabled ?? true,
|
||||||
|
amount: Math.max(4, Math.min(40, Math.round(effect.amount))),
|
||||||
|
regions: Array.isArray(effect.regions) ? effect.regions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return effect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProject(project: Project): Project {
|
||||||
|
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: project.layers.map((layer) => ({
|
||||||
|
...layer,
|
||||||
|
width: Math.max(1, Math.round(layer.width)),
|
||||||
|
height: Math.max(1, Math.round(layer.height)),
|
||||||
|
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,
|
||||||
|
effects: Array.isArray(layer.effects)
|
||||||
|
? layer.effects.map(normalizeLayerEffect)
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ test("tools switch modes and action tools add layers", async ({ page }) => {
|
|||||||
await page.goto("/editor/new");
|
await page.goto("/editor/new");
|
||||||
|
|
||||||
await page.locator('button[title="Text"]').click();
|
await page.locator('button[title="Text"]').click();
|
||||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
await page.locator('button[title="Face"]').click();
|
await page.locator('button[title="Face"]').click();
|
||||||
await expect(page.getByText("Face Tool")).toBeVisible();
|
await expect(page.getByText("Face Tool")).toBeVisible();
|
||||||
@@ -28,13 +30,19 @@ test("undo and redo restore layer changes", async ({ page }) => {
|
|||||||
await page.goto("/editor/new");
|
await page.goto("/editor/new");
|
||||||
|
|
||||||
await page.locator('button[title="Text"]').click();
|
await page.locator('button[title="Text"]').click();
|
||||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
await page.locator('header button[title="Undo"]').click();
|
await page.locator('header button[title="Undo"]').click();
|
||||||
await expect(page.getByText("No layers yet. Add text or image.")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("No layers yet. Add text or image."),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await page.locator('header button[title="Redo"]').click();
|
await page.locator('header button[title="Redo"]').click();
|
||||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exports and imports project file", async ({ page }) => {
|
test("exports and imports project file", async ({ page }) => {
|
||||||
@@ -86,10 +94,14 @@ test("exports and imports project file", async ({ page }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(filePath);
|
await page
|
||||||
|
.locator('input[type="file"][accept*=".json"]')
|
||||||
|
.setInputFiles(filePath);
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/editor\//);
|
await expect(page).toHaveURL(/\/editor\//);
|
||||||
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0);
|
await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists project list to indexeddb across reload", async ({ page }) => {
|
test("persists project list to indexeddb across reload", async ({ page }) => {
|
||||||
@@ -99,5 +111,7 @@ test("persists project list to indexeddb across reload", async ({ page }) => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open" }).first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Open" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@pien-studio/types": ["./packages/types/src/index.ts"],
|
"@pien-studio/types": ["./packages/types/src/index.ts"],
|
||||||
|
"@pien-studio/contracts": ["./packages/contracts/src/index.ts"],
|
||||||
"@pien-studio/editor-core": ["./packages/editor-core/src/index.ts"],
|
"@pien-studio/editor-core": ["./packages/editor-core/src/index.ts"],
|
||||||
"@pien-studio/storage": ["./packages/storage/src/index.ts"],
|
"@pien-studio/storage": ["./packages/storage/src/index.ts"],
|
||||||
"@pien-studio/ui/*": ["./packages/ui/src/*"]
|
"@pien-studio/ui/*": ["./packages/ui/src/*"]
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
"$schema": "https://v2-9-16.turborepo.dev/schema.json",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user