js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
1,037 lines (1,036 loc) • 42.2 kB
JavaScript
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 _a, _b, _c;
import Viewport from '../Viewport.mjs';
import AbstractComponent, { ComponentSizingMode } from '../components/AbstractComponent.mjs';
import { Rect2, Vec2, Mat33, Color4 } from '@js-draw/math';
import SerializableCommand from '../commands/SerializableCommand.mjs';
import EventDispatcher from '../EventDispatcher.mjs';
import { assertIsBoolean, assertIsNumber, assertIsNumberArray } from '../util/assertions.mjs';
import Command from '../commands/Command.mjs';
// @internal Sort by z-index, low to high
export const sortLeavesByZIndex = (leaves) => {
leaves.sort((a, b) => a.getContent().getZIndex() - b.getContent().getZIndex());
};
export var EditorImageEventType;
(function (EditorImageEventType) {
// @internal
EditorImageEventType[EditorImageEventType["ExportViewportChanged"] = 0] = "ExportViewportChanged";
// @internal
EditorImageEventType[EditorImageEventType["AutoresizeModeChanged"] = 1] = "AutoresizeModeChanged";
// Type for events fired whenever components are added to the image
EditorImageEventType[EditorImageEventType["ComponentAdded"] = 2] = "ComponentAdded";
// Type for events fired whenever components are removed from the image
EditorImageEventType[EditorImageEventType["ComponentRemoved"] = 3] = "ComponentRemoved";
})(EditorImageEventType || (EditorImageEventType = {}));
let debugMode = false;
/**
* Handles lookup/storage of elements in the image.
*
* `js-draw` images are made up of a collection of {@link AbstractComponent}s (which
* includes {@link Stroke}s, {@link TextComponent}s, etc.). An `EditorImage`
* is the data structure that stores these components.
*
* Here's how to do a few common operations:
* - **Get all components in a {@link @js-draw/math!Rect2 | Rect2}**:
* {@link EditorImage.getComponentsIntersecting}.
* - **Draw an `EditorImage` onto a canvas/SVG**: {@link EditorImage.render}.
* - **Adding a new component**: {@link EditorImage.addComponent}.
*
* **Example**:
* [[include:doc-pages/inline-examples/image-add-and-lookup.md]]
*/
class EditorImage {
// @internal
constructor() {
this.componentCount = 0;
this.settingExportRect = false;
this.root = new RootImageNode();
this.background = new RootImageNode();
this.componentsById = new Map();
this.notifier = new EventDispatcher();
this.importExportViewport = new Viewport(() => {
this.onExportViewportChanged();
});
// Default to a 500x500 image
this.importExportViewport.updateScreenSize(Vec2.of(500, 500));
this.shouldAutoresizeExportViewport = false;
}
// Returns all components that make up the background of this image. These
// components are rendered below all other components.
getBackgroundComponents() {
const result = [];
const leaves = this.background.getLeaves();
sortLeavesByZIndex(leaves);
for (const leaf of leaves) {
const content = leaf.getContent();
if (content) {
result.push(content);
}
}
return result;
}
// Returns the parent of the given element, if it exists.
findParent(elem) {
return this.background.getChildWithContent(elem) ?? this.root.getChildWithContent(elem);
}
// Forces a re-render of `elem` when the image is next re-rendered as a whole.
// Does nothing if `elem` is not in this.
queueRerenderOf(elem) {
// TODO: Make more efficient (e.g. increase IDs of all parents,
// make cache take into account last modified time instead of IDs, etc.)
const parent = this.findParent(elem);
if (parent) {
parent.remove();
this.addComponentDirectly(elem);
}
}
/** @internal */
renderWithCache(screenRenderer, cache, viewport) {
this.background.render(screenRenderer, viewport.visibleRect);
// If in debug mode, avoid rendering with cache to show additional debug information
if (!debugMode) {
cache.render(screenRenderer, this.root, viewport);
}
else {
this.root.render(screenRenderer, viewport.visibleRect);
}
}
/**
* Renders this image to the given `renderer`.
*
* If `viewport` is non-null, only components that can be seen from that viewport
* will be rendered. If `viewport` is `null`, **all** components are rendered.
*
* **Example**:
* [[include:doc-pages/inline-examples/canvas-renderer.md]]
*/
render(renderer, viewport) {
this.background.render(renderer, viewport?.visibleRect);
this.root.render(renderer, viewport?.visibleRect);
}
/**
* Like {@link renderAll}, but can be stopped early and paused.
*
* **Note**: If the image is being edited during an async rendering, there is no
* guarantee that all nodes will be rendered correctly (some may be missing).
*
* @internal
*/
async renderAllAsync(renderer, preRenderComponent) {
const stoppedEarly = !(await this.background.renderAllAsync(renderer, preRenderComponent));
if (!stoppedEarly) {
return await this.root.renderAllAsync(renderer, preRenderComponent);
}
return false;
}
/**
* Renders all nodes, even ones not within the viewport.
*
* This can be slow for large images
* @internal
*/
renderAll(renderer) {
this.render(renderer, null);
}
/**
* @returns all elements in the image, sorted by z-index (low to high).
*
* This can be slow for large images. If you only need all elemenst in part of the image,
* consider using {@link getComponentsIntersecting} instead.
*
* **Note**: The result does not include background elements. See {@link getBackgroundComponents}.
*/
getAllComponents() {
const leaves = this.root.getLeaves();
sortLeavesByZIndex(leaves);
return leaves.map((leaf) => leaf.getContent());
}
/** @deprecated in favor of {@link getAllComponents} */
getAllElements() {
return this.getAllComponents();
}
/** Returns the number of elements added to this image. @internal */
estimateNumElements() {
return this.componentCount;
}
/** @deprecated @see getComponentsIntersecting */
getElementsIntersectingRegion(region, includeBackground = false) {
return this.getComponentsIntersecting(region, includeBackground);
}
/**
* @returns a list of `AbstractComponent`s intersecting `region`, sorted by increasing z-index.
*
* Components in the background layer are only included if `includeBackground` is `true`.
*/
getComponentsIntersecting(region, includeBackground = false) {
let leaves = this.root.getLeavesIntersectingRegion(region);
if (includeBackground) {
leaves = leaves.concat(this.background.getLeavesIntersectingRegion(region));
}
sortLeavesByZIndex(leaves);
return leaves.map((leaf) => leaf.getContent());
}
/** Called whenever (just after) an element is completely removed. @internal */
onDestroyElement(elem) {
this.componentCount--;
const componentId = elem.getId();
this.componentsById.delete(componentId);
this.notifier.dispatch(EditorImageEventType.ComponentRemoved, {
kind: EditorImageEventType.ComponentRemoved,
image: this,
componentId: componentId,
});
this.autoresizeExportViewport();
}
/** Called just after an element is added. @internal */
onElementAdded(elem) {
this.componentCount++;
const elementId = elem.getId();
this.componentsById.set(elem.getId(), elem);
this.notifier.dispatch(EditorImageEventType.ComponentAdded, {
kind: EditorImageEventType.ComponentAdded,
image: this,
componentId: elementId,
});
this.autoresizeExportViewport();
}
/**
* @returns the AbstractComponent with `id`, if it exists.
*
* @see {@link AbstractComponent.getId}
*/
lookupElement(id) {
return this.componentsById.get(id) ?? null;
}
addComponentDirectly(elem) {
// Because onAddToImage can affect the element's bounding box,
// this needs to be called before parentTree.addLeaf.
elem.onAddToImage(this);
// If a background component, add to the background. Else,
// add to the normal component tree.
const parentTree = elem.isBackground() ? this.background : this.root;
const result = parentTree.addLeaf(elem);
this.onElementAdded(elem);
return result;
}
removeElementDirectly(element) {
const container = this.findParent(element);
container?.remove();
if (container) {
this.onDestroyElement(element);
return true;
}
return false;
}
/**
* Returns a command that adds the given element to the `EditorImage`.
* If `applyByFlattening` is true, the content of the wet ink renderer is
* rendered onto the main rendering canvas instead of doing a full re-render.
*
* @see {@link Display.flatten}
*
* **Example**:
*
* [[include:doc-pages/inline-examples/adding-a-stroke.md]]
*/
static addComponent(elem, applyByFlattening = false) {
return new _a.AddComponentCommand(elem, applyByFlattening);
}
/** @see EditorImage.addComponent */
addComponent(component, applyByFlattening) {
return _a.addComponent(component, applyByFlattening);
}
/** Alias for {@link addComponent}. @deprecated Prefer `.addComponent` */
addElement(elem, applyByFlattening) {
return this.addComponent(elem, applyByFlattening);
}
/** Alias for {@link addComponent}. @deprecated Prefer `.addComponent`. */
static addElement(elem, applyByFlattening = false) {
return this.addComponent(elem, applyByFlattening);
}
/**
* @returns a `Viewport` for rendering the image when importing/exporting.
*/
getImportExportViewport() {
return this.importExportViewport;
}
/**
* @see {@link setImportExportRect}
*/
getImportExportRect() {
return this.getImportExportViewport().visibleRect;
}
/**
* Sets the import/export rectangle to the given `imageRect`. Disables
* autoresize if it was previously enabled.
*
* **Note**: The import/export rectangle is the same as the size of any
* {@link BackgroundComponent}s (and other components that auto-resize).
*/
setImportExportRect(imageRect) {
return _a.SetImportExportRectCommand.of(this, imageRect, false);
}
/** @see {@link setAutoresizeEnabled} */
getAutoresizeEnabled() {
return this.shouldAutoresizeExportViewport;
}
/**
* Returns a `Command` that sets whether the image should autoresize when
* {@link AbstractComponent}s are added/removed.
*
* @example
*
* ```ts,runnable
* import { Editor } from 'js-draw';
*
* const editor = new Editor(document.body);
* const toolbar = editor.addToolbar();
*
* // Add a save button to demonstrate what the output looks like
* // (it should change size to fit whatever was drawn)
* toolbar.addSaveButton(() => {
* document.body.replaceChildren(editor.toSVG({ sanitize: true }));
* });
*
* // Actually using setAutoresizeEnabled:
* //
* // To set autoresize without announcing for accessibility/making undoable
* const addToHistory = false;
* editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(true), addToHistory);
*
* // Add to undo history **and** announce for accessibility
* //editor.dispatch(editor.image.setAutoresizeEnabled(true), true);
* ```
*/
setAutoresizeEnabled(autoresize) {
if (autoresize === this.shouldAutoresizeExportViewport) {
return Command.empty;
}
const newBBox = this.root.getBBox();
return _a.SetImportExportRectCommand.of(this, newBBox, autoresize);
}
setAutoresizeEnabledDirectly(shouldAutoresize) {
if (shouldAutoresize !== this.shouldAutoresizeExportViewport) {
this.shouldAutoresizeExportViewport = shouldAutoresize;
this.notifier.dispatch(EditorImageEventType.AutoresizeModeChanged, {
kind: EditorImageEventType.AutoresizeModeChanged,
image: this,
});
}
}
/** Updates the size/position of the viewport */
autoresizeExportViewport() {
// Only autoresize if in autoresize mode -- otherwise resizing the image
// should be done with undoable commands.
if (this.shouldAutoresizeExportViewport) {
this.setExportRectDirectly(this.root.getBBox());
}
}
/**
* Sets the import/export viewport directly, without returning a `Command`.
* As such, this is not undoable.
*
* See setImportExportRect
*
* Returns true if changes to the viewport were made (and thus
* ExportViewportChanged was fired.)
*/
setExportRectDirectly(newRect) {
const viewport = this.getImportExportViewport();
const lastSize = viewport.getScreenRectSize();
const lastTransform = viewport.canvasToScreenTransform;
const newTransform = Mat33.translation(newRect.topLeft.times(-1));
if (!lastSize.eq(newRect.size) || !lastTransform.eq(newTransform)) {
// Prevent the ExportViewportChanged event from being fired
// multiple times for the same viewport change: Set settingExportRect
// to true.
this.settingExportRect = true;
viewport.updateScreenSize(newRect.size);
viewport.resetTransform(newTransform);
this.settingExportRect = false;
this.onExportViewportChanged();
return true;
}
return false;
}
onExportViewportChanged() {
// Prevent firing duplicate events -- changes
// made by exportViewport.resetTransform may cause this method to be
// called.
if (!this.settingExportRect) {
this.notifier.dispatch(EditorImageEventType.ExportViewportChanged, {
kind: EditorImageEventType.ExportViewportChanged,
image: this,
});
}
}
/**
* @internal
*
* Enables debug mode for **all** `EditorImage`s.
*
* **Only use for debugging**.
*
* @internal
*/
static setDebugMode(newDebugMode) {
debugMode = newDebugMode;
}
}
_a = EditorImage;
// A Command that can access private [EditorImage] functionality
EditorImage.AddComponentCommand = (_b = class extends SerializableCommand {
// If [applyByFlattening], then the rendered content of this element
// is present on the display's wet ink canvas. As such, no re-render is necessary
// the first time this command is applied (the surfaces are joined instead).
constructor(element, applyByFlattening = false) {
super('add-element');
this.element = element;
this.applyByFlattening = applyByFlattening;
this.serializedElem = null;
// FIXME: The serialized version of this command may be inaccurate if this is
// serialized when this command is not on the top of the undo stack.
//
// Caching the element's serialized data leads to additional memory usage *and*
// sometimes incorrect behavior in collaborative editing.
this.serializedElem = null;
if (isNaN(element.getBBox().area)) {
throw new Error('Elements in the image cannot have NaN bounding boxes');
}
}
apply(editor) {
editor.image.addComponentDirectly(this.element);
if (!this.applyByFlattening) {
editor.queueRerender();
}
else {
this.applyByFlattening = false;
editor.display.flatten();
}
}
unapply(editor) {
editor.image.removeElementDirectly(this.element);
editor.queueRerender();
}
description(_editor, localization) {
return localization.addComponentAction(this.element.description(localization));
}
serializeToJSON() {
return {
elemData: this.serializedElem ?? this.element.serialize(),
};
}
},
__setFunctionName(_b, "AddComponentCommand"),
(() => {
SerializableCommand.register('add-element', (json, editor) => {
const id = json.elemData.id;
const foundElem = editor.image.lookupElement(id);
const elem = foundElem ?? AbstractComponent.deserialize(json.elemData);
const result = new _a.AddComponentCommand(elem);
result.serializedElem = json.elemData;
return result;
});
})(),
_b);
// Handles resizing the background import/export region of the image.
EditorImage.SetImportExportRectCommand = (_c = class extends SerializableCommand {
constructor(originalSize, originalTransform, originalAutoresize, newExportRect, newAutoresize) {
super(_a.SetImportExportRectCommand.commandId);
this.originalSize = originalSize;
this.originalTransform = originalTransform;
this.originalAutoresize = originalAutoresize;
this.newExportRect = newExportRect;
this.newAutoresize = newAutoresize;
}
// Uses `image` to store the original size/transform
static of(image, newExportRect, newAutoresize) {
const importExportViewport = image.getImportExportViewport();
const originalSize = importExportViewport.visibleRect.size;
const originalTransform = importExportViewport.canvasToScreenTransform;
const originalAutoresize = image.getAutoresizeEnabled();
return new _a.SetImportExportRectCommand(originalSize, originalTransform, originalAutoresize, newExportRect, newAutoresize);
}
apply(editor) {
editor.image.setAutoresizeEnabledDirectly(this.newAutoresize);
editor.image.setExportRectDirectly(this.newExportRect);
editor.queueRerender();
}
unapply(editor) {
const viewport = editor.image.getImportExportViewport();
editor.image.setAutoresizeEnabledDirectly(this.originalAutoresize);
viewport.updateScreenSize(this.originalSize);
viewport.resetTransform(this.originalTransform);
editor.queueRerender();
}
description(_editor, localization) {
if (this.newAutoresize !== this.originalAutoresize) {
if (this.newAutoresize) {
return localization.enabledAutoresizeOutputCommand;
}
else {
return localization.disabledAutoresizeOutputCommand;
}
}
return localization.resizeOutputCommand(this.newExportRect);
}
serializeToJSON() {
return {
originalSize: this.originalSize.xy,
originalTransform: this.originalTransform.toArray(),
newRegion: {
x: this.newExportRect.x,
y: this.newExportRect.y,
w: this.newExportRect.w,
h: this.newExportRect.h,
},
autoresize: this.newAutoresize,
originalAutoresize: this.originalAutoresize,
};
}
},
__setFunctionName(_c, "SetImportExportRectCommand"),
_c.commandId = 'set-import-export-rect',
(() => {
const commandId = _c.commandId;
SerializableCommand.register(commandId, (json, _editor) => {
assertIsNumber(json.originalSize.x);
assertIsNumber(json.originalSize.y);
assertIsNumberArray(json.originalTransform);
assertIsNumberArray([
json.newRegion.x,
json.newRegion.y,
json.newRegion.w,
json.newRegion.h,
]);
assertIsBoolean(json.autoresize ?? false);
assertIsBoolean(json.originalAutoresize ?? false);
const originalSize = Vec2.ofXY(json.originalSize);
const originalTransform = new Mat33(...json.originalTransform);
const finalRect = new Rect2(json.newRegion.x, json.newRegion.y, json.newRegion.w, json.newRegion.h);
const autoresize = json.autoresize ?? false;
const originalAutoresize = json.originalAutoresize ?? false;
return new _a.SetImportExportRectCommand(originalSize, originalTransform, originalAutoresize, finalRect, autoresize);
});
})(),
_c);
export default EditorImage;
/**
* Determines the first index in `sortedLeaves` that needs to be rendered
* (based on occlusion -- everything before that index can be skipped and
* produce a visually-equivalent image).
*
* Does nothing if visibleRect is not provided
*
* @internal
*/
export const computeFirstIndexToRender = (sortedLeaves, visibleRect) => {
let startIndex = 0;
if (visibleRect) {
for (let i = sortedLeaves.length - 1; i >= 1; i--) {
if (
// Check for occlusion
sortedLeaves[i].getBBox().containsRect(visibleRect) &&
sortedLeaves[i].getContent()?.occludesEverythingBelowWhenRenderedInRect(visibleRect)) {
startIndex = i;
break;
}
}
}
return startIndex;
};
/**
* Part of the Editor's image. Does not handle fullscreen/invisible components.
* @internal
*/
export class ImageNode {
constructor(parent = null) {
this.parent = parent;
this.targetChildCount = 30;
this.children = [];
this.bbox = Rect2.empty;
this.content = null;
this.id = ImageNode.idCounter++;
}
getId() {
return this.id;
}
onContentChange() {
this.id = ImageNode.idCounter++;
}
getContent() {
return this.content;
}
getParent() {
return this.parent;
}
// Override this to change how children are considered within a given region.
getChildrenIntersectingRegion(region, isTooSmallFilter) {
return this.children.filter((child) => {
const bbox = child.getBBox();
return !isTooSmallFilter?.(bbox) && bbox.intersects(region);
});
}
getChildrenOrSelfIntersectingRegion(region, isTooSmall) {
if (this.content && this.bbox.intersects(region) && !isTooSmall?.(this.bbox)) {
return [this];
}
return this.getChildrenIntersectingRegion(region, isTooSmall);
}
/**
* Returns a list of `ImageNode`s with content (and thus no children).
* Override getChildrenIntersectingRegion to customize how this method
* determines whether/which children are in `region`.
*
* @paran region - All resultant `ImageNode`s must intersect `region`.
* @param isTooSmall - If `isTooSmall` returns true for an image node, that node
* is excluded from the output.
*
*/
getLeavesIntersectingRegion(region, isTooSmall) {
const result = [];
const workList = [];
workList.push(this);
while (workList.length > 0) {
const current = workList.pop();
// Split the children into leaves and non-leaves
const processed = current.getChildrenOrSelfIntersectingRegion(region, isTooSmall);
for (const item of processed) {
if (item.content) {
result.push(item);
}
else {
// Non-leaves need to be processed
workList.push(item);
}
}
}
return result;
}
// Returns the child of this with the target content or `null` if no
// such child exists.
//
// Note: Relies on all children to have valid bounding boxes.
getChildWithContent(target) {
const candidates = this.getLeavesIntersectingRegion(target.getBBox());
for (const candidate of candidates) {
if (candidate.getContent() === target) {
return candidate;
}
}
return null;
}
// Returns a list of leaves with this as an ancestor.
// Like getLeavesInRegion, but does not check whether ancestors are in a given rectangle
getLeaves() {
if (this.content) {
return [this];
}
const result = [];
for (const child of this.children) {
result.push(...child.getLeaves());
}
return result;
}
addLeaf(leaf) {
this.onContentChange();
if (this.content === null && this.children.length === 0) {
this.content = leaf;
this.recomputeBBox(true);
return this;
}
if (this.content !== null) {
console.assert(this.children.length === 0);
const contentNode = new ImageNode(this);
contentNode.content = this.content;
this.content = null;
this.children.push(contentNode);
contentNode.recomputeBBox(false);
}
// If this node is contained within the leaf, make this and the leaf
// share a parent.
const leafBBox = leaf.getBBox();
if (leafBBox.containsRect(this.getBBox())) {
const nodeForNewLeaf = new ImageNode(this);
if (this.children.length < this.targetChildCount) {
this.children.push(nodeForNewLeaf);
}
else {
const nodeForChildren = new ImageNode(this);
nodeForChildren.children = this.children;
this.children = [nodeForNewLeaf, nodeForChildren];
nodeForChildren.updateParents();
nodeForChildren.recomputeBBox(true);
}
return nodeForNewLeaf.addLeaf(leaf);
}
const containingNodes = this.children.filter((child) => child.getBBox().containsRect(leafBBox));
// Does the leaf already fit within one of the children?
if (containingNodes.length > 0 && this.children.length >= this.targetChildCount) {
// Sort the containers in ascending order by area
containingNodes.sort((a, b) => a.getBBox().area - b.getBBox().area);
// Choose the smallest child that contains the new element.
const result = containingNodes[0].addLeaf(leaf);
result.rebalance();
return result;
}
const newNode = ImageNode.createLeafNode(this, leaf);
this.children.push(newNode);
newNode.recomputeBBox(true);
if (this.children.length >= this.targetChildCount) {
this.rebalance();
}
return newNode;
}
// Creates a new leaf node with the given content.
// This only establishes the parent-child linking in one direction. Callers
// must add the resultant node to the list of children manually.
static createLeafNode(parent, content) {
const newNode = new ImageNode(parent);
newNode.content = content;
return newNode;
}
getBBox() {
return this.bbox;
}
// Recomputes this' bounding box. If [bubbleUp], also recompute
// this' ancestors bounding boxes. This also re-computes this' bounding box
// in the z-direction (z-indicies).
recomputeBBox(bubbleUp) {
const oldBBox = this.bbox;
if (this.content !== null) {
this.bbox = this.content.getBBox();
}
else {
this.bbox = Rect2.union(...this.children.map((child) => child.getBBox()));
}
if (bubbleUp && !oldBBox.eq(this.bbox)) {
if (this.bbox.containsRect(oldBBox)) {
this.parent?.unionBBoxWith(this.bbox);
}
else {
this.parent?.recomputeBBox(true);
}
}
this.checkRep();
}
// Grows this' bounding box to also include `other`.
// Always bubbles up.
unionBBoxWith(other) {
this.bbox = this.bbox.union(other);
this.parent?.unionBBoxWith(other);
}
updateParents(recursive = false) {
for (const child of this.children) {
child.parent = this;
if (recursive) {
child.updateParents(recursive);
}
}
}
rebalance() {
// If the current node is its parent's only child,
if (this.parent && this.parent.children.length === 1) {
console.assert(this.parent.content === null);
console.assert(this.parent.children[0] === this);
// Remove this' parent, if this' parent isn't the root.
const oldParent = this.parent;
if (oldParent.parent !== null) {
const newParent = oldParent.parent;
newParent.children = newParent.children.filter((c) => c !== oldParent);
oldParent.parent = null;
oldParent.children = [];
this.parent = newParent;
newParent.children.push(this);
this.parent.recomputeBBox(false);
}
else if (this.content === null) {
// Remove this and transfer this' children to the parent.
this.parent.children = this.children;
this.parent.updateParents();
this.parent = null;
}
}
// Create virtual containers for children. Handles the case where there
// are many small, often non-overlapping children that we still want to be grouped.
if (this.children.length > this.targetChildCount * 10) {
const grid = this.getBBox().divideIntoGrid(4, 4);
const indexToCount = [];
while (indexToCount.length < grid.length) {
indexToCount.push(0);
}
for (const child of this.children) {
for (let i = 0; i < grid.length; i++) {
if (grid[i].containsRect(child.getBBox())) {
indexToCount[i]++;
}
}
}
let indexWithGreatest = 0;
let greatestCount = indexToCount[0];
for (let i = 1; i < indexToCount.length; i++) {
if (indexToCount[i] > greatestCount) {
indexWithGreatest = i;
greatestCount = indexToCount[i];
}
}
const targetGridSquare = grid[indexWithGreatest];
// Avoid clustering if just a few children would be grouped.
// Unnecessary clustering can lead to unnecessarily nested nodes.
if (greatestCount > 4) {
const newChildren = [];
const childNodeChildren = [];
for (const child of this.children) {
if (targetGridSquare.containsRect(child.getBBox())) {
childNodeChildren.push(child);
}
else {
newChildren.push(child);
}
}
if (childNodeChildren.length < this.children.length) {
this.children = newChildren;
const child = new ImageNode(this);
this.children.push(child);
child.children = childNodeChildren;
child.updateParents(false);
child.recomputeBBox(false);
child.rebalance();
}
}
}
// Empty?
if (this.parent && this.children.length === 0 && this.content === null) {
this.remove();
}
}
// Removes the parent-to-child link.
// Called internally by `.remove`
removeChild(child) {
this.checkRep();
const oldChildCount = this.children.length;
this.children = this.children.filter((node) => {
return node !== child;
});
console.assert(this.children.length === oldChildCount - 1, `${oldChildCount - 1} ≠ ${this.children.length} after removing all nodes equal to ${child}. Nodes should only be removed once.`);
this.children.forEach((child) => {
child.rebalance();
});
this.recomputeBBox(true);
this.rebalance();
this.checkRep();
}
// Remove this node and all of its children
remove() {
this.content?.onRemoveFromImage();
if (!this.parent) {
this.content = null;
this.children = [];
return;
}
this.parent.removeChild(this);
// Remove the child-to-parent link and invalid this
this.parent = null;
this.content = null;
this.children = [];
this.checkRep();
}
// Creates a (potentially incomplete) async rendering of this image.
// Returns false if stopped early
async renderAllAsync(renderer,
// Used to pause/stop the renderer process
preRenderComponent) {
const leaves = this.getLeaves();
sortLeavesByZIndex(leaves);
const totalLeaves = leaves.length;
for (let leafIndex = 0; leafIndex < totalLeaves; leafIndex++) {
const leaf = leaves[leafIndex];
const component = leaf.getContent();
// Even though leaf was originally a leaf, it might not be any longer --
// rendering is async and the tree can change during that time.
if (!component) {
continue;
}
const shouldContinue = await preRenderComponent(component, leafIndex, totalLeaves);
if (!shouldContinue) {
return false;
}
component.render(renderer, undefined);
}
return true;
}
render(renderer, visibleRect) {
let leaves;
if (visibleRect) {
leaves = this.getLeavesIntersectingRegion(visibleRect, (rect) => renderer.isTooSmallToRender(rect));
}
else {
leaves = this.getLeaves();
}
sortLeavesByZIndex(leaves);
// If some components hide others (and we're permitted to simplify,
// which is true in the case of visibleRect being defined), then only
// draw the non-hidden components:
const startIndex = computeFirstIndexToRender(leaves);
for (let i = startIndex; i < leaves.length; i++) {
const leaf = leaves[i];
// Leaves by definition have content
leaf.getContent().render(renderer, visibleRect);
}
// Show debug information
if (debugMode && visibleRect) {
if (startIndex !== 0) {
console.log('EditorImage: skipped ', startIndex, 'nodes due to occlusion');
}
this.renderDebugBoundingBoxes(renderer, visibleRect);
}
}
// Debug only: Shows bounding boxes of this and all children.
renderDebugBoundingBoxes(renderer, visibleRect, depth = 0) {
const bbox = this.getBBox();
const pixelSize = 1 / (renderer.getSizeOfCanvasPixelOnScreen() || 1);
if (bbox.maxDimension < 3 * pixelSize || !bbox.intersects(visibleRect)) {
return;
}
// Render debug information for this
renderer.startObject(bbox);
// Different styling for leaf nodes
const isLeaf = !!this.content;
const fill = isLeaf ? Color4.ofRGBA(1, 0, 1, 0.4) : Color4.ofRGBA(0, 1, Math.sin(depth), 0.6);
const lineWidth = isLeaf ? 1 * pixelSize : 2 * pixelSize;
renderer.drawRect(bbox.intersection(visibleRect), lineWidth, { fill });
renderer.endObject();
if (bbox.maxDimension > visibleRect.maxDimension / 3) {
const textStyle = {
fontFamily: 'monospace',
size: bbox.minDimension / 20,
renderingStyle: { fill: Color4.red },
};
renderer.drawText(`Depth: ${depth}`, Mat33.translation(bbox.bottomLeft), textStyle);
}
// Render debug information for children
for (const child of this.children) {
child.renderDebugBoundingBoxes(renderer, visibleRect, depth + 1);
}
}
checkRep(depth = 0) {
// Slow -- disabld by default
if (debugMode) {
if (this.parent && !this.parent.children.includes(this)) {
throw new Error(`Parent does not have this node as a child. (depth: ${depth})`);
}
let expectedBBox = null;
const seenChildren = new Set();
for (const child of this.children) {
expectedBBox ??= child.getBBox();
expectedBBox = expectedBBox.union(child.getBBox());
if (child.parent !== this) {
throw new Error(`Child with bbox ${child.getBBox()} and ${child.children.length} has wrong parent (was ${child.parent}).`);
}
// Children should only be present once
if (seenChildren.has(child)) {
throw new Error(`Child ${child} is present twice or more in its parent's child list`);
}
seenChildren.add(child);
}
const tolerance = this.bbox.minDimension / 100;
if (expectedBBox && !this.bbox.eq(expectedBBox, tolerance)) {
throw new Error(`Wrong bounding box ${expectedBBox} \\neq ${this.bbox} (depth: ${depth})`);
}
}
}
}
ImageNode.idCounter = 0;
/** An `ImageNode` that can properly handle fullscreen/data components. @internal */
export class RootImageNode extends ImageNode {
constructor() {
super(...arguments);
// Nodes that will always take up the entire screen
this.fullscreenChildren = [];
// Nodes that will never be visible unless a full render is done.
this.dataComponents = [];
}
getChildrenIntersectingRegion(region, _isTooSmall) {
const result = super.getChildrenIntersectingRegion(region);
for (const node of this.fullscreenChildren) {
result.push(node);
}
return result;
}
getChildrenOrSelfIntersectingRegion(region, _isTooSmall) {
const content = this.getContent();
// Fullscreen components always intersect/contain
if (content && content.getSizingMode() === ComponentSizingMode.FillScreen) {
return [this];
}
return super.getChildrenOrSelfIntersectingRegion(region, _isTooSmall);
}
getLeaves() {
const leaves = super.getLeaves();
// Add fullscreen/data components — this method should
// return *all* leaves.
return this.dataComponents.concat(this.fullscreenChildren, leaves);
}
removeChild(child) {
let removed = false;
const checkTargetChild = (component) => {
const isTarget = component === child;
removed ||= isTarget;
return !isTarget;
};
// Check whether the child is stored in the data/fullscreen
// component arrays first.
this.dataComponents = this.dataComponents.filter(checkTargetChild);
this.fullscreenChildren = this.fullscreenChildren.filter(checkTargetChild);
if (!removed) {
super.removeChild(child);
}
}
getChildWithContent(target) {
const searchExtendedChildren = () => {
// Search through all extended children
const candidates = this.fullscreenChildren.concat(this.dataComponents);
for (const candidate of candidates) {
if (candidate.getContent() === target) {
return candidate;
}
}
return null;
};
// If positioned as if a standard child, search using the superclass first.
// Because it could be mislabeled, also search the extended children if the superclass
// search fails.
if (target.getSizingMode() === ComponentSizingMode.BoundingBox) {
return super.getChildWithContent(target) ?? searchExtendedChildren();
}
// Fall back to the superclass -- it's possible that the component has
// changed labels.
return super.getChildWithContent(target) ?? searchExtendedChildren();
}
addLeaf(leafContent) {
const sizingMode = leafContent.getSizingMode();
if (sizingMode === ComponentSizingMode.BoundingBox) {
return super.addLeaf(leafContent);
}
else if (sizingMode === ComponentSizingMode.FillScreen) {
this.onContentChange();
const newNode = ImageNode.createLeafNode(this, leafContent);
this.fullscreenChildren.push(newNode);
return newNode;
}
else if (sizingMode === ComponentSizingMode.Anywhere) {
this.onContentChange();
const newNode = ImageNode.createLeafNode(this, leafContent);
this.dataComponents.push(newNode);
return newNode;
}
else {
const exhaustivenessCheck = sizingMode;
throw new Error(`Invalid sizing mode, ${sizingMode}`);
return exhaustivenessCheck;
}
}
}