feat: add ComfyUI integration for image generation and management
- Implemented ComfyGenerateRequest type and associated functions for generating images using various architectures and modes. - Added functions for listing generation options and handling image uploads. - Created workflows for different generation modes including SDXL, Z-Image, Z-Image Turbo, and Anima. - Introduced GenerationJobStatus component to display the status of ongoing generation jobs. - Developed MaskControls for managing mask operations and displaying mask analysis. - Created palette items for tool selection, layer management, and generation settings.
This commit is contained in:
@@ -1,291 +0,0 @@
|
||||
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;
|
||||
width: number;
|
||||
height: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D;
|
||||
ready: Promise<boolean>;
|
||||
previousPoint: Vec2D;
|
||||
mode: "brush" | "eraser";
|
||||
changed?: boolean;
|
||||
pending?: Promise<void>;
|
||||
cancelled?: boolean;
|
||||
previewClosed?: boolean;
|
||||
previewRequested?: boolean;
|
||||
previewInFlight?: boolean;
|
||||
previewFrame?: number;
|
||||
previewSource?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return undefined;
|
||||
|
||||
const canvas = globalThis.document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w));
|
||||
canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
|
||||
const session: BrushSession = {
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
canvas,
|
||||
context,
|
||||
ready: Promise.resolve(false),
|
||||
previousPoint: point,
|
||||
mode: editor.tools.activeTool,
|
||||
};
|
||||
session.ready = initializeBrushSession(session, asset.source).catch(() => false);
|
||||
return session;
|
||||
}
|
||||
|
||||
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 || layer.assetId !== options.session.assetId) 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;
|
||||
if (!(await options.session.ready) || options.session.cancelled) return;
|
||||
|
||||
drawStrokeSegment({
|
||||
context: options.session.context,
|
||||
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
||||
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
||||
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||
size: options.size,
|
||||
hardness: options.hardness,
|
||||
mode: options.session.mode,
|
||||
});
|
||||
|
||||
if (options.session.cancelled) return;
|
||||
options.session.changed = true;
|
||||
requestBrushStrokePreview({ store: options.store, session: options.session });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return options.session;
|
||||
}
|
||||
|
||||
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||
await options.session.pending;
|
||||
if (options.session.cancelled) return;
|
||||
|
||||
const source = options.session.changed ? canvasToDataUrl(options.session.canvas) : undefined;
|
||||
if (source) {
|
||||
const state = options.store.getState();
|
||||
const maskEdit = state.editor.maskEdit;
|
||||
if (maskEdit?.maskLayerId === options.session.layerId) {
|
||||
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
|
||||
} else {
|
||||
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
|
||||
}
|
||||
}
|
||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||
closeBrushStrokePreview(options.session);
|
||||
}
|
||||
|
||||
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||
options.session.cancelled = true;
|
||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||
closeBrushStrokePreview(options.session);
|
||||
}
|
||||
|
||||
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 initializeBrushSession(session: BrushSession, source: string) {
|
||||
const image = await loadImage(source);
|
||||
if (session.cancelled) return false;
|
||||
|
||||
session.context.clearRect(0, 0, session.width, session.height);
|
||||
session.context.drawImage(image, 0, 0, session.width, session.height);
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawStrokeSegment(options: {
|
||||
context: CanvasRenderingContext2D;
|
||||
from: Vec2D;
|
||||
to: Vec2D;
|
||||
color: string;
|
||||
size: number;
|
||||
hardness: number;
|
||||
mode: "brush" | "eraser";
|
||||
}) {
|
||||
const { context } = options;
|
||||
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
||||
context.save();
|
||||
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.restore();
|
||||
}
|
||||
|
||||
function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
|
||||
if (options.session.cancelled || options.session.previewClosed) return;
|
||||
|
||||
options.session.previewRequested = true;
|
||||
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
|
||||
|
||||
options.session.previewFrame = requestAnimationFrame(() => {
|
||||
options.session.previewFrame = undefined;
|
||||
void publishBrushStrokePreview(options);
|
||||
});
|
||||
}
|
||||
|
||||
async function publishBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
|
||||
if (options.session.cancelled || options.session.previewClosed || !options.session.previewRequested) return;
|
||||
|
||||
options.session.previewRequested = false;
|
||||
options.session.previewInFlight = true;
|
||||
const source = await canvasToObjectUrl(options.session.canvas).catch(() => undefined);
|
||||
options.session.previewInFlight = false;
|
||||
|
||||
if (!source) {
|
||||
if (options.session.previewRequested) requestBrushStrokePreview(options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.session.cancelled || options.session.previewClosed) {
|
||||
URL.revokeObjectURL(source);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSource = options.session.previewSource;
|
||||
options.session.previewSource = source;
|
||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
|
||||
if (previousSource) URL.revokeObjectURL(previousSource);
|
||||
|
||||
if (options.session.previewRequested) requestBrushStrokePreview(options);
|
||||
}
|
||||
|
||||
function closeBrushStrokePreview(session: BrushSession) {
|
||||
session.previewClosed = true;
|
||||
if (session.previewFrame !== undefined) {
|
||||
cancelAnimationFrame(session.previewFrame);
|
||||
session.previewFrame = undefined;
|
||||
}
|
||||
if (session.previewSource) {
|
||||
URL.revokeObjectURL(session.previewSource);
|
||||
session.previewSource = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToObjectUrl(canvas: HTMLCanvasElement) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob ? URL.createObjectURL(blob) : undefined);
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToDataUrl(canvas: HTMLCanvasElement) {
|
||||
try {
|
||||
return canvas.toDataURL("image/png");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { InteractionMode } from "@editor/tools";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { CanvasInputState } from "./useCanvasInput";
|
||||
|
||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false, canBrush = true) {
|
||||
if (input.isPanning) return "cursor-grabbing";
|
||||
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true) {
|
||||
if (isPanning) return "cursor-grabbing";
|
||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
|
||||
if (!canBrush) return "cursor-not-allowed";
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { blurMaskValues, despeckleMaskValues, dilateMaskValues, erodeMaskValues, maskValueFromRgba } from "../mask/maskRaster";
|
||||
|
||||
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
|
||||
const state = store.getState();
|
||||
if (state.editor.tools.activeTool !== "magicWand") return false;
|
||||
const target = resolveTarget(state.document, state.editor);
|
||||
if (!target) return true;
|
||||
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
|
||||
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
|
||||
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
|
||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
|
||||
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
|
||||
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
||||
return true;
|
||||
}
|
||||
const assetId = crypto.randomUUID();
|
||||
const maskLayerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
||||
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
||||
store.dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId: target.layer.id,
|
||||
asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } },
|
||||
maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } },
|
||||
});
|
||||
store.dispatch(commandIds.toolExitMaskEdit, undefined);
|
||||
store.dispatch(commandIds.toolSetActive, { tool: "magicWand" });
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
const layerId = editor.selection.layerIds[0];
|
||||
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
|
||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: EditorState["tools"]["magicWand"]) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, width);
|
||||
canvas.height = Math.max(1, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return source;
|
||||
const image = await loadImage(source);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const start = (startY * canvas.width + startX) * 4;
|
||||
const key = [imageData.data[start] ?? 0, imageData.data[start + 1] ?? 0, imageData.data[start + 2] ?? 0];
|
||||
const selected = postProcessSelection(settings.contiguous ? floodSelect(imageData, canvas.width, canvas.height, startX, startY, key, settings.tolerance) : globalSelect(imageData, key, settings.tolerance), canvas.width, canvas.height, settings);
|
||||
const existingMask = existingMaskSource ? await loadMaskValues(existingMaskSource, canvas.width, canvas.height) : undefined;
|
||||
for (let pixel = 0; pixel < selected.length; pixel++) {
|
||||
const current = existingMask?.[pixel] ?? 255;
|
||||
const selectionValue = selected[pixel] ?? 0;
|
||||
const value = settings.mode === "add"
|
||||
? Math.min(current, 255 - selectionValue)
|
||||
: settings.mode === "subtract"
|
||||
? Math.max(current, selectionValue)
|
||||
: 255 - selectionValue;
|
||||
const index = pixel * 4;
|
||||
imageData.data[index] = 255;
|
||||
imageData.data[index + 1] = 255;
|
||||
imageData.data[index + 2] = 255;
|
||||
imageData.data[index + 3] = value;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function floodSelect(data: ImageData, width: number, height: number, startX: number, startY: number, key: number[], tolerance: number) {
|
||||
const selected = new Uint8Array(width * height);
|
||||
const queue: Array<[number, number]> = [[startX, startY]];
|
||||
while (queue.length) {
|
||||
const [x, y] = queue.pop()!;
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const pixel = y * width + x;
|
||||
if (selected[pixel]) continue;
|
||||
if (!matches(data, pixel, key, tolerance)) continue;
|
||||
selected[pixel] = 1;
|
||||
queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function globalSelect(data: ImageData, key: number[], tolerance: number) {
|
||||
const selected = new Uint8Array(data.width * data.height);
|
||||
for (let pixel = 0; pixel < selected.length; pixel++) if (matches(data, pixel, key, tolerance)) selected[pixel] = 1;
|
||||
return selected;
|
||||
}
|
||||
|
||||
function matches(data: ImageData, pixel: number, key: number[], tolerance: number) {
|
||||
const index = pixel * 4;
|
||||
return Math.hypot((data.data[index] ?? 0) - (key[0] ?? 0), (data.data[index + 1] ?? 0) - (key[1] ?? 0), (data.data[index + 2] ?? 0) - (key[2] ?? 0)) <= tolerance;
|
||||
}
|
||||
|
||||
function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) {
|
||||
let next: Uint8ClampedArray = selectionToMaskValues(selected);
|
||||
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
|
||||
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
|
||||
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
|
||||
if (despeckle > 0) next = despeckleMaskValues(next, width, height, despeckle);
|
||||
if (choke > 0) next = erodeMaskValues(next, width, height, choke);
|
||||
if (choke < 0) next = dilateMaskValues(next, width, height, -choke);
|
||||
if (feather > 0) next = blurMaskValues(next, width, height, feather);
|
||||
return next;
|
||||
}
|
||||
|
||||
function selectionToMaskValues(selected: Uint8Array) {
|
||||
const values = new Uint8ClampedArray(selected.length);
|
||||
for (let index = 0; index < selected.length; index += 1) values[index] = selected[index] ? 255 : 0;
|
||||
return values;
|
||||
}
|
||||
|
||||
async function loadMaskValues(source: string, width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const image = await loadImage(source);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const data = context.getImageData(0, 0, width, height);
|
||||
const values = new Uint8ClampedArray(width * height);
|
||||
for (let pixel = 0; pixel < values.length; pixel++) values[pixel] = maskValueFromRgba(data.data, pixel * 4);
|
||||
return values;
|
||||
}
|
||||
|
||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const found = findLayer(layer.children, layerId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 image"));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
@@ -158,6 +158,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
...state.editor.generation,
|
||||
candidates: [
|
||||
{
|
||||
id: "candidate",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
pointerInputEventFromPointerEvent,
|
||||
wheelInputEventFromWheelEvent,
|
||||
} from "@input/index";
|
||||
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||
import { applyMagicWandAt } from "./magic-wand";
|
||||
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
|
||||
import { applyMagicWandAt } from "@operations/masks/magic-wand";
|
||||
|
||||
export type CanvasInputOptions = {
|
||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||
@@ -21,16 +21,11 @@ export type CanvasInputOptions = {
|
||||
globalWheelConsumer: GlobalWheelConsumer;
|
||||
};
|
||||
|
||||
export type CanvasInputState = {
|
||||
isPanning: boolean;
|
||||
};
|
||||
|
||||
export function useCanvasInput(
|
||||
canvasRef: RefObject<HTMLCanvasElement | null>,
|
||||
store: AppStore,
|
||||
options: CanvasInputOptions,
|
||||
): CanvasInputState {
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
) {
|
||||
const brushSession = useRef<BrushSession | undefined>(undefined);
|
||||
const brushSessionId = useRef(0);
|
||||
|
||||
@@ -115,7 +110,7 @@ export function useCanvasInput(
|
||||
if (consumed) {
|
||||
clearBrushPreview();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
setIsPanning(true);
|
||||
store.dispatch(commandIds.editorSetPointerSession, { type: "pan" });
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -203,7 +198,7 @@ export function useCanvasInput(
|
||||
const consumed = panHandler.pointerUp(inputEvent);
|
||||
if (!consumed) return;
|
||||
|
||||
setIsPanning(false);
|
||||
store.dispatch(commandIds.editorSetPointerSession, undefined);
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
};
|
||||
@@ -250,7 +245,6 @@ export function useCanvasInput(
|
||||
};
|
||||
}, [canvasRef, options, store]);
|
||||
|
||||
return { isPanning };
|
||||
}
|
||||
|
||||
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||
|
||||
Reference in New Issue
Block a user