Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+416
View File
@@ -0,0 +1,416 @@
import { useMemo, useRef } from "react";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Download,
FileText,
FileUp,
Image as ImageIcon,
Italic,
Link,
List,
PaintBucket,
Pilcrow,
Save,
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: "Без подсветки" }
];
export function QWord({ document, onChange }: QWordProps) {
const editorRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const wordCount = useMemo(() => countWords(document.html), [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();
}
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 });
}
}
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("insertUnorderedList")} title="Маркированный список">
<List size={16} />
</button>
<button type="button" onClick={insertLink} title="Вставить ссылку">
<Link 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>
</div>
<div className="page-ruler" aria-hidden="true">
{Array.from({ length: 9 }, (_, index) => (
<span key={index}>{index + 1}</span>
))}
</div>
<article
ref={editorRef}
className="paper document-content"
contentEditable
suppressContentEditableWarning
onInput={updateHtml}
dangerouslySetInnerHTML={{ __html: document.html }}
aria-label="Содержимое документа"
/>
</section>
<aside className="inspector" aria-label="Параметры документа">
<div className="inspector-tabs">
<button type="button">Стили</button>
<button className="is-active" type="button">
Свойства
</button>
</div>
<section>
<h3>Документ</h3>
<label>
Размер страницы
<select defaultValue="a4">
<option value="a4">A4, 210 x 297 мм</option>
<option value="letter">Letter, 8.5 x 11 дюймов</option>
</select>
</label>
<label>
Ориентация
<select defaultValue="portrait">
<option value="portrait">Книжная</option>
<option value="landscape">Альбомная</option>
</select>
</label>
<div className="metric-grid">
<label>
Верх
<input defaultValue="20" inputMode="numeric" />
</label>
<label>
Низ
<input defaultValue="20" inputMode="numeric" />
</label>
<label>
Слева
<input defaultValue="20" inputMode="numeric" />
</label>
<label>
Справа
<input defaultValue="20" inputMode="numeric" />
</label>
</div>
</section>
<section>
<h3>Статистика</h3>
<p className="stat-line">
<Pilcrow size={16} />
{wordCount} слов
</p>
<p className="muted">Автосохранение включено. Данные хранятся в локальном хранилище браузера.</p>
</section>
</aside>
</div>
);
}