feat: complete TASK-WP4-01 editor shell and background
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
|
||||
|
||||
const elementKeys = new Set([
|
||||
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
|
||||
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
|
||||
"template_or_asset_id", "type", "z_index",
|
||||
]);
|
||||
|
||||
export function defaultBackgroundAdjustments(): BackgroundAdjustments {
|
||||
return { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 };
|
||||
}
|
||||
|
||||
function pixelsForRatio(ratio: CanvasState["ratio"]) {
|
||||
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||
}
|
||||
|
||||
export function createEditorCanvasState(input: { assetId: string | null; ratio: CanvasState["ratio"] }): CanvasState {
|
||||
const pixels = pixelsForRatio(input.ratio);
|
||||
return {
|
||||
background: { adjustments: defaultBackgroundAdjustments(), asset_id: input.assetId },
|
||||
elements: [],
|
||||
pixel_height: pixels.pixelHeight,
|
||||
pixel_width: pixels.pixelWidth,
|
||||
ratio: input.ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function checked(state: CanvasState): CanvasState {
|
||||
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
export function updateBackgroundAdjustments(state: CanvasState, patch: Partial<BackgroundAdjustments>): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: { ...state.background.adjustments, ...patch } } });
|
||||
}
|
||||
|
||||
export function resetBackgroundAdjustments(state: CanvasState): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: defaultBackgroundAdjustments() } });
|
||||
}
|
||||
|
||||
export function switchBackground(state: CanvasState, assetId: string, palette: readonly string[]): CanvasState {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(assetId)) throw new Error("canvas_background_asset_invalid");
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
const next = structuredClone(state);
|
||||
next.background = { asset_id: assetId, adjustments: defaultBackgroundAdjustments() };
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...palette] } : element);
|
||||
return checked(next);
|
||||
}
|
||||
|
||||
export class CanvasEditHistory {
|
||||
private current: CanvasState;
|
||||
private readonly past: CanvasState[] = [];
|
||||
private readonly future: CanvasState[] = [];
|
||||
|
||||
constructor(initial: CanvasState) { this.current = checked(initial); }
|
||||
get value() { return structuredClone(this.current); }
|
||||
get canUndo() { return this.past.length > 0; }
|
||||
get canRedo() { return this.future.length > 0; }
|
||||
|
||||
commit(next: CanvasState) {
|
||||
this.past.push(this.value);
|
||||
this.current = checked(next);
|
||||
this.future.length = 0;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const previous = this.past.pop();
|
||||
if (!previous) return undefined;
|
||||
this.future.push(this.value);
|
||||
this.current = previous;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
redo() {
|
||||
const next = this.future.pop();
|
||||
if (!next) return undefined;
|
||||
this.past.push(this.value);
|
||||
this.current = next;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeFabricCanvas(input: unknown): CanvasState | undefined {
|
||||
if (!input || typeof input !== "object") return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
if (!value.background || typeof value.background !== "object" || !Array.isArray(value.elements)) return undefined;
|
||||
const elements = value.elements.map((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Object.keys(entry).some((key) => !elementKeys.has(key))) return undefined;
|
||||
return structuredClone(entry);
|
||||
});
|
||||
if (elements.some((entry) => entry === undefined)) return undefined;
|
||||
const candidate = {
|
||||
background: structuredClone(value.background),
|
||||
elements,
|
||||
pixel_height: value.pixel_height,
|
||||
pixel_width: value.pixel_width,
|
||||
ratio: value.ratio,
|
||||
schema_version: value.schema_version,
|
||||
};
|
||||
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
|
||||
}
|
||||
|
||||
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
|
||||
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
|
||||
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
|
||||
}
|
||||
Reference in New Issue
Block a user