UNPKG

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
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; } } }