810 lines
32 KiB
TypeScript
810 lines
32 KiB
TypeScript
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>
|
||
);
|
||
}
|