UNPKG

golden-layout

Version:
514 lines (452 loc) 17.4 kB
import { ResolvedItemConfig } from '../config/resolved-config' import { BrowserPopout } from '../controls/browser-popout' import { AssertError, UnexpectedNullError } from '../errors/internal-error' import { LayoutManager } from '../layout-manager' import { EventEmitter } from '../utils/event-emitter' import { AreaLinkedRect, ItemType, SizeUnitEnum } from '../utils/types' import { getUniqueId, setElementDisplayVisibility } from '../utils/utils' import { ComponentItem } from './component-item' import { ComponentParentableItem } from './component-parentable-item' import { Stack } from './stack' /** * This is the baseclass that all content items inherit from. * Most methods provide a subset of what the sub-classes do. * * It also provides a number of functions for tree traversal * @public */ export abstract class ContentItem extends EventEmitter { /** @internal */ private _type: ItemType; /** @internal */ private _id: string; /** @internal */ private _popInParentIds: string[] = []; /** @internal */ private _contentItems: ContentItem[]; /** @internal */ private _isClosable; /** @internal */ private _pendingEventPropagations: Record<string, unknown>; /** @internal */ private _throttledEvents: string[]; /** @internal */ private _isInitialised; /** @internal */ size: number; /** @internal */ sizeUnit: SizeUnitEnum; /** @internal */ minSize: number | undefined; /** @internal */ minSizeUnit: SizeUnitEnum; isGround: boolean isRow: boolean isColumn: boolean isStack: boolean isComponent: boolean get type(): ItemType { return this._type; } get id(): string { return this._id; } set id(value: string) { this._id = value; } /** @internal */ get popInParentIds(): string[] { return this._popInParentIds; } get parent(): ContentItem | null { return this._parent; } get contentItems(): ContentItem[] { return this._contentItems; } get isClosable(): boolean { return this._isClosable; } get element(): HTMLElement { return this._element; } get isInitialised(): boolean { return this._isInitialised; } static isStack(item: ContentItem): item is Stack { return item.isStack; } static isComponentItem(item: ContentItem): item is ComponentItem { return item.isComponent; } static isComponentParentableItem(item: ContentItem): item is ComponentParentableItem { return item.isStack || item.isGround; } /** @internal */ constructor(public readonly layoutManager: LayoutManager, config: ResolvedItemConfig, /** @internal */ private _parent: ContentItem | null, /** @internal */ private readonly _element: HTMLElement ) { super(); this._type = config.type; this._id = config.id; this._isInitialised = false; this.isGround = false; this.isRow = false; this.isColumn = false; this.isStack = false; this.isComponent = false; this.size = config.size; this.sizeUnit = config.sizeUnit; this.minSize = config.minSize; this.minSizeUnit = config.minSizeUnit; this._isClosable = config.isClosable; this._pendingEventPropagations = {}; this._throttledEvents = ['stateChanged']; this._contentItems = this.createContentItems(config.content); } /** * Updaters the size of the component and its children, called recursively * @param force - In some cases the size is not updated if it has not changed. In this case, events * (such as ComponentContainer.virtualRectingRequiredEvent) are not fired. Setting force to true, ensures the size is updated regardless, and * the respective events are fired. This is sometimes necessary when a component's size has not changed but it has become visible, and the * relevant events need to be fired. * @internal */ abstract updateSize(force: boolean): void; /** * Removes a child node (and its children) from the tree * @param contentItem - The child item to remove * @param keepChild - Whether to destroy the removed item */ removeChild(contentItem: ContentItem, keepChild = false): void { /* * Get the position of the item that's to be removed within all content items this node contains */ const index = this._contentItems.indexOf(contentItem); /* * Make sure the content item to be removed is actually a child of this item */ if (index === -1) { throw new Error('Can\'t remove child item. Unknown content item'); } /** * Call destroy on the content item. * All children are destroyed as well */ if (!keepChild) { this._contentItems[index].destroy(); } /** * Remove the content item from this nodes array of children */ this._contentItems.splice(index, 1); /** * If this node still contains other content items, adjust their size */ if (this._contentItems.length > 0) { this.updateSize(false); } else { /** * If this was the last content item, remove this node as well */ if (!this.isGround && this._isClosable === true) { if (this._parent === null) { throw new UnexpectedNullError('CIUC00874'); } else { this._parent.removeChild(this); } } } } /** * Sets up the tree structure for the newly added child * The responsibility for the actual DOM manipulations lies * with the concrete item * * @param contentItem - * @param index - If omitted item will be appended * @param suspendResize - Used by descendent implementations */ // eslint-disable-next-line @typescript-eslint/no-unused-vars addChild(contentItem: ContentItem, index?: number | null, suspendResize?: boolean): number { index ??= this._contentItems.length; this._contentItems.splice(index, 0, contentItem); contentItem.setParent(this); if (this._isInitialised === true && contentItem._isInitialised === false) { contentItem.init(); } return index; } /** * Replaces oldChild with newChild * @param oldChild - * @param newChild - * @internal */ replaceChild(oldChild: ContentItem, newChild: ContentItem, destroyOldChild = false): void { // Do not try to replace ComponentItem - will not work const index = this._contentItems.indexOf(oldChild); const parentNode = oldChild._element.parentNode; if (index === -1) { throw new AssertError('CIRCI23232', 'Can\'t replace child. oldChild is not child of this'); } if (parentNode === null) { throw new UnexpectedNullError('CIRCP23232'); } else { parentNode.replaceChild(newChild._element, oldChild._element); /* * Optionally destroy the old content item */ if (destroyOldChild === true) { oldChild._parent = null; oldChild.destroy(); // will now also destroy all children of oldChild } /* * Wire the new contentItem into the tree */ this._contentItems[index] = newChild; newChild.setParent(this); // newChild inherits the sizes from the old child: newChild.size = oldChild.size; newChild.sizeUnit = oldChild.sizeUnit; newChild.minSize = oldChild.minSize; newChild.minSizeUnit = oldChild.minSizeUnit; //TODO This doesn't update the config... refactor to leave item nodes untouched after creation if (newChild._parent === null) { throw new UnexpectedNullError('CIRCNC45699'); } else { if (newChild._parent._isInitialised === true && newChild._isInitialised === false) { newChild.init(); } this.updateSize(false); } } } /** * Convenience method. * Shorthand for this.parent.removeChild( this ) */ remove(): void { if (this._parent === null) { throw new UnexpectedNullError('CIR11110'); } else { this._parent.removeChild(this); } } /** * Removes the component from the layout and creates a new * browser window with the component and its children inside */ popout(): BrowserPopout { const parentId = getUniqueId(); const browserPopout = this.layoutManager.createPopoutFromContentItem(this, undefined, parentId, undefined); this.emitBaseBubblingEvent('stateChanged'); return browserPopout; } abstract toConfig(): ResolvedItemConfig; /** @internal */ calculateConfigContent(): ResolvedItemConfig[] { const contentItems = this._contentItems; const count = contentItems.length; const result = new Array<ResolvedItemConfig>(count); for (let i = 0; i < count; i++) { const item = contentItems[i]; result[i] = item.toConfig(); } return result; } /** @internal */ highlightDropZone(x: number, y: number, area: AreaLinkedRect): void { const dropTargetIndicator = this.layoutManager.dropTargetIndicator; if (dropTargetIndicator === null) { throw new UnexpectedNullError('ACIHDZ5593'); } else { dropTargetIndicator.highlightArea(area, 1); } } /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars onDrop(contentItem: ContentItem, area: ContentItem.Area): void { this.addChild(contentItem); } /** @internal */ show(): void { this.layoutManager.beginSizeInvalidation(); try { // Not sure why showAllActiveContentItems() was called. GoldenLayout seems to work fine without it. Left commented code // in source in case a reason for it becomes apparent. // this.layoutManager.showAllActiveContentItems(); setElementDisplayVisibility(this._element, true); // this.layoutManager.updateSizeFromContainer(); for (let i = 0; i < this._contentItems.length; i++) { this._contentItems[i].show(); } } finally { this.layoutManager.endSizeInvalidation(); } } /** * Destroys this item ands its children * @internal */ destroy(): void { for (let i = 0; i < this._contentItems.length; i++) { this._contentItems[i].destroy(); } this._contentItems = []; this.emitBaseBubblingEvent('beforeItemDestroyed'); this._element.remove(); this.emitBaseBubblingEvent('itemDestroyed'); } /** * Returns the area the component currently occupies * @internal */ getElementArea(element?: HTMLElement): ContentItem.Area | null { element = element ?? this._element; const rect = element.getBoundingClientRect(); const top = rect.top + document.body.scrollTop; const left = rect.left + document.body.scrollLeft; const width = rect.width; const height = rect.height; return { x1: left, y1: top, x2: left + width, y2: top + height, surface: width * height, contentItem: this }; } /** * The tree of content items is created in two steps: First all content items are instantiated, * then init is called recursively from top to bottem. This is the basic init function, * it can be used, extended or overwritten by the content items * * Its behaviour depends on the content item * @internal */ init(): void { this._isInitialised = true; this.emitBaseBubblingEvent('itemCreated'); this.emitUnknownBubblingEvent(this.type + 'Created'); } /** @internal */ protected setParent(parent: ContentItem): void { this._parent = parent; } /** @internal */ addPopInParentId(id: string): void { if (!this.popInParentIds.includes(id)) { this.popInParentIds.push(id); } } /** @internal */ protected initContentItems(): void { for (let i = 0; i < this._contentItems.length; i++) { this._contentItems[i].init(); } } /** @internal */ protected hide(): void { this.layoutManager.beginSizeInvalidation(); try { setElementDisplayVisibility(this._element, false); // this.layoutManager.updateSizeFromContainer(); } finally { this.layoutManager.endSizeInvalidation(); } } /** @internal */ protected updateContentItemsSize(force: boolean): void { for (let i = 0; i < this._contentItems.length; i++) { this._contentItems[i].updateSize(force); } } /** * creates all content items for this node at initialisation time * PLEASE NOTE, please see addChild for adding contentItems at runtime * @internal */ private createContentItems(content: readonly ResolvedItemConfig[]) { const count = content.length; const result = new Array<ContentItem>(count); for (let i = 0; i < content.length; i++) { result[i] = this.layoutManager.createContentItem(content[i], this); } return result; } /** * Called for every event on the item tree. Decides whether the event is a bubbling * event and propagates it to its parent * * @param name - The name of the event * @param event - * @internal */ private propagateEvent(name: string, args: unknown[]) { if (args.length === 1) { const event = args[0]; if (event instanceof EventEmitter.BubblingEvent && event.isPropagationStopped === false && this._isInitialised === true) { /** * In some cases (e.g. if an element is created from a DragSource) it * doesn't have a parent and is not a child of GroundItem. If that's the case * propagate the bubbling event from the top level of the substree directly * to the layoutManager */ if (this.isGround === false && this._parent) { this._parent.emitUnknown(name, event); } else { this.scheduleEventPropagationToLayoutManager(name, event); } } } } override tryBubbleEvent(name: string, args: unknown[]): void { if (args.length === 1) { const event = args[0]; if (event instanceof EventEmitter.BubblingEvent && event.isPropagationStopped === false && this._isInitialised === true ) { /** * In some cases (e.g. if an element is created from a DragSource) it * doesn't have a parent and is not a child of GroundItem. If that's the case * propagate the bubbling event from the top level of the substree directly * to the layoutManager */ if (this.isGround === false && this._parent) { this._parent.emitUnknown(name, event); } else { this.scheduleEventPropagationToLayoutManager(name, event); } } } } /** * All raw events bubble up to the Ground element. Some events that * are propagated to - and emitted by - the layoutManager however are * only string-based, batched and sanitized to make them more usable * * @param name - The name of the event * @internal */ private scheduleEventPropagationToLayoutManager(name: string, event: EventEmitter.BubblingEvent) { if (this._throttledEvents.indexOf(name) === -1) { this.layoutManager.emitUnknown(name, event); } else { if (this._pendingEventPropagations[name] !== true) { this._pendingEventPropagations[name] = true; globalThis.requestAnimationFrame(() => this.propagateEventToLayoutManager(name, event)); } } } /** * Callback for events scheduled by _scheduleEventPropagationToLayoutManager * * @param name - The name of the event * @internal */ private propagateEventToLayoutManager(name: string, event: EventEmitter.BubblingEvent) { this._pendingEventPropagations[name] = false; this.layoutManager.emitUnknown(name, event); } } /** @public */ export namespace ContentItem { /** @internal */ export interface Area extends AreaLinkedRect { surface: number; contentItem: ContentItem; } } /** @public @deprecated Use {@link (ContentItem:class)} */ export type AbstractContentItem = ContentItem;