import type { GreasePencilDataIR } from "./grease-pencil"; export const GREASE_PENCIL_REORDER_SCHEMA_VERSION = 1 as const; export const GREASE_PENCIL_REORDER_BUDGET = { maxIdBytes: 256, minFrame: -1_000_000, maxFrame: 1_000_000, } as const; export type GreasePencilLayerMoveDirection = "UP" | "DOWN" | "TOP" | "BOTTOM"; export type GreasePencilReorderCommand = | { type: "moveGreasePencilLayer"; schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION; dataId: string; layerId: string; direction: GreasePencilLayerMoveDirection; baseRevision: number; } | { type: "moveGreasePencilFrame"; schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION; dataId: string; layerId: string; frame: number; targetFrame: number; drawingId: string; baseRevision: number; }; export type GreasePencilReorderErrorCode = "GREASE_PENCIL_SCHEMA_INVALID" | "REVISION_CONFLICT"; export class GreasePencilReorderValidationError extends Error { constructor(readonly code: GreasePencilReorderErrorCode, message: string) { super(`${code}: ${message}`); this.name = "GreasePencilReorderValidationError"; } } const encoder = new TextEncoder(); const LAYER_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "direction", "baseRevision"]); const FRAME_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "frame", "targetFrame", "drawingId", "baseRevision"]); function fail(code: GreasePencilReorderErrorCode, message: string): never { throw new GreasePencilReorderValidationError(code, message); } function record(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command must be an object"); return value as Record; } function exact(value: Record, fields: ReadonlySet): void { if (Object.keys(value).some((field) => !fields.has(field))) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command contains undeclared fields"); } function boundedId(value: unknown, prefix: string, field: string): string { if (typeof value !== "string" || !value.startsWith(prefix) || encoder.encode(value).byteLength > GREASE_PENCIL_REORDER_BUDGET.maxIdBytes) { fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is not a bounded ${prefix} identity`); } return value; } function integer(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < GREASE_PENCIL_REORDER_BUDGET.minFrame || value > GREASE_PENCIL_REORDER_BUDGET.maxFrame) { fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is outside the supported frame range`); } return value; } function revision(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "baseRevision is invalid"); return value; } function dataFor(command: { dataId: string; layerId: string }, currentRevision: number, data: readonly GreasePencilDataIR[]): GreasePencilDataIR { if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "current revision is invalid"); const candidate = data.find((item) => item.id === command.dataId); if (!candidate) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil data block was not found"); if (!candidate.layers.some((layer) => layer.id === command.layerId)) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer was not found"); return candidate; } export function validateGreasePencilReorderCommand( value: unknown, currentRevision: number, data: readonly GreasePencilDataIR[], ): GreasePencilReorderCommand { const command = record(value); if (command.schemaVersion !== GREASE_PENCIL_REORDER_SCHEMA_VERSION) fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported reorder schema version"); const type = command.type; const dataId = boundedId(command.dataId, "grease-pencil:", "dataId"); const layerId = boundedId(command.layerId, "grease-pencil-layer:", "layerId"); const baseRevision = revision(command.baseRevision); if (baseRevision !== currentRevision) fail("REVISION_CONFLICT", "reorder command is stale"); const candidate = dataFor({ dataId, layerId }, currentRevision, data); const layerIndex = candidate.layers.findIndex((layer) => layer.id === layerId); if (type === "moveGreasePencilLayer") { exact(command, LAYER_FIELDS); const direction = command.direction; if (direction !== "UP" && direction !== "DOWN" && direction !== "TOP" && direction !== "BOTTOM") fail("GREASE_PENCIL_SCHEMA_INVALID", "layer move direction is invalid"); const noOp = (direction === "UP" && layerIndex === candidate.layers.length - 1) || (direction === "DOWN" && layerIndex === 0) || (direction === "TOP" && layerIndex === candidate.layers.length - 1) || (direction === "BOTTOM" && layerIndex === 0); if (noOp) fail("GREASE_PENCIL_SCHEMA_INVALID", "layer is already at the requested boundary"); return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, direction, baseRevision }; } if (type !== "moveGreasePencilFrame") fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported Grease Pencil reorder command"); exact(command, FRAME_FIELDS); const frame = integer(command.frame, "frame"); const targetFrame = integer(command.targetFrame, "targetFrame"); if (frame === targetFrame) fail("GREASE_PENCIL_SCHEMA_INVALID", "frame move must change the frame number"); const drawingId = boundedId(command.drawingId, "grease-pencil-drawing:", "drawingId"); const source = candidate.layers[layerIndex].frames.find((entry) => entry.frame === frame); if (!source || source.drawing.id !== drawingId) fail("GREASE_PENCIL_SCHEMA_INVALID", "source frame or drawing identity was not found"); if (candidate.layers[layerIndex].frames.some((entry) => entry.frame === targetFrame)) fail("GREASE_PENCIL_SCHEMA_INVALID", "target frame already exists"); return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, frame, targetFrame, drawingId, baseRevision }; }