);
}
function formatPkgName(name: string): string {
return name.replace('@ifc-lite/', '');
}
type TimelineEntry = {
version: string;
isViewerVersion: boolean;
entries: Array<{ pkg: string; highlights: typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'] }>;
};
const compareSemver = (a: string, b: string) => {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
};
/** Merge all per-package changelogs into a unified timeline grouped by version. */
function buildTimeline(
packageChangelogs: typeof __RELEASE_HISTORY__,
viewerVersion: string
): TimelineEntry[] {
type Highlights = typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'];
const versionMap = new Map>();
for (const pkg of packageChangelogs) {
for (const release of pkg.releases) {
if (!versionMap.has(release.version)) {
versionMap.set(release.version, new Map());
}
versionMap.get(release.version)!.set(pkg.name, release.highlights);
}
}
return Array.from(versionMap.entries())
.sort(([a], [b]) => compareSemver(a, b))
.map(([version, pkgMap]) => ({
version,
isViewerVersion: version === viewerVersion,
entries: Array.from(pkgMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([pkg, highlights]) => ({ pkg, highlights })),
}));
}
function WhatsNewTab() {
const packageChangelogs = __RELEASE_HISTORY__;
const viewerVersion = __APP_VERSION__;
const [expandedVersions, setExpandedVersions] = useState>(() => new Set());
const timeline = useMemo(
() => buildTimeline(packageChangelogs, viewerVersion),
[packageChangelogs, viewerVersion]
);
// Auto-expand the first version with actual changes
useEffect(() => {
if (timeline.length > 0 && expandedVersions.size === 0) {
setExpandedVersions(new Set([timeline[0].version]));
}
}, [timeline]);
const toggleVersion = useCallback((version: string) => {
setExpandedVersions((prev) => {
const next = new Set(prev);
if (next.has(version)) next.delete(version);
else next.add(version);
return next;
});
}, []);
if (timeline.length === 0) {
return (
No release history available.
);
}
return (
{/* Anchor the current app version. The rows below carry each package's
own independent version (e.g. parser may be ahead of the viewer), so
without this the highest row reads as "the version" โ issue #1107,
item 2. */}
You’re on viewer v{viewerVersion}
rows below are per-package releases
);
}
function ShortcutsTab() {
// Group shortcuts by category
const grouped = KEYBOARD_SHORTCUTS.reduce(
(acc, shortcut) => {
if (!acc[shortcut.category]) {
acc[shortcut.category] = [];
}
acc[shortcut.category].push(shortcut);
return acc;
},
{} as Record
);
return (
{/* Learn-more row: drives discovery to the marketing site and the github.io
docs. Sits above the shortcut groups so it's the first thing users hunting
for help see, without crowding the keyboard reference itself. */}
);
}
export function KeyboardShortcutsDialog({ open, onClose }: InfoDialogProps) {
// Close on escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
{/* Header โ the MCP CTA lives here, in line with the title, so
it's a discoverable "what else can this do?" affordance
without crowding the modeling toolbar. */}
Info
{/* Tabbed Content */}
About
What's New
Shortcuts
{/* Footer */}
Press{' '}
?
{' '}
to toggle this panel
);
}
// Hook to manage info dialog state (renamed export for backward compatibility)
export function useKeyboardShortcutsDialog() {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
// Listen for '?' key to toggle
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input or textarea
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
e.preventDefault();
toggle();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggle]);
return { open, toggle, close };
}