Initial QOffice implementation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user