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