700 lines
17 KiB
TypeScript
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 [];
|
|
}
|
|
}
|