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