@shellui/core 0.2.0-alpha.3 → 0.2.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/ContentView.tsx +60 -10
- package/src/components/LoadingOverlay.tsx +1 -1
- package/src/features/layouts/AppBarLayout.tsx +1 -2
- package/src/features/layouts/DefaultLayout.tsx +1 -2
- package/src/features/layouts/WindowsLayout.tsx +26 -5
- package/src/features/layouts/utils.ts +2 -7
- package/src/features/settings/SettingsProvider.tsx +13 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellui/core",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.4",
|
|
4
4
|
"description": "ShellUI Core - Core React application runtime",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"workbox-strategies": "^7.1.0",
|
|
59
59
|
"workbox-cacheable-response": "^7.1.0",
|
|
60
60
|
"workbox-expiration": "^7.1.0",
|
|
61
|
-
"@shellui/sdk": "0.2.0-alpha.
|
|
61
|
+
"@shellui/sdk": "0.2.0-alpha.4"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -30,9 +30,13 @@ export const ContentView = ({
|
|
|
30
30
|
const navigate = useNavigate();
|
|
31
31
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
32
32
|
const isInternalNavigation = useRef(false);
|
|
33
|
+
const cancelRevealRef = useRef<(() => void) | null>(null);
|
|
34
|
+
const mountTimeRef = useRef(Date.now());
|
|
33
35
|
const [initialUrl] = useState(url);
|
|
34
36
|
const [isLoading, setIsLoading] = useState(true);
|
|
35
37
|
|
|
38
|
+
const MIN_LOADING_MS = 80; // Don't reveal before this, reduces blink from theme/layout paint
|
|
39
|
+
|
|
36
40
|
useEffect(() => {
|
|
37
41
|
if (!iframeRef.current) {
|
|
38
42
|
return;
|
|
@@ -104,25 +108,63 @@ export const ContentView = ({
|
|
|
104
108
|
};
|
|
105
109
|
}, [pathPrefix, navigate]);
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
const scheduleReveal = (reveal: () => void) => {
|
|
112
|
+
const doReveal = () => {
|
|
113
|
+
const elapsed = Date.now() - mountTimeRef.current;
|
|
114
|
+
if (elapsed < MIN_LOADING_MS) {
|
|
115
|
+
const timer = setTimeout(doReveal, MIN_LOADING_MS - elapsed);
|
|
116
|
+
cancelRevealRef.current = () => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
cancelRevealRef.current = null;
|
|
119
|
+
};
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
reveal();
|
|
123
|
+
};
|
|
124
|
+
requestAnimationFrame(() => requestAnimationFrame(doReveal));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Hide loading overlay when iframe sends SHELLUI_INITIALIZED.
|
|
128
|
+
// Defer reveal (double rAF + min time) so the iframe has time to apply theme and paint.
|
|
108
129
|
useEffect(() => {
|
|
109
130
|
const cleanup = shellui.addMessageListener(
|
|
110
131
|
'SHELLUI_INITIALIZED',
|
|
111
132
|
(_data: ShellUIMessage, event: MessageEvent) => {
|
|
112
|
-
if (event.source
|
|
113
|
-
|
|
114
|
-
|
|
133
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
134
|
+
cancelRevealRef.current?.();
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
cancelRevealRef.current = () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
cancelRevealRef.current = null;
|
|
139
|
+
};
|
|
140
|
+
scheduleReveal(() => {
|
|
141
|
+
if (!cancelled) setIsLoading(false);
|
|
142
|
+
cancelRevealRef.current = null;
|
|
143
|
+
});
|
|
115
144
|
},
|
|
116
145
|
);
|
|
117
|
-
return () =>
|
|
146
|
+
return () => {
|
|
147
|
+
cancelRevealRef.current?.();
|
|
148
|
+
cancelRevealRef.current = null;
|
|
149
|
+
cleanup();
|
|
150
|
+
};
|
|
118
151
|
}, []);
|
|
119
152
|
|
|
120
|
-
// Fallback: hide overlay after 400ms if SHELLUI_INITIALIZED was not received
|
|
153
|
+
// Fallback: hide overlay after 400ms if SHELLUI_INITIALIZED was not received.
|
|
121
154
|
useEffect(() => {
|
|
122
155
|
if (!isLoading) return;
|
|
123
156
|
const timeoutId = setTimeout(() => {
|
|
124
157
|
logger.info('ContentView: Timeout expired, hiding loading overlay');
|
|
125
|
-
|
|
158
|
+
cancelRevealRef.current?.();
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
cancelRevealRef.current = () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
cancelRevealRef.current = null;
|
|
163
|
+
};
|
|
164
|
+
scheduleReveal(() => {
|
|
165
|
+
if (!cancelled) setIsLoading(false);
|
|
166
|
+
cancelRevealRef.current = null;
|
|
167
|
+
});
|
|
126
168
|
}, 400);
|
|
127
169
|
return () => clearTimeout(timeoutId);
|
|
128
170
|
}, [isLoading]);
|
|
@@ -130,11 +172,10 @@ export const ContentView = ({
|
|
|
130
172
|
// Handle external URL changes (e.g. from Sidebar)
|
|
131
173
|
useEffect(() => {
|
|
132
174
|
if (iframeRef.current && !isInternalNavigation.current) {
|
|
133
|
-
// Only update iframe src if it's actually different from its current src
|
|
134
|
-
// to avoid unnecessary reloads
|
|
135
175
|
if (iframeRef.current.src !== url) {
|
|
136
176
|
iframeRef.current.src = url;
|
|
137
177
|
setIsLoading(true);
|
|
178
|
+
mountTimeRef.current = Date.now(); // apply min delay for this load too
|
|
138
179
|
}
|
|
139
180
|
}
|
|
140
181
|
}, [url]);
|
|
@@ -234,19 +275,28 @@ export const ContentView = ({
|
|
|
234
275
|
}, []);
|
|
235
276
|
|
|
236
277
|
return (
|
|
237
|
-
<div
|
|
278
|
+
<div
|
|
279
|
+
style={{ width: '100%', height: '100%', display: 'flex', position: 'relative' }}
|
|
280
|
+
className="bg-background"
|
|
281
|
+
>
|
|
238
282
|
{/* Note: allow-same-origin is required for same-origin iframe content (e.g., Vite dev server, cookies, localStorage).
|
|
239
283
|
While this allows the iframe to remove its own sandboxing, it's acceptable here because the iframe content
|
|
240
284
|
is trusted microfrontend content from the same application origin.
|
|
241
285
|
Browser security warnings about this combination cannot be suppressed programmatically. */}
|
|
286
|
+
{/* Strategy to prevent browser deprioritizing iframe rendering:
|
|
287
|
+
- loading="eager" explicitly requests immediate loading (not deferred)
|
|
288
|
+
- opacity:0 hides the iframe during loading while keeping it in the rendering pipeline
|
|
289
|
+
- Reveal is instant (no transition) after deferred double-rAF to avoid blink */}
|
|
242
290
|
<iframe
|
|
243
291
|
ref={iframeRef}
|
|
244
292
|
src={initialUrl}
|
|
293
|
+
loading="eager"
|
|
245
294
|
style={{
|
|
246
295
|
width: '100%',
|
|
247
296
|
height: '100%',
|
|
248
297
|
border: 'none',
|
|
249
298
|
display: 'block',
|
|
299
|
+
opacity: isLoading ? 0 : 1,
|
|
250
300
|
}}
|
|
251
301
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
252
302
|
referrerPolicy="no-referrer-when-downgrade"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function LoadingOverlay() {
|
|
2
2
|
return (
|
|
3
|
-
<div className="absolute inset-0 z-10
|
|
3
|
+
<div className="absolute inset-x-0 top-0 z-10">
|
|
4
4
|
<div className="h-1 w-full overflow-hidden bg-muted/30">
|
|
5
5
|
<div
|
|
6
6
|
className="h-full w-0 bg-muted-foreground/50"
|
|
@@ -61,8 +61,7 @@ function TopBarEndItem({
|
|
|
61
61
|
const pathPrefix = getNavPathPrefix(item);
|
|
62
62
|
const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
|
|
63
63
|
const isExternal = item.openIn === 'external';
|
|
64
|
-
const isActive =
|
|
65
|
-
!isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
64
|
+
const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
66
65
|
|
|
67
66
|
const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(item.url) : null;
|
|
68
67
|
const iconSrc = item.icon ?? faviconUrl ?? null;
|
|
@@ -99,8 +99,7 @@ const NavigationContent = ({
|
|
|
99
99
|
const pathPrefix = getNavPathPrefix(navItem);
|
|
100
100
|
const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
|
|
101
101
|
const isExternal = navItem.openIn === 'external';
|
|
102
|
-
const isActive =
|
|
103
|
-
!isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
102
|
+
const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
104
103
|
const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
|
|
105
104
|
const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
|
|
106
105
|
const iconSrc = navItem.icon ?? faviconUrl ?? null;
|
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
useEffect,
|
|
7
7
|
type PointerEvent as ReactPointerEvent,
|
|
8
8
|
} from 'react';
|
|
9
|
+
import { useLocation } from 'react-router';
|
|
9
10
|
import { useTranslation } from 'react-i18next';
|
|
10
11
|
import { shellui } from '@shellui/sdk';
|
|
11
12
|
import type { NavigationItem, NavigationGroup } from '../config/types';
|
|
12
13
|
import {
|
|
13
14
|
flattenNavigationItems,
|
|
15
|
+
getActivePathPrefix,
|
|
14
16
|
getNavPathPrefix,
|
|
15
17
|
resolveLocalizedString as resolveNavLabel,
|
|
16
18
|
splitNavigationByPosition,
|
|
@@ -121,13 +123,16 @@ function AppWindow({
|
|
|
121
123
|
const resizeRafRef = useRef<number | null>(null);
|
|
122
124
|
const pendingResizeBoundsRef = useRef<WindowState['bounds'] | null>(null);
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
// Use a ref for onBoundsChange to avoid it in effect deps (prevents infinite render loop).
|
|
127
|
+
// The parent creates a new callback reference on every render (inline arrow), so including
|
|
128
|
+
// it in a dependency array would re-fire the effect every render, triggering a state update
|
|
129
|
+
// in the parent, which re-renders, which re-fires the effect → React error #185.
|
|
130
|
+
const onBoundsChangeRef = useRef(onBoundsChange);
|
|
131
|
+
onBoundsChangeRef.current = onBoundsChange;
|
|
127
132
|
|
|
128
133
|
useEffect(() => {
|
|
129
|
-
|
|
130
|
-
}, [bounds
|
|
134
|
+
onBoundsChangeRef.current(bounds);
|
|
135
|
+
}, [bounds]);
|
|
131
136
|
|
|
132
137
|
// When maximized, keep filling the viewport on window resize
|
|
133
138
|
useEffect(() => {
|
|
@@ -504,6 +509,7 @@ export function WindowsLayout({
|
|
|
504
509
|
logo: _logo,
|
|
505
510
|
navigation,
|
|
506
511
|
}: WindowsLayoutProps) {
|
|
512
|
+
const location = useLocation();
|
|
507
513
|
const { i18n } = useTranslation();
|
|
508
514
|
const { settings } = useSettings();
|
|
509
515
|
const currentLanguage = i18n.language || 'en';
|
|
@@ -523,6 +529,7 @@ export function WindowsLayout({
|
|
|
523
529
|
const [startMenuOpen, setStartMenuOpen] = useState(false);
|
|
524
530
|
const [now, setNow] = useState(() => new Date());
|
|
525
531
|
const startPanelRef = useRef<HTMLDivElement>(null);
|
|
532
|
+
const initialOpenFromUrlDoneRef = useRef(false);
|
|
526
533
|
|
|
527
534
|
// Update date/time every second for taskbar clock
|
|
528
535
|
useEffect(() => {
|
|
@@ -568,6 +575,20 @@ export function WindowsLayout({
|
|
|
568
575
|
[currentLanguage, windows.length],
|
|
569
576
|
);
|
|
570
577
|
|
|
578
|
+
// On first load only: open a window for the current URL if it matches a nav item (no reaction to later URL changes)
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
if (initialOpenFromUrlDoneRef.current) return;
|
|
581
|
+
initialOpenFromUrlDoneRef.current = true;
|
|
582
|
+
const pathname = location.pathname;
|
|
583
|
+
const windowableItems = navigationItems.filter(
|
|
584
|
+
(i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
|
|
585
|
+
);
|
|
586
|
+
const pathPrefix = getActivePathPrefix(pathname, windowableItems);
|
|
587
|
+
if (!pathPrefix) return;
|
|
588
|
+
const item = windowableItems.find((i) => getNavPathPrefix(i) === pathPrefix);
|
|
589
|
+
if (item) openWindow(item);
|
|
590
|
+
}, [location.pathname, navigationItems, openWindow]);
|
|
591
|
+
|
|
571
592
|
const closeWindow = useCallback((id: string) => {
|
|
572
593
|
setWindows((prev) => prev.filter((w) => w.id !== id));
|
|
573
594
|
setFrontWindowId((current) => (current === id ? null : current));
|
|
@@ -6,18 +6,13 @@ export function getNavPathPrefix(item: NavigationItem): string {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/** Among items that match the current pathname, return the longest path prefix. Used so only one nav item is active when URLs nest (e.g. /foo and /foo/bar). */
|
|
9
|
-
export function getActivePathPrefix(
|
|
10
|
-
pathname: string,
|
|
11
|
-
items: NavigationItem[],
|
|
12
|
-
): string | null {
|
|
9
|
+
export function getActivePathPrefix(pathname: string, items: NavigationItem[]): string | null {
|
|
13
10
|
const linkItems = items.filter(
|
|
14
11
|
(i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
|
|
15
12
|
);
|
|
16
13
|
const matching = linkItems
|
|
17
14
|
.map((i) => getNavPathPrefix(i))
|
|
18
|
-
.filter(
|
|
19
|
-
(p) => pathname === p || pathname.startsWith(p === '/' ? '/' : p + '/'),
|
|
20
|
-
);
|
|
15
|
+
.filter((p) => pathname === p || pathname.startsWith(p === '/' ? '/' : `${p}/`));
|
|
21
16
|
if (matching.length === 0) return null;
|
|
22
17
|
return matching.reduce((a, b) => (a.length >= b.length ? a : b));
|
|
23
18
|
}
|
|
@@ -65,8 +65,7 @@ function getResolvedAppearanceForSettings(
|
|
|
65
65
|
): Appearance | undefined {
|
|
66
66
|
if (typeof window === 'undefined') return undefined;
|
|
67
67
|
config?.themes?.forEach(registerTheme);
|
|
68
|
-
const themeName =
|
|
69
|
-
settings.appearance?.name || config?.defaultTheme || 'default';
|
|
68
|
+
const themeName = settings.appearance?.name || config?.defaultTheme || 'default';
|
|
70
69
|
const themeDef = getTheme(themeName) || getTheme('default');
|
|
71
70
|
if (!themeDef) return undefined;
|
|
72
71
|
const colorScheme = settings.appearance?.colorScheme ?? 'system';
|
|
@@ -135,13 +134,13 @@ function buildSettingsForPropagation(
|
|
|
135
134
|
};
|
|
136
135
|
}
|
|
137
136
|
if (config?.navigation?.length) {
|
|
138
|
-
const items: SettingsNavigationItem[] = flattenNavigationItems(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
const items: SettingsNavigationItem[] = flattenNavigationItems(config.navigation).map(
|
|
138
|
+
(item) => ({
|
|
139
|
+
path: item.path,
|
|
140
|
+
url: item.url,
|
|
141
|
+
label: resolveLabel(item.label, lang),
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
145
144
|
result = { ...result, navigation: { items } };
|
|
146
145
|
}
|
|
147
146
|
return result;
|
|
@@ -292,9 +291,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|
|
292
291
|
...defaultAppearance,
|
|
293
292
|
...parsed.appearance,
|
|
294
293
|
// Migrate from legacy theme/themeName
|
|
295
|
-
name:
|
|
294
|
+
name:
|
|
295
|
+
parsed.appearance?.name ?? parsed.appearance?.themeName ?? defaultAppearance.name,
|
|
296
296
|
colorScheme:
|
|
297
|
-
parsed.appearance?.colorScheme ??
|
|
297
|
+
parsed.appearance?.colorScheme ??
|
|
298
|
+
parsed.appearance?.theme ??
|
|
299
|
+
defaultAppearance.colorScheme,
|
|
298
300
|
colors: parsed.appearance?.colors ?? defaultAppearance.colors,
|
|
299
301
|
},
|
|
300
302
|
language: {
|