import PlainDraggable from 'plain-draggable' import { onRemove, animateRectTransition } from './utils' /** * Basic class for layout */ export class Layout { /** * Creates a new Layout instance * * @param {Object} options - The options for the layout * @param {string} options.name - The name of the layout * @param {Function} [options.enterHandler] - Handler called when entering the layout * @param {Function} [options.leaveHandler] - Handler called when leaving the layout * @throws {Error} If the layout name is not provided */ constructor (options = {}) { if (!options.name) throw Error('Layout name is not given') this.name = options.name this.enterHandler = options.enterHandler this.leaveHandler = options.leaveHandler } /** * Returns the name of the layout * * @returns {string} The name of the layout */ valueOf = () => this.name } /** * Side-By-Side Layout, HTML content and Showcase show on left/right side * * @extends {Layout} */ export class SideBySide extends Layout { /** * Handler called when entering the Side-By-Side layout * * @param {Object} options - The options object * @param {HTMLElement} options.container - The main container element * @param {HTMLElement} options.htmlHolder - The HTML content holder * @param {HTMLElement} options.showcase - The showcase element */ enterHandler = ({ container, htmlHolder, showcase }) => { const bar = document.createElement('div') bar.className = 'bar' bar.innerHTML = '
' const handle = bar.querySelector('.bar-handle') container.appendChild(bar) // Resize views by value const resizeByLeft = left => { htmlHolder.style.width = left + 'px' showcase.style.width = parseFloat(window.getComputedStyle(container).width) - left + 'px' } const draggable = new PlainDraggable(bar, { handle, containment: { left: '25%', top: 0, right: '75%', height: 0 }, }) draggable.draggableCursor = 'grab' draggable.onDrag = pos => { handle.style.transform = 'unset' resizeByLeft(pos.left) } draggable.onDragEnd = _ => { handle.style.cssText = '' } onRemove(bar, () => draggable.remove()) } /** * Handler called when leaving the Side-By-Side layout * * @param {Object} options - The options object * @param {HTMLElement} options.container - The main container element */ leaveHandler = ({ container }) => { container.querySelector('.bar')?.remove() } } /** * addDraggable. * * @param {HTMLElement} element */ const addDraggable = (element, { snap, left, top } = {}) => { element.classList.add('draggable-block') // Make sure current element always on top const siblings = Array.from( element.parentElement?.querySelectorAll(':scope > *') ?? [], ) let popTimer = null const onmouseover = () => { popTimer = setTimeout(() => { siblings.forEach(e => e.style.removeProperty('z-index')) element.style.zIndex = '9001' }, 200) } const onmouseout = () => { clearTimeout(popTimer) } element.addEventListener('mouseover', onmouseover) element.addEventListener('mouseout', onmouseout) // Add draggable part const draggablePart = document.createElement('div') element.appendChild(draggablePart) draggablePart.className = 'draggable-part' draggablePart.innerHTML = '
\u2630
' // Add draggable instance const draggable = new PlainDraggable(element, { left, top, handle: draggablePart, snap, }) // FIXME use pure CSS to hide utils draggable.onDragStart = () => { element.classList.add('dragging') } draggable.onDragEnd = () => { element.classList.remove('dragging') element.style.zIndex = '9000' } // Reposition draggable instance when resized const resizeObserver = new window.ResizeObserver(() => { draggable?.position() }) resizeObserver.observe(element) // Callback for remove onRemove(element, () => { resizeObserver.disconnect() }) new window.MutationObserver(() => { if (!element.classList.contains('draggable-block') && draggable) { element.removeEventListener('mouseover', onmouseover) element.removeEventListener('mouseout', onmouseout) resizeObserver.disconnect() } }).observe(element, { attributes: true, attributeFilter: ['class'], }) return draggable } /** * Overlay Layout, Showcase occupies viewport, and HTML content becomes draggable blocks * * @extends {Layout} */ export class Overlay extends Layout { /** * saveLeftTopAsData. * * @param {HTMLElement} element */ saveLeftTopAsData = element => { const { left, top } = element.getBoundingClientRect() element.dataset.left = left element.dataset.top = top } /** * enterHandler. * * @param {HTMLElement} options.hemlHolder - Parent element for block * @param {HTMLElement[]} options.blocks */ enterHandler = ({ htmlHolder, blocks }) => { // FIXME It is weird rect from this method and this scope are different... blocks.forEach(this.saveLeftTopAsData) // If no block are focused, focus first three blocks (make them visible) if (!blocks.find(b => b.classList.contains('focus'))) { blocks.slice(0, 3).forEach(b => b.classList.add('focus')) } // Create draggable blocks and set each position by previous one let [left, top] = [20, 20] blocks.forEach(block => { const originLeft = Number(block.dataset.left) const originTop = Number(block.dataset.top) // Create draggable block const wrapper = document.createElement('div') wrapper.classList.add('draggable-block') wrapper.innerHTML = `
\u274C
\u2795
\u2796
` wrapper.title = 'Middle-click to hide block' wrapper.onmouseup = e => { // Hide block with middle click if (e.button === 1) { block.classList.remove('focus') } } // Set DOMRect for wrapper block.replaceWith(wrapper) wrapper.appendChild(block) wrapper.style.left = left + 'px' wrapper.style.top = top + 'px' const rect = wrapper.getBoundingClientRect() left += rect.width + 30 if (left > window.innerWidth) { top += 200 left = left % window.innerWidth } // Animation for DOMRect animateRectTransition( wrapper, { left: originLeft, top: originTop }, { resume: true, duration: 300 }, ).finished.finally(() => addDraggable(wrapper, { left: rect.left, top: rect.top, snap: { x: { step: 20 }, y: { step: 20 }, }, })) // Close button wrapper.querySelector('#close').onclick = () => { block.classList.remove('focus') } // Plus/Minus font-size of content wrapper.querySelector('#plus-font-size').onclick = () => { const fontSize = parseFloat(window.getComputedStyle(block).fontSize) / 16 block.style.fontSize = `${fontSize + 0.2}rem` } wrapper.querySelector('#minus-font-size').onclick = () => { const fontSize = parseFloat(window.getComputedStyle(block).fontSize) / 16 block.style.fontSize = `${fontSize - 0.2}rem` } }) } /** * leaveHandler. * * @param {HTMLElement} htmlHolder * @param {HTMLElement[]} blocks */ leaveHandler = ({ blocks }) => { const resumeFromDraggable = block => { const draggableContainer = block.closest('.draggable-block') if (!draggableContainer) return draggableContainer.replaceWith(block) draggableContainer.remove() } blocks.forEach(resumeFromDraggable) } } /** * Sticky Layout, Showcase is draggable and stick to viewport * * @extends {Layout} */ export class Sticky extends Layout { draggable = document.createElement('div') enterHandler = ({ showcase }) => { showcase.replaceWith(this.draggable) this.draggable.appendChild(showcase) this.draggableInstance = addDraggable(this.draggable) const rect = this.draggable.getBoundingClientRect() this.draggable.style.cssText = `left: ${window.innerWidth - rect.width - 20}px; top: ${window.innerHeight - rect.height - 20}px;` } leaveHandler = ({ showcase }) => { this.draggableInstance?.remove() this.draggable.replaceWith(showcase) this.draggable.querySelectorAll(':scope > :not(.mapclay)').forEach(e => e.remove()) } }