Add QWord page setup controls

This commit is contained in:
Курнат Андрей
2026-06-04 21:30:19 +03:00
parent 0b993b7709
commit c1f8162afd
3 changed files with 150 additions and 2 deletions
+44
View File
@@ -156,6 +156,50 @@ describe("QWord", () => {
}
});
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
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;
const document: WordDocument = {
id: "word-page-setup-test",
title: "Макет.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Текст страницы</p>"
};
try {
const view = render(<QWord document={document} onChange={vi.fn()} />);
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("1123px");
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("86px");
fireEvent.click(screen.getByRole("tab", { name: "Макет" }));
fireEvent.change(screen.getByLabelText("Поля страницы"), { target: { value: "narrow" } });
fireEvent.change(screen.getByLabelText("Размер страницы"), { target: { value: "letter" } });
fireEvent.change(screen.getByLabelText("Ориентация страницы"), { target: { value: "landscape" } });
await waitFor(() => expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px"));
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("1056px");
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
expect(frame.style.minHeight).toBe("816px");
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
+91 -1
View File
@@ -156,6 +156,14 @@ const styleGalleryOptions = [
];
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
export type WordViewMode = "print" | "read" | "web";
type WordPageSizeId = "a4" | "letter";
type WordPageOrientation = "portrait" | "landscape";
type WordPageMarginPreset = "normal" | "narrow" | "wide";
type WordPageSetup = {
size: WordPageSizeId;
orientation: WordPageOrientation;
marginPreset: WordPageMarginPreset;
};
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
{ id: "file", label: "Файл" },
@@ -175,6 +183,20 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
{ id: "read", label: "Чтение" },
{ id: "web", label: "Веб-документ" }
];
const wordPageSizeOptions: Array<{ id: WordPageSizeId; label: string; width: number; height: number }> = [
{ id: "a4", label: "A4", width: 794, height: 1123 },
{ id: "letter", label: "Letter", width: 816, height: 1056 }
];
const wordPageOrientationOptions: Array<{ id: WordPageOrientation; label: string }> = [
{ id: "portrait", label: "Книжная" },
{ id: "landscape", label: "Альбомная" }
];
const wordPageMarginOptions: Array<{ id: WordPageMarginPreset; label: string; margins: { top: number; right: number; bottom: number; left: number } }> = [
{ id: "normal", label: "Обычные", margins: { top: 72, right: 86, bottom: 72, left: 86 } },
{ id: "narrow", label: "Узкие", margins: { top: 48, right: 48, bottom: 48, left: 48 } },
{ id: "wide", label: "Широкие", margins: { top: 92, right: 116, bottom: 92, left: 116 } }
];
const defaultWordPageSetup: WordPageSetup = { size: "a4", orientation: "portrait", marginPreset: "normal" };
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
function findInPage(query: string) {
@@ -189,6 +211,15 @@ function pageHeightFromStyles(element: HTMLElement) {
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
}
function qwordPageDimensions(setup: WordPageSetup) {
const size = wordPageSizeOptions.find((option) => option.id === setup.size) ?? wordPageSizeOptions[0];
return setup.orientation === "landscape" ? { width: size.height, height: size.width } : { width: size.width, height: size.height };
}
function qwordPageMargins(setup: WordPageSetup) {
return (wordPageMarginOptions.find((option) => option.id === setup.marginPreset) ?? wordPageMarginOptions[0]).margins;
}
function clampedTableSize(value: number) {
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
}
@@ -277,14 +308,23 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
const imageInputRef = useRef<HTMLInputElement>(null);
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
const [pageSetup, setPageSetup] = useState<WordPageSetup>(defaultWordPageSetup);
const [tableRowCount, setTableRowCount] = useState(3);
const [tableColumnCount, setTableColumnCount] = useState(3);
const [pageView, setPageView] = useState({ count: 1, current: 1, height: defaultPageViewHeight });
const wordCount = useMemo(() => countWords(document.html), [document.html]);
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
const pageDimensions = qwordPageDimensions(pageSetup);
const pageMargins = qwordPageMargins(pageSetup);
const pageFrameStyle = {
"--qword-page-count": String(pageView.count),
"--qword-page-gap": `${defaultPageGap}px`,
"--qword-page-width": `${pageDimensions.width}px`,
"--qword-page-height": `${pageDimensions.height}px`,
"--qword-page-margin-top": `${pageMargins.top}px`,
"--qword-page-margin-right": `${pageMargins.right}px`,
"--qword-page-margin-bottom": `${pageMargins.bottom}px`,
"--qword-page-margin-left": `${pageMargins.left}px`,
minHeight: `${pageView.count * pageView.height + Math.max(0, pageView.count - 1) * defaultPageGap}px`,
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
} as CSSProperties;
@@ -356,7 +396,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
const observer = new ResizeObserver(refreshPageView);
observer.observe(editor);
return () => observer.disconnect();
}, [document.html, zoom]);
}, [document.html, pageDimensions.height, pageDimensions.width, pageMargins.bottom, pageMargins.left, pageMargins.right, pageMargins.top, zoom]);
useLayoutEffect(() => {
if (!restoreActiveTableCellAfterRenderRef.current) {
@@ -886,6 +926,56 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
</section>
) : null}
{activeRibbonTab === "layout" ? (
<section className="word-ribbon-group word-page-setup-group" aria-label="Параметры страницы">
<div className="word-page-setup-controls">
<label className="word-mini-field">
<span>Поля</span>
<select
aria-label="Поля страницы"
value={pageSetup.marginPreset}
onChange={(event) => setPageSetup((current) => ({ ...current, marginPreset: event.target.value as WordPageMarginPreset }))}
>
{wordPageMarginOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Размер</span>
<select
aria-label="Размер страницы"
value={pageSetup.size}
onChange={(event) => setPageSetup((current) => ({ ...current, size: event.target.value as WordPageSizeId }))}
>
{wordPageSizeOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="word-mini-field">
<span>Ориентация</span>
<select
aria-label="Ориентация страницы"
value={pageSetup.orientation}
onChange={(event) => setPageSetup((current) => ({ ...current, orientation: event.target.value as WordPageOrientation }))}
>
{wordPageOrientationOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
<span className="word-ribbon-title">Параметры страницы</span>
</section>
) : null}
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
<div className="segmented-tools" aria-label="Списки">
+15 -1
View File
@@ -516,6 +516,7 @@ button {
.word-clipboard-group .word-ribbon-title,
.word-font-group .word-ribbon-title,
.word-page-setup-group .word-ribbon-title,
.word-paragraph-group .word-ribbon-title,
.word-styles-group .word-ribbon-title,
.word-insert-group .word-ribbon-title,
@@ -621,6 +622,19 @@ button {
grid-template-columns: minmax(0, 1fr);
}
.word-page-setup-group {
flex: 0 0 272px;
grid-template-columns: minmax(0, 1fr);
}
.word-page-setup-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: start;
gap: 7px;
min-width: 0;
}
.word-paragraph-group > .segmented-tools,
.word-paragraph-group > .compact-command {
align-self: start;
@@ -1086,7 +1100,7 @@ button {
z-index: 1;
width: 100%;
margin: 0;
padding: 72px 86px;
padding: var(--qword-page-margin-top, 72px) var(--qword-page-margin-right, 86px) var(--qword-page-margin-bottom, 72px) var(--qword-page-margin-left, 86px);
border: 0;
background: transparent;
box-shadow: none;