UNPKG

js-draw

Version:

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

646 lines (645 loc) 28.9 kB
"use strict"; /** * @internal * @packageDocumentation */ 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 }; }; var _a; Object.defineProperty(exports, "__esModule", { value: true }); const SerializableCommand_1 = __importDefault(require("../../commands/SerializableCommand")); const math_1 = require("@js-draw/math"); const SelectionHandle_1 = __importStar(require("./SelectionHandle")); const SelectionTool_1 = require("./SelectionTool"); const Viewport_1 = __importDefault(require("../../Viewport")); const Erase_1 = __importDefault(require("../../commands/Erase")); const Duplicate_1 = __importDefault(require("../../commands/Duplicate")); const TransformMode_1 = require("./TransformMode"); const types_1 = require("./types"); const EditorImage_1 = __importDefault(require("../../image/EditorImage")); const uniteCommands_1 = __importDefault(require("../../commands/uniteCommands")); const SelectionMenuShortcut_1 = __importDefault(require("./SelectionMenuShortcut")); const assertions_1 = require("../../util/assertions"); const describeTransformation_1 = __importDefault(require("../../util/describeTransformation")); const updateChunkSize = 100; const maxPreviewElemCount = 500; // @internal class Selection { constructor(selectedElems, editor, showContextMenu) { this.editor = editor; // The last-computed bounding box of selected content // @see getTightBoundingBox this.selectionTightBoundingBox = null; this.transform = math_1.Mat33.identity; // invariant: sorted by increasing z-index this.selectedElems = []; this.hasParent = true; // Maps IDs to whether we removed the component from the image this.removedFromImage = {}; this.activeHandle = null; this.backgroundDragging = false; this.selectionDuplicatedAnimationTimeout = null; selectedElems = [...selectedElems]; this.selectedElems = selectedElems; this.originalRegion = math_1.Rect2.empty; this.transformers = { drag: new TransformMode_1.DragTransformer(editor, this), resize: new TransformMode_1.ResizeTransformer(editor, this), rotate: new TransformMode_1.RotateTransformer(editor, this), }; // We need two containers for some CSS to apply (the outer container // needs zero height, the inner needs to prevent the selection background // from being visible outside of the editor). this.outerContainer = document.createElement('div'); this.outerContainer.classList.add(`${SelectionTool_1.cssPrefix}selection-outer-container`); this.innerContainer = document.createElement('div'); this.innerContainer.classList.add(`${SelectionTool_1.cssPrefix}selection-inner-container`); this.backgroundElem = document.createElement('div'); this.backgroundElem.classList.add(`${SelectionTool_1.cssPrefix}selection-background`); this.innerContainer.appendChild(this.backgroundElem); this.outerContainer.appendChild(this.innerContainer); const makeResizeHandle = (mode, side) => { const modeToAction = { [types_1.ResizeMode.Both]: SelectionHandle_1.HandleAction.ResizeXY, [types_1.ResizeMode.HorizontalOnly]: SelectionHandle_1.HandleAction.ResizeX, [types_1.ResizeMode.VerticalOnly]: SelectionHandle_1.HandleAction.ResizeY, }; return new SelectionHandle_1.default({ action: modeToAction[mode], side, }, this, this.editor.viewport, (startPoint) => this.transformers.resize.onDragStart(startPoint, mode), (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint), () => this.transformers.resize.onDragEnd()); }; const resizeHorizontalHandles = [ makeResizeHandle(types_1.ResizeMode.HorizontalOnly, math_1.Vec2.of(0, 0.5)), makeResizeHandle(types_1.ResizeMode.HorizontalOnly, math_1.Vec2.of(1, 0.5)), ]; const resizeVerticalHandle = makeResizeHandle(types_1.ResizeMode.VerticalOnly, math_1.Vec2.of(0.5, 1)); const resizeBothHandle = makeResizeHandle(types_1.ResizeMode.Both, math_1.Vec2.of(1, 1)); const rotationHandle = new SelectionHandle_1.default({ action: SelectionHandle_1.HandleAction.Rotate, side: math_1.Vec2.of(0.5, 0), icon: this.editor.icons.makeRotateIcon(), }, this, this.editor.viewport, (startPoint) => this.transformers.rotate.onDragStart(startPoint), (currentPoint) => this.transformers.rotate.onDragUpdate(currentPoint), () => this.transformers.rotate.onDragEnd()); const menuToggleButton = new SelectionMenuShortcut_1.default(this, this.editor.viewport, this.editor.icons.makeOverflowIcon(), showContextMenu, this.editor.localization); this.childwidgets = [ menuToggleButton, resizeBothHandle, ...resizeHorizontalHandles, resizeVerticalHandle, rotationHandle, ]; for (const widget of this.childwidgets) { widget.addTo(this.backgroundElem); } this.recomputeRegion(); this.updateUI(); } // @internal Intended for unit tests getBackgroundElem() { return this.backgroundElem; } getTransform() { return this.transform; } get preTransformRegion() { return this.originalRegion; } // The **canvas** region. get region() { // TODO: This currently assumes that the region rotates about its center. // This may not be true. const rotationMatrix = math_1.Mat33.zRotation(this.regionRotation, this.originalRegion.center); const scaleAndTranslateMat = this.transform.rightMul(rotationMatrix.inverse()); return this.originalRegion.transformedBoundingBox(scaleAndTranslateMat); } /** * Computes and returns the bounding box of the selection without * any additional padding. Computes directly from the elements that are selected. * @internal */ computeTightBoundingBox() { const bbox = this.selectedElems.reduce((accumulator, elem) => { return (accumulator ?? elem.getBBox()).union(elem.getBBox()); }, null); return bbox ?? math_1.Rect2.empty; } get regionRotation() { return this.transform.transformVec3(math_1.Vec2.unitX).angle(); } get preTransformedScreenRegion() { const toScreen = (vec) => this.editor.viewport.canvasToScreen(vec); return math_1.Rect2.fromCorners(toScreen(this.preTransformRegion.topLeft), toScreen(this.preTransformRegion.bottomRight)); } get preTransformedScreenRegionRotation() { return this.editor.viewport.getRotationAngle(); } getScreenRegion() { const toScreen = this.editor.viewport.canvasToScreenTransform; const scaleFactor = this.editor.viewport.getScaleFactor(); const screenCenter = toScreen.transformVec2(this.region.center); return new math_1.Rect2(screenCenter.x, screenCenter.y, scaleFactor * this.region.width, scaleFactor * this.region.height).translatedBy(this.region.size.times(-scaleFactor / 2)); } get screenRegionRotation() { return this.regionRotation + this.editor.viewport.getRotationAngle(); } // Applies, previews, but doesn't finalize the given transformation. setTransform(transform, preview = true) { this.transform = transform; if (preview && this.hasParent) { this.previewTransformCmds(); } } getDeltaZIndexToMoveSelectionToTop() { if (this.selectedElems.length === 0) { return 0; } const selectedBottommostZIndex = this.selectedElems[0].getZIndex(); const visibleObjects = this.editor.image.getComponentsIntersecting(this.region); const topMostVisibleZIndex = visibleObjects[visibleObjects.length - 1]?.getZIndex() ?? selectedBottommostZIndex; const deltaZIndex = topMostVisibleZIndex + 1 - selectedBottommostZIndex; return deltaZIndex; } // Applies the current transformation to the selection finalizeTransform() { const fullTransform = this.transform; const selectedElems = this.selectedElems; // Reset for the next drag this.originalRegion = this.originalRegion.transformedBoundingBox(this.transform); this.transform = math_1.Mat33.identity; this.scrollTo(); let transformPromise = undefined; // Make the commands undo-able. // Don't check for non-empty transforms because this breaks changing the // z-index of the just-transformed commands. if (this.selectedElems.length > 0) { const deltaZIndex = this.getDeltaZIndexToMoveSelectionToTop(); transformPromise = this.editor.dispatch(new _a.ApplyTransformationCommand(this, selectedElems, this.originalRegion.center, fullTransform, deltaZIndex)); } return transformPromise; } /** Sends all selected elements to the bottom of the visible image. */ sendToBack() { const visibleObjects = this.editor.image.getComponentsIntersecting(this.editor.viewport.visibleRect); // VisibleObjects and selectedElems should both be sorted by z-index const lowestVisibleZIndex = visibleObjects[0]?.getZIndex() ?? 0; const highestSelectedZIndex = this.selectedElems[this.selectedElems.length - 1]?.getZIndex() ?? 0; const targetHighestZIndex = lowestVisibleZIndex - 1; const deltaZIndex = targetHighestZIndex - highestSelectedZIndex; if (deltaZIndex !== 0) { const commands = this.selectedElems.map((elem) => { return elem.setZIndex(elem.getZIndex() + deltaZIndex); }); return (0, uniteCommands_1.default)(commands, updateChunkSize); } return null; } // Preview the effects of the current transformation on the selection previewTransformCmds() { if (this.selectedElems.length === 0) { return; } // Don't render what we're moving if it's likely to be slow. if (this.selectedElems.length > maxPreviewElemCount) { this.updateUI(); return; } const wetInkRenderer = this.editor.display.getWetInkRenderer(); wetInkRenderer.clear(); wetInkRenderer.pushTransform(this.transform); const viewportVisibleRect = this.editor.viewport.visibleRect.union(this.region); const visibleRect = viewportVisibleRect.transformedBoundingBox(this.transform.inverse()); for (const elem of this.selectedElems) { elem.render(wetInkRenderer, visibleRect); } wetInkRenderer.popTransform(); this.updateUI(); } // Recompute this' region from the selected elements. // Returns false if the selection is empty. recomputeRegion() { const newRegion = this.computeTightBoundingBox(); this.selectionTightBoundingBox = newRegion; if (!newRegion) { this.cancelSelection(); return false; } this.originalRegion = newRegion; this.padRegion(); return true; } // Applies padding to the current region if it is too small. // @internal padRegion() { const sourceRegion = this.selectionTightBoundingBox ?? this.originalRegion; const minSize = this.getMinCanvasSize(); if (sourceRegion.w < minSize || sourceRegion.h < minSize) { // Add padding const padding = minSize / 2; this.originalRegion = math_1.Rect2.bboxOf(sourceRegion.corners, padding); this.updateUI(); } } getMinCanvasSize() { const canvasHandleSize = SelectionHandle_1.handleSize / this.editor.viewport.getScaleFactor(); return canvasHandleSize * 2; } getSelectedItemCount() { return this.selectedElems.length; } // @internal updateUI() { // Don't update old selections. if (!this.hasParent) { return; } const screenRegion = this.getScreenRegion(); // marginLeft, marginTop: Display relative to the top left of the selection overlay. // left, top don't work for this. this.backgroundElem.style.marginLeft = `${screenRegion.topLeft.x}px`; this.backgroundElem.style.marginTop = `${screenRegion.topLeft.y}px`; this.backgroundElem.style.width = `${screenRegion.width}px`; this.backgroundElem.style.height = `${screenRegion.height}px`; const rotationDeg = (this.screenRegionRotation * 180) / Math.PI; this.backgroundElem.style.transform = `rotate(${rotationDeg}deg)`; this.backgroundElem.style.transformOrigin = 'center'; // If closer to perpendicular, apply different CSS const perpendicularClassName = `${SelectionTool_1.cssPrefix}rotated-near-perpendicular`; if (Math.abs(Math.sin(this.screenRegionRotation)) > 0.5) { this.innerContainer.classList.add(perpendicularClassName); } else { this.innerContainer.classList.remove(perpendicularClassName); } // Hide handles when empty if (screenRegion.width === 0 && screenRegion.height === 0) { this.innerContainer.classList.add('-empty'); } else { this.innerContainer.classList.remove('-empty'); } for (const widget of this.childwidgets) { widget.updatePosition(this.getScreenRegion()); } } // Add/remove the contents of this seleciton from the editor. // Used to prevent previewed content from looking like duplicate content // while dragging. // // Does nothing if a large number of elements are selected (and so modifying // the editor image is likely to be slow.) // // If removed from the image, selected elements are drawn as wet ink. // // [inImage] should be `true` if the selected elements should be added to the // main image, `false` if they should be removed. addRemoveSelectionFromImage(inImage) { // Don't hide elements if doing so will be slow. if (!inImage && this.selectedElems.length > maxPreviewElemCount) { return; } for (const elem of this.selectedElems) { const parent = this.editor.image.findParent(elem); if (!inImage && parent) { this.removedFromImage[elem.getId()] = true; parent.remove(); } // If we're making things visible and the selected object wasn't previously // visible, else if (!parent && this.removedFromImage[elem.getId()]) { EditorImage_1.default.addComponent(elem).apply(this.editor); this.removedFromImage[elem.getId()] = false; delete this.removedFromImage[elem.getId()]; } } // Don't await queueRerender. If we're running in a test, the re-render might never // happen. this.editor.queueRerender().then(() => { if (!inImage) { this.previewTransformCmds(); } else { // Clear renderings of any in-progress transformations const wetInkRenderer = this.editor.display.getWetInkRenderer(); wetInkRenderer.clear(); } }); } removeDeletedElemsFromSelection() { // Remove any deleted elements from the selection. this.selectedElems = this.selectedElems.filter((elem) => { const hasParent = !!this.editor.image.findParent(elem); // If we removed the element and haven't added it back yet, don't remove it // from the selection. const weRemoved = this.removedFromImage[elem.getId()]; return hasParent || weRemoved; }); } onDragStart(pointer) { // If empty, it isn't possible to drag if (this.selectedElems.length === 0) { return false; } // Clear the HTML selection (prevent HTML drag and drop being triggered by this drag) document.getSelection()?.removeAllRanges(); this.activeHandle = null; let result = false; this.backgroundDragging = false; if (this.region.containsPoint(pointer.canvasPos)) { this.backgroundDragging = true; result = true; } for (const widget of this.childwidgets) { if (widget.containsPoint(pointer.canvasPos)) { this.activeHandle = widget; this.backgroundDragging = false; result = true; } } if (result) { this.removeDeletedElemsFromSelection(); this.addRemoveSelectionFromImage(false); } if (this.activeHandle) { this.activeHandle.handleDragStart(pointer); } if (this.backgroundDragging) { this.transformers.drag.onDragStart(pointer.canvasPos); } return result; } onDragUpdate(pointer) { if (this.backgroundDragging) { this.transformers.drag.onDragUpdate(pointer.canvasPos); } if (this.activeHandle) { this.activeHandle.handleDragUpdate(pointer); } } onDragEnd() { if (this.backgroundDragging) { this.transformers.drag.onDragEnd(); } else if (this.activeHandle) { this.activeHandle.handleDragEnd(); } this.addRemoveSelectionFromImage(true); this.backgroundDragging = false; this.activeHandle = null; this.updateUI(); } onDragCancel() { this.backgroundDragging = false; this.activeHandle = null; this.setTransform(math_1.Mat33.identity); this.addRemoveSelectionFromImage(true); this.updateUI(); } // Scroll the viewport to this. Does not zoom scrollTo() { if (this.selectedElems.length === 0) { return false; } const screenSize = this.editor.viewport.getScreenRectSize(); const screenRect = new math_1.Rect2(0, 0, screenSize.x, screenSize.y); const selectionScreenRegion = this.getScreenRegion(); if (!screenRect.containsPoint(selectionScreenRegion.center)) { const targetPointScreen = selectionScreenRegion.center; const closestPointScreen = screenRect.getClosestPointOnBoundaryTo(targetPointScreen); const closestPointCanvas = this.editor.viewport.screenToCanvas(closestPointScreen); const targetPointCanvas = this.region.center; const delta = closestPointCanvas.minus(targetPointCanvas); this.editor.dispatchNoAnnounce(Viewport_1.default.transformBy(math_1.Mat33.translation(delta.times(0.5))), false); this.editor.queueRerender().then(() => { this.previewTransformCmds(); }); return true; } return false; } deleteSelectedObjects() { if (this.backgroundDragging || this.activeHandle) { this.onDragEnd(); } return new Erase_1.default(this.selectedElems); } runSelectionDuplicatedAnimation() { if (this.selectionDuplicatedAnimationTimeout) { clearTimeout(this.selectionDuplicatedAnimationTimeout); } const animationDuration = 400; // ms this.backgroundElem.style.animation = `${animationDuration}ms ease selection-duplicated-animation`; this.selectionDuplicatedAnimationTimeout = setTimeout(() => { this.backgroundElem.style.animation = ''; this.selectionDuplicatedAnimationTimeout = null; }, animationDuration); } async duplicateSelectedObjects() { const wasTransforming = this.backgroundDragging || this.activeHandle; let tmpApplyCommand = null; if (!wasTransforming) { this.runSelectionDuplicatedAnimation(); } let command; if (wasTransforming) { // Don't update the selection's focus when redoing/undoing const selectionToUpdate = null; const deltaZIndex = this.getDeltaZIndexToMoveSelectionToTop(); tmpApplyCommand = new _a.ApplyTransformationCommand(selectionToUpdate, this.selectedElems, this.region.center, this.transform, deltaZIndex); // Transform to ensure that the duplicates are in the correct location await tmpApplyCommand.apply(this.editor); // Show items again this.addRemoveSelectionFromImage(true); // With the transformation applied, create the duplicates command = (0, uniteCommands_1.default)(this.selectedElems.map((elem) => { return EditorImage_1.default.addComponent(elem.clone()); })); // Move the selected objects back to the correct location. await tmpApplyCommand?.unapply(this.editor); this.addRemoveSelectionFromImage(false); this.previewTransformCmds(); this.updateUI(); } else { command = new Duplicate_1.default(this.selectedElems); } return command; } snapSelectedObjectsToGrid() { const viewport = this.editor.viewport; // Snap the top left corner of what we have selected. const topLeftOfBBox = this.computeTightBoundingBox().topLeft; const snappedTopLeft = viewport.snapToGrid(topLeftOfBBox); const snapDelta = snappedTopLeft.minus(topLeftOfBBox); const oldTransform = this.getTransform(); this.setTransform(oldTransform.rightMul(math_1.Mat33.translation(snapDelta))); this.finalizeTransform(); } setHandlesVisible(showHandles) { if (!showHandles) { this.innerContainer.classList.add('-hide-handles'); } else { this.innerContainer.classList.remove('-hide-handles'); } } addTo(elem) { if (this.outerContainer.parentElement) { this.outerContainer.remove(); } elem.appendChild(this.outerContainer); this.hasParent = true; } setToPoint(point) { this.originalRegion = this.originalRegion.grownToPoint(point); this.selectionTightBoundingBox = null; this.updateUI(); } cancelSelection() { if (this.outerContainer.parentElement) { this.outerContainer.remove(); } this.originalRegion = math_1.Rect2.empty; this.selectionTightBoundingBox = null; this.hasParent = false; } getSelectedObjects() { return [...this.selectedElems]; } } _a = Selection; (() => { SerializableCommand_1.default.register('selection-tool-transform', (json, _editor) => { const rawTransformArray = json.transform; const rawCenterArray = json.selectionCenter ?? [0, 0]; const rawElementIds = json.elems ?? []; (0, assertions_1.assertIsNumberArray)(rawTransformArray); (0, assertions_1.assertIsNumberArray)(rawCenterArray); (0, assertions_1.assertIsStringArray)(rawElementIds); // The selection box is lost when serializing/deserializing. No need to store box rotation const fullTransform = new math_1.Mat33(...rawTransformArray); const elemIds = rawElementIds; const deltaZIndex = parseInt(json.deltaZIndex ?? 0); const center = math_1.Vec2.of(rawCenterArray[0] ?? 0, rawCenterArray[1] ?? 0); return new _a.ApplyTransformationCommand(null, elemIds, center, fullTransform, deltaZIndex); }); })(); Selection.ApplyTransformationCommand = class extends SerializableCommand_1.default { constructor(selection, // If a `string[]`, selectedElems is a list of element IDs. selectedElems, // Information used to describe the transformation selectionCenter, // Full transformation used to transform elements. fullTransform, deltaZIndex) { super('selection-tool-transform'); this.selection = selection; this.selectionCenter = selectionCenter; this.fullTransform = fullTransform; this.deltaZIndex = deltaZIndex; const isIDList = (arr) => { return typeof arr[0] === 'string'; }; // If a list of element IDs, if (isIDList(selectedElems)) { this.selectedElemIds = selectedElems; } else { this.selectedElemIds = selectedElems.map((elem) => elem.getId()); this.transformCommands = selectedElems.map((elem) => { return elem.setZIndexAndTransformBy(this.fullTransform, elem.getZIndex() + deltaZIndex); }); } } resolveToElems(editor, isUndoing) { if (this.transformCommands) { return; } this.transformCommands = this.selectedElemIds .map((id) => { const elem = editor.image.lookupElement(id); if (!elem) { // There may be valid reasons for an element lookup to fail: // For example, if the element was deleted remotely and the remote deletion // hasn't been undone. console.warn(`Unable to find element with ID, ${id}.`); return null; } let originalZIndex = elem.getZIndex(); let targetZIndex = elem.getZIndex() + this.deltaZIndex; // If the command has already been applied, the element should currently // have the target z-index. if (isUndoing) { targetZIndex = elem.getZIndex(); originalZIndex = elem.getZIndex() - this.deltaZIndex; } return elem.setZIndexAndTransformBy(this.fullTransform, targetZIndex, originalZIndex); }) .filter( // Remove all null commands (command) => command !== null); } async apply(editor) { this.resolveToElems(editor, false); this.selection?.setTransform(this.fullTransform, false); this.selection?.updateUI(); await editor.asyncApplyCommands(this.transformCommands, updateChunkSize); this.selection?.setTransform(math_1.Mat33.identity, false); this.selection?.recomputeRegion(); this.selection?.updateUI(); } async unapply(editor) { this.resolveToElems(editor, true); this.selection?.setTransform(this.fullTransform.inverse(), false); this.selection?.updateUI(); await editor.asyncUnapplyCommands(this.transformCommands, updateChunkSize, true); this.selection?.setTransform(math_1.Mat33.identity, false); this.selection?.recomputeRegion(); this.selection?.updateUI(); } serializeToJSON() { return { elems: this.selectedElemIds, transform: this.fullTransform.toArray(), deltaZIndex: this.deltaZIndex, selectionCenter: this.selectionCenter.asArray(), }; } description(_editor, localizationTable) { return localizationTable.transformedElements(this.selectedElemIds.length, (0, describeTransformation_1.default)(this.selectionCenter, this.fullTransform, false, localizationTable)); } }; exports.default = Selection;