Sync QWord statusbar view modes
This commit is contained in:
+42
-1
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { initialState } from "./data/sampleData";
|
import { initialState } from "./data/sampleData";
|
||||||
import type { QOfficeState } from "./types";
|
import type { QOfficeState } from "./types";
|
||||||
@@ -64,4 +64,45 @@ describe("qofficeWindowTitle", () => {
|
|||||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("syncs QWord statusbar view buttons with the active document view mode", () => {
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
window.history.pushState({}, "", "/qword");
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
const stage = screen.getByLabelText("Редактор QWord");
|
||||||
|
const printButton = screen.getByRole("button", { name: "Разметка страницы" });
|
||||||
|
const webButton = screen.getByRole("button", { name: "Веб-документ" });
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-print");
|
||||||
|
expect(printButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.queryByRole("button", { name: "Черновик" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(webButton);
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-web");
|
||||||
|
expect(webButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(printButton).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Вид" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Чтение" }));
|
||||||
|
|
||||||
|
expect(stage).toHaveClass("qword-view-read");
|
||||||
|
expect(screen.getByRole("button", { name: "Режим чтения" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-15
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { BookOpen, Columns2, FileText, Minus, Plus, Rows3 } from "lucide-react";
|
import { BookOpen, Columns2, FileText, Minus, Plus } from "lucide-react";
|
||||||
import { AppShell } from "./components/AppShell";
|
import { AppShell } from "./components/AppShell";
|
||||||
import { QOutlook } from "./features/qoutlook/QOutlook";
|
import { QOutlook } from "./features/qoutlook/QOutlook";
|
||||||
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
import { QPowerPoint } from "./features/qpowerpoint/QPowerPoint";
|
||||||
import { QWord, type QWordStatus } from "./features/qword/QWord";
|
import { QWord, type QWordStatus, type WordViewMode } from "./features/qword/QWord";
|
||||||
import { QExcell } from "./features/qexcell/QExcell";
|
import { QExcell } from "./features/qexcell/QExcell";
|
||||||
import { initialState } from "./data/sampleData";
|
import { initialState } from "./data/sampleData";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
@@ -12,6 +12,11 @@ import type { MailMessage, QOfficeState, SlideDeck, WordDocument, Workbook } fro
|
|||||||
|
|
||||||
const STORAGE_KEY = "qoffice-state-v1";
|
const STORAGE_KEY = "qoffice-state-v1";
|
||||||
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
|
const defaultQWordStatus: QWordStatus = { currentPage: 1, pageCount: 1, wordCount: 0 };
|
||||||
|
const wordStatusbarViewModes = [
|
||||||
|
{ id: "print", label: "Разметка страницы", Icon: FileText },
|
||||||
|
{ id: "read", label: "Режим чтения", Icon: BookOpen },
|
||||||
|
{ id: "web", label: "Веб-документ", Icon: Columns2 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
function clampedWordZoom(value: number) {
|
function clampedWordZoom(value: number) {
|
||||||
return Math.min(200, Math.max(50, value));
|
return Math.min(200, Math.max(50, value));
|
||||||
@@ -122,6 +127,7 @@ export function App() {
|
|||||||
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
const [state, setState] = usePersistentState<QOfficeState>(STORAGE_KEY, initialState, migrateQOfficeState);
|
||||||
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
||||||
const [wordZoom, setWordZoom] = useState(100);
|
const [wordZoom, setWordZoom] = useState(100);
|
||||||
|
const [wordViewMode, setWordViewMode] = useState<WordViewMode>("print");
|
||||||
const activeApp = activeAppFromLocation(window.location);
|
const activeApp = activeAppFromLocation(window.location);
|
||||||
const windowTitle = qofficeWindowTitle(state, activeApp);
|
const windowTitle = qofficeWindowTitle(state, activeApp);
|
||||||
|
|
||||||
@@ -164,18 +170,23 @@ export function App() {
|
|||||||
trailing: (
|
trailing: (
|
||||||
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
<div className="word-statusbar-tools" aria-label="Режимы просмотра и масштаб QWord">
|
||||||
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
<div className="word-view-buttons" aria-label="Режимы просмотра">
|
||||||
<button className="is-active" type="button" title="Разметка страницы" aria-label="Разметка страницы" aria-pressed="true">
|
{wordStatusbarViewModes.map((mode) => {
|
||||||
<FileText size={15} />
|
const Icon = mode.Icon;
|
||||||
</button>
|
const isActive = wordViewMode === mode.id;
|
||||||
<button type="button" title="Режим чтения" aria-label="Режим чтения">
|
return (
|
||||||
<BookOpen size={15} />
|
<button
|
||||||
</button>
|
key={mode.id}
|
||||||
<button type="button" title="Веб-документ" aria-label="Веб-документ">
|
className={isActive ? "is-active" : undefined}
|
||||||
<Columns2 size={15} />
|
type="button"
|
||||||
</button>
|
title={mode.label}
|
||||||
<button type="button" title="Черновик" aria-label="Черновик">
|
aria-label={mode.label}
|
||||||
<Rows3 size={15} />
|
aria-pressed={isActive}
|
||||||
</button>
|
onClick={() => setWordViewMode(mode.id)}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
<button type="button" title="Уменьшить масштаб" aria-label="Уменьшить масштаб" onClick={() => adjustWordZoom(-10)}>
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
@@ -212,6 +223,7 @@ export function App() {
|
|||||||
wordStatus.currentPage,
|
wordStatus.currentPage,
|
||||||
wordStatus.pageCount,
|
wordStatus.pageCount,
|
||||||
wordStatus.wordCount,
|
wordStatus.wordCount,
|
||||||
|
wordViewMode,
|
||||||
wordZoom
|
wordZoom
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -244,7 +256,14 @@ export function App() {
|
|||||||
|
|
||||||
const activeSurface =
|
const activeSurface =
|
||||||
activeApp === "word" ? (
|
activeApp === "word" ? (
|
||||||
<QWord document={state.word} zoom={wordZoom} onChange={updateWord} onStatusChange={updateWordStatus} />
|
<QWord
|
||||||
|
document={state.word}
|
||||||
|
zoom={wordZoom}
|
||||||
|
viewMode={wordViewMode}
|
||||||
|
onChange={updateWord}
|
||||||
|
onStatusChange={updateWordStatus}
|
||||||
|
onViewModeChange={setWordViewMode}
|
||||||
|
/>
|
||||||
) : activeApp === "sheets" ? (
|
) : activeApp === "sheets" ? (
|
||||||
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
<QExcell workbook={state.workbook} onChange={updateWorkbook} />
|
||||||
) : activeApp === "slides" ? (
|
) : activeApp === "slides" ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, MouseEvent } from "react";
|
import type { CSSProperties, MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
@@ -43,8 +43,10 @@ import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
|||||||
interface QWordProps {
|
interface QWordProps {
|
||||||
document: WordDocument;
|
document: WordDocument;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
viewMode?: WordViewMode;
|
||||||
onChange: (document: WordDocument) => void;
|
onChange: (document: WordDocument) => void;
|
||||||
onStatusChange?: (status: QWordStatus) => void;
|
onStatusChange?: (status: QWordStatus) => void;
|
||||||
|
onViewModeChange?: (viewMode: WordViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableCellLocation {
|
interface TableCellLocation {
|
||||||
@@ -153,7 +155,7 @@ const styleGalleryOptions = [
|
|||||||
{ value: "blockquote", label: "Цитата" }
|
{ value: "blockquote", label: "Цитата" }
|
||||||
];
|
];
|
||||||
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
||||||
type WordViewMode = "print" | "read" | "web";
|
export type WordViewMode = "print" | "read" | "web";
|
||||||
|
|
||||||
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
||||||
{ id: "file", label: "Файл" },
|
{ id: "file", label: "Файл" },
|
||||||
@@ -256,7 +258,7 @@ export function qwordCurrentPageFromViewport(input: { stageTop: number; paperTop
|
|||||||
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
|
return Math.min(pageCount, Math.max(1, Math.floor(visiblePaperOffset / visualPageHeight) + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordProps) {
|
export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange, onViewModeChange }: QWordProps) {
|
||||||
const stageRef = useRef<HTMLElement>(null);
|
const stageRef = useRef<HTMLElement>(null);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
const activeTableCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||||
@@ -265,7 +267,7 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
const [viewMode, setViewMode] = useState<WordViewMode>("print");
|
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
|
||||||
const [tableRowCount, setTableRowCount] = useState(3);
|
const [tableRowCount, setTableRowCount] = useState(3);
|
||||||
const [tableColumnCount, setTableColumnCount] = useState(3);
|
const [tableColumnCount, setTableColumnCount] = useState(3);
|
||||||
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
|
||||||
@@ -278,7 +280,16 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
|
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
||||||
const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" ");
|
const activeViewMode = viewMode ?? internalViewMode;
|
||||||
|
const stageClassName = ["document-stage", `qword-view-${activeViewMode}`].join(" ");
|
||||||
|
|
||||||
|
const changeViewMode = useCallback(
|
||||||
|
(nextViewMode: WordViewMode) => {
|
||||||
|
setInternalViewMode(nextViewMode);
|
||||||
|
onViewModeChange?.(nextViewMode);
|
||||||
|
},
|
||||||
|
[onViewModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
@@ -970,11 +981,11 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
{wordViewModes.map((mode) => (
|
{wordViewModes.map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
className={`compact-command${viewMode === mode.id ? " is-active" : ""}`}
|
className={`compact-command${activeViewMode === mode.id ? " is-active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={mode.label}
|
aria-label={mode.label}
|
||||||
aria-pressed={viewMode === mode.id}
|
aria-pressed={activeViewMode === mode.id}
|
||||||
onClick={() => setViewMode(mode.id)}
|
onClick={() => changeViewMode(mode.id)}
|
||||||
>
|
>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
{mode.label}
|
{mode.label}
|
||||||
|
|||||||
Reference in New Issue
Block a user