Add QWord Word-style status bar
This commit is contained in:
+72
-4
@@ -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" ? (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user