mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 07:42:21 +02:00
Squash Odysseus development history
This commit is contained in:
+263
-142
@@ -1,162 +1,283 @@
|
||||
/**
|
||||
* Stroke pipeline — paints one segment (last-position → current
|
||||
* position) onto the active layer (or its active mask sub-layer).
|
||||
*
|
||||
* `strokeTo` dispatches by tool:
|
||||
* - clone → cloneStrokeTo (custom stamp-based paint loop)
|
||||
* - brush → source-over with opacity × flow + softness blur
|
||||
* - eraser → destination-out with opacity × flow + softness blur
|
||||
* - inpaint → source-over (paint) or destination-out (erase) with
|
||||
* full alpha on the mask canvas
|
||||
*
|
||||
* If the active parent has an active mask sub-layer, brush / eraser /
|
||||
* inpaint target the mask canvas instead of the layer's pixel canvas.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
* composite: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
/** Shared sampled renderer for brush, eraser, clone, masks, and retouch tools. */
|
||||
import { state } from './state.js';
|
||||
import { createBrushSampler, pressureValue } from './brush-engine.js';
|
||||
import { isLayerTransparencyLocked } from './layer-groups.js';
|
||||
|
||||
const PAINT_TOOLS = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'inpaint']);
|
||||
|
||||
function toolSettings(tool) {
|
||||
if (tool === 'eraser') return { opacity: state.eraserOpacity, flow: state.eraserFlow, softness: state.eraserSoftness };
|
||||
if (tool === 'clone' || tool === 'heal') return { opacity: state.cloneOpacity, flow: state.cloneFlow, softness: state.cloneSoftness };
|
||||
if (tool === 'smudge') return { opacity: state.smudgeStrength, flow: state.brushFlow, softness: state.brushSoftness };
|
||||
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
||||
}
|
||||
|
||||
function transparentColor(color) {
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||
return `rgba(${rgb.join(',')},0)`;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
}
|
||||
|
||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
const hard = Math.max(0, Math.min(1, 1 - softness));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = operation;
|
||||
ctx.globalAlpha = alpha;
|
||||
if (softness <= 0.001) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
|
||||
function cloneStrokeTo(x, y, layer) {
|
||||
let strokeTool = null;
|
||||
let compositeFrame = null;
|
||||
let smudgeBuffer = null;
|
||||
|
||||
// Pointer events can arrive much faster than the display refresh rate.
|
||||
// Stamps are applied synchronously, so only the visible document composite
|
||||
// needs coalescing while a stroke is in progress.
|
||||
function scheduleComposite() {
|
||||
if (compositeFrame !== null) return;
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
compositeFrame = requestAnimationFrame(() => {
|
||||
compositeFrame = null;
|
||||
composite();
|
||||
});
|
||||
} else {
|
||||
compositeFrame = setTimeout(() => {
|
||||
compositeFrame = null;
|
||||
composite();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function flushComposite() {
|
||||
if (compositeFrame !== null) {
|
||||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(compositeFrame);
|
||||
else clearTimeout(compositeFrame);
|
||||
compositeFrame = null;
|
||||
}
|
||||
composite();
|
||||
}
|
||||
|
||||
function targetFor(tool, layer) {
|
||||
const activeMask = state.quickMaskActive && state.wandMask
|
||||
? { mode: 'quick-selection', canvas: state.wandMask, ctx: state.wandMask.getContext('2d') }
|
||||
: getActiveMaskLayer();
|
||||
const paintingMask = !!activeMask && ['brush', 'eraser', 'inpaint'].includes(tool);
|
||||
const ctx = paintingMask ? activeMask.ctx : (tool === 'inpaint' ? state.maskCtx : layer.ctx);
|
||||
const layerOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const documentMask = paintingMask && activeMask.mode !== 'layer';
|
||||
const maskOff = paintingMask && activeMask.mode === 'layer' ? (activeMask.offset || { x: 0, y: 0 }) : { x: 0, y: 0 };
|
||||
return {
|
||||
ctx,
|
||||
paintingMask,
|
||||
xOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.x + maskOff.x,
|
||||
yOffset: (documentMask || tool === 'inpaint') ? 0 : layerOff.y + maskOff.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneStamp(sample, layer, heal = false) {
|
||||
if (!state.cloneSourceSnapshot) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const dx = x - state.cloneStrokeStartX;
|
||||
const dy = y - state.cloneStrokeStartY;
|
||||
const srcX = state.cloneSourceX + dx;
|
||||
const srcY = state.cloneSourceY + dy;
|
||||
const ctx = layer.ctx;
|
||||
const radius = Math.max(1, state.brushSize / 2);
|
||||
// Walk last → current in roughly half-brush steps so stamps
|
||||
// overlap into a continuous brush trail.
|
||||
const lastSrcX = state.cloneSourceX + (state.lastX - state.cloneStrokeStartX);
|
||||
const lastSrcY = state.cloneSourceY + (state.lastY - state.cloneStrokeStartY);
|
||||
const dist = Math.hypot(x - state.lastX, y - state.lastY);
|
||||
const step = Math.max(1, radius * 0.5);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
const stampSize = Math.max(2, Math.ceil(radius * 2));
|
||||
const stampRadius = stampSize / 2;
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings(heal ? 'heal' : 'clone');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const sx = state.cloneSourceX - (state.cloneSourceOffsetX || 0) + (sample.x - state.cloneStrokeStartX);
|
||||
const sy = state.cloneSourceY - (state.cloneSourceOffsetY || 0) + (sample.y - state.cloneStrokeStartY);
|
||||
const size = Math.max(2, Math.ceil(radius * 2));
|
||||
const stamp = document.createElement('canvas');
|
||||
stamp.width = stampSize;
|
||||
stamp.height = stampSize;
|
||||
stamp.width = size;
|
||||
stamp.height = size;
|
||||
const stampCtx = stamp.getContext('2d');
|
||||
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
|
||||
const hardStop = stampRadius * (1 - softness);
|
||||
stampCtx.drawImage(state.cloneSourceSnapshot, sx - radius, sy - radius, size, size, 0, 0, size, size);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
|
||||
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, size, size);
|
||||
const ctx = layer.ctx;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (state.cloneOpacity / 100) * (state.cloneFlow / 100);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const px = state.lastX + (x - state.lastX) * t - off.x;
|
||||
const py = state.lastY + (y - state.lastY) * t - off.y;
|
||||
const sx = lastSrcX + (srcX - lastSrcX) * t;
|
||||
const sy = lastSrcY + (srcY - lastSrcY) * t;
|
||||
stampCtx.clearRect(0, 0, stampSize, stampSize);
|
||||
stampCtx.globalCompositeOperation = 'source-over';
|
||||
stampCtx.drawImage(
|
||||
state.cloneSourceSnapshot,
|
||||
sx - stampRadius, sy - stampRadius, stampSize, stampSize,
|
||||
0, 0, stampSize, stampSize,
|
||||
);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const mask = stampCtx.createRadialGradient(stampRadius, stampRadius, hardStop, stampRadius, stampRadius, stampRadius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, stampSize, stampSize);
|
||||
ctx.drawImage(stamp, px - stampRadius, py - stampRadius);
|
||||
}
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
|
||||
if (heal) ctx.filter = `blur(${Math.max(0.4, radius * 0.08).toFixed(2)}px)`;
|
||||
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
|
||||
ctx.restore();
|
||||
state.lastX = x;
|
||||
state.lastY = y;
|
||||
composite();
|
||||
}
|
||||
|
||||
function strokeTo(x, y) {
|
||||
function spotHealStamp(sample, layer) {
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings('heal');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const x = Math.round(sample.x - off.x);
|
||||
const y = Math.round(sample.y - off.y);
|
||||
const ring = Math.max(2, Math.ceil(radius * 2.2));
|
||||
const left = Math.max(0, x - ring);
|
||||
const top = Math.max(0, y - ring);
|
||||
const right = Math.min(layer.canvas.width - 1, x + ring);
|
||||
const bottom = Math.min(layer.canvas.height - 1, y + ring);
|
||||
if (right < left || bottom < top) return;
|
||||
const pixels = layer.ctx.getImageData(left, top, right - left + 1, bottom - top + 1).data;
|
||||
let red = 0, green = 0, blue = 0, alphaSum = 0, weightSum = 0;
|
||||
const exclusion = Math.max(1, radius * 0.72);
|
||||
for (let py = top; py <= bottom; py += 1) {
|
||||
for (let px = left; px <= right; px += 1) {
|
||||
const distance = Math.hypot(px - x, py - y);
|
||||
if (distance < exclusion || distance > ring) continue;
|
||||
const index = ((py - top) * (right - left + 1) + (px - left)) * 4;
|
||||
const weight = 1 / Math.max(1, distance);
|
||||
red += pixels[index] * weight;
|
||||
green += pixels[index + 1] * weight;
|
||||
blue += pixels[index + 2] * weight;
|
||||
alphaSum += pixels[index + 3] * weight;
|
||||
weightSum += weight;
|
||||
}
|
||||
}
|
||||
if (!weightSum) return;
|
||||
const color = `rgba(${Math.round(red / weightSum)},${Math.round(green / weightSum)},${Math.round(blue / weightSum)},${Math.max(0, Math.min(1, alphaSum / weightSum / 255))})`;
|
||||
circleStamp(layer.ctx, x, y, radius, Math.max(0, Math.min(1, settings.softness / 300)), color, alpha, 'source-over');
|
||||
}
|
||||
|
||||
function smudgeStamp(sample, layer) {
|
||||
const previousX = Number.isFinite(state.lastX) ? state.lastX : sample.x;
|
||||
const previousY = Number.isFinite(state.lastY) ? state.lastY : sample.y;
|
||||
const distance = Math.hypot(sample.x - previousX, sample.y - previousY);
|
||||
if (distance < 0.25) return;
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const settings = toolSettings('smudge');
|
||||
const alpha = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity)
|
||||
* pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
const size = Math.max(2, Math.ceil(radius * 2));
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
if (!smudgeBuffer || smudgeBuffer.width !== size || smudgeBuffer.height !== size) {
|
||||
smudgeBuffer = document.createElement('canvas');
|
||||
smudgeBuffer.width = size;
|
||||
smudgeBuffer.height = size;
|
||||
smudgeBuffer.getContext('2d').drawImage(
|
||||
layer.canvas,
|
||||
previousX - off.x - radius, previousY - off.y - radius, size, size,
|
||||
0, 0, size, size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stamp = document.createElement('canvas');
|
||||
stamp.width = size;
|
||||
stamp.height = size;
|
||||
const stampCtx = stamp.getContext('2d');
|
||||
stampCtx.drawImage(smudgeBuffer, 0, 0);
|
||||
stampCtx.globalCompositeOperation = 'destination-in';
|
||||
const hard = Math.max(0, Math.min(1, 1 - settings.softness / 300));
|
||||
const mask = stampCtx.createRadialGradient(radius, radius, radius * hard, radius, radius, radius);
|
||||
mask.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
mask.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
stampCtx.fillStyle = mask;
|
||||
stampCtx.fillRect(0, 0, size, size);
|
||||
const ctx = layer.ctx;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.globalCompositeOperation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : 'source-over';
|
||||
ctx.drawImage(stamp, sample.x - off.x - radius, sample.y - off.y - radius);
|
||||
ctx.restore();
|
||||
// Capture the newly mixed patch so the next sample carries the smear
|
||||
// forward instead of repeatedly sampling the untouched canvas.
|
||||
smudgeBuffer.getContext('2d').clearRect(0, 0, size, size);
|
||||
smudgeBuffer.getContext('2d').drawImage(
|
||||
layer.canvas,
|
||||
sample.x - off.x - radius, sample.y - off.y - radius, size, size,
|
||||
0, 0, size, size,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSample(sample) {
|
||||
const tool = strokeTool || state.tool;
|
||||
if (!PAINT_TOOLS.has(tool)) return;
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
// Clone uses a stamp-based paint loop, not the line-stroke
|
||||
// pipeline below.
|
||||
if (state.tool === 'clone') return cloneStrokeTo(x, y, layer);
|
||||
|
||||
// If the active parent has an active mask sub-layer, brush /
|
||||
// eraser / inpaint paint the mask canvas instead of the layer's
|
||||
// pixel canvas. Brush adds to the mask, Eraser carves it away,
|
||||
// Inpaint still works (its mask plumbing was already pointed at
|
||||
// the same canvas).
|
||||
const activeMask = getActiveMaskLayer();
|
||||
const paintingMask = !!activeMask &&
|
||||
(state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint');
|
||||
const ctx = paintingMask
|
||||
? activeMask.ctx
|
||||
: (state.tool === 'inpaint' ? state.maskCtx : layer.ctx);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = state.brushSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
if (state.tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
// Effective alpha = opacity × flow. Opacity = max strength a
|
||||
// stroke can reach; flow = how much erases per pass.
|
||||
ctx.globalAlpha = (state.eraserOpacity / 100) * (state.eraserFlow / 100);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
if (state.eraserSoftness > 0) {
|
||||
const blurPx = (state.eraserSoftness / 100) * (state.brushSize / 2);
|
||||
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
|
||||
}
|
||||
} else if (state.tool === 'brush') {
|
||||
// Brush — state.color onto the layer (or white onto an active
|
||||
// mask sub-layer). Mask painting forces full alpha so masks
|
||||
// stay a clean binary by default (a sub-100% brush would
|
||||
// silently paint partial-strength mask pixels).
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = paintingMask ? 'rgba(255,255,255,1)' : state.color;
|
||||
if (paintingMask) {
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
ctx.globalAlpha = (state.brushOpacity / 100) * (state.brushFlow / 100);
|
||||
if (state.brushSoftness > 0) {
|
||||
const blurPx = (state.brushSoftness / 100) * (state.brushSize / 2);
|
||||
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
|
||||
}
|
||||
}
|
||||
} else if (state.tool === 'inpaint') {
|
||||
if (state.inpaintEraseStroke) {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
// Diffusion server expects white = inpaint area. The red
|
||||
// overlay is rendered separately in composite() for the user.
|
||||
ctx.strokeStyle = 'rgba(255,255,255,1)';
|
||||
}
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = state.color;
|
||||
if (tool === 'clone') return cloneStamp(sample, layer);
|
||||
if (tool === 'heal') {
|
||||
// A healing stroke without a picked source uses the convenient local
|
||||
// spot correction. With a source snapshot, blend sampled texture into
|
||||
// the destination instead of silently falling back to ring averaging.
|
||||
return state.cloneSourceSnapshot
|
||||
? cloneStamp(sample, layer, true)
|
||||
: spotHealStamp(sample, layer);
|
||||
}
|
||||
if (tool === 'smudge') return smudgeStamp(sample, layer);
|
||||
const target = targetFor(tool, layer);
|
||||
if (!target.ctx) return;
|
||||
const settings = toolSettings(tool);
|
||||
const diameter = pressureValue(state.brushSize, sample.pressure, state.pressureSize);
|
||||
const radius = Math.max(0.5, diameter / 2);
|
||||
const opacity = pressureValue(settings.opacity / 100, sample.pressure, state.pressureOpacity);
|
||||
const flow = pressureValue(settings.flow / 100, sample.pressure, state.pressureFlow);
|
||||
let operation = 'source-over';
|
||||
let color = state.color;
|
||||
let alpha = opacity * flow;
|
||||
let softness = Math.max(0, Math.min(1, settings.softness / 300));
|
||||
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (tool === 'inpaint') {
|
||||
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
||||
color = 'rgba(255,255,255,1)';
|
||||
alpha = 1;
|
||||
softness = 0;
|
||||
} else if (tool === 'dodge') { operation = 'screen'; color = 'rgba(255,255,255,1)'; }
|
||||
else if (tool === 'burn') { operation = 'multiply'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (target.paintingMask) { color = 'rgba(255,255,255,1)'; alpha = 1; softness = 0; }
|
||||
else operation = isLayerTransparencyLocked(state, layer) ? 'source-atop' : state.brushBlendMode;
|
||||
circleStamp(target.ctx, sample.x - target.xOffset, sample.y - target.yOffset, radius, softness, color, alpha, operation);
|
||||
}
|
||||
|
||||
// Mask canvases are always full-image (no per-layer offset), so
|
||||
// painting onto a mask uses canvas-coord origin too — same as
|
||||
// inpaint.
|
||||
const onMaskOrInpaint = paintingMask || state.tool === 'inpaint';
|
||||
const drawX = onMaskOrInpaint ? 0 : off.x;
|
||||
const drawY = onMaskOrInpaint ? 0 : off.y;
|
||||
const sampler = createBrushSampler({
|
||||
getDiameter: () => state.brushSize,
|
||||
getSpacing: () => state.brushSpacing / 100,
|
||||
getSmoothing: () => state.brushSmoothing / 100,
|
||||
emit: renderSample,
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(state.lastX - drawX, state.lastY - drawY);
|
||||
ctx.lineTo(x - drawX, y - drawY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
state.lastX = x;
|
||||
state.lastY = y;
|
||||
function beginStroke(sample, tool = state.tool) {
|
||||
strokeTool = tool;
|
||||
smudgeBuffer = null;
|
||||
sampler.begin(sample);
|
||||
state.lastX = sample.x;
|
||||
state.lastY = sample.y;
|
||||
composite();
|
||||
}
|
||||
|
||||
return { strokeTo, cloneStrokeTo };
|
||||
function strokeTo(sample) {
|
||||
sampler.add(sample);
|
||||
state.lastX = sample.x;
|
||||
state.lastY = sample.y;
|
||||
scheduleComposite();
|
||||
}
|
||||
|
||||
function endStroke(sample) {
|
||||
sampler.end(sample);
|
||||
strokeTool = null;
|
||||
smudgeBuffer = null;
|
||||
flushComposite();
|
||||
}
|
||||
|
||||
return { beginStroke, strokeTo, endStroke };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user