Files
presenton--presenton/servers/nextjs/components/slide-editor/insert/insert-elements.ts
T
2026-07-13 13:08:41 +08:00

700 lines
17 KiB
TypeScript

import type { TemplateV2InsertComponent } from "@/components/slide-editor/events/events";
import { normalizeChartTypeName } from "@/components/slide-editor/charts/chart-data";
import {
measureNoWrapTextWidth,
rawFont,
} from "@/components/slide-editor/text/template-v2-text";
import type {
ChartType,
Fill,
Font,
Marker,
SlideElement,
TableCell,
} from "@/components/slide-editor/types";
const DEFAULT_CHART_INSERT_POSITION = { x: 128, y: 115 };
const DEFAULT_CHART_INSERT_SIZE = { width: 717, height: 410 };
const DEFAULT_IMAGE_PLACEHOLDER_SRC = "/placeholder.jpg";
const TEXT_INSERT_HORIZONTAL_PADDING_PX = 8;
const TEXT_INSERT_VERTICAL_PADDING_PX = 14;
const IMAGE_RADIUS = { tl: 10, tr: 10, bl: 10, br: 10 };
export type EditorInsertContent = {
elements?: SlideElement[];
components?: TemplateV2InsertComponent[];
};
function fittedTextHeight(
lineCount: number,
fontSize: number,
lineHeight: number,
) {
return Math.ceil(
lineCount * fontSize * lineHeight + TEXT_INSERT_VERTICAL_PADDING_PX,
);
}
function makeTextElement({
text,
x,
y,
width,
height,
size,
color = "101323",
bold = false,
italic = false,
lineHeight = 1.1,
horizontal = "left",
}: {
text: string;
x: number;
y: number;
width: number;
height: number;
size: number;
color?: string;
bold?: boolean;
italic?: boolean;
lineHeight?: number;
horizontal?: "left" | "center" | "right";
}): SlideElement {
return {
type: "text",
position: { x, y },
size: { width, height },
alignment: { horizontal, vertical: "top" },
runs: [{ text }],
font: {
family: "Arial",
size,
color,
bold,
italic,
line_height: lineHeight,
},
};
}
function makeTableCell({
text,
font,
color,
alignment = "left",
}: {
text: string;
font: Font;
color?: Fill;
alignment?: TableCell["alignment"];
}): TableCell {
return {
alignment,
color,
runs: [{ text, font }],
};
}
function makeBulletListElement(marker: Marker): SlideElement {
const baseFont = {
size: 18,
family: "Inter",
color: "#111111",
bold: false,
italic: false,
line_height: 1.4,
letter_spacing: 0,
ellipsis: false,
};
const items = [
[
{
text: "Clarify the goal and audience",
font: { ...baseFont, bold: true },
},
],
[{ text: "Show the strongest supporting point", font: { ...baseFont } }],
[
{
text: "Close with the next action",
font: { ...baseFont, italic: true },
},
],
];
const renderFont = rawFont({ font: baseFont });
const fittedWidth = Math.ceil(
Math.max(
...items.map((item, index) => {
const prefix =
marker === "bullet"
? "• "
: marker === "number"
? `${index + 1}. `
: "";
const text = item.map((run) => run.text).join("");
return measureNoWrapTextWidth(`${prefix}${text}`, renderFont);
}),
) + TEXT_INSERT_HORIZONTAL_PADDING_PX,
);
return {
type: "text-list",
position: { x: 122, y: 128 },
size: {
width: fittedWidth,
height: fittedTextHeight(
items.length,
baseFont.size,
baseFont.line_height,
),
},
rotation: 0,
font: baseFont,
marker,
items,
decorative: false,
name: "Project task list",
max_items: 6,
min_items: 3,
max_item_length: 60,
min_item_length: 30,
};
}
export function createTextInsertElements(kind?: string): SlideElement[] {
switch (kind) {
case "title-block":
return [
makeTextElement({
text: "Add a clear slide title",
x: 109,
y: 109,
width: 986,
height: fittedTextHeight(1, 38, 1.1),
size: 38,
bold: true,
}),
];
case "subtitle":
return [
makeTextElement({
text: "Add a concise supporting subtitle",
x: 122,
y: 154,
width: 870,
height: fittedTextHeight(1, 24, 1.2),
size: 24,
color: "344054",
lineHeight: 1.2,
}),
];
case "bullet-list":
return [makeBulletListElement("bullet")];
case "numbered-list":
return [makeBulletListElement("number")];
case "list-item":
return [makeBulletListElement("none")];
case "quote":
return [
makeTextElement({
text: '"Add a memorable quote or customer insight here."',
x: 122,
y: 147,
width: 858,
height: fittedTextHeight(2, 24, 1.25),
size: 24,
color: "101323",
italic: true,
lineHeight: 1.25,
}),
];
case "body-text":
return [
makeTextElement({
text: "Add body text here. Use this space for a short paragraph or supporting detail.",
x: 122,
y: 154,
width: 858,
height: fittedTextHeight(2, 18, 1.28),
size: 18,
color: "344054",
lineHeight: 1.28,
}),
];
default:
return [];
}
}
function chartTypeFromPaletteId(id?: string): ChartType | null {
const normalized = normalizeChartTypeName(id);
switch (normalized) {
case "area":
case "bar":
case "donut":
case "horizontal_bar":
case "line":
case "pie":
case "polar_area":
case "radar":
case "scatter":
return normalized as ChartType;
case "stackedbar":
case "stacked_bar":
return "stacked_bar";
case "horizontalstackbar":
case "horizontalstackedbar":
case "horizontal_stack_bar":
case "horizontal_stacked_bar":
return "horizontal_stacked_bar";
default:
return null;
}
}
function chartData(
categories: string[],
values: number[],
colors: string[],
) {
return categories.map((category, index) => ({
label: category,
value: values[index] ?? 0,
color: colors[index % colors.length] ?? colors[0],
}));
}
function chartExample(chartType: ChartType) {
switch (chartType) {
case "donut":
return {
title: "Revenue Share by Segment",
categories: ["Enterprise", "Mid-market", "Small business", "Consumer"],
values: [42, 28, 18, 12],
seriesName: "Revenue Share",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A"],
};
case "horizontal_bar":
return {
title: "Qualified Leads by Channel",
categories: ["Email", "Search", "Social", "Referral"],
values: [68, 54, 47, 38],
seriesName: "Qualified Leads",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A"],
};
case "polar_area":
return {
title: "Support Tickets by Priority",
categories: ["Critical", "High", "Medium", "Low"],
values: [18, 32, 46, 24],
seriesName: "Ticket Volume",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A"],
};
case "radar":
return {
title: "Product Readiness Score",
categories: ["Design", "Reliability", "Speed", "Security", "Usability"],
values: [82, 74, 88, 69, 91],
seriesName: "Readiness Score",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A", "06B6D4"],
};
case "scatter":
return {
title: "Campaign Conversion Results",
categories: ["Campaign A", "Campaign B", "Campaign C", "Campaign D"],
values: [42, 57, 63, 76],
seriesName: "Conversions",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A"],
};
default:
return {
title: "Quarterly Revenue Trend",
categories: ["Q1", "Q2", "Q3", "Q4"],
values: [38, 54, 47, 68],
seriesName: "Revenue",
colors: ["7F22FE", "155DFC", "F59E0B", "12B76A"],
};
}
}
function makeChartElement(chartType: ChartType): SlideElement {
if (chartType === "bar") {
const categories = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const values = [70, 120, 45, 145, 105, 105, 45];
const colors = [
"4D20C5",
"155DFC",
"F59E0B",
"12B76A",
"EF4444",
"06B6D4",
"8B5CF6",
];
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { ...DEFAULT_CHART_INSERT_SIZE },
chart_type: "bar",
title: "Weekly Website Visits\nJun 10-16",
color: "4D20C5",
axis_color: "D8D8D8",
grid_color: "D8D8D8",
data_labels: "top",
x_axis_grid: true,
y_axis_grid: true,
x_axis: true,
y_axis: true,
categories,
series: [{ name: "Visits", values }],
colors,
data: chartData(categories, values, colors),
};
}
if (chartType === "line") {
const categories = ["2021", "2022", "2023", "2024", "2025", "2026"];
const values = [15, 45, 85, 50, 75, 95];
const colors = [
"4D20C5",
"155DFC",
"F59E0B",
"12B76A",
"EF4444",
"06B6D4",
];
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { ...DEFAULT_CHART_INSERT_SIZE },
chart_type: "line",
title: "Revenue Growth\n2021-2026",
color: "4D20C5",
axis_color: "D8D8D8",
grid_color: "D8D8D8",
data_labels: null,
x_axis_grid: true,
y_axis_grid: true,
x_axis: true,
y_axis: false,
categories,
series: [{ name: "Revenue", values }],
colors,
data: chartData(categories, values, colors),
};
}
if (chartType === "area") {
const categories = ["2021", "2022", "2023", "2024", "2025", "2026"];
const values = [25, 48, 46, 57, 62, 78];
const colors = [
"4D20C5",
"155DFC",
"F59E0B",
"12B76A",
"EF4444",
"06B6D4",
];
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { ...DEFAULT_CHART_INSERT_SIZE },
chart_type: "area",
title: "Monthly Active Users\n2021-2026",
color: "7555F6",
axis_color: "D8D8D8",
grid_color: "D8D8D8",
data_labels: null,
x_axis_grid: true,
y_axis_grid: true,
x_axis: true,
y_axis: false,
categories,
series: [{ name: "Active Users", values }],
colors,
data: chartData(categories, values, colors),
};
}
if (chartType === "pie") {
const categories = ["Product", "Services", "Support", "Training"];
const values = [45, 30, 15, 10];
const colors = ["7555F6", "155DFC", "F59E0B", "12B76A"];
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { ...DEFAULT_CHART_INSERT_SIZE },
chart_type: "pie",
title: "Revenue Mix by Offering",
color: "7555F6",
axis_color: "D8D8D8",
grid_color: "D8D8D8",
data_labels: "top",
categories,
series: [{ name: "Revenue Share", values }],
colors,
data: chartData(categories, values, colors),
};
}
if (chartType === "stacked_bar" || chartType === "horizontal_stacked_bar") {
const categories = ["Q1", "Q2", "Q3", "Q4"];
const values = [38, 54, 47, 68];
const secondaryValues = [24, 36, 31, 42];
const colors = ["7F22FE", "155DFC"];
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { width: 538, height: 410 },
chart_type: chartType,
title: "Quarterly Revenue by Segment",
color: "7F22FE",
axis_color: "D0D5DD",
grid_color: "D0D5DD",
data_labels: "top",
legend: true,
x_axis_grid: true,
y_axis_grid: true,
categories,
series: [
{ name: "New Business", values },
{ name: "Expansion", values: secondaryValues },
],
colors,
data: chartData(categories, values, colors),
};
}
const { title, categories, values, seriesName, colors } =
chartExample(chartType);
return {
type: "chart",
position: { ...DEFAULT_CHART_INSERT_POSITION },
size: { width: 538, height: 410 },
chart_type: chartType,
title,
color: "7F22FE",
axis_color: "D0D5DD",
grid_color: "D0D5DD",
data_labels: "top",
x_axis_grid: true,
y_axis_grid: true,
categories,
series: [{ name: seriesName, values }],
colors,
data: chartData(categories, values, colors),
};
}
export function createChartInsertElements(kind?: string): SlideElement[] {
const chartType = chartTypeFromPaletteId(kind);
return chartType ? [makeChartElement(chartType)] : [];
}
function makeSimpleTableElement(): SlideElement {
const baseFont: Font = {
family: "Inter",
size: 14,
color: "#344054",
line_height: 1.2,
};
const headerFont: Font = {
...baseFont,
color: "#101323",
bold: true,
};
const headerFill: Fill = { color: "#F2F4F7", opacity: 1 };
const bodyFill: Fill = { color: "#FFFFFF", opacity: 1 };
return {
type: "table",
position: { x: 122, y: 128 },
size: { width: 819, height: 186 },
font: baseFont,
columns: [
makeTableCell({ text: "Metric", font: headerFont, color: headerFill }),
makeTableCell({ text: "Current", font: headerFont, color: headerFill }),
makeTableCell({ text: "Target", font: headerFont, color: headerFill }),
],
rows: [
[
makeTableCell({ text: "Activation", font: baseFont, color: bodyFill }),
makeTableCell({ text: "68%", font: baseFont, color: bodyFill }),
makeTableCell({ text: "75%", font: baseFont, color: bodyFill }),
],
[
makeTableCell({ text: "Retention", font: baseFont, color: bodyFill }),
makeTableCell({ text: "42%", font: baseFont, color: bodyFill }),
makeTableCell({ text: "50%", font: baseFont, color: bodyFill }),
],
],
min_columns: 2,
max_columns: 6,
min_rows: 2,
max_rows: 8,
};
}
export function createTableInsertElements(kind?: string): SlideElement[] {
return kind === "simple-table" ? [makeSimpleTableElement()] : [];
}
function makeImageElement({
x,
y,
width,
height,
name = "Image",
}: {
x: number;
y: number;
width: number;
height: number;
name?: string;
}): SlideElement {
return {
type: "image",
position: { x, y },
size: { width, height },
data: DEFAULT_IMAGE_PLACEHOLDER_SRC,
fit: "cover",
name,
border_radius: IMAGE_RADIUS,
};
}
export function createImageInsertContent(kind?: string): EditorInsertContent {
switch (kind) {
case "image":
return {
elements: [
makeImageElement({
x: 134,
y: 128,
width: 666,
height: 397,
}),
],
};
case "image-text":
return {
components: [
{
id: "image_text",
description: "Image with heading and supporting text",
position: { x: 122, y: 128 },
size: { width: 986, height: 371 },
elements: [
makeImageElement({ x: 0, y: 0, width: 486, height: 371 }),
makeTextElement({
text: "Add a heading",
x: 525,
y: 15,
width: 442,
height: 74,
size: 24,
bold: true,
}),
makeTextElement({
text: "Add supporting text that explains why this visual matters.",
x: 525,
y: 108,
width: 442,
height: 134,
size: 16,
color: "475467",
lineHeight: 1.3,
}),
],
},
],
};
case "image-grid":
return {
components: [
{
id: "image_grid",
description: "Two-by-two image grid",
position: { x: 128, y: 122 },
size: { width: 717, height: 454 },
elements: [
makeImageElement({
x: 0,
y: 0,
width: 346,
height: 211,
name: "Image 1",
}),
makeImageElement({
x: 371,
y: 0,
width: 346,
height: 211,
name: "Image 2",
}),
makeImageElement({
x: 0,
y: 243,
width: 346,
height: 211,
name: "Image 3",
}),
makeImageElement({
x: 371,
y: 243,
width: 346,
height: 211,
name: "Image 4",
}),
],
},
],
};
default:
return {};
}
}
export function createElementInsertElements(kind?: string): SlideElement[] {
switch (kind) {
case "rectangle":
return [
{
type: "rectangle",
position: { x: 134, y: 134 },
size: { width: 384, height: 192 },
fill: { color: "F4F3FF", opacity: 1 },
stroke: { color: "7A5AF8", width: 1.5 },
border_radius: { tl: 10, tr: 10, bl: 10, br: 10 },
},
];
case "ellipse":
return [
{
type: "ellipse",
position: { x: 134, y: 134 },
size: { width: 346, height: 198 },
fill: { color: "F4F3FF", opacity: 1 },
stroke: { color: "7A5AF8", width: 1.5 },
},
];
case "line":
return [
{
type: "line",
position: { x: 134, y: 218 },
size: { width: 435, height: 1 },
stroke: { color: "101323", width: 2 },
},
];
default:
return [];
}
}