From 5487d04ccaf3a82c92c3264208af3507ac64e1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Thu, 4 Jun 2026 22:24:33 +0300 Subject: [PATCH] Add QPowerPoint slide size support --- src/App.tsx | 6 +- src/data/sampleData.ts | 1 + src/features/qpowerpoint/QPowerPoint.test.tsx | 15 ++++ src/features/qpowerpoint/QPowerPoint.tsx | 75 +++++++++++++------ src/io/powerPointOffice.test.ts | 50 ++++++++++++- src/io/powerPointOffice.ts | 38 ++++++++-- src/types.ts | 3 + 7 files changed, 158 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 856e45a..c8e415f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,11 +53,15 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState { state.word.id === initialState.word.id && sampleWordHtml.includes("Ключевой результат") && !state.word.html.includes("Ключевой результат"); + const needsDeckSlideSize = !state.deck.slideSize; let changed = false; if (needsSampleWordColor) { changed = true; } + if (needsDeckSlideSize) { + changed = true; + } const messages = state.mail.messages.map((message) => { if (!launchChecklistAttachments || message.id !== "mail-1" || (message.attachments?.length ?? 0) > 0) { return message; @@ -117,7 +121,7 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState { ...state, word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word, workbook: { ...state.workbook, sheets }, - deck: { ...state.deck, slides }, + deck: { ...state.deck, slideSize: state.deck.slideSize ?? initialState.deck.slideSize ?? "wide", slides }, mail: { ...state.mail, messages } } : state; diff --git a/src/data/sampleData.ts b/src/data/sampleData.ts index 59481d7..993f294 100644 --- a/src/data/sampleData.ts +++ b/src/data/sampleData.ts @@ -109,6 +109,7 @@ export const initialState: QOfficeState = { title: "Дорожная карта.qpptx", updatedAt: new Date().toISOString(), activeSlideId: "slide-1", + slideSize: "wide", slides: [ { id: "slide-1", diff --git a/src/features/qpowerpoint/QPowerPoint.test.tsx b/src/features/qpowerpoint/QPowerPoint.test.tsx index 4450120..0f85951 100644 --- a/src/features/qpowerpoint/QPowerPoint.test.tsx +++ b/src/features/qpowerpoint/QPowerPoint.test.tsx @@ -55,6 +55,21 @@ describe("QPowerPoint", () => { expect(nextDeck.slides[0].images?.[0].hyperlink).toBe("https://example.com/updated-logo"); }); + it("переключает размер слайда презентации", () => { + const onChange = vi.fn(); + const view = render(); + const slideSizeSelect = view.container.querySelector('select[aria-label="Размер слайда"]') as HTMLSelectElement; + const canvas = view.container.querySelector(".slide-canvas") as HTMLElement; + + expect(slideSizeSelect).toHaveValue("wide"); + expect(canvas.style.aspectRatio).toBe("13.333 / 7.5"); + + fireEvent.change(slideSizeSelect, { target: { value: "standard" } }); + + const nextDeck = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as SlideDeck; + expect(nextDeck.slideSize).toBe("standard"); + }); + it("переключает начертание текста слайда", () => { const onChange = vi.fn(); const view = render(); diff --git a/src/features/qpowerpoint/QPowerPoint.tsx b/src/features/qpowerpoint/QPowerPoint.tsx index 37d7eea..4d29784 100644 --- a/src/features/qpowerpoint/QPowerPoint.tsx +++ b/src/features/qpowerpoint/QPowerPoint.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; import type { CSSProperties } from "react"; import { AlignCenter, AlignLeft, AlignRight, Bold, Copy, Download, EyeOff, FileText, FileUp, Image as ImageIcon, Italic, Link, List, ListOrdered, ListX, Palette, Plus, Save, Strikethrough, Trash2, Underline } from "lucide-react"; -import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideTextAlign, SlideTransition } from "../../types"; +import type { Slide, SlideDeck, SlideImage, SlideListStyle, SlideSizeId, SlideTextAlign, SlideTransition } from "../../types"; import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { exportPptxBlob, importPptxFile } from "../../io/powerPointOffice"; import { replaceFileExtension } from "../../io/fileHelpers"; @@ -58,8 +58,20 @@ const slideTransitionOptions: Array<{ value: SlideTransition | ""; label: string { value: "wipe", label: "Шторка" } ]; -const slideWidthInches = 13.333; -const slideHeightInches = 7.5; +const slideSizeOptions: Array<{ id: SlideSizeId; label: string; width: number; height: number }> = [ + { id: "wide", label: "Широкий 16:9", width: 13.333, height: 7.5 }, + { id: "standard", label: "Стандартный 4:3", width: 10, height: 7.5 } +]; + +type SlideDimensions = { width: number; height: number }; + +function normalizedSlideSizeId(value?: SlideSizeId): SlideSizeId { + return value === "standard" ? "standard" : "wide"; +} + +function slideDimensions(value?: SlideSizeId): SlideDimensions { + return slideSizeOptions.find((option) => option.id === normalizedSlideSizeId(value)) ?? slideSizeOptions[0]; +} function readFileAsDataUrl(file: File) { return new Promise((resolve, reject) => { @@ -99,18 +111,21 @@ function roundSlideNumber(value: number) { return Math.round(value * 10) / 10; } -function slideImageStyle(image: SlideImage) { +function slideImageStyle(image: SlideImage, dimensions: SlideDimensions) { return { - left: `${(image.x / slideWidthInches) * 100}%`, - top: `${(image.y / slideHeightInches) * 100}%`, - width: `${(image.width / slideWidthInches) * 100}%`, - height: `${(image.height / slideHeightInches) * 100}%` + left: `${(image.x / dimensions.width) * 100}%`, + top: `${(image.y / dimensions.height) * 100}%`, + width: `${(image.width / dimensions.width) * 100}%`, + height: `${(image.height / dimensions.height) * 100}%` }; } -function slideCanvasStyle(slide: Slide) { +function slideCanvasStyle(slide: Slide, dimensions: SlideDimensions): CSSProperties { const backgroundColor = normalizedHexColor(slide.backgroundColor); - return backgroundColor ? { background: `#${backgroundColor}` } : undefined; + return { + aspectRatio: `${dimensions.width} / ${dimensions.height}`, + ...(backgroundColor ? { background: `#${backgroundColor}` } : {}) + }; } function slideTextStyle( @@ -167,6 +182,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { const activeSlide = deck.slides.find((slide) => slide.id === deck.activeSlideId) ?? deck.slides[0]; const activeImages = activeSlide.images ?? []; const selectedImage = activeImages.find((image) => image.id === selectedImageId); + const activeSlideSize = slideDimensions(deck.slideSize); function updateSlide(nextSlide: Slide) { onChange({ @@ -266,6 +282,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { ...deck, title: imported.title, activeSlideId: imported.slides[0]?.id ?? "slide-1", + slideSize: imported.slideSize ?? deck.slideSize ?? "wide", slides: imported.slides.length > 0 ? imported.slides : deck.slides }); } catch (error) { @@ -289,8 +306,8 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { id: `slide-image-${Date.now()}`, src, alt: file.name, - x: roundSlideNumber(Math.max(0.4, slideWidthInches - size.width - 0.9)), - y: roundSlideNumber(Math.max(1.4, slideHeightInches - size.height - 0.8)), + x: roundSlideNumber(Math.max(0.4, activeSlideSize.width - size.width - 0.9)), + y: roundSlideNumber(Math.max(1.4, activeSlideSize.height - size.height - 0.8)), ...size }; @@ -466,6 +483,20 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { ))} +
@@ -854,13 +885,13 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { -
+
{activeImages.map((image) => (