Skip to content

Commit 2f3dcf9

Browse files
committed
Fix fullscreen fallback and iOS detection
1 parent a55098e commit 2f3dcf9

8 files changed

Lines changed: 288 additions & 33 deletions

File tree

packages/web/src/features/topbar/index.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ describe("TopBar", () => {
156156
expect(settingsButton.nextElementSibling).toBe(fullscreenButton);
157157
});
158158

159-
it("hides the fullscreen toggle when the controller reports unsupported", () => {
159+
it("keeps the fullscreen toggle visible when the controller reports unsupported", () => {
160160
const store = createStore();
161161
store.set(localeAtom, "en");
162162
store.set(workspacesLoadStateAtom, "ready");
@@ -175,6 +175,6 @@ describe("TopBar", () => {
175175
</Provider>
176176
);
177177

178-
expect(screen.queryByRole("button", { name: "Enter Fullscreen" })).toBeNull();
178+
expect(screen.getByRole("button", { name: "Enter Fullscreen" })).toBeInTheDocument();
179179
});
180180
});

packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
23
import { useRef } from "react";
34
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { localeAtom } from "../../../atoms/app-ui";
6+
import { toastsAtom } from "../../notifications/atoms";
47
import { useWorkspaceFullscreen } from "./use-workspace-fullscreen";
58

69
function installFullscreenApi() {
@@ -67,6 +70,36 @@ function clearFullscreenApi() {
6770
configurable: true,
6871
value: undefined,
6972
});
73+
74+
Object.defineProperty(document, "webkitFullscreenEnabled", {
75+
configurable: true,
76+
value: undefined,
77+
});
78+
79+
Object.defineProperty(document, "webkitCurrentFullScreenElement", {
80+
configurable: true,
81+
get: () => undefined,
82+
});
83+
84+
Object.defineProperty(document, "webkitExitFullscreen", {
85+
configurable: true,
86+
value: undefined,
87+
});
88+
89+
Object.defineProperty(document, "webkitCancelFullScreen", {
90+
configurable: true,
91+
value: undefined,
92+
});
93+
94+
Object.defineProperty(HTMLElement.prototype, "webkitRequestFullscreen", {
95+
configurable: true,
96+
value: undefined,
97+
});
98+
99+
Object.defineProperty(HTMLElement.prototype, "webkitRequestFullScreen", {
100+
configurable: true,
101+
value: undefined,
102+
});
70103
}
71104

72105
function HookHarness() {
@@ -106,6 +139,19 @@ function HookHarness() {
106139
);
107140
}
108141

142+
function renderHarness() {
143+
const store = createStore();
144+
store.set(localeAtom, "en");
145+
146+
render(
147+
<Provider store={store}>
148+
<HookHarness />
149+
</Provider>
150+
);
151+
152+
return store;
153+
}
154+
109155
describe("useWorkspaceFullscreen", () => {
110156
afterEach(() => {
111157
clearFullscreenApi();
@@ -115,7 +161,7 @@ describe("useWorkspaceFullscreen", () => {
115161
it("reports unsupported when the browser fullscreen api is unavailable", async () => {
116162
clearFullscreenApi();
117163

118-
render(<HookHarness />);
164+
renderHarness();
119165

120166
await waitFor(() => {
121167
expect(screen.getByTestId("supported")).toHaveTextContent("false");
@@ -126,7 +172,7 @@ describe("useWorkspaceFullscreen", () => {
126172
it("enters and exits fullscreen against the target element", async () => {
127173
const api = installFullscreenApi();
128174

129-
render(<HookHarness />);
175+
renderHarness();
130176

131177
await waitFor(() => {
132178
expect(screen.getByTestId("supported")).toHaveTextContent("true");
@@ -150,7 +196,7 @@ describe("useWorkspaceFullscreen", () => {
150196
it("tracks fullscreenchange even when the browser exits fullscreen outside the button", async () => {
151197
const api = installFullscreenApi();
152198

153-
render(<HookHarness />);
199+
renderHarness();
154200

155201
const target = await screen.findByTestId("fullscreen-target");
156202

@@ -175,7 +221,7 @@ describe("useWorkspaceFullscreen", () => {
175221
.mockRejectedValue(requestError);
176222
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177223

178-
render(<HookHarness />);
224+
renderHarness();
179225

180226
fireEvent.click(screen.getByRole("button", { name: "toggle" }));
181227

@@ -186,4 +232,79 @@ describe("useWorkspaceFullscreen", () => {
186232

187233
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
188234
});
235+
236+
it("shows a friendly toast when fullscreen is unavailable instead of throwing", async () => {
237+
clearFullscreenApi();
238+
const store = renderHarness();
239+
240+
fireEvent.click(screen.getByRole("button", { name: "toggle" }));
241+
242+
await waitFor(() => {
243+
expect(store.get(toastsAtom)).toHaveLength(1);
244+
});
245+
246+
expect(store.get(toastsAtom)[0]).toMatchObject({
247+
kind: "info",
248+
title: "Fullscreen unavailable",
249+
body: "Fullscreen is not available in this browser.",
250+
});
251+
});
252+
253+
it("treats webkit fullscreen support as supported even when fullscreenEnabled is false", async () => {
254+
let webkitFullscreenElement: Element | null = null;
255+
const webkitRequestFullscreen = vi.fn().mockImplementation(function (this: HTMLElement) {
256+
webkitFullscreenElement = this;
257+
document.dispatchEvent(new Event("webkitfullscreenchange"));
258+
return Promise.resolve();
259+
});
260+
const webkitExitFullscreen = vi.fn().mockImplementation(async () => {
261+
webkitFullscreenElement = null;
262+
document.dispatchEvent(new Event("webkitfullscreenchange"));
263+
});
264+
265+
Object.defineProperty(document, "fullscreenEnabled", {
266+
configurable: true,
267+
value: false,
268+
});
269+
Object.defineProperty(document, "fullscreenElement", {
270+
configurable: true,
271+
get: () => null,
272+
});
273+
Object.defineProperty(document, "exitFullscreen", {
274+
configurable: true,
275+
value: undefined,
276+
});
277+
Object.defineProperty(document, "webkitCurrentFullScreenElement", {
278+
configurable: true,
279+
get: () => webkitFullscreenElement,
280+
});
281+
Object.defineProperty(document, "webkitExitFullscreen", {
282+
configurable: true,
283+
value: webkitExitFullscreen,
284+
});
285+
Object.defineProperty(HTMLElement.prototype, "webkitRequestFullscreen", {
286+
configurable: true,
287+
value: webkitRequestFullscreen,
288+
});
289+
290+
renderHarness();
291+
292+
await waitFor(() => {
293+
expect(screen.getByTestId("supported")).toHaveTextContent("true");
294+
});
295+
296+
fireEvent.click(screen.getByRole("button", { name: "enter" }));
297+
298+
await waitFor(() => {
299+
expect(webkitRequestFullscreen).toHaveBeenCalledTimes(1);
300+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("true");
301+
});
302+
303+
fireEvent.click(screen.getByRole("button", { name: "exit" }));
304+
305+
await waitFor(() => {
306+
expect(webkitExitFullscreen).toHaveBeenCalledTimes(1);
307+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
308+
});
309+
});
189310
});

packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { useSetAtom } from "jotai";
12
import { type RefObject, useCallback, useEffect, useState } from "react";
3+
import { useTranslation } from "../../../lib/i18n";
4+
import { pushToastAtom } from "../../notifications/atoms";
25

36
export interface WorkspaceFullscreenController {
47
supported: boolean;
@@ -8,65 +11,153 @@ export interface WorkspaceFullscreenController {
811
toggleFullscreen: () => Promise<void>;
912
}
1013

11-
function canEnterFullscreen(
12-
target: HTMLElement | null
13-
): target is HTMLElement & { requestFullscreen: NonNullable<HTMLElement["requestFullscreen"]> } {
14-
return Boolean(
15-
document.fullscreenEnabled &&
16-
target &&
17-
typeof target.requestFullscreen === "function" &&
18-
typeof document.exitFullscreen === "function"
19-
);
14+
type FullscreenMethod = () => Promise<void> | void;
15+
16+
type FullscreenDocument = Document & {
17+
webkitCurrentFullScreenElement?: Element | null;
18+
webkitExitFullscreen?: FullscreenMethod;
19+
webkitCancelFullScreen?: FullscreenMethod;
20+
};
21+
22+
type FullscreenTarget = HTMLElement & {
23+
requestFullscreen?: FullscreenMethod;
24+
webkitRequestFullscreen?: FullscreenMethod;
25+
webkitRequestFullScreen?: FullscreenMethod;
26+
};
27+
28+
function getFullscreenElement(doc: Document): Element | null {
29+
const fullscreenDocument = doc as FullscreenDocument;
30+
return doc.fullscreenElement ?? fullscreenDocument.webkitCurrentFullScreenElement ?? null;
31+
}
32+
33+
function getRequestFullscreenMethod(target: HTMLElement | null): FullscreenMethod | null {
34+
if (!target) {
35+
return null;
36+
}
37+
38+
const fullscreenTarget = target as FullscreenTarget;
39+
40+
if (typeof fullscreenTarget.requestFullscreen === "function") {
41+
return fullscreenTarget.requestFullscreen.bind(fullscreenTarget);
42+
}
43+
44+
if (typeof fullscreenTarget.webkitRequestFullscreen === "function") {
45+
return fullscreenTarget.webkitRequestFullscreen.bind(fullscreenTarget);
46+
}
47+
48+
if (typeof fullscreenTarget.webkitRequestFullScreen === "function") {
49+
return fullscreenTarget.webkitRequestFullScreen.bind(fullscreenTarget);
50+
}
51+
52+
return null;
53+
}
54+
55+
function getExitFullscreenMethod(doc: Document): FullscreenMethod | null {
56+
const fullscreenDocument = doc as FullscreenDocument;
57+
58+
if (typeof doc.exitFullscreen === "function") {
59+
return doc.exitFullscreen.bind(doc);
60+
}
61+
62+
if (typeof fullscreenDocument.webkitExitFullscreen === "function") {
63+
return fullscreenDocument.webkitExitFullscreen.bind(doc);
64+
}
65+
66+
if (typeof fullscreenDocument.webkitCancelFullScreen === "function") {
67+
return fullscreenDocument.webkitCancelFullScreen.bind(doc);
68+
}
69+
70+
return null;
71+
}
72+
73+
function canUseFullscreen(target: HTMLElement | null): boolean {
74+
return Boolean(getRequestFullscreenMethod(target) && getExitFullscreenMethod(document));
75+
}
76+
77+
function getErrorMessage(error: unknown): string | null {
78+
return error instanceof Error && error.message.trim().length > 0 ? error.message : null;
2079
}
2180

2281
export function useWorkspaceFullscreen(
2382
targetRef: RefObject<HTMLElement | null>
2483
): WorkspaceFullscreenController {
84+
const t = useTranslation();
85+
const pushToast = useSetAtom(pushToastAtom);
2586
const [supported, setSupported] = useState(false);
2687
const [isFullscreen, setIsFullscreen] = useState(false);
2788

89+
const showUnsupportedToast = useCallback(() => {
90+
pushToast({
91+
kind: "info",
92+
title: t("workspace.fullscreen.unsupported_title"),
93+
body: t("workspace.fullscreen.unsupported_body"),
94+
});
95+
}, [pushToast, t]);
96+
2897
useEffect(() => {
2998
const syncState = () => {
3099
const target = targetRef.current;
31-
setSupported(canEnterFullscreen(target));
32-
setIsFullscreen(Boolean(target && document.fullscreenElement === target));
100+
setSupported(canUseFullscreen(target));
101+
setIsFullscreen(Boolean(target && getFullscreenElement(document) === target));
33102
};
34103

35104
syncState();
36105
document.addEventListener("fullscreenchange", syncState);
106+
document.addEventListener("webkitfullscreenchange", syncState as EventListener);
37107

38108
return () => {
39109
document.removeEventListener("fullscreenchange", syncState);
110+
document.removeEventListener("webkitfullscreenchange", syncState as EventListener);
40111
};
41112
}, [targetRef]);
42113

43114
const enterFullscreen = useCallback(async () => {
44115
const target = targetRef.current;
45-
if (!canEnterFullscreen(target)) {
116+
const requestFullscreen = getRequestFullscreenMethod(target);
117+
118+
if (!requestFullscreen || !getExitFullscreenMethod(document)) {
119+
showUnsupportedToast();
46120
return;
47121
}
48122

49123
try {
50-
await target.requestFullscreen();
124+
await Promise.resolve(requestFullscreen());
51125
} catch (error) {
52126
console.warn("Failed to enter fullscreen", error);
127+
pushToast({
128+
kind: "warning",
129+
title: t("workspace.fullscreen.enter_failed_title"),
130+
body: getErrorMessage(error) ?? t("workspace.fullscreen.enter_failed_body"),
131+
});
53132
}
54-
}, [targetRef]);
133+
}, [pushToast, showUnsupportedToast, t, targetRef]);
55134

56135
const exitFullscreen = useCallback(async () => {
57-
if (typeof document.exitFullscreen !== "function" || !document.fullscreenElement) {
136+
const exitFullscreenMethod = getExitFullscreenMethod(document);
137+
138+
if (!getFullscreenElement(document)) {
139+
return;
140+
}
141+
142+
if (!exitFullscreenMethod) {
143+
showUnsupportedToast();
58144
return;
59145
}
60146

61147
try {
62-
await document.exitFullscreen();
148+
await Promise.resolve(exitFullscreenMethod());
63149
} catch (error) {
64150
console.warn("Failed to exit fullscreen", error);
151+
pushToast({
152+
kind: "warning",
153+
title: t("workspace.fullscreen.exit_failed_title"),
154+
body: getErrorMessage(error) ?? t("workspace.fullscreen.exit_failed_body"),
155+
});
65156
}
66-
}, []);
157+
}, [pushToast, showUnsupportedToast, t]);
67158

68159
const toggleFullscreen = useCallback(async () => {
69-
if (targetRef.current && document.fullscreenElement === targetRef.current) {
160+
if (targetRef.current && getFullscreenElement(document) === targetRef.current) {
70161
await exitFullscreen();
71162
return;
72163
}

0 commit comments

Comments
 (0)