import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { AlignCenter, AlignLeft, AlignRight, Bold, Download, FileText, FileUp, Image as ImageIcon, Italic, Link, List, PaintBucket, Save, SeparatorHorizontal, Strikethrough, Type, 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; onChange: (document: WordDocument) => void; } 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 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) { const editorRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const [pageView, setPageView] = useState({ count: 1, height: defaultPageViewHeight }); 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) { 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 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", '


'); } 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() { 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="Вставить изображение" /> onChange({ ...document, title: event.target.value })} />
Страниц: {pageView.count} · Слов: {wordCount}
{pageView.count > 1 ? ( ) : null}
); }