Skip to content

Commit 1c8e9dc

Browse files
fix(web): fallback to 2d when webgl is unstable
1 parent 568d831 commit 1c8e9dc

3 files changed

Lines changed: 71 additions & 11 deletions

File tree

src/web/src/pages/tasks/page.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
TaskRecord,
2222
} from '@features/tasks/types';
2323
import { getCurrentAppPath, navigate, subscribeNavigation } from '@shared/navigation';
24+
import { shouldPrefer2D, supportsWebGL } from '@shared/platform';
2425

2526
const taskStyles = `
2627
:root {
@@ -1367,7 +1368,8 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
13671368
}
13681369

13691370
export default function TasksPage() {
1370-
const [interiorViewMode, setInteriorViewMode] = useState<'3d' | '2d'>('3d');
1371+
const disable3D = useMemo(() => shouldPrefer2D() || !supportsWebGL(), []);
1372+
const [interiorViewMode, setInteriorViewMode] = useState<'3d' | '2d'>(() => (disable3D ? '2d' : '3d'));
13711373
const pathname = useSyncExternalStore(subscribeNavigation, getCurrentAppPath, () => '/tasks');
13721374
const selectedTaskId = parseSelectedTaskId(pathname);
13731375
const detailMode = Boolean(selectedTaskId);
@@ -1392,6 +1394,12 @@ export default function TasksPage() {
13921394
const [edgeBusy, setEdgeBusy] = useState(false);
13931395
const [edgeError, setEdgeError] = useState<string | null>(null);
13941396

1397+
useEffect(() => {
1398+
if (disable3D && interiorViewMode === '3d') {
1399+
setInteriorViewMode('2d');
1400+
}
1401+
}, [disable3D, interiorViewMode]);
1402+
13951403
async function refreshUniverse(): Promise<void> {
13961404
try {
13971405
const planetData = await fetchJson<PlanetListResponse>('/api/planets?limit=50');
@@ -1775,7 +1783,7 @@ export default function TasksPage() {
17751783

17761784
const interiorContent = selectedTask && selectedPlanet ? (
17771785
interior && interiorTaskId === selectedTask.id && !interiorError ? (
1778-
interiorViewMode === '3d' ? (
1786+
interiorViewMode === '3d' && !disable3D ? (
17791787
<PlanetInteriorScene3D
17801788
planet={selectedPlanet}
17811789
interior={interior}
@@ -1855,19 +1863,21 @@ export default function TasksPage() {
18551863

18561864
<div className="task-detail-grid">
18571865
<main className="task-shell task-detail-main">
1858-
<div className="task-main-head">
1859-
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
1860-
<div>
1861-
<h1 style={{ margin: 0, fontSize: 18 }}>Task detail</h1>
1866+
<div className="task-main-head">
1867+
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
1868+
<div>
1869+
<h1 style={{ margin: 0, fontSize: 18 }}>Task detail</h1>
18621870
<div style={{ marginTop: 6, color: 'var(--task-dim)', fontSize: 13 }}>
18631871
This page is optimized for the Feishu progress card and focuses on one task.
18641872
</div>
18651873
</div>
18661874
<div className="task-view-switch">
18671875
<button
18681876
type="button"
1869-
className={`task-tool-btn ${interiorViewMode === '3d' ? 'active' : ''}`}
1877+
className={`task-tool-btn ${interiorViewMode === '3d' && !disable3D ? 'active' : ''}`}
18701878
onClick={() => setInteriorViewMode('3d')}
1879+
disabled={disable3D}
1880+
title={disable3D ? '3D 视图在当前设备/内置浏览器环境下容易白屏,已默认切换到 2D。' : undefined}
18711881
>
18721882
Workspace
18731883
</button>
@@ -2243,8 +2253,10 @@ export default function TasksPage() {
22432253
<div className="task-view-switch">
22442254
<button
22452255
type="button"
2246-
className={`task-tool-btn ${interiorViewMode === '3d' ? 'active' : ''}`}
2256+
className={`task-tool-btn ${interiorViewMode === '3d' && !disable3D ? 'active' : ''}`}
22472257
onClick={() => setInteriorViewMode('3d')}
2258+
disabled={disable3D}
2259+
title={disable3D ? '3D 视图在当前设备/内置浏览器环境下容易白屏,已默认切换到 2D。' : undefined}
22482260
>
22492261
像素办公室
22502262
</button>
@@ -2265,7 +2277,7 @@ export default function TasksPage() {
22652277
<div className="task-main-body">
22662278
{selectedTask && selectedPlanet ? (
22672279
interior && interiorTaskId === selectedTask.id && !interiorError ? (
2268-
interiorViewMode === '3d' ? (
2280+
interiorViewMode === '3d' && !disable3D ? (
22692281
<PlanetInteriorScene3D
22702282
planet={selectedPlanet}
22712283
interior={interior}

src/web/src/pages/universe/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PlanetEdge, PlanetListResponse, PlanetOverviewItem, TaskRecord } f
55
import UniverseCanvas from '@features/tasks/universe/UniverseCanvas';
66
import UniverseScene3D from '@features/tasks/universe/UniverseScene3D';
77
import { navigate, subscribeNavigation } from '@shared/navigation';
8+
import { shouldPrefer2D, supportsWebGL } from '@shared/platform';
89

910
const universeStyles = `
1011
:root {
@@ -462,6 +463,8 @@ function parseFocusPlanetId(search: string): string | null {
462463
}
463464

464465
function parseViewMode(search: string): UniverseViewMode {
466+
const disable3D = shouldPrefer2D() || !supportsWebGL();
467+
if (disable3D) return '2d';
465468
const view = new URLSearchParams(search).get('view');
466469
return view === '2d' ? '2d' : '3d';
467470
}
@@ -585,6 +588,7 @@ export default function UniversePage() {
585588
const search = useSyncExternalStore(subscribeNavigation, () => window.location.search, () => '');
586589
const focusPlanetId = parseFocusPlanetId(search);
587590
const viewMode = parseViewMode(search);
591+
const disable3D = useMemo(() => shouldPrefer2D() || !supportsWebGL(), []);
588592
const [planets, setPlanets] = useState<PlanetOverviewItem[]>([]);
589593
const [edges, setEdges] = useState<PlanetEdge[]>([]);
590594
const [loading, setLoading] = useState(true);
@@ -799,8 +803,10 @@ export default function UniversePage() {
799803
<div className="universe-view-switch">
800804
<button
801805
type="button"
802-
className={`universe-btn ${viewMode === '3d' ? 'primary' : ''}`}
806+
className={`universe-btn ${viewMode === '3d' && !disable3D ? 'primary' : ''}`}
803807
onClick={() => navigate(buildUniverseUrl(selectedPlanet?.id ?? focusPlanetId, '3d'))}
808+
disabled={disable3D}
809+
title={disable3D ? '3D 视图在当前设备/内置浏览器环境下容易白屏,已默认切换到 2D。' : undefined}
804810
>
805811
3D 星图
806812
</button>
@@ -925,7 +931,7 @@ export default function UniversePage() {
925931
当前服务器尚未提供 Planet API。现在展示的是由 `/api/tasks` 推导出的宇宙视图,因此连线关系和更丰富的星球元数据暂不可用。
926932
</div>
927933
) : null}
928-
{viewMode === '3d' ? (
934+
{viewMode === '3d' && !disable3D ? (
929935
<UniverseScene3D
930936
planets={planets}
931937
edges={filteredUniverse.visibleEdges}

src/web/src/shared/platform.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export function isProbablyIOS(): boolean {
2+
if (typeof navigator === 'undefined') return false;
3+
4+
const ua = navigator.userAgent || '';
5+
if (/iPad|iPhone|iPod/i.test(ua)) return true;
6+
7+
// iPadOS 13+ reports itself as Macintosh; detect by touch capability.
8+
const platform = navigator.platform || '';
9+
const touchPoints = (navigator as unknown as { maxTouchPoints?: number }).maxTouchPoints ?? 0;
10+
return platform === 'MacIntel' && touchPoints > 1;
11+
}
12+
13+
export function isFeishuInAppBrowser(): boolean {
14+
if (typeof navigator === 'undefined') return false;
15+
const ua = navigator.userAgent || '';
16+
// Heuristic tokens seen in Feishu/Lark app webviews.
17+
return /feishu|lark|bytedancelark/i.test(ua);
18+
}
19+
20+
export function supportsWebGL(): boolean {
21+
if (typeof document === 'undefined') return false;
22+
try {
23+
const canvas = document.createElement('canvas');
24+
return Boolean(
25+
canvas.getContext('webgl2')
26+
|| canvas.getContext('webgl')
27+
|| canvas.getContext('experimental-webgl'),
28+
);
29+
} catch {
30+
return false;
31+
}
32+
}
33+
34+
/**
35+
* iOS in-app webviews are the most common source of "flash then white screen"
36+
* when heavy WebGL scenes initialize. Prefer 2D to keep the task detail usable.
37+
*/
38+
export function shouldPrefer2D(): boolean {
39+
if (!isProbablyIOS()) return false;
40+
return true;
41+
}
42+

0 commit comments

Comments
 (0)