UNPKG

js-draw

Version:

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

524 lines (523 loc) 24 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SelectionMode = exports.cssPrefix = void 0; const math_1 = require("@js-draw/math"); const types_1 = require("../../types"); const Viewport_1 = __importDefault(require("../../Viewport")); const BaseTool_1 = __importDefault(require("../BaseTool")); const CanvasRenderer_1 = __importDefault(require("../../rendering/renderers/CanvasRenderer")); const SVGRenderer_1 = __importDefault(require("../../rendering/renderers/SVGRenderer")); const Selection_1 = __importDefault(require("./Selection")); const TextComponent_1 = __importDefault(require("../../components/TextComponent")); const keybindings_1 = require("../keybindings"); const ToPointerAutoscroller_1 = __importDefault(require("./ToPointerAutoscroller")); const showSelectionContextMenu_1 = __importDefault(require("./util/showSelectionContextMenu")); const ReactiveValue_1 = require("../../util/ReactiveValue"); const types_2 = require("./types"); Object.defineProperty(exports, "SelectionMode", { enumerable: true, get: function () { return types_2.SelectionMode; } }); const LassoSelectionBuilder_1 = __importDefault(require("./SelectionBuilders/LassoSelectionBuilder")); const RectSelectionBuilder_1 = __importDefault(require("./SelectionBuilders/RectSelectionBuilder")); exports.cssPrefix = 'selection-tool-'; // Allows users to select/transform portions of the `EditorImage`. // With respect to `extend`ing, `SelectionTool` is not stable. class SelectionTool extends BaseTool_1.default { constructor(editor, description) { super(editor.notifier, description); this.editor = editor; // True if clearing and recreating the selectionBox has been deferred. This is used to prevent the selection // from vanishing on pointerdown events that are intended to form other gestures (e.g. long press) that would // ultimately restore the selection. this.removeSelectionScheduled = false; this.startPoint = null; // canvas position this.expandingSelectionBox = false; this.shiftKeyPressed = false; this.snapToGrid = false; this.lastPointer = null; this.showContextMenu = async (canvasAnchor, preferSelectionMenu = true) => { await (0, showSelectionContextMenu_1.default)(this.selectionBox, this.editor, canvasAnchor, preferSelectionMenu, () => this.clearSelection()); }; this.selectionBoxHandlingEvt = false; this.lastSelectedObjects = []; // Whether the last keypress corresponded to an action that didn't transform the // selection (and thus does not need to be finalized on onKeyUp). this.hasUnfinalizedTransformFromKeyPress = false; this.modeValue = ReactiveValue_1.MutableReactiveValue.fromInitialValue(types_2.SelectionMode.Rectangle); this.modeValue.onUpdate(() => { this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, { kind: types_1.EditorEventType.ToolUpdated, tool: this, }); }); this.autoscroller = new ToPointerAutoscroller_1.default(editor.viewport, (scrollBy) => { editor.dispatch(Viewport_1.default.transformBy(math_1.Mat33.translation(scrollBy)), false); // Update the selection box/content to match the new viewport. if (this.lastPointer) { // The viewport has changed -- ensure that the screen and canvas positions // of the pointer are both correct const updatedPointer = this.lastPointer.withScreenPosition(this.lastPointer.screenPos, editor.viewport); this.onMainPointerUpdated(updatedPointer); } }); this.handleOverlay = document.createElement('div'); editor.createHTMLOverlay(this.handleOverlay); this.handleOverlay.style.display = 'none'; this.handleOverlay.classList.add('handleOverlay'); editor.notifier.on(types_1.EditorEventType.ViewportChanged, (_data) => { // The selection box could be using the wet ink display if its transformation // hasn't been finalized yet. Clear before updating the UI. this.editor.clearWetInk(); // If not currently selecting, ensure that the selection box // is large enough. if (!this.expandingSelectionBox) { this.selectionBox?.padRegion(); } this.selectionBox?.updateUI(); }); this.editor.handleKeyEventsFrom(this.handleOverlay); this.editor.handlePointerEventsFrom(this.handleOverlay); } getSelectionColor() { const colorString = getComputedStyle(this.handleOverlay).getPropertyValue('--selection-background-color'); return math_1.Color4.fromString(colorString).withAlpha(0.5); } makeSelectionBox(selectedObjects) { this.prevSelectionBox = this.selectionBox; this.selectionBox = new Selection_1.default(selectedObjects, this.editor, this.showContextMenu); if (!this.expandingSelectionBox) { // Remove any previous selection rects this.prevSelectionBox?.cancelSelection(); } this.selectionBox.addTo(this.handleOverlay); } onContextMenu(event) { const canShowSelectionMenu = this.selectionBox ?.getScreenRegion() ?.containsPoint(event.screenPos); void this.showContextMenu(event.canvasPos, canShowSelectionMenu); return true; } onPointerDown({ allPointers, current }) { const snapToGrid = this.snapToGrid; if (snapToGrid) { current = current.snappedToGrid(this.editor.viewport); } // Don't rely on .isPrimary -- it's buggy in Firefox. See https://github.com/personalizedrefrigerator/js-draw/issues/71 if (allPointers.length === 1) { this.startPoint = current.canvasPos; let transforming = false; if (this.selectionBox) { if (snapToGrid) { this.selectionBox.snapSelectedObjectsToGrid(); } const dragStartResult = this.selectionBox.onDragStart(current); if (dragStartResult) { transforming = true; this.selectionBoxHandlingEvt = true; this.expandingSelectionBox = false; } } if (!transforming) { // Shift key: Combine the new and old selection boxes at the end of the gesture. this.expandingSelectionBox = this.shiftKeyPressed; this.removeSelectionScheduled = !this.expandingSelectionBox; if (this.modeValue.get() === types_2.SelectionMode.Lasso) { this.selectionBuilder = new LassoSelectionBuilder_1.default(current.canvasPos, this.editor.viewport); } else { this.selectionBuilder = new RectSelectionBuilder_1.default(current.canvasPos); } } else { // Only autoscroll if we're transforming an existing selection this.autoscroller.start(); } return true; } return false; } onPointerMove(event) { this.onMainPointerUpdated(event.current); } onMainPointerUpdated(currentPointer) { this.lastPointer = currentPointer; if (this.removeSelectionScheduled) { this.removeSelectionScheduled = false; this.handleOverlay.replaceChildren(); this.prevSelectionBox = this.selectionBox; this.selectionBox = null; } this.autoscroller.onPointerMove(currentPointer.screenPos); if (!this.expandingSelectionBox && this.shiftKeyPressed && this.startPoint) { const screenPos = this.editor.viewport.canvasToScreen(this.startPoint); currentPointer = currentPointer.lockedToXYAxesScreen(screenPos, this.editor.viewport); } if (this.snapToGrid) { currentPointer = currentPointer.snappedToGrid(this.editor.viewport); } if (this.selectionBoxHandlingEvt) { this.selectionBox?.onDragUpdate(currentPointer); } else { this.selectionBuilder?.onPointerMove(currentPointer.canvasPos); this.editor.clearWetInk(); this.selectionBuilder?.render(this.editor.display.getWetInkRenderer(), this.getSelectionColor()); } } onPointerUp(event) { this.onMainPointerUpdated(event.current); this.autoscroller.stop(); if (this.selectionBoxHandlingEvt) { this.selectionBox?.onDragEnd(); } else if (this.selectionBuilder) { const newSelection = this.selectionBuilder.resolve(this.editor.image, this.editor.viewport); this.selectionBuilder = null; this.editor.clearWetInk(); if (this.expandingSelectionBox && this.selectionBox) { this.setSelection([...this.selectionBox.getSelectedObjects(), ...newSelection]); } else { this.setSelection(newSelection); } } this.expandingSelectionBox = false; this.removeSelectionScheduled = false; this.selectionBoxHandlingEvt = false; this.lastPointer = null; } onGestureCancel() { if (this.selectionBuilder) { this.selectionBuilder = null; this.editor.clearWetInk(); } this.autoscroller.stop(); if (this.selectionBoxHandlingEvt) { this.selectionBox?.onDragCancel(); } else if (!this.removeSelectionScheduled) { // Revert to the previous selection, if any. this.selectionBox?.cancelSelection(); this.selectionBox = this.prevSelectionBox; this.selectionBox?.addTo(this.handleOverlay); this.selectionBox?.recomputeRegion(); this.prevSelectionBox = null; } this.removeSelectionScheduled = false; this.expandingSelectionBox = false; this.lastPointer = null; this.selectionBoxHandlingEvt = false; } onSelectionUpdated() { const selectedItemCount = this.selectionBox?.getSelectedItemCount() ?? 0; const selectedObjects = this.selectionBox?.getSelectedObjects() ?? []; const hasDifferentSelection = this.lastSelectedObjects.length !== selectedItemCount || selectedObjects.some((obj, i) => this.lastSelectedObjects[i] !== obj); if (hasDifferentSelection) { this.lastSelectedObjects = selectedObjects; // Note that the selection has changed this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, { kind: types_1.EditorEventType.ToolUpdated, tool: this, }); // Only fire the SelectionUpdated event if the selection really updated. this.editor.notifier.dispatch(types_1.EditorEventType.SelectionUpdated, { kind: types_1.EditorEventType.SelectionUpdated, selectedComponents: selectedObjects, tool: this, }); if (selectedItemCount > 0) { this.editor.announceForAccessibility(this.editor.localization.selectedElements(selectedItemCount)); this.zoomToSelection(); } } if (selectedItemCount === 0 && this.selectionBox) { this.selectionBox.cancelSelection(); this.prevSelectionBox = this.selectionBox; this.selectionBox = null; } } zoomToSelection() { if (this.selectionBox) { const selectionRect = this.selectionBox.region; this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false); } } onKeyPress(event) { const shortcucts = this.editor.shortcuts; if (shortcucts.matchesShortcut(keybindings_1.snapToGridKeyboardShortcutId, event)) { this.snapToGrid = true; return true; } if (this.selectionBox && (shortcucts.matchesShortcut(keybindings_1.duplicateSelectionShortcut, event) || shortcucts.matchesShortcut(keybindings_1.sendToBackSelectionShortcut, event))) { // Handle duplication on key up — we don't want to accidentally duplicate // many times. return true; } else if (shortcucts.matchesShortcut(keybindings_1.selectAllKeyboardShortcut, event)) { this.setSelection(this.editor.image.getAllComponents()); return true; } else if (event.ctrlKey) { // Don't transform the selection with, for example, ctrl+i. // Pass it to another tool, if apliccable. return false; } else if (event.shiftKey || event.key === 'Shift') { this.shiftKeyPressed = true; if (event.key === 'Shift') { return true; } } let rotationSteps = 0; let xTranslateSteps = 0; let yTranslateSteps = 0; let xScaleSteps = 0; let yScaleSteps = 0; if (shortcucts.matchesShortcut(keybindings_1.translateLeftSelectionShortcutId, event)) { xTranslateSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.translateRightSelectionShortcutId, event)) { xTranslateSteps += 1; } else if (shortcucts.matchesShortcut(keybindings_1.translateUpSelectionShortcutId, event)) { yTranslateSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.translateDownSelectionShortcutId, event)) { yTranslateSteps += 1; } else if (shortcucts.matchesShortcut(keybindings_1.rotateClockwiseSelectionShortcutId, event)) { rotationSteps += 1; } else if (shortcucts.matchesShortcut(keybindings_1.rotateCounterClockwiseSelectionShortcutId, event)) { rotationSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.shrinkXSelectionShortcutId, event)) { xScaleSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.stretchXSelectionShortcutId, event)) { xScaleSteps += 1; } else if (shortcucts.matchesShortcut(keybindings_1.shrinkYSelectionShortcutId, event)) { yScaleSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.stretchYSelectionShortcutId, event)) { yScaleSteps += 1; } else if (shortcucts.matchesShortcut(keybindings_1.shrinkXYSelectionShortcutId, event)) { xScaleSteps -= 1; yScaleSteps -= 1; } else if (shortcucts.matchesShortcut(keybindings_1.stretchXYSelectionShortcutId, event)) { xScaleSteps += 1; yScaleSteps += 1; } let handled = xTranslateSteps !== 0 || yTranslateSteps !== 0 || rotationSteps !== 0 || xScaleSteps !== 0 || yScaleSteps !== 0; if (!this.selectionBox) { handled = false; } else if (handled) { const translateStepSize = 10 * this.editor.viewport.getSizeOfPixelOnCanvas(); const rotateStepSize = Math.PI / 8; const scaleStepSize = 5 / 4; const region = this.selectionBox.region; const scaleFactor = math_1.Vec2.of(scaleStepSize ** xScaleSteps, scaleStepSize ** yScaleSteps); const rotationMat = math_1.Mat33.zRotation(rotationSteps * rotateStepSize); const roundedRotationMatrix = rotationMat.mapEntries((component) => Viewport_1.default.roundScaleRatio(component)); const regionCenter = this.editor.viewport.roundPoint(region.center); const transform = math_1.Mat33.scaling2D(scaleFactor, this.editor.viewport.roundPoint(region.topLeft)) .rightMul(math_1.Mat33.translation(regionCenter) .rightMul(roundedRotationMatrix) .rightMul(math_1.Mat33.translation(regionCenter.times(-1)))) .rightMul(math_1.Mat33.translation(this.editor.viewport.roundPoint(math_1.Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize)))); const oldTransform = this.selectionBox.getTransform(); this.selectionBox.setTransform(oldTransform.rightMul(transform)); this.selectionBox.scrollTo(); // The transformation needs to be finalized at some point (on key up) this.hasUnfinalizedTransformFromKeyPress = true; } if (this.selectionBox && !handled && (event.key === 'Delete' || event.key === 'Backspace')) { this.editor.dispatch(this.selectionBox.deleteSelectedObjects()); this.clearSelection(); handled = true; } return handled; } onKeyUp(evt) { const shortcucts = this.editor.shortcuts; if (shortcucts.matchesShortcut(keybindings_1.snapToGridKeyboardShortcutId, evt)) { this.snapToGrid = false; return true; } if (shortcucts.matchesShortcut(keybindings_1.selectAllKeyboardShortcut, evt)) { // Selected all in onKeyDown. Don't finalizeTransform. return true; } if (this.selectionBox && shortcucts.matchesShortcut(keybindings_1.duplicateSelectionShortcut, evt)) { // Finalize duplicating the selection this.selectionBox.duplicateSelectedObjects().then((command) => { this.editor.dispatch(command); }); return true; } if (this.selectionBox && shortcucts.matchesShortcut(keybindings_1.sendToBackSelectionShortcut, evt)) { const sendToBackCommand = this.selectionBox.sendToBack(); if (sendToBackCommand) { this.editor.dispatch(sendToBackCommand); } return true; } // Here, we check if shiftKey === false because, as of this writing, // evt.shiftKey is an optional property. Being falsey could just mean // that it wasn't set. if (evt.shiftKey === false) { this.shiftKeyPressed = false; // Don't return immediately -- event may be otherwise handled } // Also check for key === 'Shift' (for the case where shiftKey is undefined) if (evt.key === 'Shift') { this.shiftKeyPressed = false; return true; } // If we don't need to finalize the transform if (!this.hasUnfinalizedTransformFromKeyPress) { return true; } if (this.selectionBox) { this.selectionBox.finalizeTransform(); this.hasUnfinalizedTransformFromKeyPress = false; return true; } return false; } onCopy(event) { if (!this.selectionBox) { return false; } const selectedElems = this.selectionBox.getSelectedObjects(); const bbox = this.selectionBox.region; if (selectedElems.length === 0) { return false; } const exportViewport = new Viewport_1.default(() => { }); const selectionScreenSize = this.selectionBox .getScreenRegion() .size.times(this.editor.display.getDevicePixelRatio()); // Update the viewport to have screen size roughly equal to the size of the selection box let scaleFactor = selectionScreenSize.maximumEntryMagnitude() / (bbox.size.maximumEntryMagnitude() || 1); // Round to a nearby power of two scaleFactor = Math.pow(2, Math.ceil(Math.log2(scaleFactor))); exportViewport.updateScreenSize(bbox.size.times(scaleFactor)); exportViewport.resetTransform(math_1.Mat33.scaling2D(scaleFactor) // Move the selection onto the screen .rightMul(math_1.Mat33.translation(bbox.topLeft.times(-1)))); const { element: svgExportElem, renderer: svgRenderer } = SVGRenderer_1.default.fromViewport(exportViewport, { sanitize: true, useViewBoxForPositioning: true }); const { element: canvas, renderer: canvasRenderer } = CanvasRenderer_1.default.fromViewport(exportViewport, { maxCanvasDimen: 4096 }); const text = []; for (const elem of selectedElems) { elem.render(svgRenderer); elem.render(canvasRenderer); if (elem instanceof TextComponent_1.default) { text.push(elem.getText()); } } event.setData('image/svg+xml', svgExportElem.outerHTML); event.setData('text/html', svgExportElem.outerHTML); event.setData('image/png', new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error('Failed to convert canvas to blob.')); } }, 'image/png'); })); if (text.length > 0) { event.setData('text/plain', text.join('\n')); } return true; } setEnabled(enabled) { const wasEnabled = this.isEnabled(); super.setEnabled(enabled); if (wasEnabled === enabled) { return; } // Clear the selection this.selectionBox?.cancelSelection(); this.onSelectionUpdated(); this.handleOverlay.replaceChildren(); this.selectionBox = null; this.shiftKeyPressed = false; this.snapToGrid = false; this.handleOverlay.style.display = enabled ? 'block' : 'none'; if (enabled) { this.handleOverlay.tabIndex = 0; this.handleOverlay.role = 'group'; this.handleOverlay.ariaLabel = this.editor.localization.selectionToolKeyboardShortcuts; } else { this.handleOverlay.tabIndex = -1; } } // Get the object responsible for displaying this' selection. // @internal getSelection() { return this.selectionBox; } /** @returns true if the selection is currently being created by the user. */ isSelecting() { return !!this.selectionBuilder; } getSelectedObjects() { return this.selectionBox?.getSelectedObjects() ?? []; } // Select the given `objects`. Any non-selectable objects in `objects` are ignored. setSelection(objects) { // Only select selectable objects. objects = objects.filter((obj) => obj.isSelectable()); // Sort by z-index objects.sort((a, b) => a.getZIndex() - b.getZIndex()); // Remove duplicates objects = objects.filter((current, idx) => { if (idx > 0) { return current !== objects[idx - 1]; } return true; }); let bbox = null; for (const object of objects) { if (bbox) { bbox = bbox.union(object.getBBox()); } else { bbox = object.getBBox(); } } this.clearSelectionNoUpdateEvent(); if (bbox) { this.makeSelectionBox(objects); } this.onSelectionUpdated(); } // Equivalent to .clearSelection, but does not dispatch an update event clearSelectionNoUpdateEvent() { this.handleOverlay.replaceChildren(); this.prevSelectionBox = this.selectionBox; this.selectionBox = null; } clearSelection() { this.clearSelectionNoUpdateEvent(); this.onSelectionUpdated(); } } exports.default = SelectionTool;