Make QOffice apps standalone Office-style surfaces
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user