mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-30 06:18:12 +08:00
Add support for reordering document tabs (#3999)
* Add support for reordering document tabs * Fix tab bar scrolling * Close tab without activating it on pointerdown
This commit is contained in:
@@ -126,6 +126,10 @@ pub enum PortfolioMessage {
|
|||||||
layers: Vec<LayerNodeIdentifier>,
|
layers: Vec<LayerNodeIdentifier>,
|
||||||
},
|
},
|
||||||
PrevDocument,
|
PrevDocument,
|
||||||
|
ReorderDocument {
|
||||||
|
document_id: DocumentId,
|
||||||
|
new_index: usize,
|
||||||
|
},
|
||||||
RequestWelcomeScreenButtonsLayout,
|
RequestWelcomeScreenButtonsLayout,
|
||||||
RequestStatusBarInfoLayout,
|
RequestStatusBarInfoLayout,
|
||||||
SetActivePanel {
|
SetActivePanel {
|
||||||
|
|||||||
@@ -1053,6 +1053,24 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
responses.add(PortfolioMessage::SelectDocument { document_id: prev_id });
|
responses.add(PortfolioMessage::SelectDocument { document_id: prev_id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PortfolioMessage::ReorderDocument { document_id, new_index } => {
|
||||||
|
let new_index = new_index.min(self.document_ids.len().saturating_sub(1));
|
||||||
|
let Some(current_index) = self.document_ids.iter().position(|&id| id == document_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_index != current_index {
|
||||||
|
self.document_ids.remove(current_index);
|
||||||
|
self.document_ids.insert(new_index, document_id);
|
||||||
|
|
||||||
|
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||||
|
|
||||||
|
// Re-send the active document so the frontend recalculates the active tab index after reordering
|
||||||
|
if let Some(active_document_id) = self.active_document_id {
|
||||||
|
responses.add(FrontendMessage::UpdateActiveDocument { document_id: active_document_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
PortfolioMessage::RequestWelcomeScreenButtonsLayout => {
|
PortfolioMessage::RequestWelcomeScreenButtonsLayout => {
|
||||||
let donate = "https://graphite.art/donate/";
|
let donate = "https://graphite.art/donate/";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, onDestroy, tick } from "svelte";
|
||||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||||
import Data from "/src/components/panels/Data.svelte";
|
import Data from "/src/components/panels/Data.svelte";
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
};
|
};
|
||||||
const BUTTON_LEFT = 0;
|
const BUTTON_LEFT = 0;
|
||||||
const BUTTON_MIDDLE = 1;
|
const BUTTON_MIDDLE = 1;
|
||||||
|
const BUTTON_RIGHT = 2;
|
||||||
|
const DRAG_ACTIVATION_DISTANCE = 5;
|
||||||
|
|
||||||
const editor = getContext<EditorWrapper>("editor");
|
const editor = getContext<EditorWrapper>("editor");
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
export let panelType: PanelType | undefined = undefined;
|
export let panelType: PanelType | undefined = undefined;
|
||||||
export let clickAction: ((index: number) => void) | undefined = undefined;
|
export let clickAction: ((index: number) => void) | undefined = undefined;
|
||||||
export let closeAction: ((index: number) => void) | undefined = undefined;
|
export let closeAction: ((index: number) => void) | undefined = undefined;
|
||||||
|
export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
|
||||||
export let emptySpaceAction: (() => void) | undefined = undefined;
|
export let emptySpaceAction: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
let className = "";
|
let className = "";
|
||||||
@@ -43,6 +46,18 @@
|
|||||||
|
|
||||||
let tabElements: (LayoutRow | undefined)[] = [];
|
let tabElements: (LayoutRow | undefined)[] = [];
|
||||||
|
|
||||||
|
// Tab drag-and-drop state
|
||||||
|
let dragStartState: { tabIndex: number; pointerX: number; pointerY: number } | undefined = undefined;
|
||||||
|
let dragging = false;
|
||||||
|
let insertionIndex: number | undefined = undefined;
|
||||||
|
let insertionMarkerLeft: number | undefined = undefined;
|
||||||
|
let lastPointerX = 0;
|
||||||
|
let tabGroupElement: LayoutRow | undefined = undefined;
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
removeDragListeners();
|
||||||
|
});
|
||||||
|
|
||||||
function onEmptySpaceAction(e: MouseEvent) {
|
function onEmptySpaceAction(e: MouseEvent) {
|
||||||
if (e.target !== e.currentTarget) return;
|
if (e.target !== e.currentTarget) return;
|
||||||
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
|
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
|
||||||
@@ -52,21 +67,149 @@
|
|||||||
await tick();
|
await tick();
|
||||||
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab drag-and-drop handlers
|
||||||
|
|
||||||
|
function tabPointerDown(e: PointerEvent, tabIndex: number) {
|
||||||
|
if (e.button !== BUTTON_LEFT) return;
|
||||||
|
if (e.target instanceof Element && e.target.closest("[data-close-button]")) return;
|
||||||
|
|
||||||
|
// Activate the tab upon pointer down
|
||||||
|
clickAction?.(tabIndex);
|
||||||
|
|
||||||
|
if (!reorderAction || tabLabels.length < 2) return;
|
||||||
|
|
||||||
|
dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY };
|
||||||
|
dragging = false;
|
||||||
|
insertionIndex = undefined;
|
||||||
|
insertionMarkerLeft = undefined;
|
||||||
|
|
||||||
|
addDragListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragStartState) return;
|
||||||
|
|
||||||
|
// Activate drag after moving beyond threshold
|
||||||
|
if (!dragging) {
|
||||||
|
const deltaX = Math.abs(e.clientX - dragStartState.pointerX);
|
||||||
|
const deltaY = Math.abs(e.clientY - dragStartState.pointerY);
|
||||||
|
if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return;
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPointerX = e.clientX;
|
||||||
|
|
||||||
|
// Only show insertion line while the cursor is within the tab bar
|
||||||
|
if (pointerIsInsideTabBar(e)) {
|
||||||
|
calculateInsertionIndex(lastPointerX);
|
||||||
|
} else {
|
||||||
|
insertionIndex = undefined;
|
||||||
|
insertionMarkerLeft = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragPointerUp() {
|
||||||
|
if (dragging && dragStartState && insertionIndex !== undefined) {
|
||||||
|
const oldIndex = dragStartState.tabIndex;
|
||||||
|
|
||||||
|
// Adjust for the fact that removing the dragged tab shifts indices
|
||||||
|
let newIndex = insertionIndex;
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
reorderAction?.(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragAbort(e: MouseEvent | KeyboardEvent) {
|
||||||
|
if (e instanceof MouseEvent && e.button === BUTTON_RIGHT) endDrag();
|
||||||
|
if (e instanceof KeyboardEvent && e.key === "Escape") endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragScroll() {
|
||||||
|
if (dragging && insertionIndex !== undefined) {
|
||||||
|
calculateInsertionIndex(lastPointerX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
dragStartState = undefined;
|
||||||
|
dragging = false;
|
||||||
|
insertionIndex = undefined;
|
||||||
|
insertionMarkerLeft = undefined;
|
||||||
|
removeDragListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerIsInsideTabBar(e: PointerEvent): boolean {
|
||||||
|
const groupDiv = tabGroupElement?.div?.();
|
||||||
|
if (!groupDiv) return false;
|
||||||
|
|
||||||
|
const rect = groupDiv.getBoundingClientRect();
|
||||||
|
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateInsertionIndex(pointerX: number) {
|
||||||
|
const groupDiv = tabGroupElement?.div?.();
|
||||||
|
if (!dragStartState || !groupDiv) return;
|
||||||
|
|
||||||
|
const groupRect = groupDiv.getBoundingClientRect();
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestMarkerLeft = 0;
|
||||||
|
|
||||||
|
// Walk through each tab to find the insertion point closest to the pointer
|
||||||
|
for (let i = 0; i < tabLabels.length; i++) {
|
||||||
|
const tabDiv = tabElements[i]?.div?.();
|
||||||
|
if (!tabDiv) continue;
|
||||||
|
|
||||||
|
const tabRect = tabDiv.getBoundingClientRect();
|
||||||
|
const tabMidpoint = tabRect.left + tabRect.width / 2;
|
||||||
|
|
||||||
|
if (pointerX > tabMidpoint) {
|
||||||
|
bestIndex = i + 1;
|
||||||
|
bestMarkerLeft = tabRect.right - groupRect.left;
|
||||||
|
} else {
|
||||||
|
bestIndex = i;
|
||||||
|
bestMarkerLeft = tabRect.left - groupRect.left;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertionIndex = bestIndex;
|
||||||
|
insertionMarkerLeft = Math.max(2, bestMarkerLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDragListeners() {
|
||||||
|
document.addEventListener("pointermove", dragPointerMove);
|
||||||
|
document.addEventListener("pointerup", dragPointerUp);
|
||||||
|
document.addEventListener("mousedown", dragAbort);
|
||||||
|
document.addEventListener("keydown", dragAbort);
|
||||||
|
tabGroupElement?.div?.()?.addEventListener("scroll", dragScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDragListeners() {
|
||||||
|
document.removeEventListener("pointermove", dragPointerMove);
|
||||||
|
document.removeEventListener("pointerup", dragPointerUp);
|
||||||
|
document.removeEventListener("mousedown", dragAbort);
|
||||||
|
document.removeEventListener("keydown", dragAbort);
|
||||||
|
tabGroupElement?.div?.()?.removeEventListener("scroll", dragScroll);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutCol on:pointerdown={() => panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
<LayoutCol on:pointerdown={() => panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
||||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||||
<LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction}>
|
<LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction} bind:this={tabGroupElement}>
|
||||||
{#each tabLabels as tabLabel, tabIndex}
|
{#each tabLabels as tabLabel, tabIndex}
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
class="tab"
|
class="tab"
|
||||||
classes={{ active: tabIndex === tabActiveIndex }}
|
classes={{ active: tabIndex === tabActiveIndex }}
|
||||||
tooltipLabel={tabLabel.tooltipLabel}
|
tooltipLabel={tabLabel.tooltipLabel}
|
||||||
tooltipDescription={tabLabel.tooltipDescription}
|
tooltipDescription={tabLabel.tooltipDescription}
|
||||||
on:click={(e) => {
|
on:pointerdown={(e) => tabPointerDown(e, tabIndex)}
|
||||||
e.stopPropagation();
|
on:click={(e) => e.stopPropagation()}
|
||||||
clickAction?.(tabIndex);
|
|
||||||
}}
|
|
||||||
on:auxclick={(e) => {
|
on:auxclick={(e) => {
|
||||||
// Middle mouse button click
|
// Middle mouse button click
|
||||||
if (e.button === BUTTON_MIDDLE) {
|
if (e.button === BUTTON_MIDDLE) {
|
||||||
@@ -90,11 +233,15 @@
|
|||||||
}}
|
}}
|
||||||
icon="CloseX"
|
icon="CloseX"
|
||||||
size={16}
|
size={16}
|
||||||
|
data-close-button
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/each}
|
{/each}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
|
{#if dragging && insertionMarkerLeft !== undefined}
|
||||||
|
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
|
||||||
|
{/if}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
<LayoutCol class="panel-body">
|
<LayoutCol class="panel-body">
|
||||||
{#if panelType}
|
{#if panelType}
|
||||||
@@ -110,6 +257,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
|
position: relative;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
|
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
|
||||||
@@ -217,6 +365,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(.tab-insertion-mark) .tab .icon-button {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-insertion-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
margin-left: -2px;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--color-e-nearwhite);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@
|
|||||||
emptySpaceAction={() => editor.newDocumentDialog()}
|
emptySpaceAction={() => editor.newDocumentDialog()}
|
||||||
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
|
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
|
||||||
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
||||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||||
bind:this={documentPanel}
|
bind:this={documentPanel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { writable } from "svelte/store";
|
|||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||||
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
||||||
|
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
|
||||||
import { rasterizeSVG } from "/src/utility-functions/rasterization";
|
import { rasterizeSVG } from "/src/utility-functions/rasterization";
|
||||||
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
|||||||
state.documents = data.openDocuments;
|
state.documents = data.openDocuments;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
|
storeDocumentTabOrder({ subscribe });
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {
|
subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
|||||||
const PERSISTENCE_DB = "graphite";
|
const PERSISTENCE_DB = "graphite";
|
||||||
const PERSISTENCE_STORE = "store";
|
const PERSISTENCE_STORE = "store";
|
||||||
|
|
||||||
|
export async function storeDocumentTabOrder(portfolio: PortfolioStore) {
|
||||||
|
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
|
||||||
|
await databaseSet("documents_tab_order", documentOrder);
|
||||||
|
}
|
||||||
|
|
||||||
export async function storeCurrentDocumentId(documentId: string) {
|
export async function storeCurrentDocumentId(documentId: string) {
|
||||||
await databaseSet("current_document_id", String(documentId));
|
await databaseSet("current_document_id", String(documentId));
|
||||||
}
|
}
|
||||||
@@ -17,8 +22,7 @@ export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersis
|
|||||||
return documents;
|
return documents;
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
|
await storeDocumentTabOrder(portfolio);
|
||||||
await databaseSet("documents_tab_order", documentOrder);
|
|
||||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,13 @@ impl EditorWrapper {
|
|||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = reorderDocument)]
|
||||||
|
pub fn reorder_document(&self, document_id: u64, new_index: usize) {
|
||||||
|
let document_id = DocumentId(document_id);
|
||||||
|
let message = PortfolioMessage::ReorderDocument { document_id, new_index };
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
||||||
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
||||||
let document_id = DocumentId(document_id);
|
let document_id = DocumentId(document_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user