All files / src/ui resizeHandle.ts

61.36% Statements 27/44
60% Branches 3/5
50% Functions 1/2
61.36% Lines 27/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110                                                            2x                                                         2x 713x     713x 713x 713x 713x 713x 713x 713x 713x 713x 713x 713x   713x 713x 713x   713x               713x 713x               713x 713x             713x 713x 713x 713x 713x  
/**
 * The draggable edge that two different things now have.
 *
 * It was a private method on {@link SidePanel} for as long as columns were the
 * only resizable thing in the window. The Analysis drawer (§18.1) is the second,
 * and it is not a column — it floats over the body rather than sitting in the
 * flex row — so it could not inherit the behaviour by being one. Copying thirty
 * lines of pointer plumbing into it is how the two would come to disagree about
 * which button arms a drag, or about whether a double-click resets, and the
 * disagreement would not be visible in either file.
 *
 * Two details here are load-bearing and neither is obvious:
 *
 * **Pointer events with capture on the handle.** A resize drag routinely leaves
 * the four-pixel strip it started on, and without capture the first fast
 * movement drops it over the map — which would then take the rest of the gesture
 * as a pan.
 *
 * **The primary button only.** A right-press would arm a drag that the browser's
 * context menu then sits on top of, and the edge would follow the pointer
 * afterwards, with no press left to end it.
 *
 * The third owner is the **console** (§23), and it is the first that is not
 * beside the map but under it — so what {@link ResizeHandleOptions.side} names
 * is now an edge rather than a left or a right, and the axis follows from it.
 * The plumbing does not change: a drag is a distance from where the press
 * landed, and which coordinate that distance is measured along is the only
 * thing an edge decides.
 */
 
import { el } from './dom'
 
export interface ResizeHandleOptions {
  /**
   * Which side of the *owner* the handle sits on.
   *
   * It decides the axis and the sign, and nothing else: a left panel's handle is
   * on its right edge, so rightward is wider; a right panel's is on its left, so
   * the sign flips. The drawer is a right-sider by this reckoning even though it
   * is not a panel, because the question the sign answers is the same one — and
   * the console is a `top`, being a panel along the bottom of the window whose
   * handle is on the edge that faces the map, where upward is taller.
   */
  side: 'left' | 'right' | 'top'
  /** Named in the handle's tooltip: "Drag to resize the Analysis drawer". */
  what: string
  /** The width — or, for a `top`, the height — at the moment a drag starts. */
  width(): number
  /** Called on every movement, with the size the pointer is asking for. */
  resize(px: number): void
  /** Called once, when the drag ends, so the owner can persist what it chose. */
  commit(): void
  /** Double-click. Absent means a double-click does nothing. */
  reset?(): void
  /** Extra class, so each owner can style its own edge. */
  class?: string
}
 
/** Build the handle element. The caller decides where it goes. */
export function resizeHandle(options: ResizeHandleOptions): HTMLElement {
  const reset = options.reset
  // A separator's orientation is the line it *is*, not the axis it moves along,
  // which is why a column's edge — dragged left and right — is the vertical one.
  const vertical = options.side !== 'top'
  const node = el('div', {
    class: `panel-column-handle${options.class === undefined ? '' : ` ${options.class}`}`,
    title:
      `Drag to resize the ${options.what}` +
      (reset === undefined ? '' : '; double-click to reset it'),
    attrs: {
      role: 'separator',
      'aria-orientation': vertical ? 'vertical' : 'horizontal',
    },
  })
 
  let start = 0
  let startWidth = 0
  let dragging = false
 
  node.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return
    dragging = true
    start = vertical ? event.clientX : event.clientY
    startWidth = options.width()
    node.setPointerCapture(event.pointerId)
    node.classList.add('is-dragging')
    event.preventDefault()
  })
  node.addEventListener('pointermove', (event) => {
    if (!dragging) return
    // Away from the owner is bigger, whichever edge the handle is on: a left
    // panel grows rightward, a right panel and the console grow back towards
    // the pointer's origin.
    const delta =
      options.side === 'left' ? event.clientX - start : start - (vertical ? event.clientX : event.clientY)
    options.resize(startWidth + delta)
  })
  const end = (event: PointerEvent): void => {
    if (!dragging) return
    dragging = false
    node.releasePointerCapture(event.pointerId)
    node.classList.remove('is-dragging')
    options.commit()
  }
  node.addEventListener('pointerup', end)
  node.addEventListener('pointercancel', end)
  if (reset !== undefined) node.addEventListener('dblclick', () => reset())
  return node
}