Make QOffice apps standalone Office-style surfaces
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Link,
|
||||
List,
|
||||
PaintBucket,
|
||||
Pilcrow,
|
||||
Save,
|
||||
SeparatorHorizontal,
|
||||
Strikethrough,
|
||||
@@ -95,12 +94,45 @@ const highlightSwatches = [
|
||||
{ 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);
|
||||
@@ -178,6 +210,7 @@ export function QWord({ document, onChange }: QWordProps) {
|
||||
function updateHtml() {
|
||||
if (editorRef.current) {
|
||||
onChange({ ...document, html: editorRef.current.innerHTML });
|
||||
refreshPageView();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +386,9 @@ export function QWord({ document, onChange }: QWordProps) {
|
||||
<FileText size={16} />
|
||||
Экспорт PDF
|
||||
</button>
|
||||
<span className="toolbar-status">
|
||||
Страниц: {pageView.count} · Слов: {wordCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="page-ruler" aria-hidden="true">
|
||||
@@ -361,68 +397,33 @@ export function QWord({ document, onChange }: QWordProps) {
|
||||
))}
|
||||
</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 className="vertical-page-ruler" aria-hidden="true">
|
||||
{Array.from({ length: 19 }, (_, index) => (
|
||||
<span key={index}>{index + 1}</span>
|
||||
))}
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user