Files
QOffice/src/features/qpowerpoint/QPowerPoint.tsx
T
2026-06-02 07:41:11 +03:00

810 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import { Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Trash2, Underline } from "lucide-react";
import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideTransition } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
import { exportPdfOrPrint } from "../../io/pdfExport";
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
interface QPowerPointProps {
deck: SlideDeck;
onChange: (deck: SlideDeck) => void;
}
type SlideTextFormatField = "titleBold" | "titleItalics" | "titleUnderline" | "subtitleBold" | "subtitleItalics" | "subtitleUnderline";
const themeLabels: Record<Slide["theme"], string> = {
classic: "Классическая",
ocean: "Океан",
graphite: "Графит"
};
const backgroundColorSwatches = [
{ color: "", label: "Фон темы" },
{ color: "FFFFFF", label: "Белый фон" },
{ color: "EAF6FF", label: "Светло-синий фон" },
{ color: "D9EAD3", label: "Светло-зеленый фон" },
{ color: "FFF2CC", label: "Светло-желтый фон" },
{ color: "FCE4D6", label: "Коралловый фон" },
{ color: "1F2937", label: "Темный фон" }
];
const textColorSwatches = [
{ color: "", label: "Цвет темы" },
{ color: "202124", label: "Черный текст" },
{ color: "FFFFFF", label: "Белый текст" },
{ color: "0F5FAE", label: "Синий текст" },
{ color: "0F766E", label: "Зеленый текст" },
{ color: "C2410C", label: "Красный текст" }
];
const slideFontFamilyOptions = ["Aptos", "Calibri", "Courier New", "Times New Roman"];
const titleFontSizeOptions = [24, 28, 30, 34, 40, 44, 48];
const subtitleFontSizeOptions = [14, 16, 18, 20, 22, 24, 28];
const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string }> = [
{ value: "", label: "Без перехода" },
{ value: "fade", label: "Растворение" },
{ value: "push", label: "Сдвиг" },
{ value: "wipe", label: "Шторка" }
];
const slideWidthInches = 13.333;
const slideHeightInches = 7.5;
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать изображение.")));
reader.readAsDataURL(file);
});
}
function readImageSize(src: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () => resolve({ width: image.naturalWidth || 1280, height: image.naturalHeight || 720 }), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось загрузить изображение.")), { once: true });
image.src = src;
});
}
function scaledSlideImageSize(size: { width: number; height: number }) {
const aspectRatio = size.width > 0 && size.height > 0 ? size.width / size.height : 16 / 9;
let width = 4.1;
let height = width / aspectRatio;
if (height > 2.6) {
height = 2.6;
width = height * aspectRatio;
}
return {
width: roundSlideNumber(width),
height: roundSlideNumber(height)
};
}
function roundSlideNumber(value: number) {
return Math.round(value * 10) / 10;
}
function slideImageStyle(image: SlideImage) {
return {
left: `${(image.x / slideWidthInches) * 100}%`,
top: `${(image.y / slideHeightInches) * 100}%`,
width: `${(image.width / slideWidthInches) * 100}%`,
height: `${(image.height / slideHeightInches) * 100}%`
};
}
function slideCanvasStyle(slide: Slide) {
const backgroundColor = normalizedHexColor(slide.backgroundColor);
return backgroundColor ? { background: `#${backgroundColor}` } : undefined;
}
function slideTextStyle(
color?: string,
fontSize?: number,
fontFamily?: string,
textFormat: { bold?: boolean; italics?: boolean; underline?: boolean; defaultBold?: boolean } = {}
): CSSProperties | undefined {
const textColor = normalizedHexColor(color);
const normalizedFontSize = normalizeFontSize(fontSize);
const normalizedFamily = normalizedFontFamily(fontFamily);
const effectiveBold = textFormat.bold ?? textFormat.defaultBold;
const style: CSSProperties = {
...(textColor ? { color: `#${textColor}` } : {}),
...(normalizedFontSize ? { fontSize: `${normalizedFontSize}pt` } : {}),
...(normalizedFamily ? { fontFamily: normalizedFamily } : {}),
...(effectiveBold !== undefined ? { fontWeight: effectiveBold ? 780 : 400 } : {}),
...(textFormat.italics ? { fontStyle: "italic" } : {}),
...(textFormat.underline ? { textDecoration: "underline" } : {})
};
return Object.keys(style).length > 0 ? style : undefined;
}
function normalizedHexColor(value?: string) {
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
}
function normalizeFontSize(value?: number) {
if (!Number.isFinite(value) || !value || value <= 0) {
return undefined;
}
return Math.round(value * 2) / 2;
}
function normalizedFontFamily(value?: string) {
const normalized = value?.trim().replace(/["<>]/g, "").slice(0, 80) ?? "";
return normalized && normalized.toLowerCase() !== "arial" ? normalized : "";
}
function clonedSlideImages(images?: SlideImage[]) {
const suffix = Date.now();
return images?.map((image, index) => ({ ...image, id: `${image.id}-copy-${suffix}-${index}` }));
}
export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [selectedImageId, setSelectedImageId] = useState("");
const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0];
const activeImages = activeSlide.images ?? [];
const selectedImage = activeImages.find((image) => image.id === selectedImageId);
function updateSlide(nextSlide: Slide) {
onChange({
...deck,
slides: deck.slides.map((slide) => (slide.id === nextSlide.id ? nextSlide : slide))
});
}
function toggleSlideTextFormat(field: SlideTextFormatField, defaultValue = false) {
const enabled = Boolean(activeSlide[field] ?? defaultValue);
updateSlide({ ...activeSlide, [field]: !enabled });
}
function updateImages(images: SlideImage[]) {
updateSlide({ ...activeSlide, images });
}
function updateSelectedImage(patch: Partial<SlideImage>) {
if (!selectedImage) {
return;
}
updateImages(activeImages.map((image) => (image.id === selectedImage.id ? { ...image, ...patch } : image)));
}
function updateSelectedImageNumber(key: "x" | "y" | "width" | "height", value: string, max: number) {
const number = Number.parseFloat(value);
if (!Number.isFinite(number)) {
return;
}
updateSelectedImage({ [key]: roundSlideNumber(Math.min(Math.max(number, 0), max)) });
}
function deleteSelectedImage() {
if (!selectedImage) {
return;
}
updateImages(activeImages.filter((image) => image.id !== selectedImage.id));
setSelectedImageId("");
}
function updateSubtitleListStyle(listStyle: SlideListStyle | "") {
updateSlide({ ...activeSlide, subtitleListStyle: listStyle || undefined });
}
function addSlide() {
const id = `slide-${Date.now()}`;
onChange({
...deck,
activeSlideId: id,
slides: [
...deck.slides,
{
id,
title: "Новый слайд",
subtitle: "Добавьте основной тезис презентации.",
notes: "",
theme: "classic",
images: []
}
]
});
}
function duplicateSlide() {
const id = `slide-${Date.now()}`;
onChange({
...deck,
activeSlideId: id,
slides: [...deck.slides, { ...activeSlide, id, title: `${activeSlide.title} — копия`, images: clonedSlideImages(activeSlide.images) }]
});
}
function deleteSlide() {
if (deck.slides.length === 1) {
return;
}
const nextSlides = deck.slides.filter((slide) => slide.id !== activeSlide.id);
onChange({ ...deck, slides: nextSlides, activeSlideId: nextSlides[0].id });
}
async function openPptx(file: File | undefined) {
if (!file) {
return;
}
try {
const imported = await importPptxFile(file);
onChange({
...deck,
title: imported.title,
activeSlideId: imported.slides[0]?.id ?? "slide-1",
slides: imported.slides.length > 0 ? imported.slides : deck.slides
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось открыть PPTX.");
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}
async function insertImage(file: File | undefined) {
if (!file) {
return;
}
try {
const src = await readFileAsDataUrl(file);
const size = scaledSlideImageSize(await readImageSize(src));
const image: SlideImage = {
id: `slide-image-${Date.now()}`,
src,
alt: file.name,
x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)),
y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)),
...size
};
updateImages([...activeImages, image]);
setSelectedImageId(image.id);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось вставить изображение.");
} finally {
if (imageInputRef.current) {
imageInputRef.current.value = "";
}
}
}
async function savePptx() {
try {
const fileName = replaceFileExtension(deck.title, ".pptx");
const blob = await exportPptxBlob(deck);
if (isDesktopOfficeBridgeAvailable()) {
await saveDesktopOfficeFile("pptx", fileName, blob);
return;
}
downloadBlobFile(fileName, blob);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось сохранить PPTX.");
}
}
async function choosePptx() {
if (isDesktopOfficeBridgeAvailable()) {
const file = await openDesktopOfficeFile("pptx");
await openPptx(file ?? undefined);
return;
}
fileInputRef.current?.click();
}
async function exportPdf() {
try {
await exportPdfOrPrint(deck.title);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
}
}
useQOfficeCommand({
open: choosePptx,
save: savePptx,
"export-pdf": exportPdf,
print: () => window.print()
});
return (
<div className="editor-layout slides-layout">
<aside className="slide-strip" aria-label="Слайды">
<button className="wide-command" type="button" onClick={addSlide}>
<Plus size={16} />
Слайд
</button>
{deck.slides.map((slide, index) => (
<button
type="button"
key={slide.id}
className={`slide-thumb theme-${slide.theme} ${slide.id === activeSlide.id ? "is-active" : ""} ${slide.hidden ? "is-hidden" : ""}`}
onClick={() => onChange({ ...deck, activeSlideId: slide.id })}
>
<span>{index + 1}</span>
<strong>{slide.title}</strong>
{slide.hidden ? (
<span className="slide-hidden-badge">
<EyeOff size={14} />
Скрыт
</span>
) : null}
</button>
))}
</aside>
<section className="slide-stage" aria-label="Редактор QPowerPoint">
<div className="module-toolbar">
<input
ref={fileInputRef}
className="hidden-file-input"
type="file"
accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation"
onChange={(event) => void openPptx(event.target.files?.[0])}
aria-label="Открыть PPTX"
/>
<input
ref={imageInputRef}
className="hidden-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/bmp,image/svg+xml"
onChange={(event) => void insertImage(event.target.files?.[0])}
aria-label="Вставить изображение"
/>
<input
className="file-title-input"
value={deck.title}
aria-label="Название презентации"
onChange={(event) => onChange({ ...deck, title: event.target.value })}
/>
<button className="compact-command" type="button" onClick={duplicateSlide}>
<Copy size={16} />
Дублировать
</button>
<button className="compact-command" type="button" onClick={deleteSlide} disabled={deck.slides.length === 1}>
<Trash2 size={16} />
Удалить
</button>
<button className="compact-command" type="button" onClick={() => imageInputRef.current?.click()}>
<ImageIcon size={16} />
Изображение
</button>
<button className="compact-command" type="button" onClick={() => void choosePptx()}>
<FileUp size={16} />
Открыть PPTX
</button>
<button className="compact-command" type="button" onClick={() => void savePptx()}>
<Save size={16} />
Сохранить PPTX
</button>
<button
className="compact-command"
type="button"
onClick={() => downloadTextFile(deck.title.replace(/\.qpptx$/i, ".json"), JSON.stringify(deck, null, 2), "application/json")}
>
<Download size={16} />
Экспорт JSON
</button>
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
<FileText size={16} />
Экспорт PDF
</button>
</div>
<div className="office-workbar slide-workbar" aria-label="Параметры QPowerPoint">
<div className="office-tool-section">
<span className="office-tool-title">Оформление</span>
<label className="office-check">
<input
type="checkbox"
checked={Boolean(activeSlide.hidden)}
onChange={(event) => updateSlide({ ...activeSlide, hidden: event.target.checked || undefined })}
/>
Скрыть слайд
</label>
<label className="office-field">
<span>Тема</span>
<select value={activeSlide.theme} onChange={(event) => updateSlide({ ...activeSlide, theme: event.target.value as Slide["theme"] })}>
{Object.entries(themeLabels).map(([theme, label]) => (
<option key={theme} value={theme}>
{label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Переход</span>
<select
value={activeSlide.transition ?? ""}
onChange={(event) => {
const transition = event.target.value as SlideTransition | "";
updateSlide({ ...activeSlide, transition: transition || undefined });
}}
>
{slideTransitionOptions.map((option) => (
<option key={option.value || "none"} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Фон слайда">
<Palette size={16} aria-hidden="true" />
{backgroundColorSwatches.map((swatch) => (
<button
key={swatch.label}
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${normalizedHexColor(activeSlide.backgroundColor) === swatch.color || (!activeSlide.backgroundColor && !swatch.color) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSlide({ ...activeSlide, backgroundColor: swatch.color || undefined })}
title={swatch.label}
aria-label={swatch.label}
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
/>
))}
</div>
</div>
<div className="office-tool-section">
<span className="office-tool-title">Текст</span>
<label className="office-field">
<span>Заголовок</span>
<select
value={activeSlide.titleFontSize ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, titleFontSize: event.target.value ? Number(event.target.value) : undefined })}
aria-label="Размер заголовка"
>
<option value="">Размер темы</option>
{titleFontSizeOptions.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Шрифт</span>
<select
value={activeSlide.titleFontFamily ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, titleFontFamily: event.target.value || undefined })}
aria-label="Семейство шрифта заголовка"
title="Семейство шрифта заголовка"
>
<option value="">Arial</option>
{slideFontFamilyOptions.map((fontFamily) => (
<option key={fontFamily} value={fontFamily}>
{fontFamily}
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Цвет заголовка">
{textColorSwatches.map((swatch) => (
<button
key={`title-${swatch.label}`}
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${normalizedHexColor(activeSlide.titleColor) === swatch.color || (!activeSlide.titleColor && !swatch.color) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSlide({ ...activeSlide, titleColor: swatch.color || undefined })}
title={swatch.label}
aria-label={swatch.label}
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
/>
))}
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Начертание заголовка">
<button
className={`icon-button format-toggle ${(activeSlide.titleBold ?? true) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleBold", true)}
title="Полужирный заголовок"
aria-label="Полужирный заголовок"
>
<Bold size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleItalics ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleItalics")}
title="Курсив заголовка"
aria-label="Курсив заголовка"
>
<Italic size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.titleUnderline ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("titleUnderline")}
title="Подчеркнутый заголовок"
aria-label="Подчеркнутый заголовок"
>
<Underline size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка заголовка
</span>
<input
value={activeSlide.titleHyperlink ?? ""}
aria-label="Ссылка заголовка слайда"
placeholder="https://example.com"
onChange={(event) => updateSlide({ ...activeSlide, titleHyperlink: event.target.value || undefined })}
/>
</label>
<label className="office-field">
<span>Подзаголовок</span>
<select
value={activeSlide.subtitleFontSize ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, subtitleFontSize: event.target.value ? Number(event.target.value) : undefined })}
aria-label="Размер подзаголовка"
>
<option value="">Размер темы</option>
{subtitleFontSizeOptions.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Шрифт</span>
<select
value={activeSlide.subtitleFontFamily ?? ""}
onChange={(event) => updateSlide({ ...activeSlide, subtitleFontFamily: event.target.value || undefined })}
aria-label="Семейство шрифта подзаголовка"
title="Семейство шрифта подзаголовка"
>
<option value="">Arial</option>
{slideFontFamilyOptions.map((fontFamily) => (
<option key={fontFamily} value={fontFamily}>
{fontFamily}
</option>
))}
</select>
</label>
<div className="slide-background-swatches" aria-label="Цвет подзаголовка">
{textColorSwatches.map((swatch) => (
<button
key={`subtitle-${swatch.label}`}
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${normalizedHexColor(activeSlide.subtitleColor) === swatch.color || (!activeSlide.subtitleColor && !swatch.color) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSlide({ ...activeSlide, subtitleColor: swatch.color || undefined })}
title={swatch.label}
aria-label={swatch.label}
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
/>
))}
</div>
<div className="cell-format-tools slide-format-tools" aria-label="Начертание подзаголовка">
<button
className={`icon-button format-toggle ${activeSlide.subtitleBold ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleBold")}
title="Полужирный подзаголовок"
aria-label="Полужирный подзаголовок"
>
<Bold size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleItalics ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleItalics")}
title="Курсив подзаголовка"
aria-label="Курсив подзаголовка"
>
<Italic size={16} />
</button>
<button
className={`icon-button format-toggle ${activeSlide.subtitleUnderline ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSlideTextFormat("subtitleUnderline")}
title="Подчеркнутый подзаголовок"
aria-label="Подчеркнутый подзаголовок"
>
<Underline size={16} />
</button>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка подзаголовка
</span>
<input
value={activeSlide.subtitleHyperlink ?? ""}
aria-label="Ссылка подзаголовка слайда"
placeholder="https://example.com"
onChange={(event) => updateSlide({ ...activeSlide, subtitleHyperlink: event.target.value || undefined })}
/>
</label>
<div className="segmented-tools slide-list-tools" aria-label="Список подзаголовка">
<button
type="button"
className={!activeSlide.subtitleListStyle ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("")}
title="Без списка"
aria-label="Без списка"
>
<ListX size={16} />
</button>
<button
type="button"
className={activeSlide.subtitleListStyle === "bullet" ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("bullet")}
title="Маркированный список"
aria-label="Маркированный список"
>
<List size={16} />
</button>
<button
type="button"
className={activeSlide.subtitleListStyle === "number" ? "is-active" : ""}
onClick={() => updateSubtitleListStyle("number")}
title="Нумерованный список"
aria-label="Нумерованный список"
>
<ListOrdered size={16} />
</button>
</div>
</div>
<div className="office-tool-section">
<span className="office-tool-title">Объекты</span>
<div className="slide-image-list">
{activeImages.map((image, index) => (
<button
key={image.id}
type="button"
className={image.id === selectedImageId ? "is-active" : ""}
onClick={() => setSelectedImageId(image.id)}
>
{image.alt || `Изображение ${index + 1}`}
</button>
))}
</div>
{selectedImage ? (
<>
<div className="image-geometry-grid">
<label>
X
<input
type="number"
min="0"
max={slideWidthInches}
step="0.1"
value={selectedImage.x}
onChange={(event) => updateSelectedImageNumber("x", event.target.value, slideWidthInches)}
/>
</label>
<label>
Y
<input
type="number"
min="0"
max={slideHeightInches}
step="0.1"
value={selectedImage.y}
onChange={(event) => updateSelectedImageNumber("y", event.target.value, slideHeightInches)}
/>
</label>
<label>
Ширина
<input
type="number"
min="0.1"
max={slideWidthInches}
step="0.1"
value={selectedImage.width}
onChange={(event) => updateSelectedImageNumber("width", event.target.value, slideWidthInches)}
/>
</label>
<label>
Высота
<input
type="number"
min="0.1"
max={slideHeightInches}
step="0.1"
value={selectedImage.height}
onChange={(event) => updateSelectedImageNumber("height", event.target.value, slideHeightInches)}
/>
</label>
</div>
<label className="office-field office-field-wide">
<span>
<Link size={14} />
Ссылка изображения
</span>
<input
value={selectedImage.hyperlink ?? ""}
aria-label="Ссылка изображения слайда"
placeholder="https://example.com"
onChange={(event) => updateSelectedImage({ hyperlink: event.target.value || undefined })}
/>
</label>
<button className="compact-command danger-command" type="button" onClick={deleteSelectedImage}>
<Trash2 size={16} />
Удалить изображение
</button>
</>
) : null}
</div>
<label className="office-tool-section notes-inline">
<span className="office-tool-title">Заметки</span>
<textarea
className="notes-field"
value={activeSlide.notes}
aria-label="Заметки докладчика"
onChange={(event) => updateSlide({ ...activeSlide, notes: event.target.value })}
/>
</label>
</div>
<div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
{activeImages.map((image) => (
<button
key={image.id}
type="button"
className={`slide-image-object ${image.id === selectedImageId ? "is-selected" : ""}`}
style={slideImageStyle(image)}
onClick={() => setSelectedImageId(image.id)}
title={image.alt || "Изображение"}
aria-label={image.alt || "Изображение слайда"}
>
<img src={image.src} alt={image.alt || ""} />
</button>
))}
<input
className="slide-title-input"
value={activeSlide.title}
aria-label="Заголовок слайда"
style={slideTextStyle(activeSlide.titleColor, activeSlide.titleFontSize, activeSlide.titleFontFamily, {
bold: activeSlide.titleBold,
italics: activeSlide.titleItalics,
underline: activeSlide.titleUnderline,
defaultBold: true
})}
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
/>
<textarea
className={`slide-subtitle-input ${activeSlide.subtitleListStyle ? `is-${activeSlide.subtitleListStyle}-list` : ""}`.trim()}
value={activeSlide.subtitle}
aria-label="Подзаголовок слайда"
style={slideTextStyle(activeSlide.subtitleColor, activeSlide.subtitleFontSize, activeSlide.subtitleFontFamily, {
bold: activeSlide.subtitleBold,
italics: activeSlide.subtitleItalics,
underline: activeSlide.subtitleUnderline
})}
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
/>
<div className="slide-accent-line" />
</div>
</section>
</div>
);
}