@flamingo-stack/openframe-frontend-core 0.0.848 → 0.0.849
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/dist/build/export-map.json +1 -1
- package/dist/{chunk-Y4BG7SPX.cjs → chunk-36XWCVSM.cjs} +63 -63
- package/dist/{chunk-YXKAHQYS.cjs → chunk-533QK4R6.cjs} +2 -1
- package/dist/{chunk-5HOWR4TG.cjs → chunk-5CBI2ZYV.cjs} +5 -5
- package/dist/{chunk-NYT6IBIX.cjs → chunk-5PIR77V5.cjs} +4 -4
- package/dist/{chunk-IIUZE73M.cjs → chunk-6AJYGXXS.cjs} +14 -14
- package/dist/{chunk-U7UBYJEZ.cjs → chunk-BU2PSRAS.cjs} +5 -5
- package/dist/{chunk-I3YRYS23.cjs → chunk-C3P7IUTU.cjs} +37 -12
- package/dist/{chunk-O4BXHCMK.cjs → chunk-FRG724DU.cjs} +2 -2
- package/dist/{chunk-YAW7CHLV.cjs → chunk-FXLROQUT.cjs} +41 -38
- package/dist/{chunk-7YJQTQPV.cjs → chunk-IG6ROHPS.cjs} +46 -46
- package/dist/{chunk-INA2FX2N.cjs → chunk-IHY7E4EX.cjs} +2 -2
- package/dist/{chunk-Y2ZWWIUA.cjs → chunk-IN3LA2FU.cjs} +2 -2
- package/dist/{chunk-7LE4HWHA.cjs → chunk-KXBZCUDW.cjs} +11 -11
- package/dist/{chunk-C3WZXOZJ.cjs → chunk-MC3L525K.cjs} +3 -3
- package/dist/{chunk-7DW3LZ52.cjs → chunk-PV2XQJ2R.cjs} +92 -171
- package/dist/{chunk-GASFYPGH.cjs → chunk-R5JPZ6TY.cjs} +14 -14
- package/dist/{chunk-MWYEROAP.cjs → chunk-SHQY4GOE.cjs} +55 -55
- package/dist/{chunk-VW4446JR.cjs → chunk-TEKN6TJ5.cjs} +14 -14
- package/dist/{chunk-QUSCSYHJ.cjs → chunk-TX4NTH22.cjs} +45 -26
- package/dist/{chunk-2V6FCVUL.cjs → chunk-YLETC7LP.cjs} +187 -169
- package/dist/{chunk-OCJXOSIH.cjs → chunk-YYHQY3K6.cjs} +30 -30
- package/dist/{chunk-74Z34I2C.cjs → chunk-Z5UCDEKZ.cjs} +4 -4
- package/dist/{chunk-IUZVOQXP.cjs → chunk-ZY34R37A.cjs} +45 -45
- package/dist/{cloud-tenants-YAZHLPOQ.cjs → cloud-tenants-HKUUEUAY.cjs} +7 -7
- package/dist/components/case-studies/index.cjs +21 -21
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/dispatch.js +1 -0
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/docs/doc-viewer.d.ts +7 -7
- package/dist/components/docs/doc-viewer.d.ts.map +1 -1
- package/dist/components/docs/doc-viewer.js +29 -16
- package/dist/components/docs/docs-hub-page.d.ts +1 -1
- package/dist/components/docs/docs-hub-page.d.ts.map +1 -1
- package/dist/components/docs/docs-hub-page.js +4 -11
- package/dist/components/docs/index.cjs +11 -11
- package/dist/components/docs/use-document-tree.d.ts.map +1 -1
- package/dist/components/docs/use-document-tree.js +24 -24
- package/dist/components/embeds/claude-embed.d.ts +2 -2
- package/dist/components/embeds/claude-embed.d.ts.map +1 -1
- package/dist/components/embeds/claude-embed.js +3 -3
- package/dist/components/embeds/embed-iframe.d.ts +5 -3
- package/dist/components/embeds/embed-iframe.d.ts.map +1 -1
- package/dist/components/embeds/embed-iframe.js +4 -3
- package/dist/components/embeds/embed-viewer-frame.d.ts +12 -9
- package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
- package/dist/components/embeds/embed-viewer-frame.js +21 -7
- package/dist/components/embeds/figma-embed.d.ts +4 -2
- package/dist/components/embeds/figma-embed.d.ts.map +1 -1
- package/dist/components/embeds/figma-embed.js +4 -3
- package/dist/components/embeds/file-download-card.d.ts +3 -1
- package/dist/components/embeds/file-download-card.d.ts.map +1 -1
- package/dist/components/embeds/file-download-card.js +18 -3
- package/dist/components/embeds/google-sheets-viewer.d.ts +3 -1
- package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -1
- package/dist/components/embeds/google-sheets-viewer.js +7 -4
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/embeds/pdf-viewer.d.ts +3 -1
- package/dist/components/embeds/pdf-viewer.d.ts.map +1 -1
- package/dist/components/embeds/pdf-viewer.js +6 -2
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/features/index.cjs +7 -7
- package/dist/components/help-center-pages/index.cjs +81 -81
- package/dist/components/index.cjs +49 -49
- package/dist/components/meeting-scheduler/index.cjs +32 -32
- package/dist/components/navigation/index.cjs +10 -10
- package/dist/components/onboarding-guides/index.cjs +10 -10
- package/dist/components/product-demo/index.cjs +16 -16
- package/dist/components/related-content/index.cjs +4 -4
- package/dist/components/tickets/index.cjs +12 -12
- package/dist/components/ui/index.cjs +7 -7
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.js +7 -3
- package/dist/{devices-IZROCLFK.cjs → devices-P7M4Z7VP.cjs} +8 -8
- package/dist/{embeddable-chat-ETW6S22E.cjs → embeddable-chat-322NDFP5.cjs} +74 -74
- package/dist/index.cjs +15 -11
- package/dist/{knowledge-4IRWLTNW.cjs → knowledge-TC7JLOWI.cjs} +8 -8
- package/dist/{logs-EFDGY5IO.cjs → logs-GPOYCP4B.cjs} +14 -14
- package/dist/{policies-3KEXZ5BP.cjs → policies-ZUHOUMDD.cjs} +11 -11
- package/dist/{scripts-PCGTZLZL.cjs → scripts-FOEVDGTH.cjs} +12 -12
- package/dist/{software-update-YCDNVFJE.cjs → software-update-XGTMNFST.cjs} +10 -10
- package/dist/types/doc-source.d.ts +2 -2
- package/dist/types/doc-source.d.ts.map +1 -1
- package/dist/utils/doc-tree-nav.d.ts +7 -0
- package/dist/utils/doc-tree-nav.d.ts.map +1 -1
- package/dist/utils/doc-tree-nav.js +19 -0
- package/dist/utils/index.cjs +25 -2
- package/dist/utils/list-url.d.ts.map +1 -1
- package/dist/utils/list-url.js +2 -1
- package/dist/utils/source-grouping.d.ts +2 -2
- package/dist/utils/source-grouping.d.ts.map +1 -1
- package/dist/utils/source-grouping.js +1 -1
- package/dist/utils/source-icons.d.ts.map +1 -1
- package/dist/utils/source-icons.js +3 -0
- package/package.json +1 -1
- package/src/components/chat/entity-cards/dispatch.tsx +3 -0
- package/src/components/docs/doc-viewer.tsx +56 -35
- package/src/components/docs/docs-hub-page.tsx +13 -37
- package/src/components/docs/use-document-tree.ts +33 -23
- package/src/components/embeds/claude-embed.tsx +4 -4
- package/src/components/embeds/embed-iframe.tsx +8 -5
- package/src/components/embeds/embed-viewer-frame.tsx +35 -15
- package/src/components/embeds/figma-embed.tsx +9 -4
- package/src/components/embeds/file-download-card.tsx +28 -4
- package/src/components/embeds/google-sheets-viewer.tsx +9 -3
- package/src/components/embeds/pdf-viewer.tsx +9 -2
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +4 -0
- package/src/types/doc-source.ts +2 -2
- package/src/utils/doc-tree-nav.ts +27 -0
- package/src/utils/list-url.ts +2 -1
- package/src/utils/source-grouping.ts +2 -2
- package/src/utils/source-icons.ts +3 -0
- package/dist/components/docs/skeletons.d.ts +0 -25
- package/dist/components/docs/skeletons.d.ts.map +0 -1
- package/dist/components/docs/skeletons.js +0 -83
- package/src/components/docs/skeletons.tsx +0 -131
|
@@ -4,15 +4,15 @@ import type React from 'react';
|
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
import { useRouter } from '../../embed-shims/next-navigation';
|
|
6
6
|
import type { DocContent, DocNode, DocRenderHandlers, DocSourceId } from '../../types/doc-source';
|
|
7
|
-
import { findDocNodeByPath } from '../../utils/doc-tree-nav';
|
|
7
|
+
import { findDocNodeByPath, rootDocumentPath } from '../../utils/doc-tree-nav';
|
|
8
8
|
import { PageShell } from '../layout/article-detail-layout';
|
|
9
9
|
import { PageLayout } from '../layout/page-layout';
|
|
10
|
-
import { UnifiedSkeleton } from '../loading/unified-skeleton';
|
|
11
10
|
import { MultiLevelNavigation, MobileNavigationDropdown } from '../navigation/multi-level-navigation';
|
|
12
11
|
import { StickySectionNav } from '../navigation/sticky-section-nav';
|
|
13
12
|
import { PersistentSidebar, PersistentMobileDropdown } from '../persistent-filter-controls';
|
|
14
13
|
import { DocSearchBar, useDocSearch } from '../shared/doc-search';
|
|
15
14
|
import { DataSyncLine } from '../ui/data-attribution';
|
|
15
|
+
import { LoadingScope } from '../ui/loading-scope';
|
|
16
16
|
import { useDocNavigation } from './doc-navigation-context';
|
|
17
17
|
import { useDocsResolveLink } from './use-docs-resolve-link';
|
|
18
18
|
import { useDocumentTree } from './use-document-tree';
|
|
@@ -46,13 +46,6 @@ export interface DocViewerProps {
|
|
|
46
46
|
*/
|
|
47
47
|
renderContent: (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Render the loading skeleton. Receives the selected node's `documentType`
|
|
51
|
-
* (undefined while structure is still loading) so the caller can return a
|
|
52
|
-
* markdown-shaped skeleton vs an embed-shaped skeleton.
|
|
53
|
-
*/
|
|
54
|
-
renderSkeleton: (documentType: DocNode['documentType']) => React.ReactNode;
|
|
55
|
-
|
|
56
49
|
/**
|
|
57
50
|
* Chat-source identifier — passed in by the page shell from server-side
|
|
58
51
|
* `currentPlatform()`. Lib has no platform context; the page shell is the
|
|
@@ -88,6 +81,13 @@ export interface DocViewerProps {
|
|
|
88
81
|
/** Initial doc path (URL `[...path]`). */
|
|
89
82
|
docPath?: string;
|
|
90
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The type of the document at `docPath`, when the page knows it before the tree is read (the host read it on the
|
|
86
|
+
* server). The content's loading state takes its shape from it on a page's first load (a Figma deck draws the embed
|
|
87
|
+
* skeleton, not a text page); once the tree is read, the selected node's own type decides.
|
|
88
|
+
*/
|
|
89
|
+
initialDocumentType?: DocNode['documentType'];
|
|
90
|
+
|
|
91
91
|
/** Sidebar header copy (`'DOCUMENTATION'`, `'DATA ROOM'`). */
|
|
92
92
|
sidebarLabel?: string;
|
|
93
93
|
|
|
@@ -113,7 +113,7 @@ export interface DocViewerProps {
|
|
|
113
113
|
/** Base route path for URL navigation. */
|
|
114
114
|
baseRoute: string;
|
|
115
115
|
|
|
116
|
-
/**
|
|
116
|
+
/** What the viewer says when the source holds no documents yet (default "No documents yet."). */
|
|
117
117
|
emptyStateText?: string;
|
|
118
118
|
|
|
119
119
|
/** Whether to render the doc-search bar (bound to chat). */
|
|
@@ -141,12 +141,13 @@ export function DocViewer(props: DocViewerProps) {
|
|
|
141
141
|
|
|
142
142
|
/** The "On this page" column's heading: static copy, drawn for real loaded and loading. */
|
|
143
143
|
const ON_THIS_PAGE_LABEL = 'ON THIS PAGE';
|
|
144
|
+
/** The loading section list's click handler: it has no sections to click. */
|
|
145
|
+
const NO_SECTION_CLICK = () => {};
|
|
144
146
|
const ON_THIS_PAGE_HEADING_CLASS = 'mb-4 font-semibold text-ods-text-secondary text-h5';
|
|
145
147
|
|
|
146
148
|
function DocViewerContent({
|
|
147
149
|
sourceId,
|
|
148
150
|
renderContent,
|
|
149
|
-
renderSkeleton,
|
|
150
151
|
chatSource,
|
|
151
152
|
title,
|
|
152
153
|
subtitle,
|
|
@@ -154,6 +155,7 @@ function DocViewerContent({
|
|
|
154
155
|
className = '',
|
|
155
156
|
shell = true,
|
|
156
157
|
docPath,
|
|
158
|
+
initialDocumentType,
|
|
157
159
|
sidebarLabel = 'DOCUMENTATION',
|
|
158
160
|
structureEndpoint,
|
|
159
161
|
contentEndpoint,
|
|
@@ -246,11 +248,31 @@ function DocViewerContent({
|
|
|
246
248
|
// - the article max-width + sticky-nav visibility (markdown only)
|
|
247
249
|
// `undefined` documentType is treated as `'markdown'` (per the DocNode
|
|
248
250
|
// discriminator's documented default).
|
|
251
|
+
// Before the tree is read, the host's `initialDocumentType` (the page's own document) stands in. Once it is read, the
|
|
252
|
+
// root takes the type of the document it shows (`rootDocumentPath`), so a root Figma or PDF never flips to the text
|
|
253
|
+
// layout between the tree arriving and the document arriving.
|
|
254
|
+
const selectedNodePath =
|
|
255
|
+
structure.length > 0 && !selectedPath ? rootDocumentPath(structure, folderIndexFile) : selectedPath;
|
|
249
256
|
const selectedNodeDocType =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
structure.length > 0
|
|
258
|
+
? selectedNodePath
|
|
259
|
+
? findDocNodeByPath(selectedNodePath, structure)?.documentType
|
|
260
|
+
: undefined
|
|
261
|
+
: initialDocumentType;
|
|
262
|
+
// While a document loads, `content` is still the PREVIOUS document's: the layout (the "On this page" column, the
|
|
263
|
+
// article width) follows the document being opened, never the one being left, or opening a Figma deck from a text
|
|
264
|
+
// page draws the text page's column beside the deck's skeleton. Loaded, the content's own type decides.
|
|
265
|
+
const isLoadingDocument = isLoadingContent || isLoadingStructure;
|
|
266
|
+
// While a document loads: its renderer with an EMPTY document of its type (the selected node's, or the host's
|
|
267
|
+
// `initialDocumentType` before the tree is read), drawn in a loading scope, so every renderer shows its own loading
|
|
268
|
+
// form.
|
|
269
|
+
const loadingContent = isLoadingDocument
|
|
270
|
+
? renderContent(
|
|
271
|
+
{ content: '', sections: [], path: selectedPath ?? '', documentType: selectedNodeDocType },
|
|
272
|
+
{ onInternalLinkClick: navigateToDoc, currentPath: selectedPath ?? '', sourceId },
|
|
273
|
+
)
|
|
274
|
+
: null;
|
|
275
|
+
const activeDocType = isLoadingDocument ? selectedNodeDocType : (content?.documentType ?? selectedNodeDocType);
|
|
254
276
|
const isMarkdownContent = !activeDocType || activeDocType === 'markdown';
|
|
255
277
|
const showStickyNav = isMarkdownContent;
|
|
256
278
|
|
|
@@ -266,11 +288,10 @@ function DocViewerContent({
|
|
|
266
288
|
const containerBgStyle =
|
|
267
289
|
colorPalette.containerBackground !== 'transparent' ? { backgroundColor: colorPalette.containerBackground } : {};
|
|
268
290
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const resolvedEmptyText = emptyStateText || defaultEmptyText;
|
|
291
|
+
// A source with documents and none open asks for one; an empty source says so in the host's words (a reader may
|
|
292
|
+
// not be the one who adds documents, so the default names no admin screen).
|
|
293
|
+
const resolvedEmptyText =
|
|
294
|
+
structure.length > 0 ? 'Select a document from the sidebar to view' : emptyStateText || 'No documents yet.';
|
|
274
295
|
|
|
275
296
|
// Unified header: title/subtitle route through the canonical (frozen)
|
|
276
297
|
// `PageLayout` `TitleBlock` (text-h2) — same as every other help-center page —
|
|
@@ -365,8 +386,10 @@ function DocViewerContent({
|
|
|
365
386
|
(app-globals.css) so content wraps instead of overflowing. */}
|
|
366
387
|
<div className={`w-full min-w-0 ${isMarkdownContent ? 'mx-auto max-w-4xl' : ''}`}>
|
|
367
388
|
<article className="space-y-2">
|
|
368
|
-
{
|
|
369
|
-
|
|
389
|
+
{isLoadingDocument ? (
|
|
390
|
+
// The document being opened draws its own loading form: its renderer, in a loading scope,
|
|
391
|
+
// with an empty document of its type (no words, no address), never a skeleton picked here.
|
|
392
|
+
<LoadingScope>{loadingContent}</LoadingScope>
|
|
370
393
|
) : !content ? (
|
|
371
394
|
<div className="py-16 text-center">
|
|
372
395
|
<p className="text-ods-text-secondary text-h4">{resolvedEmptyText}</p>
|
|
@@ -377,22 +400,20 @@ function DocViewerContent({
|
|
|
377
400
|
</article>
|
|
378
401
|
</div>
|
|
379
402
|
|
|
380
|
-
{isMarkdownContent &&
|
|
403
|
+
{isMarkdownContent && isLoadingDocument && (
|
|
381
404
|
<div className="hidden content-lg:block">
|
|
382
405
|
<div className="sticky top-24">
|
|
383
406
|
<h3 className={ON_THIS_PAGE_HEADING_CLASS}>{ON_THIS_PAGE_LABEL}</h3>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
))}
|
|
395
|
-
</div>
|
|
407
|
+
{/* The section list's own loading form (`StickySectionNav` with no sections, in a scope). */}
|
|
408
|
+
<LoadingScope>
|
|
409
|
+
<StickySectionNav
|
|
410
|
+
sections={[]}
|
|
411
|
+
activeSection=""
|
|
412
|
+
onSectionClick={NO_SECTION_CLICK}
|
|
413
|
+
ribbonPosition="left"
|
|
414
|
+
ribbonColor="var(--ods-accent)"
|
|
415
|
+
/>
|
|
416
|
+
</LoadingScope>
|
|
396
417
|
</div>
|
|
397
418
|
</div>
|
|
398
419
|
)}
|
|
@@ -5,7 +5,6 @@ import { FileDownloadCard } from '../embeds/file-download-card';
|
|
|
5
5
|
import { GoogleSheetsViewer } from '../embeds/google-sheets-viewer';
|
|
6
6
|
import { PdfViewer } from '../embeds/pdf-viewer';
|
|
7
7
|
import { DocViewer, type DocViewerProps } from './doc-viewer';
|
|
8
|
-
import { MarkdownSkeleton, EmbedSkeleton } from './skeletons';
|
|
9
8
|
|
|
10
9
|
type DocRenderer = (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;
|
|
11
10
|
|
|
@@ -20,7 +19,7 @@ type DocRenderer = (content: DocContent, handlers: DocRenderHandlers) => React.R
|
|
|
20
19
|
*/
|
|
21
20
|
export type DocumentTypeRenderers = { markdown: DocRenderer } & Partial<Record<DocumentType, DocRenderer>>;
|
|
22
21
|
|
|
23
|
-
export interface DocsHubPageProps extends Omit<DocViewerProps, 'renderContent' | '
|
|
22
|
+
export interface DocsHubPageProps extends Omit<DocViewerProps, 'renderContent' | 'showAIChat'> {
|
|
24
23
|
/** Per-document-type renderer map. `markdown` is REQUIRED. */
|
|
25
24
|
documentTypeRenderers: DocumentTypeRenderers;
|
|
26
25
|
|
|
@@ -42,32 +41,20 @@ const defaultFallbackRenderer: DocRenderer = () => (
|
|
|
42
41
|
</div>
|
|
43
42
|
);
|
|
44
43
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const defaultPdfRenderer: DocRenderer = (content, handlers) =>
|
|
52
|
-
content.fileUrl ? (
|
|
53
|
-
<PdfViewer src={content.fileUrl} fileName={content.fileName} />
|
|
54
|
-
) : (
|
|
55
|
-
defaultFallbackRenderer(content, handlers)
|
|
56
|
-
);
|
|
44
|
+
// Each viewer owns both its states without a document: in a loading scope (the document is being read) it draws its
|
|
45
|
+
// own loading form, otherwise its "not available" state when the DAL left the URL empty. So the renderers always
|
|
46
|
+
// render the viewer, and the viewer never mounts a frame without a source.
|
|
47
|
+
const defaultPdfRenderer: DocRenderer = content => (
|
|
48
|
+
<PdfViewer src={content.fileUrl ?? ''} fileName={content.fileName} />
|
|
49
|
+
);
|
|
57
50
|
|
|
58
|
-
const defaultGoogleSheetRenderer: DocRenderer =
|
|
59
|
-
content.externalUrl
|
|
60
|
-
|
|
61
|
-
) : (
|
|
62
|
-
defaultFallbackRenderer(content, handlers)
|
|
63
|
-
);
|
|
51
|
+
const defaultGoogleSheetRenderer: DocRenderer = content => (
|
|
52
|
+
<GoogleSheetsViewer externalUrl={content.externalUrl ?? ''} fileName={content.fileName} />
|
|
53
|
+
);
|
|
64
54
|
|
|
65
|
-
const defaultFigmaRenderer: DocRenderer =
|
|
66
|
-
content.externalUrl
|
|
67
|
-
|
|
68
|
-
) : (
|
|
69
|
-
defaultFallbackRenderer(content, handlers)
|
|
70
|
-
);
|
|
55
|
+
const defaultFigmaRenderer: DocRenderer = content => (
|
|
56
|
+
<FigmaEmbed url={content.externalUrl ?? ''} title={content.fileName} iframeLoading="eager" />
|
|
57
|
+
);
|
|
71
58
|
|
|
72
59
|
const defaultFileRenderer: DocRenderer = content => (
|
|
73
60
|
<FileDownloadCard
|
|
@@ -78,16 +65,6 @@ const defaultFileRenderer: DocRenderer = content => (
|
|
|
78
65
|
/>
|
|
79
66
|
);
|
|
80
67
|
|
|
81
|
-
/** The page's own loading form per document type: the markdown article's, else the embed viewer's. */
|
|
82
|
-
const documentLoadingForm = (documentType: DocumentType | undefined) =>
|
|
83
|
-
!documentType || documentType === 'markdown' ? (
|
|
84
|
-
<MarkdownSkeleton />
|
|
85
|
-
) : (
|
|
86
|
-
// Forward the documentType so the embed skeleton renders the right
|
|
87
|
-
// shape (PDF=2 buttons, sheets/figma=1 button, file=centered card).
|
|
88
|
-
<EmbedSkeleton documentType={documentType} />
|
|
89
|
-
);
|
|
90
|
-
|
|
91
68
|
/**
|
|
92
69
|
* Embeddable docs-hub page. Bundles `<DocViewer>` with safe defaults so the
|
|
93
70
|
* minimum embed is a one-line mount (consumer only has to supply
|
|
@@ -132,7 +109,6 @@ export function DocsHubPage({
|
|
|
132
109
|
className={className}
|
|
133
110
|
sidebarLabel={sidebarLabel}
|
|
134
111
|
renderContent={renderContent}
|
|
135
|
-
renderSkeleton={documentLoadingForm}
|
|
136
112
|
/>
|
|
137
113
|
);
|
|
138
114
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
findDocNodeByPath,
|
|
10
10
|
getDocAncestorNodeIds,
|
|
11
11
|
DEFAULT_FOLDER_INDEX_FILE,
|
|
12
|
+
findFirstDocPath,
|
|
13
|
+
rootDocumentPath,
|
|
12
14
|
} from '../../utils/doc-tree-nav';
|
|
13
15
|
import { contentFetch } from '../../utils/embed-content-fetch';
|
|
14
16
|
import { navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
|
|
@@ -34,21 +36,6 @@ function scrollToContent() {
|
|
|
34
36
|
* its README) instead of a blank panel or a redundant in-page listing — the
|
|
35
37
|
* sidebar tree is the directory browser. Returns null for a folder with no docs.
|
|
36
38
|
*/
|
|
37
|
-
function findFirstDocPath(folder: DocNode): string | null {
|
|
38
|
-
const children = folder.children ?? [];
|
|
39
|
-
for (const child of children) {
|
|
40
|
-
if (child.type === 'file' && !child.path.toLowerCase().endsWith('.mmd')) {
|
|
41
|
-
return child.path;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
for (const child of children) {
|
|
45
|
-
if (child.type === 'folder') {
|
|
46
|
-
const nested = findFirstDocPath(child);
|
|
47
|
-
if (nested) return nested;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
39
|
|
|
53
40
|
// ---------------------------------------------------------------------------
|
|
54
41
|
// Wire decoding for the two doc-source endpoints
|
|
@@ -220,6 +207,11 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
|
|
|
220
207
|
const cleanInitialPath = stripFolderIndexFromPath(initialPath?.replace(/\/$/, '') || '', folderIndexFile);
|
|
221
208
|
|
|
222
209
|
const [structure, setStructure] = useState<DocNode[]>([]);
|
|
210
|
+
// The latest committed tree, for a request that settles after the tree arrived (its closure holds the old one).
|
|
211
|
+
const structureRef = useRef(structure);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
structureRef.current = structure;
|
|
214
|
+
}, [structure]);
|
|
223
215
|
const [selectedPath, setSelectedPath] = useState<string>(cleanInitialPath);
|
|
224
216
|
const [content, setContent] = useState<DocContent | null>(null);
|
|
225
217
|
const [isLoadingStructure, setIsLoadingStructure] = useState(true);
|
|
@@ -409,12 +401,16 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
|
|
|
409
401
|
// No-README FOLDER → stay silent: the content effect resolves it to
|
|
410
402
|
// the folder's first child, so the folder-path 404 from the direct-
|
|
411
403
|
// load speculative fetch is expected, not an error to flash.
|
|
412
|
-
|
|
404
|
+
// Classified against the LATEST tree (`structureRef`), never the one captured when this request went out: the tree
|
|
405
|
+
// often arrives while the request is in flight, and judged against the empty tree of the call a missing
|
|
406
|
+
// document read as "not classified yet, wait", so nothing settled it and the page stayed a skeleton.
|
|
407
|
+
const latestStructure = structureRef.current;
|
|
408
|
+
const probe = findDocNodeByPath(stripFolderIndexFromPath(path, folderIndexFile), latestStructure);
|
|
413
409
|
const probeIsNoReadmeFolder = !!probe && probe.type === 'folder' && !probe.hasReadme;
|
|
414
410
|
// Before the structure has loaded to classify the path (`structure`
|
|
415
411
|
// is [] in the speculative call's closure), only silence FOLDER-LIKE
|
|
416
412
|
// paths (no `.md`) — a genuinely missing `*.md` leaf must still error.
|
|
417
|
-
const preStructureFolderLike =
|
|
413
|
+
const preStructureFolderLike = latestStructure.length === 0 && !path.endsWith('.md');
|
|
418
414
|
if (probeIsNoReadmeFolder || preStructureFolderLike) {
|
|
419
415
|
// Superseded by the targeted fetch (first-child / reclassified path)
|
|
420
416
|
// the structure effect fires. Null the request id so the `finally`
|
|
@@ -507,7 +503,15 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
|
|
|
507
503
|
let pathToFetch: string | null = null;
|
|
508
504
|
|
|
509
505
|
if (selectedPath === '') {
|
|
510
|
-
|
|
506
|
+
// The root shows its landing document (the folder index); a source with none shows its FIRST document, the
|
|
507
|
+
// rule a no-README folder follows. Asking for the missing index again would 404 for ever.
|
|
508
|
+
const rootPath = rootDocumentPath(structure, folderIndexFile);
|
|
509
|
+
if (!rootPath) {
|
|
510
|
+
// A tree with folders and no document at all: nothing to fetch; the empty state is derived below.
|
|
511
|
+
lastFetchedPath.current = null;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
pathToFetch = rootPath;
|
|
511
515
|
} else {
|
|
512
516
|
const node = findDocNodeByPath(selectedPath, structure);
|
|
513
517
|
|
|
@@ -731,19 +735,25 @@ export function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: str
|
|
|
731
735
|
// the content effect, which reached the same conclusion one commit later and
|
|
732
736
|
// meanwhile left the PREVIOUS document's body on screen.
|
|
733
737
|
const isEmptyFolder = useMemo(() => {
|
|
734
|
-
if (
|
|
738
|
+
if (structure.length === 0) return false;
|
|
739
|
+
// The root of a source whose tree holds no document at all is an empty folder too.
|
|
740
|
+
if (!selectedPath) return !rootDocumentPath(structure, folderIndexFile);
|
|
735
741
|
const node = findDocNodeByPath(selectedPath, structure);
|
|
736
742
|
return !!node && node.type === 'folder' && !node.hasReadme && !findFirstDocPath(node);
|
|
737
|
-
}, [selectedPath, structure]);
|
|
743
|
+
}, [selectedPath, structure, folderIndexFile]);
|
|
744
|
+
// A source with no documents at all (its tree is read and empty): there is nothing to load, so the viewer shows the
|
|
745
|
+
// source's empty state instead of waiting for a landing document that does not exist.
|
|
746
|
+
const isEmptySource = !isLoadingStructure && structure.length === 0;
|
|
747
|
+
const settled = isEmptyFolder || isEmptySource;
|
|
738
748
|
|
|
739
749
|
return {
|
|
740
750
|
structure,
|
|
741
751
|
selectedPath,
|
|
742
752
|
setSelectedPath,
|
|
743
|
-
content:
|
|
753
|
+
content: settled ? null : content,
|
|
744
754
|
isLoadingStructure,
|
|
745
|
-
isLoadingContent:
|
|
746
|
-
error:
|
|
755
|
+
isLoadingContent: settled ? false : isLoadingContent,
|
|
756
|
+
error: settled ? null : error,
|
|
747
757
|
expandedNodes,
|
|
748
758
|
toggleNode,
|
|
749
759
|
selectNode,
|
|
@@ -19,7 +19,7 @@ export interface ClaudeEmbedProps {
|
|
|
19
19
|
/** iframe height (CSS value), matching `FigmaEmbed`'s prop. */
|
|
20
20
|
height?: string;
|
|
21
21
|
/** iframe loading strategy. Defaults to `lazy`, as `FigmaEmbed` does. */
|
|
22
|
-
|
|
22
|
+
iframeLoading?: 'eager' | 'lazy';
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const KIND_HEADING: Record<ClaudeEmbedKind, string> = {
|
|
@@ -51,7 +51,7 @@ const KIND_HEADING: Record<ClaudeEmbedKind, string> = {
|
|
|
51
51
|
* a Claude Design url (no embed route), an artifact whose author has not
|
|
52
52
|
* allow-listed this host, or an http host (mixed content).
|
|
53
53
|
*/
|
|
54
|
-
export function ClaudeEmbed({ url, kind = 'artifact', title, height,
|
|
54
|
+
export function ClaudeEmbed({ url, kind = 'artifact', title, height, iframeLoading = 'lazy' }: ClaudeEmbedProps) {
|
|
55
55
|
// Mirror detection lives in `useClaudeMirrorSrc` (derive from
|
|
56
56
|
// EndpointsRuntime + 1-byte ranged probe + background revalidation) — this
|
|
57
57
|
// component stays presentational.
|
|
@@ -70,7 +70,7 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
|
|
|
70
70
|
icon={<ClaudeIcon className="h-5 w-5 shrink-0" />}
|
|
71
71
|
title={title?.trim() || KIND_HEADING[kind]}
|
|
72
72
|
titleVariant="h6"
|
|
73
|
-
|
|
73
|
+
resolving={isProbing}
|
|
74
74
|
actions={
|
|
75
75
|
<Button
|
|
76
76
|
variant="outline"
|
|
@@ -89,7 +89,7 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
|
|
|
89
89
|
// (unlike Figma's player) — the shell provides the toggle for both
|
|
90
90
|
// the claude.ai `/embed` route and a mirror.
|
|
91
91
|
fullscreenControl
|
|
92
|
-
|
|
92
|
+
iframeLoading={iframeLoading}
|
|
93
93
|
height={height}
|
|
94
94
|
// Same frame contract Figma gets — an artifact is a real app: it copies
|
|
95
95
|
// to the clipboard and can go fullscreen.
|
|
@@ -43,8 +43,10 @@ export interface EmbedIframeProps {
|
|
|
43
43
|
allow?: string;
|
|
44
44
|
/** iframe `referrerPolicy` attribute */
|
|
45
45
|
referrerPolicy?: React.IframeHTMLAttributes<HTMLIFrameElement>['referrerPolicy'];
|
|
46
|
-
/** iframe `loading` attribute */
|
|
47
|
-
|
|
46
|
+
/** iframe `loading` attribute (eager or lazy). */
|
|
47
|
+
iframeLoading?: 'eager' | 'lazy';
|
|
48
|
+
/** The frame's source is not known yet: the frame draws its loading form. A loading scope does the same. */
|
|
49
|
+
loading?: boolean;
|
|
48
50
|
/** iframe `allowFullScreen` attribute */
|
|
49
51
|
allowFullScreen?: boolean;
|
|
50
52
|
/**
|
|
@@ -74,7 +76,8 @@ export function EmbedIframe({
|
|
|
74
76
|
height,
|
|
75
77
|
allow,
|
|
76
78
|
referrerPolicy,
|
|
77
|
-
|
|
79
|
+
iframeLoading,
|
|
80
|
+
loading = false,
|
|
78
81
|
allowFullScreen,
|
|
79
82
|
sandbox,
|
|
80
83
|
}: EmbedIframeProps) {
|
|
@@ -87,7 +90,7 @@ export function EmbedIframe({
|
|
|
87
90
|
const isLoaded = loadedSrc === src;
|
|
88
91
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
89
92
|
const handleLoad = useCallback(() => setLoadedSrc(src), [src]);
|
|
90
|
-
const held = useLoadingScope();
|
|
93
|
+
const held = useLoadingScope() || loading;
|
|
91
94
|
|
|
92
95
|
useEffect(() => {
|
|
93
96
|
const iframe = iframeRef.current;
|
|
@@ -123,7 +126,7 @@ export function EmbedIframe({
|
|
|
123
126
|
onLoad={handleLoad}
|
|
124
127
|
allow={allow}
|
|
125
128
|
referrerPolicy={referrerPolicy}
|
|
126
|
-
loading={
|
|
129
|
+
loading={iframeLoading}
|
|
127
130
|
allowFullScreen={allow?.includes('fullscreen') ? undefined : allowFullScreen}
|
|
128
131
|
sandbox={sandbox}
|
|
129
132
|
/>
|
|
@@ -5,6 +5,7 @@ import type React from 'react';
|
|
|
5
5
|
import { useEffect, useRef, useState } from 'react';
|
|
6
6
|
import { cn } from '../../utils/cn';
|
|
7
7
|
import { FullscreenSwitchController } from '../../utils/fullscreen-switch';
|
|
8
|
+
import { InteractiveSkeleton, TextLineSkeleton } from '../loading/unified-skeleton';
|
|
8
9
|
import { Button } from '../ui/button/button';
|
|
9
10
|
import { useLoadingScope } from '../ui/loading-scope';
|
|
10
11
|
import { EmbedIframe, EmbedLoadingSkeleton } from './embed-iframe';
|
|
@@ -40,21 +41,22 @@ export interface EmbedViewerFrameProps {
|
|
|
40
41
|
/** Rendered verbatim in the header row — the caller owns its own container/state. */
|
|
41
42
|
actions?: React.ReactNode;
|
|
42
43
|
/** Iframe source; falsy renders the inline empty state (or the loading
|
|
43
|
-
* state when `
|
|
44
|
+
* state when `loading` or `resolving`) under the header. */
|
|
44
45
|
src: string | null | undefined;
|
|
45
|
-
/**
|
|
46
|
-
* loading skeleton
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
|
|
46
|
+
/** The document is being read (the standard `loading` prop; a loading scope does the same): the whole frame is its
|
|
47
|
+
* loading form, a skeleton title, its buttons' loading forms and the frame's loading box. */
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
/** The document is known (its title shows) but the viewer is still finding where its frame points (ClaudeEmbed
|
|
50
|
+
* probing for a self-hosted mirror): only the body is its loading box. */
|
|
51
|
+
resolving?: boolean;
|
|
51
52
|
/** Large icon for the inline empty state (the viewers pass `w-16 h-16`). */
|
|
52
53
|
emptyIcon?: React.ReactNode;
|
|
53
54
|
emptyMessage?: string;
|
|
54
55
|
height?: string;
|
|
55
56
|
allow?: string;
|
|
56
57
|
allowFullScreen?: boolean;
|
|
57
|
-
loading
|
|
58
|
+
/** iframe loading strategy (eager or lazy). */
|
|
59
|
+
iframeLoading?: 'eager' | 'lazy';
|
|
58
60
|
/** iframe `sandbox` — see `EmbedIframe`. Set it for user-authored contents. */
|
|
59
61
|
sandbox?: string;
|
|
60
62
|
/** Wrapper classes; defaults to the sheets/pdf `space-y-4`. */
|
|
@@ -66,6 +68,8 @@ export interface EmbedViewerFrameProps {
|
|
|
66
68
|
* button (the Figma player) and some cannot (a self-hosted Claude
|
|
67
69
|
* artifact mirror) — the shell provides the affordance uniformly. */
|
|
68
70
|
fullscreenControl?: boolean;
|
|
71
|
+
/** How many buttons `actions` draws (PDF: Preview and Download): the header's loading form draws as many. Default 1. */
|
|
72
|
+
loadingActionCount?: number;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export function EmbedViewerFrame({
|
|
@@ -74,19 +78,23 @@ export function EmbedViewerFrame({
|
|
|
74
78
|
titleVariant = 'h3',
|
|
75
79
|
actions,
|
|
76
80
|
src,
|
|
77
|
-
|
|
81
|
+
loading: loadingGiven = false,
|
|
82
|
+
resolving = false,
|
|
78
83
|
emptyIcon,
|
|
79
84
|
emptyMessage = 'Content not available',
|
|
80
85
|
height,
|
|
81
86
|
allow,
|
|
82
87
|
allowFullScreen,
|
|
83
|
-
|
|
88
|
+
iframeLoading,
|
|
84
89
|
sandbox,
|
|
85
90
|
className,
|
|
86
91
|
fullscreenControl,
|
|
92
|
+
loadingActionCount = 1,
|
|
87
93
|
}: EmbedViewerFrameProps) {
|
|
88
|
-
// ONE switch: a page's loading scope, or the
|
|
89
|
-
|
|
94
|
+
// ONE switch for the whole frame (header included): a page's loading scope, or the standard `loading` prop. The body
|
|
95
|
+
// alone also waits while the viewer resolves where its frame points.
|
|
96
|
+
const loadingForm = useLoadingScope() || loadingGiven;
|
|
97
|
+
const isLoading = loadingForm || resolving;
|
|
90
98
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
91
99
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
92
100
|
|
|
@@ -127,7 +135,11 @@ export function EmbedViewerFrame({
|
|
|
127
135
|
<div className="flex flex-col gap-3 content-sm:flex-row content-sm:items-center content-sm:justify-between">
|
|
128
136
|
<div className="flex min-w-0 items-center gap-2">
|
|
129
137
|
{icon}
|
|
130
|
-
{
|
|
138
|
+
{loadingForm ? (
|
|
139
|
+
// The document is being read: its name is not known yet. (A viewer still resolving its frame, with a
|
|
140
|
+
// known title, keeps it.)
|
|
141
|
+
<TextLineSkeleton type={titleVariant} className="w-48 max-w-full" />
|
|
142
|
+
) : titleVariant === 'h3' ? (
|
|
131
143
|
<h2 className="truncate text-ods-text-primary text-h3">{title}</h2>
|
|
132
144
|
) : (
|
|
133
145
|
<span className="truncate font-semibold text-ods-text-primary text-h6">{title}</span>
|
|
@@ -140,7 +152,15 @@ export function EmbedViewerFrame({
|
|
|
140
152
|
fullscreen toggle, `actions` renders EXACTLY as before — the
|
|
141
153
|
wrapper exists only when the toggle joins it, so pre-existing
|
|
142
154
|
viewers (figma/pdf/sheets) are byte-identical. */}
|
|
143
|
-
{
|
|
155
|
+
{loadingForm ? (
|
|
156
|
+
// The actions point at the document being read: each its button's loading form, in the actions' row.
|
|
157
|
+
<div className="flex w-full items-center gap-2 content-sm:w-auto">
|
|
158
|
+
{Array.from({ length: loadingActionCount }, (_, i) => (
|
|
159
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: loading buttons never reorder
|
|
160
|
+
<InteractiveSkeleton.Button key={i} size="small-legacy" className="flex-1 content-sm:flex-initial" />
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
) : fullscreenControl && src ? (
|
|
144
164
|
<div className="flex flex-col gap-[var(--spacing-system-xsf)] content-sm:flex-row content-sm:items-center">
|
|
145
165
|
{actions}
|
|
146
166
|
<Button
|
|
@@ -169,7 +189,7 @@ export function EmbedViewerFrame({
|
|
|
169
189
|
height={isFullscreen ? 'calc(100vh - 96px)' : height}
|
|
170
190
|
allow={allow}
|
|
171
191
|
allowFullScreen={allowFullScreen}
|
|
172
|
-
|
|
192
|
+
iframeLoading={iframeLoading}
|
|
173
193
|
sandbox={sandbox}
|
|
174
194
|
/>
|
|
175
195
|
) : isLoading ? (
|
|
@@ -19,7 +19,9 @@ export interface FigmaEmbedProps {
|
|
|
19
19
|
*/
|
|
20
20
|
height?: string;
|
|
21
21
|
/** iframe loading strategy. Defaults to `"lazy"`; the data-room viewer passes `"eager"`. */
|
|
22
|
-
|
|
22
|
+
iframeLoading?: 'eager' | 'lazy';
|
|
23
|
+
/** The document is being read: the embed draws its loading form. A loading scope does the same. */
|
|
24
|
+
loading?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
type SlidesView = 'present' | 'browse';
|
|
@@ -42,7 +44,9 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
|
|
|
42
44
|
if (v && v !== view) onChange(v as SlidesView);
|
|
43
45
|
}}
|
|
44
46
|
aria-label="Figma slides view mode"
|
|
45
|
-
|
|
47
|
+
// The whole control is one button tall (40px, border and padding included), so the header row is the same height
|
|
48
|
+
// with or without it, loading and loaded alike.
|
|
49
|
+
className="flex h-10 shrink-0 items-center gap-0.5 rounded-lg border border-ods-border bg-ods-card p-0.5"
|
|
46
50
|
>
|
|
47
51
|
{options.map(({ key, label, Icon }) => {
|
|
48
52
|
const active = view === key;
|
|
@@ -51,7 +55,7 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
|
|
|
51
55
|
key={key}
|
|
52
56
|
value={key}
|
|
53
57
|
aria-label={label}
|
|
54
|
-
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 transition-colors text-h6 ${
|
|
58
|
+
className={`flex h-full items-center gap-1.5 rounded-md px-2.5 py-1 transition-colors text-h6 ${
|
|
55
59
|
active
|
|
56
60
|
? 'bg-ods-accent text-ods-text-on-accent'
|
|
57
61
|
: 'text-ods-text-secondary hover:bg-ods-bg-hover hover:text-ods-text-primary'
|
|
@@ -80,7 +84,7 @@ function SlidesViewToggle({ view, onChange }: { view: SlidesView; onChange: (v:
|
|
|
80
84
|
* plain ReactNode) — flipping it recomputes `embedSrc`, and the frame's
|
|
81
85
|
* `EmbedIframe` remounts on the new src exactly as before the extraction.
|
|
82
86
|
*/
|
|
83
|
-
export function FigmaEmbed({ url, title, height,
|
|
87
|
+
export function FigmaEmbed({ url, title, height, iframeLoading = 'lazy', loading = false }: FigmaEmbedProps) {
|
|
84
88
|
const [view, setView] = useState<SlidesView>('present');
|
|
85
89
|
const isSlides = url ? isFigmaSlidesUrl(url) : false;
|
|
86
90
|
const embedSrc = url ? toFigmaEmbedUrl(url, { slidesView: view }) : null;
|
|
@@ -124,6 +128,7 @@ export function FigmaEmbed({ url, title, height, loading = 'lazy' }: FigmaEmbedP
|
|
|
124
128
|
}
|
|
125
129
|
src={embedSrc}
|
|
126
130
|
allow="clipboard-write; clipboard-read; fullscreen"
|
|
131
|
+
iframeLoading={iframeLoading}
|
|
127
132
|
loading={loading}
|
|
128
133
|
height={height}
|
|
129
134
|
allowFullScreen
|