import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const EDITOR_WORKFLOW_SCHEMA = 1 as const; export const EDITOR_WORKFLOW_BUDGET = { maxWorkspaces: 64, maxAreas: 1_024, maxRegions: 4_096, maxSelection: 100_000, maxKeymaps: 4_096 } as const; export const EDITOR_TYPES = ["VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA", "SPREADSHEET", "TIMELINE", "CLIP", "MASK", "SEQUENCER"] as const; export type EditorTypeIR = typeof EDITOR_TYPES[number]; export type EditorRegionKind = "HEADER" | "MAIN" | "TOOLBAR" | "SIDEBAR" | "FOOTER"; export type EditorMode = "OBJECT" | "EDIT" | "POSE"; export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: boolean } export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean } export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number } export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number } export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean; workspaceIds?: string[]; editors?: EditorTypeIR[]; modes?: EditorMode[]; } export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] } export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> } export type EditorWorkflowEditIR = | { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string } | { type: "SET_ACTIVE_AREA"; revision: number; areaId: string } | { type: "SET_SELECTION"; revision: number; selectedIds: string[]; activeObjectId: string | null } | { type: "TOGGLE_REGION"; revision: number; areaId: string; regionId: string; visible: boolean }; export class EditorWorkflowValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "EditorWorkflowValidationError"; this.code = code; } } const EDITOR_REGION_KINDS = new Set(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER"]); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); return value; } function finite(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; } function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is outside the bounded range`); return value; } function rect(value: unknown, name: string): EditorAreaIR["rect"] { if (!record(value)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); const next = { x: finite(value.x, `${name}.x`, 0, 1), y: finite(value.y, `${name}.y`, 0, 1), width: finite(value.width, `${name}.width`, 0.0001, 1), height: finite(value.height, `${name}.height`, 0.0001, 1) }; if (next.x + next.width > 1 || next.y + next.height > 1) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} exceeds workspace bounds`); return next; } function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; } function scopeOverlaps(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); } function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); } export function parseEditorWorkflow(value: unknown): EditorWorkflowIR { if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema"); if (value.workspaces.length > EDITOR_WORKFLOW_BUDGET.maxWorkspaces || value.keymaps.length > EDITOR_WORKFLOW_BUDGET.maxKeymaps) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Editor workflow exceeds the budget"); let areaCount = 0; let regionCount = 0; const workspaceIds = new Set(); const workspaces = value.workspaces.map((workspaceValue, workspaceIndex): EditorWorkspaceIR => { const name = `workspaces[${workspaceIndex}]`; if (!record(workspaceValue) || !Array.isArray(workspaceValue.areas)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} is invalid`); const id = text(workspaceValue.id, `${name}.id`); if (workspaceIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate workspace ${id}`); workspaceIds.add(id); areaCount += workspaceValue.areas.length; if (areaCount > EDITOR_WORKFLOW_BUDGET.maxAreas) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Area count exceeds the budget"); const areaIds = new Set(); const areas = workspaceValue.areas.map((areaValue, areaIndex): EditorAreaIR => { const areaName = `${name}.areas[${areaIndex}]`; if (!record(areaValue) || !EDITOR_TYPES.includes(areaValue.editor as EditorTypeIR) || !Array.isArray(areaValue.regions)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${areaName} is invalid`); const areaId = text(areaValue.id, `${areaName}.id`); if (areaIds.has(areaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate area ${areaId}`); areaIds.add(areaId); regionCount += areaValue.regions.length; if (regionCount > EDITOR_WORKFLOW_BUDGET.maxRegions) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_BUDGET_EXCEEDED", "Region count exceeds the budget"); const regionIds = new Set(); const regions = areaValue.regions.map((regionValue, regionIndex): EditorRegionIR => { const regionName = `${areaName}.regions[${regionIndex}]`; if (!record(regionValue) || !EDITOR_REGION_KINDS.has(regionValue.kind as EditorRegionKind)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${regionName} is invalid`); const regionId = text(regionValue.id, `${regionName}.id`); if (regionIds.has(regionId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Duplicate region ${regionId}`); regionIds.add(regionId); return { id: regionId, kind: regionValue.kind as EditorRegionKind, visible: regionValue.visible !== false }; }); return { id: areaId, editor: areaValue.editor as EditorTypeIR, regions, rect: rect(areaValue.rect, `${areaName}.rect`), maximized: areaValue.maximized === true }; }); const activeAreaId = text(workspaceValue.activeAreaId, `${name}.activeAreaId`); if (!areaIds.has(activeAreaId)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name}.activeAreaId is missing`); for (let index = 0; index < areas.length; index++) for (let other = index + 1; other < areas.length; other++) if (!areas[index].maximized && !areas[other].maximized && overlap(areas[index].rect, areas[other].rect)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `${name} has overlapping areas`); return { id, name: text(workspaceValue.name, `${name}.name`), areas, activeAreaId, revision: integer(workspaceValue.revision, `${name}.revision`, 0, Number.MAX_SAFE_INTEGER) }; }); const contextValue = value.context; const workspaceId = text(contextValue.workspaceId, "context.workspaceId"); const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context workspace is missing"); const activeAreaId = text(contextValue.activeAreaId, "context.activeAreaId"); const activeArea = workspace.areas.find((item) => item.id === activeAreaId); if (!activeArea || activeArea.editor !== contextValue.activeEditor) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context active area/editor is inconsistent"); if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid"); if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget"); if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid"); const keymapIds = new Set(); const keymaps: KeymapBindingIR[] = []; value.keymaps.forEach((bindingValue, index) => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort(); const parseScope = (field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => { const source = bindingValue[field]; if (source === undefined) return undefined; if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`); const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T); if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`); return parsed; }; const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", ["OBJECT", "EDIT", "POSE"] as const) }; if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`); if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`); keymaps.push(binding); }); return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps }; } export function keyChordFromKeyboardEvent(event: Pick): KeyChordIR { const key = text(event.key, "event.key", 32).toUpperCase(); const modifiers: KeyChordIR["modifiers"] = []; if (event.altKey) modifiers.push("ALT"); if (event.ctrlKey) modifiers.push("CTRL"); if (event.metaKey) modifiers.push("META"); if (event.shiftKey) modifiers.push("SHIFT"); return { key, modifiers }; } export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null { const workflow = parseEditorWorkflow(value); if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid"); const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() }; if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid"); return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") && (!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) && (!binding.editors || binding.editors.includes(workflow.context.activeEditor)) && (!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null; } export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR { const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow); if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; } else if (edit.type === "SET_ACTIVE_AREA") { const workspace = clone.workspaces.find((item) => item.id === clone.context.workspaceId); const area = workspace?.areas.find((item) => item.id === edit.areaId); if (!area) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown area ${edit.areaId}`); clone.context.activeAreaId = area.id; clone.context.activeEditor = area.editor; } else if (edit.type === "SET_SELECTION") { if (new Set(edit.selectedIds).size !== edit.selectedIds.length || edit.selectedIds.length > EDITOR_WORKFLOW_BUDGET.maxSelection || edit.selectedIds.some((id) => typeof id !== "string" || !id)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection is invalid"); if (edit.activeObjectId !== null && !edit.selectedIds.includes(edit.activeObjectId)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object must be selected"); clone.context.selection = [...edit.selectedIds]; clone.context.activeObjectId = edit.activeObjectId; } else { const area = clone.workspaces.find((item) => item.id === clone.context.workspaceId)?.areas.find((item) => item.id === edit.areaId); const region = area?.regions.find((item) => item.id === edit.regionId); if (!region) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown region ${edit.regionId}`); region.visible = edit.visible; } clone.context.revision += 1; return parseEditorWorkflow(clone); } export function gateEditorOperation(operation: "READ_ONLY_VIEW" | "SELECTION_SYNC" | "KEYMAP" | "WRITER" | "GIZMO" | "TOUCH_DRAG"): CapabilityGateResult { if (["READ_ONLY_VIEW", "SELECTION_SYNC", "KEYMAP"].includes(operation)) return readyGate("N-024", operation); return blockedGate("N-024", operation, [capabilityIssue(operation === "GIZMO" || operation === "TOUCH_DRAG" ? "EDITOR_GIZMO_UNAVAILABLE" : "EDITOR_WRITER_UNAVAILABLE", `${operation} requires editor-specific Main transaction and interaction verification`)]); }