Source: Layout.mjs

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 = '<div class="bar-handle"></div>'
    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 = '<div class="handle">\u2630</div>'

  // 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 = `
        <div class="utils">
          <div id="close">\u274C</div>
          <div id="plus-font-size" ">\u2795</div>
          <div id="minus-font-size">\u2796</div>
        </div>
      `
      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())
  }
}