430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
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<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>
|
||
);
|
||
}
|