mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: layer effects, bucket fill, simple brush
This commit is contained in:
+100
-78
@@ -10,10 +10,16 @@ import {
|
||||
reorderLayer,
|
||||
serializeProjectFile,
|
||||
setCanvasSize as applyCanvasSize,
|
||||
setLayerEffect,
|
||||
removeLayerEffect,
|
||||
setLayerVisible,
|
||||
setEffectEnabled,
|
||||
updateLayerTransform,
|
||||
normalizeProject,
|
||||
getAllTools,
|
||||
type EditorToolId,
|
||||
} from "@pien-studio/editor-core";
|
||||
import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types";
|
||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
|
||||
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
|
||||
import { hasProjectChanged } from "../lib/project-equality";
|
||||
@@ -29,13 +35,10 @@ import {
|
||||
|
||||
const DRAFT_TRANSFORM_EPSILON = 0.01;
|
||||
|
||||
export const EDITOR_TOOLS = {
|
||||
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
|
||||
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
|
||||
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
|
||||
} as const;
|
||||
export { EditorToolId };
|
||||
|
||||
export type EditorToolId = keyof typeof EDITOR_TOOLS;
|
||||
const allTools = getAllTools();
|
||||
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
|
||||
|
||||
type TransactionState = {
|
||||
baselineProject: Project;
|
||||
@@ -52,7 +55,6 @@ type EditorState = {
|
||||
clipboardLayer: Layer | null;
|
||||
isDirty: boolean;
|
||||
transaction: TransactionState | null;
|
||||
tools: typeof EDITOR_TOOLS;
|
||||
startTransaction: () => void;
|
||||
commitTransaction: () => void;
|
||||
cancelTransaction: () => void;
|
||||
@@ -70,15 +72,19 @@ type EditorState = {
|
||||
selectLayer: (layerId: string | null) => void;
|
||||
copySelectedLayer: () => void;
|
||||
cutSelectedLayer: () => void;
|
||||
pasteLayer: () => void;
|
||||
pasteLayer: (e?: ClipboardEvent) => void;
|
||||
resetProject: () => void;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadProjectById: (projectId: string) => Promise<boolean>;
|
||||
setProject: (project: Project) => void;
|
||||
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
|
||||
addCanvasSizedLayer: (sourceUri: string, name?: string) => void;
|
||||
importImageFromFile: (file: File) => Promise<void>;
|
||||
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
|
||||
setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void;
|
||||
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
||||
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
||||
setLayerVisible: (layerId: string, visible: boolean) => void;
|
||||
setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void;
|
||||
setCanvasSize: (width: number, height: number) => void;
|
||||
exportProjectToJson: () => string;
|
||||
undo: () => void;
|
||||
@@ -129,7 +135,6 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
clipboardLayer: null,
|
||||
isDirty: false,
|
||||
transaction: null,
|
||||
tools: EDITOR_TOOLS,
|
||||
|
||||
startTransaction: () =>
|
||||
set((state) => {
|
||||
@@ -172,7 +177,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
applyProjectDraft: (project) => set(() => ({ project })),
|
||||
|
||||
setTool: (tool) => {
|
||||
if (!(tool in EDITOR_TOOLS)) return;
|
||||
if (!EDITOR_TOOLS[tool]) return;
|
||||
set({ tool });
|
||||
},
|
||||
|
||||
@@ -183,6 +188,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
||||
}),
|
||||
|
||||
addCanvasSizedLayer: (sourceUri, name) =>
|
||||
set((state) => {
|
||||
const { width, height } = state.project.canvas;
|
||||
const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height });
|
||||
const nextProject = addLayer(state.project, layer);
|
||||
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
|
||||
}),
|
||||
|
||||
setSelectedLayerPosition: (x, y) =>
|
||||
set((state) => {
|
||||
if (!state.selectedLayerId) return state;
|
||||
@@ -218,8 +231,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
||||
|
||||
if (current) {
|
||||
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined);
|
||||
const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined);
|
||||
const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
|
||||
|
||||
if (
|
||||
typeof currentWidth === "number" &&
|
||||
@@ -284,67 +297,64 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
||||
}),
|
||||
|
||||
pasteLayer: () => {
|
||||
pasteLayer: (e?: ClipboardEvent) => {
|
||||
const state = get();
|
||||
if (!state.clipboardLayer) {
|
||||
navigator.clipboard.read().then(async (clipboardItems) => {
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () =>
|
||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("image", {
|
||||
name: "Image",
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(imageSize.width)),
|
||||
height: Math.max(1, Math.round(imageSize.height)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("data:image") || trimmed.startsWith("http") || trimmed.startsWith("blob:")) {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const layer = createLayer("image", {
|
||||
name: "Image",
|
||||
sourceUri: trimmed,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
};
|
||||
image.src = trimmed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
// Internal layer clipboard takes priority
|
||||
if (state.clipboardLayer) {
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
|
||||
return;
|
||||
}
|
||||
const base = state.clipboardLayer;
|
||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||
const nextProject = addLayer(state.project, pasted);
|
||||
set((s) => withCommittedProject(s, nextProject, { selectedLayerId: pasted.id }));
|
||||
|
||||
async function pasteImageBlob(blob: Blob) {
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("raster", {
|
||||
name: "Image",
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||
width: Math.max(1, Math.round(imageSize.width)),
|
||||
height: Math.max(1, Math.round(imageSize.height)),
|
||||
});
|
||||
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||
}
|
||||
|
||||
// Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt)
|
||||
if (e?.clipboardData) {
|
||||
for (const item of Array.from(e.clipboardData.items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) { void pasteImageBlob(blob); return; }
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: async Clipboard API (requires permission, may not work on Mac Safari)
|
||||
navigator.clipboard.read().then(async (clipboardItems) => {
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
await pasteImageBlob(blob);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
resetProject: () => {
|
||||
@@ -412,7 +422,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||
image.src = dataUrl;
|
||||
});
|
||||
const layer = createLayer("image", {
|
||||
const layer = createLayer("raster", {
|
||||
name,
|
||||
sourceUri: dataUrl,
|
||||
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||
@@ -428,20 +438,32 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
set((state) => {
|
||||
if (!sourceUri) return state;
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
if (!layer || layer.type !== "raster") return state;
|
||||
if (layer.sourceUri === sourceUri) return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
|
||||
return withCommittedProject(state, nextProject);
|
||||
}),
|
||||
|
||||
setImageLayerFaceBlur: (layerId, faceBlur) =>
|
||||
setLayerEffect: (layerId, effect) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer || layer.type !== "image") return state;
|
||||
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur });
|
||||
return withCommittedProject(state, nextProject);
|
||||
if (!layer) return state;
|
||||
return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
|
||||
}),
|
||||
|
||||
removeLayerEffect: (layerId, kind) =>
|
||||
set((state) => {
|
||||
const layer = state.project.layers.find((item) => item.id === layerId);
|
||||
if (!layer) return state;
|
||||
return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind));
|
||||
}),
|
||||
|
||||
setLayerVisible: (layerId, visible) =>
|
||||
set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))),
|
||||
|
||||
setEffectEnabled: (layerId, kind, enabled) =>
|
||||
set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))),
|
||||
|
||||
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
|
||||
|
||||
undo: () =>
|
||||
|
||||
Reference in New Issue
Block a user