export default class Sketchpad { readonly canvas : HTMLCanvasElement ; private readonly ctx : CanvasRenderingContext2D ; private sketching = false ; private _strokes : Array < Stroke > = [ ] ; private undoneStrokes : Array < Stroke > = [ ] ; private backgroundColor ? : string ; private readOnly = false ; private aspectRatio = 1 ; private lineWidth = 5 ; private lineColor = '#000' ; private lineCap : CanvasLineCap = 'round' ; private lineJoin : CanvasLineJoin = 'round' ; private lineMiterLimit = 10 ; private onDrawEnd ? : ( ) => void ; constructor ( el , opts ? ) { if ( el == null ) { throw new Error ( 'Must pass in a container element' ) ; } if ( opts != null ) { this . setOptions ( opts ) ; } this . canvas = document . createElement ( 'canvas' ) ; this . ctx = this . canvas . getContext ( '2d' ) as CanvasRenderingContext2D ; const width = opts ?. width || el . clientWidth ; const height = opts ?. height || width * this . aspectRatio ; this . setCanvasSize ( width , height ) ; el . appendChild ( this . canvas ) ; if ( this . _strokes . length > 0 ) { this . redraw ( ) ; } this . listen ( ) ; } get strokes ( ) : Array < StrokeI > { return this . _strokes . map ( ( s ) => s . toObj ( ) ) ; } get undos ( ) : Array < StrokeI > { return this . undoneStrokes . map ( ( s ) => s . toObj ( ) ) ; } get opts ( ) : SketchpadOptionsI { return { backgroundColor : this . backgroundColor , readOnly : this . readOnly , width : this . canvas . width , height : this . canvas . height , aspectRatio : this . canvas . width / this . canvas . height , line : { size : this . lineWidth , color : this . lineColor , cap : this . lineCap , join : this . lineJoin , miterLimit : this . lineMiterLimit , } , } ; } toJSON ( ) { return { aspectRatio : this . canvas . width / this . canvas . height , strokes : this . strokes , } ; } loadJSON ( data ) { const strokeObjs = data . strokes || [ ] ; this . _strokes = strokeObjs . map ( ( s ) => Stroke . fromObj ( s ) ) ; this . redraw ( ) ; } toDataURL ( type ) { return this . canvas . toDataURL ( type ) ; } setCanvasSize ( width , height ) { this . canvas . setAttribute ( 'width' , width . toString ( ) ) ; this . canvas . setAttribute ( 'height' , height . toString ( ) ) ; this . canvas . style . width = width + 'px' ; this . canvas . style . height = height + 'px' ; } getCanvasSize ( ) { return { width : this . canvas . width , height : this . canvas . height , } ; } setLineWidth ( width ) { this . lineWidth = width ; } setLineSize ( size ) { this . lineWidth = size ; } setLineColor ( color ) { this . lineColor = color ; } setReadOnly ( readOnly ) { this . readOnly = readOnly ; } undo ( ) { if ( this . _strokes . length === 0 ) { return ; } this . undoneStrokes . push ( this . _strokes . pop ( ) as Stroke ) ; this . redraw ( ) ; } redo ( ) { if ( this . undoneStrokes . length === 0 ) { return ; } this . _strokes . push ( this . undoneStrokes . pop ( ) as Stroke ) ; this . redraw ( ) ; } clear ( ) { this . undoneStrokes = [ ] ; this . _strokes = [ ] ; this . redraw ( ) ; } drawLine ( start , end , lineOpts ) { this . setOptions ( { line : lineOpts } ) ; start = this . getPointRelativeToCanvas ( new Point ( start . x , start . y ) ) ; end = this . getPointRelativeToCanvas ( new Point ( end . x , end . y ) ) ; this . pushStroke ( [ start , end ] ) ; this . redraw ( ) ; } resize ( width ) { const height = width * this . aspectRatio ; this . lineWidth = this . lineWidth * ( width / this . canvas . width ) ; this . setCanvasSize ( width , height ) ; this . redraw ( ) ; } getPointRelativeToCanvas ( point ) { return { x : point . x / this . canvas . width , y : point . y / this . canvas . height , } ; } getLineSizeRelativeToCanvas ( width ) { return width / this . canvas . width ; } private setOptions ( opts ) { if ( opts . backgroundColor ) { this . backgroundColor = opts . backgroundColor ; } if ( opts . line ?. size ) { this . lineWidth = opts . line . size ; } if ( opts . line ?. cap ) { this . lineCap = opts . line . cap ; } if ( opts . line ?. join ) { this . lineJoin = opts . line . join ; } if ( opts . line ?. miterLimit ) { this . lineMiterLimit = opts . line . miterLimit ; } if ( opts . aspectRatio ) { this . aspectRatio = opts . aspectRatio ; } if ( opts . data ) { this . _strokes = opts . data . strokes ?. map ( ( s ) => Stroke . fromObj ( s ) ) ?? [ ] ; } if ( opts . onDrawEnd ) { this . onDrawEnd = opts . onDrawEnd ; } } private getCursorRelativeToCanvas ( e ) { let point ; const rect = this . canvas . getBoundingClientRect ( ) ; if ( isTouchEvent ( e ) ) { const touchEvent = e as TouchEvent ; point = new Point ( touchEvent . touches [ 0 ] . clientX - rect . left , touchEvent . touches [ 0 ] . clientY - rect . top ) ; } else { const mouseEvent = e as MouseEvent ; point = new Point ( mouseEvent . clientX - rect . left , mouseEvent . clientY - rect . top ) ; } return new Point ( point . x / this . canvas . width , point . y / this . canvas . height ) ; } private normalizePoint ( p ) { return new Point ( p . x * this . canvas . width , p . y * this . canvas . height ) ; } private getLineWidthRelativeToCanvas ( size ) { return size / this . canvas . width ; } private normalizeLineWidth ( width ) { return width * this . canvas . width ; } private clearCanvas ( ) { this . ctx . clearRect ( 0 , 0 , this . canvas . width , this . canvas . height ) ; if ( this . backgroundColor ) { this . ctx . fillStyle = this . backgroundColor ; this . ctx . fillRect ( 0 , 0 , this . canvas . width , this . canvas . height ) ; } } private drawStroke ( stroke ) { if ( stroke . points == null ) return ; this . ctx . beginPath ( ) ; for ( let i = 0 ; i < stroke . points . length - 1 ; i ++ ) { const start = this . normalizePoint ( stroke . points [ i ] ) ; const end = this . normalizePoint ( stroke . points [ i + 1 ] ) ; this . ctx . moveTo ( start . x , start . y ) ; this . ctx . lineTo ( end . x , end . y ) ; } this . ctx . closePath ( ) ; if ( stroke . color ) { this . ctx . strokeStyle = stroke . color ; } if ( stroke . width ) { this . ctx . lineWidth = this . normalizeLineWidth ( stroke . width ) ; } if ( stroke . join ) { this . ctx . lineJoin = stroke . join ; } if ( stroke . cap ) { this . ctx . lineCap = stroke . cap ; } if ( stroke . miterLimit ) { this . ctx . miterLimit = stroke . miterLimit ; } this . ctx . stroke ( ) ; } private pushStroke ( points ) { this . _strokes . push ( Stroke . fromObj ( { points : points , size : this . getLineWidthRelativeToCanvas ( this . lineWidth ) , color : this . lineColor , cap : this . lineCap , join : this . lineJoin , miterLimit : this . lineMiterLimit , } ) ) ; } private pushPoint ( point ) { const stroke = this . _strokes [ this . _strokes . length - 1 ] ; if ( stroke . points ) { stroke . points . push ( point ) ; } } private redraw ( ) { this . clearCanvas ( ) ; this . _strokes . forEach ( ( s ) => this . drawStroke ( s ) ) ; } private listen ( ) { [ 'mousedown' , 'touchstart' ] . forEach ( ( name ) => this . canvas . addEventListener ( name , ( e ) => this . startStrokeHandler ( e ) ) ) ; [ 'mousemove' , 'touchmove' ] . forEach ( ( name ) => this . canvas . addEventListener ( name , ( e ) => this . drawStrokeHandler ( e ) ) ) ; [ 'mouseup' , 'mouseleave' , 'touchend' ] . forEach ( ( name ) => this . canvas . addEventListener ( name , ( e ) => this . endStrokeHandler ( e ) ) ) ; } private startStrokeHandler ( e ) { e . preventDefault ( ) ; if ( this . readOnly ) { return ; } this . sketching = true ; const point = this . getCursorRelativeToCanvas ( e ) ; this . pushStroke ( [ point ] ) ; this . redraw ( ) ; } private drawStrokeHandler ( e ) { e . preventDefault ( ) ; if ( ! this . sketching ) return ; const point = this . getCursorRelativeToCanvas ( e ) ; this . pushPoint ( point ) ; this . redraw ( ) ; } private endStrokeHandler ( e ) { e . preventDefault ( ) ; if ( ! this . sketching ) return ; this . sketching = false ; if ( isTouchEvent ( e ) ) { return ; } const point = this . getCursorRelativeToCanvas ( e ) ; this . pushPoint ( point ) ; this . redraw ( ) ; if ( this . onDrawEnd ) { this . onDrawEnd ( ) ; } } } function isTouchEvent ( e ) { return e . type . indexOf ( 'touch' ) !== - 1 ; } interface PointI { readonly x : number ; readonly y : number ; } class Point implements PointI { constructor ( public x , public y ) { } } interface RectI { readonly width : number ; readonly height : number ; } interface DataI { aspectRatio ? : number ; strokes ? : Array < StrokeI > ; } interface LineOptionsI { size ? : number ; color ? : string ; cap ? : CanvasLineCap ; join ? : CanvasLineJoin ; miterLimit ? : number ; } interface SketchpadOptionsI { backgroundColor ? : string ; readOnly ? : boolean ; width ? : number ; height ? : number ; aspectRatio ? : number ; line ? : LineOptionsI ; data ? : DataI ; onDrawEnd ? : ( ) => void ; } interface StrokeI extends LineOptionsI { points ? : Array < PointI > ; } class Stroke { points ? : Array < Point > ; width ? : number ; color ? : string ; cap ? : CanvasLineCap ; join ? : CanvasLineJoin ; miterLimit ? : number ; static fromObj ( s ) { const stroke = new Stroke ( ) ; stroke . points = s . points ; stroke . width = s . size ; stroke . color = s . color ; stroke . cap = s . cap ; stroke . join = s . join ; stroke . miterLimit = s . miterLimit ; return stroke ; } toObj ( ) { return { points : this . points , size : this . width , color : this . color , cap : this . cap , join : this . join , miterLimit : this . miterLimit , } ; } }