Files
workinf_Blender_Wasm/web/protocol/area-layout.ts
mes123456 371427a6bc feat(web): add dynamic area layouts and camera views
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>
2026-11-13 09:50:12 +08:00

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,
}));
}