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 = { 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((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(null); const imageInputRef = useRef(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) { 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 (
void openPptx(event.target.files?.[0])} aria-label="Открыть PPTX" /> void insertImage(event.target.files?.[0])} aria-label="Вставить изображение" /> onChange({ ...deck, title: event.target.value })} />
Оформление
Текст
{textColorSwatches.map((swatch) => (
{textColorSwatches.map((swatch) => (
Объекты
{activeImages.map((image, index) => ( ))}
{selectedImage ? ( <>
) : null}