UNPKG

js-draw

Version:

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

367 lines (366 loc) 15.6 kB
"use strict"; var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) { if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : ""; return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var _a; Object.defineProperty(exports, "__esModule", { value: true }); exports.ComponentSizingMode = void 0; const SerializableCommand_1 = __importDefault(require("../commands/SerializableCommand")); const EditorImage_1 = __importDefault(require("../image/EditorImage")); const math_1 = require("@js-draw/math"); const UnresolvedCommand_1 = __importDefault(require("../commands/UnresolvedCommand")); const describeTransformation_1 = __importDefault(require("../util/describeTransformation")); const assertions_1 = require("../util/assertions"); var ComponentSizingMode; (function (ComponentSizingMode) { /** The default. The compnent gets its size from its bounding box. */ ComponentSizingMode[ComponentSizingMode["BoundingBox"] = 0] = "BoundingBox"; /** Causes the component to fill the entire visible region of the screen */ ComponentSizingMode[ComponentSizingMode["FillScreen"] = 1] = "FillScreen"; /** * Displays the component anywhere (arbitrary location) on the * canvas. (Ignoring the bounding box). * * These components may be ignored unless a full render is done. * * Intended for compnents that need to be rendered on a full export, * but won't be visible to the user. * * For example, a metadata component. */ ComponentSizingMode[ComponentSizingMode["Anywhere"] = 2] = "Anywhere"; })(ComponentSizingMode || (exports.ComponentSizingMode = ComponentSizingMode = {})); /** * A base class for everything that can be added to an {@link EditorImage}. * * In addition to the `abstract` methods, there are a few methods that should be * overridden when creating a selectable/erasable subclass: * - {@link keyPoints}: Overriding this may improve how the component interacts with the selection tool. * - {@link withRegionErased}: Override/implement this to allow the component to be partially erased * by the partial stroke eraser. */ class AbstractComponent { constructor( // A unique identifier for the type of component componentKind, initialZIndex) { this.componentKind = componentKind; // Stores data attached by a loader. this.loadSaveData = {}; this.lastChangedTime = new Date().getTime(); if (initialZIndex !== undefined) { this.zIndex = initialZIndex; } else { this.zIndex = AbstractComponent.zIndexCounter++; } // Create a unique ID. this.id = `${new Date().getTime()}-${Math.random()}`; if (AbstractComponent.deserializationCallbacks[componentKind] === undefined) { throw new Error(`Component ${componentKind} has not been registered using AbstractComponent.registerComponent`); } } // Returns a unique ID for this element. // @see { @link EditorImage.lookupElement } getId() { return this.id; } // Store the deserialization callback (or lack of it) for [componentKind]. // If components are registered multiple times (as may be done in automated tests), // the most recent deserialization callback is used. static registerComponent(componentKind, deserialize) { this.deserializationCallbacks[componentKind] = deserialize ?? null; } /** * Attach data that can be used while exporting the component (e.g. to SVG). * * This is intended for use by an {@link ImageLoader}. */ attachLoadSaveData(key, data) { if (!this.loadSaveData[key]) { this.loadSaveData[key] = []; } this.loadSaveData[key].push(data); } /** See {@link attachLoadSaveData} */ getLoadSaveData() { return this.loadSaveData; } getZIndex() { return this.zIndex; } /** * @returns the bounding box of this. This can be a slight overestimate if doing so * significantly improves performance. */ getBBox() { return this.contentBBox; } /** * @returns the bounding box of this. Unlike `getBBox`, this should **not** be a rough estimate. */ getExactBBox() { return this.getBBox(); } /** * Returns information about how this component should be displayed * (e.g. fill the screen or get its size from {@link getBBox}). * * {@link EditorImage.queueRerenderOf} must be called to apply changes to * the output of this method if this component has already been added to an * {@link EditorImage}. */ getSizingMode() { return ComponentSizingMode.BoundingBox; } /** * **Optimization** * * Should return `true` if this component covers the entire `visibleRect` * and would prevent anything below this component from being visible. * * Should return `false` otherwise. */ occludesEverythingBelowWhenRenderedInRect(_visibleRect) { return false; } /** Called when this component is added to the given image. */ onAddToImage(_image) { } onRemoveFromImage() { } /** * @returns true if this component intersects `rect` -- it is entirely contained * within the rectangle or one of the rectangle's edges intersects this component. * * The default implementation assumes that `this.getExactBBox()` returns a tight bounding box * -- that any horiziontal/vertical line that intersects this' boounding box also * intersects a point in this component. If this is not the case, components must override * this function. */ intersectsRect(rect) { // If this component intersects the given rectangle, // it is either contained entirely within rect or intersects one of rect's edges. // If contained within, if (rect.containsRect(this.getExactBBox())) { return true; } // Otherwise check if it intersects one of the rectangle's edges. const testLines = rect.getEdges(); return testLines.some((edge) => this.intersects(edge)); } /** * Returns a selection of points within this object. Each contiguous section * of this object should have a point in the returned array. * * Subclasses should override this method if the center of the bounding box is * not contained within the object. */ keyPoints() { return [this.getBBox().center]; } // @returns true iff this component can be selected (e.g. by the selection tool.) isSelectable() { return true; } // @returns true iff this component should be added to the background, rather than the // foreground of the image. isBackground() { return false; } // @returns an approximation of the proportional time it takes to render this component. // This is intended to be a rough estimate, but, for example, a stroke with two points sould have // a renderingWeight approximately twice that of a stroke with one point. getProportionalRenderingTime() { return 1; } /** * Returns a command that, when applied, transforms this by [affineTransfm] and * updates the editor. * * The transformed component is also moved to the top (use * {@link AbstractComponent#setZIndexAndTransformBy} to avoid this behavior). */ transformBy(affineTransfm) { return new AbstractComponent.TransformElementCommand(affineTransfm, this.getId(), this); } // Returns a command that updates this component's z-index. setZIndex(newZIndex) { return new AbstractComponent.TransformElementCommand(math_1.Mat33.identity, this.getId(), this, newZIndex); } /** * Combines {@link transformBy} and {@link setZIndex} into a single command. * * @param newZIndex - The z-index this component should have after applying this command. * @param originalZIndex - @internal The z-index the component should revert to after unapplying * this command. */ setZIndexAndTransformBy(affineTransfm, newZIndex, originalZIndex) { return new AbstractComponent.TransformElementCommand(affineTransfm, this.getId(), this, newZIndex, originalZIndex); } // Returns a copy of this component. clone() { const clone = this.createClone(); for (const attachmentKey in this.loadSaveData) { for (const val of this.loadSaveData[attachmentKey]) { clone.attachLoadSaveData(attachmentKey, val); } } return clone; } /** * Creates a copy of this component with a particular `id`. * This is used internally by {@link Duplicate} when deserializing. * * @internal -- users of the library shouldn't need this. */ cloneWithId(cloneId) { const clone = this.clone(); clone.id = cloneId; return clone; } // Convert the component to an object that can be passed to // `JSON.stringify`. // // Do not rely on the output of this function to take a particular form — // this function's output can change form without a major version increase. serialize() { const data = this.serializeToJSON(); if (data === null) { throw new Error(`${this} cannot be serialized.`); } return { name: this.componentKind, zIndex: this.zIndex, id: this.id, loadSaveData: this.loadSaveData, data, }; } // Returns true if `data` is not deserializable. May return false even if [data] // is not deserializable. static isNotDeserializable(json) { if (typeof json === 'string') { json = JSON.parse(json); } if (typeof json !== 'object') { return true; } if (!this.deserializationCallbacks[json?.name]) { return true; } if (!json.data) { return true; } return false; } // Convert a string or an object produced by `JSON.parse` into an `AbstractComponent`. static deserialize(json) { if (typeof json === 'string') { json = JSON.parse(json); } if (AbstractComponent.isNotDeserializable(json)) { throw new Error(`Element with data ${json} cannot be deserialized.`); } (0, assertions_1.assertIsString)(json.id); const instance = this.deserializationCallbacks[json.name](json.data); instance.id = json.id; if (isFinite(json.zIndex)) { instance.zIndex = json.zIndex; // Ensure that new components will be added on top. AbstractComponent.zIndexCounter = Math.max(AbstractComponent.zIndexCounter, instance.zIndex + 1); } // TODO: What should we do with json.loadSaveData? // If we attach it to [instance], we create a potential security risk — loadSaveData // is often used to store unrecognised attributes so they can be preserved on output. // ...but what if we're deserializing data sent across the network? return instance; } } // Topmost z-index // TODO: Should be a property of the EditorImage. AbstractComponent.zIndexCounter = 0; AbstractComponent.deserializationCallbacks = {}; AbstractComponent.transformElementCommandId = 'transform-element'; AbstractComponent.TransformElementCommand = (_a = class extends UnresolvedCommand_1.default { // Construct a new TransformElementCommand. `component`, while optional, should // be provided if available. If not provided, it will be fetched from the editor's // document when the command is applied. constructor(affineTransfm, componentID, component, targetZIndex, origZIndex) { super(AbstractComponent.transformElementCommandId, componentID, component); this.affineTransfm = affineTransfm; this.origZIndex = origZIndex; this.targetZIndex = targetZIndex ?? AbstractComponent.zIndexCounter++; // Ensure that we keep drawing on top even after changing the z-index. if (this.targetZIndex >= AbstractComponent.zIndexCounter) { AbstractComponent.zIndexCounter = this.targetZIndex + 1; } if (component && origZIndex === undefined) { this.origZIndex = component.getZIndex(); } } resolveComponent(image) { if (this.component) { return; } super.resolveComponent(image); this.origZIndex ??= this.component.getZIndex(); } updateTransform(editor, newTransfm, targetZIndex) { if (!this.component) { throw new Error('this.component is undefined or null!'); } // Any parent should have only one direct child. const parent = editor.image.findParent(this.component); let hadParent = false; if (parent) { parent.remove(); hadParent = true; } this.component.applyTransformation(newTransfm); this.component.zIndex = targetZIndex; this.component.lastChangedTime = new Date().getTime(); // Ensure that new components are automatically drawn above the current component. if (targetZIndex >= AbstractComponent.zIndexCounter) { AbstractComponent.zIndexCounter = targetZIndex + 1; } // Add the element back to the document. if (hadParent) { EditorImage_1.default.addComponent(this.component).apply(editor); } } apply(editor) { this.resolveComponent(editor.image); this.updateTransform(editor, this.affineTransfm, this.targetZIndex); editor.queueRerender(); } unapply(editor) { this.resolveComponent(editor.image); this.updateTransform(editor, this.affineTransfm.inverse(), this.origZIndex); editor.queueRerender(); } description(_editor, localizationTable) { return localizationTable.transformedElements(1, (0, describeTransformation_1.default)(math_1.Vec2.zero, this.affineTransfm, false, localizationTable)); } serializeToJSON() { return { id: this.componentID, transfm: this.affineTransfm.toArray(), targetZIndex: this.targetZIndex, origZIndex: this.origZIndex, }; } }, __setFunctionName(_a, "TransformElementCommand"), (() => { SerializableCommand_1.default.register(AbstractComponent.transformElementCommandId, (json, editor) => { const elem = editor.image.lookupElement(json.id) ?? undefined; const transform = new math_1.Mat33(...json.transfm); const targetZIndex = json.targetZIndex; const origZIndex = json.origZIndex ?? undefined; return new AbstractComponent.TransformElementCommand(transform, json.id, elem, targetZIndex, origZIndex); }); })(), _a); exports.default = AbstractComponent;