Files
QOffice/src/features/qword/QWord.tsx
T
Курнат Андрей 500d368af4 Sync QWord statusbar view modes
2026-06-04 21:20:29 +03:00

1058 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
ClipboardPaste,
Columns2,
Copy,
Download,
FileText,
FileUp,
Image as ImageIcon,
Italic,
Link,
List,
ListOrdered,
PaintBucket,
RemoveFormatting,
Replace,
Save,
Scissors,
Search,
SeparatorHorizontal,
Strikethrough,
Subscript,
Superscript,
Table2,
Type,
Rows3,
Underline
} from "lucide-react";
import type { WordDocument } from "../../types";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
import { exportPdfOrPrint } from "../../io/pdfExport";
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
interface QWordProps {
document: WordDocument;
zoom?: number;
viewMode?: WordViewMode;
onChange: (document: WordDocument) => void;
onStatusChange?: (status: QWordStatus) => void;
onViewModeChange?: (viewMode: WordViewMode) => void;
}
interface TableCellLocation {
tableIndex: number;
rowIndex: number;
cellIndex: number;
}
export interface QWordStatus {
currentPage: number;
pageCount: number;
wordCount: number;
}
type PageSearchWindow = Window &
typeof globalThis & {
find?: (query: string) => boolean;
};
function countWords(html: string) {
const text = html.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").trim();
return text ? text.split(/\s+/).length : 0;
}
function normalizedLinkInput(value: string | null) {
const raw = value?.trim();
if (!raw) {
return "";
}
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(candidate);
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
} catch {
window.alert("Введите корректный адрес ссылки.");
return "";
}
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать изображение.")));
reader.readAsDataURL(file);
});
}
function readImageSize(src: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () => resolve({ width: image.naturalWidth || 520, height: image.naturalHeight || 260 }), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось загрузить изображение.")), { once: true });
image.src = src;
});
}
function scaledImageSize(size: { width: number; height: number }, maxWidth = 680) {
const scale = size.width > maxWidth ? maxWidth / size.width : 1;
return {
width: Math.max(1, Math.round(size.width * scale)),
height: Math.max(1, Math.round(size.height * scale))
};
}
const textColorSwatches = [
{ color: "#202124", label: "Черный текст" },
{ color: "#0f5fae", label: "Синий текст" },
{ color: "#0f766e", label: "Зеленый текст" },
{ color: "#c2410c", label: "Красный текст" },
{ color: "#7c3aed", label: "Фиолетовый текст" }
];
const highlightSwatches = [
{ color: "#fff2cc", label: "Желтая подсветка" },
{ color: "#d9ead3", label: "Зеленая подсветка" },
{ color: "#ddebf7", label: "Синяя подсветка" },
{ color: "#fce4d6", label: "Коралловая подсветка" },
{ color: "#ffffff", label: "Без подсветки" }
];
const fontFamilyOptions = ["Calibri", "Aptos", "Arial", "Times New Roman", "Courier New"];
const fontSizeOptions = [9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36];
const fontSizeCommandMap: Record<number, string> = {
9: "1",
10: "2",
11: "2",
12: "3",
14: "4",
16: "4",
18: "5",
20: "5",
24: "6",
28: "6",
36: "7"
};
const styleGalleryOptions = [
{ value: "p", label: "Обычный" },
{ value: "h1", label: "Заголовок 1" },
{ value: "h2", label: "Заголовок 2" },
{ value: "h3", label: "Заголовок 3" },
{ value: "h4", label: "Заголовок 4" },
{ value: "h5", label: "Заголовок 5" },
{ value: "h6", label: "Заголовок 6" },
{ value: "blockquote", label: "Цитата" }
];
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
export type WordViewMode = "print" | "read" | "web";
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
{ id: "file", label: "Файл" },
{ id: "home", label: "Главная" },
{ id: "insert", label: "Вставка" },
{ id: "design", label: "Конструктор" },
{ id: "layout", label: "Макет" },
{ id: "references", label: "Ссылки" },
{ id: "mailings", label: "Рассылки" },
{ id: "review", label: "Рецензирование" },
{ id: "view", label: "Вид" },
{ id: "help", label: "Справка" }
];
const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
{ id: "print", label: "Разметка" },
{ id: "read", label: "Чтение" },
{ id: "web", label: "Веб-документ" }
];
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
function findInPage(query: string) {
return (window as PageSearchWindow).find?.(query) ?? false;
}
const defaultPageViewHeight = 1180;
const defaultPageGap = 28;
function pageHeightFromStyles(element: HTMLElement) {
const pageHeight = Number.parseFloat(window.getComputedStyle(element).getPropertyValue("--qword-page-height"));
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
}
function clampedTableSize(value: number) {
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
}
function qwordTableHtml(rows: number, columns: number) {
const rowCount = clampedTableSize(rows);
const columnCount = clampedTableSize(columns);
const cells = Array.from({ length: columnCount }, () => "<td><br></td>").join("");
const tableRows = Array.from({ length: rowCount }, () => `<tr>${cells}</tr>`).join("");
return `<table><tbody>${tableRows}</tbody></table>`;
}
function createBlankTableCell(tagName: "td" | "th" = "td") {
const cell = globalThis.document.createElement(tagName);
cell.append(globalThis.document.createElement("br"));
return cell;
}
function tableCellFromSelection(editor: HTMLElement): HTMLTableCellElement | undefined {
const node = window.getSelection()?.anchorNode;
const element = node instanceof Element ? node : node?.parentElement;
const cell = element?.closest("td,th");
return cell instanceof HTMLTableCellElement && editor.contains(cell) ? cell : undefined;
}
function tableCellLocation(editor: HTMLElement, cell: HTMLTableCellElement): TableCellLocation | undefined {
const table = cell.closest("table");
const row = cell.parentElement;
if (!(table instanceof HTMLTableElement) || !(row instanceof HTMLTableRowElement) || !editor.contains(table)) {
return undefined;
}
const tableIndex = Array.from(editor.querySelectorAll("table")).indexOf(table);
const rowIndex = Array.from(table.rows).indexOf(row);
const cellIndex = cell.cellIndex;
return tableIndex >= 0 && rowIndex >= 0 && cellIndex >= 0 ? { cellIndex, rowIndex, tableIndex } : undefined;
}
function tableCellFromLocation(editor: HTMLElement, location: TableCellLocation): HTMLTableCellElement | undefined {
const table = Array.from(editor.querySelectorAll("table"))[location.tableIndex];
const cell = table?.rows[location.rowIndex]?.cells[location.cellIndex];
return cell instanceof HTMLTableCellElement ? cell : undefined;
}
function placeCaretInTableCell(cell: HTMLTableCellElement) {
if (cell.childNodes.length === 0) {
cell.append(globalThis.document.createElement("br"));
}
const range = globalThis.document.createRange();
range.selectNodeContents(cell);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function keepEditorSelection(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop: number; pageHeight: number; pageCount: number; zoom: number }) {
const pageCount = Math.max(1, input.pageCount);
const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1;
const visualPageHeight = Math.max(1, input.pageHeight * zoom);
const visiblePaperOffset = Math.max(0, input.stageTop - input.paperTop);
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
}
export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange, onViewModeChange }: QWordProps) {
const stageRef = useRef<HTMLElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
const activeTableCellLocationRef = useRef<TableCellLocation | null>(null);
const restoreActiveTableCellAfterRenderRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
const [tableRowCount, setTableRowCount] = useState(3);
const [tableColumnCount, setTableColumnCount] = useState(3);
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
const wordCount = useMemo(() => countWords(document.html), [document.html]);
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
const pageFrameStyle = {
"--qword-page-count": String(pageView.count),
"--qword-page-gap": `${defaultPageGap}px`,
minHeight: `${pageView.count * pageView.height + Math.max(0, pageView.count - 1) * defaultPageGap}px`,
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
} as CSSProperties;
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
const activeViewMode = viewMode ?? internalViewMode;
const stageClassName = ["document-stage", `qword-view-${activeViewMode}`].join(" ");
const changeViewMode = useCallback(
(nextViewMode: WordViewMode) => {
setInternalViewMode(nextViewMode);
onViewModeChange?.(nextViewMode);
},
[onViewModeChange]
);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor || editor.innerHTML === document.html) {
return;
}
editor.innerHTML = document.html;
}, [document.html]);
function currentPageFromStage(pageCount: number, pageHeight: number) {
const stage = stageRef.current;
const editor = editorRef.current;
if (!stage || !editor) {
return 1;
}
const stageRect = stage.getBoundingClientRect();
const paperRect = editor.getBoundingClientRect();
const currentZoom = Number.parseFloat(window.getComputedStyle(stage).getPropertyValue("--qword-zoom"));
return qwordCurrentPageFromViewport({
stageTop: stageRect.top,
paperTop: paperRect.top,
pageHeight,
pageCount,
zoom: Number.isFinite(currentZoom) ? currentZoom : 1
});
}
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));
const currentPage = currentPageFromStage(count, height);
setPageView((current) =>
current.count === count && current.height === height && current.current === currentPage ? current : { count, current: currentPage, height }
);
});
}
useLayoutEffect(() => {
refreshPageView();
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const observer = new ResizeObserver(refreshPageView);
observer.observe(editor);
return () => observer.disconnect();
}, [document.html, zoom]);
useLayoutEffect(() => {
if (!restoreActiveTableCellAfterRenderRef.current) {
return;
}
restoreActiveTableCellAfterRenderRef.current = false;
const editor = editorRef.current;
const location = activeTableCellLocationRef.current;
if (!editor || !location) {
return;
}
const restoredCell = tableCellFromLocation(editor, location);
if (restoredCell) {
activeTableCellRef.current = restoredCell;
placeCaretInTableCell(restoredCell);
}
}, [document.html]);
useLayoutEffect(() => {
onStatusChange?.({ currentPage: pageView.current, pageCount: pageView.count, wordCount });
}, [onStatusChange, pageView.count, pageView.current, wordCount]);
function runCommand(command: string, value?: string) {
editorRef.current?.focus();
globalThis.document.execCommand(command, false, value);
if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML });
}
}
function applyTextColor(color: string) {
runCommand("foreColor", color);
}
function applyHighlight(color: string) {
editorRef.current?.focus();
globalThis.document.execCommand("hiliteColor", false, color);
globalThis.document.execCommand("backColor", false, color);
updateHtml();
}
function applyFontFamily(fontFamily: string) {
if (fontFamily) {
runCommand("fontName", fontFamily);
}
}
function applyFontSize(fontSize: string) {
const pointSize = Number(fontSize);
if (Number.isFinite(pointSize)) {
runCommand("fontSize", fontSizeCommandMap[pointSize] ?? "3");
}
}
function findText() {
const query = window.prompt("Найти текст");
if (!query) {
return;
}
editorRef.current?.focus();
findInPage(query);
}
function replaceText() {
const query = window.prompt("Найти текст для замены");
if (!query) {
return;
}
editorRef.current?.focus();
if (!findInPage(query)) {
return;
}
const replacement = window.prompt("Заменить на", "") ?? "";
globalThis.document.execCommand("insertText", false, replacement);
updateHtml();
}
function insertLink() {
const href = normalizedLinkInput(window.prompt("Введите адрес ссылки"));
if (!href) {
return;
}
editorRef.current?.focus();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
const anchor = globalThis.document.createElement("a");
anchor.href = href;
anchor.textContent = href;
const range = selection?.rangeCount ? selection.getRangeAt(0) : globalThis.document.createRange();
range.insertNode(anchor);
range.setStartAfter(anchor);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
globalThis.document.execCommand("createLink", false, href);
}
updateHtml();
}
function insertPageBreak() {
runCommand("insertHTML", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
}
function setActiveTableCell(cell: HTMLTableCellElement) {
const editor = editorRef.current;
activeTableCellRef.current = cell;
if (editor) {
activeTableCellLocationRef.current = tableCellLocation(editor, cell) ?? activeTableCellLocationRef.current;
}
}
function queueActiveTableCellRestore(cell: HTMLTableCellElement) {
setActiveTableCell(cell);
restoreActiveTableCellAfterRenderRef.current = true;
}
function rememberActiveTableCell(): HTMLTableCellElement | undefined {
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const cell = tableCellFromSelection(editor);
if (cell) {
setActiveTableCell(cell);
}
return cell;
}
function rememberActiveTableCellFromEvent(event: MouseEvent<HTMLElement>) {
const editor = editorRef.current;
const target = event.target;
const cell = target instanceof Element ? target.closest("td,th") : undefined;
if (editor && cell instanceof HTMLTableCellElement && editor.contains(cell)) {
setActiveTableCell(cell);
return;
}
rememberActiveTableCell();
}
function activeTableCell(): HTMLTableCellElement | undefined {
const editor = editorRef.current;
if (!editor) {
return undefined;
}
const selectedCell = rememberActiveTableCell();
if (selectedCell) {
return selectedCell;
}
const rememberedCell = activeTableCellRef.current;
if (rememberedCell && editor.contains(rememberedCell)) {
return rememberedCell;
}
const rememberedLocation = activeTableCellLocationRef.current;
if (!rememberedLocation) {
return undefined;
}
const restoredCell = tableCellFromLocation(editor, rememberedLocation);
if (restoredCell) {
activeTableCellRef.current = restoredCell;
}
return restoredCell;
}
function insertTable() {
const editor = editorRef.current;
if (!editor) {
return;
}
editor.focus();
const html = `${qwordTableHtml(tableRowCount, tableColumnCount)}<p><br></p>`;
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : undefined;
if (range && (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer))) {
range.deleteContents();
const fragment = globalThis.document.createRange().createContextualFragment(html);
const firstCell = fragment.querySelector("td,th") as HTMLTableCellElement | null;
const lastNode = fragment.lastChild;
range.insertNode(fragment);
if (firstCell) {
queueActiveTableCellRestore(firstCell);
placeCaretInTableCell(firstCell);
} else if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
} else {
editor.insertAdjacentHTML("beforeend", html);
const tables = editor.querySelectorAll("table");
const firstCell = tables[tables.length - 1]?.querySelector("td,th") as HTMLTableCellElement | null;
if (firstCell) {
queueActiveTableCellRestore(firstCell);
placeCaretInTableCell(firstCell);
}
}
updateHtml();
}
function insertTableRowBelow() {
const editor = editorRef.current;
if (!editor) {
return;
}
const cell = activeTableCell();
const row = cell?.parentElement;
if (!cell || !(row instanceof HTMLTableRowElement)) {
window.alert("Поместите курсор в ячейку таблицы.");
return;
}
const newRow = globalThis.document.createElement("tr");
Array.from({ length: Math.max(1, row.cells.length) }, () => createBlankTableCell()).forEach((newCell) => newRow.append(newCell));
row.after(newRow);
const nextCell = newRow.cells[Math.min(cell.cellIndex, newRow.cells.length - 1)] ?? newRow.cells[0];
queueActiveTableCellRestore(nextCell);
placeCaretInTableCell(nextCell);
updateHtml();
}
function insertTableColumnRight() {
const editor = editorRef.current;
if (!editor) {
return;
}
const cell = activeTableCell();
const row = cell?.parentElement;
const table = cell?.closest("table");
if (!cell || !(row instanceof HTMLTableRowElement) || !(table instanceof HTMLTableElement)) {
window.alert("Поместите курсор в ячейку таблицы.");
return;
}
const columnIndex = cell.cellIndex;
let selectedNewCell: HTMLTableCellElement | undefined;
Array.from(table.rows).forEach((tableRow) => {
const referenceCell = tableRow.cells[Math.min(columnIndex, Math.max(0, tableRow.cells.length - 1))];
const tagName = referenceCell?.tagName.toLowerCase() === "th" ? "th" : "td";
const newCell = createBlankTableCell(tagName);
if (referenceCell?.nextSibling) {
tableRow.insertBefore(newCell, referenceCell.nextSibling);
} else {
tableRow.append(newCell);
}
if (tableRow === row) {
selectedNewCell = newCell;
}
});
if (selectedNewCell) {
queueActiveTableCellRestore(selectedNewCell);
placeCaretInTableCell(selectedNewCell);
}
updateHtml();
}
async function insertImage(file: File | undefined) {
if (!file) {
return;
}
try {
const dataUrl = await readFileAsDataUrl(file);
const size = scaledImageSize(await readImageSize(dataUrl));
const paragraph = globalThis.document.createElement("p");
const image = globalThis.document.createElement("img");
image.src = dataUrl;
image.alt = file.name;
image.width = size.width;
image.height = size.height;
paragraph.append(image);
editorRef.current?.append(paragraph);
updateHtml();
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось вставить изображение.");
} finally {
if (imageInputRef.current) {
imageInputRef.current.value = "";
}
}
}
function updateHtml() {
rememberActiveTableCell();
if (editorRef.current) {
onChange({ ...document, html: editorRef.current.innerHTML });
refreshPageView();
}
}
async function openDocx(file: File | undefined) {
if (!file) {
return;
}
try {
const imported = await importDocxFile(file);
onChange({ ...document, title: imported.title, html: imported.html });
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось открыть DOCX.");
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}
async function saveDocx() {
try {
const fileName = replaceFileExtension(document.title, ".docx");
const blob = await exportDocxBlob(document.title, editorRef.current?.innerHTML ?? document.html);
if (isDesktopOfficeBridgeAvailable()) {
await saveDesktopOfficeFile("docx", fileName, blob);
return;
}
downloadBlobFile(fileName, blob);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось сохранить DOCX.");
}
}
async function chooseDocx() {
if (isDesktopOfficeBridgeAvailable()) {
const file = await openDesktopOfficeFile("docx");
await openDocx(file ?? undefined);
return;
}
fileInputRef.current?.click();
}
async function exportPdf() {
try {
await exportPdfOrPrint(document.title);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
}
}
useQOfficeCommand({
open: chooseDocx,
save: saveDocx,
"export-pdf": exportPdf,
print: () => window.print()
});
return (
<div className="editor-layout qword-layout">
<div className="module-toolbar word-module-toolbar">
<input
ref={fileInputRef}
className="hidden-file-input"
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={(event) => void openDocx(event.target.files?.[0])}
aria-label="Открыть DOCX"
/>
<input
ref={imageInputRef}
className="hidden-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/bmp"
onChange={(event) => void insertImage(event.target.files?.[0])}
aria-label="Вставить изображение"
/>
<div className="word-ribbon-tabs" role="tablist" aria-label="Вкладки QWord">
{wordRibbonTabs.map((tab) => (
<button
key={tab.id}
id={`qword-ribbon-tab-${tab.id}`}
className={activeRibbonTab === tab.id ? "is-active" : ""}
type="button"
role="tab"
aria-controls="qword-ribbon-panel"
aria-selected={activeRibbonTab === tab.id}
onClick={() => setActiveRibbonTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
id="qword-ribbon-panel"
className={`word-ribbon word-ribbon-${activeRibbonTab}`}
role="tabpanel"
aria-label={`Лента команд QWord: ${activeRibbonTabLabel}`}
aria-labelledby={`qword-ribbon-tab-${activeRibbonTab}`}
>
{activeRibbonTab === "file" ? (
<section className="word-ribbon-group word-file-group" aria-label="Файл">
<div className="word-file-actions">
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
<FileUp size={16} />
</button>
<button className="word-icon-command" type="button" onClick={() => void saveDocx()} title="Сохранить" aria-label="Сохранить">
<Save size={16} />
</button>
<button
className="word-icon-command"
type="button"
onClick={() => downloadTextFile(document.title.replace(/\.qdocx$/i, ".html"), document.html, "text/html")}
title="Сохранить как HTML"
aria-label="Сохранить как HTML"
>
<Download size={16} />
</button>
<button className="word-icon-command" type="button" onClick={() => void exportPdf()} title="Экспорт PDF" aria-label="Экспорт PDF">
<FileText size={16} />
</button>
</div>
<span className="word-ribbon-title">Файл</span>
</section>
) : null}
{activeRibbonTab === "home" ? (
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
<ClipboardPaste size={22} />
<span>Вставить</span>
</button>
<div className="word-command-stack">
<button type="button" onClick={() => runCommand("cut")} title="Вырезать">
<Scissors size={15} />
Вырезать
</button>
<button type="button" onClick={() => runCommand("copy")} title="Копировать">
<Copy size={15} />
Копировать
</button>
<button type="button" onClick={() => runCommand("removeFormat")} title="Очистить формат">
<RemoveFormatting size={15} />
Очистить
</button>
</div>
<span className="word-ribbon-title">Буфер обмена</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
<div className="word-select-row">
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
{fontFamilyOptions.map((fontFamily) => (
<option key={fontFamily} value={fontFamily}>
{fontFamily}
</option>
))}
</select>
<select className="word-font-size-select" aria-label="Размер шрифта" defaultValue="11" onChange={(event) => applyFontSize(event.target.value)}>
{fontSizeOptions.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
</div>
<div className="segmented-tools word-format-tools" aria-label="Форматирование текста">
<button type="button" onClick={() => runCommand("bold")} title="Полужирный">
<Bold size={16} />
</button>
<button type="button" onClick={() => runCommand("italic")} title="Курсив">
<Italic size={16} />
</button>
<button type="button" onClick={() => runCommand("underline")} title="Подчеркнутый">
<Underline size={16} />
</button>
<button type="button" onClick={() => runCommand("strikeThrough")} title="Зачеркнутый" aria-label="Зачеркнутый">
<Strikethrough size={16} />
</button>
<button type="button" onClick={() => runCommand("subscript")} title="Нижний индекс">
<Subscript size={16} />
</button>
<button type="button" onClick={() => runCommand("superscript")} title="Верхний индекс">
<Superscript size={16} />
</button>
</div>
<div className="word-color-row">
<div className="text-color-tools" aria-label="Цвет текста">
<Type size={16} aria-hidden="true" />
{textColorSwatches.map((swatch) => (
<button
key={swatch.label}
className="color-swatch"
type="button"
onClick={() => applyTextColor(swatch.color)}
title={swatch.label}
aria-label={swatch.label}
style={{ backgroundColor: swatch.color }}
/>
))}
</div>
<div className="text-color-tools" aria-label="Подсветка текста">
<PaintBucket size={16} aria-hidden="true" />
{highlightSwatches.map((swatch) => (
<button
key={swatch.label}
className="color-swatch"
type="button"
onClick={() => applyHighlight(swatch.color)}
title={swatch.label}
aria-label={swatch.label}
style={{ backgroundColor: swatch.color }}
/>
))}
</div>
</div>
<span className="word-ribbon-title">Шрифт</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
<div className="segmented-tools" aria-label="Списки">
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
<List size={16} />
</button>
<button type="button" onClick={() => runCommand("insertOrderedList")} title="Нумерованный список">
<ListOrdered size={16} />
</button>
</div>
<div className="segmented-tools" aria-label="Выравнивание">
<button type="button" onClick={() => runCommand("justifyLeft")} title="По левому краю">
<AlignLeft size={16} />
</button>
<button type="button" onClick={() => runCommand("justifyCenter")} title="По центру">
<AlignCenter size={16} />
</button>
<button type="button" onClick={() => runCommand("justifyRight")} title="По правому краю">
<AlignRight size={16} />
</button>
<button type="button" onClick={() => runCommand("justifyFull")} title="По ширине">
<AlignJustify size={16} />
</button>
</div>
<button className="compact-command" type="button" onClick={insertPageBreak} title="Разрыв страницы" aria-label="Разрыв страницы">
<SeparatorHorizontal size={16} />
Разрыв
</button>
<span className="word-ribbon-title">Абзац</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "design" ? (
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
<div className="word-style-gallery">
{styleGalleryOptions.map((style) => (
<button key={style.value} type="button" onClick={() => runCommand("formatBlock", style.value)}>
<span>AaБбВв</span>
{style.label}
</button>
))}
</div>
<span className="word-ribbon-title">Стили</span>
</section>
) : null}
{activeRibbonTab === "insert" || activeRibbonTab === "references" ? (
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
<button className="compact-command" type="button" onClick={insertLink}>
<Link size={16} />
Ссылка
</button>
<button className="compact-command" type="button" onClick={() => imageInputRef.current?.click()}>
<ImageIcon size={16} />
Рисунок
</button>
<span className="word-ribbon-title">Вставка</span>
</section>
) : null}
{activeRibbonTab === "insert" ? (
<section className="word-ribbon-group word-table-group" aria-label="Таблица">
<div className="word-table-controls">
<label className="word-mini-field">
<span>Строки</span>
<select aria-label="Строки таблицы" value={tableRowCount} onChange={(event) => setTableRowCount(clampedTableSize(Number(event.target.value)))}>
{tableSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Столбцы</span>
<select aria-label="Столбцы таблицы" value={tableColumnCount} onChange={(event) => setTableColumnCount(clampedTableSize(Number(event.target.value)))}>
{tableSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<button className="compact-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTable}>
<Table2 size={16} />
Таблица
</button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableRowBelow}>
<Rows3 size={16} />
Строка ниже
</button>
<button className="compact-command word-table-structure-command" type="button" onMouseDown={keepEditorSelection} onClick={insertTableColumnRight}>
<Columns2 size={16} />
Столбец справа
</button>
</div>
<span className="word-ribbon-title">Таблица</span>
</section>
) : null}
{activeRibbonTab === "view" ? (
<section className="word-ribbon-group word-view-group" aria-label="Режимы просмотра">
<div className="word-view-mode-row">
{wordViewModes.map((mode) => (
<button
key={mode.id}
className={`compact-command${activeViewMode === mode.id ? " is-active" : ""}`}
type="button"
aria-label={mode.label}
aria-pressed={activeViewMode === mode.id}
onClick={() => changeViewMode(mode.id)}
>
<FileText size={16} />
{mode.label}
</button>
))}
</div>
<span className="word-ribbon-title">Вид</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "review" ? (
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
<button className="compact-command" type="button" onClick={findText}>
<Search size={16} />
Найти
</button>
<button className="compact-command" type="button" onClick={replaceText}>
<Replace size={16} />
Заменить
</button>
<span className="word-ribbon-title">Редактирование</span>
</section>
) : null}
</div>
</div>
<section ref={stageRef} className={stageClassName} style={stageStyle} onScroll={refreshPageView} aria-label="Редактор QWord">
<div className="page-ruler" aria-hidden="true">
{Array.from({ length: 9 }, (_, index) => (
<span key={index}>{index + 1}</span>
))}
</div>
<div className="vertical-page-ruler" aria-hidden="true">
{Array.from({ length: 19 }, (_, index) => (
<span key={index}>{index + 1}</span>
))}
</div>
<div className="paper-frame" style={pageFrameStyle}>
<div className="page-sheets" aria-hidden="true">
{Array.from({ length: pageView.count }, (_, index) => (
<span key={index} className="page-sheet" />
))}
</div>
<article
ref={editorRef}
className="paper document-content"
contentEditable
suppressContentEditableWarning
onInput={updateHtml}
onFocus={rememberActiveTableCell}
onKeyUp={rememberActiveTableCell}
onMouseDown={rememberActiveTableCellFromEvent}
onMouseUp={rememberActiveTableCellFromEvent}
aria-label="Содержимое документа"
/>
{pageView.count > 1 ? (
<div className="page-break-guides" aria-hidden="true">
{Array.from({ length: pageView.count - 1 }, (_, index) => (
<span key={index} style={{ top: `${(index + 1) * pageView.height + index * defaultPageGap}px` }} />
))}
</div>
) : null}
</div>
</section>
</div>
);
}