Skip to content

Commit b52bf7b

Browse files
authored
Add support for reordering document tabs (#3999)
* Add support for reordering document tabs * Fix tab bar scrolling * Close tab without activating it on pointerdown
1 parent 55115d8 commit b52bf7b

File tree

7 files changed

+207
-8
lines changed

7 files changed

+207
-8
lines changed

editor/src/messages/portfolio/portfolio_message.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ pub enum PortfolioMessage {
126126
layers: Vec<LayerNodeIdentifier>,
127127
},
128128
PrevDocument,
129+
ReorderDocument {
130+
document_id: DocumentId,
131+
new_index: usize,
132+
},
129133
RequestWelcomeScreenButtonsLayout,
130134
RequestStatusBarInfoLayout,
131135
SetActivePanel {

editor/src/messages/portfolio/portfolio_message_handler.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,24 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
10531053
responses.add(PortfolioMessage::SelectDocument { document_id: prev_id });
10541054
}
10551055
}
1056+
PortfolioMessage::ReorderDocument { document_id, new_index } => {
1057+
let new_index = new_index.min(self.document_ids.len().saturating_sub(1));
1058+
let Some(current_index) = self.document_ids.iter().position(|&id| id == document_id) else {
1059+
return;
1060+
};
1061+
1062+
if new_index != current_index {
1063+
self.document_ids.remove(current_index);
1064+
self.document_ids.insert(new_index, document_id);
1065+
1066+
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
1067+
1068+
// Re-send the active document so the frontend recalculates the active tab index after reordering
1069+
if let Some(active_document_id) = self.active_document_id {
1070+
responses.add(FrontendMessage::UpdateActiveDocument { document_id: active_document_id });
1071+
}
1072+
}
1073+
}
10561074
PortfolioMessage::RequestWelcomeScreenButtonsLayout => {
10571075
let donate = "https://graphite.art/donate/";
10581076

frontend/src/components/window/Panel.svelte

Lines changed: 169 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { getContext, tick } from "svelte";
2+
import { getContext, onDestroy, tick } from "svelte";
33
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
44
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
55
import Data from "/src/components/panels/Data.svelte";
@@ -22,6 +22,8 @@
2222
};
2323
const BUTTON_LEFT = 0;
2424
const BUTTON_MIDDLE = 1;
25+
const BUTTON_RIGHT = 2;
26+
const DRAG_ACTIVATION_DISTANCE = 5;
2527
2628
const editor = getContext<EditorWrapper>("editor");
2729
@@ -32,6 +34,7 @@
3234
export let panelType: PanelType | undefined = undefined;
3335
export let clickAction: ((index: number) => void) | undefined = undefined;
3436
export let closeAction: ((index: number) => void) | undefined = undefined;
37+
export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
3538
export let emptySpaceAction: (() => void) | undefined = undefined;
3639
3740
let className = "";
@@ -43,6 +46,18 @@
4346
4447
let tabElements: (LayoutRow | undefined)[] = [];
4548
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+
4661
function onEmptySpaceAction(e: MouseEvent) {
4762
if (e.target !== e.currentTarget) return;
4863
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
@@ -52,21 +67,149 @@
5267
await tick();
5368
tabElements[newIndex]?.div?.()?.scrollIntoView();
5469
}
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+
}
55200
</script>
56201

57202
<LayoutCol on:pointerdown={() => panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
58203
<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}>
60205
{#each tabLabels as tabLabel, tabIndex}
61206
<LayoutRow
62207
class="tab"
63208
classes={{ active: tabIndex === tabActiveIndex }}
64209
tooltipLabel={tabLabel.tooltipLabel}
65210
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()}
70213
on:auxclick={(e) => {
71214
// Middle mouse button click
72215
if (e.button === BUTTON_MIDDLE) {
@@ -90,11 +233,15 @@
90233
}}
91234
icon="CloseX"
92235
size={16}
236+
data-close-button
93237
/>
94238
{/if}
95239
</LayoutRow>
96240
{/each}
97241
</LayoutRow>
242+
{#if dragging && insertionMarkerLeft !== undefined}
243+
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
244+
{/if}
98245
</LayoutRow>
99246
<LayoutCol class="panel-body">
100247
{#if panelType}
@@ -110,6 +257,7 @@
110257
overflow: hidden;
111258
112259
.tab-bar {
260+
position: relative;
113261
height: 28px;
114262
min-height: auto;
115263
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
@@ -217,6 +365,21 @@
217365
}
218366
}
219367
}
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+
}
220383
}
221384
222385
.panel-body {

frontend/src/components/window/Workspace.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
emptySpaceAction={() => editor.newDocumentDialog()}
152152
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
153153
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
154+
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
154155
tabActiveIndex={$portfolio.activeDocumentIndex}
155156
bind:this={documentPanel}
156157
/>

frontend/src/stores/portfolio.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { writable } from "svelte/store";
22
import type { Writable } from "svelte/store";
33
import type { SubscriptionsRouter } from "/src/subscriptions-router";
44
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
5+
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
56
import { rasterizeSVG } from "/src/utility-functions/rasterization";
67
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";
78

@@ -41,6 +42,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
4142
state.documents = data.openDocuments;
4243
return state;
4344
});
45+
storeDocumentTabOrder({ subscribe });
4446
});
4547

4648
subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {

frontend/src/utility-functions/persistence.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
66
const PERSISTENCE_DB = "graphite";
77
const PERSISTENCE_STORE = "store";
88

9+
export async function storeDocumentTabOrder(portfolio: PortfolioStore) {
10+
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
11+
await databaseSet("documents_tab_order", documentOrder);
12+
}
13+
914
export async function storeCurrentDocumentId(documentId: string) {
1015
await databaseSet("current_document_id", String(documentId));
1116
}
@@ -17,8 +22,7 @@ export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersis
1722
return documents;
1823
});
1924

20-
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
21-
await databaseSet("documents_tab_order", documentOrder);
25+
await storeDocumentTabOrder(portfolio);
2226
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
2327
}
2428

frontend/wrapper/src/editor_wrapper.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,13 @@ impl EditorWrapper {
427427
self.dispatch(message);
428428
}
429429

430+
#[wasm_bindgen(js_name = reorderDocument)]
431+
pub fn reorder_document(&self, document_id: u64, new_index: usize) {
432+
let document_id = DocumentId(document_id);
433+
let message = PortfolioMessage::ReorderDocument { document_id, new_index };
434+
self.dispatch(message);
435+
}
436+
430437
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
431438
pub fn close_document_with_confirmation(&self, document_id: u64) {
432439
let document_id = DocumentId(document_id);

0 commit comments

Comments
 (0)