Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions editor/src/messages/portfolio/portfolio_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ pub enum PortfolioMessage {
layers: Vec<LayerNodeIdentifier>,
},
PrevDocument,
ReorderDocument {
document_id: DocumentId,
new_index: usize,
},
RequestWelcomeScreenButtonsLayout,
RequestStatusBarInfoLayout,
SetActivePanel {
Expand Down
18 changes: 18 additions & 0 deletions editor/src/messages/portfolio/portfolio_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,24 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
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 => {
let donate = "https://graphite.art/donate/";

Expand Down
175 changes: 169 additions & 6 deletions frontend/src/components/window/Panel.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, tick } from "svelte";
import { getContext, onDestroy, tick } from "svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Data from "/src/components/panels/Data.svelte";
Expand All @@ -22,6 +22,8 @@
};
const BUTTON_LEFT = 0;
const BUTTON_MIDDLE = 1;
const BUTTON_RIGHT = 2;
const DRAG_ACTIVATION_DISTANCE = 5;

const editor = getContext<EditorWrapper>("editor");

Expand All @@ -32,6 +34,7 @@
export let panelType: PanelType | undefined = undefined;
export let clickAction: ((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;

let className = "";
Expand All @@ -43,6 +46,18 @@

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) {
if (e.target !== e.currentTarget) return;
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
Expand All @@ -52,21 +67,149 @@
await tick();
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>

<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-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}
<LayoutRow
class="tab"
classes={{ active: tabIndex === tabActiveIndex }}
tooltipLabel={tabLabel.tooltipLabel}
tooltipDescription={tabLabel.tooltipDescription}
on:click={(e) => {
e.stopPropagation();
clickAction?.(tabIndex);
}}
on:pointerdown={(e) => tabPointerDown(e, tabIndex)}
on:click={(e) => e.stopPropagation()}
on:auxclick={(e) => {
// Middle mouse button click
if (e.button === BUTTON_MIDDLE) {
Expand All @@ -90,11 +233,15 @@
}}
icon="CloseX"
size={16}
data-close-button
/>
{/if}
</LayoutRow>
{/each}
</LayoutRow>
{#if dragging && insertionMarkerLeft !== undefined}
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
{/if}
</LayoutRow>
<LayoutCol class="panel-body">
{#if panelType}
Expand All @@ -110,6 +257,7 @@
overflow: hidden;

.tab-bar {
position: relative;
height: 28px;
min-height: auto;
background: var(--color-1-nearblack); // Needed for the viewport hole punch on desktop
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/window/Workspace.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
emptySpaceAction={() => editor.newDocumentDialog()}
clickAction={(tabIndex) => editor.selectDocument($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}
bind:this={documentPanel}
/>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/stores/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
import { rasterizeSVG } from "/src/utility-functions/rasterization";
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";

Expand Down Expand Up @@ -41,6 +42,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
state.documents = data.openDocuments;
return state;
});
storeDocumentTabOrder({ subscribe });
});

subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/utility-functions/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
const PERSISTENCE_DB = "graphite";
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) {
await databaseSet("current_document_id", String(documentId));
}
Expand All @@ -17,8 +22,7 @@ export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersis
return documents;
});

const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
await databaseSet("documents_tab_order", documentOrder);
await storeDocumentTabOrder(portfolio);
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
}

Expand Down
7 changes: 7 additions & 0 deletions frontend/wrapper/src/editor_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,13 @@ impl EditorWrapper {
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)]
pub fn close_document_with_confirmation(&self, document_id: u64) {
let document_id = DocumentId(document_id);
Expand Down
Loading