1058 lines
40 KiB
TypeScript
1058 lines
40 KiB
TypeScript
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(/ /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>
|
||
);
|
||
}
|