Preserve QWord caret while typing
This commit is contained in:
@@ -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
@@ -17,6 +17,28 @@ function clampedWordZoom(value: number) {
|
|||||||
return Math.min(200, Math.max(50, value));
|
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 {
|
function migrateQOfficeState(state: QOfficeState): QOfficeState {
|
||||||
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments;
|
||||||
const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1");
|
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 [wordStatus, setWordStatus] = useState<QWordStatus>(defaultQWordStatus);
|
||||||
const [wordZoom, setWordZoom] = useState(100);
|
const [wordZoom, setWordZoom] = useState(100);
|
||||||
const activeApp = activeAppFromLocation(window.location);
|
const activeApp = activeAppFromLocation(window.location);
|
||||||
|
const windowTitle = qofficeWindowTitle(state, activeApp);
|
||||||
|
|
||||||
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
const updateWordStatus = useCallback((nextStatus: QWordStatus) => {
|
||||||
setWordStatus((current) =>
|
setWordStatus((current) =>
|
||||||
@@ -227,7 +250,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell activeApp={activeApp} status={status}>
|
<AppShell activeApp={activeApp} windowTitle={windowTitle} status={status}>
|
||||||
{activeSurface}
|
{activeSurface}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ describe("AppShell", () => {
|
|||||||
expect(screen.getByText("125%")).toBeInTheDocument();
|
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("отправляет команды панели быстрого доступа активному приложению", () => {
|
it("отправляет команды панели быстрого доступа активному приложению", () => {
|
||||||
const commands: string[] = [];
|
const commands: string[] = [];
|
||||||
function onCommand(event: WindowEventMap["qoffice-command"]) {
|
function onCommand(event: WindowEventMap["qoffice-command"]) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { AppId } from "../types";
|
|||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
|
windowTitle?: string;
|
||||||
status: {
|
status: {
|
||||||
savedLabel: string;
|
savedLabel: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -37,8 +38,9 @@ function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
|
|||||||
event.preventDefault();
|
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 statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
|
||||||
|
const displayWindowTitle = windowTitle?.trim() || appTitles[activeApp];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qoffice-shell">
|
<div className="qoffice-shell">
|
||||||
@@ -58,7 +60,7 @@ export function AppShell({ activeApp, status, children }: AppShellProps) {
|
|||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<strong className="window-title">{appTitles[activeApp]}</strong>
|
<strong className="window-title">{displayWindowTitle}</strong>
|
||||||
<div className="titlebar-actions">
|
<div className="titlebar-actions">
|
||||||
<label className="command-search">
|
<label className="command-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
|
|||||||
@@ -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("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
it("вставляет таблицу выбранного размера через вкладку Вставка", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
|||||||
@@ -280,6 +280,15 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
const activeRibbonTabLabel = wordRibbonTabs.find((tab) => tab.id === activeRibbonTab)?.label ?? "Главная";
|
||||||
const stageClassName = ["document-stage", `qword-view-${viewMode}`].join(" ");
|
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) {
|
function currentPageFromStage(pageCount: number, pageHeight: number) {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
@@ -1021,7 +1030,6 @@ export function QWord({ document, zoom = 100, onChange, onStatusChange }: QWordP
|
|||||||
onKeyUp={rememberActiveTableCell}
|
onKeyUp={rememberActiveTableCell}
|
||||||
onMouseDown={rememberActiveTableCellFromEvent}
|
onMouseDown={rememberActiveTableCellFromEvent}
|
||||||
onMouseUp={rememberActiveTableCellFromEvent}
|
onMouseUp={rememberActiveTableCellFromEvent}
|
||||||
dangerouslySetInnerHTML={{ __html: document.html }}
|
|
||||||
aria-label="Содержимое документа"
|
aria-label="Содержимое документа"
|
||||||
/>
|
/>
|
||||||
{pageView.count > 1 ? (
|
{pageView.count > 1 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user