Area headers now expose a real ⋮ menu with split, join, swap, duplicate, maximize/focus, close and reset; the first operation measures the CSS grid into normalized rects and switches that workspace to an OPFS-persisted custom layout while reusing the existing React content per slot. ViewportNavigation gains Numpad0 camera view with last-view restore, framing-only navigation when unlocked, Camera to View lock that writes the camera object (rotation-mode-aware native setObjectTransform), and Ctrl+Alt+Numpad0 camera-to-view. Camera lock persists per area. Co-Authored-By: Claude Code <noreply@anthropic.com>
188 lines
9.1 KiB
TypeScript
188 lines
9.1 KiB
TypeScript
import type { AreaIR, EditorType, WorkspaceIR } from "./ui-schema";
|
|
|
|
export type AreaDirection = "LEFT" | "RIGHT" | "UP" | "DOWN";
|
|
export type AreaSplitDirection = "HORIZONTAL" | "VERTICAL";
|
|
|
|
export type AreaOperation =
|
|
| { type: "SET_AREA_EDITOR"; areaId: string; editor: EditorType }
|
|
| { type: "SPLIT_AREA"; areaId: string; direction: AreaSplitDirection; factor: number }
|
|
| { type: "JOIN_AREA"; areaId: string; targetAreaId: string }
|
|
| { type: "SWAP_AREAS"; areaId: string; targetAreaId: string }
|
|
| { type: "DUPLICATE_AREA"; areaId: string }
|
|
| { type: "CLOSE_AREA"; areaId: string }
|
|
| { type: "TOGGLE_MAXIMIZE_AREA"; areaId: string }
|
|
| { type: "TOGGLE_FULLSCREEN_AREA"; areaId: string }
|
|
| { type: "SET_AREA_LOCK"; areaId: string; cameraLock: boolean }
|
|
| { type: "SET_AREA_RECT"; areaId: string; rect: AreaIR["rect"] }
|
|
| { type: "RESET_LAYOUT" };
|
|
|
|
export const AREA_MIN_WIDTH = 0.03;
|
|
export const AREA_MIN_HEIGHT = 0.03;
|
|
|
|
export interface MeasuredArea {
|
|
id: string;
|
|
editor: EditorType;
|
|
contentKey: string | null;
|
|
rect: AreaIR["rect"];
|
|
}
|
|
|
|
function clampRect(rect: AreaIR["rect"]): AreaIR["rect"] {
|
|
const width = Math.max(AREA_MIN_WIDTH, Math.min(1, rect.width));
|
|
const height = Math.max(AREA_MIN_HEIGHT, Math.min(1, rect.height));
|
|
return {
|
|
x: Math.max(0, Math.min(1 - width, rect.x)),
|
|
y: Math.max(0, Math.min(1 - height, rect.y)),
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
function newAreaId(areas: AreaIR[]): string {
|
|
let index = 1;
|
|
while (areas.some((area) => area.id === `area-${index}`)) index += 1;
|
|
return `area-${index}`;
|
|
}
|
|
|
|
export function splitArea(area: AreaIR, direction: AreaSplitDirection, factor: number): { active: AreaIR; added: AreaIR } | null {
|
|
const clamped = Math.max(0.1, Math.min(0.9, Number.isFinite(factor) ? factor : 0.5));
|
|
const rect = area.rect;
|
|
if (direction === "VERTICAL") {
|
|
const width = rect.width * clamped;
|
|
if (width < AREA_MIN_WIDTH || rect.width - width < AREA_MIN_WIDTH) return null;
|
|
return {
|
|
active: { ...area, rect: clampRect({ x: rect.x, y: rect.y, width, height: rect.height }) },
|
|
added: { ...area, id: `${area.id}:split`, contentKey: null, rect: clampRect({ x: rect.x + width, y: rect.y, width: rect.width - width, height: rect.height }) },
|
|
};
|
|
}
|
|
const height = rect.height * clamped;
|
|
if (height < AREA_MIN_HEIGHT || rect.height - height < AREA_MIN_HEIGHT) return null;
|
|
return {
|
|
active: { ...area, rect: clampRect({ x: rect.x, y: rect.y + rect.height - height, width: rect.width, height }) },
|
|
added: { ...area, id: `${area.id}:split`, contentKey: null, rect: clampRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height - height }) },
|
|
};
|
|
}
|
|
|
|
function overlapLength(aStart: number, aSize: number, bStart: number, bSize: number): number {
|
|
return Math.max(0, Math.min(aStart + aSize, bStart + bSize) - Math.max(aStart, bStart));
|
|
}
|
|
|
|
export function findAreaNeighbor(areas: AreaIR[], areaId: string, direction: AreaDirection): AreaIR | null {
|
|
const source = areas.find((area) => area.id === areaId);
|
|
if (!source) return null;
|
|
const r = source.rect;
|
|
let best: { area: AreaIR; distance: number } | null = null;
|
|
for (const candidate of areas) {
|
|
if (candidate.id === areaId) continue;
|
|
const c = candidate.rect;
|
|
const verticalOverlap = overlapLength(r.x, r.width, c.x, c.width);
|
|
const horizontalOverlap = overlapLength(r.y, r.height, c.y, c.height);
|
|
let distance = Number.POSITIVE_INFINITY;
|
|
if (direction === "LEFT" && horizontalOverlap >= AREA_MIN_HEIGHT && c.x + c.width <= r.x + 0.0005) distance = r.x - (c.x + c.width);
|
|
else if (direction === "RIGHT" && horizontalOverlap >= AREA_MIN_HEIGHT && c.x >= r.x + r.width - 0.0005) distance = c.x - (r.x + r.width);
|
|
else if (direction === "DOWN" && verticalOverlap >= AREA_MIN_WIDTH && c.y + c.height <= r.y + 0.0005) distance = r.y - (c.y + c.height);
|
|
else if (direction === "UP" && verticalOverlap >= AREA_MIN_WIDTH && c.y >= r.y + r.height - 0.0005) distance = c.y - (r.y + r.height);
|
|
if (distance < (best?.distance ?? Number.POSITIVE_INFINITY)) best = { area: candidate, distance };
|
|
}
|
|
return best?.area ?? null;
|
|
}
|
|
|
|
export function joinArea(areas: AreaIR[], areaId: string, targetAreaId: string): AreaIR[] {
|
|
const active = areas.find((area) => area.id === areaId);
|
|
const target = areas.find((area) => area.id === targetAreaId);
|
|
if (!active || !target || active.id === target.id) return areas;
|
|
const x = Math.min(active.rect.x, target.rect.x);
|
|
const y = Math.min(active.rect.y, target.rect.y);
|
|
const width = Math.max(active.rect.x + active.rect.width, target.rect.x + target.rect.width) - x;
|
|
const height = Math.max(active.rect.y + active.rect.height, target.rect.y + target.rect.height) - y;
|
|
return areas
|
|
.filter((area) => area.id !== target.id)
|
|
.map((area) => area.id === active.id ? { ...area, rect: clampRect({ x, y, width, height }) } : area);
|
|
}
|
|
|
|
function closeArea(areas: AreaIR[], areaId: string): AreaIR[] {
|
|
if (areas.length <= 1) return areas;
|
|
let best: AreaIR | null = null;
|
|
let bestScore = 0;
|
|
for (const direction of ["LEFT", "RIGHT", "UP", "DOWN"] as const) {
|
|
const neighbor = findAreaNeighbor(areas, areaId, direction);
|
|
if (!neighbor) continue;
|
|
const source = areas.find((area) => area.id === areaId)!;
|
|
const score = direction === "LEFT" || direction === "RIGHT"
|
|
? overlapLength(source.rect.y, source.rect.height, neighbor.rect.y, neighbor.rect.height)
|
|
: overlapLength(source.rect.x, source.rect.width, neighbor.rect.x, neighbor.rect.width);
|
|
if (score > bestScore) { best = neighbor; bestScore = score; }
|
|
}
|
|
return best ? joinArea(areas, areaId, best.id) : areas;
|
|
}
|
|
|
|
export function applyAreaOperation(workspace: WorkspaceIR, operation: AreaOperation): WorkspaceIR {
|
|
const areas = workspace.areas;
|
|
const activeId = workspace.activeAreaId;
|
|
const update = (next: AreaIR[], activeAreaId = workspace.activeAreaId): WorkspaceIR => ({
|
|
...workspace,
|
|
areas: next,
|
|
activeAreaId: next.some((area) => area.id === activeAreaId) ? activeAreaId : next[0]?.id ?? activeAreaId,
|
|
revision: workspace.revision + 1,
|
|
});
|
|
switch (operation.type) {
|
|
case "SET_AREA_EDITOR":
|
|
return update(areas.map((area) => area.id === operation.areaId ? { ...area, editor: operation.editor } : area));
|
|
case "SET_AREA_LOCK":
|
|
return update(areas.map((area) => area.id === operation.areaId ? { ...area, view3d: { cameraLock: operation.cameraLock } } : area));
|
|
case "SPLIT_AREA": {
|
|
const active = areas.find((area) => area.id === operation.areaId);
|
|
if (!active) return workspace;
|
|
const split = splitArea(active, operation.direction, operation.factor);
|
|
if (!split) return workspace;
|
|
const added = { ...split.added, id: newAreaId(areas) };
|
|
return update(areas.map((area) => area.id === active.id ? split.active : area).concat(added));
|
|
}
|
|
case "DUPLICATE_AREA": {
|
|
const active = areas.find((area) => area.id === operation.areaId);
|
|
if (!active) return workspace;
|
|
const split = splitArea(active, "VERTICAL", 0.5);
|
|
if (!split) return workspace;
|
|
return update(areas.map((area) => area.id === active.id ? split.active : area).concat({ ...split.added, id: newAreaId(areas) }));
|
|
}
|
|
case "JOIN_AREA":
|
|
return update(joinArea(areas, operation.areaId, operation.targetAreaId));
|
|
case "CLOSE_AREA":
|
|
return update(closeArea(areas, operation.areaId), operation.areaId === activeId ? areas.find((area) => area.id !== activeId)?.id ?? activeId : activeId);
|
|
case "SWAP_AREAS": {
|
|
const a = areas.find((area) => area.id === operation.areaId);
|
|
const b = areas.find((area) => area.id === operation.targetAreaId);
|
|
if (!a || !b || a.id === b.id) return workspace;
|
|
return update(areas.map((area) => area.id === a.id
|
|
? { ...area, editor: b.editor, view3d: b.view3d }
|
|
: area.id === b.id ? { ...area, editor: a.editor, view3d: a.view3d } : area));
|
|
}
|
|
case "TOGGLE_MAXIMIZE_AREA":
|
|
return update(areas.map((area) => ({ ...area, maximized: area.id === operation.areaId ? !area.maximized : false })), operation.areaId);
|
|
case "TOGGLE_FULLSCREEN_AREA":
|
|
return update(areas.map((area) => ({ ...area, maximized: area.id === operation.areaId ? !area.maximized : false })), operation.areaId);
|
|
case "SET_AREA_RECT":
|
|
return update(areas.map((area) => area.id === operation.areaId ? { ...area, rect: clampRect(operation.rect) } : area));
|
|
case "RESET_LAYOUT":
|
|
return { ...workspace, layoutMode: "default", areas: areas.map((area) => ({ ...area, maximized: false })), revision: workspace.revision + 1 };
|
|
default:
|
|
return workspace;
|
|
}
|
|
}
|
|
|
|
export function measureAreaRects(elements: Array<{ id: string; editor: EditorType; contentKey: string | null; rect: DOMRect }>, gridRect: DOMRect): AreaIR[] {
|
|
if (gridRect.width <= 0 || gridRect.height <= 0) return [];
|
|
return elements.map((element) => ({
|
|
id: element.id,
|
|
editor: element.editor,
|
|
contentKey: element.contentKey,
|
|
regions: [],
|
|
rect: clampRect({
|
|
x: (element.rect.left - gridRect.left) / gridRect.width,
|
|
y: 1 - (element.rect.bottom - gridRect.top) / gridRect.height,
|
|
width: element.rect.width / gridRect.width,
|
|
height: element.rect.height / gridRect.height,
|
|
}),
|
|
maximized: false,
|
|
}));
|
|
}
|