123 lines
6.0 KiB
TypeScript
123 lines
6.0 KiB
TypeScript
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command must be an object");
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>): 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 };
|
|
}
|