This commit is contained in:
87
src/routing/RouteImageReadyGate.test.ts
Normal file
87
src/routing/RouteImageReadyGate.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
collectRouteImageUrls,
|
||||
extractCssImageUrls,
|
||||
normalizePreloadImageUrl,
|
||||
} from './routeImageReadyGateUtils';
|
||||
import { RouteImageReadyGate } from './RouteImageReadyGate';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('RouteImageReadyGate image url helpers', () => {
|
||||
it('extracts urls from layered CSS image values', () => {
|
||||
expect(
|
||||
extractCssImageUrls(
|
||||
'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")',
|
||||
),
|
||||
).toEqual(['/hero.png', 'icons/card.webp']);
|
||||
});
|
||||
|
||||
it('normalizes preloadable urls against the current document', () => {
|
||||
expect(normalizePreloadImageUrl('/cover.png')).toBe(
|
||||
new URL('/cover.png', document.baseURI).href,
|
||||
);
|
||||
expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
expect(normalizePreloadImageUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('collects img and CSS background urls from a route root', () => {
|
||||
const root = document.createElement('section');
|
||||
root.innerHTML = `
|
||||
<img src="/images/card.png" />
|
||||
<div style='background-image: url("/images/bg.webp")'></div>
|
||||
<div style='border-image-source: url("/ui/frame.png")'></div>
|
||||
`;
|
||||
|
||||
expect(collectRouteImageUrls(root)).toEqual([
|
||||
new URL('/images/card.png', document.baseURI).href,
|
||||
new URL('/images/bg.webp', document.baseURI).href,
|
||||
new URL('/ui/frame.png', document.baseURI).href,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reveals route content after a short cap when images stay pending', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
createElement(
|
||||
RouteImageReadyGate,
|
||||
{
|
||||
eyebrow: '正在载入游戏',
|
||||
text: '正在载入冒险...',
|
||||
},
|
||||
createElement(
|
||||
'section',
|
||||
{
|
||||
'data-testid': 'route-content',
|
||||
},
|
||||
createElement('img', {
|
||||
src: '/generated-characters/slow-cover.png',
|
||||
alt: 'slow cover',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('route-content');
|
||||
const visibilityGate = content.parentElement;
|
||||
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(visibilityGate?.style.visibility).toBe('hidden');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(visibilityGate?.style.visibility).toBe('visible');
|
||||
});
|
||||
});
|
||||
230
src/routing/RouteImageReadyGate.tsx
Normal file
230
src/routing/RouteImageReadyGate.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
collectRouteImageUrls,
|
||||
hasCssImageUrlChange,
|
||||
type ImagePreloadResult,
|
||||
} from './routeImageReadyGateUtils';
|
||||
import { RouteLoadingScreen } from './RouteLoadingScreen';
|
||||
|
||||
type RouteImageReadyGateProps = {
|
||||
children: ReactNode;
|
||||
eyebrow: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const IMAGE_GATE_QUIET_MS = 140;
|
||||
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
|
||||
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
|
||||
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
|
||||
|
||||
const settledImageUrls = new Set<string>();
|
||||
const imagePreloadTasks = new Map<string, Promise<ImagePreloadResult>>();
|
||||
|
||||
function preloadImageUrl(url: string) {
|
||||
const existingTask = imagePreloadTasks.get(url);
|
||||
if (existingTask) {
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
const task = new Promise<ImagePreloadResult>((resolve) => {
|
||||
if (typeof Image === 'undefined') {
|
||||
resolve({ url, status: 'loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
|
||||
const settle = (status: ImagePreloadResult['status']) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
settledImageUrls.add(url);
|
||||
resolve({ url, status });
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
settle('timeout');
|
||||
}, IMAGE_PRELOAD_TIMEOUT_MS);
|
||||
|
||||
image.onload = () => settle('loaded');
|
||||
image.onerror = () => settle('failed');
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
|
||||
if (image.complete) {
|
||||
settle(image.naturalWidth > 0 ? 'loaded' : 'failed');
|
||||
}
|
||||
});
|
||||
|
||||
imagePreloadTasks.set(url, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
|
||||
* 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。
|
||||
*/
|
||||
export function RouteImageReadyGate({
|
||||
children,
|
||||
eyebrow,
|
||||
text,
|
||||
}: RouteImageReadyGateProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const startTimeRef = useRef(0);
|
||||
const scanTimerRef = useRef<number | null>(null);
|
||||
const revealTimerRef = useRef<number | null>(null);
|
||||
const scanVersionRef = useRef(0);
|
||||
const revealedRef = useRef(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
startTimeRef.current = window.performance.now();
|
||||
revealedRef.current = false;
|
||||
setReady(false);
|
||||
|
||||
const clearScanTimer = () => {
|
||||
if (scanTimerRef.current !== null) {
|
||||
window.clearTimeout(scanTimerRef.current);
|
||||
scanTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRevealTimer = () => {
|
||||
if (revealTimerRef.current !== null) {
|
||||
window.clearTimeout(revealTimerRef.current);
|
||||
revealTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleScan = () => {
|
||||
clearScanTimer();
|
||||
scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS);
|
||||
};
|
||||
|
||||
const scheduleReveal = (version: number) => {
|
||||
if (revealedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRevealTimer();
|
||||
|
||||
const elapsed = window.performance.now() - startTimeRef.current;
|
||||
const preferredDelay = Math.max(
|
||||
IMAGE_GATE_QUIET_MS,
|
||||
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
|
||||
);
|
||||
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
|
||||
const delay = Math.min(preferredDelay, maxRemainingDelay);
|
||||
|
||||
revealTimerRef.current = window.setTimeout(() => {
|
||||
if (disposed || version !== scanVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
revealedRef.current = true;
|
||||
setReady(true);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
function runScan() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = scanVersionRef.current + 1;
|
||||
scanVersionRef.current = version;
|
||||
const pendingUrls = collectRouteImageUrls(root).filter(
|
||||
(url) => !settledImageUrls.has(url),
|
||||
);
|
||||
|
||||
if (pendingUrls.length > 0) {
|
||||
// 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
|
||||
pendingUrls.forEach((url) => {
|
||||
void preloadImageUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReveal(version);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (disposed || revealedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRescan = mutations.some((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
return (
|
||||
mutation.addedNodes.length > 0 ||
|
||||
mutation.removedNodes.length > 0
|
||||
);
|
||||
}
|
||||
if (mutation.type !== 'attributes') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
mutation.attributeName === 'src' ||
|
||||
mutation.attributeName === 'srcset'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return mutation.attributeName === 'style'
|
||||
? hasCssImageUrlChange(mutation)
|
||||
: false;
|
||||
});
|
||||
|
||||
if (!shouldRescan) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRevealTimer();
|
||||
scheduleScan();
|
||||
});
|
||||
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'srcset', 'style'],
|
||||
attributeOldValue: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
scheduleScan();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
clearScanTimer();
|
||||
clearRevealTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-hidden={!ready}
|
||||
style={{
|
||||
visibility: ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!ready ? (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<RouteLoadingScreen eyebrow={eyebrow} text={text} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/routing/RouteLoadingScreen.tsx
Normal file
16
src/routing/RouteLoadingScreen.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function RouteLoadingScreen({
|
||||
eyebrow,
|
||||
text,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0d1016] px-6 text-zinc-200">
|
||||
<div className="text-center">
|
||||
<div className="text-sm tracking-[0.26em] text-zinc-500">{eyebrow}</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/routing/appPageRoutes.test.ts
Normal file
48
src/routing/appPageRoutes.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
isKnownMainAppPagePath,
|
||||
normalizeAppPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from './appPageRoutes';
|
||||
|
||||
describe('appPageRoutes', () => {
|
||||
it('normalizes page paths for stable matching', () => {
|
||||
expect(normalizeAppPath('')).toBe('/');
|
||||
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe('/creation/rpg/agent');
|
||||
});
|
||||
|
||||
it('resolves platform entry stages from independent paths', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/rpg/agent')).toBe(
|
||||
'agent-workspace',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
|
||||
'big-fish-result',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
|
||||
'puzzle-gallery-detail',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to platform for unknown paths inside the main app', () => {
|
||||
expect(resolveSelectionStageFromPath('/missing')).toBe('platform');
|
||||
});
|
||||
|
||||
it('resolves paths from selection stages', () => {
|
||||
expect(resolvePathForSelectionStage('custom-world-generating')).toBe(
|
||||
'/creation/rpg/generating',
|
||||
);
|
||||
expect(resolvePathForSelectionStage('puzzle-runtime')).toBe(
|
||||
'/runtime/puzzle',
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes runtime pages as main app pages', () => {
|
||||
expect(
|
||||
isKnownMainAppPagePath(APP_RUNTIME_ROUTES['rpg-character-select']),
|
||||
).toBe(true);
|
||||
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
|
||||
});
|
||||
});
|
||||
69
src/routing/appPageRoutes.ts
Normal file
69
src/routing/appPageRoutes.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { SelectionStage } from '../components/platform-entry';
|
||||
|
||||
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
|
||||
|
||||
const STAGE_ROUTE_ENTRIES = [
|
||||
['platform', '/'],
|
||||
['detail', '/worlds/detail'],
|
||||
['agent-workspace', '/creation/rpg/agent'],
|
||||
['custom-world-generating', '/creation/rpg/generating'],
|
||||
['custom-world-result', '/creation/rpg/result'],
|
||||
['big-fish-agent-workspace', '/creation/big-fish/agent'],
|
||||
['big-fish-result', '/creation/big-fish/result'],
|
||||
['big-fish-runtime', '/runtime/big-fish'],
|
||||
['puzzle-agent-workspace', '/creation/puzzle/agent'],
|
||||
['puzzle-result', '/creation/puzzle/result'],
|
||||
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
|
||||
['puzzle-runtime', '/runtime/puzzle'],
|
||||
] as const satisfies readonly (readonly [SelectionStage, string])[];
|
||||
|
||||
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
|
||||
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
|
||||
|
||||
export const APP_RUNTIME_ROUTES: Record<RuntimePageRoute, string> = {
|
||||
'rpg-character-select': '/runtime/rpg/characters',
|
||||
'rpg-adventure': '/runtime/rpg/adventure',
|
||||
};
|
||||
|
||||
const ROUTE_STAGE_BY_PATH = new Map(
|
||||
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
|
||||
) as Map<string, SelectionStage>;
|
||||
|
||||
export function normalizeAppPath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
|
||||
if (!trimmedPathname || trimmedPathname === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return trimmedPathname.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
export function resolveSelectionStageFromPath(
|
||||
pathname: string,
|
||||
): SelectionStage {
|
||||
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
|
||||
}
|
||||
|
||||
export function resolvePathForSelectionStage(stage: SelectionStage) {
|
||||
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
|
||||
}
|
||||
|
||||
export function isKnownMainAppPagePath(pathname: string) {
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
|
||||
return (
|
||||
ROUTE_STAGE_BY_PATH.has(normalizedPath) ||
|
||||
runtimePaths.includes(normalizedPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function pushAppHistoryPath(path: string) {
|
||||
const normalizedPath = normalizeAppPath(path);
|
||||
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
||||
window.history.pushState(null, '', normalizedPath);
|
||||
}
|
||||
53
src/routing/appRoutes.test.ts
Normal file
53
src/routing/appRoutes.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { matchAppRoute } from './appRoutes';
|
||||
|
||||
describe('matchAppRoute', () => {
|
||||
it('routes the main app by default', () => {
|
||||
expect(matchAppRoute('/')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes puzzle playground path to the standalone puzzle runtime', () => {
|
||||
expect(matchAppRoute('/puzzle')).toEqual({
|
||||
kind: 'puzzle-playground',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes big fish playground path to the standalone big fish runtime', () => {
|
||||
expect(matchAppRoute('/BIG-FISH/')).toEqual({
|
||||
kind: 'big-fish-playground',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes former standalone editor paths back to the main game', () => {
|
||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
expect(matchAppRoute('/behavior-editor')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes independent page paths to the main app shell', () => {
|
||||
expect(matchAppRoute('/creation/rpg/agent')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
expect(matchAppRoute('/runtime/puzzle')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
expect(matchAppRoute('/runtime/big-fish')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat unrelated prefixes as preset editor routes', () => {
|
||||
expect(matchAppRoute('/npc-editorial')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/routing/appRoutes.tsx
Normal file
85
src/routing/appRoutes.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
|
||||
|
||||
import { normalizeAppPath } from './appPageRoutes';
|
||||
|
||||
type AppRouteComponent = LazyExoticComponent<
|
||||
ComponentType<Record<string, unknown>>
|
||||
>;
|
||||
|
||||
export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'puzzle-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'game';
|
||||
};
|
||||
|
||||
export type ResolvedAppRoute = {
|
||||
kind: AppRouteMatch['kind'];
|
||||
loadingEyebrow: string;
|
||||
loadingText: string;
|
||||
Component: AppRouteComponent;
|
||||
componentProps?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
||||
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
||||
|
||||
function normalizeRoutePath(pathname: string) {
|
||||
return normalizeAppPath(pathname);
|
||||
}
|
||||
|
||||
export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
const normalizedPath = normalizeRoutePath(pathname);
|
||||
|
||||
if (normalizedPath === '/puzzle') {
|
||||
return {
|
||||
kind: 'puzzle-playground',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath === '/big-fish') {
|
||||
return {
|
||||
kind: 'big-fish-playground',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
const matchedRoute = matchAppRoute(pathname);
|
||||
|
||||
if (matchedRoute.kind === 'puzzle-playground') {
|
||||
return {
|
||||
kind: 'puzzle-playground',
|
||||
loadingEyebrow: '正在载入拼图',
|
||||
loadingText: '正在进入拼图关卡...',
|
||||
Component: PuzzlePlaygroundApp,
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'big-fish-playground') {
|
||||
return {
|
||||
kind: 'big-fish-playground',
|
||||
loadingEyebrow: '正在载入大鱼',
|
||||
loadingText: '正在进入玩法...',
|
||||
Component: BigFishPlaygroundApp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
loadingText: '正在载入冒险...',
|
||||
Component: GameApp,
|
||||
};
|
||||
}
|
||||
111
src/routing/routeImageReadyGateUtils.ts
Normal file
111
src/routing/routeImageReadyGateUtils.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
const CSS_IMAGE_URL_PATTERN =
|
||||
/url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu;
|
||||
|
||||
export type ImagePreloadResult = {
|
||||
url: string;
|
||||
status: 'loaded' | 'failed' | 'timeout';
|
||||
};
|
||||
|
||||
export function extractCssImageUrls(value: string) {
|
||||
const urls: string[] = [];
|
||||
CSS_IMAGE_URL_PATTERN.lastIndex = 0;
|
||||
|
||||
let match = CSS_IMAGE_URL_PATTERN.exec(value);
|
||||
while (match) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? '';
|
||||
const normalizedRawUrl = rawUrl.trim();
|
||||
if (normalizedRawUrl) {
|
||||
urls.push(normalizedRawUrl);
|
||||
}
|
||||
match = CSS_IMAGE_URL_PATTERN.exec(value);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function normalizePreloadImageUrl(rawUrl: string) {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedUrl.startsWith('data:') ||
|
||||
trimmedUrl.startsWith('blob:') ||
|
||||
trimmedUrl.startsWith('http://') ||
|
||||
trimmedUrl.startsWith('https://')
|
||||
) {
|
||||
return trimmedUrl;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof document === 'undefined'
|
||||
? 'http://localhost/'
|
||||
: document.baseURI;
|
||||
|
||||
try {
|
||||
return new URL(trimmedUrl, baseUrl).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addNormalizedImageUrl(urls: Set<string>, rawUrl: string | null) {
|
||||
if (!rawUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizePreloadImageUrl(rawUrl);
|
||||
if (normalizedUrl) {
|
||||
urls.add(normalizedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCssImageUrlChange(mutation: MutationRecord) {
|
||||
if (!(mutation.target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousUrls = mutation.oldValue
|
||||
? extractCssImageUrls(mutation.oldValue)
|
||||
: [];
|
||||
const currentUrls = [
|
||||
...extractCssImageUrls(mutation.target.style.backgroundImage),
|
||||
...extractCssImageUrls(mutation.target.style.borderImageSource),
|
||||
...extractCssImageUrls(mutation.target.style.listStyleImage),
|
||||
];
|
||||
|
||||
if (previousUrls.length !== currentUrls.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentUrls.some((url, index) => url !== previousUrls[index]);
|
||||
}
|
||||
|
||||
export function collectRouteImageUrls(root: HTMLElement) {
|
||||
const urls = new Set<string>();
|
||||
const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
|
||||
|
||||
root.querySelectorAll<HTMLImageElement>('img').forEach((image) => {
|
||||
addNormalizedImageUrl(urls, image.currentSrc);
|
||||
addNormalizedImageUrl(urls, image.getAttribute('src'));
|
||||
});
|
||||
|
||||
elements.forEach((element) => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
[
|
||||
element.style.backgroundImage,
|
||||
element.style.borderImageSource,
|
||||
element.style.listStyleImage,
|
||||
computedStyle.backgroundImage,
|
||||
computedStyle.borderImageSource,
|
||||
computedStyle.listStyleImage,
|
||||
].forEach((cssImageValue) => {
|
||||
extractCssImageUrls(cssImageValue).forEach((url) => {
|
||||
addNormalizedImageUrl(urls, url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
Reference in New Issue
Block a user