masua
Version:
Simple masonry layout library in TypeScript.
348 lines (301 loc) • 10.5 kB
text/typescript
interface State {
sizes: number[]
columns: number[]
container: HTMLElement
// Number of columns, only used for internal calculations.
count: number
width: number
removeListener?: () => void
currentGutterX: number
currentGutterY: number
resizeTimeout?: ReturnType<typeof setTimeout>
baseWidth: number
gutterX: number
gutterY: number
gutter: number
minify: boolean
singleColumnGutter: number
surroundingGutter: boolean
direction: 'ltr' | 'rtl'
wedge: boolean
}
export interface Configuration {
baseWidth: number | string
gutter: number | string
gutterX: number | string
gutterY: number | string
minify: boolean
surroundingGutter: boolean
singleColumnGutter: number
direction: 'ltr' | 'rtl'
wedge: boolean
}
interface NumberConfiguration extends Configuration {
baseWidth: number
gutter: number
gutterX: number
gutterY: number
singleColumnGutter: number
}
// biome-ignore lint/suspicious/noConsole: User feedback.
const log = (message: string, type: 'log' | 'error' = 'log') => console[type](`masua: ${message}.`)
function getCount(state: State) {
if (state.surroundingGutter) {
return Math.floor((state.width - state.currentGutterX) / (state.baseWidth + state.currentGutterX))
}
return Math.floor((state.width + state.currentGutterX) / (state.baseWidth + state.currentGutterX))
}
function getLongest(state: State) {
let longest = 0
for (let index = 0; index < state.count; index += 1) {
if ((state.columns[index] ?? 0) > (state.columns[longest] ?? 1)) {
longest = index
}
}
return longest
}
function getNextColumn(index: number, state: State) {
return index % state.columns.length
}
function getShortest(state: State) {
let shortest = 0
for (let index = 0; index < state.count; index += 1) {
if ((state.columns[index] ?? 1) < (state.columns[shortest] ?? 0)) {
shortest = index
}
}
return shortest
}
function reset(state: State) {
state.sizes = []
state.columns = []
state.count = 0
state.width = state.container.clientWidth
const minWidth = state.baseWidth
if (state.width < minWidth) {
state.width = minWidth
state.container.style.minWidth = `${minWidth}px`
}
if (getCount(state) === 1) {
// Set ultimate gutter when only one column is displayed
state.currentGutterX = state.singleColumnGutter
// As gutters are reduced, two column may fit, forcing to 1
state.count = 1
} else if (state.width < state.baseWidth + 2 * state.currentGutterX) {
// Remove gutter when screen is to low
state.currentGutterX = 0
} else {
state.currentGutterX = state.gutterX
}
}
function computeWidth(state: State) {
let width: number
if (state.surroundingGutter) {
width = (state.width - state.currentGutterX) / state.count - state.currentGutterX
} else {
width = (state.width + state.currentGutterX) / state.count - state.currentGutterX
}
width = Number.parseFloat(width.toFixed(2))
return width
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO decrease complexity
function layout(state: State) {
if (!state.container) {
log('Container not found', 'error')
return
}
reset(state)
// Computing columns count
if (state.count === 0) {
state.count = getCount(state)
}
// Computing columns width
const colWidth = computeWidth(state)
for (let index = 0; index < state.count; index += 1) {
state.columns[index] = 0
}
// Saving children real heights
const { children } = state.container
for (let index = 0; index < children.length; index += 1) {
// Set colWidth before retrieving element height if content is proportional
const child = children[index] as HTMLElement
child.style.width = `${colWidth}px`
state.sizes[index] = child.clientHeight
}
let startX: number
if (state.direction === 'ltr') {
startX = state.surroundingGutter ? state.currentGutterX : 0
} else {
startX = state.width - (state.surroundingGutter ? state.currentGutterX : 0)
}
if (state.count > state.sizes.length) {
// If more columns than children
const occupiedSpace = state.sizes.length * (colWidth + state.currentGutterX) - state.currentGutterX
if (state.wedge === false) {
if (state.direction === 'ltr') {
startX = (state.width - occupiedSpace) / 2
} else {
startX = state.width - (state.width - occupiedSpace) / 2
}
} else if (state.direction === 'ltr') {
//
} else {
startX = state.width - state.currentGutterX
}
}
// Computing position of children
for (let index = 0; index < children.length; index += 1) {
const nextColumn = state.minify ? getShortest(state) : getNextColumn(index, state)
let childrenGutter = 0
if (state.surroundingGutter || nextColumn !== state.columns.length) {
childrenGutter = state.currentGutterX
}
let x: number
if (state.direction === 'ltr') {
x = startX + (colWidth + childrenGutter) * nextColumn
} else {
x = startX - (colWidth + childrenGutter) * nextColumn - colWidth
}
const y = state.columns[nextColumn] ?? 0
const child = children[index] as HTMLElement
child.style.transform = `translate3d(${Math.round(x)}px,${Math.round(y)}px,0)`
child.style.position = 'absolute'
if (state.columns[nextColumn]) {
state.columns[nextColumn] += (state.sizes[index] ?? 0) + (state.count > 1 ? state.gutterY : state.singleColumnGutter) // margin-bottom
} else {
state.columns[nextColumn] = (state.sizes[index] ?? 0) + (state.count > 1 ? state.gutterY : state.singleColumnGutter) // margin-bottom
}
}
state.container.style.height = `${(state.columns[getLongest(state)] ?? 0) - state.currentGutterY}px`
}
function resizeThrottler(state: State) {
// ignore resize events as long as an actualResizeHandler execution is in the queue
if (!state.resizeTimeout) {
state.resizeTimeout = setTimeout(() => {
state.resizeTimeout = undefined
// IOS Safari throw random resize event on scroll, call layout only if size has changed
if (state.container.clientWidth !== state.width) {
layout(state)
}
// The actualResizeHandler will execute at a rate of 30fps
}, 33)
}
}
function init(state: State) {
// TODO what does this do?
// for (const i in configuration) {
// if (configuration[i] !== undefined) {
// this.conf[i] = conf[i]
// }
// }
state.currentGutterX = state.gutterX
state.currentGutterY = state.gutterY
const onResize = resizeThrottler.bind(null, state)
window.addEventListener('resize', onResize)
state.removeListener = function removeListener() {
window.removeEventListener('resize', onResize)
if (state.resizeTimeout != null) {
window.clearTimeout(state.resizeTimeout)
state.resizeTimeout = undefined
}
}
layout(state)
}
function destroy(state: State) {
if (typeof state.removeListener === 'function') {
state.removeListener()
}
const { children } = state.container
for (const child of Array.from(children)) {
const childElement = child as HTMLElement
childElement.style.removeProperty('width')
childElement.style.removeProperty('transform')
}
state.container.style.removeProperty('height')
state.container.style.removeProperty('min-width')
}
const sizeCache = new Map<string, number>()
function getSizeInPixels(size: string) {
const fromCache = sizeCache.get(size)
if (fromCache) {
return fromCache
}
const tempElement = document.createElement('div')
tempElement.style.width = size
document.body.appendChild(tempElement)
const computedWidth = window.getComputedStyle(tempElement).width
document.body.removeChild(tempElement)
const pixels = Number.parseFloat(computedWidth)
sizeCache.set(size, pixels)
return pixels
}
const sizeValues: (keyof Configuration)[] = ['baseWidth', 'gutter', 'gutterX', 'gutterY', 'singleColumnGutter']
function convertStringSizesToPixels(configuration: Partial<Configuration>): Partial<NumberConfiguration> {
for (const property of sizeValues) {
const value = configuration[property]
if (typeof value === 'string') {
// @ts-ignore No idea what's the issue here.
configuration[property] = getSizeInPixels(value)
}
}
return configuration as Partial<NumberConfiguration>
}
function getContainer(element: HTMLElement | string) {
if (typeof element === 'string') {
const foundElementOnPage = document.querySelector(element) as HTMLElement
if (!foundElementOnPage) {
log(`element with selector ${element} not found on page`)
return false
}
return foundElementOnPage
}
if (element instanceof HTMLElement) {
return element
}
log('first argument invalid, must be HTMLElement or a string')
return false
}
export function grid(element: HTMLElement | string, configuration: Partial<Configuration> = {}) {
if (!element && process.env.NODE_ENV !== 'production') {
throw new Error('masua: "element" parameter is missing or undefined.')
}
const container = getContainer(element)
if (!container) {
return
}
const numberConfiguration = convertStringSizesToPixels(configuration)
const state: State = {
sizes: [],
columns: [],
container,
count: 0,
width: 0,
baseWidth: 255,
gutter: 10,
minify: true,
surroundingGutter: false,
direction: 'ltr',
wedge: false,
currentGutterX: 0,
currentGutterY: 0,
...numberConfiguration,
gutterX: numberConfiguration.gutterX || numberConfiguration.gutter || 10,
gutterY: numberConfiguration.gutterY || numberConfiguration.gutter || 10,
// One column is theoretically an Y-gutter so that's preferred if available.
singleColumnGutter: numberConfiguration.singleColumnGutter || numberConfiguration.gutterY || numberConfiguration.gutter || 10,
}
init(state)
return {
destroy: () => destroy(state),
update: (changes: Partial<Configuration> = {}) => {
// TODO animate box and container location/height changes.
const numberChanges = convertStringSizesToPixels(changes)
Object.assign(state, numberChanges)
state.gutterX = numberChanges.gutterX || numberChanges.gutter || state.gutterX
state.gutterY = numberChanges.gutterY || numberChanges.gutter || state.gutterY
state.singleColumnGutter =
numberChanges.singleColumnGutter || numberChanges.gutterY || numberChanges.gutter || state.singleColumnGutter
layout(state)
},
}
}