Files
tyx_AI_xhs/apps/web/src/editor-elements.ts
T

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;
}
}