js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
1,173 lines • 59.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Editor = void 0;
const EditorImage_1 = __importDefault(require("./image/EditorImage"));
const ToolController_1 = __importDefault(require("./tools/ToolController"));
const types_1 = require("./types");
const inputEvents_1 = require("./inputEvents");
const UndoRedoHistory_1 = __importDefault(require("./UndoRedoHistory"));
const Viewport_1 = __importDefault(require("./Viewport"));
const EventDispatcher_1 = __importDefault(require("./EventDispatcher"));
const math_1 = require("@js-draw/math");
const Display_1 = __importStar(require("./rendering/Display"));
const SVGLoader_1 = __importDefault(require("./SVGLoader/SVGLoader"));
const Pointer_1 = __importDefault(require("./Pointer"));
const getLocalizationTable_1 = __importDefault(require("./localizations/getLocalizationTable"));
const IconProvider_1 = __importDefault(require("./toolbar/IconProvider"));
const CanvasRenderer_1 = __importDefault(require("./rendering/renderers/CanvasRenderer"));
const untilNextAnimationFrame_1 = __importDefault(require("./util/untilNextAnimationFrame"));
const uniteCommands_1 = __importDefault(require("./commands/uniteCommands"));
const SelectionTool_1 = __importDefault(require("./tools/SelectionTool/SelectionTool"));
const Erase_1 = __importDefault(require("./commands/Erase"));
const BackgroundComponent_1 = __importStar(require("./components/BackgroundComponent"));
const sendPenEvent_1 = __importDefault(require("./testing/sendPenEvent"));
const KeyboardShortcutManager_1 = __importDefault(require("./shortcuts/KeyboardShortcutManager"));
const EdgeToolbar_1 = __importDefault(require("./toolbar/EdgeToolbar"));
const StrokeKeyboardControl_1 = __importDefault(require("./tools/InputFilter/StrokeKeyboardControl"));
const guessKeyCodeFromKey_1 = __importDefault(require("./util/guessKeyCodeFromKey"));
const makeAboutDialog_1 = __importDefault(require("./dialogs/makeAboutDialog"));
const version_1 = __importDefault(require("./version"));
const editorImageToSVG_1 = require("./image/export/editorImageToSVG");
const ReactiveValue_1 = __importStar(require("./util/ReactiveValue"));
const listenForKeyboardEventsFrom_1 = __importDefault(require("./util/listenForKeyboardEventsFrom"));
const mitLicenseAttribution_1 = __importDefault(require("./util/mitLicenseAttribution"));
const ClipboardHandler_1 = __importDefault(require("./util/ClipboardHandler"));
const ContextMenuRecognizer_1 = __importDefault(require("./tools/InputFilter/ContextMenuRecognizer"));
/**
* The main entrypoint for the full editor.
*
* ## Example
* To create an editor with a toolbar,
* ```ts,runnable
* import { Editor } from 'js-draw';
*
* const editor = new Editor(document.body);
*
* const toolbar = editor.addToolbar();
* toolbar.addSaveButton(() => {
* const saveData = editor.toSVG().outerHTML;
* // Do something with saveData...
* });
* ```
*
* See also
* * [`examples.md`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples.md).
*/
class Editor {
/**
* @example
* ```ts,runnable
* import { Editor } from 'js-draw';
*
* const container = document.body;
*
* // Create an editor
* const editor = new Editor(container, {
* // 2e-10 and 1e12 are the default values for minimum/maximum zoom.
* minZoom: 2e-10,
* maxZoom: 1e12,
* });
*
* // Add the default toolbar
* const toolbar = editor.addToolbar();
*
* const createCustomIcon = () => {
* // Create/return an icon here.
* };
*
* // Add a custom button
* toolbar.addActionButton({
* label: 'Custom Button'
* icon: createCustomIcon(),
* }, () => {
* // Do something here
* });
* ```
*/
constructor(parent, settings = {}) {
this.eventListenerTargets = [];
this.previousAccessibilityAnnouncement = '';
this.pointers = {};
this.announceUndoCallback = (command) => {
this.announceForAccessibility(this.localization.undoAnnouncement(command.description(this, this.localization)));
};
this.announceRedoCallback = (command) => {
this.announceForAccessibility(this.localization.redoAnnouncement(command.description(this, this.localization)));
};
// Listeners to be called once at the end of the next re-render.
this.nextRerenderListeners = [];
this.rerenderQueued = false;
this.closeAboutDialog = null;
this.localization = {
...(0, getLocalizationTable_1.default)(),
...settings.localization,
};
// Fill default settings.
this.settings = {
wheelEventsEnabled: settings.wheelEventsEnabled ?? true,
renderingMode: settings.renderingMode ?? Display_1.RenderingMode.CanvasRenderer,
localization: this.localization,
minZoom: settings.minZoom ?? 2e-10,
maxZoom: settings.maxZoom ?? 1e12,
keyboardShortcutOverrides: settings.keyboardShortcutOverrides ?? {},
iconProvider: settings.iconProvider ?? new IconProvider_1.default(),
notices: settings.notices ?? [],
appInfo: settings.appInfo ? { ...settings.appInfo } : null,
pens: {
additionalPenTypes: settings.pens?.additionalPenTypes ?? [],
filterPenTypes: settings.pens?.filterPenTypes ?? (() => true),
},
text: {
fonts: settings.text?.fonts ?? ['sans-serif', 'serif', 'monospace'],
},
image: {
showImagePicker: settings.image?.showImagePicker ?? undefined,
},
svg: {
loaderPlugins: settings.svg?.loaderPlugins ?? [],
},
clipboardApi: settings.clipboardApi ?? null,
};
// Validate settings
if (this.settings.minZoom > this.settings.maxZoom) {
throw new Error('Minimum zoom must be lesser than maximum zoom!');
}
this.readOnly = ReactiveValue_1.MutableReactiveValue.fromInitialValue(false);
this.icons = this.settings.iconProvider;
this.shortcuts = new KeyboardShortcutManager_1.default(this.settings.keyboardShortcutOverrides);
this.container = document.createElement('div');
this.renderingRegion = document.createElement('div');
this.container.appendChild(this.renderingRegion);
this.container.classList.add('imageEditorContainer', 'js-draw');
this.loadingWarning = document.createElement('div');
this.loadingWarning.classList.add('loadingMessage');
this.loadingWarning.ariaLive = 'polite';
this.container.appendChild(this.loadingWarning);
this.accessibilityControlArea = document.createElement('textarea');
this.accessibilityControlArea.setAttribute('placeholder', this.localization.accessibilityInputInstructions);
this.accessibilityControlArea.style.opacity = '0';
this.accessibilityControlArea.style.width = '0';
this.accessibilityControlArea.style.height = '0';
this.accessibilityControlArea.style.position = 'absolute';
this.accessibilityAnnounceArea = document.createElement('div');
this.accessibilityAnnounceArea.setAttribute('aria-live', 'assertive');
this.accessibilityAnnounceArea.className = 'accessibilityAnnouncement';
this.container.appendChild(this.accessibilityAnnounceArea);
this.renderingRegion.style.touchAction = 'none';
this.renderingRegion.className = 'imageEditorRenderArea';
this.renderingRegion.appendChild(this.accessibilityControlArea);
this.renderingRegion.setAttribute('tabIndex', '0');
this.renderingRegion.setAttribute('alt', '');
this.notifier = new EventDispatcher_1.default();
this.viewport = new Viewport_1.default((oldTransform, newTransform) => {
this.notifier.dispatch(types_1.EditorEventType.ViewportChanged, {
kind: types_1.EditorEventType.ViewportChanged,
newTransform,
oldTransform,
});
});
this.display = new Display_1.default(this, this.settings.renderingMode, this.renderingRegion);
this.image = new EditorImage_1.default();
this.history = new UndoRedoHistory_1.default(this, this.announceRedoCallback, this.announceUndoCallback);
this.toolController = new ToolController_1.default(this, this.localization);
// TODO: Make this pipeline configurable (e.g. allow users to add global input stabilization)
this.toolController.addInputMapper(StrokeKeyboardControl_1.default.fromEditor(this));
this.toolController.addInputMapper(new ContextMenuRecognizer_1.default());
parent.appendChild(this.container);
this.viewport.updateScreenSize(math_1.Vec2.of(this.display.width, this.display.height));
this.registerListeners();
this.queueRerender();
this.hideLoadingWarning();
// Enforce zoom limits.
this.notifier.on(types_1.EditorEventType.ViewportChanged, (evt) => {
if (evt.kind !== types_1.EditorEventType.ViewportChanged)
return;
const getZoom = (mat) => mat.transformVec3(math_1.Vec2.unitX).length();
const zoom = getZoom(evt.newTransform);
if (zoom > this.settings.maxZoom || zoom < this.settings.minZoom) {
const oldZoom = getZoom(evt.oldTransform);
let resetTransform = math_1.Mat33.identity;
if (oldZoom <= this.settings.maxZoom && oldZoom >= this.settings.minZoom) {
resetTransform = evt.oldTransform;
}
else {
// If 1x zoom isn't acceptable, try a zoom between the minimum and maximum.
resetTransform = math_1.Mat33.scaling2D((this.settings.minZoom + this.settings.maxZoom) / 2);
}
this.viewport.resetTransform(resetTransform);
}
else if (!isFinite(zoom)) {
// Recover from possible division-by-zero
console.warn(`Non-finite zoom (${zoom}) detected. Resetting the viewport. This was likely caused by division by zero.`);
if (isFinite(getZoom(evt.oldTransform))) {
this.viewport.resetTransform(evt.oldTransform);
}
else {
this.viewport.resetTransform();
}
}
});
}
/**
* @returns a shallow copy of the current settings of the editor.
*
* Do not modify.
*/
getCurrentSettings() {
return {
...this.settings,
};
}
/**
* @returns a reference to the editor's container.
*
* @example
* ```
* // Set the editor's height to 500px
* editor.getRootElement().style.height = '500px';
* ```
*/
getRootElement() {
return this.container;
}
/**
* @returns the bounding box of the main rendering region of the editor in the HTML viewport.
*
* @internal
*/
getOutputBBoxInDOM() {
return math_1.Rect2.of(this.renderingRegion.getBoundingClientRect());
}
/**
* Shows a "Loading..." message.
* @param fractionLoaded - should be a number from 0 to 1, where 1 represents completely loaded.
*/
showLoadingWarning(fractionLoaded) {
const loadingPercent = Math.round(fractionLoaded * 100);
this.loadingWarning.innerText = this.localization.loading(loadingPercent);
this.loadingWarning.style.display = 'block';
}
/** @see {@link showLoadingWarning} */
hideLoadingWarning() {
this.loadingWarning.style.display = 'none';
this.announceForAccessibility(this.localization.doneLoading);
}
/**
* Announce `message` for screen readers. If `message` is the same as the previous
* message, it is re-announced.
*/
announceForAccessibility(message) {
// Force re-announcing an announcement if announced again.
if (message === this.previousAccessibilityAnnouncement) {
message = message + '. ';
}
this.accessibilityAnnounceArea.innerText = message;
this.previousAccessibilityAnnouncement = message;
}
/**
* Creates a toolbar. If `defaultLayout` is true, default buttons are used.
* @returns a reference to the toolbar.
*/
addToolbar(defaultLayout = true) {
const toolbar = new EdgeToolbar_1.default(this, this.container, this.localization);
if (defaultLayout) {
toolbar.addDefaults();
}
return toolbar;
}
registerListeners() {
this.handlePointerEventsFrom(this.renderingRegion);
this.handleKeyEventsFrom(this.renderingRegion);
this.handlePointerEventsFrom(this.accessibilityAnnounceArea);
// Prevent selected text from control areas from being dragged.
// See https://github.com/personalizedrefrigerator/joplin-plugin-freehand-drawing/issues/8
const preventSelectionOf = [
this.renderingRegion,
this.accessibilityAnnounceArea,
this.accessibilityControlArea,
this.loadingWarning,
];
for (const element of preventSelectionOf) {
element.addEventListener('drag', (event) => {
event.preventDefault();
return false;
});
element.addEventListener('dragstart', (event) => {
event.preventDefault();
return false;
});
}
this.container.addEventListener('wheel', (evt) => {
this.handleHTMLWheelEvent(evt);
});
const handleResize = () => {
this.viewport.updateScreenSize(math_1.Vec2.of(this.display.width, this.display.height));
this.rerender();
this.updateEditorSizeVariables();
};
if ('ResizeObserver' in window) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(this.renderingRegion);
resizeObserver.observe(this.container);
}
else {
addEventListener('resize', handleResize);
}
this.accessibilityControlArea.addEventListener('input', () => {
this.accessibilityControlArea.value = '';
});
const copyHandler = new ClipboardHandler_1.default(this);
document.addEventListener('copy', async (evt) => {
if (!this.isEventSink(document.querySelector(':focus'))) {
return;
}
copyHandler.copy(evt);
});
document.addEventListener('paste', (evt) => {
this.handlePaste(evt);
});
}
updateEditorSizeVariables() {
// Add CSS variables so that absolutely-positioned children of the editor can
// still fill the screen.
this.container.style.setProperty('--editor-current-width-px', `${this.container.clientWidth}px`);
this.container.style.setProperty('--editor-current-height-px', `${this.container.clientHeight}px`);
this.container.style.setProperty('--editor-current-display-width-px', `${this.renderingRegion.clientWidth}px`);
this.container.style.setProperty('--editor-current-display-height-px', `${this.renderingRegion.clientHeight}px`);
}
/** @internal */
handleHTMLWheelEvent(event) {
let delta = math_1.Vec3.of(event.deltaX, event.deltaY, event.deltaZ);
// Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
// pinch-zooming.
if (!event.ctrlKey && !event.metaKey) {
if (!this.settings.wheelEventsEnabled) {
return;
}
else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
const focusedChild = this.container.querySelector(':focus');
if (!focusedChild) {
return;
}
}
}
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
delta = delta.times(15);
}
else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
delta = delta.times(100);
}
if (event.ctrlKey || event.metaKey) {
delta = math_1.Vec3.of(0, 0, event.deltaY);
}
// Ensure that `pos` is relative to `this.renderingRegion`
const bbox = this.getOutputBBoxInDOM();
const pos = math_1.Vec2.of(event.clientX, event.clientY).minus(bbox.topLeft);
if (this.toolController.dispatchInputEvent({
kind: inputEvents_1.InputEvtType.WheelEvt,
delta,
screenPos: pos,
})) {
event.preventDefault();
return true;
}
return false;
}
getPointerList() {
const nowTime = performance.now();
const res = [];
for (const id in this.pointers) {
const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
if (this.pointers[id] && nowTime - this.pointers[id].timeStamp < maxUnupdatedTime) {
res.push(this.pointers[id]);
}
}
return res;
}
/**
* A protected method that can override setPointerCapture in environments where it may fail
* (e.g. with synthetic events). @internal
*/
setPointerCapture(target, pointerId) {
try {
target.setPointerCapture(pointerId);
}
catch (error) {
console.warn('Failed to setPointerCapture', error);
}
}
/** Can be overridden in a testing environment to handle synthetic events. @internal */
releasePointerCapture(target, pointerId) {
try {
target.releasePointerCapture(pointerId);
}
catch (error) {
console.warn('Failed to releasePointerCapture', error);
}
}
/**
* Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
* as the content of the editor.
*/
handleHTMLPointerEvent(eventType, evt) {
const eventsRelativeTo = this.renderingRegion;
const eventTarget = evt.target ?? this.renderingRegion;
if (eventType === 'pointerdown') {
const pointer = Pointer_1.default.ofEvent(evt, true, this.viewport, eventsRelativeTo);
this.pointers[pointer.id] = pointer;
this.setPointerCapture(eventTarget, pointer.id);
const event = {
kind: inputEvents_1.InputEvtType.PointerDownEvt,
current: pointer,
allPointers: this.getPointerList(),
};
this.toolController.dispatchInputEvent(event);
return true;
}
else if (eventType === 'pointermove') {
const pointer = Pointer_1.default.ofEvent(evt, this.pointers[evt.pointerId]?.down ?? false, this.viewport, eventsRelativeTo);
if (pointer.down) {
const prevData = this.pointers[pointer.id];
if (prevData) {
const distanceMoved = pointer.screenPos.distanceTo(prevData.screenPos);
// If the pointer moved less than two pixels, don't send a new event.
if (distanceMoved < 2) {
return false;
}
}
this.pointers[pointer.id] = pointer;
if (this.toolController.dispatchInputEvent({
kind: inputEvents_1.InputEvtType.PointerMoveEvt,
current: pointer,
allPointers: this.getPointerList(),
})) {
evt.preventDefault();
}
}
return true;
}
else if (eventType === 'pointercancel' || eventType === 'pointerup') {
const pointer = Pointer_1.default.ofEvent(evt, false, this.viewport, eventsRelativeTo);
if (!this.pointers[pointer.id]) {
return false;
}
this.pointers[pointer.id] = pointer;
this.releasePointerCapture(eventTarget, pointer.id);
if (this.toolController.dispatchInputEvent({
kind: inputEvents_1.InputEvtType.PointerUpEvt,
current: pointer,
allPointers: this.getPointerList(),
})) {
evt.preventDefault();
}
delete this.pointers[pointer.id];
return true;
}
return eventType;
}
isEventSink(evtTarget) {
let currentElem = evtTarget;
while (currentElem !== null) {
for (const elem of this.eventListenerTargets) {
if (elem === currentElem) {
return true;
}
}
currentElem = currentElem.parentElement;
}
return false;
}
/** @internal */
async handleDrop(evt) {
evt.preventDefault();
await this.handlePaste(evt);
}
/** @internal */
async handlePaste(evt) {
const target = document.querySelector(':focus') ?? evt.target;
if (!this.isEventSink(target)) {
return;
}
return await new ClipboardHandler_1.default(this).paste(evt);
}
/**
* Forward pointer events from `elem` to this editor. Such that right-click/right-click drag
* events are also forwarded, `elem`'s contextmenu is disabled.
*
* `filter` is called once per pointer event, before doing any other processing. If `filter` returns `true` the event is
* forwarded to the editor.
*
* **Note**: `otherEventsFilter` is like `filter`, but is called for other pointer-related
* events that could also be forwarded to the editor. To forward just pointer events,
* for example, `otherEventsFilter` could be given as `()=>false`.
*
* @example
* ```ts
* const overlay = document.createElement('div');
* editor.createHTMLOverlay(overlay);
*
* // Send all pointer events that don't have the control key pressed
* // to the editor.
* editor.handlePointerEventsFrom(overlay, (event) => {
* if (event.ctrlKey) {
* return false;
* }
* return true;
* });
* ```
*/
handlePointerEventsFrom(elem, filter, otherEventsFilter) {
// May be required to prevent text selection on iOS/Safari:
// See https://stackoverflow.com/a/70992717/17055750
const touchstartListener = (evt) => {
if (otherEventsFilter && !otherEventsFilter('touchstart', evt)) {
return;
}
evt.preventDefault();
};
const contextmenuListener = (evt) => {
if (otherEventsFilter && !otherEventsFilter('contextmenu', evt)) {
return;
}
// Don't show a context menu
evt.preventDefault();
};
const listeners = {
touchstart: touchstartListener,
contextmenu: contextmenuListener,
};
const eventNames = [
'pointerdown',
'pointermove',
'pointerup',
'pointercancel',
];
for (const eventName of eventNames) {
listeners[eventName] = (evt) => {
// This listener will only be called in the context of PointerEvents.
const event = evt;
if (filter && !filter(eventName, event)) {
return undefined;
}
return this.handleHTMLPointerEvent(eventName, event);
};
}
// Add all listeners.
for (const eventName in listeners) {
elem.addEventListener(eventName, listeners[eventName]);
}
return {
/** Remove all event listeners registered by this function. */
remove: () => {
for (const eventName in listeners) {
elem.removeEventListener(eventName, listeners[eventName]);
}
},
};
}
/**
* Like {@link handlePointerEventsFrom} except ignores short input gestures like clicks.
*
* `filter` is called once per event, before doing any other processing. If `filter` returns `true` the event is
* forwarded to the editor.
*
* `otherEventsFilter` is passed unmodified to `handlePointerEventsFrom`.
*/
handlePointerEventsExceptClicksFrom(elem, filter, otherEventsFilter) {
// Maps pointer IDs to gesture start points
const gestureData = Object.create(null);
return this.handlePointerEventsFrom(elem, (eventName, event) => {
if (filter && !filter(eventName, event)) {
return false;
}
// Position of the current event.
// jsdom doesn't seem to support pageX/pageY -- use clientX/clientY if unavailable
const currentPos = math_1.Vec2.of(event.pageX ?? event.clientX, event.pageY ?? event.clientY);
const pointerId = event.pointerId ?? 0;
// Whether to send the current event to the editor
let sendToEditor = true;
if (eventName === 'pointerdown') {
// Buffer the event, but don't send it to the editor yet.
// We don't want to send single-click events, but we do want to send full strokes.
gestureData[pointerId] = {
eventBuffer: [[eventName, event]],
startPoint: currentPos,
hasMovedSignificantly: false,
};
// Capture the pointer so we receive future events even if the overlay is hidden.
this.setPointerCapture(elem, event.pointerId);
// Don't send to the editor.
sendToEditor = false;
}
else if (eventName === 'pointermove' && gestureData[pointerId]) {
const gestureStartPos = gestureData[pointerId].startPoint;
const eventBuffer = gestureData[pointerId].eventBuffer;
// Skip if the pointer hasn't moved enough to not be a "click".
const strokeStartThreshold = 10;
const isWithinClickThreshold = gestureStartPos && currentPos.distanceTo(gestureStartPos) < strokeStartThreshold;
if (isWithinClickThreshold && !gestureData[pointerId].hasMovedSignificantly) {
eventBuffer.push([eventName, event]);
sendToEditor = false;
}
else {
// Send all buffered events to the editor -- start the stroke.
for (const [eventName, event] of eventBuffer) {
this.handleHTMLPointerEvent(eventName, event);
}
gestureData[pointerId].eventBuffer = [];
gestureData[pointerId].hasMovedSignificantly = true;
sendToEditor = true;
}
}
// Pointers that aren't down -- send to the editor.
else if (eventName === 'pointermove') {
sendToEditor = true;
}
// Otherwise, if we received a pointerup/pointercancel without flushing all pointerevents from the
// buffer, the gesture wasn't recognised as a stroke. Thus, the editor isn't expecting a pointerup/
// pointercancel event.
else if ((eventName === 'pointerup' || eventName === 'pointercancel') &&
gestureData[pointerId] &&
gestureData[pointerId].eventBuffer.length > 0) {
this.releasePointerCapture(elem, event.pointerId);
// Don't send to the editor.
sendToEditor = false;
delete gestureData[pointerId];
}
// Forward all other events to the editor.
return sendToEditor;
}, otherEventsFilter);
}
/** @internal */
handleHTMLKeyDownEvent(htmlEvent) {
console.assert(htmlEvent.type === 'keydown', `handling a keydown event with type ${htmlEvent.type}`);
const event = (0, inputEvents_1.keyPressEventFromHTMLEvent)(htmlEvent);
if (this.toolController.dispatchInputEvent(event)) {
htmlEvent.preventDefault();
return true;
}
else if (event.key === 't' || event.key === 'T') {
htmlEvent.preventDefault();
this.display.rerenderAsText();
return true;
}
else if (event.key === 'Escape') {
this.renderingRegion.blur();
return true;
}
return false;
}
/** @internal */
handleHTMLKeyUpEvent(htmlEvent) {
console.assert(htmlEvent.type === 'keyup', `Handling a keyup event with type ${htmlEvent.type}`);
const event = (0, inputEvents_1.keyUpEventFromHTMLEvent)(htmlEvent);
if (this.toolController.dispatchInputEvent(event)) {
htmlEvent.preventDefault();
return true;
}
return false;
}
/**
* Adds event listners for keypresses (and drop events) on `elem` and forwards those
* events to the editor.
*
* If the given `filter` returns `false` for an event, the event is ignored and not
* passed to the editor.
*/
handleKeyEventsFrom(elem, filter = () => true) {
(0, listenForKeyboardEventsFrom_1.default)(elem, {
filter,
handleKeyDown: (htmlEvent) => {
this.handleHTMLKeyDownEvent(htmlEvent);
},
handleKeyUp: (htmlEvent) => {
this.handleHTMLKeyUpEvent(htmlEvent);
},
getHandlesKeyEventsFrom: (element) => {
return this.eventListenerTargets.includes(element);
},
});
// Allow drop.
elem.ondragover = (evt) => {
evt.preventDefault();
};
elem.ondrop = (evt) => {
this.handleDrop(evt);
};
this.eventListenerTargets.push(elem);
}
/**
* Attempts to prevent **user-triggered** events from modifying
* the content of the image.
*/
setReadOnly(readOnly) {
if (readOnly !== this.readOnly.get()) {
this.readOnly.set(readOnly);
this.notifier.dispatch(types_1.EditorEventType.ReadOnlyModeToggled, {
kind: types_1.EditorEventType.ReadOnlyModeToggled,
editorIsReadOnly: readOnly,
});
}
}
// @internal
isReadOnlyReactiveValue() {
return this.readOnly;
}
isReadOnly() {
return this.readOnly;
}
/**
* `apply` a command. `command` will be announced for accessibility.
*
* **Example**:
* [[include:doc-pages/inline-examples/adding-a-stroke.md]]
*/
dispatch(command, addToHistory = true) {
const dispatchResult = this.dispatchNoAnnounce(command, addToHistory);
const commandDescription = command.description(this, this.localization);
this.announceForAccessibility(commandDescription);
return dispatchResult;
}
/**
* Dispatches a command without announcing it. By default, does not add to history.
* Use this to show finalized commands that don't need to have `announceForAccessibility`
* called.
*
* If `addToHistory` is `false`, this is equivalent to `command.apply(editor)`.
*
* @example
* ```
* const addToHistory = false;
* editor.dispatchNoAnnounce(editor.viewport.zoomTo(someRectangle), addToHistory);
* ```
*/
dispatchNoAnnounce(command, addToHistory = false) {
const result = command.apply(this);
if (addToHistory) {
const apply = false; // Don't double-apply
this.history.push(command, apply);
}
return result;
}
/**
* Apply a large transformation in chunks.
* If `apply` is `false`, the commands are unapplied.
* Triggers a re-render after each `updateChunkSize`-sized group of commands
* has been applied.
*/
async asyncApplyOrUnapplyCommands(commands, apply, updateChunkSize) {
console.assert(updateChunkSize > 0);
this.display.setDraftMode(true);
for (let i = 0; i < commands.length; i += updateChunkSize) {
this.showLoadingWarning(i / commands.length);
for (let j = i; j < commands.length && j < i + updateChunkSize; j++) {
const cmd = commands[j];
if (apply) {
cmd.apply(this);
}
else {
cmd.unapply(this);
}
}
// Re-render to show progress, but only if we're not done.
if (i + updateChunkSize < commands.length) {
await new Promise((resolve) => {
this.rerender();
requestAnimationFrame(resolve);
});
}
}
this.display.setDraftMode(false);
this.hideLoadingWarning();
}
/** @see {@link asyncApplyOrUnapplyCommands } */
asyncApplyCommands(commands, chunkSize) {
return this.asyncApplyOrUnapplyCommands(commands, true, chunkSize);
}
/**
* @see {@link asyncApplyOrUnapplyCommands}
*
* If `unapplyInReverseOrder`, commands are reversed before unapplying.
*/
asyncUnapplyCommands(commands, chunkSize, unapplyInReverseOrder = false) {
if (unapplyInReverseOrder) {
commands = [...commands]; // copy
commands.reverse();
}
return this.asyncApplyOrUnapplyCommands(commands, false, chunkSize);
}
/**
* Schedule a re-render for some time in the near future. Does not schedule an additional
* re-render if a re-render is already queued.
*
* @returns a promise that resolves when re-rendering has completed.
*/
queueRerender() {
if (!this.rerenderQueued) {
this.rerenderQueued = true;
requestAnimationFrame(() => {
// If .rerender was called manually, we might not need to
// re-render.
if (this.rerenderQueued) {
this.rerender();
this.rerenderQueued = false;
}
});
}
return new Promise((resolve) => {
this.nextRerenderListeners.push(() => resolve());
});
}
// @internal
isRerenderQueued() {
return this.rerenderQueued;
}
/**
* Re-renders the entire image.
*
* @see {@link Editor.queueRerender}
*/
rerender(showImageBounds = true) {
this.display.startRerender();
// Don't render if the display has zero size.
if (this.display.width === 0 || this.display.height === 0) {
return;
}
const renderer = this.display.getDryInkRenderer();
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
// Draw a rectangle around the region that will be visible on save
if (showImageBounds && !this.image.getAutoresizeEnabled()) {
const exportRectFill = { fill: math_1.Color4.fromHex('#44444455') };
const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
renderer.drawRect(this.getImportExportRect(), exportRectStrokeWidth, exportRectFill);
}
this.rerenderQueued = false;
this.nextRerenderListeners.forEach((listener) => listener());
this.nextRerenderListeners = [];
}
/**
* Draws the given path onto the wet ink renderer. The given path will
* be displayed on top of the main image.
*
* @see {@link Display.getWetInkRenderer} {@link Display.flatten}
*/
drawWetInk(...path) {
for (const part of path) {
this.display.getWetInkRenderer().drawPath(part);
}
}
/**
* Clears the wet ink display.
*
* The wet ink display can be used by the currently active tool to display a preview
* of an in-progress action.
*
* @see {@link Display.getWetInkRenderer}
*/
clearWetInk() {
this.display.getWetInkRenderer().clear();
}
/**
* Focuses the region used for text input/key commands.
*/
focus() {
this.renderingRegion.focus();
}
/**
* Creates an element that will be positioned on top of the dry/wet ink
* renderers.
*
* So as not to change the position of other overlays, `overlay` should either
* be styled to have 0 height or have `position: absolute`.
*
* This is useful for displaying content on top of the rendered content
* (e.g. a selection box).
*/
createHTMLOverlay(overlay) {
// TODO(v2): Fix conflict with toolbars that have been added to the editor.
overlay.classList.add('overlay', 'js-draw-editor-overlay');
this.container.appendChild(overlay);
return {
remove: () => overlay.remove(),
};
}
/**
* Anchors the given `element` to the canvas with a given position/transformation in canvas space.
*/
anchorElementToCanvas(element, canvasTransform) {
if (canvasTransform instanceof math_1.Mat33) {
canvasTransform = ReactiveValue_1.default.fromImmutable(canvasTransform);
}
// The element hierarchy looks like this:
// overlay > contentWrapper > content
//
// Both contentWrapper and overlay are present to:
// 1. overlay: Positions the content at the top left of the viewport. The overlay
// has `height: 0` to allow other overlays to also be aligned with the viewport's
// top left.
// 2. contentWrapper: Has the same width/height as the editor's visible region and
// has `overflow: hidden`. This prevents the anchored element from being visible
// when not in the visible region of the canvas.
const overlay = document.createElement('div');
overlay.classList.add('anchored-element-overlay');
const contentWrapper = document.createElement('div');
contentWrapper.classList.add('content-wrapper');
element.classList.add('content');
// Updates CSS variables that specify the position/rotation/scale of the content.
const updateElementPositioning = () => {
const transform = canvasTransform.get();
const canvasRotation = transform.transformVec3(math_1.Vec2.unitX).angle();
const screenRotation = canvasRotation + this.viewport.getRotationAngle();
const screenTransform = this.viewport.canvasToScreenTransform.rightMul(canvasTransform.get());
overlay.style.setProperty('--full-transform', screenTransform.toCSSMatrix());
const translation = screenTransform.transformVec2(math_1.Vec2.zero);
overlay.style.setProperty('--position-x', `${translation.x}px`);
overlay.style.setProperty('--position-y', `${translation.y}px`);
overlay.style.setProperty('--rotation', `${(screenRotation * 180) / Math.PI}deg`);
overlay.style.setProperty('--scale', `${screenTransform.getScaleFactor()}`);
};
updateElementPositioning();
// The anchored element needs to be updated both when the user moves the canvas
// and when the anchored element's transform changes.
const updateListener = canvasTransform.onUpdate(updateElementPositioning);
const viewportListener = this.notifier.on(types_1.EditorEventType.ViewportChanged, updateElementPositioning);
contentWrapper.appendChild(element);
overlay.appendChild(contentWrapper);
overlay.classList.add('overlay', 'js-draw-editor-overlay');
this.renderingRegion.insertAdjacentElement('afterend', overlay);
return {
remove: () => {
overlay.remove();
updateListener.remove();
viewportListener.remove();
},
};
}
/**
* Creates a CSS stylesheet with `content` and applies it to the document
* (and thus, to this editor).
*/
addStyleSheet(content) {
const styleSheet = document.createElement('style');
styleSheet.innerText = content;
this.container.appendChild(styleSheet);
return styleSheet;
}
/**
* Dispatch a keyboard event to the currently selected tool.
* Intended for unit testing.
*
* If `shiftKey` is undefined, it is guessed from `key`.
*
* At present, the **key code** dispatched is guessed from the given key and,
* while this works for ASCII alphanumeric characters, this does not work for
* most non-alphanumeric keys.
*
* Because guessing the key code from `key` is problematic, **only use this for testing**.
*/
sendKeyboardEvent(eventType, key, ctrlKey = false, altKey = false, shiftKey = undefined) {
shiftKey ??= key.toUpperCase() === key && key.toLowerCase() !== key;
this.toolController.dispatchInputEvent({
kind: eventType,
key,
code: (0, guessKeyCodeFromKey_1.default)(key),
ctrlKey,
altKey,
shiftKey,
});
}
/**
* Dispatch a pen event to the currently selected tool.
* Intended primarially for unit tests.
*
* @deprecated
* @see {@link sendPenEvent} {@link sendTouchEvent}
*/
sendPenEvent(eventType, point,
// @deprecated
allPointers) {
(0, sendPenEvent_1.default)(this, eventType, point, allPointers);
}
/**
* Adds all components in `components` such that they are in the center of the screen.
* This is a convenience method that creates **and applies** a single command.
*
* If `selectComponents` is true (the default), the components are selected.
*
* `actionDescription`, if given, should be a screenreader-friendly description of the
* reason components were added (e.g. "pasted").
*/
async addAndCenterComponents(components, selectComponents = true, actionDescription) {
let bbox = null;
for (const component of components) {
if (bbox) {
bbox = bbox.union(component.getBBox());
}
else {
bbox = component.getBBox();
}
}
if (!bbox) {
return;
}
// Find a transform that scales/moves bbox onto the screen.
const visibleRect = this.viewport.visibleRect;
const scaleRatioX = visibleRect.width / bbox.width;
const scaleRatioY = visibleRect.height / bbox.height;
let scaleRatio = scaleRatioX;
if (bbox.width * scaleRatio > visibleRect.width ||
bbox.height * scaleRatio > visibleRect.height) {
scaleRatio = scaleRatioY;
}
scaleRatio *= 2 / 3;
scaleRatio = Viewport_1.default.roundScaleRatio(scaleRatio);
const transfm = math_1.Mat33.translation(visibleRect.center.minus(bbox.center)).rightMul(math_1.Mat33.scaling2D(scaleRatio, bbox.center));
const commands = [];
for (const component of components) {
// To allow deserialization, we need to add first, then transform.
commands.push(EditorImage_1.default.addComponent(component));
commands.push(component.transformBy(transfm));
}
const applyChunkSize = 100;
await this.dispatch((0, uniteCommands_1.default)(commands, { applyChunkSize, description: actionDescription }), true);
if (selectComponents) {
for (const selectionTool of this.toolController.getMatchingTools(SelectionTool_1.default)) {
selectionTool.setEnabled(true);
selectionTool.setSelection(components);
}
}
}
/**
* Get a data URL (e.g. as produced by `HTMLCanvasElement::toDataURL`).
* If `format` is not `image/png`, a PNG image URL may still be returned (as in the
* case of `HTMLCanvasElement::toDataURL`).
*
* The export resolution is the same as the size of the drawing canvas, unless `outputSize`
* is given.
*
* **Example**:
* [[include:doc-pages/inline-examples/adding-an-image-and-data-urls.md]]
*/
toDataURL(format = 'image/png', outputSize) {
const { element: canvas, renderer } = CanvasRenderer_1.default.fromViewport(this.image.getImportExportViewport(), { canvasSize: outputSize });
this.image.renderAll(renderer);
const dataURL = canvas.toDataURL(format);
return dataURL;
}
/**
* Converts the editor's content into an SVG image.
*
* If the output SVG has width or height less than `options.minDimension`, its size
* will be increased.
*
* @see
* {@link SVGRenderer}
*/
toSVG(options) {
return (0, editorImageToSVG_1.editorImageToSVGSync)(this.image, options ?? {});
}
/**
* Converts the editor's content into an SVG image in an asynchronous,
* but **potentially lossy** way.
*
* **Warning**: If the image is being edited during an async rendering, edited components
* may not be rendered.
*
* Like {@link toSVG}, but can be configured to briefly pause after processing every
* `pauseAfterCount` items. This can prevent the editor from becoming unresponsive
* when saving very large images.
*/
async toSVGAsync(options = {}) {
const pauseAfterCount = options.pauseAfterCount ?? 100;
return await (0, editorImageToSVG_1.editorImageToSVGAsync)(this.image, async (_component, processedCount, totalComponents) => {
if (options.onProgress) {
const shouldContinue = await options.onProgress(processedCount, totalComponents);
if (shouldContinue === false) {
return false;
}
}
if (processedCount % pauseAfterCount === 0) {
await (0, untilNextAnimationFrame_1.default)();
}
return true;
}, {
minDimension: options.minDimension,
});
}
/**
* Load editor data from an `ImageLoader` (e.g. an {@link SVGLoader}).
*
* @see loadFromSVG
*/
async loadFrom(loader) {
this.showLoadingWarning(0);
this.display.setDraftMode(true);
const originalBackgrounds = this.image.getBackgroundComponents();
const eraseBackgroundCommand = new Erase_1.default(originalBackgrounds);
await loader.start(async (component) => {
await this.dispatchNoAnnounce(EditorImage_1.default.addComponent(component));
}, (countProcessed, totalToProcess) => {
if (countProcessed % 500 === 0) {
this.showLoadingWarning(countProcessed / totalToProcess);
this.rerender();
return (0, untilNextAnimationFrame_1.default)();
}
return null;
}, (importExportRect, options) => {
this.dispatchNoAnnounce(this.setImportExportRect(importExportRect), false);
this.dispatchNoAnnounce(this.viewport.zoomTo(importExportRect), false);
if (options) {
this.dispatchNoAnnounce(this.image.setAutoresizeEnabled(options.autoresize), false);
}
});
// Ensure that we don't have multiple overlapping BackgroundComponents. Remove
// old BackgroundComponents.
// Overlapping BackgroundComponents may cause changing the background color to
// not work properly.
if (this.image.getBackgroundComponents().length !== originalBackgrounds.length) {
await this.dispatchNoAnnounce(eraseBackgroundCommand);
}
this.hideLoadingWarning();
this.display.setDraftMode(false);
this.queueRerender();
}
getTopmostBackgroundComponent() {
let background = null;
// Find a background component, if one exists.
// Use the last (topmost) background compo