Files
odysseus/static/js/editor/tools/clone.js
T

119 lines
5.0 KiB
JavaScript

/**
* Clone tool — Alt-click (desktop) or double-tap (mobile) sets the
* sample source; a regular click+drag stamps from that source onto the
* active layer. The source point moves WITH the brush so the offset
* stays constant across the stroke.
*
* begin() handles the source-pick and stroke-start branches; the
* actual per-sample stamping continues through the shared stroke
* pipeline (`_strokeTo`) which knows about clone-mode internally.
*
* @param {{
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* strokeTo: (x: number, y: number) => void,
* showToast: (msg: string) => void,
* onSourceChanged?: () => void,
* }} deps
*/
import { state } from '../state.js';
import { isLayerPixelLocked } from '../layer-groups.js';
import { canvasCoords } from '../canvas-coords.js';
export function createCloneTool({ activeLayer, saveState, beginStroke, showToast, onSourceChanged, getSampleCanvas }) {
return {
begin(e) {
const layer = activeLayer();
const coords = canvasCoords(e, state.mainCanvas);
// Mobile equivalent of Alt-click: double-tap in screen pixels.
// Wider tolerances (500 ms, 40 px) than desktop because finger
// taps drift more than mouse clicks.
const isTouchEvt = e.type && e.type.startsWith('touch');
let isDoubleTap = false;
if (isTouchEvt) {
const t = e.touches ? e.touches[0] : null;
const cx = t ? t.clientX : 0;
const cy = t ? t.clientY : 0;
const now = Date.now();
const dt = now - state.cloneLastTapTime;
const dx = cx - state.cloneLastTapX;
const dy = cy - state.cloneLastTapY;
if (dt < 500 && Math.hypot(dx, dy) < 40) {
isDoubleTap = true;
state.cloneLastTapTime = 0; // consume the pair
} else {
state.cloneLastTapTime = now;
state.cloneLastTapX = cx;
state.cloneLastTapY = cy;
}
}
if (e.altKey || isDoubleTap) {
state.cloneSourceX = coords.x;
state.cloneSourceY = coords.y;
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
const sampledCanvas = getSampleCanvas?.({ layer, mode: state.cloneSampleMode });
const sourceLayer = layer && (
state.cloneSampleMode === 'active-layer' || !sampledCanvas
) ? layer : null;
const sourceOffset = sourceLayer ? (state.layerOffsets.get(sourceLayer.id) || { x: 0, y: 0 }) : { x: 0, y: 0 };
state.cloneSourceOffsetX = sourceOffset.x;
state.cloneSourceOffsetY = sourceOffset.y;
state.cloneSourceSnapshot = null; // captured at first stroke
onSourceChanged?.();
showToast('Clone source set');
return;
}
// Healing defaults to source-free spot correction. Once a source is
// chosen, the same tool switches to sampled healing in the pipeline.
if (state.tool === 'heal' && (state.cloneSourceX === null || state.cloneSourceY === null)) {
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before healing');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before healing its pixels');
return;
}
saveState('Healing stroke');
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
return;
}
if (state.cloneSourceX === null || state.cloneSourceY === null) {
showToast(isTouchEvt
? 'Double-tap first to set a clone source'
: 'Alt-click first to set a clone source');
return;
}
if (!layer || isLayerPixelLocked(state, layer)) {
showToast('Unlock image pixels before cloning');
return;
}
if (layer.kind === 'placed') {
showToast('Rasterize the placed layer before cloning into it');
return;
}
const toolName = state.tool === 'heal' ? 'Healing stroke' : 'Clone stroke';
saveState(toolName);
// Snapshot the source layer's pixels at stroke-start so the
// brush samples clean source pixels even after it has painted
// over them. Otherwise we'd cascade-clone the same ring.
const srcLayer = state.layers.find(l => l.id === state.cloneSourceLayerId) || layer;
const sourceCanvas = getSampleCanvas?.({ layer: srcLayer, mode: state.cloneSampleMode }) || srcLayer.canvas;
const snap = document.createElement('canvas');
snap.width = sourceCanvas.width;
snap.height = sourceCanvas.height;
snap.getContext('2d').drawImage(sourceCanvas, 0, 0);
state.cloneSourceSnapshot = snap;
state.cloneStrokeStartX = coords.x;
state.cloneStrokeStartY = coords.y;
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
beginStroke({ x: coords.x, y: coords.y, pressure: Number(e.pressure) > 0 ? e.pressure : 1 }, state.tool);
},
};
}