188 lines
7.6 KiB
TypeScript
188 lines
7.6 KiB
TypeScript
import { commandIds } from "@commands/ids";
|
|
import type { ImageDocument } from "@core/document";
|
|
import type { Vec2D } from "@core/geometry";
|
|
import type { Layer } from "@core/layer";
|
|
import type { RasterLayer } from "@core/raster-layer";
|
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
|
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
|
import type { AppStore } from "@editor/store";
|
|
|
|
export type BrushSession = {
|
|
layerId: string;
|
|
assetId: string;
|
|
previousPoint: Vec2D;
|
|
mode: "brush" | "eraser";
|
|
source?: string;
|
|
pending?: Promise<void>;
|
|
cancelled?: boolean;
|
|
};
|
|
|
|
export type BrushTargetEditorState = {
|
|
selection: SelectionState;
|
|
tools: Pick<ToolState, "activeTool" | "interactionMode">;
|
|
maskEdit?: MaskEditState;
|
|
};
|
|
|
|
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
|
|
const layer = resolveBrushTargetLayer(document, editor);
|
|
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
|
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
|
|
}
|
|
|
|
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
|
return Boolean(resolveBrushTargetLayer(document, editor));
|
|
}
|
|
|
|
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
|
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
|
if (resolveBrushTargetLayer(document, editor)) return undefined;
|
|
|
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
|
if (!layerId) {
|
|
if (editor.selection.artboardId) return "Brushes paint layers, not artboards. Select or add a raster layer first.";
|
|
return "Select a raster layer or layer mask to paint.";
|
|
}
|
|
|
|
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
|
if (!layer) return "Select a raster layer or layer mask to paint.";
|
|
if (layer.locked) return "Unlock this layer before painting.";
|
|
if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
|
|
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
|
|
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
|
|
return "Select a raster layer or layer mask to paint.";
|
|
}
|
|
|
|
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
|
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
|
const editingMask = Boolean(editor.maskEdit);
|
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
|
if (!layerId) return undefined;
|
|
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
|
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
|
return layer;
|
|
}
|
|
|
|
export function updateBrushSession(options: {
|
|
store: AppStore;
|
|
session: BrushSession;
|
|
point: Vec2D;
|
|
color: string;
|
|
size: number;
|
|
hardness: number;
|
|
}): BrushSession {
|
|
const state = options.store.getState();
|
|
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
|
if (!layer) return options.session;
|
|
|
|
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
|
if (!asset) return options.session;
|
|
|
|
const from = options.session.previousPoint;
|
|
const to = options.point;
|
|
options.session.previousPoint = to;
|
|
options.session.pending = (options.session.pending ?? Promise.resolve())
|
|
.then(async () => {
|
|
if (options.session.cancelled) return;
|
|
|
|
const source = await drawStroke({
|
|
source: options.session.source ?? asset.source,
|
|
width: asset.intrinsicSize.w,
|
|
height: asset.intrinsicSize.h,
|
|
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
|
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
|
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
|
size: options.size,
|
|
hardness: options.hardness,
|
|
mode: options.session.mode,
|
|
});
|
|
|
|
if (options.session.cancelled) return;
|
|
options.session.source = source;
|
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return options.session;
|
|
}
|
|
|
|
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
|
|
await options.session.pending;
|
|
if (options.session.cancelled) return;
|
|
|
|
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source });
|
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
|
}
|
|
|
|
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
|
options.session.cancelled = true;
|
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
|
}
|
|
|
|
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
|
return {
|
|
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
|
|
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
|
|
};
|
|
}
|
|
|
|
async function drawStroke(options: {
|
|
source: string;
|
|
width: number;
|
|
height: number;
|
|
from: Vec2D;
|
|
to: Vec2D;
|
|
color: string;
|
|
size: number;
|
|
hardness: number;
|
|
mode: "brush" | "eraser";
|
|
}) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(options.width));
|
|
canvas.height = Math.max(1, Math.round(options.height));
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return options.source;
|
|
|
|
const image = await loadImage(options.source);
|
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
|
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
|
context.strokeStyle = options.color;
|
|
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color;
|
|
context.shadowBlur = (1 - hardness) * options.size;
|
|
context.lineWidth = options.size;
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
context.beginPath();
|
|
context.moveTo(options.from.x, options.from.y);
|
|
context.lineTo(options.to.x, options.to.y);
|
|
context.stroke();
|
|
context.globalCompositeOperation = "source-over";
|
|
|
|
return canvas.toDataURL("image/png");
|
|
}
|
|
|
|
function loadImage(source: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error("Failed to load raster layer"));
|
|
image.src = source;
|
|
});
|
|
}
|
|
|
|
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
|
|
const layer = findLayer(layers, layerId);
|
|
return layer?.type === "raster" ? layer : undefined;
|
|
}
|
|
|
|
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
|
|
for (const layer of layers) {
|
|
if (layer.id === layerId) return layer;
|
|
if (layer.type === "group") {
|
|
const child = findLayer(layer.children, layerId);
|
|
if (child) return child;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|