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((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image")); image.src = source; }); }