import { LitElement, html, customElement, property, css, query } from 'lit-element'; import { Store, get, set , del } from 'idb-keyval'; // import PointerTracker from 'pointer-tracker'; import PointerTracker, { Pointer } from "./PointerTracker.js"; // import { fileSave } from 'browser-nativefs'; // @ts-ignore import { fileSave, fileOpen } from 'https://cdn.jsdelivr.net/npm/browser-nativefs@0.8.2/dist/index.min.js'; import * as Utils from './utils'; declare let ClipboardItem; @customElement('inking-canvas') export class InkingCanvas extends LitElement { // all properties used to manage the canvas object @query('canvas') private canvas: HTMLCanvasElement; @property({ type: CanvasRenderingContext2D }) private context: CanvasRenderingContext2D; private static readonly minCanvasHeight = 300; private static minCanvasWidth = 300; private static readonly minCanvasHeightCSS = css`${InkingCanvas.minCanvasHeight}px`; private static minCanvasWidthCSS = css`${InkingCanvas.minCanvasWidth}px`; private canvasStore: Store; // all properties immediately customizable by developer @property({type: Number, attribute: "height"}) canvasHeight: number = -1; @property({type: Number, attribute: "width"}) canvasWidth: number = -1; @property({type: String, attribute: "name"}) name: string = ""; // all properties used to manage canvas resizing @property({type: Object}) private isWaitingToDraw: boolean = false; @property({type: Object}) private currentAspectRatio: {width: number, height: number}; @property({type: Number}) private scale: number = 1; @property({type: Object}) private origin: {x: number, y: number}; @property({type: CustomEvent}) private inkingCanvasDrawnEvent: CustomEvent = new CustomEvent('inking-canvas-drawn'); // all properties used by PointerTracker implementation @property({type: Map}) private strokes: Map; @property({type: PointerTracker}) private tracker: PointerTracker; // acknowledge mouse input baseline to establish pressure-controlled pen stroke size private readonly defaultMousePressure: number = 0.5; // establish the default stroke widths that should match the default inking-toolbar tool slider values private readonly nonHighlighterStrokeSize: number = 24; private readonly highlighterStrokeSize: number = 50; private defaultStrokeSize = this.nonHighlighterStrokeSize; // record properties set by external influencers (like toolbar) @property({type: Number}) private strokeSize: number = -1; @property({type: String}) private toolStyle: string = "pen"; @property({type: String}) private strokeColor: string = "black"; // notify external influencers (like toolbar) when inking is happening @property({type: CustomEvent}) private inkingStartedEvent: CustomEvent = new CustomEvent('inking-started'); render() { return html` `; } constructor() { super(); } firstUpdated() { // TODO: put this somewhere else later this.deleteCanvasContents(); if (!this.canvasStore) { this.canvasStore = new Store(Utils.toDash(this.name) + "-store", Utils.toDash(this.name)); } // establish canvas w & h, low-latency, stroke shape, starting image, etc Utils.runAsynchronously( () => { this.setUpCanvas(); }); // equip canvas to handle & adapt to external resizing window.addEventListener('resize', () => this.requestDrawCanvas(), false); // refresh canvas when browser tab was switched and is re-engaged window.addEventListener('focus', () => this.requestDrawCanvas(), false); // set up input capture events Utils.runAsynchronously( () => { this.setUpPointerTrackerEvents(); }); } // expose ability to get/set stroke color, size, & style setStrokeColor(color: string) { if (this.context) this.strokeColor = color; } getStrokeColor() { return this.strokeColor; } setStrokeSize(strokeSize: number) { if (this.context) this.strokeSize = strokeSize; } getStrokeSize() { return this.strokeSize; } setStrokeStyle(toolStyle: string) { if (this.context) { this.toolStyle = toolStyle; this.setGlobalCompositeOperationForTool(toolStyle); } } getStrokeStyle() { return this.toolStyle; } // expose canvas object for advanced use cases getCanvas() { return this.canvas; } // expose how canvas has resized since its initialization getScale() { return this.scale; } // expose ability to delete canvas contents eraseAll() { Utils.runAsynchronously( () => { this.clearCanvas() this.deleteCanvasContents(); }); } // expose ability to trigger additional inking canvas redraws requestDrawCanvas() { if (!this.isWaitingToDraw) { this.isWaitingToDraw = true; } } // expose ability to expand canvas to fit its toolbar width setMinWidth(newMinWidth: number) { InkingCanvas.minCanvasWidth = newMinWidth; InkingCanvas.minCanvasWidthCSS = css`${newMinWidth}px`; this.canvas.style.minWidth = InkingCanvas.minCanvasWidthCSS.toString(); } // expose ability to request canvas blob image for live sharing requestBlob() { try { Utils.runAsynchronously( async() => { const outerThis = this; this.canvas.toBlob( function(blob) { let inkingCanvasBlobRequestedEvent = new CustomEvent("inking-canvas-blob-requested", { detail: { blob: blob, } }); outerThis.dispatchEvent(inkingCanvasBlobRequestedEvent); }); }); } catch (err) { console.error("Could not dispatch inking canvas blob requested event: ", err); } } private setGlobalCompositeOperationForTool(toolStyle: string) { switch (toolStyle) { case ("pen") : this.context.globalCompositeOperation = "source-over"; break; case ("pencil") : this.context.globalCompositeOperation = "darken"; break; case ("highlighter") : this.context.globalCompositeOperation = "darken"; break; case ("eraser") : this.context.globalCompositeOperation = "source-over"; break; default : console.log("unknown pen style captured"); break; } } private async setUpCanvas() { // set css canvas dimensions prior to setting logical canvas dimensions (including calling getBoundingClientRect()) if (this.isCanvasHeightSet()) { this.canvas.style.height = this.canvasHeight + "px"; this.canvas.height = this.canvasHeight * devicePixelRatio; } else { this.canvas.style.height = '100%'; } if (this.isCanvasWidthSet()) { this.canvas.style.width = this.canvasWidth + "px"; this.canvas.width = this.canvasWidth * devicePixelRatio; } else { this.canvas.style.width = '100%'; } if (!this.isCanvasHeightSet || !this.isCanvasWidthSet()) { let rect = this.canvas.getBoundingClientRect(); if (!this.isCanvasHeightSet()) this.canvas.height = rect.height * devicePixelRatio; if (!this.isCanvasWidthSet()) this.canvas.width = rect.width * devicePixelRatio; } // record original canvas aspect ratio for resizing this.currentAspectRatio = {width: this.canvas.width, height: this.canvas.height}; // enable low-latency if possible this.context = Utils.getLowLatencyContext(this.canvas, "inking"); this.requestDrawCanvas(); Utils.runAsynchronously( () => { this.drawCanvas(); }); } private isCanvasHeightSet() { return this.canvasHeight > 0; } private isCanvasWidthSet() { return this.canvasWidth > 0; } private async drawCanvas() { if (this.context && this.isWaitingToDraw) { // toggle semaphore this.isWaitingToDraw = false; Utils.runAsynchronously( async() => { this.clearCanvas(); // reload canvas with previous contents const outerThis = this; const canvasContents = await (get('canvasContents', this.canvasStore) as any); if (canvasContents) { const tempImage = new Image(); tempImage.onload = () => { outerThis.context.drawImage(tempImage, 0, 0); } tempImage.src = canvasContents; } }); // notify external influencers that drawing happened this.dispatchEvent(this.inkingCanvasDrawnEvent); // console.log("canvas was resized"); } // start & continue canvas redraw loop Utils.runAsynchronously( () => { requestAnimationFrame( async () => this.drawCanvas()); }); } private async clearCanvas() { // record new height and width let rect = this.canvas.getBoundingClientRect(); this.canvas.height = rect.height * devicePixelRatio; this.canvas.width = rect.width * devicePixelRatio; // determine scale of contents to fit canvas this.scale = Math.min(this.canvas.width / this.currentAspectRatio.width, this.canvas.height / this.currentAspectRatio.height); // set the origin so that the scaled content is centered on the canvas this.origin = { x: (this.canvas.width - (this.currentAspectRatio.width * this.scale)) / 2, y: (this.canvas.height - (this.currentAspectRatio.height * this.scale)) / 2 }; // ensure canvas is fresh this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.fillStyle = 'white'; this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); // scale and center canvas this.context.setTransform(this.scale, 0, 0, this.scale, this.origin.x, this.origin.y); // make the stroke points round this.context.lineCap = 'round'; this.context.lineJoin = 'round'; } private getPosX(pointer: any, rect: DOMRect) { return ((pointer.clientX * devicePixelRatio) - (rect.left * devicePixelRatio) - this.origin.x) / this.scale; } private getPosY(pointer: any, rect: DOMRect) { return ((pointer.clientY * devicePixelRatio) - (rect.top * devicePixelRatio) - this.origin.y) / this.scale; } private isStylusEraserActive(pointer: any) { return ((pointer.nativePointer as PointerEvent).buttons === 32 || (pointer.nativePointer as PointerEvent).button === 5); } private async setUpPointerTrackerEvents() { this.strokes = new Map(); const outerThis = this; this.tracker = new PointerTracker(this.canvas, { start(pointer, event) { // console.log("current start event pressure: " + (pointer.nativePointer as PointerEvent).pressure); event.preventDefault(); // notify any connected toolbar to close any open dropdown outerThis.dispatchEvent(outerThis.inkingStartedEvent); outerThis.strokes.set(pointer.id, (pointer.nativePointer as PointerEvent).width); // console.log("pointer added"); return true; }, async end(pointer, event) { outerThis.strokes.delete(pointer.id); // console.log("pointer deleted"); // save snapshot of canvas to redraw if window resizes/refreshes outerThis.cacheCanvasContents(); }, move(previousPointers, changedPointers, event) { for (const pointer of changedPointers) { // find last pointer event of same stroke to connect the new pointer event to it const previous = previousPointers.find(p => p.id === pointer.id); outerThis.drawLocalStroke(pointer, previous, event); } } }); } private isStrokeSizeSet(remoteData?: any) { return (remoteData && remoteData.strokeSize !== -1) || (!remoteData && this.strokeSize !== -1); } private isStrokeOfToolStyle(toolStyle: string, remoteData?: any) { return (remoteData && remoteData.toolStyle === toolStyle) || (!remoteData && this.toolStyle === toolStyle); } private adjustStrokeProperties(pointer: Pointer, remoteData?: any) { // identify input type let pointerType = remoteData ? remoteData.pointerType : (pointer.nativePointer as PointerEvent).pointerType; // console.log("pointer type: " + pointerType); // collect width for touch and mouse strokes let width = remoteData ? remoteData.width : (pointer.nativePointer as PointerEvent).width; this.strokes.set(pointer.id, width); // if (pointerType !== "pen") console.log("width: " + width); // collect info for pen/stylus strokes let pressure = remoteData ? remoteData.pressure : (pointer.nativePointer as PointerEvent).pressure; // if (pointerType === "pen") console.log("pressure: " + pressure); let tiltX = (pointer.nativePointer as PointerEvent).tiltX; // if (pointerType === "pen") console.log("tiltX: " + tiltX); let tiltY = (pointer.nativePointer as PointerEvent).tiltY; // if (pointerType === "pen") console.log("tiltY: " + tiltY); let twist = (pointer.nativePointer as PointerEvent).twist; // if (pointerType === "pen") console.log("twist: " + twist); let tangentialPressue = (pointer.nativePointer as PointerEvent).tangentialPressure; // if (pointerType === "pen") console.log("tangentialPressure: " + tangentialPressue); // adjust stroke thickness for each input type if toolbar size slider isn't active if (!this.isStrokeSizeSet(remoteData)) { if (this.isStrokeOfToolStyle("highlighter", remoteData)) { this.defaultStrokeSize = this.highlighterStrokeSize; } else { this.defaultStrokeSize = this.nonHighlighterStrokeSize; } if (pointerType === 'pen') { if (this.defaultMousePressure > pressure) { this.context.lineWidth = this.defaultStrokeSize - (2 * this.defaultStrokeSize * (this.defaultMousePressure - pressure)); } else if (this.defaultMousePressure === pressure) { this.context.lineWidth = this.defaultStrokeSize; } else { this.context.lineWidth = this.defaultStrokeSize + (2 * this.defaultStrokeSize * (pressure - this.defaultMousePressure)); } } else if (pointerType === "touch") { this.context.lineWidth = this.strokes.get(pointer.id); } else { // set mouse stroke width to default inking-canvas value this.context.lineWidth = this.defaultStrokeSize; } } else { // take stroke size defined by external influencer this.context.lineWidth = remoteData ? remoteData.strokeSize : this.strokeSize; } } private drawStroke(pointer: Pointer, previous: Pointer, event: Event, remoteData?: any) { this.adjustStrokeProperties(pointer, remoteData); let previousX: number, previousY: number, currentX: number, currentY: number; // translate pointer position if canvas has been resized/scaled & then draw the stroke if (this.origin) { // TODO: find better way to handle pen/pointer events for Firefox // make pen events in Firefox appear like mouse events since pressure appears 0 and width is super large if ((pointer.nativePointer as PointerEvent).width > window.innerWidth) { // console.log(width, pressure); if (this.isStrokeSizeSet(remoteData)) { this.context.lineWidth = remoteData ? remoteData.strokeSize : this.strokeSize; } else { this.context.lineWidth = this.defaultStrokeSize; } } let rect = this.canvas.getBoundingClientRect(); // ensure stroke does not retrace any past data this.context.beginPath(); // determine location of the stroke's start previousX = this.getPosX(previous, rect); previousY = this.getPosY(previous, rect); this.context.moveTo(previousX, previousY); // determine location of the stroke's end if ('getCoalesced' in pointer.nativePointer) { for (const point of pointer.getCoalesced()) { currentX = this.getPosX(point, rect); currentY = this.getPosY(point, rect); this.context.lineTo(currentX, currentY); } } else { currentX = this.getPosX(pointer, rect); currentY = this.getPosY(pointer, rect); this.context.lineTo(currentX, currentY); } } if (remoteData) { this.setGlobalCompositeOperationForTool(remoteData.toolStyle); } else { this.setGlobalCompositeOperationForTool(this.toolStyle); } if (this.isStrokeOfToolStyle("pencil", remoteData) && !this.isStylusEraserActive(pointer)) { // update the inking texture with the correct color this.context.fillStyle = remoteData? remoteData.color : this.strokeColor; // change up the stroke texture Utils.drawPencilStroke(this.context, previousX, currentX, previousY, currentY); } else { // update the stroke color (for no added texture) this.context.strokeStyle = remoteData? remoteData.color : this.strokeColor; // TODO: make stylus erase work in Firefox (which does not seem to detect the below button states for stylus input) // handle pen/stylus erasing if (this.isStylusEraserActive(pointer)) { console.log("eraser detected"); this.context.strokeStyle = "white"; this.setGlobalCompositeOperationForTool("eraser"); } // apply ink to canvas this.context.stroke(); } } private drawLocalStroke(pointer: Pointer, previous: Pointer, event: Event) { this.drawStroke(pointer, previous, event); // broadcast stroke details for live sharing let inkingCanvasPointerMoveEvent = new CustomEvent("inking-canvas-pointer-move", { detail: { pointer: pointer, previous: previous, event: event, x0: previous.clientX, y0: previous.clientY, x1: (event as PointerEvent).clientX, y1: (event as PointerEvent).clientY, color: this.strokeColor, pointerType: (event as PointerEvent).pointerType, pressure: (event as PointerEvent).pressure, width: (event as PointerEvent).width, strokeSize: this.strokeSize, toolStyle: this.toolStyle, inkingCanvasName: this.name } }); this.dispatchEvent(inkingCanvasPointerMoveEvent); } drawRemoteStroke(strokeData: any) { try { if (strokeData && strokeData.pointer && strokeData.previous && strokeData.event) { this.drawStroke(strokeData.pointer, strokeData.previous, strokeData.event, strokeData); } else { console.error("Input for drawRemoteStrokes function not valid."); } } catch (err) { console.error("Could not draw strokes from remote source.", err); } } async copyCanvasContents() { try { if (!navigator.clipboard) { console.error("This browser does not yet support copying an image to the clipboard (cannot find navigator.clipboard)"); this.dispatchEvent(this.getCanvasCopiedFailedEvent()); return; } if ("ClipboardItem" in window) { const outerThis = this; this.canvas.toBlob( async blob => { await (navigator.clipboard as any).write([ new (ClipboardItem as any)({ "image/png": blob }) ]).then(function() { console.log("Canvas contents copied successfully!"); let inkingCanvasCopied = new CustomEvent("inking-canvas-copied", { detail: { message: "Copied canvas to clipboard!" }, bubbles: true, composed: true }); outerThis.dispatchEvent(inkingCanvasCopied); }, function (err) { console.error("Could not copy " + outerThis.name + " canvas contents, " + err); outerThis.dispatchEvent(outerThis.getCanvasCopiedFailedEvent()); }) }); } else { console.error("This browser does not yet support copying an image to the clipboard (using ClipboardItem in the Clipboard API)"); this.dispatchEvent(this.getCanvasCopiedFailedEvent()); } } catch (err) { console.error("This browser does not yet support copying an image to the clipboard. Error: " + err); this.dispatchEvent(this.getCanvasCopiedFailedEvent()); } } async saveCanvasContents() { const options = { fileName: "InkingCanvasDrawing.png", // List of allowed MIME types, defaults to `*/*`. mimeTypes: ['image/*'], // List of allowed file extensions, defaults to `''`. extensions: ['png', 'jpg', 'jpeg'], // Set to `true` for allowing multiple files, defaults to `false`. multiple: true, description: 'Inking canvas image files', }; const outerThis = this; this.canvas.toBlob( async blob => { await fileSave(blob, options ).then( function() { console.log("Canvas contents downloaded successfully!"); }, function (err) { console.error("Could not download " + outerThis.name + " canvas contents, " + err); })} ); } async importCanvasContents() { try { const options = { mimeTypes: ['image/*'], extensions: ['.png', '.jpg', '.jpeg'], }; const blob: Blob = await fileOpen(options); let outerThis = this; const blobURL = URL.createObjectURL(blob); let img = new Image; img.onload = function(){ let posX: number, posY: number; if ((img.width === outerThis.canvas.width) && (img.height === outerThis.canvas.height)) { posX = posY = 0; } else if (img.width > outerThis.canvas.width || img.height > outerThis.canvas.height) { let ratioX = (outerThis.canvas.width)/img.width; let ratioY = (outerThis.canvas.height)/img.height; let ratio = Math.min(ratioX, ratioY); img.width *= ratio; img.height *= ratio; posX = posY = 0; } if (img.width < outerThis.canvas.width || img.height < outerThis.canvas.height) { posX = (img.width === outerThis.canvas.width) ? 0 : (outerThis.canvas.width - img.width) / 2; posY = (img.height === outerThis.canvas.height) ? 0 : (outerThis.canvas.height - img.height) / 2; } if (posX > -1 && posY > -1) { outerThis.context.resetTransform(); outerThis.context.drawImage(img, posX, posY, img.width, img.height); outerThis.cacheCanvasContents(); } else { console.error("Could not import picture to canvas. Either the canvas or image dimensions could not be resolved.") } }; img.src = blobURL; } catch (err) { console.error("Could not import picture to canvas. Error: " + err); } } private getCanvasCopiedFailedEvent() { return new CustomEvent("inking-canvas-copied", { detail: { message: "Could not copy canvas to clipboard :(" }, bubbles: true, composed: true }); } private cacheCanvasContents() { // update the recorded canvas aspect ratio for future resizing this.currentAspectRatio.width = this.canvas.width; this.currentAspectRatio.height = this.canvas.height; Utils.runAsynchronously( async () => { let canvasContents = this.canvas.toDataURL(); await set('canvasContents', canvasContents, this.canvasStore); }); } private deleteCanvasContents() { Utils.runAsynchronously( async () => { await del('canvasContents', this.canvasStore); }); } static get styles() { return css` canvas { box-sizing: border-box; border: 4px solid black; position: absolute; min-height: ${this.minCanvasHeightCSS}; min-width: ${this.minCanvasWidthCSS}; touch-action: none; display: block; } `; } }