Persist QWord page setup in documents
This commit is contained in:
@@ -31,7 +31,8 @@ export const initialState: QOfficeState = {
|
|||||||
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
<tr><td>Поставка</td><td>Разработка и контроль качества</td><td>Карина М.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`
|
`,
|
||||||
|
pageSetup: { size: "a4", orientation: "portrait", marginPreset: "normal" }
|
||||||
},
|
},
|
||||||
workbook: {
|
workbook: {
|
||||||
id: "qexcell-quarter-plan",
|
id: "qexcell-quarter-plan",
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ describe("QWord", () => {
|
|||||||
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
|
it("меняет поля, размер и ориентацию страницы на вкладке Макет", async () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -176,8 +177,21 @@ describe("QWord", () => {
|
|||||||
html: "<p>Текст страницы</p>"
|
html: "<p>Текст страницы</p>"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function StatefulQWord() {
|
||||||
|
const [currentDocument, setCurrentDocument] = useState(document);
|
||||||
|
return (
|
||||||
|
<QWord
|
||||||
|
document={currentDocument}
|
||||||
|
onChange={(nextDocument) => {
|
||||||
|
onChange(nextDocument);
|
||||||
|
setCurrentDocument(nextDocument);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = render(<QWord document={document} onChange={vi.fn()} />);
|
const view = render(<StatefulQWord />);
|
||||||
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
const frame = view.container.querySelector(".paper-frame") as HTMLElement;
|
||||||
|
|
||||||
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
|
expect(frame.style.getPropertyValue("--qword-page-width")).toBe("794px");
|
||||||
@@ -194,6 +208,42 @@ describe("QWord", () => {
|
|||||||
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
|
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("48px");
|
||||||
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
|
expect(frame.style.getPropertyValue("--qword-page-margin-right")).toBe("48px");
|
||||||
expect(frame.style.minHeight).toBe("816px");
|
expect(frame.style.minHeight).toBe("816px");
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ pageSetup: { size: "letter", orientation: "landscape", marginPreset: "narrow" } }));
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("применяет сохраненные в документе параметры страницы при открытии QWord", () => {
|
||||||
|
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-saved-page-setup-test",
|
||||||
|
title: "Сохраненный макет.docx",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
html: "<p>Текст страницы</p>",
|
||||||
|
pageSetup: { size: "letter", orientation: "landscape", marginPreset: "wide" }
|
||||||
|
};
|
||||||
|
|
||||||
|
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("1056px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-height")).toBe("816px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-left")).toBe("116px");
|
||||||
|
expect(frame.style.getPropertyValue("--qword-page-margin-top")).toBe("92px");
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.ResizeObserver = originalResizeObserver;
|
globalThis.ResizeObserver = originalResizeObserver;
|
||||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
Rows3,
|
Rows3,
|
||||||
Underline
|
Underline
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { WordDocument } from "../../types";
|
import type { WordDocument, WordPageMarginPreset, WordPageOrientation, WordPageSetup, WordPageSizeId } from "../../types";
|
||||||
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
|
||||||
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
import { exportDocxBlob, importDocxFile } from "../../io/wordOffice";
|
||||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||||
@@ -156,15 +156,6 @@ 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: "Файл" },
|
||||||
{ id: "home", label: "Главная" },
|
{ id: "home", label: "Главная" },
|
||||||
@@ -220,6 +211,18 @@ function qwordPageMargins(setup: WordPageSetup) {
|
|||||||
return (wordPageMarginOptions.find((option) => option.id === setup.marginPreset) ?? wordPageMarginOptions[0]).margins;
|
return (wordPageMarginOptions.find((option) => option.id === setup.marginPreset) ?? wordPageMarginOptions[0]).margins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedWordPageSetup(setup?: WordDocument["pageSetup"]): WordPageSetup {
|
||||||
|
const candidateSize = setup?.size;
|
||||||
|
const candidateOrientation = setup?.orientation;
|
||||||
|
const candidateMarginPreset = setup?.marginPreset;
|
||||||
|
const size = candidateSize && wordPageSizeOptions.some((option) => option.id === candidateSize) ? candidateSize : defaultWordPageSetup.size;
|
||||||
|
const orientation =
|
||||||
|
candidateOrientation && wordPageOrientationOptions.some((option) => option.id === candidateOrientation) ? candidateOrientation : defaultWordPageSetup.orientation;
|
||||||
|
const marginPreset =
|
||||||
|
candidateMarginPreset && wordPageMarginOptions.some((option) => option.id === candidateMarginPreset) ? candidateMarginPreset : defaultWordPageSetup.marginPreset;
|
||||||
|
return { size, orientation, marginPreset };
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -308,11 +311,11 @@ 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 pageSetup = useMemo(() => normalizedWordPageSetup(document.pageSetup), [document.pageSetup]);
|
||||||
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
const stageStyle = { "--qword-zoom": String(zoom / 100) } as CSSProperties;
|
||||||
const pageDimensions = qwordPageDimensions(pageSetup);
|
const pageDimensions = qwordPageDimensions(pageSetup);
|
||||||
const pageMargins = qwordPageMargins(pageSetup);
|
const pageMargins = qwordPageMargins(pageSetup);
|
||||||
@@ -340,6 +343,14 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
[onViewModeChange]
|
[onViewModeChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const changePageSetup = useCallback(
|
||||||
|
(nextPageSetup: Partial<WordPageSetup>) => {
|
||||||
|
onChange({ ...document, pageSetup: normalizedWordPageSetup({ ...pageSetup, ...nextPageSetup }) });
|
||||||
|
refreshPageView();
|
||||||
|
},
|
||||||
|
[document, onChange, pageSetup]
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor || editor.innerHTML === document.html) {
|
if (!editor || editor.innerHTML === document.html) {
|
||||||
@@ -934,7 +945,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
<select
|
<select
|
||||||
aria-label="Поля страницы"
|
aria-label="Поля страницы"
|
||||||
value={pageSetup.marginPreset}
|
value={pageSetup.marginPreset}
|
||||||
onChange={(event) => setPageSetup((current) => ({ ...current, marginPreset: event.target.value as WordPageMarginPreset }))}
|
onChange={(event) => changePageSetup({ marginPreset: event.target.value as WordPageMarginPreset })}
|
||||||
>
|
>
|
||||||
{wordPageMarginOptions.map((option) => (
|
{wordPageMarginOptions.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
@@ -948,7 +959,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
<select
|
<select
|
||||||
aria-label="Размер страницы"
|
aria-label="Размер страницы"
|
||||||
value={pageSetup.size}
|
value={pageSetup.size}
|
||||||
onChange={(event) => setPageSetup((current) => ({ ...current, size: event.target.value as WordPageSizeId }))}
|
onChange={(event) => changePageSetup({ size: event.target.value as WordPageSizeId })}
|
||||||
>
|
>
|
||||||
{wordPageSizeOptions.map((option) => (
|
{wordPageSizeOptions.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
@@ -962,7 +973,7 @@ export function QWord({ document, zoom = 100, viewMode, onChange, onStatusChange
|
|||||||
<select
|
<select
|
||||||
aria-label="Ориентация страницы"
|
aria-label="Ориентация страницы"
|
||||||
value={pageSetup.orientation}
|
value={pageSetup.orientation}
|
||||||
onChange={(event) => setPageSetup((current) => ({ ...current, orientation: event.target.value as WordPageOrientation }))}
|
onChange={(event) => changePageSetup({ orientation: event.target.value as WordPageOrientation })}
|
||||||
>
|
>
|
||||||
{wordPageOrientationOptions.map((option) => (
|
{wordPageOrientationOptions.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
export type AppId = "word" | "sheets" | "slides" | "mail";
|
export type AppId = "word" | "sheets" | "slides" | "mail";
|
||||||
|
|
||||||
|
export type WordPageSizeId = "a4" | "letter";
|
||||||
|
export type WordPageOrientation = "portrait" | "landscape";
|
||||||
|
export type WordPageMarginPreset = "normal" | "narrow" | "wide";
|
||||||
|
|
||||||
|
export interface WordPageSetup {
|
||||||
|
size: WordPageSizeId;
|
||||||
|
orientation: WordPageOrientation;
|
||||||
|
marginPreset: WordPageMarginPreset;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WordDocument {
|
export interface WordDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
pageSetup?: WordPageSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workbook {
|
export interface Workbook {
|
||||||
|
|||||||
Reference in New Issue
Block a user