UNPKG

golden-layout

Version:
708 lines (621 loc) 26.3 kB
import { ComponentItemConfig, ItemConfig, RowOrColumnItemConfig, StackItemConfig } from '../config/config' import { ResolvedRowOrColumnItemConfig, ResolvedStackItemConfig } from '../config/resolved-config' import { Splitter } from '../controls/splitter' import { AssertError, UnexpectedNullError } from '../errors/internal-error' import { LayoutManager } from '../layout-manager' import { DomConstants } from '../utils/dom-constants' import { ItemType, JsonValue, SizeUnitEnum, WidthOrHeightPropertyName } from '../utils/types' import { getElementHeight, getElementWidth, getElementWidthAndHeight, numberToPixels, pixelsToNumber, setElementHeight, setElementWidth } from "../utils/utils" import { ComponentItem } from './component-item' import { ContentItem } from './content-item' /** @public */ export class RowOrColumn extends ContentItem { /** @internal */ private readonly _childElementContainer: HTMLElement; /** @internal */ private readonly _configType: 'row' | 'column'; /** @internal */ private readonly _isColumn: boolean; /** @internal */ private readonly _splitterSize: number; /** @internal */ private readonly _splitterGrabSize: number; /** @internal */ private readonly _dimension: WidthOrHeightPropertyName; /** @internal */ private readonly _splitter: Splitter[] = []; /** @internal */ private _splitterPosition: number | null; /** @internal */ private _splitterMinPosition: number | null; /** @internal */ private _splitterMaxPosition: number | null; /** @internal */ constructor(isColumn: boolean, layoutManager: LayoutManager, config: ResolvedRowOrColumnItemConfig, /** @internal */ private _rowOrColumnParent: ContentItem ) { super(layoutManager, config, _rowOrColumnParent, RowOrColumn.createElement(document, isColumn)); this.isRow = !isColumn; this.isColumn = isColumn; this._childElementContainer = this.element; this._splitterSize = layoutManager.layoutConfig.dimensions.borderWidth; this._splitterGrabSize = layoutManager.layoutConfig.dimensions.borderGrabWidth; this._isColumn = isColumn; this._dimension = isColumn ? 'height' : 'width'; this._splitterPosition = null; this._splitterMinPosition = null; this._splitterMaxPosition = null; switch (config.type) { case ItemType.row: case ItemType.column: this._configType = config.type; break; default: throw new AssertError('ROCCCT00925'); } } newComponent(componentType: JsonValue, componentState?: JsonValue, title?: string, index?: number): ComponentItem { const itemConfig: ComponentItemConfig = { type: 'component', componentType, componentState, title, }; return this.newItem(itemConfig, index) as ComponentItem; } addComponent(componentType: JsonValue, componentState?: JsonValue, title?: string, index?: number): number { const itemConfig: ComponentItemConfig = { type: 'component', componentType, componentState, title, }; return this.addItem(itemConfig, index); } newItem(itemConfig: RowOrColumnItemConfig | StackItemConfig | ComponentItemConfig, index?: number): ContentItem { index = this.addItem(itemConfig, index); const createdItem = this.contentItems[index]; if (ContentItem.isStack(createdItem) && (ItemConfig.isComponent(itemConfig))) { // createdItem is a Stack which was created to hold wanted component. Return component return createdItem.contentItems[0]; } else { return createdItem; } } addItem(itemConfig: RowOrColumnItemConfig | StackItemConfig | ComponentItemConfig, index?: number ): number { this.layoutManager.checkMinimiseMaximisedStack(); const resolvedItemConfig = ItemConfig.resolve(itemConfig, false); const contentItem = this.layoutManager.createAndInitContentItem(resolvedItemConfig, this); return this.addChild(contentItem, index, false); } /** * Add a new contentItem to the Row or Column * * @param contentItem - * @param index - The position of the new item within the Row or Column. * If no index is provided the item will be added to the end * @param suspendResize - If true the items won't be resized. This will leave the item in * an inconsistent state and is only intended to be used if multiple * children need to be added in one go and resize is called afterwards * * @returns */ override addChild(contentItem: ContentItem, index?: number, suspendResize?: boolean): number { // contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); if (index === undefined) { index = this.contentItems.length; } if (this.contentItems.length > 0) { const splitterElement = this.createSplitter(Math.max(0, index - 1)).element; if (index > 0) { this.contentItems[index - 1].element.insertAdjacentElement('afterend', splitterElement); splitterElement.insertAdjacentElement('afterend', contentItem.element); } else { this.contentItems[0].element.insertAdjacentElement('beforebegin', splitterElement); splitterElement.insertAdjacentElement('beforebegin', contentItem.element); } } else { this._childElementContainer.appendChild(contentItem.element); } super.addChild(contentItem, index); const newItemSize = (1 / this.contentItems.length) * 100; if (suspendResize === true) { this.emitBaseBubblingEvent('stateChanged'); return index; } for (let i = 0; i < this.contentItems.length; i++) { const indexedContentItem = this.contentItems[i]; if (indexedContentItem === contentItem) { contentItem.size = newItemSize; } else { const itemSize = indexedContentItem.size *= (100 - newItemSize) / 100; indexedContentItem.size = itemSize; } } this.updateSize(false); this.emitBaseBubblingEvent('stateChanged'); return index; } /** * Removes a child of this element * * @param contentItem - * @param keepChild - If true the child will be removed, but not destroyed * */ override removeChild(contentItem: ContentItem, keepChild: boolean): void { const index = this.contentItems.indexOf(contentItem); const splitterIndex = Math.max(index - 1, 0); if (index === -1) { throw new Error('Can\'t remove child. ContentItem is not child of this Row or Column'); } /** * Remove the splitter before the item or after if the item happens * to be the first in the row/column */ if (this._splitter[splitterIndex]) { this._splitter[splitterIndex].destroy(); this._splitter.splice(splitterIndex, 1); } super.removeChild(contentItem, keepChild); if (this.contentItems.length === 1 && this.isClosable === true) { const childItem = this.contentItems[0]; this.contentItems.length = 0; this._rowOrColumnParent.replaceChild(this, childItem, true); } else { this.updateSize(false); this.emitBaseBubblingEvent('stateChanged'); } } /** * Replaces a child of this Row or Column with another contentItem */ override replaceChild(oldChild: ContentItem, newChild: ContentItem): void { const size = oldChild.size; super.replaceChild(oldChild, newChild); newChild.size = size; this.updateSize(false); this.emitBaseBubblingEvent('stateChanged'); } /** * Called whenever the dimensions of this item or one of its parents change */ override updateSize(force: boolean): void { this.layoutManager.beginVirtualSizedContainerAdding(); try { this.updateNodeSize(); this.updateContentItemsSize(force); } finally { this.layoutManager.endVirtualSizedContainerAdding(); } } /** * Invoked recursively by the layout manager. ContentItem.init appends * the contentItem's DOM elements to the container, RowOrColumn init adds splitters * in between them * @internal */ override init(): void { if (this.isInitialised === true) return; this.updateNodeSize(); for (let i = 0; i < this.contentItems.length; i++) { this._childElementContainer.appendChild(this.contentItems[i].element); } super.init(); for (let i = 0; i < this.contentItems.length - 1; i++) { this.contentItems[i].element.insertAdjacentElement('afterend', this.createSplitter(i).element); } this.initContentItems(); } toConfig(): ResolvedRowOrColumnItemConfig { const result: ResolvedRowOrColumnItemConfig = { type: this.type as 'row' | 'column', content: this.calculateConfigContent() as (ResolvedRowOrColumnItemConfig | ResolvedStackItemConfig)[], size: this.size, sizeUnit: this.sizeUnit, minSize: this.minSize, minSizeUnit: this.minSizeUnit, id: this.id, isClosable: this.isClosable, } return result; } /** @internal */ protected override setParent(parent: ContentItem): void { this._rowOrColumnParent = parent; super.setParent(parent); } /** @internal */ private updateNodeSize(): void { if (this.contentItems.length > 0) { this.calculateRelativeSizes(); this.setAbsoluteSizes(); } this.emitBaseBubblingEvent('stateChanged'); this.emit('resize'); } /** * Turns the relative sizes calculated by calculateRelativeSizes into * absolute pixel values and applies them to the children's DOM elements * * Assigns additional pixels to counteract Math.floor * @internal */ private setAbsoluteSizes() { const absoluteSizes = this.calculateAbsoluteSizes(); for (let i = 0; i < this.contentItems.length; i++) { if (absoluteSizes.additionalPixel - i > 0) { absoluteSizes.itemSizes[i]++; } if (this._isColumn) { setElementWidth(this.contentItems[i].element, absoluteSizes.crossAxisSize); setElementHeight(this.contentItems[i].element, absoluteSizes.itemSizes[i]); } else { setElementWidth(this.contentItems[i].element, absoluteSizes.itemSizes[i]); setElementHeight(this.contentItems[i].element, absoluteSizes.crossAxisSize); } } } /** * Calculates the absolute sizes of all of the children of this Item. * @returns Set with absolute sizes and additional pixels. * @internal */ private calculateAbsoluteSizes(): RowOrColumn.AbsoluteSizes { const totalSplitterSize = (this.contentItems.length - 1) * this._splitterSize; const { width: elementWidth, height: elementHeight } = getElementWidthAndHeight(this.element); let totalSize: number; let crossAxisSize: number; if (this._isColumn) { totalSize = elementHeight - totalSplitterSize; crossAxisSize = elementWidth; } else { totalSize = elementWidth - totalSplitterSize; crossAxisSize = elementHeight; } let totalAssigned = 0; const itemSizes = []; for (let i = 0; i < this.contentItems.length; i++) { const contentItem = this.contentItems[i]; let itemSize: number; if (contentItem.sizeUnit === SizeUnitEnum.Percent) { itemSize = Math.floor(totalSize * (contentItem.size / 100)); } else { throw new AssertError('ROCCAS6692'); } totalAssigned += itemSize; itemSizes.push(itemSize); } const additionalPixel = Math.floor(totalSize - totalAssigned); return { itemSizes, additionalPixel, totalSize, crossAxisSize, }; } /** * Calculates the relative sizes of all children of this Item. The logic * is as follows: * * - Add up the total size of all items that have a configured size * * - If the total == 100 (check for floating point errors) * Excellent, job done * * - If the total is \> 100, * set the size of items without set dimensions to 1/3 and add this to the total * set the size off all items so that the total is hundred relative to their original size * * - If the total is \< 100 * If there are items without set dimensions, distribute the remainder to 100 evenly between them * If there are no items without set dimensions, increase all items sizes relative to * their original size so that they add up to 100 * * @internal */ private calculateRelativeSizes() { let total = 0; const itemsWithFractionalSize: ContentItem[] = []; let totalFractionalSize = 0; for (let i = 0; i < this.contentItems.length; i++) { const contentItem = this.contentItems[i]; const sizeUnit = contentItem.sizeUnit; switch (sizeUnit) { case SizeUnitEnum.Percent: { total += contentItem.size; break; } case SizeUnitEnum.Fractional: { itemsWithFractionalSize.push(contentItem); totalFractionalSize += contentItem.size; break; } default: throw new AssertError('ROCCRS49110', JSON.stringify(contentItem)); } } /** * Everything adds up to hundred, all good :-) */ if (Math.round(total) === 100) { this.respectMinItemSize(); return; } else { /** * Allocate the remaining size to the items with a fractional size */ if (Math.round(total) < 100 && itemsWithFractionalSize.length > 0) { const fractionalAllocatedSize = 100 - total; for (let i = 0; i < itemsWithFractionalSize.length; i++) { const contentItem = itemsWithFractionalSize[i]; contentItem.size = fractionalAllocatedSize * (contentItem.size / totalFractionalSize); contentItem.sizeUnit = SizeUnitEnum.Percent; } this.respectMinItemSize(); return; } else { /** * If the total is > 100, but there are also items with a fractional size, assign another 50% * to the fractional items * * This will be reset in the next step */ if (Math.round(total) > 100 && itemsWithFractionalSize.length > 0) { for (let i = 0; i < itemsWithFractionalSize.length; i++) { const contentItem = itemsWithFractionalSize[i]; contentItem.size = 50 * (contentItem.size / totalFractionalSize); contentItem.sizeUnit = SizeUnitEnum.Percent; } total += 50; } /** * Set every items size relative to 100 relative to its size to total */ for (let i = 0; i < this.contentItems.length; i++) { const contentItem = this.contentItems[i]; contentItem.size = (contentItem.size / total) * 100; } this.respectMinItemSize(); } } } /** * Adjusts the column widths to respect the dimensions minItemWidth if set. * @internal */ private respectMinItemSize() { interface Entry { size: number; } const minItemSize = this.calculateContentItemMinSize(this); if (minItemSize <= 0 || this.contentItems.length <= 1) { return; } else { let totalOverMin = 0; let totalUnderMin = 0; const entriesOverMin: Entry[] = []; const allEntries: Entry[] = []; const absoluteSizes = this.calculateAbsoluteSizes(); /** * Figure out how much we are under the min item size total and how much room we have to use. */ for (let i = 0; i < absoluteSizes.itemSizes.length; i++) { const itemSize = absoluteSizes.itemSizes[i]; let entry: Entry; if (itemSize < minItemSize) { totalUnderMin += minItemSize - itemSize; entry = { size: minItemSize }; } else { totalOverMin += itemSize - minItemSize; entry = { size: itemSize }; entriesOverMin.push(entry); } allEntries.push(entry); } /** * If there is nothing under min, or there is not enough over to make up the difference, do nothing. */ if (totalUnderMin === 0 || totalUnderMin > totalOverMin) { return; } else { /** * Evenly reduce all columns that are over the min item width to make up the difference. */ const reducePercent = totalUnderMin / totalOverMin; let remainingSize = totalUnderMin; for (let i = 0; i < entriesOverMin.length; i++) { const entry = entriesOverMin[i]; const reducedSize = Math.round((entry.size - minItemSize) * reducePercent); remainingSize -= reducedSize; entry.size -= reducedSize; } /** * Take anything remaining from the last item. */ if (remainingSize !== 0) { allEntries[allEntries.length - 1].size -= remainingSize; } /** * Set every items size relative to 100 relative to its size to total */ for (let i = 0; i < this.contentItems.length; i++) { const contentItem = this.contentItems[i]; contentItem.size = (allEntries[i].size / absoluteSizes.totalSize) * 100; } } } } /** * Instantiates a new Splitter, binds events to it and adds * it to the array of splitters at the position specified as the index argument * * What it doesn't do though is append the splitter to the DOM * * @param index - The position of the splitter * * @returns * @internal */ private createSplitter(index: number): Splitter { const splitter = new Splitter(this._isColumn, this._splitterSize, this._splitterGrabSize); splitter.on('drag', (offsetX, offsetY) => this.onSplitterDrag(splitter, offsetX, offsetY)); splitter.on('dragStop', () => this.onSplitterDragStop(splitter)); splitter.on('dragStart', () => this.onSplitterDragStart(splitter)); this._splitter.splice(index, 0, splitter); return splitter; } /** * Locates the instance of Splitter in the array of * registered splitters and returns a map containing the contentItem * before and after the splitters, both of which are affected if the * splitter is moved * * @returns A map of contentItems that the splitter affects * @internal */ private getSplitItems(splitter: Splitter) { const index = this._splitter.indexOf(splitter); return { before: this.contentItems[index], after: this.contentItems[index + 1] }; } private calculateContentItemMinSize(contentItem: ContentItem) { const minSize = contentItem.minSize; if (minSize !== undefined) { if (contentItem.minSizeUnit === SizeUnitEnum.Pixel) { return minSize; } else { throw new AssertError('ROCGMD98831', JSON.stringify(contentItem)); } } else { const dimensions = this.layoutManager.layoutConfig.dimensions; return this._isColumn ? dimensions.defaultMinItemHeight : dimensions.defaultMinItemWidth; } } /** * Gets the minimum dimensions for the given item configuration array * @internal */ private calculateContentItemsTotalMinSize(contentItems: readonly ContentItem[]) { let totalMinSize = 0; for (const contentItem of contentItems) { totalMinSize += this.calculateContentItemMinSize(contentItem); } return totalMinSize; } /** * Invoked when a splitter's dragListener fires dragStart. Calculates the splitters * movement area once (so that it doesn't need calculating on every mousemove event) * @internal */ private onSplitterDragStart(splitter: Splitter) { const items = this.getSplitItems(splitter); const beforeWidth = pixelsToNumber(items.before.element.style[this._dimension]); const afterSize = pixelsToNumber(items.after.element.style[this._dimension]); const beforeMinSize = this.calculateContentItemsTotalMinSize(items.before.contentItems); const afterMinSize = this.calculateContentItemsTotalMinSize(items.after.contentItems); this._splitterPosition = 0; this._splitterMinPosition = -1 * (beforeWidth - beforeMinSize); this._splitterMaxPosition = afterSize - afterMinSize; } /** * Invoked when a splitter's DragListener fires drag. Updates the splitter's DOM position, * but not the sizes of the elements the splitter controls in order to minimize resize events * * @param splitter - * @param offsetX - Relative pixel values to the splitter's original position. Can be negative * @param offsetY - Relative pixel values to the splitter's original position. Can be negative * @internal */ private onSplitterDrag(splitter: Splitter, offsetX: number, offsetY: number) { let offset = this._isColumn ? offsetY : offsetX; if (this._splitterMinPosition === null || this._splitterMaxPosition === null) { throw new UnexpectedNullError('ROCOSD59226'); } offset = Math.max(offset, this._splitterMinPosition); offset = Math.min(offset, this._splitterMaxPosition); this._splitterPosition = offset; const offsetPixels = numberToPixels(offset); if (this._isColumn) { splitter.element.style.top = offsetPixels; } else { splitter.element.style.left = offsetPixels; } } /** * Invoked when a splitter's DragListener fires dragStop. Resets the splitters DOM position, * and applies the new sizes to the elements before and after the splitter and their children * on the next animation frame * @internal */ private onSplitterDragStop(splitter: Splitter) { if (this._splitterPosition === null) { throw new UnexpectedNullError('ROCOSDS66932'); } else { const items = this.getSplitItems(splitter); const sizeBefore = pixelsToNumber(items.before.element.style[this._dimension]); const sizeAfter = pixelsToNumber(items.after.element.style[this._dimension]); const splitterPositionInRange = (this._splitterPosition + sizeBefore) / (sizeBefore + sizeAfter); const totalRelativeSize = items.before.size + items.after.size; items.before.size = splitterPositionInRange * totalRelativeSize; items.after.size = (1 - splitterPositionInRange) * totalRelativeSize; splitter.element.style.top = numberToPixels(0); splitter.element.style.left = numberToPixels(0); globalThis.requestAnimationFrame(() => this.updateSize(false)); } } } /** @public */ export namespace RowOrColumn { /** @internal */ export interface AbsoluteSizes { itemSizes: number[], additionalPixel: number, totalSize: number, crossAxisSize: number } /** @internal */ export function getElementDimensionSize(element: HTMLElement, dimension: WidthOrHeightPropertyName): number { if (dimension === 'width') { return getElementWidth(element); } else { return getElementHeight(element); } } /** @internal */ export function setElementDimensionSize(element: HTMLElement, dimension: WidthOrHeightPropertyName, value: number): void { if (dimension === 'width') { return setElementWidth(element, value); } else { return setElementHeight(element, value); } } /** @internal */ export function createElement(document: Document, isColumn: boolean): HTMLDivElement { const element = document.createElement('div'); element.classList.add(DomConstants.ClassName.Item); if (isColumn) { element.classList.add(DomConstants.ClassName.Column); } else { element.classList.add(DomConstants.ClassName.Row); } return element; } }