/** * Shared stroke pipeline for brush / eraser / inpaint. * * Per-sample stamping happens in `_strokeTo` (still in galleryEditor.js * because it touches a lot of pixel-pass internals). This module owns * the begin / continue / end orchestration around it: * * - begin: capture the inpaint-erase flag for the stroke, ensure a * mask sub-layer exists when inpaint runs against an empty * layer, push an undo entry with a tool-specific label, then * kick off the first stamp. * - continue: forward the new cursor position to `_strokeTo`. * - end: clear the drawing flag, composite, sync any tool indicators * that reflect mask state. * * Clone has its own begin (see tools/clone.js) but reuses `continue` * and `end` because once a clone stroke is in progress, the pipeline * is identical. * * @param {{ * saveState: (label: string) => void, * strokeTo: (x: number, y: number) => void, * composite: () => void, * getActiveMaskLayer: () => object | null, * activeParentLayer: () => object | null, * ensureActiveMaskLayer: () => object | null, * createLayer: (name: string, w: number, h: number) => object, * renderLayerPanel: () => void, * syncToolClearIndicators: () => void, * }} deps */ import { state } from '../state.js'; import { canvasCoords } from '../canvas-coords.js'; const STROKE_TOOLS = new Set(['brush', 'eraser', 'inpaint']); function strokeLabel(tool) { if (tool === 'brush') return 'Brush stroke'; if (tool === 'eraser') return 'Eraser stroke'; if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask'; return 'Stroke'; } export function createStrokeTool({ saveState, strokeTo, composite, getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer, renderLayerPanel, syncToolClearIndicators, }) { return { /** * Begin a stroke. Returns true if the dispatcher should consider * the event handled (i.e. tool is one of brush/eraser/inpaint). */ tryBegin(e) { if (!STROKE_TOOLS.has(state.tool)) return false; // Capture the inpaint-erase flag for this stroke. Ctrl+Alt // pressed at pointerdown flips the persistent toggle for one // stroke only. if (state.tool === 'inpaint') { const flip = e && e.ctrlKey && e.altKey; state.inpaintEraseStroke = flip ? !state.inpaintEraseMode : state.inpaintEraseMode; // Make sure we're painting onto an existing mask sub-layer. If // there's no parent layer at all, create one first so a totally // empty canvas can accept an inpaint stroke. if (!getActiveMaskLayer()) { let parent = activeParentLayer(); if (!parent) { parent = createLayer('Layer 1', state.imgWidth, state.imgHeight); state.layers.push(parent); state.activeLayerId = parent.id; } if (parent.masks && parent.masks.length) { parent.activeMaskId = parent.masks[parent.masks.length - 1].id; const m = getActiveMaskLayer(); if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; renderLayerPanel(); } } else { const mk = ensureActiveMaskLayer(); if (mk) { state.maskCanvas = mk.canvas; state.maskCtx = mk.ctx; renderLayerPanel(); } } } } saveState(strokeLabel(state.tool)); state.drawing = true; const coords = canvasCoords(e, state.mainCanvas); state.lastX = coords.x; state.lastY = coords.y; strokeTo(coords.x, coords.y); return true; }, /** * Forward an in-progress stroke. Returns true if a stroke is * actually in progress (dispatcher should short-circuit). */ tryContinue(e) { if (!state.drawing) return false; e.preventDefault(); const coords = canvasCoords(e, state.mainCanvas); strokeTo(coords.x, coords.y); return true; }, /** * Wrap up an in-progress stroke. Returns true if there was one. */ tryEnd() { if (!state.drawing) return false; const wasDrawingInpaint = state.tool === 'inpaint'; state.drawing = false; composite(); if (wasDrawingInpaint) syncToolClearIndicators(); return true; }, }; }