Add QWord Word-style status bar

This commit is contained in:
Курнат Андрей
2026-06-02 07:08:43 +03:00
parent ffa377d931
commit 07f7335c74
5 changed files with 197 additions and 17 deletions
+72 -4
View File
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
import { AppShell } from "./components/AppShell";
import { QOutlook } from "./features/qoutlook/QOutlook";
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
import { QWord } from "./features/qword/QWord";
import { QWord, type QWordStatus } from "./features/qword/QWord";
import { QExcell } from "./features/qexcell/QExcell";
import { initialState } from "./data/sampleData";
import { usePersistentState } from "./hooks/usePersistentState";
@@ -10,6 +11,11 @@ import { activeAppFromLocation } from "./appRouting";
import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } from "./types";
const STORAGE_KEY = "qoffice-state-v1";
const defaultQWordStatus: QWordStatus = { pageCount: 1, wordCount: 0 };
function clampedWordZoom(value: number) {
return Math.min(200, Math.max(50, value));
}
function migrateQOfficeState(state: QOfficeState): QOfficeState {
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
@@ -92,8 +98,20 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState {
export function App() {
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
const [wordZoom, setWordZoom] = useState(100);
const activeApp = activeAppFromLocation(window.location);
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
setWordStatus((current) =>
current.pageCount === nextStatus.pageCount && current.wordCount === nextStatus.wordCount ? current : nextStatus
);
}, []);
const adjustWordZoom = useCallback((delta: number) => {
setWordZoom((current) => clampedWordZoom(current + delta));
}, []);
const status = useMemo(() => {
const updatedAt =
activeApp === "word"
@@ -104,11 +122,61 @@ export function App() {
? state.deck.updatedAt
: undefined;
if (activeApp === "word") {
return {
savedLabel: "Сохранено локально",
updatedAt,
className: "word-statusbar",
content: (
<>
<span>Страница 1 из {wordStatus.pageCount}</span>
<span>Число слов: {wordStatus.wordCount}</span>
<span>русский</span>
</>
),
trailing: (
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
<div className="word-view-buttons" aria-label="Режимы просмотра">
<button className="is-active" type="button" title="Разметка страницы" aria-label="Разметка страницы" aria-pressed="true">
<FileText size={15} />
</button>
<button type="button" title="Режим чтения" aria-label="Режим чтения">
<BookOpen size={15} />
</button>
<button type="button" title="Веб-документ" aria-label="Веб-документ">
<Columns2 size={15} />
</button>
<button type="button" title="Черновик" aria-label="Черновик">
<Rows3 size={15} />
</button>
</div>
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
<Minus size={14} />
</button>
<input
className="word-zoom-slider"
type="range"
min="50"
max="200"
step="10"
value={wordZoom}
onChange={(event) => setWordZoom(clampedWordZoom(Number(event.target.value)))}
aria-label="Масштаб QWord"
/>
<button type="button" title="Увеличить масштаб" aria-label="Увеличить масштаб" onClick={() => adjustWordZoom(10)}>
<Plus size={14} />
</button>
<span>{wordZoom}%</span>
</div>
)
};
}
return {
savedLabel: "Сохранено локально",
updatedAt
};
}, [activeApp, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt]);
}, [activeApp, adjustWordZoom, state.deck.updatedAt, state.word.updatedAt, state.workbook.updatedAt, wordStatus.pageCount, wordStatus.wordCount, wordZoom]);
function updateWord(word: WordDocument) {
setState((current) => ({ ...current, word: { ...word, updatedAt: new Date().toISOString() } }));
@@ -139,7 +207,7 @@ export function App() {
const activeSurface =
activeApp === "word" ? (
<QWord document={state.word} onChange={updateWord} />
<QWord document={state.word} zoom={wordZoom} onChange={updateWord} onStatusChange={updateWordStatus} />
) : activeApp === "sheets" ? (
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
) : activeApp === "slides" ? (
+24
View File
@@ -13,4 +13,28 @@ describe("AppShell", () => {
expect(screen.getByText("Рабочая область QWord")).toBeInTheDocument();
expect(view.container.querySelector(".ribbon")).not.toBeInTheDocument();
});
it("показывает пользовательскую строку состояния активного приложения", () => {
render(
<AppShell
activeApp="word"
status={{
savedLabel: "Сохранено локально",
content: (
<>
<span>Страница 1 из 2</span>
<span>Число слов: 42</span>
</>
),
trailing: <span>125%</span>
}}
>
<div>Рабочая область QWord</div>
</AppShell>
);
expect(screen.getByText("Страница 1 из 2")).toBeInTheDocument();
expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
expect(screen.getByText("125%")).toBeInTheDocument();
});
});
+15 -4
View File
@@ -14,6 +14,9 @@ interface AppShellProps {
status: {
savedLabel: string;
updatedAt?: string;
content?: ReactNode;
trailing?: ReactNode;
className?: string;
};
children: ReactNode;
}
@@ -26,6 +29,8 @@ const appTitles: Record<AppId, string> = {
};
export function AppShell({ activeApp, status, children }: AppShellProps) {
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
return (
<div className="qoffice-shell">
<main className="workspace">
@@ -59,10 +64,16 @@ export function AppShell({ activeApp, status, children }: AppShellProps) {
<div className="surface-host">{children}</div>
<footer className="statusbar">
<span>Готово</span>
<span>{status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"}</span>
<span>Масштаб 100%</span>
<footer className={statusClassName}>
<div className="statusbar-main">
{status.content ?? (
<>
<span>Готово</span>
<span>{status.updatedAt ? `Обновлено: ${new Date(status.updatedAt).toLocaleString("ru-RU")}` : "Локальная почта"}</span>
</>
)}
</div>
<div className="statusbar-trailing">{status.trailing ?? <span>Масштаб 100%</span>}</div>
</footer>
</main>
</div>
+16 -6
View File
@@ -1,4 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import {
AlignCenter,
AlignJustify,
@@ -38,7 +39,14 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
interface QWordProps {
document: WordDocument;
zoom?: number;
onChange: (document: WordDocument) => void;
onStatusChange?: (status: QWordStatus) => void;
}
export interface QWordStatus {
pageCount: number;
wordCount: number;
}
type PageSearchWindow = Window &
@@ -147,12 +155,13 @@ function pageHeightFromStyles(element: HTMLElement) {
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
}
export function QWord({ document, onChange }: QWordProps) {
export function QWord({ document, zoom = 100, onChange, onStatusChange }: 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]);
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
function refreshPageView() {
window.requestAnimationFrame(() => {
@@ -177,7 +186,11 @@ export function QWord({ document, onChange }: QWordProps) {
const observer = new ResizeObserver(refreshPageView);
observer.observe(editor);
return () => observer.disconnect();
}, [document.html]);
}, [document.html, zoom]);
useLayoutEffect(() => {
onStatusChange?.({ pageCount: pageView.count, wordCount });
}, [onStatusChange, pageView.count, wordCount]);
function runCommand(command: string, value?: string) {
editorRef.current?.focus();
@@ -358,7 +371,7 @@ export function QWord({ document, onChange }: QWordProps) {
return (
<div className="editor-layout qword-layout">
<section className="document-stage" aria-label="Редактор QWord">
<section className="document-stage" style={stageStyle} aria-label="Редактор QWord">
<div className="module-toolbar word-module-toolbar">
<input
ref={fileInputRef}
@@ -563,9 +576,6 @@ export function QWord({ document, onChange }: QWordProps) {
<Replace size={16} />
Заменить
</button>
<span className="toolbar-status">
Страниц: {pageView.count} · Слов: {wordCount}
</span>
<span className="word-ribbon-title">Редактирование</span>
</section>
</div>
+70 -3
View File
@@ -281,18 +281,83 @@ button {
.statusbar {
display: flex;
align-items: center;
gap: 28px;
padding: 0 18px;
gap: 0;
justify-content: space-between;
overflow: hidden;
padding: 0 10px 0 18px;
border-top: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 12px;
}
.statusbar span:last-child {
.statusbar-main,
.statusbar-trailing {
display: flex;
align-items: center;
min-width: 0;
}
.statusbar-main {
flex: 1 1 auto;
gap: 28px;
overflow: hidden;
}
.statusbar-trailing {
flex: 0 0 auto;
gap: 8px;
margin-left: auto;
}
.statusbar span {
white-space: nowrap;
}
.word-statusbar {
color: #4f5967;
}
.word-statusbar-tools,
.word-view-buttons {
display: inline-flex;
align-items: center;
}
.word-statusbar-tools {
gap: 6px;
}
.word-view-buttons {
gap: 2px;
padding-right: 8px;
border-right: 1px solid #d2d9e3;
}
.word-statusbar-tools button {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: #4f5967;
cursor: pointer;
}
.word-statusbar-tools button:hover,
.word-statusbar-tools button.is-active {
border-color: #c3d3e6;
background: #eef5fd;
color: #1f4f8a;
}
.word-zoom-slider {
width: 120px;
accent-color: #6b7280;
}
.editor-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -869,6 +934,7 @@ button {
display: grid;
grid-template-columns: repeat(9, 1fr);
width: var(--qword-page-width, 794px);
zoom: var(--qword-zoom, 1);
height: 26px;
margin: 12px auto 0;
color: #7a8797;
@@ -905,6 +971,7 @@ button {
.paper-frame {
position: relative;
width: var(--qword-page-width, 794px);
zoom: var(--qword-zoom, 1);
margin: 0 auto 38px;
}