Files
QOffice/src/features/qword/QWord.tsx
T
2026-06-01 23:08:56 +03:00

430 lines
15 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 { 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(/&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 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<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(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", '<span data-qoffice-page-break="true" contenteditable="false"></span><p><br></p>');
}
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 (
<div className="editor-layout qword-layout">
<section className="document-stage" aria-label="Редактор QWord">
<div className="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="Вставить изображение"
/>
<input
className="file-title-input"
value={document.title}
aria-label="Название документа"
onChange={(event) => onChange({ ...document, title: event.target.value })}
/>
<div className="segmented-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("insertUnorderedList")} title="Маркированный список">
<List size={16} />
</button>
<button type="button" onClick={insertLink} title="Вставить ссылку">
<Link size={16} />
</button>
<button type="button" onClick={insertPageBreak} title="Разрыв страницы" aria-label="Разрыв страницы">
<SeparatorHorizontal size={16} />
</button>
<button type="button" onClick={() => imageInputRef.current?.click()} title="Вставить изображение">
<ImageIcon size={16} />
</button>
<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>
</div>
<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>
<select aria-label="Стиль абзаца" onChange={(event) => runCommand("formatBlock", event.target.value)}>
<option value="p">Обычный текст</option>
<option value="h1">Заголовок 1</option>
<option value="h2">Заголовок 2</option>
<option value="blockquote">Цитата</option>
</select>
<button className="compact-command" type="button" onClick={() => void chooseDocx()}>
<FileUp size={16} />
Открыть DOCX
</button>
<button className="compact-command" type="button" onClick={() => void saveDocx()}>
<Save size={16} />
Сохранить DOCX
</button>
<button
className="compact-command"
type="button"
onClick={() => downloadTextFile(document.title.replace(/\.qdocx$/i, ".html"), document.html, "text/html")}
>
<Download size={16} />
Экспорт HTML
</button>
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
<FileText size={16} />
Экспорт PDF
</button>
<span className="toolbar-status">
Страниц: {pageView.count} · Слов: {wordCount}
</span>
</div>
<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">
<article
ref={editorRef}
className="paper document-content"
contentEditable
suppressContentEditableWarning
onInput={updateHtml}
dangerouslySetInnerHTML={{ __html: document.html }}
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}px` }}>
Страница {index + 2}
</span>
))}
</div>
) : null}
</div>
</section>
</div>
);
}