Preserve QWord caret while typing

This commit is contained in:
Курнат Андрей
2026-06-04 21:00:40 +03:00
parent c16df37c95
commit 626576e156
6 changed files with 142 additions and 4 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { initialState } from "./data/sampleData";
import type { QOfficeState } from "./types";
import { qofficeWindowTitle } from "./App";
describe("qofficeWindowTitle", () => {
it("uses the current artifact name for standalone application title bars", () => {
expect(qofficeWindowTitle(initialState, "word")).toBe(`${initialState.word.title} - QWord`);
expect(qofficeWindowTitle(initialState, "sheets")).toBe(`${initialState.workbook.title} - QExcell`);
expect(qofficeWindowTitle(initialState, "slides")).toBe(`${initialState.deck.title} - QPowerPoint`);
const activeMessage = initialState.mail.messages.find((message) => message.id === initialState.mail.activeMessageId);
expect(qofficeWindowTitle(initialState, "mail")).toBe(`${activeMessage?.subject} - QOutlook`);
});
it("falls back to default standalone titles when names are blank", () => {
const state: QOfficeState = {
...initialState,
word: { ...initialState.word, title: " " },
workbook: { ...initialState.workbook, title: "" },
deck: { ...initialState.deck, title: "\n\t" },
mail: {
...initialState.mail,
messages: initialState.mail.messages.map((message) =>
message.id === initialState.mail.activeMessageId ? { ...message, subject: "" } : message
)
}
};
expect(qofficeWindowTitle(state, "word")).toBe("Документ1 - QWord");
expect(qofficeWindowTitle(state, "sheets")).toBe("Книга1 - QExcell");
expect(qofficeWindowTitle(state, "slides")).toBe("Презентация1 - QPowerPoint");
expect(qofficeWindowTitle(state, "mail")).toBe("Почта - QOutlook");
});
});
+24 -1
View File
@@ -17,6 +17,28 @@ function clampedWordZoom(value: number) {
return Math.min(200, Math.max(50, value));
}
function normalizedWindowTitlePart(value: string | undefined, fallback: string) {
const normalized = value?.replace(/\s+/g, " ").trim();
return normalized || fallback;
}
export function qofficeWindowTitle(state: QOfficeState, activeApp: QOfficeState["activeApp"]) {
if (activeApp === "word") {
return `${normalizedWindowTitlePart(state.word.title, "Документ1")} - QWord`;
}
if (activeApp === "sheets") {
return `${normalizedWindowTitlePart(state.workbook.title, "Книга1")} - QExcell`;
}
if (activeApp === "slides") {
return `${normalizedWindowTitlePart(state.deck.title, "Презентация1")} - QPowerPoint`;
}
const activeMessage = state.mail.messages.find((message) => message.id === state.mail.activeMessageId);
return `${normalizedWindowTitlePart(activeMessage?.subject, "Почта")} - QOutlook`;
}
function migrateQOfficeState(state: QOfficeState): QOfficeState {
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
@@ -101,6 +123,7 @@ export function App() {
const [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
const [wordZoom, setWordZoom] = useState(100);
const activeApp = activeAppFromLocation(window.location);
const windowTitle = qofficeWindowTitle(state, activeApp);
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
setWordStatus((current) =>
@@ -227,7 +250,7 @@ export function App() {
);
return (
<AppShell activeApp={activeApp} status={status}>
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
{activeSurface}
</AppShell>
);
+10
View File
@@ -42,6 +42,16 @@ describe("AppShell", () => {
expect(screen.getByText("125%")).toBeInTheDocument();
});
it("показывает заголовок текущего файла активного приложения", () => {
render(
<AppShell activeApp="word" windowTitle="Отчет.docx - QWord" status={{ savedLabel: "Сохранено локально" }}>
<div>Рабочая область QWord</div>
</AppShell>
);
expect(screen.getByText("Отчет.docx - QWord")).toBeInTheDocument();
});
it("отправляет команды панели быстрого доступа активному приложению", () => {
const commands: string[] = [];
function onCommand(event: WindowEventMap["qoffice-command"]) {
+4 -2
View File
@@ -12,6 +12,7 @@ import type { AppId } from "../types";
interface AppShellProps {
activeApp: AppId;
windowTitle?: string;
status: {
savedLabel: string;
updatedAt?: string;
@@ -37,8 +38,9 @@ function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function AppShell({ activeApp, status, children }: AppShellProps) {
export function AppShell({ activeApp, windowTitle, status, children }: AppShellProps) {
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
return (
<div className="qoffice-shell">
@@ -58,7 +60,7 @@ export function AppShell({ activeApp, status, children }: AppShellProps) {
<ChevronDown size={14} />
</button>
</div>
<strong className="window-title">{appTitles[activeApp]}</strong>
<strong className="window-title">{displayWindowTitle}</strong>
<div className="titlebar-actions">
<label className="command-search">
<Search size={16} />
+60
View File
@@ -181,6 +181,66 @@ describe("QWord", () => {
}
});
it("сохраняет позицию курсора при наборе текста в документе", async () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const onChange = vi.fn();
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-caret-input-test",
title: "Курсор.docx",
updatedAt: "2026-06-04T00:00:00.000Z",
html: "<p>Начало</p>"
};
function StatefulQWord() {
const [currentDocument, setCurrentDocument] = useState(document);
return (
<QWord
document={currentDocument}
onChange={(nextDocument) => {
onChange(nextDocument);
setCurrentDocument(nextDocument);
}}
/>
);
}
try {
render(<StatefulQWord />);
const paper = screen.getByLabelText("Содержимое документа");
const textNode = paper.querySelector("p")?.firstChild;
expect(textNode).toBeInstanceOf(Text);
const typedText = "Начало текст";
textNode!.textContent = typedText;
const range = window.document.createRange();
range.setStart(textNode!, typedText.length);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.input(paper);
await waitFor(() => expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ html: `<p>${typedText}</p>` })));
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(textNode);
expect(selection?.anchorOffset).toBe(typedText.length);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
window.requestAnimationFrame = originalRequestAnimationFrame;
}
});
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const originalRequestAnimationFrame = window.requestAnimationFrame;
+9 -1
View File
@@ -280,6 +280,15 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" ");
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor || editor.innerHTML === document.html) {
return;
}
editor.innerHTML = document.html;
}, [document.html]);
function currentPageFromStage(pageCount: number, pageHeight: number) {
const stage = stageRef.current;
const editor = editorRef.current;
@@ -1021,7 +1030,6 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
onKeyUp={rememberActiveTableCell}
onMouseDown={rememberActiveTableCellFromEvent}
onMouseUp={rememberActiveTableCellFromEvent}
dangerouslySetInnerHTML={{ __html: document.html }}
aria-label="Содержимое документа"
/>
{pageView.count > 1 ? (