280 lines
11 KiB
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>
|