golden-layout
Version:
A multi-screen javascript Layout manager
514 lines (452 loc) • 17.4 kB
text/typescript
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;