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
+3 -1
View File
@@ -16,6 +16,8 @@ npm install
npm run dev npm run dev
``` ```
Прямые web-пути отдельных приложений: `/qword`, `/qexcell`, `/qpowerpoint`, `/qoutlook`.
## Desktop-оболочка ## Desktop-оболочка
```bash ```bash
@@ -32,7 +34,7 @@ npm run build
## Поддержка форматов ## Поддержка форматов
- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. - QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта.
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc`, `Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. - QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc`, `Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
+22 -13
View File
@@ -6,9 +6,21 @@ import { QWord } from "./features/qword/QWord";
import { QExcell } from "./features/qexcell/QExcell"; import { QExcell } from "./features/qexcell/QExcell";
import { initialState } from "./data/sampleData"; import { initialState } from "./data/sampleData";
import { usePersistentState } from "./hooks/usePersistentState"; import { usePersistentState } from "./hooks/usePersistentState";
import type { AppId, MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types"; import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
const STORAGE_KEY = "qoffice-state-v1"; const STORAGE_KEY = "qoffice-state-v1";
const appPaths = {
"/": "word",
"/qword": "word",
"/qexcell": "sheets",
"/qpowerpoint": "slides",
"/qoutlook": "mail"
} as const;
function activeAppFromPath(pathname: string) {
const normalizedPath = pathname.toLowerCase().replace(/\/$/, "") || "/";
return appPaths[normalizedPath as keyof typeof appPaths] ?? "word";
}
function migrateQOfficeState(state: QOfficeState): QOfficeState { function migrateQOfficeState(state: QOfficeState): QOfficeState {
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments; const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
@@ -91,14 +103,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
export function App() { export function App() {
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState); const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
const activeApp = activeAppFromPath(window.location.pathname);
const status = useMemo(() => { const status = useMemo(() => {
const updatedAt = const updatedAt =
state.activeApp === "word" activeApp === "word"
? state.word.updatedAt ? state.word.updatedAt
: state.activeApp === "sheets" : activeApp === "sheets"
? state.workbook.updatedAt ? state.workbook.updatedAt
: state.activeApp === "slides" : activeApp === "slides"
? state.deck.updatedAt ? state.deck.updatedAt
: undefined; : undefined;
@@ -106,11 +119,7 @@ export function App() {
savedLabel: "Сохранено локально", savedLabel: "Сохранено локально",
updatedAt updatedAt
}; };
}, [state]); }, [activeApp, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt]);
function setActiveApp(activeApp: AppId) {
setState((current) => ({ ...current, activeApp }));
}
function updateWord(word: WordDocument) { function updateWord(word: WordDocument) {
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } })); setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
@@ -140,18 +149,18 @@ export function App() {
} }
const activeSurface = const activeSurface =
state.activeApp === "word" ? ( activeApp === "word" ? (
<QWord document={state.word} onChange={updateWord} /> <QWord document={state.word} onChange={updateWord} />
) : state.activeApp === "sheets" ? ( ) : activeApp === "sheets" ? (
<QExcell workbook={state.workbook} onChange={updateWorkbook} /> <QExcell workbook={state.workbook} onChange={updateWorkbook} />
) : state.activeApp === "slides" ? ( ) : activeApp === "slides" ? (
<QPowerPoint deck={state.deck} onChange={updateDeck} /> <QPowerPoint deck={state.deck} onChange={updateDeck} />
) : ( ) : (
<QOutlook mail={state.mail} onChange={updateMail} onFolderChange={setMailFolder} /> <QOutlook mail={state.mail} onChange={updateMail} onFolderChange={setMailFolder} />
); );
return ( return (
<AppShell activeApp={state.activeApp} onAppChange={setActiveApp} status={status}> <AppShell activeApp={activeApp} status={status}>
{activeSurface} {activeSurface}
</AppShell> </AppShell>
); );
+25 -98
View File
@@ -1,23 +1,16 @@
import { import {
BarChart3, ChevronDown,
FileText, Redo2,
HelpCircle,
Mail,
MonitorCog,
PanelRight,
Presentation,
Save, Save,
Search, Search,
Settings,
Share2, Share2,
Table2 Undo2
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { AppId } from "../types"; import type { AppId } from "../types";
interface AppShellProps { interface AppShellProps {
activeApp: AppId; activeApp: AppId;
onAppChange: (appId: AppId) => void;
status: { status: {
savedLabel: string; savedLabel: string;
updatedAt?: string; updatedAt?: string;
@@ -25,85 +18,37 @@ interface AppShellProps {
children: ReactNode; children: ReactNode;
} }
const apps: Array<{ id: AppId; label: string; icon: typeof FileText; accent: string }> = [ const appTitles: Record<AppId, string> = {
{ id: "word", label: "QWord", icon: FileText, accent: "word" }, word: "Документ1 - QWord",
{ id: "sheets", label: "QExcell", icon: Table2, accent: "sheets" }, sheets: "Книга1 - QExcell",
{ id: "slides", label: "QPowerPoint", icon: Presentation, accent: "slides" }, slides: "Презентация1 - QPowerPoint",
{ id: "mail", label: "QOutlook", icon: Mail, accent: "mail" } mail: "Почта - QOutlook"
]; };
const ribbonGroups = [
{
title: "Буфер",
commands: [
{ label: "Вставить", icon: FileText },
{ label: "Копировать", icon: Save }
]
},
{
title: "Вставка",
commands: [
{ label: "Таблица", icon: Table2 },
{ label: "Диаграмма", icon: BarChart3 },
{ label: "Панель", icon: PanelRight }
]
},
{
title: "Совместная работа",
commands: [
{ label: "Поделиться", icon: Share2 },
{ label: "Параметры", icon: MonitorCog }
]
}
];
export function AppShell({ activeApp, onAppChange, status, children }: AppShellProps) {
const active = apps.find((app) => app.id === activeApp) ?? apps[0];
export function AppShell({ activeApp, status, children }: AppShellProps) {
return ( return (
<div className="qoffice-shell"> <div className="qoffice-shell">
<aside className="app-rail" aria-label="Приложения QOffice">
<div className="brand">QOffice</div>
<nav className="app-nav">
{apps.map((app) => {
const Icon = app.icon;
return (
<button
key={app.id}
className={`app-nav-item accent-${app.accent} ${activeApp === app.id ? "is-active" : ""}`}
type="button"
onClick={() => onAppChange(app.id)}
aria-pressed={activeApp === app.id}
title={`Открыть ${app.label}`}
>
<span className="app-icon">
<Icon size={22} strokeWidth={2.2} />
</span>
<span>{app.label}</span>
</button>
);
})}
</nav>
<div className="rail-footer">
<button className="icon-button" type="button" title="Параметры" aria-label="Параметры">
<Settings size={18} />
</button>
<button className="icon-button" type="button" title="Справка" aria-label="Справка">
<HelpCircle size={18} />
</button>
</div>
</aside>
<main className="workspace"> <main className="workspace">
<header className="titlebar"> <header className="titlebar">
<div> <div className="quick-access" aria-label="Панель быстрого доступа">
<strong>{active.label}</strong> <button type="button" title="Сохранить" aria-label="Сохранить">
<span>{status.savedLabel}</span> <Save size={16} />
</button>
<button type="button" title="Отменить" aria-label="Отменить">
<Undo2 size={16} />
</button>
<button type="button" title="Повторить" aria-label="Повторить">
<Redo2 size={16} />
</button>
<button type="button" title="Настроить панель" aria-label="Настроить панель">
<ChevronDown size={14} />
</button>
</div> </div>
<strong className="window-title">{appTitles[activeApp]}</strong>
<div className="titlebar-actions"> <div className="titlebar-actions">
<label className="command-search"> <label className="command-search">
<Search size={16} /> <Search size={16} />
<input placeholder="Найти команду" aria-label="Найти команду" /> <input placeholder="Поиск" aria-label="Поиск" />
</label> </label>
<button className="primary-command" type="button"> <button className="primary-command" type="button">
<Share2 size={16} /> <Share2 size={16} />
@@ -120,24 +65,6 @@ export function AppShell({ activeApp, onAppChange, status, children }: AppShellP
</button> </button>
))} ))}
</div> </div>
<div className="ribbon-groups">
{ribbonGroups.map((group) => (
<div className="ribbon-group" key={group.title}>
<div className="ribbon-command-row">
{group.commands.map((command) => {
const Icon = command.icon;
return (
<button type="button" key={command.label} className="tool-button" title={command.label}>
<Icon size={17} />
<span>{command.label}</span>
</button>
);
})}
</div>
<span className="ribbon-group-title">{group.title}</span>
</div>
))}
</div>
</section> </section>
<div className="surface-host">{children}</div> <div className="surface-host">{children}</div>
+244 -163
View File
@@ -11,6 +11,8 @@ import {
Bold, Bold,
Download, Download,
Eraser, Eraser,
Eye,
EyeOff,
FileText, FileText,
FileUp, FileUp,
FunctionSquare, FunctionSquare,
@@ -30,7 +32,7 @@ import {
WrapText WrapText
} from "lucide-react"; } from "lucide-react";
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types"; import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet"; import { cellId, columnLabel, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, parseCellId, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice"; import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
import { replaceFileExtension } from "../../io/fileHelpers"; import { replaceFileExtension } from "../../io/fileHelpers";
@@ -47,6 +49,7 @@ const extraEditableRows = 1;
const extraEditableColumns = 1; const extraEditableColumns = 1;
const emptyHyperlinks: Record<string, string> = {}; const emptyHyperlinks: Record<string, string> = {};
const emptyCellFormats: Record<string, CellFormat> = {}; const emptyCellFormats: Record<string, CellFormat> = {};
const emptyHiddenItems: Record<string, boolean> = {};
const emptyCharts: SheetChart[] = []; const emptyCharts: SheetChart[] = [];
const fillSwatches = [ const fillSwatches = [
@@ -224,6 +227,23 @@ function normalizedPrintAreaInput(value: string) {
return [...new Set(ranges)].join(","); return [...new Set(ranges)].join(",");
} }
function nextHiddenItems(items: Record<string, boolean>, key: string, hidden: boolean) {
const nextItems = Object.entries(items).reduce<Record<string, boolean>>((result, [itemKey, isHidden]) => {
if (isHidden) {
result[itemKey] = true;
}
return result;
}, {});
if (hidden) {
nextItems[key] = true;
} else {
delete nextItems[key];
}
return Object.keys(nextItems).length > 0 ? nextItems : undefined;
}
function chartTypeIcon(type: SheetChartType) { function chartTypeIcon(type: SheetChartType) {
if (type === "line") { if (type === "line") {
return LineChart; return LineChart;
@@ -250,11 +270,19 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
const activeMergedCells = activeSheet.mergedCells ?? []; const activeMergedCells = activeSheet.mergedCells ?? [];
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks; const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats; const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
const activeHiddenColumns = activeSheet.hiddenColumns ?? emptyHiddenItems;
const activeHiddenRows = activeSheet.hiddenRows ?? emptyHiddenItems;
const activeCharts = activeSheet.charts ?? emptyCharts; const activeCharts = activeSheet.charts ?? emptyCharts;
const selectedRawValue = activeSheet.cells[selectedCell] ?? ""; const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
const selectedHyperlink = activeHyperlinks[selectedCell] ?? ""; const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
const selectedCellFormat = activeCellFormats[selectedCell] ?? {}; const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat); const selectedCellPosition = parseCellId(selectedCell) ?? { column: 0, row: 0 };
const selectedColumnLabel = columnLabel(selectedCellPosition.column);
const selectedRowLabel = String(selectedCellPosition.row + 1);
const selectedColumnHidden = Boolean(activeHiddenColumns[selectedColumnLabel]);
const selectedRowHidden = Boolean(activeHiddenRows[selectedRowLabel]);
const hiddenColumnCount = Object.values(activeHiddenColumns).filter(Boolean).length;
const hiddenRowCount = Object.values(activeHiddenRows).filter(Boolean).length;
const boundsCells = useMemo( const boundsCells = useMemo(
() => () =>
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>( [...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
@@ -368,6 +396,40 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
}); });
} }
function addSheet() {
const id = `sheet-${workbook.sheets.length + 1}`;
onChange({
...workbook,
activeSheet: id,
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
});
}
function updateSelectedColumnHidden(hidden: boolean) {
const hiddenColumns = nextHiddenItems(activeHiddenColumns, selectedColumnLabel, hidden);
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenColumns } : sheet))
});
}
function updateSelectedRowHidden(hidden: boolean) {
const hiddenRows = nextHiddenItems(activeHiddenRows, selectedRowLabel, hidden);
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hiddenRows } : sheet))
});
}
function showAllHiddenSheetItems() {
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, hiddenColumns: undefined, hiddenRows: undefined } : sheet
)
});
}
function selectChart(chart: SheetChart) { function selectChart(chart: SheetChart) {
setSelectedChartId(chart.id); setSelectedChartId(chart.id);
setChartTitle(chart.title); setChartTitle(chart.title);
@@ -534,6 +596,26 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
<Link size={16} /> <Link size={16} />
Ссылка Ссылка
</button> </button>
<button
className={`compact-command ${selectedColumnHidden ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSelectedColumnHidden(!selectedColumnHidden)}
title={`${selectedColumnHidden ? "Показать" : "Скрыть"} столбец ${selectedColumnLabel}`}
aria-label={`${selectedColumnHidden ? "Показать" : "Скрыть"} столбец ${selectedColumnLabel}`}
>
{selectedColumnHidden ? <Eye size={16} /> : <EyeOff size={16} />}
{selectedColumnHidden ? "Показать столбец" : "Скрыть столбец"}
</button>
<button
className={`compact-command ${selectedRowHidden ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateSelectedRowHidden(!selectedRowHidden)}
title={`${selectedRowHidden ? "Показать" : "Скрыть"} строку ${selectedRowLabel}`}
aria-label={`${selectedRowHidden ? "Показать" : "Скрыть"} строку ${selectedRowLabel}`}
>
{selectedRowHidden ? <Eye size={16} /> : <EyeOff size={16} />}
{selectedRowHidden ? "Показать строку" : "Скрыть строку"}
</button>
<div className="cell-format-tools" aria-label="Формат ячейки"> <div className="cell-format-tools" aria-label="Формат ячейки">
<button <button
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()} className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
@@ -720,64 +802,177 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
/> />
</div> </div>
<div className="office-workbar sheet-workbar" aria-label="Параметры QExcell">
<div className="office-tool-section">
<span className="office-tool-title">Лист</span>
<label className="office-field">
<span>Активный</span>
<select value={workbook.activeSheet} onChange={(event) => onChange({ ...workbook, activeSheet: event.target.value })}>
{workbook.sheets.map((sheet) => (
<option key={sheet.id} value={sheet.id}>
{sheet.name}
</option>
))}
</select>
</label>
<button className="compact-command" type="button" onClick={addSheet}>
<Plus size={16} />
Новый лист
</button>
<label className="office-field">
<span>Имя</span>
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
</label>
<label className="office-field">
<span>Печать</span>
<input
value={activeSheet.printArea ?? ""}
placeholder="A1:E20"
onChange={(event) => updateActiveSheetPrintArea(event.target.value)}
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
/>
</label>
</div>
<div className="office-tool-section">
<span className="office-tool-title">Ячейка</span>
<span className="office-chip">{selectedCell}</span>
<label className="office-field">
<span>Формат</span>
<select value={selectedCellFormat.numberFormat ?? ""} onChange={(event) => updateCellFormat(selectedCell, { numberFormat: event.target.value })}>
{numberFormatOptions.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field office-field-wide">
<span>Ссылка</span>
<input
value={selectedHyperlink}
placeholder="https://example.com"
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
/>
</label>
{hiddenColumnCount + hiddenRowCount > 0 ? (
<button className="compact-command" type="button" onClick={showAllHiddenSheetItems}>
<Eye size={16} />
Показать всё
</button>
) : null}
</div>
<div className="office-tool-section">
<span className="office-tool-title">Диаграмма</span>
<label className="office-field">
<span>Заголовок</span>
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
</label>
<label className="office-field">
<span>Тип</span>
<select value={chartType} onChange={(event) => setChartType(event.target.value as SheetChartType)}>
{chartTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="office-field">
<span>Подписи</span>
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
</label>
<label className="office-field">
<span>Значения</span>
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
</label>
<button className="compact-command" type="button" onClick={upsertChart}>
<BarChart3 size={16} />
{selectedChartId ? "Сохранить" : "Добавить"}
</button>
{selectedChartId ? (
<button className="compact-command" type="button" onClick={resetChartForm}>
<Plus size={16} />
Новая
</button>
) : null}
<span className="office-chip">E: {totals.toLocaleString("ru-RU")}</span>
</div>
</div>
<div className="grid-wrap"> <div className="grid-wrap">
<table className="sheet-grid"> <table className="sheet-grid">
<thead> <thead>
<tr> <tr>
<th aria-label="Номера строк" /> <th aria-label="Номера строк" />
{visibleColumnLabels.map((column) => ( {visibleColumnLabels.map((column) => (
<th key={column}>{column}</th> <th
key={column}
className={activeHiddenColumns[column] ? "is-hidden-column" : undefined}
title={activeHiddenColumns[column] ? `Скрытый столбец ${column}` : undefined}
>
{column}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => {
<tr key={row}> const rowLabel = String(row + 1);
<th>{row + 1}</th> const isHiddenRow = Boolean(activeHiddenRows[rowLabel]);
{visibleColumnLabels.map((_, column) => { return (
const id = cellId(column, row); <tr key={row} className={isHiddenRow ? "is-hidden-row" : undefined}>
const merge = mergedCellInfo(id, activeMergedCells); <th className={isHiddenRow ? "is-hidden-row" : undefined} title={isHiddenRow ? `Скрытая строка ${rowLabel}` : undefined}>
if (merge && !merge.isOrigin) { {rowLabel}
return null; </th>
} {visibleColumnLabels.map((columnName, column) => {
const id = cellId(column, row);
const merge = mergedCellInfo(id, activeMergedCells);
if (merge && !merge.isOrigin) {
return null;
}
const raw = activeSheet.cells[id] ?? ""; const raw = activeSheet.cells[id] ?? "";
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw; const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
const hyperlink = activeHyperlinks[id] ?? ""; const hyperlink = activeHyperlinks[id] ?? "";
const cellFormat = activeCellFormats[id]; const cellFormat = activeCellFormats[id];
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat); const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
return ( const isHiddenColumn = Boolean(activeHiddenColumns[columnName]);
<td return (
key={id} <td
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""}`.trim()} key={id}
style={cellBoxStyle(cellFormat)} className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""} ${cellFormat?.border ? "is-bordered" : ""} ${cellFormat?.verticalAlign ? `is-valign-${cellFormat.verticalAlign}` : ""} ${isHiddenColumn ? "is-hidden-column" : ""} ${isHiddenRow ? "is-hidden-row" : ""}`.trim()}
colSpan={merge?.columnSpan} style={cellBoxStyle(cellFormat)}
rowSpan={merge?.rowSpan} colSpan={merge?.columnSpan}
> rowSpan={merge?.rowSpan}
{cellFormat?.wrapText ? ( >
<textarea {cellFormat?.wrapText ? (
value={selectedCell === id ? raw : display} <textarea
aria-label={`Ячейка ${id}`} value={selectedCell === id ? raw : display}
title={hyperlink || undefined} aria-label={`Ячейка ${id}`}
style={cellInputStyle(cellFormat)} title={hyperlink || undefined}
onFocus={() => setSelectedCell(id)} style={cellInputStyle(cellFormat)}
onChange={(event) => updateCell(id, event.target.value)} onFocus={() => setSelectedCell(id)}
/> onChange={(event) => updateCell(id, event.target.value)}
) : ( />
<input ) : (
value={selectedCell === id ? raw : display} <input
aria-label={`Ячейка ${id}`} value={selectedCell === id ? raw : display}
title={hyperlink || undefined} aria-label={`Ячейка ${id}`}
style={cellInputStyle(cellFormat)} title={hyperlink || undefined}
onFocus={() => setSelectedCell(id)} style={cellInputStyle(cellFormat)}
onChange={(event) => updateCell(id, event.target.value)} onFocus={() => setSelectedCell(id)}
/> onChange={(event) => updateCell(id, event.target.value)}
)} />
</td> )}
); </td>
})} );
</tr> })}
))} </tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -798,120 +993,6 @@ export function QExcell({ workbook, onChange }: QExcellProps) {
) : null} ) : null}
</section> </section>
<aside className="inspector" aria-label="Сводка книги">
<section>
<h3>Книга</h3>
<label>
Лист
<select value={workbook.activeSheet} onChange={(event) => onChange({ ...workbook, activeSheet: event.target.value })}>
{workbook.sheets.map((sheet) => (
<option key={sheet.id} value={sheet.id}>
{sheet.name}
</option>
))}
</select>
</label>
<button
className="wide-command"
type="button"
onClick={() => {
const id = `sheet-${workbook.sheets.length + 1}`;
onChange({
...workbook,
activeSheet: id,
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
});
}}
>
<Plus size={16} />
Новый лист
</button>
<label>
Имя листа
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
</label>
<label>
Область печати
<input
value={activeSheet.printArea ?? ""}
placeholder="A1:E20"
onChange={(event) => updateActiveSheetPrintArea(event.target.value)}
onBlur={(event) => updateActiveSheetPrintArea(normalizedPrintAreaInput(event.target.value))}
/>
</label>
</section>
<section>
<h3>Выделение</h3>
<p className="stat-line">{selectedCell}</p>
<p className="muted">Значение: {selectedRawValue || "пусто"}</p>
<p className="muted">Расчет: {selectedFormattedValue || "пусто"}</p>
<label>
Формат числа
<select value={selectedCellFormat.numberFormat ?? ""} onChange={(event) => updateCellFormat(selectedCell, { numberFormat: event.target.value })}>
{numberFormatOptions.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Ссылка
<input
value={selectedHyperlink}
placeholder="https://example.com"
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
/>
</label>
<button className="wide-command" type="button" onClick={insertLink}>
<Link size={16} />
Вставить ссылку
</button>
<p className="muted">Объединённых диапазонов: {activeMergedCells.length}</p>
</section>
<section>
<h3>Диаграммы</h3>
<label>
Заголовок
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
</label>
<label>
Тип
<select value={chartType} onChange={(event) => setChartType(event.target.value as SheetChartType)}>
{chartTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Подписи
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
</label>
<label>
Значения
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
</label>
<button className="wide-command" type="button" onClick={upsertChart}>
<BarChart3 size={16} />
{selectedChartId ? "Сохранить диаграмму" : "Добавить диаграмму"}
</button>
{selectedChartId ? (
<button className="wide-command" type="button" onClick={resetChartForm}>
<Plus size={16} />
Новая диаграмма
</button>
) : null}
<p className="muted">Диаграмм на листе: {activeCharts.length}</p>
</section>
<section>
<h3>Сводка</h3>
<p className="big-number">{totals.toLocaleString("ru-RU")}</p>
<p className="muted">Сумма числовых значений в столбце E.</p>
</section>
</aside>
</div> </div>
); );
} }
+227 -221
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import type { CSSProperties } 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 type { Slide, SlideDeck, SlideImage, SlideTransition } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice"; import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice";
@@ -394,6 +394,232 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</button> </button>
</div> </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)}> <div className={`slide-canvas theme-${activeSlide.theme}`} style={slideCanvasStyle(activeSlide)}>
{activeImages.map((image) => ( {activeImages.map((image) => (
<button <button
@@ -426,226 +652,6 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) {
</div> </div>
</section> </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> </div>
); );
} }
+64 -63
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { import {
AlignCenter, AlignCenter,
AlignLeft, AlignLeft,
@@ -12,7 +12,6 @@ import {
Link, Link,
List, List,
PaintBucket, PaintBucket,
Pilcrow,
Save, Save,
SeparatorHorizontal, SeparatorHorizontal,
Strikethrough, Strikethrough,
@@ -95,12 +94,45 @@ const highlightSwatches = [
{ color: "#ffffff", label: "Без подсветки" } { color: "#ffffff", label: "Без подсветки" }
]; ];
const defaultPageViewHeight = 1180;
function pageHeightFromStyles(element: HTMLElement) {
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
}
export function QWord({ document, onChange }: QWordProps) { export function QWord({ document, onChange }: QWordProps) {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const [pageView, setPageView] = useState({ count: 1, height: defaultPageViewHeight });
const wordCount = useMemo(() => countWords(document.html), [document.html]); const wordCount = useMemo(() => countWords(document.html), [document.html]);
function refreshPageView() {
window.requestAnimationFrame(() => {
const editor = editorRef.current;
if (!editor) {
return;
}
const height = pageHeightFromStyles(editor);
const count = Math.max(1, Math.ceil(editor.scrollHeight / height));
setPageView((current) => (current.count === count && current.height === height ? current : { count, height }));
});
}
useLayoutEffect(() => {
refreshPageView();
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const observer = new ResizeObserver(refreshPageView);
observer.observe(editor);
return () => observer.disconnect();
}, [document.html]);
function runCommand(command: string, value?: string) { function runCommand(command: string, value?: string) {
editorRef.current?.focus(); editorRef.current?.focus();
globalThis.document.execCommand(command, false, value); globalThis.document.execCommand(command, false, value);
@@ -178,6 +210,7 @@ export function QWord({ document, onChange }: QWordProps) {
function updateHtml() { function updateHtml() {
if (editorRef.current) { if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML }); onChange({ ...document, html: editorRef.current.innerHTML });
refreshPageView();
} }
} }
@@ -353,6 +386,9 @@ export function QWord({ document, onChange }: QWordProps) {
<FileText size={16} /> <FileText size={16} />
Экспорт PDF Экспорт PDF
</button> </button>
<span className="toolbar-status">
Страниц: {pageView.count} · Слов: {wordCount}
</span>
</div> </div>
<div className="page-ruler" aria-hidden="true"> <div className="page-ruler" aria-hidden="true">
@@ -361,68 +397,33 @@ export function QWord({ document, onChange }: QWordProps) {
))} ))}
</div> </div>
<article <div className="vertical-page-ruler" aria-hidden="true">
ref={editorRef} {Array.from({ length: 19 }, (_, index) => (
className="paper document-content" <span key={index}>{index + 1}</span>
contentEditable ))}
suppressContentEditableWarning
onInput={updateHtml}
dangerouslySetInnerHTML={{ __html: document.html }}
aria-label="Содержимое документа"
/>
</section>
<aside className="inspector" aria-label="Параметры документа">
<div className="inspector-tabs">
<button type="button">Стили</button>
<button className="is-active" type="button">
Свойства
</button>
</div> </div>
<section>
<h3>Документ</h3> <div className="paper-frame">
<label> <article
Размер страницы ref={editorRef}
<select defaultValue="a4"> className="paper document-content"
<option value="a4">A4, 210 x 297 мм</option> contentEditable
<option value="letter">Letter, 8.5 x 11 дюймов</option> suppressContentEditableWarning
</select> onInput={updateHtml}
</label> dangerouslySetInnerHTML={{ __html: document.html }}
<label> aria-label="Содержимое документа"
Ориентация />
<select defaultValue="portrait"> {pageView.count > 1 ? (
<option value="portrait">Книжная</option> <div className="page-break-guides" aria-hidden="true">
<option value="landscape">Альбомная</option> {Array.from({ length: pageView.count - 1 }, (_, index) => (
</select> <span key={index} style={{ top: `${(index + 1) * pageView.height}px` }}>
</label> Страница {index + 2}
<div className="metric-grid"> </span>
<label> ))}
Верх </div>
<input defaultValue="20" inputMode="numeric" /> ) : null}
</label> </div>
<label> </section>
Низ
<input defaultValue="20" inputMode="numeric" />
</label>
<label>
Слева
<input defaultValue="20" inputMode="numeric" />
</label>
<label>
Справа
<input defaultValue="20" inputMode="numeric" />
</label>
</div>
</section>
<section>
<h3>Статистика</h3>
<p className="stat-line">
<Pilcrow size={16} />
{wordCount} слов
</p>
<p className="muted">Автосохранение включено. Данные хранятся в локальном хранилище браузера.</p>
</section>
</aside>
</div> </div>
); );
} }
+315 -185
View File
@@ -37,7 +37,7 @@ body,
} }
body { body {
overflow: hidden; overflow: auto;
} }
button, button,
@@ -54,96 +54,14 @@ button {
.qoffice-shell { .qoffice-shell {
display: grid; display: grid;
grid-template-columns: 116px minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
overflow: hidden;
background: var(--bg); background: var(--bg);
} }
.app-rail {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--line);
background: #fbfcfe;
}
.brand {
display: flex;
align-items: center;
height: 52px;
padding: 0 18px;
color: #0e5da8;
font-size: 18px;
font-weight: 760;
}
.app-nav {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
padding: 14px 8px;
}
.app-nav-item {
display: grid;
grid-template-columns: 1fr;
justify-items: center;
gap: 7px;
min-height: 84px;
padding: 10px 6px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #334155;
cursor: pointer;
}
.app-nav-item span:last-child {
max-width: 96px;
overflow-wrap: anywhere;
font-size: 13px;
}
.app-nav-item:hover,
.app-nav-item.is-active {
border-color: #c7d8eb;
background: #edf6ff;
}
.app-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 7px;
color: #ffffff;
}
.accent-word .app-icon {
background: var(--word);
}
.accent-sheets .app-icon {
background: var(--sheets);
}
.accent-slides .app-icon {
background: var(--slides);
}
.accent-mail .app-icon {
background: var(--mail);
}
.rail-footer {
display: flex;
justify-content: center;
gap: 8px;
padding: 14px 8px 20px;
}
.icon-button, .icon-button,
.tool-button, .tool-button,
.compact-command, .compact-command,
@@ -169,34 +87,68 @@ button {
.workspace { .workspace {
display: grid; display: grid;
grid-template-rows: 52px 124px minmax(0, 1fr) 34px; grid-template-rows: 34px 36px minmax(0, 1fr) 28px;
min-width: 0; min-width: 0;
min-height: 0;
height: 100%; height: 100%;
overflow: hidden;
} }
.titlebar { .titlebar {
display: flex; display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
position: relative;
align-items: center; align-items: center;
justify-content: space-between; gap: 14px;
gap: 16px; min-height: 34px;
padding: 0 18px 0 22px; padding: 0 10px;
border-bottom: 1px solid var(--line); background: #2f5f9f;
background: rgba(255, 255, 255, 0.94); color: #ffffff;
} }
.titlebar strong { .titlebar strong {
display: block; display: block;
font-size: 15px; font-size: 13px;
font-weight: 650;
text-align: center;
} }
.titlebar span { .titlebar span {
color: var(--muted); color: rgba(255, 255, 255, 0.82);
font-size: 12px; font-size: 12px;
} }
.quick-access {
display: inline-flex;
align-items: center;
gap: 4px;
}
.quick-access button {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 0;
border-radius: 3px;
background: transparent;
color: #ffffff;
cursor: pointer;
}
.quick-access button:hover {
background: rgba(255, 255, 255, 0.16);
}
.window-title {
justify-self: center;
min-width: 170px;
}
.titlebar-actions { .titlebar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
} }
@@ -205,15 +157,27 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 236px; min-width: 270px;
height: 34px; height: 24px;
padding: 0 10px; padding: 0 9px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 0;
background: var(--surface); background: var(--surface);
color: var(--muted); color: var(--muted);
} }
.titlebar .command-search {
width: min(320px, 32vw);
min-width: 220px;
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.36);
color: #15345c;
}
.titlebar .command-search input::placeholder {
color: #15345c;
}
.command-search input, .command-search input,
.mail-search input { .mail-search input {
min-width: 0; min-width: 0;
@@ -223,11 +187,11 @@ button {
} }
.primary-command { .primary-command {
height: 34px; height: 26px;
padding: 0 12px; padding: 0 12px;
border-color: #0e5da8; border-color: rgba(255, 255, 255, 0.55);
background: var(--accent); background: #ffffff;
color: #ffffff; color: #2f5f9f;
font-weight: 650; font-weight: 650;
} }
@@ -246,13 +210,13 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
height: 36px; height: 36px;
padding-left: 14px; padding: 0 10px 0 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.ribbon-tabs button { .ribbon-tabs button {
height: 36px; height: 36px;
padding: 0 14px; padding: 0 13px;
border: 0; border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
background: transparent; background: transparent;
@@ -300,11 +264,20 @@ button {
} }
.surface-host { .surface-host {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%;
overflow: hidden; overflow: hidden;
} }
.surface-host > * {
min-width: 0;
min-height: 0;
}
.statusbar { .statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -322,7 +295,7 @@ button {
.editor-layout { .editor-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
@@ -336,6 +309,11 @@ button {
background: #eef3f8; background: #eef3f8;
} }
.document-stage {
position: relative;
background: #dedede;
}
.module-toolbar { .module-toolbar {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -384,9 +362,9 @@ button {
} }
.module-toolbar select, .module-toolbar select,
.inspector select, .office-workbar select,
.inspector input, .office-workbar input,
.inspector textarea { .office-workbar textarea {
height: 34px; height: 34px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
@@ -400,6 +378,84 @@ button {
font-size: 13px; font-size: 13px;
} }
.compact-command.is-active {
border-color: var(--accent);
background: #e8f2ff;
color: #0f5fae;
}
.toolbar-status {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 10px;
border-left: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.office-workbar {
display: flex;
align-items: stretch;
gap: 0;
overflow-x: auto;
border-bottom: 1px solid var(--line);
background: #ffffff;
}
.office-tool-section {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 62px;
padding: 8px 12px;
border-right: 1px solid var(--line);
white-space: nowrap;
}
.office-tool-title {
align-self: end;
color: var(--muted);
font-size: 11px;
}
.office-field {
display: inline-grid;
gap: 2px;
color: var(--muted);
font-size: 11px;
}
.office-field input,
.office-field select {
min-width: 110px;
}
.office-field-wide input {
min-width: 190px;
}
.office-check {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text);
font-size: 12px;
}
.office-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: #f8fafc;
color: #475569;
font-size: 12px;
}
.cell-format-tools { .cell-format-tools {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -499,7 +555,7 @@ button {
.page-ruler { .page-ruler {
display: grid; display: grid;
grid-template-columns: repeat(9, 1fr); grid-template-columns: repeat(9, 1fr);
width: min(1000px, calc(100% - 80px)); width: clamp(794px, calc(100% - 80px), 1000px);
height: 26px; height: 26px;
margin: 12px auto 0; margin: 12px auto 0;
color: #7a8797; color: #7a8797;
@@ -511,10 +567,39 @@ button {
padding-left: 6px; padding-left: 6px;
} }
.paper { .vertical-page-ruler {
width: min(1000px, calc(100% - 80px)); position: sticky;
min-height: 1180px; left: 0;
z-index: 1;
float: left;
display: grid;
grid-template-rows: repeat(19, 42px);
width: 18px;
min-height: 798px;
margin-top: 0;
border-right: 1px solid #c5cfdb;
background: #f4f4f4;
color: #475569;
font-size: 10px;
}
.vertical-page-ruler span {
border-top: 1px solid #c5cfdb;
writing-mode: vertical-rl;
text-align: start;
}
.paper-frame {
position: relative;
width: clamp(794px, calc(100% - 80px), 1000px);
margin: 0 auto 38px; margin: 0 auto 38px;
}
.paper {
--qword-page-height: 1180px;
min-height: var(--qword-page-height);
width: 100%;
margin: 0;
padding: 72px 86px; padding: 72px 86px;
border: 1px solid #c7d1dd; border: 1px solid #c7d1dd;
background: #ffffff; background: #ffffff;
@@ -522,6 +607,42 @@ button {
outline: 0; outline: 0;
} }
.page-break-guides {
position: absolute;
inset: 0;
pointer-events: none;
}
.page-break-guides span {
position: absolute;
left: 0;
right: 0;
display: grid;
place-items: center;
height: 24px;
border-top: 1px dashed #94a3b8;
color: #64748b;
font-size: 11px;
transform: translateY(-12px);
}
.page-break-guides span::before,
.page-break-guides span::after {
content: "";
position: absolute;
top: 11px;
width: calc(50% - 58px);
border-top: 1px dashed #94a3b8;
}
.page-break-guides span::before {
left: 0;
}
.page-break-guides span::after {
right: 0;
}
.document-content h1 { .document-content h1 {
margin: 0 0 18px; margin: 0 0 18px;
padding-bottom: 14px; padding-bottom: 14px;
@@ -599,56 +720,6 @@ button {
background: #edf2f7; background: #edf2f7;
} }
.inspector {
min-width: 0;
overflow: auto;
border-left: 1px solid var(--line);
background: #fbfcfe;
}
.inspector-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
height: 48px;
border-bottom: 1px solid var(--line);
}
.inspector-tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
cursor: pointer;
}
.inspector-tabs button.is-active {
border-bottom-color: var(--accent);
font-weight: 700;
}
.inspector section {
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.inspector h3 {
margin: 0 0 14px;
font-size: 14px;
}
.inspector label {
display: grid;
gap: 7px;
margin-bottom: 14px;
color: #4b5563;
font-size: 12px;
}
.inspector label span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -747,6 +818,21 @@ button {
background: #f8fbff; background: #f8fbff;
} }
.sheet-grid th.is-hidden-column,
.sheet-grid th.is-hidden-row,
.sheet-grid td.is-hidden-column,
.sheet-grid td.is-hidden-row {
background-image: repeating-linear-gradient(135deg, rgba(15, 95, 174, 0.08) 0 8px, transparent 8px 16px);
color: #64748b;
}
.sheet-grid td.is-hidden-column input,
.sheet-grid td.is-hidden-column textarea,
.sheet-grid td.is-hidden-row input,
.sheet-grid td.is-hidden-row textarea {
color: #64748b;
}
.sheet-grid td.is-linked input, .sheet-grid td.is-linked input,
.sheet-grid td.is-linked textarea { .sheet-grid td.is-linked textarea {
color: #0f5fae; color: #0f5fae;
@@ -914,7 +1000,7 @@ button {
} }
.slides-layout { .slides-layout {
grid-template-columns: 190px minmax(0, 1fr) 320px; grid-template-columns: 190px minmax(0, 1fr);
} }
.slide-strip { .slide-strip {
@@ -970,7 +1056,7 @@ button {
.slide-stage { .slide-stage {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
padding-bottom: 24px; padding-bottom: 24px;
} }
@@ -1127,6 +1213,44 @@ button {
resize: vertical; resize: vertical;
} }
.notes-inline {
align-items: stretch;
}
.notes-inline .notes-field {
width: 220px;
min-height: 44px;
height: 46px;
padding: 6px 8px;
resize: none;
}
.office-workbar .slide-image-list {
display: inline-flex;
gap: 6px;
max-width: 220px;
overflow-x: auto;
}
.office-workbar .slide-image-list button {
min-width: 96px;
white-space: nowrap;
}
.office-workbar .image-geometry-grid {
grid-template-columns: repeat(4, 72px);
margin-top: 0;
}
.office-workbar .image-geometry-grid input {
width: 72px;
min-width: 0;
}
.office-workbar .danger-command {
margin-top: 0;
}
.mail-layout { .mail-layout {
display: grid; display: grid;
grid-template-columns: 190px 360px minmax(0, 1fr); grid-template-columns: 190px 360px minmax(0, 1fr);
@@ -1409,18 +1533,17 @@ button {
background: #ffffff; background: #ffffff;
} }
.app-rail,
.titlebar, .titlebar,
.ribbon, .ribbon,
.statusbar, .statusbar,
.module-toolbar, .module-toolbar,
.inspector,
.slide-strip, .slide-strip,
.mail-folders, .mail-folders,
.message-list-panel, .message-list-panel,
.message-actions, .message-actions,
.compose-panel, .compose-panel,
.page-ruler { .page-ruler,
.vertical-page-ruler {
display: none !important; display: none !important;
} }
@@ -1433,6 +1556,15 @@ button {
box-shadow: none; box-shadow: none;
} }
.paper-frame {
width: auto;
margin: 0;
}
.page-break-guides {
display: none;
}
.slide-canvas { .slide-canvas {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -1458,11 +1590,9 @@ button {
@media (max-width: 980px) { @media (max-width: 980px) {
.qoffice-shell { .qoffice-shell {
grid-template-columns: 74px minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.brand,
.app-nav-item span:last-child,
.ribbon, .ribbon,
.command-search { .command-search {
display: none; display: none;
@@ -1484,17 +1614,12 @@ button {
font-size: 0; font-size: 0;
} }
.app-nav-item {
min-height: 58px;
}
.editor-layout, .editor-layout,
.slides-layout, .slides-layout,
.mail-layout { .mail-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.inspector,
.slide-strip, .slide-strip,
.mail-folders, .mail-folders,
.message-list-panel { .message-list-panel {
@@ -1504,21 +1629,26 @@ button {
.document-stage, .document-stage,
.spreadsheet-stage, .spreadsheet-stage,
.slide-stage { .slide-stage {
overflow-x: hidden; overflow: auto;
} }
.page-ruler { .page-ruler {
display: none; display: grid;
width: 794px;
margin-top: 12px;
}
.paper-frame {
width: 794px;
margin: 0 auto 32px;
} }
.paper { .paper {
width: 100%; --qword-page-height: 1123px;
min-height: auto; min-height: var(--qword-page-height);
margin: 0; padding: 56px 64px;
padding: 40px 28px 56px; border: 1px solid #c7d1dd;
border-right: 0; box-shadow: var(--shadow);
border-left: 0;
box-shadow: none;
} }
.module-toolbar { .module-toolbar {