diff --git a/docs/packages/h5p-react.md b/docs/packages/h5p-react.md index db036d44b..f8f23fb20 100644 --- a/docs/packages/h5p-react.md +++ b/docs/packages/h5p-react.md @@ -12,9 +12,9 @@ using [@lumieducation/h5p-server](https://www.npmjs.com/package/@lumieducation/h5p-server))** that provides endpoints that do these things: -- get the required data about content (one for playing, one for editing) -- save content created in the editor -- serve all AJAX endpoints required by the H5P core +- get the required data about content (one for playing, one for editing) +- save content created in the editor +- serve all AJAX endpoints required by the H5P core It is recommended to checkout the rest example that uses @lumieducation/h5p-server to see how the component can be used in an @@ -54,19 +54,19 @@ $ npm install @lumieducation/h5p-react Then, import the component in your JavaScript / TypeScript code: ```js - import { H5PPlayerUI, H5PEditorUI } from '@lumieducation/h5p-react'; +import { H5PPlayerUI, H5PEditorUI } from '@lumieducation/h5p-react'; ``` Then, you can insert the components into your JSX: ```jsx - { /** retrieve content model from server and return it as Promise **/ } } /> - { /** retrieve content model from server and return it as Promise **/} } saveContentCallback = { async (contentId, requestBody) => { /** save content on server **/ } }/> @@ -80,8 +80,8 @@ The components automatically (re-)load data from the server by calling The content is automatically loaded from the server after **both** of these conditions have been fulfilled (in any order): -1) `loadContentCallback` is set or changed -2) `contentId` is set +1. `loadContentCallback` is set or changed +2. `contentId` is set You can change the value of `contentId` and it will discard the old content and display the new one. You can also safely remove the component from the DOM. @@ -137,7 +137,7 @@ using a renderer that simply returns the player model if you call `H5PPlayer.render(...)`: ```ts -h5pPlayerOnServer.setRenderer(model => model); +h5pPlayerOnServer.setRenderer((model) => model); const playerModel = await h5pPlayerOnServer.render(contentId, user); // send playerModel to client and return it in loadContentCallback ``` @@ -163,16 +163,16 @@ H5PEditor.render(...) and H5PEditor.getContent(...). The render must be set to simply return the editor model like this: ```js -h5pEditorOnServer.setRenderer(model => model); +h5pEditorOnServer.setRenderer((model) => model); ``` Notes: -- `contentId` can be `undefined` if the editor is used to create new content -- The library, metadata and params property of the returned object must -only be defined if `contentId` is defined. -- The callback should throw an error with a message in the message property if -something goes wrong. +- `contentId` can be `undefined` if the editor is used to create new content +- The library, metadata and params property of the returned object must + only be defined if `contentId` is defined. +- The callback should throw an error with a message in the message property if + something goes wrong. #### saveContentCallback @@ -241,3 +241,59 @@ detailed message can be found in the `message` paramter. Note: You can also simply catch errors by wrapping the `save()` method in a `try {...} catch {...}` block instead of subscribing to this event. + +## Executing underlying H5P functionality + +The H5PPlayerComponent offers properties and methods that can be used to do +things with the underlying "core" H5P data structures and objects: + +### h5pInstance + +This property is the object found in H5P.instances for the contentId of the +object. Contains things like the parameters of the content, its metadata and +structures created by the content type's JavaScript. + +**The object is only available after the `initialized` event was fired. +Important: This object is only partially typed and there are more properties +and methods on it!** + +### h5pObject + +H5P has a global "H5P" namespace that is often used like this: + +```ts +H5P.init(); // initialize H5P +H5P.externalDispatcher.on('xAPI', myCallback); +const dialog = new H5P.Dialog(...); +``` + +The problem you'll face when you try to use this namespace is that you typically +want to operate on the object inside the H5P iframe if a content type requires +iframe embedding. The h5pObject property solves this problem: It contains the +correct global H5P namespace regardless of whether there's an iframe or not. + +You can use it like this: + +```ts +const H5Pns = myPlayerComponent.h5pObject; +H5Pns.externalDispatcher.on('xAPI', myCallback); +const dialog = new H5Pns.Dialog(...); +``` + +**The property is only available after the `initialized` event was fired. +Important: This object is only partially typed and there are more properties and +methods on it!** + +### getCopyrightHtml + +You can get the copyright notice of the content by calling this method. Returns +undefined if there is not copyright information. Returns HTML code that you must +display somewhere. + +### hasCopyrightInformation + +Returns true if there is copyright information to be displayed. + +### showCopyright + +Shows the copyright information in a window overlaying the H5P content. diff --git a/docs/packages/h5p-webcomponents.md b/docs/packages/h5p-webcomponents.md index fbfa11027..b743ce9f0 100644 --- a/docs/packages/h5p-webcomponents.md +++ b/docs/packages/h5p-webcomponents.md @@ -273,6 +273,62 @@ detailed message can be found in `event.detail.message`. Note: You can also simply catch errors by wrapping the `save()` method in a `try {...} catch {...}` block instead of subscribing to this event. +## Executing underlying H5P functionality + +The H5PPlayerComponent offers properties and methods that can be used to do +things with the underlying "core" H5P data structures and objects: + +### h5pInstance + +This property is the object found in H5P.instances for the contentId of the +object. Contains things like the parameters of the content, its metadata and +structures created by the content type's JavaScript. + +**The object is only available after the `initialized` event was fired. +Important: This object is only partially typed and there are more properties +and methods on it!** + +### h5pObject + +H5P has a global "H5P" namespace that is often used like this: + +```ts +H5P.init(); // initialize H5P +H5P.externalDispatcher.on('xAPI', myCallback); +const dialog = new H5P.Dialog(...); +``` + +The problem you'll face when you try to use this namespace is that you typically +want to operate on the object inside the H5P iframe if a content type requires +iframe embedding. The h5pObject property solves this problem: It contains the +correct global H5P namespace regardless of whether there's an iframe or not. + +You can use it like this: + +```ts +const H5Pns = myPlayerComponent.h5pObject; +H5Pns.externalDispatcher.on('xAPI', myCallback); +const dialog = new H5Pns.Dialog(...); +``` + +**The property is only available after the `initialized` event was fired. +Important: This object is only partially typed and there are more properties and +methods on it!** + +### getCopyrightHtml + +You can get the copyright notice of the content by calling this method. Returns +undefined if there is not copyright information. Returns HTML code that you must +display somewhere. + +### hasCopyrightInformation + +Returns true if there is copyright information to be displayed. + +### showCopyright + +Shows the copyright information in a window overlaying the H5P content. + ## Support This work obtained financial support for development from the German diff --git a/packages/h5p-examples/src/expressRoutes.ts b/packages/h5p-examples/src/expressRoutes.ts index df4063e43..58d4d9a60 100644 --- a/packages/h5p-examples/src/expressRoutes.ts +++ b/packages/h5p-examples/src/expressRoutes.ts @@ -26,7 +26,14 @@ export default function ( try { const h5pPage = await h5pPlayer.render( req.params.contentId, - req.user + req.user, + { + showCopyButton: true, + showDownloadButton: true, + showFrame: true, + showH5PIcon: true, + showLicenseButton: true + } ); res.send(h5pPage); res.status(200).end(); diff --git a/packages/h5p-react/src/H5PPlayerUI.tsx b/packages/h5p-react/src/H5PPlayerUI.tsx index 731025e7e..87ddd8cb0 100644 --- a/packages/h5p-react/src/H5PPlayerUI.tsx +++ b/packages/h5p-react/src/H5PPlayerUI.tsx @@ -4,7 +4,9 @@ import { defineElements, H5PPlayerComponent, IxAPIEvent, - IContext + IContext, + IH5PInstance, + IH5P } from '@lumieducation/h5p-webcomponents'; import type { IPlayerModel } from '@lumieducation/h5p-server'; @@ -57,28 +59,75 @@ export default class H5PPlayerUI extends React.Component<{ this.unregisterEvents(); } + /** + * The internal H5P instance object of the H5P content. + * + * Only available after the `initialized` event was fired. Important: This + * object is only partially typed and there are more properties and methods + * on it! + */ + public get h5pInstance(): IH5PInstance | undefined { + return this.h5pPlayer.current?.h5pInstance; + } + + /** + * The global H5P object / namespace (normally accessible through "H5P..." + * or "window.H5P") of the content type. Depending on the embed type this + * can be an object from the internal iframe, so you can use it to break the + * barrier of the iframe and execute JavaScript inside the iframe. + * + * Only available after the `initialized` event was fired. Important: This + * object is only partially typed and there are more properties and methods + * on it! + */ + public get h5pObject(): IH5P | undefined { + return this.h5pPlayer.current?.h5pObject; + } + + /** + * Returns the copyright notice in HTML that you can insert somewhere to + * display it. Undefined if there is no copyright information. + */ + public getCopyrightHtml(): string { + return this.h5pPlayer.current?.getCopyrightHtml() ?? ''; + } + public getSnapshotBeforeUpdate(): void { // Should the old editor instance be destroyed, we unregister from it... this.unregisterEvents(); return null; } + /** + * @returns true if there is copyright information to be displayed. + */ + public hasCopyrightInformation(): boolean { + return this.h5pPlayer.current?.hasCopyrightInformation(); + } + public render(): React.ReactNode { return ( + /> ); } + /** + * Displays the copyright notice in the regular H5P way. + */ + public showCopyright(): void { + this.h5pPlayer.current?.showCopyright(); + } + private loadContentCallbackWrapper = ( contentId: string - ): Promise => { - return this.props.loadContentCallback(contentId); - }; + ): Promise => this.props.loadContentCallback(contentId); - private onInitialized = (event: CustomEvent<{ contentId: string }>) => { + private onInitialized = ( + event: CustomEvent<{ contentId: string }> + ): void => { if (this.props.onInitialized) { this.props.onInitialized(event.detail.contentId); } @@ -90,7 +139,7 @@ export default class H5PPlayerUI extends React.Component<{ event: IxAPIEvent; statement: any; }> - ) => { + ): void => { if (this.props.onxAPIStatement) { this.props.onxAPIStatement( event.detail.statement, diff --git a/packages/h5p-rest-example-client/src/components/ContentListEntryComponent.tsx b/packages/h5p-rest-example-client/src/components/ContentListEntryComponent.tsx index 3cbeea248..4fe6cff77 100644 --- a/packages/h5p-rest-example-client/src/components/ContentListEntryComponent.tsx +++ b/packages/h5p-rest-example-client/src/components/ContentListEntryComponent.tsx @@ -6,6 +6,8 @@ import Col from 'react-bootstrap/Col'; import Button from 'react-bootstrap/Button'; import Overlay from 'react-bootstrap/Overlay'; import Tooltip from 'react-bootstrap/Tooltip'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Modal from 'react-bootstrap/Modal'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { @@ -17,7 +19,8 @@ import { faPlay, faPencilAlt, faFileDownload, - faTrashAlt + faTrashAlt, + faCopyright } from '@fortawesome/free-solid-svg-icons'; import { H5PEditorUI, H5PPlayerUI } from '@lumieducation/h5p-react'; @@ -49,7 +52,8 @@ export default class ContentListEntryComponent extends React.Component<{ saved: false, loading: true, saveErrorMessage: '', - saveError: false + saveError: false, + showingCustomCopyright: false }; this.h5pEditor = React.createRef(); this.saveButton = React.createRef(); @@ -64,6 +68,7 @@ export default class ContentListEntryComponent extends React.Component<{ saving: boolean; saveError: boolean; saveErrorMessage: string; + showingCustomCopyright: boolean; }; private h5pPlayer: React.RefObject; @@ -113,6 +118,38 @@ export default class ContentListEntryComponent extends React.Component<{ ) : undefined} + {this.state.playing && + this.h5pPlayer.current?.hasCopyrightInformation() ? ( + + + + + + Copyright + + + + { + this.showCopyrightCustom(); + }} + > + Show in custom dialog + + { + this.showCopyrightNative(); + }} + > + Show in native H5P dialog + + + + + ) : undefined} {this.state.editing ? ( ) : undefined} + + + Copyright information + + + +
+
+ + + + +
); } @@ -317,6 +380,18 @@ export default class ContentListEntryComponent extends React.Component<{ this.setState({ editing: false, playing: false }); } + protected showCopyrightCustom() { + this.setState({ showingCustomCopyright: true }); + } + + protected closeCopyrightCustom() { + this.setState({ showingCustomCopyright: false }); + } + + protected showCopyrightNative() { + this.h5pPlayer.current?.showCopyright(); + } + private onPlayerInitialized = () => { this.setState({ loading: false }); }; diff --git a/packages/h5p-server/src/H5PPlayer.ts b/packages/h5p-server/src/H5PPlayer.ts index 459c20c58..8446f3822 100644 --- a/packages/h5p-server/src/H5PPlayer.ts +++ b/packages/h5p-server/src/H5PPlayer.ts @@ -105,6 +105,12 @@ export default class H5PPlayer { ignoreUserPermissions?: boolean; metadataOverride?: ContentMetadata; parametersOverride?: ContentParameters; + showCopyButton?: boolean; + showDownloadButton?: boolean; + showEmbedButton?: boolean; + showFrame?: boolean; + showH5PIcon?: boolean; + showLicenseButton?: boolean; } ): Promise { log.info(`rendering page for ${contentId}`); @@ -186,7 +192,15 @@ export default class H5PPlayer { metadata, assets, mainLibrarySupportsFullscreen, - user + user, + { + showCopyButton: options?.showCopyButton ?? false, + showDownloadButton: options?.showDownloadButton ?? false, + showEmbedButton: options?.showEmbedButton ?? false, + showFrame: options?.showFrame ?? false, + showH5PIcon: options?.showH5PIcon ?? false, + showLicenseButton: options?.showLicenseButton ?? false + } ), scripts: this.listCoreScripts().concat(assets.scripts), styles: this.listCoreStyles().concat(assets.styles), @@ -311,7 +325,15 @@ export default class H5PPlayer { metadata: IContentMetadata, assets: IAssets, supportsFullscreen: boolean, - user: IUser + user: IUser, + displayOptions: { + showCopyButton: boolean; + showDownloadButton: boolean; + showEmbedButton: boolean; + showFrame: boolean; + showH5PIcon: boolean; + showLicenseButton: boolean; + } ): IIntegration { // see https://h5p.org/creating-your-own-h5p-plugin log.info(`generating integration for ${contentId}`); @@ -324,12 +346,12 @@ export default class H5PPlayer { contents: { [`cid-${contentId}`]: { displayOptions: { - copy: false, - copyright: false, - embed: false, - export: false, - frame: false, - icon: false + copy: displayOptions.showCopyButton, + copyright: displayOptions.showLicenseButton, + embed: displayOptions.showEmbedButton, + export: displayOptions.showDownloadButton, + frame: displayOptions.showFrame, + icon: displayOptions.showH5PIcon }, fullScreen: supportsFullscreen ? '1' : '0', jsonContent: JSON.stringify(parameters), @@ -342,7 +364,8 @@ export default class H5PPlayer { }, scripts: assets.scripts, styles: assets.styles, - url: this.urlGenerator.uniqueContentUrl(contentId) + url: this.urlGenerator.uniqueContentUrl(contentId), + exportUrl: this.urlGenerator.downloadPackage(contentId) } }, core: { diff --git a/packages/h5p-server/test/H5PPlayer.renderHtmlPage.test.ts b/packages/h5p-server/test/H5PPlayer.renderHtmlPage.test.ts index 456cad989..015bbb844 100644 --- a/packages/h5p-server/test/H5PPlayer.renderHtmlPage.test.ts +++ b/packages/h5p-server/test/H5PPlayer.renderHtmlPage.test.ts @@ -62,7 +62,8 @@ describe('Rendering the HTML page', () => { }, "scripts":[], "styles":[], - "url":"foo" + "url":"foo", + "exportUrl": "/h5p/download/foo" } }, "core":{ @@ -440,7 +441,8 @@ describe('Rendering the HTML page', () => { }, "scripts":[], "styles":[], - "url":"foo" + "url":"foo", + "exportUrl": "/h5p/download/foo" } }, "core":{ diff --git a/packages/h5p-webcomponents/src/h5p-editor.ts b/packages/h5p-webcomponents/src/h5p-editor.ts index acb06456d..210ab1049 100644 --- a/packages/h5p-webcomponents/src/h5p-editor.ts +++ b/packages/h5p-webcomponents/src/h5p-editor.ts @@ -4,24 +4,6 @@ import { mergeH5PIntegration } from './h5p-utils'; import { addScripts } from './dom-utils'; declare global { - interface Window { - /** - * The global H5P "class" of the H5P client core. - */ - H5P: any; - /** - * Used by the H5P core to communicate settings between the server and - * the H5P core client. - */ - H5PIntegration: any; - /** - * We keep track of whether h5p is initialized globally to avoid - * resetting settings when we load another editor component. As the H5P - * core works with globals and this state must be shared with the player - * component as well, we have to use a global here, too. - */ - h5pIsInitialized: boolean; - } /** * The H5P core "class" for the editor. */ @@ -379,7 +361,7 @@ export class H5PEditorComponent extends HTMLElement { // We have to prevent H5P from initializing when the h5p.js file is // loaded. if (!window.H5P) { - window.H5P = {}; + window.H5P = {} as any; } window.H5P.preventInit = true; diff --git a/packages/h5p-webcomponents/src/h5p-player.ts b/packages/h5p-webcomponents/src/h5p-player.ts index 67a7f7eea..6113b0612 100644 --- a/packages/h5p-webcomponents/src/h5p-player.ts +++ b/packages/h5p-webcomponents/src/h5p-player.ts @@ -2,20 +2,7 @@ import type { IPlayerModel } from '@lumieducation/h5p-server'; import { mergeH5PIntegration, removeUnusedContent } from './h5p-utils'; import { addScripts, addStylesheets } from './dom-utils'; - -declare global { - interface Window { - /** - * The global H5P "class" of the H5P client core. - */ - H5P: any; - /** - * Used by the H5P core to communicate settings between the server and - * the H5P core client. - */ - H5PIntegration: any; - } -} +import { IH5P, IH5PInstance } from './h5p-types'; export interface IxAPIEvent { data: { @@ -39,6 +26,37 @@ export class H5PPlayerComponent extends HTMLElement { this.setAttribute('content-id', contentId); } + /** + * The internal H5P instance object of the H5P content. + * + * Only available after the `initialized` event was fired. Important: This + * object is only partially typed and there are more properties and methods + * on it! + */ + get h5pInstance(): IH5PInstance { + return this.h5pInstanceInternal; + } + private set h5pInstance(value: IH5PInstance) { + this.h5pInstanceInternal = value; + } + + /** + * The global H5P object / namespace (normally accessible through "H5P..." + * or "window.H5P") of the content type. Depending on the embed type this + * can be an object from the internal iframe, so you can use it to break the + * barrier of the iframe and execute JavaScript inside the iframe. + * + * Only available after the `initialized` event was fired. Important: This + * object is only partially typed and there are more properties and methods + * on it! + */ + get h5pObject(): IH5P { + return this.h5pObjectInternal; + } + private set h5pObject(value: IH5P) { + this.h5pObjectInternal = value; + } + /** * Called when the component needs to load data about content. The endpoint * called in here should call H5PPlayer.render() and send back the player @@ -84,6 +102,8 @@ export class H5PPlayerComponent extends HTMLElement { ) => Promise; private resizeObserver: ResizeObserver; private root: HTMLElement; + private h5pInstanceInternal: IH5PInstance; + private h5pObjectInternal: IH5P; private static initTemplate(): void { // We create the static template only once @@ -173,18 +193,68 @@ export class H5PPlayerComponent extends HTMLElement { } } + /** + * Returns the copyright notice in HTML that you can insert somewhere to + * display it. Undefined if there is no copyright information. + */ + public getCopyrightHtml(): string | undefined { + if (!this.h5pInstance) { + console.error( + 'Cannot show copyright as H5P instance is undefined. The H5P object might not be initialized yet.' + ); + return ''; + } + if (!this.h5pObject) { + console.error( + 'H5P object undefined. This typically means H5P has not been initialized yet.' + ); + return ''; + } + return this.h5pObject.getCopyrights( + this.h5pInstance, + this.h5pInstance.params, + this.playerModel.contentId, + this.h5pInstance.contentData.metadata + ); + } + + /** + * @returns true if there is copyright information to be displayed. + */ + public hasCopyrightInformation(): boolean { + return !!this.getCopyrightHtml(); + } + + /** + * Displays the copyright notice in the regular H5P way. + */ + public showCopyright(): void { + const copyrightHtml = this.getCopyrightHtml(); + const dialog = new this.h5pObject.Dialog( + 'copyrights', + this.h5pObject.t('copyrightInformation'), + copyrightHtml, + this.h5pObject.jQuery('.h5p-container') + ); + dialog.open(true); + } + /** * Called when any H5P content signals that it was initialized */ private onContentInitialized = (): void => { - if ( - this.playerModel.embedTypes.includes('div') - ? window.H5P.instances[0] - : (document.getElementById( - `h5p-iframe-${this.playerModel.contentId}` - ) as HTMLIFrameElement).contentWindow.H5P?.instances - ?.length >= 1 - ) { + const divMode = this.playerModel.embedTypes.includes('div'); + this.h5pObject = divMode + ? window.H5P + : (document.getElementById( + `h5p-iframe-${this.playerModel.contentId}` + ) as HTMLIFrameElement).contentWindow.H5P; + this.h5pInstance = this.h5pObject?.instances?.find( + // H5P converts our string contentId into number, so we don't use === + // eslint-disable-next-line eqeqeq + (i) => i.contentId == this.contentId + ); + if (this.h5pInstance) { this.dispatchEvent( new CustomEvent('initialized', { detail: { contentId: this.contentId } @@ -241,7 +311,7 @@ export class H5PPlayerComponent extends HTMLElement { // We have to prevent H5P from initializing when the h5p.js file is // loaded. if (!window.H5P) { - window.H5P = {}; + window.H5P = {} as any; } window.H5P.preventInit = true; diff --git a/packages/h5p-webcomponents/src/h5p-types.ts b/packages/h5p-webcomponents/src/h5p-types.ts new file mode 100644 index 000000000..3a7721985 --- /dev/null +++ b/packages/h5p-webcomponents/src/h5p-types.ts @@ -0,0 +1,61 @@ +import type { IIntegration } from '@lumieducation/h5p-server'; + +export interface IH5PInstance { + contentId: string; + contentData: { + metadata: any; + standalone: boolean; + }; + params: any; + trigger: (event: string, eventData?: any, extras?: any) => void; +} + +export interface IH5PDialog { + new (name: string, title: string, content: string, $element: any); + open(scrollbar: boolean): void; + close(): void; +} + +export interface IH5PEventDispatcher { + on(eventName: string, callback: (event: any) => void, that?: any); + off(eventName: string, callback: (event: any) => void, that?: any); +} + +export interface IH5P { + [key: string]: any; + instances: IH5PInstance[]; + getCopyrights: ( + instance: IH5PInstance, + parameters, + contentId, + metadata + ) => string; + triggerXAPI(verb: string, extra: any): void; + Dialog: IH5PDialog; + externalDispatcher: IH5PEventDispatcher; + init(root: any); + preventInit: boolean; + jQuery: any; +} + +declare global { + interface Window { + /** + * The global H5P "class" of the H5P client core. + */ + H5P: IH5P; + /** + * Used by the H5P core to communicate settings between the server and + * the H5P core client. + */ + H5PIntegration: IIntegration; + /** + * We keep track of whether h5p is initialized globally to avoid + * resetting settings when we load another editor component. As the H5P + * core works with globals and this state must be shared with the player + * component as well, we have to use a global here, too. + * Only used in the editor + */ + h5pIsInitialized: boolean; + } +} diff --git a/packages/h5p-webcomponents/src/index.ts b/packages/h5p-webcomponents/src/index.ts index 8da5366a1..b3ac0576f 100644 --- a/packages/h5p-webcomponents/src/index.ts +++ b/packages/h5p-webcomponents/src/index.ts @@ -1,7 +1,22 @@ import { H5PEditorComponent } from './h5p-editor'; import { H5PPlayerComponent, IxAPIEvent, IContext } from './h5p-player'; +import { + IH5P, + IH5PDialog, + IH5PEventDispatcher, + IH5PInstance +} from './h5p-types'; -export { H5PEditorComponent, H5PPlayerComponent, IxAPIEvent, IContext }; +export { + H5PEditorComponent, + H5PPlayerComponent, + IxAPIEvent, + IContext, + IH5P, + IH5PInstance, + IH5PDialog, + IH5PEventDispatcher +}; export function defineElements(element?: string | string[]): void { if (