Skip to content

Commit 74bbe6b

Browse files
Merge pull request #6 from Sunbird-Knowlg/v1.0.3
V1.0.3
2 parents 60ed4f7 + 2b02ba1 commit 74bbe6b

26 files changed

Lines changed: 2934 additions & 180 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@project-sunbird/generic-editor-v2",
3-
"version": "2.0.4",
3+
"version": "2.0.6",
44
"description": "Sunbird Generic Editor — React content upload/generic editor (theme-reactive, multilingual). Fork of @sunbird/content-editor that previews via the legacy ekstep content renderer.",
55
"main": "dist/sunbird-generic-editor.umd.js",
66
"module": "dist/sunbird-generic-editor.es.js",
@@ -49,4 +49,4 @@
4949
"vite-plugin-dts": "^4.5.4",
5050
"vitest": "^3.2.6"
5151
}
52-
}
52+
}

src/ContentEditor.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,4 @@
1-
/**
2-
* ContentEditor — the editor root component.
3-
*
4-
* import { ContentEditor } from '@project-sunbird/generic-editor-v2';
5-
* import '@project-sunbird/generic-editor-v2/dist/sunbird-generic-editor.css';
6-
*
7-
* <ContentEditor
8-
* context={{ uid, sid, did, channel, pdata, user, framework }}
9-
* contentId="do_123" // omit for a brand-new upload
10-
* language="en"
11-
* onClose={() => navigate('/workspace')}
12-
* onTelemetryEvent={(e) => post(e)}
13-
* />
14-
*/
1+
/** ContentEditor — the editor root component; render with a `context`, optional `contentId` (omit for a new upload), and its `onClose`/`onTelemetryEvent` callbacks. */
152
import React from 'react';
163
import './editor.scss';
174
import { useEditor, type UseEditorOptions } from './useEditor';
@@ -23,6 +10,7 @@ import MetadataDrawer from './components/MetadataDrawer';
2310
import CollaboratorDrawer from './components/CollaboratorDrawer';
2411
import ReviewDrawer from './components/ReviewDrawer';
2512
import ReviewCommentsDrawer from './components/ReviewCommentsDrawer';
13+
import TranscriptsDrawer from './components/TranscriptsDrawer';
2614
import AssetPickerModal from './components/AssetPickerModal';
2715
import Toast from './components/Toast';
2816
import { t } from './i18n/i18n';
@@ -56,6 +44,7 @@ const ContentEditor: React.FC<ContentEditorProps> = (props) => {
5644
<CollaboratorDrawer ed={ed} />
5745
<ReviewDrawer ed={ed} />
5846
<ReviewCommentsDrawer ed={ed} />
47+
<TranscriptsDrawer ed={ed} />
5948
<AssetPickerModal ed={ed} />
6049

6150
<Toast toast={ed.toast} />

src/components/Drawer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ interface DrawerProps {
1010
children: React.ReactNode;
1111
/** Localized label for the close button (falls back to English). */
1212
closeLabel?: string;
13+
/** Extra class on the root, e.g. for a drawer-specific scoped theme override. */
14+
className?: string;
1315
}
1416

1517
/** Slide-in drawer (right in LTR, left in RTL). Always mounted so the transform transition runs. */
16-
const Drawer: React.FC<DrawerProps> = ({ open, onClose, titleIcon, title, footer, children, closeLabel = 'Close' }) => (
17-
<div className="ce-drawer" data-open={open} aria-hidden={!open}>
18+
const Drawer: React.FC<DrawerProps> = ({ open, onClose, titleIcon, title, footer, children, closeLabel = 'Close', className }) => (
19+
<div className={`ce-drawer${className ? ` ${className}` : ''}`} data-open={open} aria-hidden={!open}>
1820
<div className="ce-drawer-head">
1921
<div className="ce-drawer-title-row">
2022
<span className="ce-drawer-title-ic">{titleIcon}</span>

src/components/EditorPreview.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,66 @@ describe('<EditorPreview />', () => {
3131
const { container } = render(<EditorPreview ed={ed} context={mockContext} />);
3232
expect(container.querySelector('iframe')).toHaveAttribute('src', '/preview.html?foo=1&webview=true');
3333
});
34+
35+
it('renders the iframe immediately for non-video content regardless of transcriptsChecked', () => {
36+
const ed = makeEd({ content: mockContent, transcriptsChecked: false }); // mockContent is application/pdf
37+
const { container } = render(<EditorPreview ed={ed} context={mockContext} />);
38+
expect(container.querySelector('iframe')).toBeInTheDocument();
39+
});
40+
41+
it('shows a loading state instead of the iframe for video content until transcripts have been checked at least once (negative)', () => {
42+
const videoContent = { ...mockContent, mimeType: 'video/mp4' };
43+
const ed = makeEd({ content: videoContent, transcriptsChecked: false, transcripts: [] });
44+
const { container } = render(<EditorPreview ed={ed} context={mockContext} />);
45+
expect(container.querySelector('iframe')).not.toBeInTheDocument();
46+
expect(container.querySelector('.ce-spinner')).toBeInTheDocument();
47+
});
48+
49+
it('renders the iframe for video content once transcriptsChecked is true, even with zero transcripts', () => {
50+
const videoContent = { ...mockContent, mimeType: 'video/mp4' };
51+
const ed = makeEd({ content: videoContent, transcriptsChecked: true, transcripts: [] });
52+
const { container } = render(<EditorPreview ed={ed} context={mockContext} />);
53+
expect(container.querySelector('iframe')).toBeInTheDocument();
54+
});
55+
56+
it('does not treat a transcript with no status as Live (negative)', () => {
57+
const videoContent = { ...mockContent, mimeType: 'video/mp4' };
58+
const ed1 = makeEd({
59+
content: videoContent,
60+
transcriptsChecked: true,
61+
transcripts: [{ code: 'c_en', language: 'English', languageCode: 'en', captionsUrl: 'https://x/en.vtt' }], // no status
62+
});
63+
const { container, rerender } = render(<EditorPreview ed={ed1} context={mockContext} />);
64+
const withoutStatusIframe = container.querySelector('iframe');
65+
66+
const ed2 = makeEd({
67+
content: videoContent,
68+
transcriptsChecked: true,
69+
transcripts: [],
70+
});
71+
rerender(<EditorPreview ed={ed2} context={mockContext} />);
72+
const emptyIframe = container.querySelector('iframe');
73+
// Same remount key either way, since a status-less entry must be excluded just like an empty list.
74+
expect(emptyIframe).toBe(withoutStatusIframe);
75+
});
76+
77+
it('remounts the iframe (new DOM node) when the live-caption set changes at equal length', () => {
78+
const videoContent = { ...mockContent, mimeType: 'video/mp4' };
79+
const ed1 = makeEd({
80+
content: videoContent,
81+
transcriptsChecked: true,
82+
transcripts: [{ code: 'c_en', language: 'English', languageCode: 'en', captionsUrl: 'https://x/en.vtt', status: 'Live' }],
83+
});
84+
const { container, rerender } = render(<EditorPreview ed={ed1} context={mockContext} />);
85+
const firstIframe = container.querySelector('iframe');
86+
87+
const ed2 = makeEd({
88+
content: videoContent,
89+
transcriptsChecked: true,
90+
transcripts: [{ code: 'c_fr', language: 'French', languageCode: 'fr', captionsUrl: 'https://x/fr.vtt', status: 'Live' }],
91+
});
92+
rerender(<EditorPreview ed={ed2} context={mockContext} />);
93+
const secondIframe = container.querySelector('iframe');
94+
expect(secondIframe).not.toBe(firstIframe);
95+
});
3496
});

src/components/EditorPreview.tsx

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
11
import React, { useRef } from 'react';
22
import type { EditorController } from '../useEditor';
3-
import type { ContentData, EditorContext } from '../types';
3+
import type { ContentData, EditorContext, RawTranscript } from '../types';
44
import { getMimeTypeLabel, t } from '../i18n/i18n';
55
import { FileIcon } from '../icons';
6+
import { isVideoMimeType } from '../constants';
67

7-
/**
8-
* Preview via the legacy ekstep content renderer — the same mechanism the old
9-
* generic editor used (org.ekstep.genericeditorpreview). One renderer handles all
10-
* mimeTypes (video/pdf/epub/ecml/html/scorm/h5p/youtube/url) through its coreplugins.
11-
*
12-
* Loads `content/preview/preview.html?webview=true` in an iframe (same-origin via the
13-
* host proxy), then calls its global `initializePreview()` with the content id +
14-
* metadata. No player-v2 dependency, no client-side unzip.
15-
*/
8+
interface PlayerTranscript {
9+
language: string;
10+
identifier: string;
11+
languageCode: string;
12+
artifactUrl: string;
13+
wordByWordUrl?: string;
14+
sourceLanguage?: boolean;
15+
}
16+
17+
/** Maps raw enrichment.transcripts into the shape sunbird-video-player expects, mirroring
18+
* the portal's ContentService mapping (artifactUrl here is the VTT, not transcript.json).
19+
* Requires an explicit 'Live' status - a missing status is treated the same safe way
20+
* TranscriptsDrawer.statusTone does (not-yet-approved), never served as a live caption. */
21+
function mapRawTranscripts(raw: RawTranscript[]): PlayerTranscript[] {
22+
return raw
23+
.filter((e): e is RawTranscript & { captionsUrl: string } =>
24+
!!e.captionsUrl && e.status === 'Live')
25+
.map((e) => ({
26+
language: e.language || (e.languageCode || 'Unknown').toUpperCase(),
27+
identifier: e.code ?? e.identifier ?? '',
28+
languageCode: e.languageCode || '',
29+
artifactUrl: e.captionsUrl,
30+
wordByWordUrl: e.captionsUrl,
31+
sourceLanguage: !!e.sourceLanguage,
32+
}));
33+
}
34+
35+
/** Preview via the legacy ekstep content renderer (all mimeTypes through its coreplugins), loaded in an iframe and driven via its global `initializePreview()`. */
1636
const RendererPreview: React.FC<{
1737
content: ContentData; context: EditorContext; previewUrl: string; previewConfig: Record<string, unknown>;
1838
}> = ({ content, context, previewUrl, previewConfig }) => {
@@ -55,9 +75,17 @@ const RendererPreview: React.FC<{
5575
};
5676

5777
const EditorPreview: React.FC<{ ed: EditorController; context: EditorContext }> = ({ ed, context }) => {
58-
const { content, lang, previewUrl, previewConfig } = ed;
78+
const { content, lang, previewUrl, previewConfig, transcripts: rawTranscripts, transcriptsChecked } = ed;
79+
5980
if (!content) return null;
6081

82+
const isVideo = isVideoMimeType(content.mimeType);
83+
const transcripts = mapRawTranscripts(rawTranscripts);
84+
// The iframe key changes only when the actual live-caption set changes (not just its
85+
// count), so a same-count swap (one language leaves Live as another joins) still remounts.
86+
const transcriptsKey = transcripts.map((tr) => tr.identifier).sort().join(',');
87+
const previewMetadata = transcripts.length ? { ...content, transcripts } : content;
88+
6189
return (
6290
<div className="ce-preview-card">
6391
<div className="ce-preview-bar">
@@ -67,13 +95,22 @@ const EditorPreview: React.FC<{ ed: EditorController; context: EditorContext }>
6795
<span className="ce-preview-chip">{t(lang, 'PREVIEW_MODE')}</span>
6896
</div>
6997
<div className="ce-preview-frame">
70-
<RendererPreview
71-
key={`${content.identifier}-${content.artifactUrl ?? ''}`}
72-
content={content}
73-
context={context}
74-
previewUrl={previewUrl}
75-
previewConfig={previewConfig}
76-
/>
98+
{isVideo && !transcriptsChecked ? (
99+
// Wait for the first transcripts check before ever mounting the renderer, so a
100+
// slow initial read can't yank the iframe out (and restart playback) right after
101+
// the user presses play - by the time it first mounts, this is already settled.
102+
<div className="ce-center" style={{ height: '100%' }}>
103+
<div className="ce-spinner ce-spinner--sm" />
104+
</div>
105+
) : (
106+
<RendererPreview
107+
key={`${content.identifier}-${content.artifactUrl ?? ''}-${transcriptsKey}`}
108+
content={previewMetadata}
109+
context={context}
110+
previewUrl={previewUrl}
111+
previewConfig={previewConfig}
112+
/>
113+
)}
77114
</div>
78115
</div>
79116
);

src/components/Header.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,30 @@ describe('<Header />', () => {
4141
fireEvent.click(btn);
4242
expect(publish).toHaveBeenCalled();
4343
});
44+
45+
it('shows "View transcript" for video content once transcripts exist, and opens the drawer', () => {
46+
const setDrawer = vi.fn();
47+
const ed = makeEd({
48+
view: 'player', mode: 'edit', setDrawer, hasTranscripts: true,
49+
content: { ...mockContent, mimeType: 'video/mp4' },
50+
});
51+
render(<Header ed={ed} />);
52+
fireEvent.click(screen.getByRole('button', { name: 'View transcript' }));
53+
expect(setDrawer).toHaveBeenCalledWith('transcripts');
54+
});
55+
56+
it('hides "View transcript" for video content with no transcripts yet (negative)', () => {
57+
const ed = makeEd({
58+
view: 'player', mode: 'edit', hasTranscripts: false,
59+
content: { ...mockContent, mimeType: 'video/mp4' },
60+
});
61+
render(<Header ed={ed} />);
62+
expect(screen.queryByRole('button', { name: 'View transcript' })).not.toBeInTheDocument();
63+
});
64+
65+
it('hides "View transcript" for non-video content (negative)', () => {
66+
const ed = makeEd({ view: 'player', mode: 'edit', content: { ...mockContent, mimeType: 'application/pdf' } });
67+
render(<Header ed={ed} />);
68+
expect(screen.queryByRole('button', { name: 'View transcript' })).not.toBeInTheDocument();
69+
});
4470
});

src/components/Header.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import React from 'react';
22
import type { EditorController } from '../useEditor';
33
import { t } from '../i18n/i18n';
44
import {
5-
ImageIcon, PencilIcon, SaveIcon, CloseIcon, SendIcon, UserPlusIcon, CheckIcon, CommentIcon,
5+
ImageIcon, PencilIcon, SaveIcon, CloseIcon, SendIcon, UserPlusIcon, CheckIcon, CommentIcon, CaptionsIcon,
66
} from '../icons';
77
import { STATUS } from '../constants';
88

99
const Header: React.FC<{ ed: EditorController }> = ({ ed }) => {
10-
const { content, lang, mode, setDrawer, saveDraft, close, busy, busyAction, setReviewErrors, setReviewSubmitMode, headerLogo, hasReviewComments } = ed;
10+
const { content, lang, mode, setDrawer, saveDraft, close, busy, busyAction, setReviewErrors, setReviewSubmitMode, headerLogo, hasReviewComments, hasTranscripts } = ed;
1111
const savingDraft = busyAction === 'save-draft';
1212
const hasContent = !!content?.identifier && ed.view === 'player';
1313
const isDraft = (content?.status ?? STATUS.DRAFT) === STATUS.DRAFT;
@@ -61,6 +61,19 @@ const Header: React.FC<{ ed: EditorController }> = ({ ed }) => {
6161
</button>
6262
)}
6363

64+
{hasContent && hasTranscripts && (
65+
<button
66+
type="button"
67+
className="ce-icon-btn"
68+
onClick={() => setDrawer('transcripts')}
69+
disabled={busy}
70+
data-tooltip={t(lang, 'VIEW_TRANSCRIPT')}
71+
aria-label={t(lang, 'VIEW_TRANSCRIPT')}
72+
>
73+
<CaptionsIcon size={16} />
74+
</button>
75+
)}
76+
6477
{canEdit && !isReviewer && hasReviewComments && (
6578
<button
6679
type="button"

0 commit comments

Comments
 (0)