1805 lines
53 KiB
TypeScript
1805 lines
53 KiB
TypeScript
"use client";
|
|
|
|
import type Konva from "konva";
|
|
import type { SlideElement, TextRun } from "@/components/slide-editor/types";
|
|
import type {
|
|
TemplateV2InlineEditKind,
|
|
TemplateV2TextEditStyle,
|
|
} from "@/components/slide-editor/text/template-v2-text-editing";
|
|
import { textRunsContent } from "@/components/slide-editor/text/text-runs";
|
|
import {
|
|
applyTextStyle,
|
|
displayText,
|
|
editorFontRecordToRaw,
|
|
fontScaleFromResize,
|
|
rawFont,
|
|
rawFontRecordForEditor,
|
|
rawTableCellText,
|
|
rawTextContent,
|
|
rawTextListRenderTextRuns,
|
|
rawTextListItemText,
|
|
rawTextListRunsForEditor,
|
|
rawTextRunsForEditor,
|
|
scaleRawTextMetrics,
|
|
setRawTextContent,
|
|
setRawTextListContent,
|
|
setRawTextListRunsContent,
|
|
setRawTextRunsContent,
|
|
normalizeRawTextMarkdownElement,
|
|
textVisualLocalBox,
|
|
} from "@/components/slide-editor/text/template-v2-text";
|
|
import {
|
|
intrinsicFlowSize,
|
|
isFlowLayoutElement,
|
|
layoutFlowChildren,
|
|
} from "@/components/slide-editor/layout/flowLayout";
|
|
import { deleteLayoutChildFromArray } from "@/components/slide-editor/layout/layoutResize";
|
|
import type { TemplateV2SurfaceSelectedDetail } from "@/components/slide-editor/events/events";
|
|
import { rawChartToEditorChart } from "@/components/slide-editor/model/chart-model";
|
|
import {
|
|
alignmentOffset,
|
|
asRecord,
|
|
clamp,
|
|
DECORATIVE_LINE_LENGTH,
|
|
DECORATIVE_LINE_THICKNESS,
|
|
isRecord,
|
|
normalizeId,
|
|
readArray,
|
|
readBoolean,
|
|
readNumber,
|
|
readOptionalSize,
|
|
readPadding,
|
|
readPoint,
|
|
readSize,
|
|
readString,
|
|
ROOT_ELEMENTS_COMPONENT_INDEX,
|
|
STAGE_HEIGHT,
|
|
STAGE_WIDTH,
|
|
TEXT_AVERAGE_CHAR_EM,
|
|
type Box,
|
|
type ChildArrayInfo,
|
|
type ElementSelection,
|
|
type LaidOutChild,
|
|
type Point,
|
|
type RawComponent,
|
|
type RawElement,
|
|
type RawUi,
|
|
type SelectOptions,
|
|
type Selection,
|
|
type Size,
|
|
type UnknownRecord,
|
|
} from "@/components/slide-editor/model/core";
|
|
|
|
export {
|
|
alignmentOffset,
|
|
asRecord,
|
|
clamp,
|
|
cloneJson,
|
|
DECORATIVE_LINE_LENGTH,
|
|
DECORATIVE_LINE_THICKNESS,
|
|
isEditableTarget,
|
|
isRecord,
|
|
MAX_HISTORY_ENTRIES,
|
|
normalizeId,
|
|
readArray,
|
|
readBoolean,
|
|
readNumber,
|
|
readOptionalSize,
|
|
readPadding,
|
|
readPoint,
|
|
readSize,
|
|
readString,
|
|
ROOT_ELEMENTS_COMPONENT_INDEX,
|
|
SCROLL_DISMISS_THRESHOLD_PX,
|
|
STAGE_BOX,
|
|
STAGE_HEIGHT,
|
|
STAGE_WIDTH,
|
|
TEXT_AVERAGE_CHAR_EM,
|
|
withHash,
|
|
} from "@/components/slide-editor/model/core";
|
|
export type {
|
|
Box,
|
|
ChildArrayInfo,
|
|
ComponentSelection,
|
|
ElementSelection,
|
|
LaidOutChild,
|
|
MultiComponentDragState,
|
|
MultiComponentSelection,
|
|
Point,
|
|
RawComponent,
|
|
RawElement,
|
|
RawUi,
|
|
SelectOptions,
|
|
Selection,
|
|
Size,
|
|
UnknownRecord,
|
|
} from "@/components/slide-editor/model/core";
|
|
export {
|
|
editorChartToRawChart,
|
|
rawChartToEditorChart,
|
|
} from "@/components/slide-editor/model/chart-model";
|
|
export {
|
|
appendInsertedContent,
|
|
convertInsertedChildArrays,
|
|
hasTemplateV2Metadata,
|
|
insertedComponentToRaw,
|
|
insertedElementToComponent,
|
|
normalizeInsertedBorderRadius,
|
|
normalizeInsertedElementGeometry,
|
|
normalizeInsertedTableCells,
|
|
normalizeInsertedTableRows,
|
|
normalizeInsertedTextCollections,
|
|
normalizeInsertedTextListItems,
|
|
normalizeInsertedTextRuns,
|
|
rawElementFromInsertedElement,
|
|
sourceElementBox,
|
|
sourceElementSize,
|
|
} from "@/components/slide-editor/model/inserted-content";
|
|
export {
|
|
backgroundColor,
|
|
borderRadius,
|
|
colorWithOpacity,
|
|
fillColor,
|
|
fillOpacity,
|
|
shadowProps,
|
|
strokeColor,
|
|
strokeOpacity,
|
|
strokeWidth,
|
|
} from "@/components/slide-editor/model/render-style";
|
|
|
|
export function updateComponentInUi(
|
|
sourceUi: RawUi,
|
|
componentIndex: number,
|
|
updater: (component: RawComponent) => RawComponent,
|
|
) {
|
|
const components = [...readArray(sourceUi.components)];
|
|
const current = asRecord(components[componentIndex]);
|
|
if (!current) return sourceUi;
|
|
const updated = updater(current);
|
|
if (updated === current) return sourceUi;
|
|
components[componentIndex] = updated;
|
|
return { ...sourceUi, components };
|
|
}
|
|
|
|
export function setComponentPositionsInUi(
|
|
sourceUi: RawUi,
|
|
positions: Array<{ componentIndex: number; position: Point }>,
|
|
) {
|
|
const positionByIndex = new Map<number, Point>();
|
|
positions.forEach(({ componentIndex, position }) => {
|
|
if (!Number.isInteger(componentIndex) || componentIndex < 0) return;
|
|
positionByIndex.set(componentIndex, {
|
|
x: position.x,
|
|
y: position.y,
|
|
});
|
|
});
|
|
if (positionByIndex.size === 0) return sourceUi;
|
|
|
|
let changed = false;
|
|
const components = readArray(sourceUi.components).map((component, index) => {
|
|
const record = asRecord(component);
|
|
const nextPosition = positionByIndex.get(index);
|
|
if (!record || !nextPosition) return component;
|
|
const currentPosition = readPoint(record.position);
|
|
if (
|
|
Math.abs(currentPosition.x - nextPosition.x) < 0.01 &&
|
|
Math.abs(currentPosition.y - nextPosition.y) < 0.01
|
|
) {
|
|
return component;
|
|
}
|
|
changed = true;
|
|
return {
|
|
...record,
|
|
position: {
|
|
x: nextPosition.x,
|
|
y: nextPosition.y,
|
|
},
|
|
};
|
|
});
|
|
|
|
return changed ? { ...sourceUi, components } : sourceUi;
|
|
}
|
|
|
|
export function updateElementInUi(
|
|
sourceUi: RawUi,
|
|
selection: ElementSelection,
|
|
updater: (element: RawElement) => RawElement,
|
|
) {
|
|
if (selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
const currentElements = readArray(sourceUi.elements);
|
|
const elements = updateElementArray(
|
|
currentElements,
|
|
selection.elementPath,
|
|
updater,
|
|
);
|
|
return elements === currentElements ? sourceUi : { ...sourceUi, elements };
|
|
}
|
|
|
|
const components = [...readArray(sourceUi.components)];
|
|
const component = asRecord(components[selection.componentIndex]);
|
|
if (!component) return sourceUi;
|
|
const currentElements = readArray(component.elements);
|
|
const elements = updateElementArray(
|
|
currentElements,
|
|
selection.elementPath,
|
|
updater,
|
|
);
|
|
if (elements === currentElements) return sourceUi;
|
|
components[selection.componentIndex] = normalizeSingleChartWrapperComponent(
|
|
{ ...component, elements },
|
|
selection,
|
|
);
|
|
return { ...sourceUi, components };
|
|
}
|
|
|
|
export function syncComponentHeightToElement(
|
|
sourceUi: RawUi,
|
|
selection: ElementSelection,
|
|
) {
|
|
if (
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX ||
|
|
selection.elementPath.length !== 1
|
|
) {
|
|
return sourceUi;
|
|
}
|
|
|
|
const components = [...readArray(sourceUi.components)];
|
|
const component = asRecord(components[selection.componentIndex]);
|
|
const componentElements = component ? readArray(component.elements) : [];
|
|
const element = component
|
|
? asRecord(componentElements[selection.elementPath[0]])
|
|
: null;
|
|
if (!component || !element) return sourceUi;
|
|
|
|
const componentSize = readSize(component.size, {
|
|
width: STAGE_WIDTH,
|
|
height: STAGE_HEIGHT,
|
|
});
|
|
const box = elementBox(element);
|
|
const contentHeight = Math.max(1, box.y + box.height);
|
|
const height =
|
|
componentElements.length === 1
|
|
? contentHeight
|
|
: Math.max(componentSize.height, contentHeight);
|
|
if (Math.abs(height - componentSize.height) < 0.01) return sourceUi;
|
|
|
|
components[selection.componentIndex] = {
|
|
...component,
|
|
size: { ...componentSize, height },
|
|
};
|
|
return { ...sourceUi, components };
|
|
}
|
|
|
|
export function normalizeSingleChartWrapperComponent(
|
|
component: RawComponent,
|
|
selection: ElementSelection,
|
|
): RawComponent {
|
|
if (selection.elementPath.length !== 1) return component;
|
|
const elements = readArray(component.elements);
|
|
if (elements.length !== 1) return component;
|
|
const child = asRecord(elements[0]);
|
|
if (!child || readString(child.type) !== "chart") return component;
|
|
if ((readNumber(component.rotation) ?? 0) !== 0) return component;
|
|
|
|
const childBox = elementBox(child);
|
|
const componentPosition = readPoint(component.position);
|
|
return {
|
|
...component,
|
|
position: {
|
|
x: componentPosition.x + childBox.x,
|
|
y: componentPosition.y + childBox.y,
|
|
},
|
|
size: {
|
|
width: childBox.width,
|
|
height: childBox.height,
|
|
},
|
|
elements: [
|
|
{
|
|
...child,
|
|
position: { x: 0, y: 0 },
|
|
size: {
|
|
width: childBox.width,
|
|
height: childBox.height,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function updateElementArray(
|
|
elements: unknown[],
|
|
path: number[],
|
|
updater: (element: RawElement) => RawElement,
|
|
): unknown[] {
|
|
if (path.length === 0) return elements;
|
|
const [index, ...rest] = path;
|
|
const current = asRecord(elements[index]);
|
|
if (!current) return elements;
|
|
if (rest.length === 0) {
|
|
const updated = updater(current);
|
|
if (updated === current) return elements;
|
|
const next = [...elements];
|
|
next[index] = updated;
|
|
return next;
|
|
}
|
|
const childInfo = childArrayInfo(current);
|
|
if (!childInfo) return elements;
|
|
const updatedChildren = updateElementArray(childInfo.items, rest, updater);
|
|
if (updatedChildren === childInfo.items) return elements;
|
|
const next = [...elements];
|
|
next[index] = withUpdatedChildItems(current, childInfo, updatedChildren);
|
|
return next;
|
|
}
|
|
|
|
export function deleteSelectionFromUi(sourceUi: RawUi, selection: Selection) {
|
|
if (!selection) return sourceUi;
|
|
|
|
const components = [...readArray(sourceUi.components)];
|
|
if (selection.kind === "multi-component") {
|
|
const indexes = Array.from(new Set(selection.componentIndexes))
|
|
.filter((index) => Number.isInteger(index) && index >= 0)
|
|
.sort((a, b) => b - a);
|
|
indexes.forEach((componentIndex) => {
|
|
if (componentIndex < components.length) {
|
|
components.splice(componentIndex, 1);
|
|
}
|
|
});
|
|
return { ...sourceUi, components };
|
|
}
|
|
if (selection?.kind === "component") {
|
|
components.splice(selection.componentIndex, 1);
|
|
return { ...sourceUi, components };
|
|
}
|
|
if (selection?.kind === "element") {
|
|
if (selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
const currentElements = readArray(sourceUi.elements);
|
|
const elements = deleteLayoutChildFromArray(
|
|
currentElements,
|
|
selection.elementPath,
|
|
);
|
|
return elements === currentElements ? sourceUi : { ...sourceUi, elements };
|
|
}
|
|
|
|
const component = asRecord(components[selection.componentIndex]);
|
|
if (!component) return sourceUi;
|
|
const currentElements = readArray(component.elements);
|
|
const elements = deleteLayoutChildFromArray(
|
|
currentElements,
|
|
selection.elementPath,
|
|
);
|
|
if (elements !== currentElements) {
|
|
components[selection.componentIndex] = { ...component, elements };
|
|
return { ...sourceUi, components };
|
|
}
|
|
|
|
components.splice(selection.componentIndex, 1);
|
|
return { ...sourceUi, components };
|
|
}
|
|
return sourceUi;
|
|
}
|
|
|
|
export function resizeComponent(
|
|
component: RawComponent,
|
|
next: Box & { scaleX: number; scaleY: number; rotation?: number },
|
|
) {
|
|
const fontScale = fontScaleFromResize(next.scaleX, next.scaleY);
|
|
return {
|
|
...component,
|
|
position: { x: next.x, y: next.y },
|
|
size: { width: next.width, height: next.height },
|
|
rotation: next.rotation ?? readNumber(component.rotation) ?? 0,
|
|
elements: scaleRawElements(
|
|
readArray(component.elements),
|
|
next.scaleX,
|
|
next.scaleY,
|
|
fontScale,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function resizeComponentFrame(
|
|
component: RawComponent,
|
|
next: Box & { rotation?: number },
|
|
) {
|
|
return {
|
|
...component,
|
|
position: { x: next.x, y: next.y },
|
|
size: { width: next.width, height: next.height },
|
|
rotation: next.rotation ?? readNumber(component.rotation) ?? 0,
|
|
};
|
|
}
|
|
|
|
export function resizeComponentElementBounds(
|
|
component: RawComponent,
|
|
next: Box & { scaleX: number; scaleY: number; rotation?: number },
|
|
) {
|
|
return {
|
|
...component,
|
|
position: { x: next.x, y: next.y },
|
|
size: { width: next.width, height: next.height },
|
|
rotation: next.rotation ?? readNumber(component.rotation) ?? 0,
|
|
elements: resizeRawElementBounds(
|
|
readArray(component.elements),
|
|
next.scaleX,
|
|
next.scaleY,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function resizeRawElementBounds(
|
|
elements: unknown[],
|
|
scaleX: number,
|
|
scaleY: number,
|
|
): unknown[] {
|
|
const safeScaleX = Number.isFinite(scaleX) && scaleX > 0 ? scaleX : 1;
|
|
const safeScaleY = Number.isFinite(scaleY) && scaleY > 0 ? scaleY : 1;
|
|
|
|
return elements.map((value) => {
|
|
const element = asRecord(value);
|
|
if (!element) return value;
|
|
const explicitSize = readOptionalSize(element.size);
|
|
const childInfo = childArrayInfo(element);
|
|
const resizedChildren = childInfo
|
|
? resizeRawElementBounds(childInfo.items, safeScaleX, safeScaleY)
|
|
: null;
|
|
return {
|
|
...element,
|
|
...(explicitSize
|
|
? {
|
|
size: {
|
|
width: Math.max(1, explicitSize.width * safeScaleX),
|
|
height: Math.max(1, explicitSize.height * safeScaleY),
|
|
},
|
|
}
|
|
: {}),
|
|
...(childInfo && resizedChildren
|
|
? withUpdatedChildItems({}, childInfo, resizedChildren)
|
|
: {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function scaleRawElements(
|
|
elements: unknown[],
|
|
scaleX: number,
|
|
scaleY: number,
|
|
fontScale: number,
|
|
): unknown[] {
|
|
return elements.map((value) => {
|
|
const element = asRecord(value);
|
|
if (!element) return value;
|
|
const box = elementBox(element);
|
|
const explicitSize = readOptionalSize(element.size);
|
|
const childInfo = childArrayInfo(element);
|
|
const scaledChildren = childInfo
|
|
? scaleRawElements(childInfo.items, scaleX, scaleY, fontScale)
|
|
: null;
|
|
const scaledElement = scaleRawElementTextMetrics(element, fontScale);
|
|
return {
|
|
...scaledElement,
|
|
position: { x: box.x * scaleX, y: box.y * scaleY },
|
|
...(explicitSize
|
|
? {
|
|
size: {
|
|
width: Math.max(1, explicitSize.width * scaleX),
|
|
height: Math.max(1, explicitSize.height * scaleY),
|
|
},
|
|
}
|
|
: {}),
|
|
...(childInfo && scaledChildren
|
|
? withUpdatedChildItems({}, childInfo, scaledChildren)
|
|
: {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function scaleRawElementTextMetrics(element: RawElement, fontScale: number) {
|
|
if (!Number.isFinite(fontScale) || Math.abs(fontScale - 1) < 0.001) {
|
|
return element;
|
|
}
|
|
const type = readString(element.type);
|
|
if (type !== "text" && type !== "text-list" && type !== "table") {
|
|
return element;
|
|
}
|
|
return scaleRawTextMetrics(element, fontScale);
|
|
}
|
|
|
|
export function positionFromNodeInParent(
|
|
node: Konva.Node,
|
|
parentBox: Box,
|
|
renderedBox: Box,
|
|
): Point {
|
|
const position = unclampedPositionFromNodeInParent(node, parentBox, renderedBox);
|
|
return clampRelativePosition(position, renderedBox, parentBox);
|
|
}
|
|
|
|
export function unclampedPositionFromNodeInParent(
|
|
node: Konva.Node,
|
|
parentBox: Box,
|
|
renderedBox: Box,
|
|
): Point {
|
|
const absolute = node.absolutePosition();
|
|
const offsetX = node.offsetX() ? renderedBox.width / 2 : 0;
|
|
const offsetY = node.offsetY() ? renderedBox.height / 2 : 0;
|
|
return {
|
|
x: absolute.x - parentBox.x - offsetX,
|
|
y: absolute.y - parentBox.y - offsetY,
|
|
};
|
|
}
|
|
|
|
export function clampRelativePosition(pos: Point, box: Box, parentSize: Size): Point {
|
|
return {
|
|
x: clamp(pos.x, 0, Math.max(0, parentSize.width - box.width)),
|
|
y: clamp(pos.y, 0, Math.max(0, parentSize.height - box.height)),
|
|
};
|
|
}
|
|
|
|
export function layoutChildren(
|
|
parent: RawElement,
|
|
children: unknown[],
|
|
parentBox: Box,
|
|
): LaidOutChild[] {
|
|
const rawChildren = children.filter(isRecord) as RawElement[];
|
|
const type = readString(parent.type);
|
|
if (type === "container") {
|
|
return layoutContainerChildren(parent, rawChildren, parentBox);
|
|
}
|
|
if (isFlowLayoutElement(parent)) {
|
|
return layoutFlowChildren(parent, rawChildren, parentBox, {
|
|
elementBox,
|
|
elementSize,
|
|
isManualPositioned,
|
|
}) as LaidOutChild[];
|
|
}
|
|
return rawChildren.map((child, index) => ({
|
|
child,
|
|
index,
|
|
box: null as Box | null,
|
|
layoutManaged: false,
|
|
}));
|
|
}
|
|
|
|
export function elementWithNormalizedLayoutChildren(
|
|
element: RawElement,
|
|
parentBox: Box,
|
|
): RawElement {
|
|
const childInfo = childArrayInfo(element);
|
|
if (!childInfo || childInfo.items.length === 0) {
|
|
return element;
|
|
}
|
|
|
|
const laidOutChildren = layoutChildren(element, childInfo.items, parentBox);
|
|
const nextChildren = childInfo.items.map((child, index) => {
|
|
const record = asRecord(child);
|
|
const laidOut = laidOutChildren.find((item) => item.index === index);
|
|
if (!record || !laidOut?.box || !laidOut.layoutManaged) {
|
|
return child;
|
|
}
|
|
return {
|
|
...record,
|
|
position: {
|
|
x: laidOut.box.x,
|
|
y: laidOut.box.y,
|
|
},
|
|
size: {
|
|
width: laidOut.box.width,
|
|
height: laidOut.box.height,
|
|
},
|
|
};
|
|
});
|
|
|
|
return withUpdatedChildItems(element, childInfo, nextChildren);
|
|
}
|
|
|
|
export function shouldUseCenterOrigin(element: RawElement) {
|
|
return Boolean(element);
|
|
}
|
|
|
|
export function layoutContainerChildren(
|
|
parent: RawElement,
|
|
children: RawElement[],
|
|
parentBox: Box,
|
|
): LaidOutChild[] {
|
|
if (children.length === 0) return [];
|
|
const padding = readPadding(parent.padding);
|
|
const content = {
|
|
x: padding.left,
|
|
y: padding.top,
|
|
width: Math.max(1, parentBox.width - padding.left - padding.right),
|
|
height: Math.max(1, parentBox.height - padding.top - padding.bottom),
|
|
};
|
|
const alignment = asRecord(parent.alignment) ?? {};
|
|
|
|
return children.map((child, index) => {
|
|
if (isManualPositioned(child)) {
|
|
return { child, index, box: elementBox(child), layoutManaged: false };
|
|
}
|
|
|
|
const point = readPoint(child.position);
|
|
const childType = readString(child.type);
|
|
const explicitSize = readOptionalSize(child.size);
|
|
const inferredSize =
|
|
childType === "group" && explicitSize == null
|
|
? { width: content.width, height: content.height }
|
|
: elementSize(child, content);
|
|
const width = explicitSize?.width ?? inferredSize.width;
|
|
const height = explicitSize?.height ?? inferredSize.height;
|
|
|
|
if (childType === "group") {
|
|
return {
|
|
child,
|
|
index,
|
|
box: {
|
|
x: content.x + point.x,
|
|
y: content.y + point.y,
|
|
width,
|
|
height,
|
|
},
|
|
layoutManaged: true,
|
|
};
|
|
}
|
|
|
|
const horizontal = readString(alignment.horizontal) ?? "left";
|
|
const vertical = readString(alignment.vertical) ?? "top";
|
|
return {
|
|
child,
|
|
index,
|
|
box: {
|
|
x:
|
|
horizontal === "center"
|
|
? content.x + alignmentOffset("center", content.width, width)
|
|
: horizontal === "right"
|
|
? content.x + alignmentOffset("right", content.width, width)
|
|
: content.x + point.x,
|
|
y:
|
|
vertical === "middle"
|
|
? content.y + alignmentOffset("center", content.height, height)
|
|
: vertical === "bottom"
|
|
? content.y + alignmentOffset("bottom", content.height, height)
|
|
: content.y + point.y,
|
|
width,
|
|
height,
|
|
},
|
|
layoutManaged: true,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function estimateTextWidth(text: string, font: ReturnType<typeof rawFont>) {
|
|
const longestLine = text
|
|
.split(/\r?\n/)
|
|
.reduce((longest, line) => Math.max(longest, line.length), 0);
|
|
const weight = font.bold ? 0.56 : TEXT_AVERAGE_CHAR_EM;
|
|
return Math.max(font.size, longestLine * font.size * weight);
|
|
}
|
|
|
|
export function estimateTextHeight(
|
|
text: string,
|
|
font: ReturnType<typeof rawFont>,
|
|
width: number,
|
|
) {
|
|
const lineHeight = font.size * font.lineHeight;
|
|
const averageCharWidth = Math.max(1, font.size * TEXT_AVERAGE_CHAR_EM);
|
|
const charsPerLine = Math.max(1, Math.floor(width / averageCharWidth));
|
|
const lines = text.split(/\r?\n/).reduce((count, line) => {
|
|
return count + Math.max(1, Math.ceil(line.length / charsPerLine));
|
|
}, 0);
|
|
return Math.max(lineHeight, lines * lineHeight);
|
|
}
|
|
|
|
export function getElementAtSelection(ui: RawUi, selection: ElementSelection) {
|
|
if (selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
return getElementFromArray(readArray(ui.elements), selection.elementPath);
|
|
}
|
|
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
if (!component) return null;
|
|
return getElementFromArray(readArray(component.elements), selection.elementPath);
|
|
}
|
|
|
|
export function getElementFromArray(elements: unknown[], path: number[]): RawElement | null {
|
|
const [index, ...rest] = path;
|
|
const current = asRecord(elements[index]);
|
|
if (!current) return null;
|
|
if (rest.length === 0) return current;
|
|
const childInfo = childArrayInfo(current);
|
|
return childInfo ? getElementFromArray(childInfo.items, rest) : null;
|
|
}
|
|
|
|
export function absoluteBoxForSelection(ui: RawUi, selection: Selection): Box | null {
|
|
if (!selection) return null;
|
|
if (selection.kind === "multi-component") {
|
|
const components = readArray(ui.components);
|
|
const boxes = selection.componentIndexes.flatMap((componentIndex) => {
|
|
const component = asRecord(components[componentIndex]);
|
|
return component ? [componentBox(component)] : [];
|
|
});
|
|
return boxes.length > 0 ? boxContainingBoxes(boxes) : null;
|
|
}
|
|
if (
|
|
selection.kind === "element" &&
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX
|
|
) {
|
|
return absoluteElementBox(rootElementsComponent(ui), selection.elementPath);
|
|
}
|
|
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
if (!component) return null;
|
|
const componentOrigin = readPoint(component.position);
|
|
if (selection.kind === "component") return componentBox(component);
|
|
const elementBoxValue = absoluteElementBox(component, selection.elementPath);
|
|
if (!elementBoxValue) return null;
|
|
return {
|
|
x: componentOrigin.x + elementBoxValue.x,
|
|
y: componentOrigin.y + elementBoxValue.y,
|
|
width: elementBoxValue.width,
|
|
height: elementBoxValue.height,
|
|
};
|
|
}
|
|
|
|
export function boxContainingBoxes(boxes: Box[]): Box {
|
|
const minX = Math.min(...boxes.map((box) => box.x));
|
|
const minY = Math.min(...boxes.map((box) => box.y));
|
|
const maxX = Math.max(...boxes.map((box) => box.x + box.width));
|
|
const maxY = Math.max(...boxes.map((box) => box.y + box.height));
|
|
return {
|
|
x: minX,
|
|
y: minY,
|
|
width: Math.max(1, maxX - minX),
|
|
height: Math.max(1, maxY - minY),
|
|
};
|
|
}
|
|
|
|
export function absoluteInlineEditBox(
|
|
ui: RawUi,
|
|
selection: ElementSelection,
|
|
frame?: Box | null,
|
|
): Box | null {
|
|
const element = getElementAtSelection(ui, selection);
|
|
const localFrame =
|
|
frame ?? renderedLocalBoxForElementSelection(ui, selection);
|
|
if (!element || !localFrame) return absoluteBoxForSelection(ui, selection);
|
|
|
|
return (
|
|
absoluteBoxForElementLocalFrame(ui, selection, localFrame) ??
|
|
absoluteBoxForSelection(ui, selection)
|
|
);
|
|
}
|
|
|
|
export function absoluteBoxForElementLocalFrame(
|
|
ui: RawUi,
|
|
selection: ElementSelection,
|
|
frame: Box,
|
|
): Box | null {
|
|
if (selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
return absoluteElementLocalFrame(
|
|
rootElementsComponent(ui),
|
|
selection.elementPath,
|
|
frame,
|
|
);
|
|
}
|
|
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
if (!component) return null;
|
|
const componentOrigin = readPoint(component.position);
|
|
const elementFrame = absoluteElementLocalFrame(
|
|
component,
|
|
selection.elementPath,
|
|
frame,
|
|
);
|
|
if (!elementFrame) return null;
|
|
return {
|
|
x: componentOrigin.x + elementFrame.x,
|
|
y: componentOrigin.y + elementFrame.y,
|
|
width: elementFrame.width,
|
|
height: elementFrame.height,
|
|
};
|
|
}
|
|
|
|
export function absoluteElementLocalFrame(
|
|
component: RawComponent,
|
|
path: number[],
|
|
frame: Box,
|
|
) {
|
|
let items = readArray(component.elements).filter(isRecord) as RawElement[];
|
|
let parentElement: RawElement | null = null;
|
|
let parentRenderBox: Box = {
|
|
x: 0,
|
|
y: 0,
|
|
...readSize(component.size, { width: STAGE_WIDTH, height: STAGE_HEIGHT }),
|
|
};
|
|
let x = 0;
|
|
let y = 0;
|
|
for (const index of path.slice(0, -1)) {
|
|
const element = asRecord(items[index]);
|
|
if (!element) return null;
|
|
const laidOut =
|
|
parentElement != null
|
|
? layoutChildren(parentElement, items, parentRenderBox).find(
|
|
(item) => item.index === index,
|
|
)
|
|
: null;
|
|
const box = laidOut?.box ?? elementBox(element);
|
|
x += box.x;
|
|
y += box.y;
|
|
const childInfo = childArrayInfo(element);
|
|
parentElement = element;
|
|
parentRenderBox = { x: 0, y: 0, width: box.width, height: box.height };
|
|
items = (childInfo?.items ?? []).filter(isRecord) as RawElement[];
|
|
}
|
|
return {
|
|
x: x + frame.x,
|
|
y: y + frame.y,
|
|
width: frame.width,
|
|
height: frame.height,
|
|
};
|
|
}
|
|
|
|
export function renderedLocalBoxForElementSelection(
|
|
ui: RawUi,
|
|
selection: ElementSelection,
|
|
): Box | null {
|
|
if (selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
return localElementBox(rootElementsComponent(ui), selection.elementPath);
|
|
}
|
|
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
if (!component) return null;
|
|
return localElementBox(component, selection.elementPath);
|
|
}
|
|
|
|
export function rootElementsComponent(ui: RawUi): RawComponent {
|
|
return {
|
|
position: { x: 0, y: 0 },
|
|
size: { width: STAGE_WIDTH, height: STAGE_HEIGHT },
|
|
elements: readArray(ui.elements),
|
|
};
|
|
}
|
|
|
|
export function absoluteElementBox(component: RawComponent, path: number[]) {
|
|
const local = localElementBox(component, path);
|
|
if (!local) return null;
|
|
let items = readArray(component.elements).filter(isRecord) as RawElement[];
|
|
let parentElement: RawElement | null = null;
|
|
let parentRenderBox: Box = {
|
|
x: 0,
|
|
y: 0,
|
|
...readSize(component.size, { width: STAGE_WIDTH, height: STAGE_HEIGHT }),
|
|
};
|
|
let x = 0;
|
|
let y = 0;
|
|
for (const index of path.slice(0, -1)) {
|
|
const element = asRecord(items[index]);
|
|
if (!element) return null;
|
|
const laidOut =
|
|
parentElement != null
|
|
? layoutChildren(parentElement, items, parentRenderBox).find(
|
|
(item) => item.index === index,
|
|
)
|
|
: null;
|
|
const box = laidOut?.box ?? elementBox(element);
|
|
x += box.x;
|
|
y += box.y;
|
|
const childInfo = childArrayInfo(element);
|
|
parentElement = element;
|
|
parentRenderBox = { x: 0, y: 0, width: box.width, height: box.height };
|
|
items = (childInfo?.items ?? []).filter(isRecord) as RawElement[];
|
|
}
|
|
return {
|
|
x: x + local.x,
|
|
y: y + local.y,
|
|
width: local.width,
|
|
height: local.height,
|
|
};
|
|
}
|
|
|
|
export function localElementBox(component: RawComponent, path: number[]) {
|
|
let items = readArray(component.elements).filter(isRecord) as RawElement[];
|
|
let parentElement: RawElement | null = null;
|
|
let parentRenderBox: Box = {
|
|
x: 0,
|
|
y: 0,
|
|
...readSize(component.size, { width: STAGE_WIDTH, height: STAGE_HEIGHT }),
|
|
};
|
|
for (let depth = 0; depth < path.length; depth += 1) {
|
|
const index = path[depth];
|
|
const element = asRecord(items[index]);
|
|
if (!element) return null;
|
|
const laidOut =
|
|
parentElement != null
|
|
? layoutChildren(parentElement, items, parentRenderBox).find(
|
|
(item) => item.index === index,
|
|
)
|
|
: null;
|
|
const box = laidOut?.box ?? elementBox(element);
|
|
if (depth === path.length - 1) return box;
|
|
const childInfo = childArrayInfo(element);
|
|
parentElement = element;
|
|
parentRenderBox = { x: 0, y: 0, width: box.width, height: box.height };
|
|
items = (childInfo?.items ?? []).filter(isRecord) as RawElement[];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function eventTargetsThisSlide(
|
|
detail: {
|
|
slideId?: string | number | null;
|
|
slideIndex?: number | null;
|
|
},
|
|
slideId: string | number | null | undefined,
|
|
slideIndex: number | null,
|
|
isSurfaceActive: () => boolean,
|
|
) {
|
|
const currentSlideId = slideId != null ? String(slideId) : null;
|
|
const eventSlideId =
|
|
detail.slideId !== undefined && detail.slideId !== null
|
|
? String(detail.slideId)
|
|
: null;
|
|
if (eventSlideId && currentSlideId && eventSlideId !== currentSlideId) {
|
|
return false;
|
|
}
|
|
if (
|
|
!eventSlideId &&
|
|
typeof detail.slideIndex === "number" &&
|
|
(slideIndex == null || detail.slideIndex !== slideIndex)
|
|
) {
|
|
return false;
|
|
}
|
|
const hasTarget = Boolean(eventSlideId) || typeof detail.slideIndex === "number";
|
|
return hasTarget || isSurfaceActive();
|
|
}
|
|
|
|
export function keyForSelection(selection: Selection) {
|
|
if (!selection) return "";
|
|
if (selection.kind === "component") return `component:${selection.componentIndex}`;
|
|
if (selection.kind === "multi-component") {
|
|
return `multi-component:${selection.componentIndexes.join(".")}`;
|
|
}
|
|
return `element:${selection.componentIndex}:${selection.elementPath.join(".")}`;
|
|
}
|
|
|
|
export function keysForSelection(selection: Selection) {
|
|
if (!selection) return [];
|
|
if (selection.kind === "multi-component") {
|
|
return selection.componentIndexes.map((componentIndex) =>
|
|
keyForSelection({ kind: "component", componentIndex }),
|
|
);
|
|
}
|
|
return [keyForSelection(selection)];
|
|
}
|
|
|
|
export function selectionWithComponentToggle(
|
|
currentSelection: Selection,
|
|
nextSelection: Selection,
|
|
options?: SelectOptions,
|
|
): Selection {
|
|
if (!options?.additive || nextSelection?.kind !== "component") {
|
|
return nextSelection;
|
|
}
|
|
|
|
const componentIndex = nextSelection.componentIndex;
|
|
const currentIndexes = componentIndexesForSelection(currentSelection);
|
|
const nextIndexes = currentIndexes.includes(componentIndex)
|
|
? currentIndexes.filter((index) => index !== componentIndex)
|
|
: [...currentIndexes, componentIndex];
|
|
|
|
return selectionForComponentIndexes(nextIndexes);
|
|
}
|
|
|
|
export function componentIndexesForSelection(selection: Selection) {
|
|
if (!selection) return [];
|
|
if (selection.kind === "component") return [selection.componentIndex];
|
|
if (selection.kind === "multi-component") return selection.componentIndexes;
|
|
return [];
|
|
}
|
|
|
|
export function selectionForComponentIndexes(indexes: number[]): Selection {
|
|
const uniqueIndexes = Array.from(
|
|
new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0)),
|
|
);
|
|
if (uniqueIndexes.length === 0) return null;
|
|
if (uniqueIndexes.length === 1) {
|
|
return { kind: "component", componentIndex: uniqueIndexes[0] };
|
|
}
|
|
return { kind: "multi-component", componentIndexes: uniqueIndexes };
|
|
}
|
|
|
|
export function componentForClipboardSelection(
|
|
ui: RawUi,
|
|
selection: Selection,
|
|
): { components: Array<{ component: RawComponent; box: Box }>; box: Box } | null {
|
|
if (!selection) return null;
|
|
|
|
if (selection.kind === "multi-component") {
|
|
const components = selection.componentIndexes.flatMap((componentIndex) => {
|
|
const component = asRecord(readArray(ui.components)[componentIndex]);
|
|
return component ? [{ component, box: componentBox(component) }] : [];
|
|
});
|
|
return components.length > 0
|
|
? { components, box: unionBoxes(components.map((item) => item.box)) }
|
|
: null;
|
|
}
|
|
|
|
if (selection.kind === "component") {
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
return component
|
|
? {
|
|
components: [{ component, box: componentBox(component) }],
|
|
box: componentBox(component),
|
|
}
|
|
: null;
|
|
}
|
|
|
|
if (selection.componentIndex >= 0) {
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
return component
|
|
? {
|
|
components: [{ component, box: componentBox(component) }],
|
|
box: componentBox(component),
|
|
}
|
|
: null;
|
|
}
|
|
|
|
const element = getElementAtSelection(ui, selection);
|
|
const box = absoluteBoxForSelection(ui, selection);
|
|
return element && box
|
|
? {
|
|
components: [{ component: rootElementClipboardComponent(element, box), box }],
|
|
box,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
export function rootElementClipboardComponent(element: RawElement, box: Box): RawComponent {
|
|
const type = readString(element.type) ?? "element";
|
|
const label =
|
|
readString(element.name) || readString(element.id) || `Copied ${type}`;
|
|
return {
|
|
id: `${normalizeId(label)}_component`,
|
|
description: label,
|
|
position: { x: box.x, y: box.y },
|
|
size: { width: box.width, height: box.height },
|
|
elements: [
|
|
{
|
|
...element,
|
|
position: { x: 0, y: 0 },
|
|
size: { width: box.width, height: box.height },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function unionBoxes(boxes: Box[]): Box {
|
|
if (boxes.length === 0) return { x: 0, y: 0, width: 1, height: 1 };
|
|
const left = Math.min(...boxes.map((box) => box.x));
|
|
const top = Math.min(...boxes.map((box) => box.y));
|
|
const right = Math.max(...boxes.map((box) => box.x + box.width));
|
|
const bottom = Math.max(...boxes.map((box) => box.y + box.height));
|
|
return {
|
|
x: left,
|
|
y: top,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
};
|
|
}
|
|
|
|
export function surfaceSelectionTarget(
|
|
ui: RawUi,
|
|
selection: Selection,
|
|
slideIndex: number | null,
|
|
): TemplateV2SurfaceSelectedDetail["selection"] {
|
|
if (!selection) return null;
|
|
if (selection.kind === "multi-component") {
|
|
const components = selection.componentIndexes.map((componentIndex) => {
|
|
const component = asRecord(readArray(ui.components)[componentIndex]);
|
|
const componentLabel = componentDisplayLabel(component, componentIndex);
|
|
return {
|
|
kind: "component" as const,
|
|
slideIndex,
|
|
componentIndex,
|
|
componentId: readString(component?.id) || undefined,
|
|
componentLabel,
|
|
targetLabel: componentLabel,
|
|
};
|
|
});
|
|
return {
|
|
kind: "multi-component",
|
|
slideIndex,
|
|
components,
|
|
componentIds: components
|
|
.map((component) => component.componentId)
|
|
.filter((value): value is string => Boolean(value)),
|
|
componentLabels: components.map((component) => component.componentLabel),
|
|
targetLabel: `${components.length} components selected`,
|
|
};
|
|
}
|
|
if (selection.kind === "component") {
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
const componentLabel = componentDisplayLabel(component, selection.componentIndex);
|
|
return {
|
|
kind: "component",
|
|
slideIndex,
|
|
componentIndex: selection.componentIndex,
|
|
componentId: readString(component?.id) || undefined,
|
|
componentLabel,
|
|
targetLabel: componentLabel,
|
|
};
|
|
}
|
|
|
|
const element = getElementAtSelection(ui, selection);
|
|
const component = asRecord(readArray(ui.components)[selection.componentIndex]);
|
|
const componentLabel =
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX
|
|
? ""
|
|
: componentDisplayLabel(component, selection.componentIndex);
|
|
const elementType = readString(element?.type) || "Element";
|
|
const elementName = readString(element?.name);
|
|
const targetLabel =
|
|
elementName ||
|
|
(componentLabel ? `${elementType} in ${componentLabel}` : elementType);
|
|
return {
|
|
kind: "element",
|
|
slideIndex,
|
|
componentIndex:
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX
|
|
? undefined
|
|
: selection.componentIndex,
|
|
componentId: readString(component?.id) || undefined,
|
|
componentLabel: componentLabel || undefined,
|
|
elementPath: elementPathForSelection(ui, selection) || undefined,
|
|
elementType,
|
|
elementName: elementName || undefined,
|
|
targetLabel,
|
|
};
|
|
}
|
|
|
|
export function componentDisplayLabel(component: UnknownRecord | null, index: number) {
|
|
return (
|
|
readString(component?.description) ||
|
|
readString(component?.name) ||
|
|
readString(component?.id) ||
|
|
`Component ${index + 1}`
|
|
);
|
|
}
|
|
|
|
export function elementPathForSelection(ui: RawUi, selection: ElementSelection) {
|
|
const parts: string[] =
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX
|
|
? []
|
|
: [`components[${selection.componentIndex}]`];
|
|
let items =
|
|
selection.componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX
|
|
? readArray(ui.elements)
|
|
: readArray(asRecord(readArray(ui.components)[selection.componentIndex])?.elements);
|
|
let current: RawElement | null = null;
|
|
|
|
for (let depth = 0; depth < selection.elementPath.length; depth += 1) {
|
|
const index = selection.elementPath[depth] ?? -1;
|
|
if (!Number.isFinite(index) || index < 0 || index >= items.length) return "";
|
|
if (depth === 0) {
|
|
parts.push(`elements[${index}]`);
|
|
} else if (current) {
|
|
const childInfo = childArrayInfo(current);
|
|
if (!childInfo) return "";
|
|
parts.push(childInfo.key === "child" ? "child" : `${childInfo.key}[${index}]`);
|
|
}
|
|
current = asRecord(items[index]) as RawElement | null;
|
|
items = current ? childArrayInfo(current)?.items ?? [] : [];
|
|
}
|
|
|
|
return parts.join(".");
|
|
}
|
|
|
|
export function selectionFromKey(key: string): Selection {
|
|
if (key.startsWith("component:")) {
|
|
const componentIndex = Number(key.split(":")[1]);
|
|
return Number.isFinite(componentIndex)
|
|
? { kind: "component", componentIndex }
|
|
: null;
|
|
}
|
|
if (key.startsWith("multi-component:")) {
|
|
const componentIndexes = key
|
|
.split(":")[1]
|
|
?.split(".")
|
|
.map(Number)
|
|
.filter((value) => Number.isInteger(value) && value >= 0) ?? [];
|
|
return selectionForComponentIndexes(componentIndexes);
|
|
}
|
|
const [, component, path] = key.split(":");
|
|
const componentIndex = Number(component);
|
|
const elementPath = path
|
|
?.split(".")
|
|
.map(Number)
|
|
.filter((value) => Number.isFinite(value));
|
|
if (!Number.isFinite(componentIndex) || !elementPath?.length) return null;
|
|
return { kind: "element", componentIndex, elementPath };
|
|
}
|
|
|
|
export function selectionTouchesComponent(
|
|
key: string | null,
|
|
componentIndex: number,
|
|
) {
|
|
return (
|
|
key === `component:${componentIndex}` ||
|
|
key?.startsWith(`element:${componentIndex}:`) === true
|
|
);
|
|
}
|
|
|
|
export function selectionTouchesElement(
|
|
key: string | null,
|
|
componentIndex: number,
|
|
elementPath: number[],
|
|
) {
|
|
if (!key) return false;
|
|
const ownKey = `element:${componentIndex}:${elementPath.join(".")}`;
|
|
return key === ownKey || key.startsWith(`${ownKey}.`);
|
|
}
|
|
|
|
export function numberPathEqual(previous: number[], next: number[]) {
|
|
return (
|
|
previous.length === next.length &&
|
|
previous.every((value, index) => value === next[index])
|
|
);
|
|
}
|
|
|
|
export function boxEqual(previous: Box, next: Box) {
|
|
return (
|
|
previous.x === next.x &&
|
|
previous.y === next.y &&
|
|
previous.width === next.width &&
|
|
previous.height === next.height
|
|
);
|
|
}
|
|
|
|
export function nullableBoxEqual(
|
|
previous: Box | null | undefined,
|
|
next: Box | null | undefined,
|
|
) {
|
|
if (previous == null || next == null) return previous == null && next == null;
|
|
return boxEqual(previous, next);
|
|
}
|
|
|
|
export function componentKey(component: RawComponent, index: number) {
|
|
return `${readString(component.id) ?? "component"}:${index}`;
|
|
}
|
|
|
|
export function rawElementKey(element: RawElement, index: number) {
|
|
return `${readString(element.id) ?? readString(element.name) ?? readString(element.type) ?? "element"}:${index}`;
|
|
}
|
|
|
|
export function componentBox(component: RawComponent): Box {
|
|
return {
|
|
...readPoint(component.position),
|
|
...readSize(component.size, { width: STAGE_WIDTH, height: STAGE_HEIGHT }),
|
|
};
|
|
}
|
|
|
|
export function elementBox(element: RawElement): Box {
|
|
const box = {
|
|
...readPoint(element.position),
|
|
...elementSize(element),
|
|
};
|
|
const type = readString(element.type);
|
|
if (type === "text") {
|
|
return textVisualLocalBox(element, box);
|
|
}
|
|
if (type === "text-list") {
|
|
return textVisualLocalBox(element, box, {
|
|
runs: rawTextListRenderTextRuns(element),
|
|
});
|
|
}
|
|
return box;
|
|
}
|
|
|
|
export function isManualPositioned(element: RawElement) {
|
|
return element.__presenton_manual_position === true;
|
|
}
|
|
|
|
export function elementSize(element: RawElement, fallback?: Size): Size {
|
|
const explicit = readOptionalSize(element.size);
|
|
if (explicit) return explicit;
|
|
|
|
const type = readString(element.type);
|
|
if (type === "group") {
|
|
return childrenBounds(childArrayInfo(element)?.items ?? []);
|
|
}
|
|
if (type === "container") {
|
|
const padding = readPadding(element.padding);
|
|
const child = asRecord(element.child);
|
|
const childSize = child ? elementSize(child, fallback) : fallback;
|
|
if (childSize) {
|
|
return {
|
|
width: Math.max(1, childSize.width + padding.left + padding.right),
|
|
height: Math.max(1, childSize.height + padding.top + padding.bottom),
|
|
};
|
|
}
|
|
}
|
|
if (type === "text") {
|
|
const font = rawFont(element);
|
|
const text = displayText(rawTextContent(element));
|
|
const width = fallback?.width ?? estimateTextWidth(text, font);
|
|
return {
|
|
width: Math.max(1, width),
|
|
height: Math.max(1, estimateTextHeight(text, font, width)),
|
|
};
|
|
}
|
|
if (type === "text-list") {
|
|
const font = rawFont(element);
|
|
const text = displayText(textRunsContent(rawTextListRunsForEditor(element)));
|
|
const width = fallback?.width ?? estimateTextWidth(text, font);
|
|
return {
|
|
width: Math.max(1, width),
|
|
height: Math.max(1, estimateTextHeight(text, font, width)),
|
|
};
|
|
}
|
|
if (type === "line") {
|
|
return {
|
|
width: fallback?.width ?? DECORATIVE_LINE_LENGTH,
|
|
height: fallback?.height ?? DECORATIVE_LINE_THICKNESS,
|
|
};
|
|
}
|
|
if (type === "rectangle" || type === "ellipse") {
|
|
return {
|
|
width: fallback?.width ?? DECORATIVE_LINE_LENGTH,
|
|
height: fallback?.height ?? DECORATIVE_LINE_LENGTH,
|
|
};
|
|
}
|
|
if (
|
|
type === "flex" ||
|
|
type === "grid" ||
|
|
type === "list-view" ||
|
|
type === "grid-view"
|
|
) {
|
|
return (
|
|
fallback ??
|
|
intrinsicFlowSize(
|
|
element,
|
|
(childArrayInfo(element)?.items ?? []).filter(isRecord) as RawElement[],
|
|
{
|
|
elementBox,
|
|
elementSize,
|
|
isManualPositioned,
|
|
},
|
|
)
|
|
);
|
|
}
|
|
return fallback ?? { width: 1, height: 1 };
|
|
}
|
|
|
|
export function childrenBounds(children: unknown[]): Size {
|
|
const records = children.filter(isRecord) as RawElement[];
|
|
if (records.length === 0) return { width: 1, height: 1 };
|
|
|
|
return records.reduce<Size>(
|
|
(bounds, child) => {
|
|
const box = elementBox(child);
|
|
return {
|
|
width: Math.max(bounds.width, box.x + box.width),
|
|
height: Math.max(bounds.height, box.y + box.height),
|
|
};
|
|
},
|
|
{ width: 1, height: 1 },
|
|
);
|
|
}
|
|
|
|
export function childArrayInfo(element: RawElement): ChildArrayInfo | null {
|
|
if (Array.isArray(element.children)) return { key: "children", items: element.children };
|
|
if (Array.isArray(element.elements)) return { key: "elements", items: element.elements };
|
|
if (isRecord(element.child)) return { key: "child", items: [element.child] };
|
|
return null;
|
|
}
|
|
|
|
export function withUpdatedChildItems(
|
|
element: RawElement,
|
|
childInfo: ChildArrayInfo,
|
|
updatedChildren: unknown[],
|
|
) {
|
|
if (childInfo.key === "child") {
|
|
return { ...element, child: updatedChildren[0] ?? null };
|
|
}
|
|
return { ...element, [childInfo.key]: updatedChildren };
|
|
}
|
|
|
|
export function shouldClipElementChildren(
|
|
element: RawElement,
|
|
childInfo: ChildArrayInfo | null,
|
|
) {
|
|
if (!childInfo) return false;
|
|
const type = readString(element.type);
|
|
return type === "container";
|
|
}
|
|
|
|
export function isBoxVisualType(type: string | null) {
|
|
return (
|
|
type === "rectangle" ||
|
|
type === "container" ||
|
|
type === "flex" ||
|
|
type === "grid" ||
|
|
type === "list-view" ||
|
|
type === "grid-view" ||
|
|
type === "group"
|
|
);
|
|
}
|
|
|
|
export function elementWithInlineDraft(
|
|
element: RawElement,
|
|
kind: TemplateV2InlineEditKind,
|
|
draft: string,
|
|
style?: TemplateV2TextEditStyle,
|
|
frame?: Box | null,
|
|
runs?: TextRun[],
|
|
) {
|
|
if (kind === "text") {
|
|
const next =
|
|
runs != null
|
|
? setRawTextRunsContent(element, runs)
|
|
: draft === rawTextContent(element)
|
|
? element
|
|
: setRawTextContent(element, draft, style);
|
|
return preserveInlineEditFrame(next, frame);
|
|
}
|
|
if (kind === "text-list") {
|
|
const next =
|
|
runs != null
|
|
? setRawTextListRunsContent(element, runs)
|
|
: setRawTextListContent(element, draft);
|
|
const styled = style ? applyTextStyle(next, style) : next;
|
|
return preserveInlineEditFrame(styled, frame);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
export function preserveInlineEditFrame(element: RawElement, frame?: Box | null) {
|
|
if (!frame) return element;
|
|
return {
|
|
...element,
|
|
position: {
|
|
...(asRecord(element.position) ?? {}),
|
|
x: frame.x,
|
|
y: frame.y,
|
|
},
|
|
size: {
|
|
...(asRecord(element.size) ?? {}),
|
|
width: frame.width,
|
|
height: frame.height,
|
|
},
|
|
__presenton_manual_position: true,
|
|
};
|
|
}
|
|
|
|
export function normalizeMarkdownTextInUi(ui: RawUi): RawUi {
|
|
let changed = false;
|
|
const nextUi: RawUi = { ...ui };
|
|
const elements = readArray(ui.elements);
|
|
const normalizedElements = normalizeMarkdownTextElementArray(elements);
|
|
if (normalizedElements !== elements) {
|
|
nextUi.elements = normalizedElements;
|
|
changed = true;
|
|
}
|
|
|
|
const components = readArray(ui.components);
|
|
let componentsChanged = false;
|
|
const normalizedComponents = components.map((component) => {
|
|
const record = asRecord(component);
|
|
if (!record) return component;
|
|
const componentElements = readArray(record.elements);
|
|
const normalizedComponentElements =
|
|
normalizeMarkdownTextElementArray(componentElements);
|
|
if (normalizedComponentElements === componentElements) return component;
|
|
componentsChanged = true;
|
|
return {
|
|
...record,
|
|
elements: normalizedComponentElements,
|
|
};
|
|
});
|
|
|
|
if (componentsChanged) {
|
|
nextUi.components = normalizedComponents;
|
|
changed = true;
|
|
}
|
|
|
|
return changed ? nextUi : ui;
|
|
}
|
|
|
|
export function normalizeMarkdownTextElementArray(elements: unknown[]): unknown[] {
|
|
let changed = false;
|
|
const normalized = elements.map((element) => {
|
|
const next = normalizeMarkdownTextElementTree(element);
|
|
if (next !== element) changed = true;
|
|
return next;
|
|
});
|
|
return changed ? normalized : elements;
|
|
}
|
|
|
|
export function normalizeMarkdownTextElementTree(value: unknown): unknown {
|
|
const element = asRecord(value);
|
|
if (!element) return value;
|
|
|
|
let next = element;
|
|
if (readString(element.type) === "text") {
|
|
const normalized = normalizeRawTextMarkdownElement(element);
|
|
next = normalized.element;
|
|
}
|
|
|
|
const childInfo = childArrayInfo(next);
|
|
if (!childInfo) return next;
|
|
|
|
const normalizedChildren = normalizeMarkdownTextElementArray(childInfo.items);
|
|
return normalizedChildren === childInfo.items
|
|
? next
|
|
: withUpdatedChildItems(next, childInfo, normalizedChildren);
|
|
}
|
|
|
|
export function rawElementForEditorToolbar(
|
|
element: RawElement,
|
|
absoluteBox: Box,
|
|
): SlideElement | null {
|
|
const type = readString(element.type);
|
|
if (!type) return null;
|
|
|
|
const projected: UnknownRecord = {
|
|
...element,
|
|
type,
|
|
position: {
|
|
x: absoluteBox.x,
|
|
y: absoluteBox.y,
|
|
},
|
|
size: {
|
|
width: absoluteBox.width,
|
|
height: absoluteBox.height,
|
|
},
|
|
font: rawFontRecordForEditor(element.font),
|
|
stroke: rawStrokeForEditor(element.stroke),
|
|
border_radius: rawBorderRadiusForEditor(
|
|
element.border_radius ?? element.borderRadius,
|
|
),
|
|
};
|
|
|
|
if (type === "text") {
|
|
projected.runs = rawTextRunsForEditor(element).map((run) => ({
|
|
text: run.text,
|
|
font: rawFontRecordForEditor(run.font),
|
|
}));
|
|
} else if (type === "text-list") {
|
|
projected.items = readArray(element.items).map((item) => {
|
|
if (Array.isArray(item)) {
|
|
return item.map((value) => {
|
|
const run = asRecord(value) ?? {};
|
|
return { ...run, font: rawFontRecordForEditor(run.font) };
|
|
});
|
|
}
|
|
return [{ text: rawTextListItemText(item) }];
|
|
});
|
|
} else if (type === "table") {
|
|
projected.columns = readArray(element.columns).map(rawTableCellForEditor);
|
|
projected.rows = readArray(element.rows).map((row) =>
|
|
readArray(row).map(rawTableCellForEditor),
|
|
);
|
|
} else if (type === "chart") {
|
|
Object.assign(projected, rawChartToEditorChart(element));
|
|
projected.position = {
|
|
x: absoluteBox.x,
|
|
y: absoluteBox.y,
|
|
};
|
|
projected.size = {
|
|
width: absoluteBox.width,
|
|
height: absoluteBox.height,
|
|
};
|
|
}
|
|
|
|
return projected as unknown as SlideElement;
|
|
}
|
|
|
|
export function mergeEditorToolbarElement(
|
|
current: RawElement,
|
|
editorElement: SlideElement,
|
|
renderedBox: Box,
|
|
): RawElement {
|
|
const editor = editorElement as unknown as UnknownRecord;
|
|
const currentPosition = readPoint(current.position);
|
|
const editorPosition = asRecord(editor.position);
|
|
const editorSize = asRecord(editor.size);
|
|
const editorX = readNumber(editorPosition?.x);
|
|
const editorY = readNumber(editorPosition?.y);
|
|
const editorWidth = readNumber(editorSize?.width);
|
|
const editorHeight = readNumber(editorSize?.height);
|
|
const nextPosition = {
|
|
x:
|
|
currentPosition.x +
|
|
((editorX ?? renderedBox.x) - renderedBox.x),
|
|
y:
|
|
currentPosition.y +
|
|
((editorY ?? renderedBox.y) - renderedBox.y),
|
|
};
|
|
const nextSize = {
|
|
width: Math.max(
|
|
1,
|
|
editorWidth ?? renderedBox.width,
|
|
),
|
|
height: Math.max(
|
|
1,
|
|
editorHeight ?? renderedBox.height,
|
|
),
|
|
};
|
|
const merged: RawElement = {
|
|
...current,
|
|
...editor,
|
|
position: nextPosition,
|
|
size: nextSize,
|
|
font: editorFontRecordToRaw(editor.font, current.font),
|
|
stroke: editorStrokeToRaw(editor.stroke, current.stroke),
|
|
border_radius: editorBorderRadiusToRaw(
|
|
editor.border_radius ?? editor.borderRadius,
|
|
current.border_radius ?? current.borderRadius,
|
|
),
|
|
};
|
|
|
|
if (Array.isArray(editor.runs)) {
|
|
const currentRuns = readArray(current.runs);
|
|
merged.runs = editor.runs.map((value, index) => {
|
|
const run = asRecord(value) ?? {};
|
|
const currentRun = asRecord(currentRuns[index]) ?? {};
|
|
return {
|
|
...currentRun,
|
|
...run,
|
|
font: editorFontRecordToRaw(run.font, currentRun.font),
|
|
};
|
|
});
|
|
}
|
|
if (readString(current.type) === "table") {
|
|
merged.columns = readArray(editor.columns).map((cell, index) =>
|
|
editorTableCellToRaw(cell, readArray(current.columns)[index]),
|
|
);
|
|
merged.rows = readArray(editor.rows).map((row, rowIndex) =>
|
|
readArray(row).map((cell, colIndex) =>
|
|
editorTableCellToRaw(
|
|
cell,
|
|
readArray(readArray(current.rows)[rowIndex])[colIndex],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
Math.abs(nextPosition.x - currentPosition.x) > 0.01 ||
|
|
Math.abs(nextPosition.y - currentPosition.y) > 0.01 ||
|
|
Math.abs(nextSize.width - elementSize(current).width) > 0.01 ||
|
|
Math.abs(nextSize.height - elementSize(current).height) > 0.01
|
|
) {
|
|
merged.__presenton_manual_position = true;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function rawStrokeForEditor(value: unknown) {
|
|
const stroke = asRecord(value);
|
|
if (!stroke) return value;
|
|
return { ...stroke };
|
|
}
|
|
|
|
export function editorStrokeToRaw(value: unknown, fallback: unknown) {
|
|
const stroke = asRecord(value);
|
|
if (!stroke) return fallback;
|
|
return {
|
|
...(asRecord(fallback) ?? {}),
|
|
...stroke,
|
|
};
|
|
}
|
|
|
|
export function rawBorderRadiusForEditor(value: unknown) {
|
|
const radius = asRecord(value);
|
|
const uniform = readNumber(value);
|
|
if (!radius && uniform == null) return value;
|
|
const raw = radius ?? { tl: uniform, tr: uniform, bl: uniform, br: uniform };
|
|
return {
|
|
tl: readNumber(raw.tl) ?? 0,
|
|
tr: readNumber(raw.tr) ?? 0,
|
|
bl: readNumber(raw.bl) ?? 0,
|
|
br: readNumber(raw.br) ?? 0,
|
|
};
|
|
}
|
|
|
|
export function editorBorderRadiusToRaw(value: unknown, fallback: unknown) {
|
|
const radius = asRecord(value);
|
|
if (!radius) return fallback;
|
|
return {
|
|
tl: readNumber(radius.tl) ?? 0,
|
|
tr: readNumber(radius.tr) ?? 0,
|
|
bl: readNumber(radius.bl) ?? 0,
|
|
br: readNumber(radius.br) ?? 0,
|
|
};
|
|
}
|
|
|
|
export function rawTableCellForEditor(value: unknown) {
|
|
const cell = asRecord(value) ?? {};
|
|
const rawRuns = readArray(cell.runs);
|
|
const runs =
|
|
rawRuns.length > 0
|
|
? rawRuns.map((value) => {
|
|
const run = asRecord(value) ?? {};
|
|
return { ...run, font: rawFontRecordForEditor(run.font) };
|
|
})
|
|
: [{ text: rawTableCellText(cell) }];
|
|
return {
|
|
...cell,
|
|
color: cell.color ?? cell.fill,
|
|
font: rawFontRecordForEditor(cell.font),
|
|
runs,
|
|
};
|
|
}
|
|
|
|
export function editorTableCellToRaw(value: unknown, fallback: unknown) {
|
|
const cell = asRecord(value) ?? {};
|
|
const current = asRecord(fallback) ?? {};
|
|
const currentRuns = readArray(current.runs);
|
|
return {
|
|
...current,
|
|
...cell,
|
|
color: cell.color ?? current.color ?? current.fill,
|
|
font: editorFontRecordToRaw(cell.font, current.font),
|
|
runs: readArray(cell.runs).map((value, index) => {
|
|
const run = asRecord(value) ?? {};
|
|
const currentRun = asRecord(currentRuns[index]) ?? {};
|
|
return {
|
|
...currentRun,
|
|
...run,
|
|
font: editorFontRecordToRaw(run.font, currentRun.font),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function linePoints(width: number, height: number, strokeWidthValue: number) {
|
|
if (height <= Math.max(2, strokeWidthValue * 2)) {
|
|
return [0, height / 2, width, height / 2];
|
|
}
|
|
if (width <= Math.max(2, strokeWidthValue * 2)) {
|
|
return [width / 2, 0, width / 2, height];
|
|
}
|
|
return [0, 0, width, height];
|
|
}
|
|
|
|
export function valueProgress(element: RawElement) {
|
|
const min = readNumber(element.min_value) ?? readNumber(element.minValue) ?? 0;
|
|
const max = readNumber(element.max_value) ?? readNumber(element.maxValue) ?? 100;
|
|
const value = readNumber(element.value) ?? min;
|
|
const range = max - min;
|
|
if (!Number.isFinite(range) || range === 0) return 0;
|
|
return clamp((value - min) / range, 0, 1);
|
|
}
|
|
|
|
export function pointOnCircle(x: number, y: number, radius: number, degrees: number) {
|
|
const radians = (degrees * Math.PI) / 180;
|
|
return {
|
|
x: x + Math.cos(radians) * radius,
|
|
y: y + Math.sin(radians) * radius,
|
|
};
|
|
}
|
|
|
|
export function rawIconQuery(element: RawElement): string {
|
|
for (const key of ["icon_query", "query", "__icon_query__"]) {
|
|
const query = readString(element[key])?.trim();
|
|
if (query) return query;
|
|
}
|
|
|
|
const name = (readString(element.name) ?? "").replace(/[_-]+/g, " ").trim();
|
|
return name || "icon";
|
|
}
|
|
|
|
export function isRawIconElement(element: RawElement): boolean {
|
|
return (
|
|
readString(element.type) === "image" && readBoolean(element.is_icon) === true
|
|
);
|
|
}
|
|
|
|
export function isStaticSvgIconSource(source: string, baseUrl: string): boolean {
|
|
try {
|
|
const pathname = new URL(source, baseUrl).pathname;
|
|
return (
|
|
pathname.startsWith("/static/icons/") &&
|
|
pathname.toLowerCase().endsWith(".svg")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|