js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
255 lines (254 loc) • 11 kB
JavaScript
import CanvasRenderer from './renderers/CanvasRenderer.mjs';
import { EditorEventType } from '../types.mjs';
import DummyRenderer from './renderers/DummyRenderer.mjs';
import { Vec2, Color4 } from '@js-draw/math';
import RenderingCache from './caching/RenderingCache.mjs';
import TextOnlyRenderer from './renderers/TextOnlyRenderer.mjs';
export var RenderingMode;
(function (RenderingMode) {
RenderingMode[RenderingMode["DummyRenderer"] = 0] = "DummyRenderer";
RenderingMode[RenderingMode["CanvasRenderer"] = 1] = "CanvasRenderer";
// SVGRenderer is not supported by the main display
})(RenderingMode || (RenderingMode = {}));
/**
* Handles `HTMLCanvasElement`s (or other drawing surfaces if being used) used to display the editor's contents.
*
* @example
* ```
* const editor = new Editor(document.body);
* const w = editor.display.width;
* const h = editor.display.height;
* const center = Vec2.of(w / 2, h / 2);
* const colorAtCenter = editor.display.getColorAt(center);
* ```
*/
export default class Display {
/** @internal */
constructor(editor, mode, parent) {
this.editor = editor;
this.parent = parent;
this.textRerenderOutput = null;
this.devicePixelRatio = window.devicePixelRatio ?? 1;
/**
* @returns the color at the given point on the dry ink renderer, or `null` if `screenPos`
* is not on the display.
*/
this.getColorAt = (_screenPos) => {
return null;
};
if (mode === RenderingMode.CanvasRenderer) {
this.initializeCanvasRendering();
}
else if (mode === RenderingMode.DummyRenderer) {
this.dryInkRenderer = new DummyRenderer(editor.viewport);
this.wetInkRenderer = new DummyRenderer(editor.viewport);
}
else {
throw new Error(`Unknown rendering mode, ${mode}!`);
}
this.textRenderer = new TextOnlyRenderer(editor.viewport, editor.localization);
this.initializeTextRendering();
const cacheBlockResolution = Vec2.of(600, 600);
this.cache = new RenderingCache({
createRenderer: () => {
if (mode === RenderingMode.DummyRenderer) {
return new DummyRenderer(editor.viewport);
}
else if (mode !== RenderingMode.CanvasRenderer) {
throw new Error('Unspported rendering mode');
}
// Make the canvas slightly larger than each cache block to prevent
// seams.
const canvas = document.createElement('canvas');
canvas.width = cacheBlockResolution.x + 1;
canvas.height = cacheBlockResolution.y + 1;
const ctx = canvas.getContext('2d');
return new CanvasRenderer(ctx, editor.viewport);
},
isOfCorrectType: (renderer) => {
return this.dryInkRenderer.canRenderFromWithoutDataLoss(renderer);
},
blockResolution: cacheBlockResolution,
cacheSize: 600 * 600 * 4 * 90,
// On higher resolution displays, don't scale cache blocks as much to decrease blurriness.
// TODO: Decrease the minimum cache scale as well.
maxScale: Math.max(1, 1.3 / window.devicePixelRatio),
// Require about 20 strokes with 4 parts each to cache an image in one of the
// parts of the cache grid.
minProportionalRenderTimePerCache: 20 * 4,
// Require about 105 strokes with 4 parts each to use the cache at all.
minProportionalRenderTimeToUseCache: 105 * 4,
});
this.editor.notifier.on(EditorEventType.DisplayResized, (event) => {
if (event.kind !== EditorEventType.DisplayResized) {
throw new Error('Mismatched event.kinds!');
}
this.resizeSurfacesCallback?.();
});
}
/**
* @returns the visible width of the display (e.g. how much
* space the display's element takes up in the x direction
* in the DOM).
*/
get width() {
return this.dryInkRenderer.displaySize().x;
}
/** @returns the visible height of the display. See {@link width}. */
get height() {
return this.dryInkRenderer.displaySize().y;
}
/** @internal */
getCache() {
return this.cache;
}
initializeCanvasRendering() {
const dryInkCanvas = document.createElement('canvas');
const wetInkCanvas = document.createElement('canvas');
const dryInkCtx = dryInkCanvas.getContext('2d');
const wetInkCtx = wetInkCanvas.getContext('2d');
this.dryInkRenderer = new CanvasRenderer(dryInkCtx, this.editor.viewport);
this.wetInkRenderer = new CanvasRenderer(wetInkCtx, this.editor.viewport);
dryInkCanvas.className = 'dryInkCanvas';
wetInkCanvas.className = 'wetInkCanvas';
if (this.parent) {
this.parent.appendChild(dryInkCanvas);
this.parent.appendChild(wetInkCanvas);
}
this.resizeSurfacesCallback = () => {
const expectedWidth = (canvas) => {
const widthInPixels = Math.ceil(canvas.clientWidth * this.devicePixelRatio);
// Avoid setting the canvas width to zero -- doing so can cause errors when attempting
// to use the canvas:
return widthInPixels || canvas.width;
};
const expectedHeight = (canvas) => {
const heightInPixels = Math.ceil(canvas.clientHeight * this.devicePixelRatio);
return heightInPixels || canvas.height; // Zero-size canvases can cause errors.
};
const hasSizeMismatch = (canvas) => {
return expectedHeight(canvas) !== canvas.height || expectedWidth(canvas) !== canvas.width;
};
// Ensure that the drawing surfaces sizes match the
// canvas' sizes to prevent stretching.
if (hasSizeMismatch(dryInkCanvas) || hasSizeMismatch(wetInkCanvas)) {
dryInkCanvas.width = expectedWidth(dryInkCanvas);
dryInkCanvas.height = expectedHeight(dryInkCanvas);
wetInkCanvas.width = expectedWidth(wetInkCanvas);
wetInkCanvas.height = expectedHeight(wetInkCanvas);
// Ensure correct drawing operations on high-resolution screens.
// See
// https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#scaling_for_high_resolution_displays
//
// This scaling causes the rendering contexts to automatically convert
// between screen coordinates and pixel coordinates.
wetInkCtx.resetTransform();
dryInkCtx.resetTransform();
dryInkCtx.scale(this.devicePixelRatio, this.devicePixelRatio);
wetInkCtx.scale(this.devicePixelRatio, this.devicePixelRatio);
this.editor.notifier.dispatch(EditorEventType.DisplayResized, {
kind: EditorEventType.DisplayResized,
newSize: Vec2.of(this.width, this.height),
});
}
};
this.resizeSurfacesCallback();
this.flattenCallback = () => {
dryInkCtx.save();
dryInkCtx.resetTransform();
dryInkCtx.drawImage(wetInkCanvas, 0, 0);
dryInkCtx.restore();
};
this.getColorAt = (screenPos) => {
// getImageData isn't affected by a transformation matrix -- we need to
// pre-transform screenPos to convert it from screen coordinates into pixel
// coordinates.
const adjustedScreenPos = screenPos.times(this.devicePixelRatio);
const pixel = dryInkCtx.getImageData(adjustedScreenPos.x, adjustedScreenPos.y, 1, 1);
const data = pixel?.data;
if (data) {
const color = Color4.ofRGBA(data[0] / 255, data[1] / 255, data[2] / 255, data[3] / 255);
return color;
}
return null;
};
}
initializeTextRendering() {
const textRendererOutputContainer = document.createElement('div');
textRendererOutputContainer.classList.add('textRendererOutputContainer');
const rerenderButton = document.createElement('button');
rerenderButton.classList.add('rerenderButton');
rerenderButton.innerText = this.editor.localization.rerenderAsText;
this.textRerenderOutput = document.createElement('div');
this.textRerenderOutput.setAttribute('aria-live', 'polite');
rerenderButton.onclick = () => {
this.rerenderAsText();
};
textRendererOutputContainer.replaceChildren(rerenderButton, this.textRerenderOutput);
this.editor.createHTMLOverlay(textRendererOutputContainer);
}
/**
* Sets the device-pixel-ratio.
*
* Intended for debugging. Users do not need to call this manually.
*
* @internal
*/
setDevicePixelRatio(dpr) {
const minDpr = 0.001;
const maxDpr = 10;
if (isFinite(dpr) && dpr >= minDpr && dpr <= maxDpr && dpr !== this.devicePixelRatio) {
this.devicePixelRatio = dpr;
this.resizeSurfacesCallback?.();
return this.editor.queueRerender();
}
return undefined;
}
/** @internal */
getDevicePixelRatio() {
return this.devicePixelRatio;
}
/**
* Rerenders the text-based display.
* The text-based display is intended for screen readers and can be navigated to by pressing `tab`.
*/
rerenderAsText() {
this.textRenderer.clear();
this.editor.image.render(this.textRenderer, this.editor.viewport);
if (this.textRerenderOutput) {
this.textRerenderOutput.innerText = this.textRenderer.getDescription();
}
}
/**
* Clears the main drawing surface and otherwise prepares for a rerender.
*
* @returns the dry ink renderer.
*/
startRerender() {
this.resizeSurfacesCallback?.();
this.dryInkRenderer.clear();
return this.dryInkRenderer;
}
/**
* If `draftMode`, the dry ink renderer is configured to render
* low-quality output.
*/
setDraftMode(draftMode) {
this.dryInkRenderer.setDraftMode(draftMode);
}
/** @internal */
getDryInkRenderer() {
return this.dryInkRenderer;
}
/**
* @returns The renderer used for showing action previews (e.g. an unfinished stroke).
* The `wetInkRenderer`'s surface is stacked above the `dryInkRenderer`'s.
*/
getWetInkRenderer() {
return this.wetInkRenderer;
}
/** Re-renders the contents of the wetInkRenderer onto the dryInkRenderer. */
flatten() {
this.flattenCallback?.();
}
}