UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

1,191 lines 56.2 kB
import EditorImage from './image/EditorImage.mjs'; import ToolController from './tools/ToolController.mjs'; import { EditorEventType } from './types.mjs'; import { InputEvtType, keyUpEventFromHTMLEvent, keyPressEventFromHTMLEvent, } from './inputEvents.mjs'; import UndoRedoHistory from './UndoRedoHistory.mjs'; import Viewport from './Viewport.mjs'; import EventDispatcher from './EventDispatcher.mjs'; import { Vec2, Vec3, Color4, Mat33, Rect2 } from '@js-draw/math'; import Display, { RenderingMode } from './rendering/Display.mjs'; import SVGLoader from './SVGLoader/SVGLoader.mjs'; import Pointer from './Pointer.mjs'; import getLocalizationTable from './localizations/getLocalizationTable.mjs'; import IconProvider from './toolbar/IconProvider.mjs'; import CanvasRenderer from './rendering/renderers/CanvasRenderer.mjs'; import untilNextAnimationFrame from './util/untilNextAnimationFrame.mjs'; import uniteCommands from './commands/uniteCommands.mjs'; import SelectionTool from './tools/SelectionTool/SelectionTool.mjs'; import Erase from './commands/Erase.mjs'; import BackgroundComponent, { BackgroundType } from './components/BackgroundComponent.mjs'; import sendPenEvent from './testing/sendPenEvent.mjs'; import KeyboardShortcutManager from './shortcuts/KeyboardShortcutManager.mjs'; import EdgeToolbar from './toolbar/EdgeToolbar.mjs'; import StrokeKeyboardControl from './tools/InputFilter/StrokeKeyboardControl.mjs'; import guessKeyCodeFromKey from './util/guessKeyCodeFromKey.mjs'; import makeAboutDialog from './dialogs/makeAboutDialog.mjs'; import version from './version.mjs'; import { editorImageToSVGSync, editorImageToSVGAsync } from './image/export/editorImageToSVG.mjs'; import ReactiveValue, { MutableReactiveValue } from './util/ReactiveValue.mjs'; import listenForKeyboardEventsFrom from './util/listenForKeyboardEventsFrom.mjs'; import mitLicenseAttribution from './util/mitLicenseAttribution.mjs'; import ClipboardHandler from './util/ClipboardHandler.mjs'; import ContextMenuRecognizer from './tools/InputFilter/ContextMenuRecognizer.mjs'; /** * 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). */ export 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 = { ...getLocalizationTable(), ...settings.localization, }; // Fill default settings. this.settings = { wheelEventsEnabled: settings.wheelEventsEnabled ?? true, renderingMode: settings.renderingMode ?? RenderingMode.CanvasRenderer, localization: this.localization, minZoom: settings.minZoom ?? 2e-10, maxZoom: settings.maxZoom ?? 1e12, keyboardShortcutOverrides: settings.keyboardShortcutOverrides ?? {}, iconProvider: settings.iconProvider ?? new IconProvider(), 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 = MutableReactiveValue.fromInitialValue(false); this.icons = this.settings.iconProvider; this.shortcuts = new KeyboardShortcutManager(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(); this.viewport = new Viewport((oldTransform, newTransform) => { this.notifier.dispatch(EditorEventType.ViewportChanged, { kind: EditorEventType.ViewportChanged, newTransform, oldTransform, }); }); this.display = new Display(this, this.settings.renderingMode, this.renderingRegion); this.image = new EditorImage(); this.history = new UndoRedoHistory(this, this.announceRedoCallback, this.announceUndoCallback); this.toolController = new ToolController(this, this.localization); // TODO: Make this pipeline configurable (e.g. allow users to add global input stabilization) this.toolController.addInputMapper(StrokeKeyboardControl.fromEditor(this)); this.toolController.addInputMapper(new ContextMenuRecognizer()); parent.appendChild(this.container); this.viewport.updateScreenSize(Vec2.of(this.display.width, this.display.height)); this.registerListeners(); this.queueRerender(); this.hideLoadingWarning(); // Enforce zoom limits. this.notifier.on(EditorEventType.ViewportChanged, (evt) => { if (evt.kind !== EditorEventType.ViewportChanged) return; const getZoom = (mat) => mat.transformVec3(Vec2.unitX).length(); const zoom = getZoom(evt.newTransform); if (zoom > this.settings.maxZoom || zoom < this.settings.minZoom) { const oldZoom = getZoom(evt.oldTransform); let resetTransform = 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 = 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 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(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(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(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 = 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 = Vec3.of(0, 0, event.deltaY); } // Ensure that `pos` is relative to `this.renderingRegion` const bbox = this.getOutputBBoxInDOM(); const pos = Vec2.of(event.clientX, event.clientY).minus(bbox.topLeft); if (this.toolController.dispatchInputEvent({ kind: 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.ofEvent(evt, true, this.viewport, eventsRelativeTo); this.pointers[pointer.id] = pointer; this.setPointerCapture(eventTarget, pointer.id); const event = { kind: InputEvtType.PointerDownEvt, current: pointer, allPointers: this.getPointerList(), }; this.toolController.dispatchInputEvent(event); return true; } else if (eventType === 'pointermove') { const pointer = Pointer.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: InputEvtType.PointerMoveEvt, current: pointer, allPointers: this.getPointerList(), })) { evt.preventDefault(); } } return true; } else if (eventType === 'pointercancel' || eventType === 'pointerup') { const pointer = Pointer.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: 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(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 = 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 = 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 = 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) { listenForKeyboardEventsFrom(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(EditorEventType.ReadOnlyModeToggled, { kind: 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: 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 Mat33) { canvasTransform = ReactiveValue.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(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(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(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: guessKeyCodeFromKey(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) { sendPenEvent(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.roundScaleRatio(scaleRatio); const transfm = Mat33.translation(visibleRect.center.minus(bbox.center)).rightMul(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.addComponent(component)); commands.push(component.transformBy(transfm)); } const applyChunkSize = 100; await this.dispatch(uniteCommands(commands, { applyChunkSize, description: actionDescription }), true); if (selectComponents) { for (const selectionTool of this.toolController.getMatchingTools(SelectionTool)) { 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.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 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 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 untilNextAnimationFrame(); } 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(originalBackgrounds); await loader.start(async (component) => { await this.dispatchNoAnnounce(EditorImage.addComponent(component)); }, (countProcessed, totalToProcess) => { if (countProcessed % 500 === 0) { this.showLoadingWarning(countProcessed / totalToProcess); this.rerender(); return untilNextAnimationFrame(); } 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 component if there are multiple. for (const component of this.image.getBackgroundComponents()) { if (component instanceof BackgroundComponent) { background = component; } } return background; } /** * This is a convenience method for adding or updating the {@link BackgroundComponent} * and {@link EditorImage.setAutoresizeEnabled} for the current image. * * If there are multiple {@link BackgroundComponent}s in the image, this only modifies * the topmost such element. * * **Example**: * ```ts,runnable * import { Editor, Color4, BackgroundComponentBackgroundType } from 'js-draw'; * const editor = new Editor(document.body); * editor.dispatch(editor.setBackgroundStyle({ * color: Color4.orange, * type: BackgroundComponentBackgroundType.Grid, * autoresize: true, * })); * ``` * * To change the background size, see {@link EditorImage.setImportExportRect}. */ setBackgroundStyle(style) { const originalBackground = this.getTopmostBackgroundComponent(); const commands = []; if (originalBackground) { commands.push(new Erase([originalBackground])); } const originalType = originalBackground?.getBackgroundType?.() ?? BackgroundType.None; const originalColor = originalBackground?.getStyle?.().color ?? Color4.transparent; const originalFillsScreen = this.image.getAutoresizeEnabled(); const defaultType = style.color && originalType === BackgroundType.None ? BackgroundType.SolidColor : originalType; const backgroundType = style.type ?? defaultType; const backgroundColor = style.color ?? originalColor; const fillsScreen = style.autoresize ?? originalFillsScreen; if (backgroundType !== BackgroundType.None) { const newBackground = new BackgroundComponent(backgroundType, backgroundColor); commands.push(EditorImage.addComponent(newBackground)); } if (fillsScreen !== originalFillsScreen) { commands.push(this.image.setAutoresizeEnabled(fillsScreen)); // Avoid 0x0 backgrounds if (!fillsScreen && this.image.getImportExportRect().maxDimension === 0) { commands.push(this.image.setImportExportRect(this.image.getImportExportRect().resizedTo(Vec2.of(500, 500)))); } } return uniteCommands(commands); } /** * Set the backgroun