-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Rearrange document tabs via drag and drop #3707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,7 @@ | |
| </script> | ||
|
|
||
| <script lang="ts"> | ||
| import { getContext, tick } from "svelte"; | ||
| import { getContext, onMount, onDestroy, tick } from "svelte"; | ||
|
|
||
| import type { Editor } from "@graphite/editor"; | ||
| import { isEventSupported } from "@graphite/utility-functions/platform"; | ||
|
|
@@ -26,8 +26,15 @@ | |
| import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte"; | ||
| import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte"; | ||
|
|
||
| type DragState = { | ||
| startIndex: number; | ||
| startX: number; | ||
| startY: number; | ||
| }; | ||
|
|
||
| const BUTTON_LEFT = 0; | ||
| const BUTTON_MIDDLE = 1; | ||
| const DRAG_THRESHOLD = 5; | ||
|
|
||
| const editor = getContext<Editor>("editor"); | ||
|
|
||
|
|
@@ -39,6 +46,7 @@ | |
| export let clickAction: ((index: number) => void) | undefined = undefined; | ||
| export let closeAction: ((index: number) => void) | undefined = undefined; | ||
| export let emptySpaceAction: (() => void) | undefined = undefined; | ||
| export let reorderAction: ((fromIndex: number, toIndex: number) => void) | undefined = undefined; | ||
|
|
||
| let className = ""; | ||
| export { className as class }; | ||
|
|
@@ -48,6 +56,14 @@ | |
| export let styles: Record<string, string | number | undefined> = {}; | ||
|
|
||
| let tabElements: (LayoutRow | undefined)[] = []; | ||
| let tabGroupElement: LayoutRow | undefined; | ||
|
|
||
| // Drag-and-drop state | ||
| let dragState: DragState | undefined = undefined; | ||
| let dragInPanel = false; | ||
| let dragDropIndex: number | undefined = undefined; | ||
| let dragIndicatorLeft: number | undefined = undefined; | ||
| let justFinishedDrag = false; | ||
|
|
||
| function onEmptySpaceAction(e: MouseEvent) { | ||
| if (e.target !== e.currentTarget) return; | ||
|
|
@@ -58,19 +74,139 @@ | |
| await tick(); | ||
| tabElements[newIndex]?.div?.()?.scrollIntoView(); | ||
| } | ||
|
|
||
| // --- Drag-and-drop --- | ||
|
|
||
| function tabPointerDown(e: PointerEvent, tabIndex: number) { | ||
| if (e.button !== BUTTON_LEFT || !reorderAction) return; | ||
| dragState = { startIndex: tabIndex, startX: e.clientX, startY: e.clientY }; | ||
| } | ||
|
|
||
| function calculateDropIndex(clientX: number): { index: number; left: number } | undefined { | ||
| const groupDiv = tabGroupElement?.div?.(); | ||
| if (!groupDiv) return undefined; | ||
|
|
||
| const groupRect = groupDiv.getBoundingClientRect(); | ||
| const scrollLeft = groupDiv.scrollLeft; | ||
|
|
||
| for (let i = 0; i < tabLabels.length; i++) { | ||
| const el = tabElements[i]?.div?.(); | ||
| if (!el) continue; | ||
|
|
||
| const rect = el.getBoundingClientRect(); | ||
| if (clientX < rect.left || clientX > rect.right) continue; | ||
|
|
||
| const pointerFraction = (clientX - rect.left) / rect.width; | ||
| if (pointerFraction < 0.5) { | ||
| return { index: i, left: rect.left - groupRect.left + scrollLeft }; | ||
| } else { | ||
| return { index: i + 1, left: rect.right - groupRect.left + scrollLeft }; | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| function draggingPointerMove(e: PointerEvent) { | ||
| if (!dragState || !tabGroupElement) return; | ||
|
|
||
| if (!dragInPanel) { | ||
| const distance = Math.hypot(e.clientX - dragState.startX, e.clientY - dragState.startY); | ||
| if (distance <= DRAG_THRESHOLD) return; | ||
| dragInPanel = true; | ||
| } | ||
|
|
||
| if (dragInPanel) { | ||
| const result = calculateDropIndex(e.clientX); | ||
| if (result) { | ||
| // Adjust index to account for the item being removed from its original position | ||
| let targetIndex = result.index; | ||
| if (targetIndex > dragState.startIndex) targetIndex -= 1; | ||
|
|
||
| if (targetIndex === dragState.startIndex) { | ||
| dragDropIndex = undefined; | ||
| dragIndicatorLeft = undefined; | ||
| } else { | ||
| dragDropIndex = targetIndex; | ||
| dragIndicatorLeft = result.left; | ||
| } | ||
| } else { | ||
| dragDropIndex = undefined; | ||
| dragIndicatorLeft = undefined; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function draggingPointerUp() { | ||
| if (dragInPanel && dragDropIndex !== undefined) { | ||
| reorderAction?.(dragState.startIndex, dragDropIndex); | ||
| justFinishedDrag = true; | ||
| // Clear after the current tick so a same-tick click is still suppressed, but the next intentional click is not swallowed | ||
| setTimeout(() => { | ||
| justFinishedDrag = false; | ||
| }, 0); | ||
| } else if (justFinishedDrag) { | ||
| // Avoid right-click abort getting stuck with `justFinishedDrag` set and blocking the first subsequent click | ||
| setTimeout(() => { | ||
| justFinishedDrag = false; | ||
| }, 0); | ||
| } | ||
|
|
||
| abortDrag(); | ||
| } | ||
|
|
||
| function draggingMouseDown(e: MouseEvent) { | ||
| if (e.button === 2 && dragInPanel) { | ||
| justFinishedDrag = true; | ||
| abortDrag(); | ||
| } | ||
| } | ||
|
|
||
| function draggingKeyDown(e: KeyboardEvent) { | ||
| if (e.key === "Escape" && dragInPanel) { | ||
| justFinishedDrag = true; | ||
| abortDrag(); | ||
| } | ||
| } | ||
|
|
||
| function abortDrag() { | ||
| dragState = undefined; | ||
| dragInPanel = false; | ||
| dragDropIndex = undefined; | ||
| dragIndicatorLeft = undefined; | ||
| } | ||
|
|
||
| onMount(() => { | ||
| addEventListener("pointermove", draggingPointerMove); | ||
| addEventListener("pointerup", draggingPointerUp); | ||
| addEventListener("mousedown", draggingMouseDown); | ||
| addEventListener("keydown", draggingKeyDown); | ||
| }); | ||
|
|
||
| onDestroy(() => { | ||
| removeEventListener("pointermove", draggingPointerMove); | ||
| removeEventListener("pointerup", draggingPointerUp); | ||
| removeEventListener("mousedown", draggingMouseDown); | ||
| removeEventListener("keydown", draggingKeyDown); | ||
| }); | ||
|
Comment on lines
+179
to
+191
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Global event listeners for drag-and-drop are added in A more performant and robust approach is to attach these listeners to the To implement this, you can:
|
||
| </script> | ||
|
|
||
| <LayoutCol on:pointerdown={() => panelType && editor.handle.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}> | ||
| <LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}> | ||
| <LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction}> | ||
| <LayoutRow class="tab-group" classes={{ "drag-ongoing": dragInPanel }} scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction} bind:this={tabGroupElement}> | ||
| {#each tabLabels as tabLabel, tabIndex} | ||
| <LayoutRow | ||
| class="tab" | ||
| classes={{ active: tabIndex === tabActiveIndex }} | ||
| classes={{ active: tabIndex === tabActiveIndex, dragging: dragInPanel && dragState?.startIndex === tabIndex }} | ||
| tooltipLabel={tabLabel.tooltipLabel} | ||
| tooltipDescription={tabLabel.tooltipDescription} | ||
| on:pointerdown={(e) => tabPointerDown(e, tabIndex)} | ||
| on:click={(e) => { | ||
| e.stopPropagation(); | ||
| if (justFinishedDrag) { | ||
| justFinishedDrag = false; | ||
| return; | ||
| } | ||
| clickAction?.(tabIndex); | ||
| }} | ||
| on:auxclick={(e) => { | ||
|
|
@@ -110,6 +246,9 @@ | |
| {/if} | ||
| </LayoutRow> | ||
| {/each} | ||
| {#if dragInPanel && dragDropIndex !== undefined && dragIndicatorLeft !== undefined} | ||
| <div class="drop-indicator" style:left={`${dragIndicatorLeft}px`} /> | ||
| {/if} | ||
| </LayoutRow> | ||
| </LayoutRow> | ||
| <LayoutCol class="panel-body"> | ||
|
|
@@ -150,13 +289,21 @@ | |
| flex: 0 0 auto; | ||
| } | ||
|
|
||
| &.drag-ongoing .tab { | ||
| pointer-events: none; | ||
| } | ||
|
|
||
| .tab { | ||
| flex: 0 1 auto; | ||
| height: 28px; | ||
| padding: 0 8px; | ||
| align-items: center; | ||
| position: relative; | ||
|
|
||
| &.dragging { | ||
| opacity: 0.5; | ||
| } | ||
|
|
||
| &.active { | ||
| background: var(--color-3-darkgray); | ||
| border-radius: 6px 6px 0 0; | ||
|
|
@@ -232,6 +379,17 @@ | |
| } | ||
| } | ||
| } | ||
|
|
||
| .drop-indicator { | ||
| position: absolute; | ||
| top: 4px; | ||
| bottom: 4px; | ||
| width: 2px; | ||
| background: var(--color-e-nearwhite); | ||
| pointer-events: none; | ||
| z-index: 1; | ||
| transform: translateX(-50%); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Accessing
dragState.startIndexwithout a null check.dragStateis typedDragState | undefinedand the enclosingifcondition doesn't narrow it. Add an explicit guard to avoid a potential runtimeTypeError.Prompt for AI agents