371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
|
|
|
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
|
|
|
type CanvasElement = CanvasState["elements"][number];
|
|
|
|
export interface CanvasPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface CanvasRect extends CanvasPoint {
|
|
height: number;
|
|
width: number;
|
|
}
|
|
|
|
export interface CanvasElementIdentity {
|
|
createdAt: string;
|
|
elementId: string;
|
|
}
|
|
|
|
export type CanvasLayerCommand = "back" | "backward" | "forward" | "front";
|
|
|
|
interface StaticStickerInput {
|
|
assetId: string;
|
|
identity: CanvasElementIdentity;
|
|
opacity?: number;
|
|
position: CanvasPoint;
|
|
resourceVersion: string;
|
|
rotation?: number;
|
|
scale?: CanvasElement["scale"];
|
|
style_parameters?: CanvasElement["style_parameters"];
|
|
z_index?: number;
|
|
zIndex: number;
|
|
}
|
|
|
|
const snapThreshold = 0.015;
|
|
const hitHalfExtent = 0.08;
|
|
|
|
function styleNumber(element: CanvasElement, key: string, fallback: number) {
|
|
const value = element.style_parameters?.[key];
|
|
return typeof value === "number" ? value : fallback;
|
|
}
|
|
|
|
function textLineUnits(line: string) {
|
|
return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0);
|
|
}
|
|
|
|
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
|
if (element.type === "color_card") {
|
|
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
|
|
return {
|
|
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
|
|
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
|
|
};
|
|
}
|
|
if (element.type === "dynamic_sticker") {
|
|
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
|
const halfWidth = model?.halfSize.width ?? 170;
|
|
const halfHeight = model?.halfSize.height ?? 62;
|
|
return {
|
|
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
|
|
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
|
|
};
|
|
}
|
|
if (element.type !== "text_template") {
|
|
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
|
}
|
|
const lines = (element.content ?? "").split("\n");
|
|
const fontSize = element.font_size ?? 48;
|
|
const letterSpacing = styleNumber(element, "letter_spacing", 1);
|
|
const lineHeight = styleNumber(element, "line_height", 1.2);
|
|
const strokeWidth = styleNumber(element, "stroke_width", 0);
|
|
const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line)));
|
|
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
|
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
|
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
|
return {
|
|
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
|
|
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
|
|
};
|
|
}
|
|
|
|
function cloneState(state: CanvasState) {
|
|
return structuredClone(state);
|
|
}
|
|
|
|
function requireState(state: CanvasState) {
|
|
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
|
return cloneState(state);
|
|
}
|
|
|
|
function clamp(value: number, minimum: number, maximum: number) {
|
|
return Math.max(minimum, Math.min(maximum, value));
|
|
}
|
|
|
|
function normalizedRotation(value: number) {
|
|
const normalized = ((value + 180) % 360 + 360) % 360 - 180;
|
|
return normalized === -180 ? 180 : normalized;
|
|
}
|
|
|
|
function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) {
|
|
return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001);
|
|
}
|
|
|
|
function reindex(elements: CanvasElement[]) {
|
|
return elements.map((element, zIndex) => ({ ...element, z_index: zIndex }));
|
|
}
|
|
|
|
export function createStaticStickerElement(input: StaticStickerInput): CanvasElement {
|
|
return {
|
|
created_at: input.identity.createdAt,
|
|
element_id: input.identity.elementId,
|
|
opacity: input.opacity ?? 1,
|
|
position: structuredClone(input.position),
|
|
resource_version: input.resourceVersion,
|
|
rotation: input.rotation ?? 0,
|
|
scale: structuredClone(input.scale ?? { x: 1, y: 1 }),
|
|
style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }),
|
|
template_or_asset_id: input.assetId,
|
|
type: "static_sticker",
|
|
z_index: input.z_index ?? input.zIndex,
|
|
};
|
|
}
|
|
|
|
export class CanvasElementController {
|
|
private current: CanvasState;
|
|
private selection: string[] = [];
|
|
private cyclePoint: CanvasPoint | undefined = undefined;
|
|
private cycleSignature = "";
|
|
private cycleIndex = -1;
|
|
|
|
constructor(initial: CanvasState) {
|
|
this.current = requireState(initial);
|
|
}
|
|
|
|
get value() {
|
|
return cloneState(this.current);
|
|
}
|
|
|
|
get selectedIds() {
|
|
return [...this.selection];
|
|
}
|
|
|
|
replaceState(next: CanvasState) {
|
|
this.current = requireState(next);
|
|
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
|
this.selection = this.selection.filter((elementId) => existing.has(elementId));
|
|
this.pointerMoved();
|
|
}
|
|
|
|
clearSelection() {
|
|
this.selection = [];
|
|
this.pointerMoved();
|
|
}
|
|
|
|
pointerMoved() {
|
|
this.cyclePoint = undefined;
|
|
this.cycleSignature = "";
|
|
this.cycleIndex = -1;
|
|
}
|
|
|
|
selectById(elementId: string, append = false) {
|
|
if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds;
|
|
if (!append) this.selection = [elementId];
|
|
else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId);
|
|
else this.selection = [...this.selection, elementId];
|
|
return this.selectedIds;
|
|
}
|
|
|
|
selectIds(elementIds: readonly string[]) {
|
|
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
|
this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))];
|
|
this.pointerMoved();
|
|
return this.selectedIds;
|
|
}
|
|
|
|
candidatesAt(point: CanvasPoint) {
|
|
return this.current.elements
|
|
.filter((element) => {
|
|
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
|
|
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
|
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
|
})
|
|
.sort((left, right) => right.z_index - left.z_index)
|
|
.map((element) => structuredClone(element));
|
|
}
|
|
|
|
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
|
|
const candidates = this.candidatesAt(point);
|
|
if (candidates.length === 0) {
|
|
if (!options.append) this.selection = [];
|
|
this.pointerMoved();
|
|
return this.selectedIds;
|
|
}
|
|
if (options.append) {
|
|
this.pointerMoved();
|
|
return this.selectById(candidates[0]!.element_id, true);
|
|
}
|
|
const signature = candidates.map((candidate) => candidate.element_id).join("|");
|
|
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
|
|
else this.cycleIndex = 0;
|
|
this.cyclePoint = { ...point };
|
|
this.cycleSignature = signature;
|
|
this.selection = [candidates[this.cycleIndex]!.element_id];
|
|
return this.selectedIds;
|
|
}
|
|
|
|
marqueeSelect(rectangle: CanvasRect, append = false) {
|
|
const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width);
|
|
const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width);
|
|
const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height);
|
|
const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height);
|
|
const hits = this.current.elements
|
|
.filter((element) => element.position.x >= minimumX && element.position.x <= maximumX
|
|
&& element.position.y >= minimumY && element.position.y <= maximumY)
|
|
.sort((left, right) => left.z_index - right.z_index)
|
|
.map((element) => element.element_id);
|
|
this.selection = append ? [...new Set([...this.selection, ...hits])] : hits;
|
|
this.pointerMoved();
|
|
return this.selectedIds;
|
|
}
|
|
|
|
add(element: CanvasElement) {
|
|
if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached");
|
|
const nextElement = structuredClone(element);
|
|
nextElement.z_index = this.current.elements.length;
|
|
this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] });
|
|
this.selection = [nextElement.element_id];
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
|
|
replaceElement(element: CanvasElement) {
|
|
const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id);
|
|
if (index < 0) throw new Error("canvas_element_not_found");
|
|
const elements = [...this.current.elements];
|
|
elements[index] = structuredClone(element);
|
|
this.current = requireState({ ...this.current, elements });
|
|
this.selection = [element.element_id];
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
|
|
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
|
const selected = new Set(this.selection);
|
|
this.current = requireState({
|
|
...this.current,
|
|
elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element),
|
|
});
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
|
|
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
|
|
const selected = new Set(this.selection);
|
|
const primary = this.current.elements.find((element) => selected.has(element.element_id));
|
|
if (!primary) return { guides: [] as string[], state: this.value };
|
|
let nextX = primary.position.x + delta.x;
|
|
let nextY = primary.position.y + delta.y;
|
|
const guides: string[] = [];
|
|
const snapAxis = (value: number, axis: "x" | "y") => {
|
|
const canvasTargets = [0, 0.5, 1];
|
|
const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold);
|
|
if (target !== undefined) {
|
|
guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`);
|
|
return target;
|
|
}
|
|
const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold);
|
|
if (other) {
|
|
guides.push(`element-${axis}:${other.element_id}`);
|
|
return other.position[axis];
|
|
}
|
|
return value;
|
|
};
|
|
if (options.snap !== false) {
|
|
nextX = snapAxis(nextX, "x");
|
|
nextY = snapAxis(nextY, "y");
|
|
}
|
|
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
|
|
const state = this.updateSelected((element) => ({
|
|
...element,
|
|
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
|
|
}));
|
|
return { guides, state };
|
|
}
|
|
|
|
scaleSelected(factor: number) {
|
|
if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid");
|
|
return this.updateSelected((element) => ({
|
|
...element,
|
|
scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) },
|
|
}));
|
|
}
|
|
|
|
rotateSelected(degrees: number) {
|
|
if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid");
|
|
return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) }));
|
|
}
|
|
|
|
flipSelectedHorizontal() {
|
|
return this.updateSelected((element) => element.type === "static_sticker" ? {
|
|
...element,
|
|
style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true },
|
|
} : element);
|
|
}
|
|
|
|
setSelectedOpacity(opacity: number) {
|
|
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid");
|
|
return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element);
|
|
}
|
|
|
|
copySelected() {
|
|
const selected = new Set(this.selection);
|
|
return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element));
|
|
}
|
|
|
|
pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
|
if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached");
|
|
const pasted = elements.map((source, index) => {
|
|
const identity = identityFactory(source, index);
|
|
return {
|
|
...structuredClone(source),
|
|
created_at: identity.createdAt,
|
|
element_id: identity.elementId,
|
|
position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) },
|
|
z_index: this.current.elements.length + index,
|
|
};
|
|
});
|
|
this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] });
|
|
this.selection = pasted.map((element) => element.element_id);
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
|
|
duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
|
return this.pasteElements(this.copySelected(), identityFactory);
|
|
}
|
|
|
|
deleteSelected() {
|
|
const selected = new Set(this.selection);
|
|
this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) });
|
|
this.selection = [];
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
|
|
changeLayer(command: CanvasLayerCommand) {
|
|
const selected = new Set(this.selection);
|
|
const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index);
|
|
if (command === "front" || command === "back") {
|
|
const selectedElements = ordered.filter((element) => selected.has(element.element_id));
|
|
const unselectedElements = ordered.filter((element) => !selected.has(element.element_id));
|
|
ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements]));
|
|
}
|
|
else if (command === "forward") {
|
|
for (let index = ordered.length - 2; index >= 0; index -= 1) {
|
|
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!];
|
|
}
|
|
} else {
|
|
for (let index = 1; index < ordered.length; index += 1) {
|
|
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!];
|
|
}
|
|
}
|
|
this.current = requireState({ ...this.current, elements: reindex(ordered) });
|
|
this.pointerMoved();
|
|
return this.value;
|
|
}
|
|
}
|