|
1 | 1 | <script lang="ts"> |
2 | | - import { getContext, tick } from "svelte"; |
| 2 | + import { getContext, onDestroy, tick } from "svelte"; |
3 | 3 | import LayoutCol from "/src/components/layout/LayoutCol.svelte"; |
4 | 4 | import LayoutRow from "/src/components/layout/LayoutRow.svelte"; |
5 | 5 | import Data from "/src/components/panels/Data.svelte"; |
|
22 | 22 | }; |
23 | 23 | const BUTTON_LEFT = 0; |
24 | 24 | const BUTTON_MIDDLE = 1; |
| 25 | + const BUTTON_RIGHT = 2; |
| 26 | + const DRAG_ACTIVATION_DISTANCE = 5; |
25 | 27 |
|
26 | 28 | const editor = getContext<EditorWrapper>("editor"); |
27 | 29 |
|
|
32 | 34 | export let panelType: PanelType | undefined = undefined; |
33 | 35 | export let clickAction: ((index: number) => void) | undefined = undefined; |
34 | 36 | export let closeAction: ((index: number) => void) | undefined = undefined; |
| 37 | + export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined; |
35 | 38 | export let emptySpaceAction: (() => void) | undefined = undefined; |
36 | 39 |
|
37 | 40 | let className = ""; |
|
43 | 46 |
|
44 | 47 | let tabElements: (LayoutRow | undefined)[] = []; |
45 | 48 |
|
| 49 | + // Tab drag-and-drop state |
| 50 | + let dragStartState: { tabIndex: number; pointerX: number; pointerY: number } | undefined = undefined; |
| 51 | + let dragging = false; |
| 52 | + let insertionIndex: number | undefined = undefined; |
| 53 | + let insertionMarkerLeft: number | undefined = undefined; |
| 54 | + let lastPointerX = 0; |
| 55 | + let tabGroupElement: LayoutRow | undefined = undefined; |
| 56 | +
|
| 57 | + onDestroy(() => { |
| 58 | + removeDragListeners(); |
| 59 | + }); |
| 60 | +
|
46 | 61 | function onEmptySpaceAction(e: MouseEvent) { |
47 | 62 | if (e.target !== e.currentTarget) return; |
48 | 63 | if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.(); |
|
52 | 67 | await tick(); |
53 | 68 | tabElements[newIndex]?.div?.()?.scrollIntoView(); |
54 | 69 | } |
| 70 | +
|
| 71 | + // Tab drag-and-drop handlers |
| 72 | +
|
| 73 | + function tabPointerDown(e: PointerEvent, tabIndex: number) { |
| 74 | + if (e.button !== BUTTON_LEFT) return; |
| 75 | + if (e.target instanceof Element && e.target.closest("[data-close-button]")) return; |
| 76 | +
|
| 77 | + // Activate the tab upon pointer down |
| 78 | + clickAction?.(tabIndex); |
| 79 | +
|
| 80 | + if (!reorderAction || tabLabels.length < 2) return; |
| 81 | +
|
| 82 | + dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY }; |
| 83 | + dragging = false; |
| 84 | + insertionIndex = undefined; |
| 85 | + insertionMarkerLeft = undefined; |
| 86 | +
|
| 87 | + addDragListeners(); |
| 88 | + } |
| 89 | +
|
| 90 | + function dragPointerMove(e: PointerEvent) { |
| 91 | + if (!dragStartState) return; |
| 92 | +
|
| 93 | + // Activate drag after moving beyond threshold |
| 94 | + if (!dragging) { |
| 95 | + const deltaX = Math.abs(e.clientX - dragStartState.pointerX); |
| 96 | + const deltaY = Math.abs(e.clientY - dragStartState.pointerY); |
| 97 | + if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return; |
| 98 | + dragging = true; |
| 99 | + } |
| 100 | +
|
| 101 | + lastPointerX = e.clientX; |
| 102 | +
|
| 103 | + // Only show insertion line while the cursor is within the tab bar |
| 104 | + if (pointerIsInsideTabBar(e)) { |
| 105 | + calculateInsertionIndex(lastPointerX); |
| 106 | + } else { |
| 107 | + insertionIndex = undefined; |
| 108 | + insertionMarkerLeft = undefined; |
| 109 | + } |
| 110 | + } |
| 111 | +
|
| 112 | + function dragPointerUp() { |
| 113 | + if (dragging && dragStartState && insertionIndex !== undefined) { |
| 114 | + const oldIndex = dragStartState.tabIndex; |
| 115 | +
|
| 116 | + // Adjust for the fact that removing the dragged tab shifts indices |
| 117 | + let newIndex = insertionIndex; |
| 118 | + if (newIndex > oldIndex) newIndex -= 1; |
| 119 | +
|
| 120 | + if (oldIndex !== newIndex) { |
| 121 | + reorderAction?.(oldIndex, newIndex); |
| 122 | + } |
| 123 | + } |
| 124 | +
|
| 125 | + endDrag(); |
| 126 | + } |
| 127 | +
|
| 128 | + function dragAbort(e: MouseEvent | KeyboardEvent) { |
| 129 | + if (e instanceof MouseEvent && e.button === BUTTON_RIGHT) endDrag(); |
| 130 | + if (e instanceof KeyboardEvent && e.key === "Escape") endDrag(); |
| 131 | + } |
| 132 | +
|
| 133 | + function dragScroll() { |
| 134 | + if (dragging && insertionIndex !== undefined) { |
| 135 | + calculateInsertionIndex(lastPointerX); |
| 136 | + } |
| 137 | + } |
| 138 | +
|
| 139 | + function endDrag() { |
| 140 | + dragStartState = undefined; |
| 141 | + dragging = false; |
| 142 | + insertionIndex = undefined; |
| 143 | + insertionMarkerLeft = undefined; |
| 144 | + removeDragListeners(); |
| 145 | + } |
| 146 | +
|
| 147 | + function pointerIsInsideTabBar(e: PointerEvent): boolean { |
| 148 | + const groupDiv = tabGroupElement?.div?.(); |
| 149 | + if (!groupDiv) return false; |
| 150 | +
|
| 151 | + const rect = groupDiv.getBoundingClientRect(); |
| 152 | + return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; |
| 153 | + } |
| 154 | +
|
| 155 | + function calculateInsertionIndex(pointerX: number) { |
| 156 | + const groupDiv = tabGroupElement?.div?.(); |
| 157 | + if (!dragStartState || !groupDiv) return; |
| 158 | +
|
| 159 | + const groupRect = groupDiv.getBoundingClientRect(); |
| 160 | + let bestIndex = 0; |
| 161 | + let bestMarkerLeft = 0; |
| 162 | +
|
| 163 | + // Walk through each tab to find the insertion point closest to the pointer |
| 164 | + for (let i = 0; i < tabLabels.length; i++) { |
| 165 | + const tabDiv = tabElements[i]?.div?.(); |
| 166 | + if (!tabDiv) continue; |
| 167 | +
|
| 168 | + const tabRect = tabDiv.getBoundingClientRect(); |
| 169 | + const tabMidpoint = tabRect.left + tabRect.width / 2; |
| 170 | +
|
| 171 | + if (pointerX > tabMidpoint) { |
| 172 | + bestIndex = i + 1; |
| 173 | + bestMarkerLeft = tabRect.right - groupRect.left; |
| 174 | + } else { |
| 175 | + bestIndex = i; |
| 176 | + bestMarkerLeft = tabRect.left - groupRect.left; |
| 177 | + break; |
| 178 | + } |
| 179 | + } |
| 180 | +
|
| 181 | + insertionIndex = bestIndex; |
| 182 | + insertionMarkerLeft = Math.max(2, bestMarkerLeft); |
| 183 | + } |
| 184 | +
|
| 185 | + function addDragListeners() { |
| 186 | + document.addEventListener("pointermove", dragPointerMove); |
| 187 | + document.addEventListener("pointerup", dragPointerUp); |
| 188 | + document.addEventListener("mousedown", dragAbort); |
| 189 | + document.addEventListener("keydown", dragAbort); |
| 190 | + tabGroupElement?.div?.()?.addEventListener("scroll", dragScroll); |
| 191 | + } |
| 192 | +
|
| 193 | + function removeDragListeners() { |
| 194 | + document.removeEventListener("pointermove", dragPointerMove); |
| 195 | + document.removeEventListener("pointerup", dragPointerUp); |
| 196 | + document.removeEventListener("mousedown", dragAbort); |
| 197 | + document.removeEventListener("keydown", dragAbort); |
| 198 | + tabGroupElement?.div?.()?.removeEventListener("scroll", dragScroll); |
| 199 | + } |
55 | 200 | </script> |
56 | 201 |
|
57 | 202 | <LayoutCol on:pointerdown={() => panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}> |
58 | 203 | <LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}> |
59 | | - <LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction}> |
| 204 | + <LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction} bind:this={tabGroupElement}> |
60 | 205 | {#each tabLabels as tabLabel, tabIndex} |
61 | 206 | <LayoutRow |
62 | 207 | class="tab" |
63 | 208 | classes={{ active: tabIndex === tabActiveIndex }} |
64 | 209 | tooltipLabel={tabLabel.tooltipLabel} |
65 | 210 | tooltipDescription={tabLabel.tooltipDescription} |
66 | | - on:click={(e) => { |
67 | | - e.stopPropagation(); |
68 | | - clickAction?.(tabIndex); |
69 | | - }} |
| 211 | + on:pointerdown={(e) => tabPointerDown(e, tabIndex)} |
| 212 | + on:click={(e) => e.stopPropagation()} |
70 | 213 | on:auxclick={(e) => { |
71 | 214 | // Middle mouse button click |
72 | 215 | if (e.button === BUTTON_MIDDLE) { |
|
90 | 233 | }} |
91 | 234 | icon="CloseX" |
92 | 235 | size={16} |
| 236 | + data-close-button |
93 | 237 | /> |
94 | 238 | {/if} |
95 | 239 | </LayoutRow> |
96 | 240 | {/each} |
97 | 241 | </LayoutRow> |
| 242 | + {#if dragging && insertionMarkerLeft !== undefined} |
| 243 | + <div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div> |
| 244 | + {/if} |
98 | 245 | </LayoutRow> |
99 | 246 | <LayoutCol class="panel-body"> |
100 | 247 | {#if panelType} |
|
110 | 257 | overflow: hidden; |
111 | 258 |
|
112 | 259 | .tab-bar { |
| 260 | + position: relative; |
113 | 261 | height: 28px; |
114 | 262 | min-height: auto; |
115 | 263 | background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop |
|
217 | 365 | } |
218 | 366 | } |
219 | 367 | } |
| 368 | +
|
| 369 | + &:has(.tab-insertion-mark) .tab .icon-button { |
| 370 | + pointer-events: none; |
| 371 | + } |
| 372 | +
|
| 373 | + .tab-insertion-mark { |
| 374 | + position: absolute; |
| 375 | + top: 4px; |
| 376 | + bottom: 4px; |
| 377 | + width: 3px; |
| 378 | + margin-left: -2px; |
| 379 | + z-index: 1; |
| 380 | + background: var(--color-e-nearwhite); |
| 381 | + pointer-events: none; |
| 382 | + } |
220 | 383 | } |
221 | 384 |
|
222 | 385 | .panel-body { |
|
0 commit comments