433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
import type { CSSProperties } from "react";
|
|
|
|
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
|
|
|
interface PaneCell {
|
|
xStart: number;
|
|
xEnd: number;
|
|
yStart: number;
|
|
yEnd: number;
|
|
}
|
|
|
|
interface Track {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
export interface WorkbenchResizeHandle {
|
|
axis: "horizontal" | "vertical";
|
|
ratioIndex: number;
|
|
position: number;
|
|
crossStart: number;
|
|
crossEnd: number;
|
|
localStart: number;
|
|
localEnd: number;
|
|
beforeUnitCount: number;
|
|
afterUnitCount: number;
|
|
}
|
|
|
|
export interface WorkbenchLayoutGeometry {
|
|
gridStyle: CSSProperties;
|
|
paneStyles: Array<CSSProperties | undefined>;
|
|
resizeHandles: WorkbenchResizeHandle[];
|
|
splitRatios: number[];
|
|
}
|
|
|
|
const MIN_RATIO = 0.05;
|
|
const MAX_RATIO = 0.95;
|
|
const MIN_PANE_EXTENT_PX = 160;
|
|
const MAIN_PANE_RATIO = 1.65 / 2.65;
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function ratioAt(
|
|
ratios: readonly number[],
|
|
index: number,
|
|
fallback: number,
|
|
): number {
|
|
const value = ratios[index];
|
|
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
|
|
}
|
|
|
|
function tracksTemplate(tracks: readonly Track[]): string {
|
|
return tracks
|
|
.map((track) => {
|
|
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
|
|
return `minmax(0, ${weight}fr)`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
function sequentialTracks(
|
|
count: number,
|
|
ratios: readonly number[],
|
|
ratioOffset: number,
|
|
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
|
|
const tracks: Track[] = [];
|
|
const handles: WorkbenchResizeHandle[] = [];
|
|
const resolvedRatios: number[] = [];
|
|
let start = 0;
|
|
|
|
for (let index = 0; index < count - 1; index += 1) {
|
|
const remainingPaneCount = count - index;
|
|
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
|
|
const end = start + (1 - start) * ratio;
|
|
tracks.push({ start, end });
|
|
handles.push({
|
|
axis: "vertical",
|
|
ratioIndex: ratioOffset + index,
|
|
position: end,
|
|
crossStart: 0,
|
|
crossEnd: 1,
|
|
localStart: start,
|
|
localEnd: 1,
|
|
beforeUnitCount: 1,
|
|
afterUnitCount: remainingPaneCount - 1,
|
|
});
|
|
resolvedRatios.push(ratio);
|
|
start = end;
|
|
}
|
|
tracks.push({ start, end: 1 });
|
|
return { tracks, handles, resolvedRatios };
|
|
}
|
|
|
|
function cellStyle(
|
|
columnStart: number,
|
|
columnEnd: number,
|
|
rowStart: number,
|
|
rowEnd: number,
|
|
): CSSProperties {
|
|
return {
|
|
gridColumn: `${columnStart} / ${columnEnd}`,
|
|
gridRow: `${rowStart} / ${rowEnd}`,
|
|
};
|
|
}
|
|
|
|
function singlePaneGeometry(): WorkbenchLayoutGeometry {
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: "minmax(0, 1fr)",
|
|
gridTemplateRows: "minmax(0, 1fr)",
|
|
},
|
|
paneStyles: [undefined],
|
|
resizeHandles: [],
|
|
splitRatios: [],
|
|
};
|
|
}
|
|
|
|
function columnsGeometry(
|
|
paneCount: number,
|
|
ratios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const split = sequentialTracks(paneCount, ratios, 0);
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: tracksTemplate(split.tracks),
|
|
gridTemplateRows: "minmax(0, 1fr)",
|
|
},
|
|
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
|
|
resizeHandles: split.handles,
|
|
splitRatios: split.resolvedRatios,
|
|
};
|
|
}
|
|
|
|
function rowsGeometry(
|
|
paneCount: number,
|
|
ratios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const split = sequentialTracks(paneCount, ratios, 0);
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: "minmax(0, 1fr)",
|
|
gridTemplateRows: tracksTemplate(split.tracks),
|
|
},
|
|
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
|
|
resizeHandles: split.handles.map((handle) => ({
|
|
...handle,
|
|
axis: "horizontal",
|
|
})),
|
|
splitRatios: split.resolvedRatios,
|
|
};
|
|
}
|
|
|
|
function gridGeometry(
|
|
paneCount: number,
|
|
ratios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const columnCount = Math.ceil(Math.sqrt(paneCount));
|
|
const rowCount = Math.ceil(paneCount / columnCount);
|
|
const columns = sequentialTracks(columnCount, ratios, 0);
|
|
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
|
|
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
|
|
column: index % columnCount,
|
|
row: Math.floor(index / columnCount),
|
|
}));
|
|
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
|
|
...handle,
|
|
beforeUnitCount: boundaryIndex + 1,
|
|
afterUnitCount: columnCount - boundaryIndex - 1,
|
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
|
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
|
|
...handle,
|
|
axis: "horizontal" as const,
|
|
beforeUnitCount: boundaryIndex + 1,
|
|
afterUnitCount: rowCount - boundaryIndex - 1,
|
|
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
|
|
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: tracksTemplate(columns.tracks),
|
|
gridTemplateRows: tracksTemplate(rows.tracks),
|
|
},
|
|
paneStyles: paneCells.map((cell) => cellStyle(
|
|
cell.column + 1,
|
|
cell.column + 2,
|
|
cell.row + 1,
|
|
cell.row + 2,
|
|
)),
|
|
resizeHandles: [...verticalHandles, ...horizontalHandles],
|
|
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
|
|
};
|
|
}
|
|
|
|
function mainStackGeometry(
|
|
paneCount: number,
|
|
ratios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
|
|
const stack = sequentialTracks(paneCount - 1, ratios, 1);
|
|
const verticalHandle: WorkbenchResizeHandle = {
|
|
axis: "vertical",
|
|
ratioIndex: 0,
|
|
position: columnRatio,
|
|
crossStart: 0,
|
|
crossEnd: 1,
|
|
localStart: 0,
|
|
localEnd: 1,
|
|
beforeUnitCount: 1,
|
|
afterUnitCount: 1,
|
|
};
|
|
const stackHandles = stack.handles.map((handle) => ({
|
|
...handle,
|
|
axis: "horizontal" as const,
|
|
crossStart: columnRatio,
|
|
}));
|
|
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: tracksTemplate([
|
|
{ start: 0, end: columnRatio },
|
|
{ start: columnRatio, end: 1 },
|
|
]),
|
|
gridTemplateRows: tracksTemplate(stack.tracks),
|
|
},
|
|
paneStyles: [
|
|
cellStyle(1, 2, 1, paneCount),
|
|
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
|
|
],
|
|
resizeHandles: [verticalHandle, ...stackHandles],
|
|
splitRatios: [columnRatio, ...stack.resolvedRatios],
|
|
};
|
|
}
|
|
|
|
function uniqueBoundaries(values: readonly number[]): number[] {
|
|
return Array.from(new Set(values.map((value) => value.toFixed(8))))
|
|
.map(Number)
|
|
.sort((left, right) => left - right);
|
|
}
|
|
|
|
function boundaryIndex(boundaries: readonly number[], value: number): number {
|
|
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
|
|
}
|
|
|
|
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
|
|
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
|
|
? [cell.xStart, cell.xEnd]
|
|
: [cell.yStart, cell.yEnd]));
|
|
return Math.max(1, boundaries.length - 1);
|
|
}
|
|
|
|
function bspGeometry(
|
|
paneCount: number,
|
|
ratios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
|
|
const handles: WorkbenchResizeHandle[] = [];
|
|
const resolvedRatios: number[] = [];
|
|
|
|
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
|
|
const leaf = cells.pop();
|
|
if (!leaf) break;
|
|
const ratioIndex = paneIndex - 1;
|
|
const ratio = ratioAt(ratios, ratioIndex, 0.5);
|
|
resolvedRatios.push(ratio);
|
|
if (paneIndex % 2 === 1) {
|
|
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
|
|
cells.push(
|
|
{ ...leaf, xEnd: position },
|
|
{ ...leaf, xStart: position },
|
|
);
|
|
handles.push({
|
|
axis: "vertical",
|
|
ratioIndex,
|
|
position,
|
|
crossStart: leaf.yStart,
|
|
crossEnd: leaf.yEnd,
|
|
localStart: leaf.xStart,
|
|
localEnd: leaf.xEnd,
|
|
beforeUnitCount: 0,
|
|
afterUnitCount: 0,
|
|
});
|
|
} else {
|
|
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
|
|
cells.push(
|
|
{ ...leaf, yEnd: position },
|
|
{ ...leaf, yStart: position },
|
|
);
|
|
handles.push({
|
|
axis: "horizontal",
|
|
ratioIndex,
|
|
position,
|
|
crossStart: leaf.xStart,
|
|
crossEnd: leaf.xEnd,
|
|
localStart: leaf.yStart,
|
|
localEnd: leaf.yEnd,
|
|
beforeUnitCount: 0,
|
|
afterUnitCount: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const handle of handles) {
|
|
if (handle.axis === "vertical") {
|
|
const beforeCells = cells.filter((cell) => (
|
|
cell.xStart >= handle.localStart
|
|
&& cell.xEnd <= handle.position + Number.EPSILON
|
|
&& cell.yStart >= handle.crossStart
|
|
&& cell.yEnd <= handle.crossEnd
|
|
));
|
|
const afterCells = cells.filter((cell) => (
|
|
cell.xStart >= handle.position - Number.EPSILON
|
|
&& cell.xEnd <= handle.localEnd
|
|
&& cell.yStart >= handle.crossStart
|
|
&& cell.yEnd <= handle.crossEnd
|
|
));
|
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
|
} else {
|
|
const beforeCells = cells.filter((cell) => (
|
|
cell.yStart >= handle.localStart
|
|
&& cell.yEnd <= handle.position + Number.EPSILON
|
|
&& cell.xStart >= handle.crossStart
|
|
&& cell.xEnd <= handle.crossEnd
|
|
));
|
|
const afterCells = cells.filter((cell) => (
|
|
cell.yStart >= handle.position - Number.EPSILON
|
|
&& cell.yEnd <= handle.localEnd
|
|
&& cell.xStart >= handle.crossStart
|
|
&& cell.xEnd <= handle.crossEnd
|
|
));
|
|
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
|
|
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
|
|
}
|
|
}
|
|
|
|
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
|
|
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
|
|
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
|
|
start,
|
|
end: columnBoundaries[index + 1],
|
|
}));
|
|
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
|
|
start,
|
|
end: rowBoundaries[index + 1],
|
|
}));
|
|
|
|
return {
|
|
gridStyle: {
|
|
gridTemplateColumns: tracksTemplate(columnTracks),
|
|
gridTemplateRows: tracksTemplate(rowTracks),
|
|
},
|
|
paneStyles: cells.map((cell) => cellStyle(
|
|
boundaryIndex(columnBoundaries, cell.xStart) + 1,
|
|
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
|
|
boundaryIndex(rowBoundaries, cell.yStart) + 1,
|
|
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
|
|
)),
|
|
resizeHandles: handles,
|
|
splitRatios: resolvedRatios,
|
|
};
|
|
}
|
|
|
|
export function createWorkbenchLayoutGeometry(
|
|
layout: WorkbenchLayout,
|
|
paneCount: number,
|
|
splitRatios: readonly number[],
|
|
): WorkbenchLayoutGeometry {
|
|
const count = Math.max(1, paneCount);
|
|
if (count === 1) return singlePaneGeometry();
|
|
switch (layout) {
|
|
case "columns":
|
|
return columnsGeometry(count, splitRatios);
|
|
case "rows":
|
|
return rowsGeometry(count, splitRatios);
|
|
case "grid":
|
|
return gridGeometry(count, splitRatios);
|
|
case "main-stack":
|
|
return mainStackGeometry(count, splitRatios);
|
|
case "bsp":
|
|
return bspGeometry(count, splitRatios);
|
|
}
|
|
}
|
|
|
|
export function splitRatioBounds(
|
|
handle: WorkbenchResizeHandle,
|
|
axisExtentPx: number,
|
|
): { min: number; max: number } {
|
|
const localExtentPx = Math.max(
|
|
1,
|
|
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
|
|
);
|
|
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
|
|
const paneExtentPx = Math.min(
|
|
MIN_PANE_EXTENT_PX,
|
|
localExtentPx * 0.8 / paneCount,
|
|
);
|
|
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
|
|
const max = Math.min(
|
|
MAX_RATIO,
|
|
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
|
|
);
|
|
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
|
|
}
|
|
|
|
export function resizeHandleRatio(
|
|
handle: WorkbenchResizeHandle,
|
|
normalizedPosition: number,
|
|
axisExtentPx: number,
|
|
): number {
|
|
const localRatio = (normalizedPosition - handle.localStart)
|
|
/ Math.max(0.01, handle.localEnd - handle.localStart);
|
|
const bounds = splitRatioBounds(handle, axisExtentPx);
|
|
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
|
|
}
|
|
|
|
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
|
|
if (handle.axis === "vertical") {
|
|
return {
|
|
left: `${handle.position * 100}%`,
|
|
top: `${handle.crossStart * 100}%`,
|
|
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
|
transform: "translateX(-50%)",
|
|
};
|
|
}
|
|
return {
|
|
top: `${handle.position * 100}%`,
|
|
left: `${handle.crossStart * 100}%`,
|
|
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
|
|
transform: "translateY(-50%)",
|
|
};
|
|
}
|