|
2 | 2 | import { Modal } from '@skeletonlabs/skeleton-svelte'; |
3 | 3 |
|
4 | 4 | let { commentary, id, openState = $bindable() } = $props(); |
| 5 | +
|
| 6 | + /** @type {HTMLElement | null} */ |
| 7 | + let headerEl = $state(null); |
| 8 | + let offsetX = $state(0); |
| 9 | + let offsetY = $state(0); |
| 10 | + let dragging = $state(false); |
| 11 | +
|
| 12 | + let startX = 0; |
| 13 | + let startY = 0; |
| 14 | + let startOffsetX = 0; |
| 15 | + let startOffsetY = 0; |
| 16 | +
|
| 17 | + /** Apply current offset to the Skeleton content wrapper (header's parent). */ |
| 18 | + $effect(() => { |
| 19 | + const contentEl = headerEl?.parentElement; |
| 20 | + if (!contentEl) return; |
| 21 | + contentEl.style.transform = `translate(${offsetX}px, ${offsetY}px)`; |
| 22 | + }); |
| 23 | +
|
| 24 | + /** |
| 25 | + * Clamp a candidate {x, y} so the modal stays grabbable on screen. |
| 26 | + * Keeps the header visible (at least HEADER_KEEP px tall) and the modal |
| 27 | + * within the viewport with EDGE_MARGIN px of slack. |
| 28 | + * @param {number} x |
| 29 | + * @param {number} y |
| 30 | + * @returns {{ x: number, y: number }} |
| 31 | + */ |
| 32 | + function clamp(x, y) { |
| 33 | + const contentEl = headerEl?.parentElement; |
| 34 | + if (!contentEl) return { x, y }; |
| 35 | + // Measure without our current transform so we get the un-translated rect. |
| 36 | + const prevTransform = contentEl.style.transform; |
| 37 | + contentEl.style.transform = ''; |
| 38 | + const rect = contentEl.getBoundingClientRect(); |
| 39 | + contentEl.style.transform = prevTransform; |
| 40 | +
|
| 41 | + const EDGE_MARGIN = 8; |
| 42 | + const HEADER_KEEP = headerEl?.offsetHeight ?? 56; |
| 43 | +
|
| 44 | + const minX = EDGE_MARGIN - rect.right; |
| 45 | + const maxX = window.innerWidth - EDGE_MARGIN - rect.left; |
| 46 | + const minY = EDGE_MARGIN - rect.top; |
| 47 | + const maxY = window.innerHeight - HEADER_KEEP - rect.top; |
| 48 | +
|
| 49 | + return { |
| 50 | + x: Math.min(Math.max(x, minX), maxX), |
| 51 | + y: Math.min(Math.max(y, minY), maxY) |
| 52 | + }; |
| 53 | + } |
| 54 | +
|
| 55 | + /** @param {PointerEvent} e */ |
| 56 | + function onPointerDown(e) { |
| 57 | + // Only primary button / single touch contact |
| 58 | + if (e.button !== undefined && e.button !== 0) return; |
| 59 | + dragging = true; |
| 60 | + startX = e.clientX; |
| 61 | + startY = e.clientY; |
| 62 | + startOffsetX = offsetX; |
| 63 | + startOffsetY = offsetY; |
| 64 | + /** @type {HTMLElement} */ (e.currentTarget).setPointerCapture(e.pointerId); |
| 65 | + e.preventDefault(); |
| 66 | + } |
| 67 | +
|
| 68 | + /** @param {PointerEvent} e */ |
| 69 | + function onPointerMove(e) { |
| 70 | + if (!dragging) return; |
| 71 | + const next = clamp(startOffsetX + (e.clientX - startX), startOffsetY + (e.clientY - startY)); |
| 72 | + offsetX = next.x; |
| 73 | + offsetY = next.y; |
| 74 | + } |
| 75 | +
|
| 76 | + /** @param {PointerEvent} e */ |
| 77 | + function onPointerUp(e) { |
| 78 | + if (!dragging) return; |
| 79 | + dragging = false; |
| 80 | + /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture(e.pointerId); |
| 81 | + } |
| 82 | +
|
| 83 | + /** Re-clamp on viewport resize so the modal stays on screen. */ |
| 84 | + function onResize() { |
| 85 | + const next = clamp(offsetX, offsetY); |
| 86 | + offsetX = next.x; |
| 87 | + offsetY = next.y; |
| 88 | + } |
5 | 89 | </script> |
6 | 90 |
|
| 91 | +<svelte:window onresize={onResize} /> |
| 92 | +
|
7 | 93 | <Modal |
8 | 94 | defaultOpen={true} |
9 | 95 | onOpenChange={() => (openState = false)} |
10 | 96 | classes="fassungskommentar_modal" |
11 | 97 | triggerBase="btn preset-tonal" |
12 | 98 | contentBase="w-[80vw] card preset-filled border border-gray-400 rounded-md text-black space-y-4 shadow-xl max-w-screen-lg" |
13 | 99 | base="" |
14 | | - backdropClasses="bg-black/[0.03]" |
| 100 | + backdropBackground="" |
| 101 | + backdropClasses="" |
15 | 102 | > |
16 | 103 | {#snippet content()} |
17 | | - <header class="flex items-center justify-center px-4 py-4 bg-gray-400 rounded-t-md"> |
| 104 | + <!-- svelte-ignore a11y_no_static_element_interactions -- header is a drag handle for the modal; not itself an interactive control --> |
| 105 | + <header |
| 106 | + bind:this={headerEl} |
| 107 | + class="flex items-center justify-center px-4 py-4 bg-gray-400 rounded-t-md select-none touch-none {dragging |
| 108 | + ? 'cursor-grabbing' |
| 109 | + : 'cursor-grab'}" |
| 110 | + onpointerdown={onPointerDown} |
| 111 | + onpointermove={onPointerMove} |
| 112 | + onpointerup={onPointerUp} |
| 113 | + onpointercancel={onPointerUp} |
| 114 | + > |
18 | 115 | <h1 |
19 | 116 | class={`text-md uppercase tracking-wider ${ |
20 | 117 | id[2] === 'A' ? 'text-red-800' : 'text-green-900' |
|
0 commit comments