sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
264 lines (204 loc) • 7.2 kB
text/typescript
import {PANE_COLLAPSED_WIDTH, PANE_DEFAULT_MIN_WIDTH} from './constants'
import {_calcPaneResize, _sortElements} from './helpers'
import {
type PaneConfigOpts,
type PaneData,
type PaneResizeCache,
type PaneResizeData,
} from './types'
export interface PaneLayoutState {
expandedElement: HTMLElement | null
panes: PaneData[]
resizing: boolean
}
export type PaneLayoutStateObserver = (state: PaneLayoutState) => void
export interface PaneLayoutController {
collapse: (element: HTMLElement) => void
expand: (element: HTMLElement) => void
mount: (element: HTMLElement, options: PaneConfigOpts) => () => void
resize: (type: 'start' | 'move' | 'end', leftElement: HTMLElement, deltaX: number) => void
setRootElement: (nextRootElement: HTMLElement | null) => void
setRootWidth: (nextRootWidth: number) => void
subscribe: (observer: PaneLayoutStateObserver) => () => void
}
export function createPaneLayoutController(): PaneLayoutController {
const observers: PaneLayoutStateObserver[] = []
const elements: HTMLElement[] = []
const optionsMap = new WeakMap<HTMLElement, PaneConfigOpts & {original: PaneConfigOpts}>()
const userCollapsedElementSet = new Set<HTMLElement>()
const cache: Partial<PaneResizeCache> = {}
// Mutable internal state
let rootElement: HTMLElement | null = null
let rootWidth = 0
let expandedElement: HTMLElement | null = null
let resizeDataMap = new Map<HTMLElement, PaneResizeData>()
let resizing = false
function collapse(element: HTMLElement) {
userCollapsedElementSet.add(element)
if (expandedElement === element) {
expandedElement = null
}
_notifyObservers()
}
function expand(element: HTMLElement) {
userCollapsedElementSet.delete(element)
expandedElement = element
_notifyObservers()
}
function mount(element: HTMLElement, options: PaneConfigOpts) {
optionsMap.set(element, {...options, original: options})
elements.push(element)
if (rootElement) {
_sortElements(rootElement, elements)
}
expand(element)
return () => {
const idx = elements.indexOf(element)
if (idx > -1) {
elements.splice(idx, 1)
}
optionsMap.delete(element)
_notifyObservers()
}
}
// eslint-disable-next-line complexity
function resize(type: 'start' | 'move' | 'end', leftElement: HTMLElement, deltaX: number) {
const leftIndex = elements.indexOf(leftElement)
const leftOptions = optionsMap.get(leftElement)
if (!leftOptions) return
const rightElement = elements[leftIndex + 1]
const rightOptions = optionsMap.get(rightElement)
if (!rightOptions) return
if (type === 'start') {
resizing = true
cache.left = {
element: leftElement,
flex: leftOptions.flex || 1,
width: leftElement.offsetWidth,
}
cache.right = {
element: rightElement,
flex: rightOptions.flex || 1,
width: rightElement.offsetWidth,
}
_notifyObservers()
}
if (type === 'move' && cache.left && cache.right) {
resizeDataMap = new Map<HTMLElement, PaneResizeData>()
const {leftW, rightW, leftFlex, rightFlex} = _calcPaneResize(
cache as PaneResizeCache,
leftOptions,
rightOptions,
deltaX,
)
// update resize cache
resizeDataMap.set(leftElement, {flex: leftFlex, width: leftW})
resizeDataMap.set(rightElement, {flex: rightFlex, width: rightW})
_notifyObservers()
}
if (type === 'end') {
resizing = false
const leftResizeData = resizeDataMap.get(leftElement)
const rightResizeData = resizeDataMap.get(rightElement)
// Update left options
optionsMap.set(leftElement, {
...leftOptions,
currentMinWidth: 0,
currentMaxWidth: leftOptions.maxWidth ?? Infinity,
flex: leftResizeData?.flex ?? leftOptions.flex,
})
// Update right options
optionsMap.set(rightElement, {
...rightOptions,
currentMinWidth: 0,
currentMaxWidth: leftOptions.maxWidth ?? Infinity,
flex: rightResizeData?.flex ?? rightOptions.flex,
})
// Reset resize data map
resizeDataMap = new Map()
// Reset cache
delete cache.left
delete cache.right
_notifyObservers()
}
}
function setRootElement(nextRootElement: HTMLElement | null) {
rootElement = nextRootElement
}
function setRootWidth(nextRootWidth: number) {
rootWidth = nextRootWidth
_notifyObservers()
}
function subscribe(observer: PaneLayoutStateObserver) {
observers.push(observer)
return () => {
const idx = observers.push(observer)
if (idx > -1) {
observers.splice(idx, 1)
}
}
}
return {collapse, expand, mount, resize, setRootElement, setRootWidth, subscribe}
// eslint-disable-next-line complexity
function _notifyObservers() {
if (!rootWidth) return
// Create a reversed array of pane elements, so we can loop over them backwards.
// Place the expanded element first (so it has the least chance of being collapsed).
const _elements: HTMLElement[] = []
for (const element of elements) {
if (element !== expandedElement) {
_elements.unshift(element)
}
}
if (expandedElement) {
_elements.unshift(expandedElement)
}
const dataMap = new WeakMap<HTMLElement, PaneData>()
const len = _elements.length
const lastElement = _elements[0]
const collapsedWidth = (len - 1) * PANE_COLLAPSED_WIDTH
let remaingWidth = rootWidth - collapsedWidth
for (const element of _elements) {
const options = optionsMap.get(element)
if (!options) {
continue
}
const minWidth = options.currentMinWidth || options.minWidth || PANE_DEFAULT_MIN_WIDTH
const isLast = element === lastElement
// A pane is collapsed if:
// - it’s explictly collapsed by the user
const userCollapsed = userCollapsedElementSet.has(element)
// - it’s minimum width is larger than the remaining width
const sizeCollapsed = minWidth > remaingWidth
// - if the element is not the last (expanded pane)
const collapsed = isLast ? false : userCollapsed || sizeCollapsed
const resizeData = resizeDataMap.get(element)
// Collect pane data
dataMap.set(element, {
element: element,
collapsed: collapsed,
currentMinWidth: resizeData?.width ?? options.currentMinWidth,
currentMaxWidth: resizeData?.width ?? options.currentMaxWidth,
flex: resizeData?.flex ?? options.flex ?? 1,
})
// Update remaining width
if (collapsed) {
remaingWidth -= PANE_COLLAPSED_WIDTH
} else {
remaingWidth -= minWidth - PANE_COLLAPSED_WIDTH
}
}
const panes: PaneData[] = []
for (const element of elements) {
const data = dataMap.get(element)
if (data) panes.push(data)
}
for (const observer of observers) {
observer({
expandedElement: expandedElement || elements[elements.length - 1] || null,
panes,
resizing,
})
}
}
}