gabriele-cusato_HandTranscr.../HandTranscriptMd/src/drawing-canvas.ts

492 lines
14 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';
// 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
readonly LINE_SPACING = 32;
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;
// Callback debug: se impostato, mostra Notice all'utente per ogni evento IME/touch
private debugFn: ((msg: string) => void) | null = null;
constructor(container: HTMLElement, width: number, height: number, defaultHeight: number, mobileMode = false, debugFn: ((msg: string) => void) | null = null) {
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
this.defaultHeight = defaultHeight;
this.mobileMode = mobileMode;
this.debugFn = debugFn;
this.canvas.classList.add('hwm_canvas');
// touch-action: none sul canvas previene scroll/zoom durante il disegno
this.canvas.style.setProperty('touch-action', 'none', 'important');
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d')!;
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; }
// 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;
this.canvas.addEventListener('pointerdown', (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = true;
startY = e.clientY;
startScroll = scrollContainer.scrollTop;
this.canvas.setPointerCapture(e.pointerId);
});
this.canvas.addEventListener('pointermove', (e: PointerEvent) => {
if (!scrolling || (e.pointerType || 'pen') !== 'touch') return;
e.preventDefault();
scrollContainer.scrollTop = startScroll + (startY - e.clientY);
});
const stop = (e: PointerEvent) => {
if ((e.pointerType || 'pen') !== 'touch') return;
scrolling = false;
};
this.canvas.addEventListener('pointerup', stop);
this.canvas.addEventListener('pointerleave', stop);
}
setMode(mode: DrawMode) { this.mode = mode; }
getMode(): DrawMode { return this.mode; }
setColor(color: string) { this.color = color; }
setLineWidth(w: number) { this.lineWidth = w; }
getStrokes(): Stroke[] { return [...this.strokes]; }
getWidth(): number { return this.canvas.width; }
getHeight(): number { return this.canvas.height; }
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();
}
// 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.canvas.height = newHeight;
this.redraw();
}
destroy() {
if (this.animFrameId !== null) {
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);
}
/* --- 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;
if (pt.y > this.canvas.height - this.EXPAND_MARGIN) {
const newHeight = this.canvas.height + this.EXPAND_AMOUNT;
this.animateHeight(newHeight);
}
}
private animateHeight(targetHeight: number) {
const startHeight = this.canvas.height;
if (startHeight === targetHeight) return;
if (this.animFrameId !== null) {
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);
const h = Math.round(startHeight + (targetHeight - startHeight) * eased);
this.canvas.height = h;
this.redraw();
if (this.currentStroke) {
this.drawFullStroke(this.currentStroke);
}
// Notifica chi ascolta (overlay auto-scroll)
this.resizeCb?.();
if (progress < 1) {
this.animFrameId = requestAnimationFrame(step);
} else {
this.animFrameId = null;
}
};
this.animFrameId = requestAnimationFrame(step);
}
/* --- Coordinate --- */
private eventToPoint(e: PointerEvent): Point {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
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() {
const w = this.canvas.width;
const h = this.canvas.height;
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;
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();
}
private drawSegment(stroke: Stroke) {
const pts = stroke.points;
if (pts.length < 2) return;
const ctx = this.ctx;
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();
}
}