Graphite/frontend/src/components/window/Workspace.svelte

280 lines
11 KiB
Svelte

<script lang="ts">
import { getContext, onDestroy } from "svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Panel from "/src/components/window/Panel.svelte";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";
const MIN_PANEL_SIZE = 100;
const PANEL_SIZES = {
/**/ root: 100,
/* ├─ */ content: 80,
/* │ ├─ */ document: 70,
/* │ └─ */ data: 30,
/* └─ */ details: 20,
/* ├─ */ properties: 45,
/* └─ */ layers: 55,
} as const;
let panelSizes: Record<string, number> = { ...PANEL_SIZES };
let documentPanel: Panel | undefined;
let gutterResizeRestore: [number, number] | undefined = undefined;
let pointerCaptureId: number | undefined = undefined;
let activeResizeCleanup: (() => void) | undefined = undefined;
// Reactive panel layout derived from backend state
$: panelLayout = $portfolio.panelLayout;
$: propertiesGroup = panelLayout.propertiesGroup;
$: layersGroup = panelLayout.layersGroup;
$: dataGroup = panelLayout.dataGroup;
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
const name = doc.details.name;
const unsaved = !doc.details.isSaved;
if (!editor.inDevelopmentMode()) return { name, unsaved };
const tooltipDescription = `Document ID: ${doc.id}`;
return { name, unsaved, tooltipLabel: name, tooltipDescription };
});
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("portfolio");
function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
editor.movePanelTab(sourcePanelId, targetPanelId, insertIndex);
}
function isPanelName(name: string): name is keyof typeof PANEL_SIZES {
return name in PANEL_SIZES;
}
function resetPanelSizes(e: MouseEvent) {
const gutter = e.currentTarget;
if (!(gutter instanceof HTMLDivElement)) return;
const nextSibling = gutter.nextElementSibling;
const prevSibling = gutter.previousElementSibling;
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement)) return;
const nextSiblingName = nextSibling.getAttribute("data-subdivision-name") || undefined;
const prevSiblingName = prevSibling.getAttribute("data-subdivision-name") || undefined;
if (!nextSiblingName || !prevSiblingName || !isPanelName(nextSiblingName) || !isPanelName(prevSiblingName)) return;
panelSizes = { ...panelSizes, [nextSiblingName]: PANEL_SIZES[nextSiblingName], [prevSiblingName]: PANEL_SIZES[prevSiblingName] };
}
function resizePanel(e: PointerEvent) {
const gutter = e.target;
if (!(gutter instanceof HTMLDivElement)) return;
const nextSibling = gutter.nextElementSibling;
const prevSibling = gutter.previousElementSibling;
const parentElement = gutter.parentElement;
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
const nextSiblingName = nextSibling.getAttribute("data-subdivision-name") || undefined;
const prevSiblingName = prevSibling.getAttribute("data-subdivision-name") || undefined;
if (!nextSiblingName || !prevSiblingName || !(nextSiblingName in PANEL_SIZES) || !(prevSiblingName in PANEL_SIZES)) return;
// Are we resizing horizontally?
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
// Get the current size in px of the panels being resized and the gutter
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
// Measure the resizing panels as a percentage of all sibling panels
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
// Prevent cursor flicker as mouse temporarily leaves the gutter
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
const mouseStart = isHorizontal ? e.clientX : e.clientY;
const abortResize = () => {
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
removeListeners();
activeResizeCleanup = undefined;
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
if (gutterResizeRestore !== undefined) {
panelSizes[nextSiblingName] = gutterResizeRestore[0];
panelSizes[prevSiblingName] = gutterResizeRestore[1];
gutterResizeRestore = undefined;
}
};
const onPointerMove = (e: PointerEvent) => {
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
let mouseDelta = mouseStart - mouseCurrent;
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
if (gutterResizeRestore === undefined) gutterResizeRestore = [panelSizes[nextSiblingName], panelSizes[prevSiblingName]];
panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
};
const onPointerUp = () => {
gutterResizeRestore = undefined;
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
removeListeners();
activeResizeCleanup = undefined;
};
const onMouseDown = (e: MouseEvent) => {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortResize();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") abortResize();
};
const addListeners = () => {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
};
const removeListeners = () => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
};
addListeners();
activeResizeCleanup = removeListeners;
}
onDestroy(() => {
activeResizeCleanup?.();
});
</script>
<LayoutRow class="workspace" data-workspace>
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["root"] }} data-subdivision-name="root">
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
<Panel
class="document-panel"
panelId="DocumentGroup"
panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]}
tabCloseButtons={true}
tabMinWidths={true}
tabLabels={documentTabLabels}
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}
/>
</LayoutRow>
{#if dataGroup.tabs.length > 0}
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["data"] }} data-subdivision-name="data">
<Panel
panelId="DataGroup"
panelTypes={dataGroup.tabs}
tabLabels={dataGroup.tabs.map((name) => ({ name }))}
tabActiveIndex={dataGroup.activeTabIndex}
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("DataGroup", tabIndex)}
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("DataGroup", oldIndex, newIndex)}
crossPanelDropAction={crossPanelDrop}
/>
</LayoutRow>
{/if}
</LayoutCol>
{#if propertiesGroup.tabs.length > 0 || layersGroup.tabs.length > 0}
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
{#if propertiesGroup.tabs.length > 0}
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
<Panel
panelId="PropertiesGroup"
panelTypes={propertiesGroup.tabs}
tabLabels={propertiesGroup.tabs.map((name) => ({ name }))}
tabActiveIndex={propertiesGroup.activeTabIndex}
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("PropertiesGroup", tabIndex)}
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("PropertiesGroup", oldIndex, newIndex)}
crossPanelDropAction={crossPanelDrop}
/>
</LayoutRow>
{/if}
{#if propertiesGroup.tabs.length > 0 && layersGroup.tabs.length > 0}
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
{/if}
{#if layersGroup.tabs.length > 0}
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
<Panel
panelId="LayersGroup"
panelTypes={layersGroup.tabs}
tabLabels={layersGroup.tabs.map((name) => ({ name }))}
tabActiveIndex={layersGroup.activeTabIndex}
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("LayersGroup", tabIndex)}
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("LayersGroup", oldIndex, newIndex)}
crossPanelDropAction={crossPanelDrop}
/>
</LayoutRow>
{/if}
</LayoutCol>
{/if}
</LayoutRow>
</LayoutRow>
<style lang="scss">
.workspace {
position: relative;
flex: 1 1 100%;
.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;
&.layout-row {
cursor: ns-resize;
}
&.layout-col {
cursor: ew-resize;
}
}
// Needed for the viewport hole punch on desktop
.viewport-hole-punch & .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;
}
}
</style>