/** Canvas drawing tools, local drawing undo and PNG uploads. */
export function createDrawing({
lifecycle,
_safeImgSrc,
api,
attachColorPicker
}) {
function _buildDrawHtml() {
return `
`;
}
function _wireCanvas(container, initialImageUrl) {
const canvas = container.querySelector('.note-form-canvas');
if (!canvas) return;
const resolveThemeColor = (expression, fallback) => {
const probe = document.createElement('span');
probe.style.color = expression;
probe.style.position = 'fixed';
probe.style.visibility = 'hidden';
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color || fallback;
probe.remove();
return resolved;
};
const colorToHex = (color, fallback) => {
try {
const probe = document.createElement('canvas');
probe.width = probe.height = 1;
const probeCtx = probe.getContext('2d');
probeCtx.fillStyle = color;
probeCtx.fillRect(0, 0, 1, 1);
const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data;
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`;
} catch { return fallback; }
};
const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34');
const surfaceColorHex = colorToHex(surfaceColor, '#282c34');
const highlightColor = colorToHex(
resolveThemeColor('var(--accent, var(--red))', '#e06c75'),
'#e06c75',
);
const colorInput = container.querySelector('.note-form-draw-color');
if (colorInput) colorInput.value = highlightColor;
// Bump the backing-store resolution for retina displays so strokes stay
// crisp. Set only style.width : leaving style.height to auto so the canvas
// scales uniformly via its intrinsic aspect ratio. If both CSS dimensions
// are pinned, max-width:100% shrinks the width only, leaving rasterized
// glyphs visibly stretched relative to their on-screen input.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = canvas.width;
const cssH = canvas.height;
// Fill the container up to the logical width (don't pin a hard 600px,
// which on a narrow phone forces the card wider than the viewport and
// pushes the drawing outside the note). _pos() scales pointer coords by
// the actual displayed width, so accuracy is preserved at any size.
canvas.style.width = '100%';
canvas.style.maxWidth = cssW + 'px';
canvas.style.height = 'auto';
canvas.style.aspectRatio = cssW + ' / ' + cssH;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.fillStyle = surfaceColor;
ctx.fillRect(0, 0, cssW, cssH);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Load prior drawing as starting point so consecutive edits compose.
const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
if (safeInitialImageUrl) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
img.src = safeInitialImageUrl;
// Float an X over the canvas so the user can blank it out and go back to
// a clean draw surface. Removes itself once clicked.
const wrap = container.querySelector('.note-form-draw-wrap');
if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) {
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'note-form-draw-bg-rm';
rm.title = 'Clear photo (regular draw)';
rm.innerHTML = '×';
rm.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
ctx.fillStyle = surfaceColor;
ctx.fillRect(0, 0, cssW, cssH);
rm.remove();
});
wrap.appendChild(rm);
}
}
// Swap the native browser color dialog for the in-house HSV picker
// (same one used by Themes + the gallery editor). Existing `input` event
// listeners + .value reads keep working : see colorPicker.js.
if (colorInput) attachColorPicker(colorInput);
const sizeInput = container.querySelector('.note-form-draw-size');
const beSeg = container.querySelector('.note-form-draw-be');
const brushBtn = container.querySelector('.note-form-draw-brush');
const eraserBtn = container.querySelector('.note-form-draw-eraser');
const textBtn = container.querySelector('.note-form-draw-text');
const lineBtn = container.querySelector('.note-form-draw-line');
const circleBtn = container.querySelector('.note-form-draw-circle');
const undoBtn = container.querySelector('.note-form-draw-undo');
// Single source of truth for what clicks/drags do. Other booleans are
// derived from this so we never end up with conflicting "eraser AND text"
// states (the bug that made T appear broken after using the eraser).
// Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle'
let mode = 'pen';
let drawing = false;
let last = null;
// The text tool has three preset sizes (CSS px font-size).
const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 };
// Line / circle stroke widths in logical pixels : three crisp options.
const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 };
// Snapshot taken at the start of a shape drag so the preview can repaint
// cleanly on each move without accumulating intermediate strokes.
let _shapeSnapshot = null;
// Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each
// operation : stroke, text commit, or future operations : and pop+restore
// on Undo. Cap to 30 to keep memory bounded.
const _undoStack = [];
const UNDO_LIMIT = 30;
const _syncUndoButton = () => {
const available = _undoStack.length > 0;
undoBtn?.classList.toggle('has-undo', available);
undoBtn?.toggleAttribute('disabled', !available);
undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true');
};
const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true }));
const _snapshot = () => {
try {
const w = canvas.width, h = canvas.height;
_undoStack.push(ctx.getImageData(0, 0, w, h));
if (_undoStack.length > UNDO_LIMIT) _undoStack.shift();
_syncUndoButton();
} catch {}
};
const _undo = () => {
const prev = _undoStack.pop();
if (!prev) { _syncUndoButton(); return; }
// Restore against the raw backing store: temporarily reset the active
// ctx scale, paint the snapshot 1:1, then reapply our standard transform.
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.putImageData(prev, 0, 0);
ctx.restore();
_markCanvasDirty();
_syncUndoButton();
};
const _pos = (e) => {
// CSS can shrink the canvas (max-width:100%) without changing its logical
// size, so compute the displayed-to-logical scale per axis. Pointer coords
// are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH).
const r = canvas.getBoundingClientRect();
const sx = cssW / r.width;
const sy = cssH / r.height;
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy };
};
const _begin = (e) => {
if (mode.startsWith('text-')) {
// Stop the event so the browser doesn't synthesize a follow-up click
// that would blur the input we're about to create.
e.preventDefault?.();
e.stopPropagation?.();
_openTextInput(e);
return;
}
_snapshot();
last = _pos(e);
drawing = true;
if (mode.startsWith('line-') || mode.startsWith('circle-')) {
// Capture the backing pixels so the preview can replay each move from
// the same starting state (otherwise live shapes accumulate).
try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {}
return;
}
ctx.beginPath();
ctx.moveTo(last.x, last.y);
};
// Drop an HTML input at the click position so the user can type, then
// rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form
// annotations work in our doc editor.
let _activeTextInput = null;
const _openTextInput = (e) => {
// Commit any prior pending input before starting a new one : otherwise
// the first click leaves an orphaned input the user thinks "didn't work".
if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} }
const r = canvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
// Position is anchored to the wrap, not the canvas; since the canvas is
// the first child of the wrap and the wrap has no padding, they share an
// origin, so we offset from the canvas rect directly.
const px = t.clientX - r.left;
const py = t.clientY - r.top;
const logical = _pos(e);
// Size is decided by which T variant is active (S/M/L), not the stroke
// slider : those are independent dials.
const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm';
const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m;
const wrap = container.querySelector('.note-form-draw-wrap');
if (!wrap) return;
if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative';
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-form-draw-textinput';
input.placeholder = 'type then Enter';
const color = colorInput?.value || '#222';
const maxW = Math.max(120, Math.floor(r.width - px - 4));
input.style.cssText = [
'position:absolute',
`left:${px}px`,
`top:${Math.max(0, py - sizeCss * 0.7)}px`,
`font:${sizeCss}px Arial, sans-serif`,
`color:${color}`,
'background:#ffffff',
'border:2px solid var(--accent)',
'border-radius:4px',
'outline:none',
'padding:2px 6px',
'min-width:120px',
`max-width:${maxW}px`,
'z-index:1000',
'box-shadow:0 2px 8px rgba(0,0,0,0.25)',
'pointer-events:auto',
].join(';');
wrap.appendChild(input);
_activeTextInput = input;
// Focus synchronously so the call still counts as inside the user gesture
// on iOS / Android, then re-focus on the next frame in case a competing
// synthetic event (touch → click) moved focus away.
input.focus();
requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); });
let committed = false;
const commit = () => {
if (committed) return;
committed = true;
const text = input.value;
if (_activeTextInput === input) _activeTextInput = null;
input.remove();
if (!text) return;
// Snapshot BEFORE rasterizing so Undo removes the text in one step.
_snapshot();
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = color;
// Canvas now scales uniformly (style.height: auto), so a single ratio
// suffices for picking the logical font size that matches what the
// user just saw in the HTML input.
const sx = cssW / r.width;
const logicalSize = sizeCss * sx;
ctx.font = `${logicalSize}px sans-serif`;
ctx.textBaseline = 'top';
ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7);
ctx.restore();
_markCanvasDirty();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
else if (ev.key === 'Escape') { input.value = ''; input.blur(); }
});
};
const _move = (e) => {
if (!drawing) return;
e.preventDefault?.();
const p = _pos(e);
if (mode.startsWith('line-') || mode.startsWith('circle-')) {
// Restore the pre-shape bitmap, then redraw the preview shape from
// anchor → current pointer.
if (_shapeSnapshot) {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.putImageData(_shapeSnapshot, 0, 0);
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = colorInput?.value || '#222';
const sizeKey = mode.slice(-1);
ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m;
ctx.beginPath();
if (mode.startsWith('line-')) {
ctx.moveTo(last.x, last.y);
ctx.lineTo(p.x, p.y);
} else {
const dx = p.x - last.x;
const dy = p.y - last.y;
const radius = Math.hypot(dx, dy);
ctx.arc(last.x, last.y, radius, 0, Math.PI * 2);
}
ctx.stroke();
return;
}
const erasing = mode === 'eraser';
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor);
ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1);
ctx.lineTo(p.x, p.y);
ctx.stroke();
last = p;
};
const _end = () => {
if (drawing) _markCanvasDirty();
drawing = false; last = null; _shapeSnapshot = null;
};
canvas.addEventListener('mousedown', _begin);
canvas.addEventListener('mousemove', _move);
lifecycle.listen(canvas, window, 'mouseup', _end);
// Non-passive so text mode can preventDefault : otherwise the synthetic
// mousedown/click that follows a touch can blur the freshly-created text
// input on iOS Safari, making T look like a no-op.
canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false });
canvas.addEventListener('touchmove', _move, { passive: false });
canvas.addEventListener('touchend', _end);
canvas.addEventListener('touchcancel', _end);
// Single mode setter : keeps the toolbar, swatch, and cursor in sync, and
// makes sure exiting the eraser restores the user's chosen color whether
// they exit by clicking another tool or by toggling eraser off.
let _preEraseColor = null;
const _setMode = (next) => {
const wasEraser = mode === 'eraser';
mode = next;
const isEraser = next === 'eraser';
const isPen = next === 'pen';
const isText = next.startsWith('text-');
// Swatch: theme surface while erasing, restored when leaving that mode.
if (isEraser && !wasEraser && colorInput) {
_preEraseColor = colorInput.value;
colorInput.value = surfaceColorHex;
} else if (!isEraser && wasEraser && colorInput && _preEraseColor) {
colorInput.value = _preEraseColor;
_preEraseColor = null;
}
// Brush/Eraser segmented pill : slides to the active side. When a non-
// pen/eraser tool (T / line / circle) is active, neither half is
// highlighted, but the pill still indicates which side the user was on
// last so they can return with one click.
const isLine = next.startsWith('line-');
const isCircle = next.startsWith('circle-');
beSeg?.classList.toggle('is-eraser', isEraser);
brushBtn?.classList.toggle('active', isPen);
eraserBtn?.classList.toggle('active', isEraser);
textBtn?.classList.toggle('active', isText);
lineBtn?.classList.toggle('active', isLine);
circleBtn?.classList.toggle('active', isCircle);
// Per-button size badges (S/M/L), driven off the mode suffix.
const tBadge = textBtn?.querySelector('.note-form-draw-text-badge');
if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : '';
const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge');
if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : '';
const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge');
if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : '';
// Reflect the chosen size in the icon itself : thicker line/circle stroke
// for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`.
const _sz = next.slice(-1);
[textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l'));
if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz);
if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz);
if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz);
canvas.style.cursor = isText ? 'text' : 'crosshair';
};
brushBtn?.addEventListener('click', () => _setMode('pen'));
eraserBtn?.addEventListener('click', () => _setMode('eraser'));
// T / Line / Circle: each cycles its own three sizes, then back to pen.
const _cycle = (prefix) => {
const seq = ['s', 'm', 'l'];
if (!mode.startsWith(prefix)) return prefix + 's';
const cur = mode.slice(-1);
const next = seq[seq.indexOf(cur) + 1];
return next ? prefix + next : 'pen';
};
textBtn?.addEventListener('click', () => _setMode(_cycle('text-')));
lineBtn?.addEventListener('click', () => _setMode(_cycle('line-')));
circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-')));
undoBtn?.addEventListener('click', () => _undo());
_syncUndoButton();
// Stash so the save handler can read it later without re-resolving DOM.
canvas._cssW = cssW;
canvas._cssH = cssH;
return canvas;
}
async function _uploadCanvasAsPng(canvas, galleryId = null) {
if (!canvas) return null;
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
if (!blob) return null;
const fd = new FormData();
fd.append('files', blob, 'drawing.png');
if (galleryId) fd.append('gallery_id', galleryId);
try {
const res = await api.upload(fd);
const data = await res.json();
const id = data.files?.[0]?.id;
return id ? {
url: api.uploadUrl(id),
galleryId: data.files?.[0]?.gallery_id || null,
} : null;
} catch { return null; }
}
return { _buildDrawHtml, _wireCanvas, _uploadCanvasAsPng };
}