Improve QWord ribbon fidelity

This commit is contained in:
Курнат Андрей
2026-06-01 23:29:28 +03:00
parent c8c1be7a30
commit 464484bb19
2 changed files with 502 additions and 98 deletions
+266 -98
View File
@@ -1,9 +1,12 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
ClipboardPaste,
Copy,
Download,
FileText,
FileUp,
@@ -11,10 +14,17 @@ import {
Italic,
Link,
List,
ListOrdered,
PaintBucket,
RemoveFormatting,
Replace,
Save,
Scissors,
Search,
SeparatorHorizontal,
Strikethrough,
Subscript,
Superscript,
Type,
Underline
} from "lucide-react";
@@ -31,6 +41,11 @@ interface QWordProps {
onChange: (document: WordDocument) => void;
}
type PageSearchWindow = Window &
typeof globalThis & {
find?: (query: string) => boolean;
};
function countWords(html: string) {
const text = html.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").trim();
return text ? text.split(/\s+/).length : 0;
@@ -94,6 +109,32 @@ const highlightSwatches = [
{ 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<number, string> = {
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: "blockquote", label: "Цитата" }
];
function findInPage(query: string) {
return (window as PageSearchWindow).find?.(query) ?? false;
}
const defaultPageViewHeight = 1180;
function pageHeightFromStyles(element: HTMLElement) {
@@ -152,6 +193,45 @@ export function QWord({ document, onChange }: QWordProps) {
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) {
@@ -274,7 +354,7 @@ export function QWord({ document, onChange }: QWordProps) {
return (
<div className="editor-layout qword-layout">
<section className="document-stage" aria-label="Редактор QWord">
<div className="module-toolbar">
<div className="module-toolbar word-module-toolbar">
<input
ref={fileInputRef}
className="hidden-file-input"
@@ -291,104 +371,192 @@ export function QWord({ document, onChange }: QWordProps) {
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 className="word-ribbon" aria-label="Лента команд QWord">
<section className="word-ribbon-group word-file-group" aria-label="Файл">
<div className="word-file-actions">
<button className="word-icon-command" type="button" onClick={() => void chooseDocx()} title="Открыть" aria-label="Открыть">
<FileUp size={16} />
</button>
<button className="word-icon-command" type="button" onClick={() => void saveDocx()} title="Сохранить" aria-label="Сохранить">
<Save size={16} />
</button>
<button
className="word-icon-command"
type="button"
onClick={() => downloadTextFile(document.title.replace(/\.qdocx$/i, ".html"), document.html, "text/html")}
title="Сохранить как HTML"
aria-label="Сохранить как HTML"
>
<Download size={16} />
</button>
<button className="word-icon-command" type="button" onClick={() => void exportPdf()} title="Экспорт PDF" aria-label="Экспорт PDF">
<FileText size={16} />
</button>
</div>
<span className="word-ribbon-title">Файл</span>
</section>
<section className="word-ribbon-group word-clipboard-group" aria-label="Буфер обмена">
<button className="word-large-command" type="button" onClick={() => runCommand("paste")} title="Вставить">
<ClipboardPaste size={22} />
<span>Вставить</span>
</button>
<div className="word-command-stack">
<button type="button" onClick={() => runCommand("cut")} title="Вырезать">
<Scissors size={15} />
Вырезать
</button>
<button type="button" onClick={() => runCommand("copy")} title="Копировать">
<Copy size={15} />
Копировать
</button>
<button type="button" onClick={() => runCommand("removeFormat")} title="Очистить формат">
<RemoveFormatting size={15} />
Очистить
</button>
</div>
<span className="word-ribbon-title">Буфер обмена</span>
</section>
<section className="word-ribbon-group word-font-group" aria-label="Шрифт">
<div className="word-select-row">
<select className="word-font-select" aria-label="Шрифт" defaultValue="Calibri" onChange={(event) => applyFontFamily(event.target.value)}>
{fontFamilyOptions.map((fontFamily) => (
<option key={fontFamily} value={fontFamily}>
{fontFamily}
</option>
))}
</select>
<select className="word-font-size-select" aria-label="Размер шрифта" defaultValue="11" onChange={(event) => applyFontSize(event.target.value)}>
{fontSizeOptions.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
</div>
<div className="segmented-tools word-format-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("subscript")} title="Нижний индекс">
<Subscript size={16} />
</button>
<button type="button" onClick={() => runCommand("superscript")} title="Верхний индекс">
<Superscript size={16} />
</button>
</div>
<div className="word-color-row">
<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>
</div>
<span className="word-ribbon-title">Шрифт</span>
</section>
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
<div className="segmented-tools" aria-label="Списки">
<button type="button" onClick={() => runCommand("insertUnorderedList")} title="Маркированный список">
<List size={16} />
</button>
<button type="button" onClick={() => runCommand("insertOrderedList")} title="Нумерованный список">
<ListOrdered size={16} />
</button>
</div>
<div className="segmented-tools" aria-label="Выравнивание">
<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>
<button type="button" onClick={() => runCommand("justifyFull")} title="По ширине">
<AlignJustify size={16} />
</button>
</div>
<button className="compact-command" type="button" onClick={insertPageBreak} title="Разрыв страницы" aria-label="Разрыв страницы">
<SeparatorHorizontal size={16} />
Разрыв
</button>
<span className="word-ribbon-title">Абзац</span>
</section>
<section className="word-ribbon-group word-styles-group" aria-label="Стили">
<div className="word-style-gallery">
{styleGalleryOptions.map((style) => (
<button key={style.value} type="button" onClick={() => runCommand("formatBlock", style.value)}>
<span>AaБбВв</span>
{style.label}
</button>
))}
</div>
<span className="word-ribbon-title">Стили</span>
</section>
<section className="word-ribbon-group word-insert-group" aria-label="Вставка">
<button className="compact-command" type="button" onClick={insertLink}>
<Link size={16} />
Ссылка
</button>
<button className="compact-command" type="button" onClick={() => imageInputRef.current?.click()}>
<ImageIcon size={16} />
Рисунок
</button>
<span className="word-ribbon-title">Вставка</span>
</section>
<section className="word-ribbon-group word-edit-group" aria-label="Редактирование">
<button className="compact-command" type="button" onClick={findText}>
<Search size={16} />
Найти
</button>
<button className="compact-command" type="button" onClick={replaceText}>
<Replace size={16} />
Заменить
</button>
<span className="toolbar-status">
Страниц: {pageView.count} · Слов: {wordCount}
</span>
<span className="word-ribbon-title">Редактирование</span>
</section>
</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">
+236
View File
@@ -327,6 +327,242 @@ button {
background: rgba(255, 255, 255, 0.96);
}
.word-module-toolbar {
display: block;
min-height: 118px;
padding: 0;
overflow: hidden;
}
.word-ribbon {
display: flex;
align-items: stretch;
width: 100%;
min-width: 100%;
min-height: 118px;
overflow: hidden;
background: #ffffff;
}
.word-ribbon-group {
display: grid;
grid-template-rows: minmax(0, 1fr) 18px;
align-items: stretch;
gap: 4px;
min-height: 118px;
min-width: 0;
padding: 8px 10px 4px;
border-right: 1px solid #d6dde8;
}
.word-ribbon-title {
align-self: end;
color: #69778a;
font-size: 11px;
line-height: 1;
text-align: center;
}
.word-command-row,
.word-select-row,
.word-color-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.word-file-group {
flex: 0 0 76px;
grid-template-columns: minmax(0, 1fr);
}
.word-file-actions {
display: grid;
grid-template-columns: repeat(2, 28px);
align-content: start;
justify-content: center;
gap: 6px;
}
.word-icon-command {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 4px;
background: #ffffff;
color: var(--text);
cursor: pointer;
}
.word-icon-command:hover {
border-color: #c7d8eb;
background: #edf6ff;
}
.word-clipboard-group {
flex: 0 0 150px;
grid-template-columns: auto auto;
}
.word-clipboard-group .word-ribbon-title,
.word-font-group .word-ribbon-title,
.word-paragraph-group .word-ribbon-title,
.word-styles-group .word-ribbon-title,
.word-insert-group .word-ribbon-title,
.word-edit-group .word-ribbon-title {
grid-column: 1 / -1;
}
.word-large-command {
display: grid;
place-items: center;
align-content: center;
gap: 4px;
min-width: 66px;
min-height: 70px;
border: 1px solid transparent;
border-radius: 4px;
background: #ffffff;
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.word-large-command:hover,
.word-command-stack button:hover,
.word-style-gallery button:hover {
border-color: #c7d8eb;
background: #edf6ff;
}
.word-command-stack {
display: grid;
align-content: start;
gap: 4px;
}
.word-command-stack button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 22px;
border: 1px solid transparent;
border-radius: 4px;
background: #ffffff;
color: var(--text);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.word-font-group {
flex: 1 1 266px;
grid-template-columns: minmax(0, 1fr);
}
.word-font-select {
min-width: 0;
flex: 1 1 170px;
}
.word-font-size-select {
flex: 0 0 64px;
width: 64px;
}
.word-format-tools {
width: fit-content;
max-width: 100%;
margin-top: 6px;
}
.word-color-row {
flex-wrap: wrap;
margin-top: 6px;
}
.word-color-row .text-color-tools {
min-height: 26px;
padding-left: 0;
border-left: 0;
}
.word-color-row .color-swatch {
width: 18px;
height: 18px;
}
.word-paragraph-group {
flex: 0 0 170px;
grid-template-columns: minmax(0, 1fr);
}
.word-paragraph-group > .segmented-tools,
.word-paragraph-group > .compact-command {
align-self: start;
}
.word-paragraph-group .compact-command {
width: 100%;
}
.word-styles-group {
flex: 0 1 210px;
grid-template-columns: minmax(0, 1fr);
}
.word-style-gallery {
display: grid;
grid-template-columns: repeat(2, minmax(74px, 1fr));
gap: 6px;
min-width: 0;
}
.word-style-gallery button {
display: grid;
align-content: center;
min-height: 64px;
padding: 6px 8px;
border: 1px solid #d6dde8;
border-radius: 4px;
background: #ffffff;
color: #334155;
cursor: pointer;
font-size: 12px;
text-align: left;
}
.word-style-gallery button span {
color: #1e5fa7;
font-size: 20px;
line-height: 1.1;
}
.word-insert-group {
flex: 0 0 96px;
grid-template-columns: minmax(0, 1fr);
}
.word-insert-group .compact-command,
.word-edit-group .compact-command {
justify-content: flex-start;
}
.word-edit-group {
flex: 0 0 132px;
grid-template-columns: minmax(0, 1fr);
}
.word-edit-group .toolbar-status {
min-height: 26px;
padding: 0;
border-left: 0;
font-size: 11px;
}
.hidden-file-input {
display: none;
}