mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Add support for persistent storage of panel layouts, sizes, and active tabs (#4017)
* Add persistence to panel layouts * Fix and persist the Window > Focus Document mode * Add a Window > Reset Workspace action * workspace_layout.json -> workspace_layout.ron * Fix native app hole punch * Cleanup review pass
This commit is contained in:
@@ -51,39 +51,25 @@
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
}
|
||||
.release-candidate-expiry {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
padding: 12px 40px;
|
||||
border-radius: 4px;
|
||||
text-align-last: justify;
|
||||
font-size: 18px;
|
||||
z-index: 1000;
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch .workspace .workspace-grid-subdivision:has(.panel.document-panel)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.release-candidate-expiry {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--color-e-nearwhite);
|
||||
color: var(--color-2-mildblack);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
padding: 12px 40px;
|
||||
border-radius: 4px;
|
||||
text-align-last: justify;
|
||||
font-size: 18px;
|
||||
z-index: 1000;
|
||||
|
||||
.text-label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.text-label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
export let subdivision: PanelLayoutSubdivision;
|
||||
export let subdivision: PanelLayoutSubdivision | undefined;
|
||||
export let depth: number;
|
||||
export let splitPath: number[] = [];
|
||||
|
||||
// Local size overrides for gutter resizing (keyed by child index)
|
||||
let sizeOverrides: Record<number, number> = {};
|
||||
@@ -29,7 +30,7 @@
|
||||
// Reset overrides when the subdivision changes (e.g., backend sends a new layout)
|
||||
$: if (subdivision) sizeOverrides = {};
|
||||
// Reactive array of resolved sizes (merging backend defaults with local overrides)
|
||||
$: resolvedSizes = "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
|
||||
const name = doc.details.name;
|
||||
const unsaved = !doc.details.isSaved;
|
||||
@@ -44,7 +45,7 @@
|
||||
});
|
||||
|
||||
function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) {
|
||||
if (!("Split" in subdivision)) return;
|
||||
if (!(subdivision && "Split" in subdivision)) return;
|
||||
|
||||
const gutter = e.target;
|
||||
if (!(gutter instanceof HTMLDivElement)) return;
|
||||
@@ -55,13 +56,13 @@
|
||||
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
|
||||
|
||||
// Double-click resets both adjacent panels to their default sizes
|
||||
const children = subdivision.Split.children;
|
||||
const now = Date.now();
|
||||
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
|
||||
lastGutterClickTime = now;
|
||||
lastGutterClickTarget = gutter;
|
||||
if (isDoubleClick) {
|
||||
sizeOverrides = { ...sizeOverrides, [prevIndex]: children[prevIndex].size, [nextIndex]: children[nextIndex].size };
|
||||
sizeOverrides = {};
|
||||
editor.resetPanelGroupSizes(splitPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,6 +114,12 @@
|
||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||
removeListeners();
|
||||
activeResizeCleanup = undefined;
|
||||
|
||||
// Persist the resized sizes to the backend
|
||||
if ("Split" in subdivision) {
|
||||
const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size);
|
||||
editor.setPanelGroupSizes(splitPath, allSizes);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
@@ -159,7 +166,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if "PanelGroup" in subdivision}
|
||||
{#if subdivision && "PanelGroup" in subdivision}
|
||||
{@const group = subdivision.PanelGroup}
|
||||
{#if isDocumentGroup(group.state)}
|
||||
<Panel
|
||||
@@ -182,7 +189,7 @@
|
||||
panelId={String(group.id)}
|
||||
panelTypes={group.state.tabs}
|
||||
tabLabels={group.state.tabs.map((name) => ({ name }))}
|
||||
tabActiveIndex={Number(group.state.activeTabIndex)}
|
||||
tabActiveIndex={Number(group.state.active_tab_index)}
|
||||
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
|
||||
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
|
||||
crossPanelDropAction={crossPanelDrop}
|
||||
@@ -190,7 +197,7 @@
|
||||
splitDropAction={splitDrop}
|
||||
/>
|
||||
{/if}
|
||||
{:else if "Split" in subdivision}
|
||||
{:else if subdivision && "Split" in subdivision}
|
||||
{#each subdivision.Split.children as child, index}
|
||||
{#if index > 0}
|
||||
{#if horizontal}
|
||||
@@ -201,28 +208,17 @@
|
||||
{/if}
|
||||
{#if horizontal}
|
||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
|
||||
</LayoutCol>
|
||||
{:else}
|
||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
|
||||
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
|
||||
</LayoutRow>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.workspace-grid-subdivision {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 28px;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-resize-gutter {
|
||||
flex: 0 0 4px;
|
||||
border-radius: 2px;
|
||||
@@ -241,4 +237,25 @@
|
||||
transition: background 0.2s 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-grid-subdivision {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 28px;
|
||||
|
||||
&.folded {
|
||||
flex-grow: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Needed for the viewport hole punch on desktop
|
||||
.viewport-hole-punch .workspace-grid-subdivision:has(> .panel.document-panel)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 6px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { saveEditorPreferences, loadEditorPreferences, storeDocument, removeDocument, loadFirstDocument, loadRestDocuments, saveActiveDocument } from "/src/utility-functions/persistence";
|
||||
import {
|
||||
saveEditorPreferences,
|
||||
loadEditorPreferences,
|
||||
saveWorkspaceLayout,
|
||||
loadWorkspaceLayout,
|
||||
storeDocument,
|
||||
removeDocument,
|
||||
loadFirstDocument,
|
||||
loadRestDocuments,
|
||||
saveActiveDocument,
|
||||
} from "/src/utility-functions/persistence";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
@@ -22,6 +32,14 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi
|
||||
await loadEditorPreferences(editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerSaveWorkspaceLayout", async (data) => {
|
||||
await saveWorkspaceLayout(data.workspaceLayout);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerLoadWorkspaceLayout", async () => {
|
||||
await loadWorkspaceLayout(editor);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
|
||||
await storeDocument(data, portfolio);
|
||||
});
|
||||
@@ -53,6 +71,8 @@ export function destroyPersistenceManager() {
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveWorkspaceLayout");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument");
|
||||
|
||||
@@ -18,7 +18,7 @@ const initialState: PortfolioStoreState = {
|
||||
unsaved: false,
|
||||
documents: [],
|
||||
activeDocumentIndex: 0,
|
||||
panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n },
|
||||
panelLayout: {},
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
@@ -161,6 +161,15 @@ export async function loadEditorPreferences(editor: EditorWrapper) {
|
||||
editor.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
|
||||
}
|
||||
|
||||
export async function saveWorkspaceLayout(layout: unknown) {
|
||||
await databaseSet("workspace_layout", layout);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceLayout(editor: EditorWrapper) {
|
||||
const layout = await databaseGet<Record<string, unknown>>("workspace_layout");
|
||||
if (layout) editor.loadWorkspaceLayout(layout);
|
||||
}
|
||||
|
||||
export async function wipeDocuments() {
|
||||
await databaseDelete("documents_tab_order");
|
||||
await databaseDelete("current_document_id");
|
||||
|
||||
@@ -379,6 +379,16 @@ impl EditorWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = loadWorkspaceLayout)]
|
||||
pub fn load_workspace_layout(&self, layout: JsValue) {
|
||||
let Ok(layout) = serde_wasm_bindgen::from_value(layout) else {
|
||||
log::error!("Failed to deserialize workspace layout");
|
||||
return;
|
||||
};
|
||||
let message = PortfolioMessage::LoadWorkspaceLayout { layout };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = selectDocument)]
|
||||
pub fn select_document(&self, document_id: u64) {
|
||||
let document_id = DocumentId(document_id);
|
||||
@@ -486,6 +496,21 @@ impl EditorWrapper {
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = resetPanelGroupSizes)]
|
||||
pub fn reset_panel_group_sizes(&self, split_path: JsValue) {
|
||||
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
|
||||
let message = PortfolioMessage::ResetPanelGroupSizes { split_path };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = setPanelGroupSizes)]
|
||||
pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) {
|
||||
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
|
||||
let sizes: Vec<f64> = serde_wasm_bindgen::from_value(sizes).unwrap();
|
||||
let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
||||
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
||||
let document_id = DocumentId(document_id);
|
||||
|
||||
Reference in New Issue
Block a user