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((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 = { 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 }, () => "
").join(""); const tableRows = Array.from({ length: rowCount }, () => `${cells}`).join(""); return `${tableRows}
`; } 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) { 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(null); const editorRef = useRef(null); const activeTableCellRef = useRef(null); const activeTableCellLocationRef = useRef(null); const restoreActiveTableCellAfterRenderRef = useRef(false); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const [activeRibbonTab, setActiveRibbonTab] = useState("home"); const [internalViewMode, setInternalViewMode] = useState("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", '


'); } 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) { 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)}


`; 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 (
void openDocx(event.target.files?.[0])} aria-label="Открыть DOCX" /> void insertImage(event.target.files?.[0])} aria-label="Вставить изображение" />
{wordRibbonTabs.map((tab) => ( ))}
{activeRibbonTab === "file" ? (
Файл
) : null} {activeRibbonTab === "home" ? (
Буфер обмена
) : null} {activeRibbonTab === "home" || activeRibbonTab === "design" ? (
Шрифт
) : null} {activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
Абзац
) : null} {activeRibbonTab === "home" || activeRibbonTab === "design" ? (
{styleGalleryOptions.map((style) => ( ))}
Стили
) : null} {activeRibbonTab === "insert" || activeRibbonTab === "references" ? (
Вставка
) : null} {activeRibbonTab === "insert" ? (
Таблица
) : null} {activeRibbonTab === "view" ? (
{wordViewModes.map((mode) => ( ))}
Вид
) : null} {activeRibbonTab === "home" || activeRibbonTab === "review" ? (
Редактирование
) : null}
{pageView.count > 1 ? ( ) : null}
); }