Add QWord page setup controls
This commit is contained in:
@@ -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("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
it("переключает вкладки ленты и показывает команды выбранной вкладки", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ const styleGalleryOptions = [
|
|||||||
];
|
];
|
||||||
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
type WordRibbonTabId = "file" | "home" | "insert" | "design" | "layout" | "references" | "mailings" | "review" | "view" | "help";
|
||||||
export type WordViewMode = "print" | "read" | "web";
|
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 }> = [
|
const wordRibbonTabs: Array<{ id: WordRibbonTabId; label: string }> = [
|
||||||
{ id: "file", label: "Файл" },
|
{ id: "file", label: "Файл" },
|
||||||
@@ -175,6 +183,20 @@ const wordViewModes: Array<{ id: WordViewMode; label: string }> = [
|
|||||||
{ id: "read", label: "Чтение" },
|
{ id: "read", label: "Чтение" },
|
||||||
{ id: "web", 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];
|
const tableSizeOptions = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
function findInPage(query: string) {
|
function findInPage(query: string) {
|
||||||
@@ -189,6 +211,15 @@ function pageHeightFromStyles(element: HTMLElement) {
|
|||||||
return Number.isFinite(pageHeight) && pageHeight > 0 ? pageHeight : defaultPageViewHeight;
|
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) {
|
function clampedTableSize(value: number) {
|
||||||
return Number.isFinite(value) ? Math.min(8, Math.max(1, Math.trunc(value))) : 3;
|
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 imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
const [activeRibbonTab, setActiveRibbonTab] = useState<WordRibbonTabId>("home");
|
||||||
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
|
const [internalViewMode, setInternalViewMode] = useState<WordViewMode>("print");
|
||||||
|
const [pageSetup, setPageSetup] = useState<WordPageSetup>(defaultWordPageSetup);
|
||||||
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 });
|
||||||
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
const wordCount = useMemo(() => countWords(document.html), [document.html]);
|
||||||
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
||||||
|
const pageDimensions = qwordPageDimensions(pageSetup);
|
||||||
|
const pageMargins = qwordPageMargins(pageSetup);
|
||||||
const pageFrameStyle = {
|
const pageFrameStyle = {
|
||||||
"--qword-page-count": String(pageView.count),
|
"--qword-page-count": String(pageView.count),
|
||||||
"--qword-page-gap": `${defaultPageGap}px`,
|
"--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`,
|
minHeight: `${pageView.count * pageView.height + Math.max(0, pageView.count - 1) * defaultPageGap}px`,
|
||||||
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
|
paddingBottom: pageView.count > 1 ? `${(pageView.count - 1) * defaultPageGap}px` : undefined
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
@@ -356,7 +396,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
const observer = new ResizeObserver(refreshPageView);
|
const observer = new ResizeObserver(refreshPageView);
|
||||||
observer.observe(editor);
|
observer.observe(editor);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [document.html, zoom]);
|
}, [document.html, pageDimensions.height, pageDimensions.width, pageMargins.bottom, pageMargins.left, pageMargins.right, pageMargins.top, zoom]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!restoreActiveTableCellAfterRenderRef.current) {
|
if (!restoreActiveTableCellAfterRenderRef.current) {
|
||||||
@@ -886,6 +926,56 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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" ? (
|
{activeRibbonTab === "home" || activeRibbonTab === "layout" ? (
|
||||||
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
<section className="word-ribbon-group word-paragraph-group" aria-label="Абзац">
|
||||||
<div className="segmented-tools" aria-label="Списки">
|
<div className="segmented-tools" aria-label="Списки">
|
||||||
|
|||||||
+15
-1
@@ -516,6 +516,7 @@ button {
|
|||||||
|
|
||||||
.word-clipboard-group .word-ribbon-title,
|
.word-clipboard-group .word-ribbon-title,
|
||||||
.word-font-group .word-ribbon-title,
|
.word-font-group .word-ribbon-title,
|
||||||
|
.word-page-setup-group .word-ribbon-title,
|
||||||
.word-paragraph-group .word-ribbon-title,
|
.word-paragraph-group .word-ribbon-title,
|
||||||
.word-styles-group .word-ribbon-title,
|
.word-styles-group .word-ribbon-title,
|
||||||
.word-insert-group .word-ribbon-title,
|
.word-insert-group .word-ribbon-title,
|
||||||
@@ -621,6 +622,19 @@ button {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
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 > .segmented-tools,
|
||||||
.word-paragraph-group > .compact-command {
|
.word-paragraph-group > .compact-command {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
@@ -1086,7 +1100,7 @@ button {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
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;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user