Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+455
View File
@@ -0,0 +1,455 @@
import { useRef, useState } from "react";
import { Copy, Download, FileText, FileUp, Image as ImageIcon, Palette, Plus, Save, Trash2 } from "lucide-react";
import type { Slide, SlideDeck, SlideImage } 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;
}
const themeLabels: Record<Slide["theme"], string> = {
classic: "Классическая",
ocean: "Океан",
graphite: "Графит"
};
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 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 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 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" : ""}`}
onClick={() => onChange({ ...deck, activeSlideId: slide.id })}
>
<span>{index + 1}</span>
<strong>{slide.title}</strong>
</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={`slide-canvas theme-${activeSlide.theme}`}>
{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="Заголовок слайда"
onChange={(event) => updateSlide({ ...activeSlide, title: event.target.value })}
/>
<textarea
className="slide-subtitle-input"
value={activeSlide.subtitle}
aria-label="Подзаголовок слайда"
onChange={(event) => updateSlide({ ...activeSlide, subtitle: event.target.value })}
/>
<div className="slide-accent-line" />
</div>
</section>
<aside className="inspector" aria-label="Параметры слайда">
<section>
<h3>Оформление</h3>
<label>
<span>
<Palette size={16} />
Тема
</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>
</section>
<section>
<h3>Изображения</h3>
<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>
<button className="compact-command danger-command" type="button" onClick={deleteSelectedImage}>
<Trash2 size={16} />
Удалить изображение
</button>
</>
) : null}
</section>
<section>
<h3>Заметки докладчика</h3>
<textarea
className="notes-field"
value={activeSlide.notes}
aria-label="Заметки докладчика"
onChange={(event) => updateSlide({ ...activeSlide, notes: event.target.value })}
/>
</section>
</aside>
</div>
);
}