Files
Graphite/frontend/src/components/window/PanelSubdivision.svelte
Keavon Chambers a56746c6bf Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)
* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs

* Code review fixes
2026-05-14 22:48:33 -07:00

295 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 { DockingSplitDirection, DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
const MIN_PANEL_SIZE = 100;
const DOUBLE_CLICK_MILLISECONDS = 500;
// Must match DOCUMENT_PANEL_SHARE / NON_DOCUMENT_PANEL_SHARE in utility_types.rs
const DOCUMENT_PANEL_SHARE = 0.8;
const EQUAL_PANEL_SHARE = 0.5;
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("portfolio");
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> = {};
// Gutter resize state
let gutterResizeRestore: [number, number] | undefined = undefined;
let pointerCaptureId: number | undefined = undefined;
let activeResizeCleanup: (() => void) | undefined = undefined;
let lastGutterClickTarget: EventTarget | undefined = undefined;
let lastGutterClickTime = 0;
let lastSubdivisionRef: PanelLayoutSubdivision | undefined = undefined;
// At even depths (0, 2, 4...) children are in a row, at odd depths (1, 3, 5...) in a column
$: horizontal = depth % 2 === 0;
// Compare by reference because `safe_not_equal` treats any store update as changed, which would wipe drag overrides
$: if (subdivision !== lastSubdivisionRef) {
sizeOverrides = {};
lastSubdivisionRef = subdivision;
}
// Reactive array of resolved sizes (merging backend defaults with local overrides)
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => {
const name = doc.name;
const unsaved = !doc.is_saved;
if (!editor.inDevelopmentMode()) return { name, unsaved };
const tooltipDescription = `Document ID: ${doc.id}`;
return { name, unsaved, tooltipLabel: name, tooltipDescription };
});
onDestroy(() => {
activeResizeCleanup?.();
});
function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) {
if (!(subdivision && "Split" in subdivision)) return;
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;
// Double-click resets the two adjacent panels to the default ratio (80:20 near document, otherwise 50:50)
const now = Date.now();
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
lastGutterClickTime = now;
lastGutterClickTarget = gutter;
if (isDoubleClick) {
const children = subdivision.Split.children;
const adjacentSum = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
const prevHasDocument = subtreeContainsDocument(children[prevIndex].subdivision);
const nextHasDocument = subtreeContainsDocument(children[nextIndex].subdivision);
let prevShare = EQUAL_PANEL_SHARE;
if (prevHasDocument && !nextHasDocument) prevShare = DOCUMENT_PANEL_SHARE;
else if (!prevHasDocument && nextHasDocument) prevShare = 1 - DOCUMENT_PANEL_SHARE;
sizeOverrides[prevIndex] = adjacentSum * prevShare;
sizeOverrides[nextIndex] = adjacentSum * (1 - prevShare);
sizeOverrides = sizeOverrides;
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
return;
}
const isHorizontal = horizontal;
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
// Only redistribute within the two adjacent panels' combined flex-grow total
const adjacentFlexGrowTotal = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
const adjacentPixelTotal = prevSiblingSize + nextSiblingSize;
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
const mouseStart = isHorizontal ? e.clientX : e.clientY;
const abortResize = () => {
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
pointerCaptureId = undefined;
removeListeners();
activeResizeCleanup = undefined;
if (gutterResizeRestore !== undefined) {
sizeOverrides[nextIndex] = gutterResizeRestore[0];
sizeOverrides[prevIndex] = gutterResizeRestore[1];
sizeOverrides = sizeOverrides;
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 = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]];
sizeOverrides[nextIndex] = (adjacentFlexGrowTotal * (nextSiblingSize + mouseDelta)) / adjacentPixelTotal;
sizeOverrides[prevIndex] = (adjacentFlexGrowTotal * (prevSiblingSize - mouseDelta)) / adjacentPixelTotal;
sizeOverrides = sizeOverrides;
};
const onPointerUp = () => {
gutterResizeRestore = undefined;
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(new Uint32Array(splitPath), new Float64Array(allSizes));
}
};
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;
}
function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
editor.movePanelTab(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
}
function groupDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
}
function splitDrop(targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) {
editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex);
}
function isDocumentGroup(state: PanelGroupState): boolean {
return state.tabs.some((t) => t === "Document" || t === "Welcome");
}
function subtreeContainsDocument(node: PanelLayoutSubdivision): boolean {
if ("PanelGroup" in node) return isDocumentGroup(node.PanelGroup.state);
if ("Split" in node) return node.Split.children.some((child) => subtreeContainsDocument(child.subdivision));
return false;
}
</script>
{#if subdivision && "PanelGroup" in subdivision}
{@const group = subdivision.PanelGroup}
{#if isDocumentGroup(group.state)}
<Panel
class="document-panel"
panelId={String(group.id)}
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)}
renameAction={(tabIndex, newName) => {
// Ensure the target document is the active one before renaming, since `RenameDocument` operates on the active document
editor.selectDocument($portfolio.documents[tabIndex].id);
editor.renameDocument(newName);
}}
tabActiveIndex={$portfolio.activeDocumentIndex}
groupDropAction={groupDrop}
splitDropAction={splitDrop}
/>
{:else}
<Panel
panelId={String(group.id)}
panelTypes={group.state.tabs}
tabLabels={group.state.tabs.map((name) => ({ name }))}
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}
groupDropAction={groupDrop}
splitDropAction={splitDrop}
/>
{/if}
{:else if subdivision && "Split" in subdivision}
{#each subdivision.Split.children as child, index}
{#if index > 0}
{#if horizontal}
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
{:else}
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
{/if}
{/if}
{#if horizontal}
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
<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} splitPath={[...splitPath, index]} />
</LayoutRow>
{/if}
{/each}
{/if}
<style lang="scss">
.workspace-grid-resize-gutter {
flex: 0 0 4px;
border-radius: 2px;
transition: background 0.2s 0s;
&.layout-row {
cursor: ns-resize;
}
&.layout-col {
cursor: ew-resize;
}
&:hover {
background: var(--color-5-dullgray);
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>