gabriele-cusato_HandTranscr.../HandTranscriptMd/src/drawing-canvas.ts
2026-05-24 00:30:13 +02:00

590 lines
18 KiB
TypeScript

/* =============================================
DrawingCanvas — Motore di disegno su Canvas API
Usa curve di Bézier quadratiche (midpoint) per
tratti fluidi. Supporta penna e gomma parziale.
Undo/redo basato su history di stati completi
(funziona sia per disegno che per gomma).
============================================= */
export interface Point {
x: number;
y: number;
pressure: number;
}
export interface Stroke {
points: Point[];
color: string;
width: number;
}
export type DrawMode = 'pen' | 'eraser';
// Spaziatura righe orizzontali — costante condivisa con svg-utils.ts
export const LINE_SPACING = 32;
// Deep copy di un array di Stroke
function cloneStrokes(strokes: Stroke[]): Stroke[] {
return strokes.map(s => ({
points: s.points.map(p => ({ ...p })),
color: s.color,
width: s.width
}));
}
export class DrawingCanvas {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private strokes: Stroke[] = [];
private currentStroke: Stroke | null = null;
private mode: DrawMode = 'pen';
private color = '#000000';
private lineWidth = 2;
private isDrawing = false;
private changeCb: (() => void) | null = null;
// Se true: siamo su mobile (Android/iOS)
private mobileMode = false;
// History per undo/redo: ogni entry è uno snapshot completo dei tratti.
// Funziona sia per disegno che per gomma.
private history: Stroke[][] = [];
private historyIdx = -1;
// Flag per sapere se la gomma ha modificato qualcosa durante un drag
private eraserChanged = false;
// Callback invocato quando l'altezza del canvas cambia (auto-expand)
private resizeCb: (() => void) | null = null;
// Altezza di default delle settings (usata per reset su clear)
private defaultHeight: number;
// Righe e sfondo — usa la costante esportata del modulo
readonly LINE_SPACING = LINE_SPACING;
private bgColor = '#ffffff';
private lineColor = '#e0e0e0';
// Auto-expand
private readonly EXPAND_MARGIN = 40;
private readonly EXPAND_AMOUNT = 150;
private animFrameId: number | null = null;
private boundDown: (e: PointerEvent) => void;
private boundMove: (e: PointerEvent) => void;
private boundUp: (e: PointerEvent) => void;
// Cleanup per i listener aggiuntivi di allowFingerScroll()
private fingerScrollCleanup: (() => void) | null = null;
// Callback debug: se impostato, mostra Notice all'utente per ogni evento IME/touch
private debugFn: ((msg: string) => void) | null = null;
// Device Pixel Ratio: scala il buffer interno per display ad alta densità (Retina, ecc.)
private dpr: number;
// Dimensione logica CSS del canvas (in pixel logici, non fisici)
private logicalWidth: number;
private logicalHeight: number;
// Spazio coordinate dei tratti salvati: cresce quando il display si allarga, non scende mai.
// Garantisce che i tratti rimangano nell'SVG anche dopo una rotazione portrait.
private worldWidth: number;
// Scala orizzontale di visualizzazione: logicalWidth / worldWidth.
// < 1 quando il display è più stretto del mondo (es. portrait dopo landscape): il contenuto
// si comprime per mostrare tutto senza tagliare nulla.
private viewScale = 1.0;
// Mantenuto per compatibilità ma sempre 0 (non usiamo centering, solo scaling)
private viewOffsetX = 0;
constructor(container: HTMLElement, width: number, height: number, defaultHeight: number, mobileMode = false, debugFn: ((msg: string) => void) | null = null) {
this.dpr = window.devicePixelRatio || 1;
this.worldWidth = width;
this.logicalWidth = width;
this.logicalHeight = height;
this.defaultHeight = defaultHeight;
this.mobileMode = mobileMode;
this.debugFn = debugFn;
this.canvas = activeDocument.createElement('canvas');
// Dimensione CSS: pixel logici → il browser mostra il canvas a questa dimensione
this.canvas.style.width = width + 'px';
this.canvas.style.height = height + 'px';
// Buffer interno: pixel fisici moltiplicati per il DPR → nessuna pixelazione
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.canvas.classList.add('hwm_canvas');
// touch-action gestito in styles.css (.hwm_canvas { touch-action: none !important })
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d')!;
// Scala il context: da questo punto tutte le coordinate ctx sono in pixel logici
this.ctx.scale(this.dpr, this.dpr);
this.clearBackground();
// Stato iniziale nella history (canvas vuoto)
this.pushHistory();
this.boundDown = this.onPointerDown.bind(this);
this.boundMove = this.onPointerMove.bind(this);
this.boundUp = this.onPointerUp.bind(this);
this.canvas.addEventListener('pointerdown', this.boundDown);
this.canvas.addEventListener('pointermove', this.boundMove);
this.canvas.addEventListener('pointerup', this.boundUp);
this.canvas.addEventListener('pointerleave', this.boundUp);
}
/* --- API pubblica --- */
onChange(cb: () => void) { this.changeCb = cb; }
// Registra callback per quando l'altezza cambia (utile per auto-scroll nell'overlay)
onResize(cb: () => void) { this.resizeCb = cb; }
// Adatta il canvas alla larghezza di display indicata (rotazione schermo, apertura modal).
// - expandWorld=true (default, Desktop modal): worldWidth cresce → SVG più largo.
// - expandWorld=false (Android ResizeObserver): worldWidth invariato → SVG sempre a canvasWidth.
// Su Android serve evitare che il viewBox dell'SVG cambii tra sessioni su schermi diversi
// (altrimenti l'aspect ratio del SVG cambia e la preview inline si accorcia mostrando sfondo
// nero sotto l'img).
setDisplayWidth(displayWidth: number, expandWorld = true) {
if (displayWidth === this.logicalWidth) return;
if (expandWorld && displayWidth > this.worldWidth) {
// Espansione: il mondo si allarga con il display
this.worldWidth = displayWidth;
}
// Aggiorna larghezza logica e fattore di scala
this.logicalWidth = displayWidth;
this.viewScale = this.logicalWidth / this.worldWidth;
this.canvas.style.width = displayWidth + 'px';
// Cambiare canvas.width resetta il context → ri-applicare la scala DPR
this.canvas.width = Math.round(displayWidth * this.dpr);
this.ctx.scale(this.dpr, this.dpr);
this.redraw();
}
// Abilita scroll manuale con il dito sul canvas.
// touch-action resta 'none' (la penna non trigga scroll del browser),
// il dito scrolla il container via JS.
allowFingerScroll(scrollContainer: HTMLElement) {
let scrolling = false;
let startY = 0;
let startScroll = 0;
// Listener con riferimento nominale → possono essere rimossi in destroy()
const onDown = (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = true;
startY = e.clientY;
startScroll = scrollContainer.scrollTop;
this.canvas.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!scrolling || (e.pointerType || 'pen') !== 'touch') return;
e.preventDefault();
scrollContainer.scrollTop = startScroll + (startY - e.clientY);
};
const onStop = (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = false;
};
this.canvas.addEventListener('pointerdown', onDown);
this.canvas.addEventListener('pointermove', onMove);
this.canvas.addEventListener('pointerup', onStop);
this.canvas.addEventListener('pointerleave', onStop);
// Registra la funzione di cleanup per destroy()
this.fingerScrollCleanup = () => {
this.canvas.removeEventListener('pointerdown', onDown);
this.canvas.removeEventListener('pointermove', onMove);
this.canvas.removeEventListener('pointerup', onStop);
this.canvas.removeEventListener('pointerleave', onStop);
};
}
setMode(mode: DrawMode) { this.mode = mode; }
getMode(): DrawMode { return this.mode; }
// Restituisce true se un tratto è in corso (pointer down)
isPointerDown(): boolean { return this.isDrawing; }
setColor(color: string) { this.color = color; }
setLineWidth(w: number) { this.lineWidth = w; }
getStrokes(): Stroke[] { return [...this.strokes]; }
// Ritorna le dimensioni nel sistema di coordinate mondo (usato per l'SVG viewBox)
getWidth(): number { return this.worldWidth; }
getHeight(): number { return this.logicalHeight; }
setBackground(bgColor: string, lineColor: string) {
this.bgColor = bgColor;
this.lineColor = lineColor;
this.redraw();
}
getBgColor(): string { return this.bgColor; }
getLineColor(): string { return this.lineColor; }
loadStrokes(strokes: Stroke[]) {
this.strokes = cloneStrokes(strokes);
// Reset history con lo stato caricato
this.history = [];
this.historyIdx = -1;
this.pushHistory();
this.redraw();
}
// Remap colori di tutti i tratti (correnti + history) al cambio tema.
// fn: funzione pura che restituisce il nuovo colore dato quello corrente.
// Non modifica la history — undo/redo continuano a funzionare con i colori aggiornati.
remapStrokeColors(fn: (color: string) => string) {
// Remap tratti correnti
for (const s of this.strokes) s.color = fn(s.color);
// Remap tutti gli snapshot in history (così undo/redo mantiene colori coerenti)
for (const snapshot of this.history) {
for (const s of snapshot) s.color = fn(s.color);
}
this.redraw();
}
// Torna allo stato precedente nella history
undo(): boolean {
if (this.historyIdx <= 0) return false;
this.historyIdx--;
this.strokes = cloneStrokes(this.history[this.historyIdx]!);
this.redraw();
this.changeCb?.();
return true;
}
// Avanza allo stato successivo nella history
redo(): boolean {
if (this.historyIdx >= this.history.length - 1) return false;
this.historyIdx++;
this.strokes = cloneStrokes(this.history[this.historyIdx]!);
this.redraw();
this.changeCb?.();
return true;
}
clear() {
this.strokes = [];
this.pushHistory();
// Ridisegna subito (canvas visualmente vuoto) anche se l'altezza
// è già quella di default (animateHeight ritornerebbe senza fare nulla)
this.redraw();
this.animateHeight(this.defaultHeight);
this.changeCb?.();
}
resizeHeight(newHeight: number) {
if (newHeight < 100) return;
this.logicalHeight = newHeight;
this.canvas.style.height = newHeight + 'px';
// canvas.height resetta il context → ri-applicare la scala DPR
this.canvas.height = Math.round(newHeight * this.dpr);
this.ctx.scale(this.dpr, this.dpr);
this.redraw();
}
destroy() {
if (this.animFrameId !== null) {
window.cancelAnimationFrame(this.animFrameId);
}
this.canvas.removeEventListener('pointerdown', this.boundDown);
this.canvas.removeEventListener('pointermove', this.boundMove);
this.canvas.removeEventListener('pointerup', this.boundUp);
this.canvas.removeEventListener('pointerleave', this.boundUp);
// Rimuove i listener aggiuntivi per lo scroll con il dito (se impostati)
this.fingerScrollCleanup?.();
}
/* --- History --- */
// Salva uno snapshot dei tratti correnti nella history.
// Taglia eventuali stati futuri (redo) quando si aggiunge un nuovo stato.
private pushHistory() {
this.history = this.history.slice(0, this.historyIdx + 1);
this.history.push(cloneStrokes(this.strokes));
this.historyIdx = this.history.length - 1;
}
/* --- Pointer Events --- */
private onPointerDown(e: PointerEvent) {
// pointerType vuoto ("") = evento degradato da Android → trattato come penna
const ptype = e.pointerType || 'pen';
// Su mobile: il dito non disegna mai
if (this.mobileMode && ptype === 'touch') {
this.debugFn?.('👆 Dito sul canvas');
e.stopPropagation();
return;
}
e.preventDefault();
if (this.mobileMode) {
this.debugFn?.(`🖊 pointerdown tipo="${e.pointerType}" → "${ptype}"`);
e.stopPropagation();
}
this.canvas.setPointerCapture(e.pointerId);
this.isDrawing = true;
const pt = this.eventToPoint(e);
if (this.mode === 'pen') {
this.currentStroke = {
points: [pt],
color: this.color,
width: this.lineWidth,
};
} else {
// Inizio drag gomma: reset flag
this.eraserChanged = false;
this.eraseAt(pt);
}
}
private onPointerMove(e: PointerEvent) {
// Su mobile: ignora il dito
if (this.mobileMode && (e.pointerType || 'pen') === 'touch') return;
if (!this.isDrawing) return;
e.preventDefault();
const pt = this.eventToPoint(e);
if (this.mode === 'pen' && this.currentStroke) {
this.currentStroke.points.push(pt);
this.drawSegment(this.currentStroke);
this.checkAutoExpand(pt);
} else if (this.mode === 'eraser') {
this.eraseAt(pt);
}
}
private onPointerUp(e: PointerEvent) {
// Su mobile: ignora il dito
if (this.mobileMode && (e.pointerType || 'pen') === 'touch') return;
if (!this.isDrawing) return;
this.isDrawing = false;
if (this.mode === 'pen' && this.currentStroke) {
if (this.currentStroke.points.length >= 2) {
this.strokes.push(this.currentStroke);
// Salva nella history dopo ogni tratto completato
this.pushHistory();
this.changeCb?.();
}
this.currentStroke = null;
} else if (this.mode === 'eraser' && this.eraserChanged) {
// Salva nella history dopo un drag gomma che ha cancellato qualcosa
this.pushHistory();
this.changeCb?.();
}
}
/* --- Auto-expand --- */
private checkAutoExpand(pt: Point) {
// Se un'animazione è già in corso non lanciarne un'altra:
// ripartire da un'altezza intermedia causerebbe un effetto di restringimento.
if (this.animFrameId !== null) return;
// Confronto in pixel logici: pt.y è in coordinate mondo, logicalHeight è logica
if (pt.y > this.logicalHeight - this.EXPAND_MARGIN) {
const newLogicalH = this.logicalHeight + this.EXPAND_AMOUNT;
this.animateHeight(newLogicalH);
}
}
private animateHeight(targetLogicalH: number) {
const startLogicalH = this.logicalHeight;
if (startLogicalH === targetLogicalH) return;
if (this.animFrameId !== null) {
window.cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
const duration = 300;
const startTime = performance.now();
const step = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
// Altezza in pixel logici per questa frame
const h = Math.round(startLogicalH + (targetLogicalH - startLogicalH) * eased);
this.logicalHeight = h;
this.canvas.style.height = h + 'px';
// canvas.height è in pixel fisici; cambiarlo resetta il context → ri-scalare
this.canvas.height = Math.round(h * this.dpr);
this.ctx.scale(this.dpr, this.dpr);
this.redraw();
if (this.currentStroke) {
this.drawFullStroke(this.currentStroke);
}
// Notifica chi ascolta (overlay auto-scroll)
this.resizeCb?.();
if (progress < 1) {
this.animFrameId = window.requestAnimationFrame(step);
} else {
this.animFrameId = null;
}
};
this.animFrameId = window.requestAnimationFrame(step);
}
/* --- Coordinate --- */
private eventToPoint(e: PointerEvent): Point {
const rect = this.canvas.getBoundingClientRect();
// Divide per viewScale per tornare alle coordinate mondo (invarianti al cambio orientamento)
return {
x: (e.clientX - rect.left) / this.viewScale,
y: (e.clientY - rect.top),
pressure: e.pressure > 0 ? e.pressure : 0.5,
};
}
/* --- Gomma parziale --- */
// La gomma rimuove solo i punti vicini, tagliando i tratti in segmenti.
// Non salva nella history ad ogni singolo punto cancellato —
// lo snapshot viene salvato una sola volta al pointerup.
private eraseAt(pt: Point) {
const radius = 15;
const r2 = radius * radius;
let changed = false;
const newStrokes: Stroke[] = [];
for (const stroke of this.strokes) {
let segment: Point[] = [];
let strokeTouched = false;
for (const p of stroke.points) {
const dx = p.x - pt.x;
const dy = p.y - pt.y;
if (dx * dx + dy * dy < r2) {
if (segment.length >= 2) {
newStrokes.push({
points: [...segment],
color: stroke.color,
width: stroke.width
});
}
segment = [];
strokeTouched = true;
} else {
segment.push(p);
}
}
if (!strokeTouched) {
newStrokes.push(stroke);
} else {
changed = true;
if (segment.length >= 2) {
newStrokes.push({
points: [...segment],
color: stroke.color,
width: stroke.width
});
}
}
}
if (changed) {
this.strokes = newStrokes;
this.eraserChanged = true;
this.redraw();
}
}
/* --- Rendering --- */
private clearBackground() {
// Usa pixel logici: ctx.scale(dpr, dpr) è già applicato nel constructor/resize
const w = this.logicalWidth;
const h = this.logicalHeight;
this.ctx.fillStyle = this.bgColor;
this.ctx.fillRect(0, 0, w, h);
this.ctx.strokeStyle = this.lineColor;
this.ctx.lineWidth = 0.5;
for (let y = this.LINE_SPACING; y < h; y += this.LINE_SPACING) {
this.ctx.beginPath();
this.ctx.moveTo(0, y);
this.ctx.lineTo(w, y);
this.ctx.stroke();
}
}
private redraw() {
this.clearBackground();
for (const stroke of this.strokes) {
this.drawFullStroke(stroke);
}
}
private drawFullStroke(stroke: Stroke) {
const pts = stroke.points;
if (pts.length < 2) return;
const ctx = this.ctx;
// Scala orizzontale: comprime i tratti mondo nello spazio logico disponibile
ctx.save();
ctx.scale(this.viewScale, 1.0);
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(pts[0]!.x, pts[0]!.y);
if (pts.length === 2) {
ctx.lineTo(pts[1]!.x, pts[1]!.y);
} else {
for (let i = 1; i < pts.length - 1; i++) {
const curr = pts[i]!;
const next = pts[i + 1]!;
const midX = (curr.x + next.x) / 2;
const midY = (curr.y + next.y) / 2;
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
}
const last = pts[pts.length - 1]!;
ctx.lineTo(last.x, last.y);
}
ctx.stroke();
ctx.restore();
}
private drawSegment(stroke: Stroke) {
const pts = stroke.points;
if (pts.length < 2) return;
const ctx = this.ctx;
// Stessa scala di drawFullStroke per coerenza durante il disegno live
ctx.save();
ctx.scale(this.viewScale, 1.0);
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
if (pts.length === 2) {
ctx.moveTo(pts[0]!.x, pts[0]!.y);
ctx.lineTo(pts[1]!.x, pts[1]!.y);
} else {
const i = pts.length - 2;
const prev = i > 0 ? pts[i - 1]! : pts[0]!;
const curr = pts[i]!;
const next = pts[i + 1]!;
const startX = (prev.x + curr.x) / 2;
const startY = (prev.y + curr.y) / 2;
const endX = (curr.x + next.x) / 2;
const endY = (curr.y + next.y) / 2;
ctx.moveTo(startX, startY);
ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
}
ctx.stroke();
ctx.restore();
}
}