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
JavaScript
"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;