Make QOffice apps standalone Office-style surfaces

This commit is contained in:
Курнат Андрей
2026-06-01 23:08:56 +03:00
parent 3418725017
commit 91e8a6e9fd
7 changed files with 900 additions and 744 deletions
+227 -221
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import { Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Palette, Play, Plus, Save, Trash2 } from "lucide-react";
import { Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Palette, Plus, Save, Trash2 } from "lucide-react";
import type { Slide, SlideDeck, SlideImage, SlideTransition } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
@@ -394,6 +394,232 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</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>
<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>
<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>
<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
@@ -426,226 +652,6 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</div>
</section>
<aside className="inspector" aria-label="Параметры слайда">
<section>
<h3>Оформление</h3>
<label className="slide-hidden-toggle">
<span>
<EyeOff size={16} />
Показ слайда
</span>
<input
type="checkbox"
checked={Boolean(activeSlide.hidden)}
onChange={(event) => updateSlide({ ...activeSlide, hidden: event.target.checked || undefined })}
/>
<span className="slide-hidden-toggle-text">Скрыть при показе слайд-шоу</span>
</label>
<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>
<label>
<span>
<Play size={16} />
Переход
</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="Фон слайда">
{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 className="slide-text-controls">
<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>
<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>
<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>
<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>
<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>
<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>
</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>
);
}