Wire quick access commands

This commit is contained in:
Курнат Андрей
2026-06-02 20:39:30 +03:00
parent 231bb6c076
commit c16df37c95
5 changed files with 76 additions and 10 deletions
+31 -2
View File
@@ -1,8 +1,12 @@
import { render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { AppShell } from "./AppShell"; import { AppShell } from "./AppShell";
describe("AppShell", () => { describe("AppShell", () => {
afterEach(() => {
cleanup();
});
it("не показывает общую ленту навигации между приложениями", () => { it("не показывает общую ленту навигации между приложениями", () => {
const view = render( const view = render(
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}> <AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
@@ -37,4 +41,29 @@ describe("AppShell", () => {
expect(screen.getByText("Число слов: 42")).toBeInTheDocument(); expect(screen.getByText("Число слов: 42")).toBeInTheDocument();
expect(screen.getByText("125%")).toBeInTheDocument(); expect(screen.getByText("125%")).toBeInTheDocument();
}); });
it("отправляет команды панели быстрого доступа активному приложению", () => {
const commands: string[] = [];
function onCommand(event: WindowEventMap["qoffice-command"]) {
commands.push(event.detail.command);
}
window.addEventListener("qoffice-command", onCommand);
try {
const view = render(
<AppShell activeApp="word" status={{ savedLabel: "Сохранено локально" }}>
<div>Рабочая область QWord</div>
</AppShell>
);
const quickAccessButtons = view.container.querySelectorAll(".quick-access button");
fireEvent.click(quickAccessButtons[0]);
fireEvent.click(quickAccessButtons[1]);
fireEvent.click(quickAccessButtons[2]);
expect(commands).toEqual(["save", "undo", "redo"]);
} finally {
window.removeEventListener("qoffice-command", onCommand);
}
});
}); });
+13 -4
View File
@@ -6,7 +6,8 @@ import {
Share2, Share2,
Undo2 Undo2
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import type { MouseEvent, ReactNode } from "react";
import type { QOfficeCommand } from "../hooks/useQOfficeCommand";
import type { AppId } from "../types"; import type { AppId } from "../types";
interface AppShellProps { interface AppShellProps {
@@ -28,6 +29,14 @@ const appTitles: Record<AppId, string> = {
mail: "Почта - QOutlook" mail: "Почта - QOutlook"
}; };
function dispatchQOfficeCommand(command: QOfficeCommand) {
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command } }));
}
function keepActiveEditorFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
export function AppShell({ activeApp, status, children }: AppShellProps) { export function AppShell({ activeApp, status, children }: AppShellProps) {
const statusClassName = ["statusbar", status.className].filter(Boolean).join(" "); const statusClassName = ["statusbar", status.className].filter(Boolean).join(" ");
@@ -36,13 +45,13 @@ export function AppShell({ activeApp, status, children }: AppShellProps) {
<main className="workspace"> <main className="workspace">
<header className="titlebar"> <header className="titlebar">
<div className="quick-access" aria-label="Панель быстрого доступа"> <div className="quick-access" aria-label="Панель быстрого доступа">
<button type="button" title="Сохранить" aria-label="Сохранить"> <button type="button" title="Сохранить" aria-label="Сохранить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("save")}>
<Save size={16} /> <Save size={16} />
</button> </button>
<button type="button" title="Отменить" aria-label="Отменить"> <button type="button" title="Отменить" aria-label="Отменить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("undo")}>
<Undo2 size={16} /> <Undo2 size={16} />
</button> </button>
<button type="button" title="Повторить" aria-label="Повторить"> <button type="button" title="Повторить" aria-label="Повторить" onMouseDown={keepActiveEditorFocus} onClick={() => dispatchQOfficeCommand("redo")}>
<Redo2 size={16} /> <Redo2 size={16} />
</button> </button>
<button type="button" title="Настроить панель" aria-label="Настроить панель"> <button type="button" title="Настроить панель" aria-label="Настроить панель">
+1 -1
View File
@@ -1,7 +1,7 @@
export {}; export {};
type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail"; type DesktopOfficeFileKind = "docx" | "xlsx" | "pptx" | "eml" | "mail";
type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print"; type QOfficeDesktopCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
interface DesktopOpenOfficeFileResult { interface DesktopOpenOfficeFileResult {
canceled: boolean; canceled: boolean;
+25 -2
View File
@@ -1,5 +1,5 @@
import { render, waitFor } from "@testing-library/react"; import { cleanup, render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { useQOfficeCommand } from "./useQOfficeCommand"; import { useQOfficeCommand } from "./useQOfficeCommand";
function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) { function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf?: () => void }) {
@@ -8,6 +8,10 @@ function CommandProbe({ onSave, onExportPdf }: { onSave: () => void; onExportPdf
} }
describe("useQOfficeCommand", () => { describe("useQOfficeCommand", () => {
afterEach(() => {
cleanup();
});
it("вызывает обработчик команды QOffice", async () => { it("вызывает обработчик команды QOffice", async () => {
const onSave = vi.fn(); const onSave = vi.fn();
render(<CommandProbe onSave={onSave} />); render(<CommandProbe onSave={onSave} />);
@@ -39,4 +43,23 @@ describe("useQOfficeCommand", () => {
await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onExportPdf).toHaveBeenCalledTimes(1));
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
}); });
it("использует команду документа для undo и redo без обработчика приложения", async () => {
const originalExecCommand = document.execCommand;
const execCommand = vi.fn();
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
try {
render(<CommandProbe onSave={vi.fn()} />);
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "undo" } }));
window.dispatchEvent(new CustomEvent("qoffice-command", { detail: { command: "redo" } }));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("redo"));
expect(execCommand).toHaveBeenNthCalledWith(1, "undo");
expect(execCommand).toHaveBeenNthCalledWith(2, "redo");
} finally {
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
}
});
}); });
+6 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export type QOfficeCommand = "open" | "save" | "export-pdf" | "print"; export type QOfficeCommand = "open" | "save" | "export-pdf" | "print" | "undo" | "redo";
export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>; export type QOfficeCommandHandlers = Partial<Record<QOfficeCommand, () => void | Promise<void>>>;
export function useQOfficeCommand(handlers: QOfficeCommandHandlers) { export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
@@ -16,6 +16,11 @@ export function useQOfficeCommand(handlers: QOfficeCommandHandlers) {
const handler = handlersRef.current[command]; const handler = handlersRef.current[command];
if (handler) { if (handler) {
void handler(); void handler();
return;
}
if (command === "undo" || command === "redo") {
globalThis.document.execCommand?.(command);
} }
} }