diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..c6af081 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: CodeQL config + +# The H5P core as h5p-php-library has it, at the commit vendor/h5p-core/SOURCE.json names, and +# not changed here: a finding in it is upstream's, reported there and taken with `pnpm core:sync`. +# Until #37 the same code came from h5p-standalone under node_modules/, which was not scanned either. +paths-ignore: + - packages/runtime/vendor diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5d8cb5..cce1f01 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,4 +25,5 @@ jobs: with: languages: javascript-typescript build-mode: none + config-file: ./.github/codeql/codeql-config.yml - uses: github/codeql-action/analyze@v4 diff --git a/AGENTS.md b/AGENTS.md index 360b967..a7c6638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,8 @@ packages/player/ @missing-elements/h5p-offline-player — the published package-reader.ts zip index + per-entry serving strategy pack-plan.ts which parts of the pack a package needs, and which of its own folders they replace (`planPack`): pure, unit-tested in tests/unit/pack-plan.test.ts + library-load.ts what a frame loads, its libraries' scripts and styles in order (`LibraryLoad`), + which the `boot` route answers: h5p-standalone's walk and sort, ported (#37) frame-document.ts the frame HTML the worker synthesizes, and its CSP; it names the runtime's frame-boot.js with `\n` : '' + const legacyLoader = loader ? `\n` : '' // The last rule in the style block is for H5P.Video's YouTube handler, which pins its iframe // over the 16:9 box it builds with one line that reaches into the YouTube API object's minified @@ -416,8 +426,7 @@ ${fontFaces}
- -${strings} +${legacyLoader}${strings} ` diff --git a/packages/player/src/sw/library-load.ts b/packages/player/src/sw/library-load.ts new file mode 100644 index 0000000..1943a2d --- /dev/null +++ b/packages/player/src/sw/library-load.ts @@ -0,0 +1,193 @@ +import type { LibraryLoad } from '../shared/protocol' +import { dependenciesIn, type LibraryDependency, type PackageManifest } from './package-reader' + +/** + * What the frame loads, and in what order: every library the content needs, its scripts and its + * stylesheets, dependencies first. Until the core was vendored (#37) h5p-standalone worked this out + * in the frame, asking the virtual server for each `library.json` in turn, after a probe of + * whether the package names its folders with versions; the worker has the archives and the rules + * by which a folder answers, so it answers the frame with the result instead, on the `boot` route. + * + * The order is h5p-standalone's to the letter — the same walk, the same topological sort with the + * same tie-breaks — because it is the order every content type has been played in here, the + * sweep's included, and the order among libraries that do not depend on each other is a choice + * the content was tested against. Two things differ, neither of which changes the order of any + * package measured on 2026-10-18 (the demo's, the fixtures and the sweep's 55): a library is named + * by the folder it is served from, so two requests that one folder answers — content asking for + * 1.0 where 1.1 is served, and a library asking for 1.1 — load it once rather than twice; and a + * dependency cycle, which h5p-standalone threw on, is broken where it closes. + */ + +/** One library as the walk found it: the folder that answered, and what its `library.json` says. */ +export interface WalkedLibrary { + folder: string + machineName: string + major: number + minor: number + /** Its `preloadedDependencies`, as the dependencies were written. */ + dependencies: LibraryDependency[] + /** Its `preloadedJs` and `preloadedCss`, as entry names under the package's root. */ + scripts: string[] + styles: string[] +} + +/** How the walk finds a library: the folder that answers for it and its parsed `library.json`, or null. */ +export type LibraryLookup = (dependency: LibraryDependency) => Promise<{ folder: string; library: unknown } | null> + +/** A dependency by name and version, as a map key; a folder may answer for more than one. */ +const keyOf = (dependency: LibraryDependency) => `${dependency.machineName} ${dependency.majorVersion}.${dependency.minorVersion}` + +/** + * The libraries `roots` need, in the order h5p-standalone's `loadDependencies` found them: the roots + * first, then a level at a time, each level the dependencies the one before it named that are not + * found yet. The lookups of a level run together. A library nothing answers for is named in + * `missing`, as the folder its version would have, and its own dependencies go unwalked. + */ +export async function walkLibraries( + roots: readonly LibraryDependency[], + lookup: LibraryLookup +): Promise<{ libraries: WalkedLibrary[]; folderOf: Map; missing: string[] }> { + const libraries: WalkedLibrary[] = [] + const byFolder = new Map() + /** Which folder answered for each dependency, by `keyOf`. */ + const folderOf = new Map() + const missing: string[] = [] + const asked = new Set() + /** Those not asked for yet, each once, and from now on asked. */ + const unasked = (dependencies: LibraryDependency[]) => + dependencies.filter((dependency) => { + const key = keyOf(dependency) + if (asked.has(key)) return false + asked.add(key) + return true + }) + + let level = unasked([...roots]) + while (level.length > 0) { + const answers = await Promise.all(level.map((dependency) => lookup(dependency))) + const found: WalkedLibrary[] = [] + answers.forEach((answer, index) => { + const dependency = level[index] + if (!answer) { + missing.push(`${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}`) + return + } + folderOf.set(keyOf(dependency), answer.folder) + if (byFolder.has(answer.folder)) return + const library = walkedLibrary(answer.folder, answer.library, dependency) + byFolder.set(answer.folder, library) + libraries.push(library) + found.push(library) + }) + level = unasked(found.flatMap((library) => library.dependencies)) + } + return { libraries, folderOf, missing } +} + +/** + * The scripts and stylesheets of `libraries`, dependencies first: h5p-standalone's + * `sortDependencies`, edge for edge. A library with no dependencies is an edge of one, whose missing + * end `toposort` takes for a node of its own; that node decides nothing but the tie-breaks, and it is + * kept so that they come out the same. + */ +export function loadOrder(libraries: readonly WalkedLibrary[], folderOf: ReadonlyMap): { scripts: string[]; styles: string[] } { + const edges: Array<[string, string | undefined]> = [] + const byFolder = new Map(libraries.map((library) => [library.folder, library])) + for (const library of libraries) { + const dependencies = library.dependencies.flatMap((dependency) => folderOf.get(keyOf(dependency)) ?? []) + if (dependencies.length === 0) edges.push([library.folder, undefined]) + for (const dependency of dependencies) edges.push([library.folder, dependency]) + } + + const scripts: string[] = [] + const styles: string[] = [] + for (const folder of toposort(edges).reverse()) { + const library = folder === undefined ? undefined : byFolder.get(folder) + if (!library) continue + scripts.push(...library.scripts) + styles.push(...library.styles) + } + return { scripts, styles } +} + +/** + * What the frame loads for a package whose libraries `walkLibraries` found from `manifest`'s + * dependencies: the main library at the version served, the files in order, and the folders the + * core's `H5P.getLibraryPath` cannot name by version — `libraryDirectories`, for a package that + * keeps a library in a folder named without one. `null` when the main library is not among them. + */ +export function libraryLoad( + manifest: PackageManifest, + walk: { libraries: readonly WalkedLibrary[]; folderOf: ReadonlyMap } +): Omit | null { + const main = (manifest.preloadedDependencies ?? []).find((dependency) => dependency.machineName === manifest.mainLibrary) + const mainFolder = main && walk.folderOf.get(keyOf(main)) + const served = mainFolder === undefined ? undefined : walk.libraries.find((library) => library.folder === mainFolder) + if (!served) return null + + const directories: Record = {} + for (const library of walk.libraries) { + const named = `${library.machineName}-${library.major}.${library.minor}` + if (library.folder !== named) directories[named] = library.folder + } + return { library: `${served.machineName} ${served.major}.${served.minor}`, ...loadOrder(walk.libraries, walk.folderOf), directories } +} + +/** A `library.json` as the walk needs it; untrusted, so every field is held to its shape. */ +function walkedLibrary(folder: string, library: unknown, asked: LibraryDependency): WalkedLibrary { + const json = (library ?? {}) as Record + const version = (part: unknown, fallback: number) => + typeof part === 'number' && Number.isInteger(part) && part >= 0 ? part : typeof part === 'string' && /^\d+$/.test(part) ? Number(part) : fallback + return { + folder, + machineName: typeof json.machineName === 'string' ? json.machineName : asked.machineName, + major: version(json.majorVersion, Number(asked.majorVersion)), + minor: version(json.minorVersion, Number(asked.minorVersion)), + dependencies: dependenciesIn(json), + scripts: filesIn(folder, json.preloadedJs), + styles: filesIn(folder, json.preloadedCss) + } +} + +/** + * A `preloadedJs` or `preloadedCss` as entry names under the package's root, `/`, in + * order. A path that would leave the folder — absolute, with a scheme, or climbing out with `..` — + * is left out: the file comes from an archive, and a page names it as a script. + */ +export function filesIn(folder: string, files: unknown): string[] { + if (!Array.isArray(files)) return [] + return files.flatMap((file) => { + const path = (file as { path?: unknown } | null)?.path + if (typeof path !== 'string' || !path || /^[\\/]|^[a-z][a-z\d+.-]*:|(?:^|[\\/])\.\.(?:[\\/]|$)/i.test(path)) return [] + return [`${folder}/${path}`] + }) +} + +/** + * `toposort` 2.0.2 by Marcel Klehr (MIT), the sort h5p-standalone bundles, ported as it is but for a + * cycle: where it threw, the edge that closes one is passed over, since a node counts as visited + * from the moment it is entered, and the rest still sorts. Every node comes out before the nodes + * its edges point at; the last node first seen is visited first, and a node's edges last to first. + */ +export function toposort(edges: ReadonlyArray): T[] { + const nodes = [...new Set(edges.flatMap(([from, to]) => [from, to]))] + const outgoing = new Map>() + for (const [from, to] of edges) { + if (!outgoing.has(from)) outgoing.set(from, new Set()) + if (!outgoing.has(to)) outgoing.set(to, new Set()) + outgoing.get(from)!.add(to) + } + + let cursor = nodes.length + const sorted = new Array(cursor) + const visited = new Set() + const visit = (node: T) => { + if (visited.has(node)) return + visited.add(node) + const children = [...(outgoing.get(node) ?? [])] + for (let child = children.length - 1; child >= 0; child--) visit(children[child]) + sorted[--cursor] = node + } + for (let index = nodes.length - 1; index >= 0; index--) visit(nodes[index]) + return sorted +} diff --git a/packages/player/src/sw/mount.ts b/packages/player/src/sw/mount.ts index 03747df..74ee141 100644 --- a/packages/player/src/sw/mount.ts +++ b/packages/player/src/sw/mount.ts @@ -28,6 +28,7 @@ import { contentTypeOf } from '../shared/mime' import { PlayerError, type FromWorkerMessage, + type LibraryLoad, type LoadSettings, type PackOffer, type PackPlan, @@ -38,7 +39,8 @@ import { import { contentRange, parseRange, type ByteRange } from '../shared/range' import { openSource } from '../shared/source' import * as db from '../shared/idb' -import { type IndexedEntry, type LocatedEntry, PackageReader, explainMissingLibraries, locationOf } from './package-reader' +import { type IndexedEntry, type LocatedEntry, PackageReader, dependenciesIn, explainMissingLibraries, libraryFolderNames, locationOf } from './package-reader' +import { libraryLoad, walkLibraries } from './library-load' import { STORED } from '../shared/local-header' import { CONTENT_CHECK_HEADER, buildFrameDocument, createNonce } from './frame-document' import { loadSegment, matchRoute, routesFor, type LoadRef, type RouteMatch, type Routes } from './routes' @@ -318,6 +320,9 @@ class VirtualServer { case 'frame': return await this.serveFrame(match) + case 'boot': + return await this.serveBoot(match) + case 'entry': return await this.serveEntry(event, match, match.path) @@ -346,18 +351,23 @@ class VirtualServer { await bookkeeping(db.touchPackage(load.pkgId)) - // From the reader the element's `index` left open, never a new one: a picked file's reader - // asks the page for the file through the frame, and the frame is what is being served. With - // no reader the list is left out, and the frame asks for each script and takes a 404 as none. - const upgrades = await this.readers.get(loadSegment(load))?.then( - (reader) => reader.foldersWithUpgrades(), - () => undefined - ) + // For a runtime up to 0.2, whose boot script takes the list from the document: from the + // reader the element's `index` left open, never a new one, since a picked file's reader asks + // the page for the file through the frame, and the frame is what is being served. With no + // reader the list is left out, and the frame asks for each script and takes a 404 as none. A + // later runtime asks the `boot` route, whose answer carries it. + const upgrades = settings.frameAssets.mainJs + ? await this.readers.get(loadSegment(load))?.then( + (reader) => reader.foldersWithUpgrades(), + () => undefined + ) + : undefined const nonce = createNonce() const body = buildFrameDocument({ pkgId: load.pkgId, virtualRoot: `${this.routes.virtual}${loadSegment(load)}`, + bootUrl: `${this.routes.boot}${loadSegment(load)}`, assets: settings.frameAssets, nonce, title: record.title, @@ -380,6 +390,57 @@ class VirtualServer { }) } + /** + * What the frame loads (`LibraryLoad`): every library the content needs, its scripts and + * stylesheets dependencies first, as `sw/library-load.ts` works them out from the manifest the + * runtime is served — the merged one when archives are attached. The boot script of a runtime + * after 0.2 asks for it as it starts. Asked by the frame rather than written into its document, + * because only a request from the frame can open a reader the worker lost on a restart: for a + * picked file the page is asked for the file through the frame, which, while its own document is + * being served, does not exist yet. + * + * A library is looked up the way `get()` serves its files — the folder its version names, the + * bare machine name, a compatible newer minor — and on an archive still arriving it is waited + * for as any entry the frame asks for is. One that nothing answers for is named in the refusal. + */ + private async serveBoot(load: LoadRef): Promise { + // `let`: a wait on a partial reader hands back the reader to go on with, which is a new one + // once the archive is whole, as in `serveEntry`. + let reader = await this.refresh(load, await this.reader(load)) + const manifest = reader.hasFallbacks ? await reader.mergedManifest() : reader.manifest + // A download that stops moving is a stall, answered with a retry as an entry's is, and not a + // library the package lacks: a lookup that found one ends the walk's waiting. + let stalled: string | undefined + const walk = await walkLibraries(dependenciesIn(manifest), async (dependency) => { + for (const folder of libraryFolderNames(dependency)) { + const name = `${folder}/library.json` + let located = reader.get(name) + if (!located && reader.partial && !stalled) { + const outcome = await this.awaitEntry(load, reader, name) + if (outcome === 'stalled') { + stalled = name + return null + } + reader = outcome.reader + located = outcome.located + } + if (located) { + const served = located.entry.name + return { folder: served.slice(0, served.indexOf('/')), library: await this.readJson(located) } + } + } + return null + }) + if (stalled) return retryLater(`The download has stalled before ${stalled}`) + if (walk.missing.length > 0) { + const missingLibraries = { mainLibrary: manifest.mainLibrary, folders: walk.missing.sort(), all: false } + throw new PlayerError('bad-archive', explainMissingLibraries(missingLibraries), { missingLibraries }) + } + const libraries = libraryLoad(manifest, walk) + if (!libraries) throw new PlayerError('bad-archive', `h5p.json names ${String(manifest.mainLibrary)} as its main library, and does not list it among its dependencies`) + return json({ ...libraries, upgrades: reader.foldersWithUpgrades() } satisfies LibraryLoad) + } + /* ---------------------------------------------------------------- package entries */ private async serveEntry(event: FetchEvent, load: LoadRef, rawPath: string): Promise { diff --git a/packages/player/src/sw/package-reader.ts b/packages/player/src/sw/package-reader.ts index f4163bf..2ed4124 100644 --- a/packages/player/src/sw/package-reader.ts +++ b/packages/player/src/sw/package-reader.ts @@ -1047,7 +1047,7 @@ export function textMatches(params: unknown, patterns: RegExp[]): boolean { } /** The well-formed entries of a manifest's or a `library.json`'s `preloadedDependencies`. */ -function dependenciesIn(value: unknown): LibraryDependency[] { +export function dependenciesIn(value: unknown): LibraryDependency[] { const dependencies = (value as { preloadedDependencies?: unknown } | null)?.preloadedDependencies if (!Array.isArray(dependencies)) return [] const version = (part: unknown) => typeof part === 'number' || (typeof part === 'string' && /^\d+$/.test(part)) @@ -1058,10 +1058,10 @@ function dependenciesIn(value: unknown): LibraryDependency[] { } /** - * The two folder names a library may be stored under. h5p-standalone probes the versioned one - * first and falls back to the bare machine name, so a package is usable with either. + * The two folder names a library may be stored under: the versioned one, and the bare machine + * name older packages use. Each is tried in that order, as h5p-standalone tried them. */ -function libraryFolderNames(dependency: LibraryDependency): string[] { +export function libraryFolderNames(dependency: LibraryDependency): string[] { return [ `${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}`, dependency.machineName diff --git a/packages/player/src/sw/routes.ts b/packages/player/src/sw/routes.ts index 456d2ed..671bf19 100644 --- a/packages/player/src/sw/routes.ts +++ b/packages/player/src/sw/routes.ts @@ -1,7 +1,8 @@ /** * URL shape of the virtual routes. Our own registration uses the scope `h5p/`, so routes * sit directly under it; `mountH5P` puts them at `h5p/`. Both spell the same thing — - * `…/h5p/virtual/…` and `…/h5p/frame/…` — so everything downstream works off one base. + * `…/h5p/virtual/…`, `…/h5p/frame/…` and `…/h5p/boot/…` — so everything downstream works off one + * base. */ import { LOAD_KEY_BYTES, PKG_ID_LENGTH } from '../shared/pkg-id' @@ -11,6 +12,8 @@ export interface Routes { base: string virtual: string frame: string + /** What a frame loads, its libraries in order (`LibraryLoad`), which its boot script asks for. */ + boot: string ping: string } @@ -22,6 +25,7 @@ export function routesFor(scope: string): Routes { base, virtual: `${base}virtual/`, frame: `${base}frame/`, + boot: `${base}boot/`, ping: `${base}virtual/_ping` } } @@ -38,6 +42,7 @@ export interface LoadRef { export type RouteMatch = | { kind: 'ping' } | ({ kind: 'frame' } & LoadRef) + | ({ kind: 'boot' } & LoadRef) | ({ kind: 'entry'; path: string } & LoadRef) | { kind: 'none' } @@ -58,9 +63,10 @@ function parseLoad(segment: string): LoadRef | null { export function matchRoute(routes: Routes, url: string): RouteMatch { if (url === routes.ping) return { kind: 'ping' } - if (url.startsWith(routes.frame)) { - const load = parseLoad(url.slice(routes.frame.length).split(/[/?#]/, 1)[0]) - return load ? { kind: 'frame', ...load } : { kind: 'none' } + for (const kind of ['frame', 'boot'] as const) { + if (!url.startsWith(routes[kind])) continue + const load = parseLoad(url.slice(routes[kind].length).split(/[/?#]/, 1)[0]) + return load ? { kind, ...load } : { kind: 'none' } } if (url.startsWith(routes.virtual)) { diff --git a/packages/player/tests/browser/l10n.test.ts b/packages/player/tests/browser/l10n.test.ts index 15a1c8c..0bb3f79 100644 --- a/packages/player/tests/browser/l10n.test.ts +++ b/packages/player/tests/browser/l10n.test.ts @@ -93,7 +93,6 @@ describe('the core’s strings', () => { it('come from the runtime property’s own files when a host hands them over, and stay English when it has none', async () => { const base = new URL('/frame-assets/', location.href).href const runtime = { - mainJs: `${base}main.bundle.js`, frameJs: `${base}frame.bundle.js`, frameCss: `${base}h5p.css`, bootJs: `${base}frame-boot.js`, diff --git a/packages/player/tests/browser/options.test.ts b/packages/player/tests/browser/options.test.ts index 6734932..1a80dbc 100644 --- a/packages/player/tests/browser/options.test.ts +++ b/packages/player/tests/browser/options.test.ts @@ -4,7 +4,8 @@ import '../../src/h5p-offline-player' import { FIXTURES, createPlayer, frameDocument, frameWindow, play, waitForEvent } from './utils' /** - * h5p-standalone's display and loading options, as the element's attributes. Each is read off the + * The display and loading options, by the names h5p-standalone gave them, as the element's + * attributes. Each is read off the * runtime's own configuration (`H5PIntegration`) or the frame's DOM, not off the element, so what * is pinned is what the content actually sees. */ @@ -43,7 +44,7 @@ async function complete(player: H5PPlayerElement): Promise { return (await xapi).detail.statement } -describe('h5p-standalone options', () => { +describe('the display and loading options', () => { it('hides the action bar by default and shows it when asked, with the buttons the content is told to offer', async () => { const plain = await play(FIXTURES.basic) expect(frameDocument(plain).querySelector('.h5p-actions')).toBeNull() diff --git a/packages/player/tests/browser/pack.test.ts b/packages/player/tests/browser/pack.test.ts index 3aae7d9..7d5da3a 100644 --- a/packages/player/tests/browser/pack.test.ts +++ b/packages/player/tests/browser/pack.test.ts @@ -69,6 +69,20 @@ describe('the library pack, split into parts', () => { expect(await loadOf(player)).toMatchObject({ parts: ['H5P.OfflineTest-1.2.h5p'], superseded: ['H5P.OfflineTest'] }) }) + it("plays the pack's folders, named with versions, beside the package's own named without", async () => { + // The content type comes from the pack at 1.2; the extra library, which the pack has at the + // package's own version, from the package's folder named without one. h5p-standalone named + // every folder one way, by its first dependency, and ended its boot on the extra library's 404. + const player = await play(fresh(FIXTURES.unversionedPack, 'mixed'), withPack()) + + expect(messageOf(player)).toBe(`${BASIC_MESSAGE} [1.1] [1.2]`) + expect(await loadOf(player)).toMatchObject({ parts: ['H5P.OfflineTest-1.2.h5p'], superseded: ['H5P.OfflineTest'] }) + const view = frameWindow(player) as Window & { h5pOfflineExtraLoaded?: boolean; H5PIntegration: { libraryDirectories: Record } } + expect(view.h5pOfflineExtraLoaded).toBe(true) + // So that the core's `H5P.getLibraryPath` finds the folder its name does not say. + expect(view.H5PIntegration.libraryDirectories).toEqual({ 'H5P.OfflineExtra-1.0': 'H5P.OfflineExtra' }) + }) + it('plays the package as exported with refresh-libraries="off"', async () => { const player = await play(fresh(FIXTURES.basic, 'off'), withPack({ 'refresh-libraries': 'off' })) diff --git a/packages/player/tests/browser/playback.test.ts b/packages/player/tests/browser/playback.test.ts index 3e71ca6..8c3ed39 100644 --- a/packages/player/tests/browser/playback.test.ts +++ b/packages/player/tests/browser/playback.test.ts @@ -215,9 +215,9 @@ describe('playing a package', () => { }) it('plays a package whose library folders carry no version', async () => { - // Older packages are built this way. h5p-standalone probes the versioned name first and the - // 404 it gets is what tells it to fall back to the bare one, so that 404 has to be a clean - // 404 rather than an error. + // Older packages are built this way. Each library is looked for under the versioned name + // first, and the 404 it gets is what sends the lookup to the bare one, so that 404 has to be a + // clean 404 rather than an error. const player = await play(FIXTURES.unversioned) expect(player.state).toBe('ready') diff --git a/packages/player/tests/browser/runtime.test.ts b/packages/player/tests/browser/runtime.test.ts index 71e3002..9c0d108 100644 --- a/packages/player/tests/browser/runtime.test.ts +++ b/packages/player/tests/browser/runtime.test.ts @@ -11,7 +11,6 @@ import { FIXTURES, createPlayer, frameDocument, waitForSettled } from './utils' describe('the runtime property', () => { const base = new URL('/frame-assets/', location.href).href const runtime = { - mainJs: `${base}main.bundle.js?via=runtime`, frameJs: `${base}frame.bundle.js?via=runtime`, frameCss: `${base}h5p.css?via=runtime`, bootJs: `${base}frame-boot.js?via=runtime`, @@ -29,14 +28,15 @@ describe('the runtime property', () => { expect(player.state).toBe('ready') const scripts = [...frameDocument(player).querySelectorAll('script[src]')].map((script) => script.getAttribute('src')) - expect(scripts).toContain(runtime.mainJs) + // The boot script under the nonce, and the core it loads after it. expect(scripts).toContain(runtime.bootJs) + expect(scripts).toContain(runtime.frameJs) expect(frameDocument(player).querySelector('style')?.textContent).toContain(runtime.fonts[0].url) }) - it('refuses a value missing one of the four files, since that frame would never boot', () => { + it('refuses a value missing one of the three files, since that frame would never boot', () => { const player = createPlayer() - player.runtime = { mainJs: 'a', frameJs: 'b', frameCss: 'c' } as never + player.runtime = { frameJs: 'b', frameCss: 'c' } as never expect(player.runtime).toBeNull() player.runtime = runtime expect(player.runtime?.bootJs).toBe(runtime.bootJs) diff --git a/packages/player/tests/browser/upgrades.test.ts b/packages/player/tests/browser/upgrades.test.ts index 519202d..ead6ac5 100644 --- a/packages/player/tests/browser/upgrades.test.ts +++ b/packages/player/tests/browser/upgrades.test.ts @@ -68,8 +68,8 @@ describe('content written for an older minor', () => { }) it('names a main library whose minor is 0 in full', async () => { - // h5p-standalone wrote "H5P.OfflineTest 1", which the core took for the folder - // `H5P.OfflineTest-1.undefined`, and every statement's category with it. + // h5p-standalone, which loaded the runtime until #37, wrote "H5P.OfflineTest 1", which the + // core took for the folder `H5P.OfflineTest-1.undefined`, and every statement's category with it. const player = await play(FIXTURES.basic) expect(content(player).libraryInfo.versionedNameNoSpaces).toBe('H5P.OfflineTest-1.0') }) diff --git a/packages/player/tests/browser/utils.ts b/packages/player/tests/browser/utils.ts index 759babe..f577652 100644 --- a/packages/player/tests/browser/utils.ts +++ b/packages/player/tests/browser/utils.ts @@ -63,7 +63,8 @@ export const FIXTURES = { writtenFor12: '/fixtures/upgrade-1.2.h5p', upgradeFails: '/fixtures/upgrade-fails.h5p', /** Library folders with no version suffix, the shape older packages use. */ - unversioned: '/fixtures/unversioned.h5p' + unversioned: '/fixtures/unversioned.h5p', + unversionedPack: '/fixtures/unversioned-pack.h5p' } as const /** diff --git a/packages/player/tests/browser/virtual-server.test.ts b/packages/player/tests/browser/virtual-server.test.ts index d1397cb..be551d2 100644 --- a/packages/player/tests/browser/virtual-server.test.ts +++ b/packages/player/tests/browser/virtual-server.test.ts @@ -55,7 +55,7 @@ describe('the virtual file server', () => { expect(response.status).toBe(416) }) - it('404s a missing entry, which is how h5p-standalone probes folder naming', async () => { + it('404s a missing entry, which is how a library is looked for under its other folder name', async () => { const player = await play(FIXTURES.basic) const response = await frameFetch(player, virtualUrl(player, 'H5P.OfflineTest/library.json')) expect(response.status).toBe(404) diff --git a/packages/player/tests/unit/frame-document.test.ts b/packages/player/tests/unit/frame-document.test.ts index 02c89f6..cd7a7db 100644 --- a/packages/player/tests/unit/frame-document.test.ts +++ b/packages/player/tests/unit/frame-document.test.ts @@ -13,16 +13,22 @@ const PKG = 'b'.repeat(32) const options = { pkgId: PKG, virtualRoot: `https://site.example/assets/h5p/virtual/${PKG}`, + bootUrl: `https://site.example/assets/h5p/boot/${PKG}`, assets: { - mainJs: 'https://site.example/frame-assets/main.bundle.js', frameJs: 'https://site.example/frame-assets/frame.bundle.js', frameCss: 'https://site.example/frame-assets/h5p.css', bootJs: 'https://site.example/frame-assets/frame-boot.js' - }, + } as { mainJs?: string; frameJs: string; frameCss: string; bootJs?: string }, nonce: 'deadbeef', title: 'A course' } +/** A record an element wrote with the runtime up to 0.2, which boots through h5p-standalone's loader. */ +const legacy = { ...options, assets: { ...options.assets, mainJs: 'https://site.example/frame-assets/main.bundle.js' } } + +/** The boot configuration the document carries. */ +const bootConfigOf = (html: string) => JSON.parse(/id="h5p-boot-config">([^<]*) { it('allows the per-response nonce and nothing else inline', () => { const csp = buildContentSecurityPolicy(options) @@ -34,7 +40,6 @@ describe('buildContentSecurityPolicy', () => { const csp = buildContentSecurityPolicy({ ...options, assets: { - mainJs: 'https://cdn.example/dist/main.bundle.js', frameJs: 'https://cdn.example/dist/frame.bundle.js', frameCss: 'https://cdn.example/dist/frame-assets/h5p.css' } @@ -113,7 +118,6 @@ describe('the policy for an untrusted package', () => { const sources = scriptSources(buildContentSecurityPolicy({ ...options, untrusted: true })) expect(sources).not.toContain("'self'") expect(sources).toContain(`${options.virtualRoot}/`) - expect(sources).toContain(options.assets.mainJs) expect(sources).toContain(options.assets.frameJs) expect(sources).toContain(options.assets.bootJs) expect(sources).toContain("'nonce-deadbeef'") @@ -214,7 +218,7 @@ describe('buildFrameDocument', () => { expect(html).not.toMatch(/' })).not.toContain('"') }) - it('tells the boot script which library folders carry an upgrades.js, and leaves the list out when it has none', () => { - // The boot script fetches upgrade steps only from these, so it never asks for a script that - // is not there; with no list, from a worker older than it, it asks and takes a 404 as none. - expect(buildFrameDocument({ ...options, upgrades: ['H5P.QuestionSet-1.21', 'H5P.Summary-1.10'] })).toContain( - '"upgrades":["H5P.QuestionSet-1.21","H5P.Summary-1.10"]' - ) - expect(html).not.toContain('"upgrades"') + it("names the load's boot route, where the boot script asks what to load, and no loader", () => { + const html = buildFrameDocument({ ...options, upgrades: ['H5P.QuestionSet-1.21'] }) + expect(bootConfigOf(html).boot).toBe(options.bootUrl) + expect(html).not.toContain('main.bundle.js') + // The route answers with the list of folders that carry an `upgrades.js`, with the rest. + expect(bootConfigOf(html).upgrades).toBeUndefined() + }) + + it('writes the document a runtime up to 0.2 expects: its loader first, and the upgrade list', () => { + // A record such an element wrote names h5p-standalone's `main.bundle.js`, whose boot script + // walks the libraries in the frame and takes the folders that carry an `upgrades.js` from here; + // with no list, from a worker older than it, it asks and takes a 404 as none. + const html = buildFrameDocument({ ...legacy, upgrades: ['H5P.QuestionSet-1.21', 'H5P.Summary-1.10'] }) + expect(html).toContain('') + expect(html.indexOf('main.bundle.js')).toBeLessThan(html.indexOf('frame-boot.js')) + expect(html).toContain('"upgrades":["H5P.QuestionSet-1.21","H5P.Summary-1.10"]') + expect(bootConfigOf(html).boot).toBeUndefined() + expect(buildFrameDocument(legacy)).not.toContain('"upgrades"') + expect(buildContentSecurityPolicy({ ...legacy, untrusted: true })).toContain(legacy.assets.mainJs) }) }) @@ -341,13 +357,15 @@ describe('the boot script', () => { expect(source).toContain("document.getElementById('h5p-boot-config')") }) - it('uses div embedding, so there is no inner about:blank frame to inherit a controller', () => { - expect(source).toMatch(/embedType:\s*["']div["']/) + it('mounts the content as a div, so there is no inner about:blank frame to inherit a controller', () => { + expect(source).toContain("content.className = 'h5p-content'") + expect(source).not.toMatch(/createElement\(["']iframe["']\)/) }) it('turns off every result endpoint, because there is no server to post to', () => { expect(source).toMatch(/postUserStatistics:\s*false/) - expect(source).toMatch(/saveFreq:\s*false/) + // Saves go to the element, and only with `resume`. + expect(source).toMatch(/saveFreq:[^\n]*\bresume\b[^\n]*:\s*false,/) }) it('reports a runtime script or stylesheet that fails to load, in the capture phase', () => { @@ -374,7 +392,7 @@ describe('the boot script', () => { describe('the host’s frame options', () => { const withOptions = (frameOptions: object) => buildFrameDocument({ ...options, frameOptions } as typeof options & { frameOptions: object }) - it('ride into the boot config by h5p-standalone’s names', () => { + it('ride into the boot config by the names h5p-standalone gave them', () => { const html = withOptions({ frame: true, copyright: true, reportingIsEnabled: true, activityId: 'https://lms.example/a/1' }) expect(html).toContain('"options":{"frame":true,"copyright":true,"reportingIsEnabled":true,"activityId":"https://lms.example/a/1"}') }) @@ -444,7 +462,7 @@ describe('the package’s metadata', () => { describe('the frame’s language', () => { const l10nJs = 'https://site.example/frame-assets/l10n.js' const translated = { ...options, assets: { ...options.assets, l10nJs } } - const bootLanguage = (html: string) => JSON.parse(/id="h5p-boot-config">([^<]*) bootConfigOf(html).language const htmlTag = (html: string) => /]*>/.exec(html)![0] it('is English when neither the element nor the package says otherwise, with no translations', () => { @@ -457,7 +475,7 @@ describe('the frame’s language', () => { it('names the runtime’s translations ahead of the boot script, and tells it the element’s language', () => { const html = buildFrameDocument({ ...translated, language: 'de-AT' }) const at = html.indexOf(``) - expect(at).toBeGreaterThan(html.indexOf('main.bundle.js')) + expect(at).toBeGreaterThan(0) expect(at).toBeLessThan(html.indexOf('frame-boot.js')) expect(bootLanguage(html)).toBe('de-AT') }) diff --git a/packages/player/tests/unit/libraries.test.ts b/packages/player/tests/unit/libraries.test.ts index d8f1b8c..8bd1c74 100644 --- a/packages/player/tests/unit/libraries.test.ts +++ b/packages/player/tests/unit/libraries.test.ts @@ -35,7 +35,7 @@ describe('findMissingLibraries', () => { expect(missing).toEqual([]) }) - it('accepts a bare machine name, which h5p-standalone falls back to', () => { + it('accepts a bare machine name, the folder older packages use', () => { const missing = findMissingLibraries( entries('H5P.InteractiveVideo/library.json', 'H5P.Video/library.json'), manifest diff --git a/packages/player/tests/unit/library-load.test.ts b/packages/player/tests/unit/library-load.test.ts new file mode 100644 index 0000000..a7c612c --- /dev/null +++ b/packages/player/tests/unit/library-load.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' +import { filesIn, libraryLoad, loadOrder, toposort, walkLibraries, type LibraryLookup } from '../../src/sw/library-load' +import type { LibraryDependency } from '../../src/sw/package-reader' + +/** + * The order the frame loads libraries in has to be h5p-standalone's, which every content type has + * been played in here. So h5p-standalone's own algorithm is kept below as the oracle: its + * `loadDependencies` and `sortDependencies` from `src/h5p-standalone.ts` at v3.8.2, with the + * `toposort` 2.0.2 it bundles (Marcel Klehr, MIT), each as it was written but for types. + */ +function referenceToposort(edges: Array>): Array { + const nodes = Array.from(new Set(edges.flatMap((edge) => [edge[0], edge[1]]))) + let cursor = nodes.length + const sorted = new Array(cursor) + const visited: Record = {} + let i = cursor + const outgoingEdges = new Map>() + for (const edge of edges) { + if (!outgoingEdges.has(edge[0])) outgoingEdges.set(edge[0], new Set()) + if (!outgoingEdges.has(edge[1])) outgoingEdges.set(edge[1], new Set()) + outgoingEdges.get(edge[0])!.add(edge[1]) + } + const nodesHash = new Map(nodes.map((node, index) => [node, index])) + const visit = (node: string | undefined, index: number, predecessors: Set) => { + if (predecessors.has(node)) throw new Error('Cyclic dependency') + if (visited[index]) return + visited[index] = true + const outgoing = Array.from(outgoingEdges.get(node) || new Set()) + let n = outgoing.length + if (n) { + predecessors.add(node) + do { + const child = outgoing[--n] + visit(child, nodesHash.get(child)!, predecessors) + } while (n) + predecessors.delete(node) + } + sorted[--cursor] = node + } + while (i--) if (!visited[i]) visit(nodes[i], i, new Set()) + return sorted +} + +interface Fake { + folder: string + dependencies: string[] + js: string[] + css: string[] +} + +function referenceOrder(roots: string[], libraries: Map): { scripts: string[]; styles: string[] } { + // loadDependencies: a level at a time, duplicates in a level and all. + const dependencies: Fake[] = [] + let toFind = roots + while (toFind.length > 0) { + const discovered = toFind.map((folder) => libraries.get(folder)!) + const findNext: string[] = [] + for (const library of discovered) { + dependencies.push(library) + for (const dependency of library.dependencies) { + const found = dependencies.find((each) => each.folder === dependency) + const inLevel = discovered.find((each) => each.folder === dependency) + if (!found && !inLevel) findNext.push(dependency) + } + } + toFind = findNext + } + // sortDependencies. + const graph: Array> = [] + const css: Record = {} + const js: Record = {} + for (const dependency of dependencies) { + if (dependency.dependencies.length === 0) graph.push([dependency.folder]) + for (const node of dependency.dependencies) graph.push([dependency.folder, node]) + css[dependency.folder] = dependency.css.map((path) => `${dependency.folder}/${path}`) + js[dependency.folder] = dependency.js.map((path) => `${dependency.folder}/${path}`) + } + const styles: string[] = [] + const scripts: string[] = [] + for (const name of referenceToposort(graph).reverse()) { + if (name === undefined) continue + styles.push(...css[name]) + scripts.push(...js[name]) + } + return { scripts, styles } +} + +/** A small, seeded generator, so a failure names a graph that can be played again. */ +function random(seed: number) { + let state = seed + return () => { + state = (state * 1103515245 + 12345) % 2147483648 + return state / 2147483648 + } +} + +const dependencyOf = (folder: string): LibraryDependency => { + const [, machineName, major, minor] = /^(.+)-(\d+)\.(\d+)$/.exec(folder)! + return { machineName, majorVersion: Number(major), minorVersion: Number(minor) } +} + +/** The walk's view of the fakes: each answered from its own folder. */ +const lookupIn = + (libraries: Map): LibraryLookup => + async (dependency) => { + const folder = `${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}` + const library = libraries.get(folder) + if (!library) return null + return { + folder, + library: { + machineName: dependency.machineName, + majorVersion: dependency.majorVersion, + minorVersion: dependency.minorVersion, + preloadedDependencies: library.dependencies.map(dependencyOf), + preloadedJs: library.js.map((path) => ({ path })), + preloadedCss: library.css.map((path) => ({ path })) + } + } + } + +describe('the order the frame loads libraries in', () => { + it("is h5p-standalone's, on five hundred dependency graphs", async () => { + for (let seed = 1; seed <= 500; seed++) { + const next = random(seed) + const count = 1 + Math.floor(next() * 14) + const folders = Array.from({ length: count }, (_, index) => `H5P.L${index}-1.${Math.floor(next() * 3)}`) + const libraries = new Map() + folders.forEach((folder, index) => { + // Each depends only on libraries after it, so the graph has no cycle, in a random order. + const later = folders.slice(index + 1).filter(() => next() < 0.35) + later.sort(() => next() - 0.5) + const js = next() < 0.85 ? ['a.js', ...(next() < 0.3 ? ['b.js'] : [])] : [] + const css = next() < 0.6 ? ['a.css'] : [] + libraries.set(folder, { folder, dependencies: later, js, css }) + }) + const roots = folders.filter((_, index) => index === 0 || next() < 0.3) + roots.sort(() => next() - 0.5) + + const walk = await walkLibraries(roots.map(dependencyOf), lookupIn(libraries)) + expect(loadOrder(walk.libraries, walk.folderOf), `seed ${seed}`).toEqual(referenceOrder(roots, libraries)) + } + }) + + it('loads a library once when two versions ask for the folder that serves both', async () => { + // Content written for Text 1.0 and a library asking for 1.1, where 1.1 is what is served: + // h5p-standalone named both folders and loaded the file twice. + const lookup: LibraryLookup = async ({ machineName, majorVersion, minorVersion }) => { + if (machineName === 'H5P.Text') return { folder: 'H5P.Text-1.1', library: { machineName, majorVersion: 1, minorVersion: 1, preloadedJs: [{ path: 'text.js' }] } } + return { + folder: `${machineName}-${majorVersion}.${minorVersion}`, + library: { machineName, majorVersion, minorVersion, preloadedDependencies: [{ machineName: 'H5P.Text', majorVersion: 1, minorVersion: 1 }], preloadedJs: [{ path: 'set.js' }] } + } + } + const walk = await walkLibraries( + [ + { machineName: 'H5P.Set', majorVersion: 1, minorVersion: 0 }, + { machineName: 'H5P.Text', majorVersion: 1, minorVersion: 0 } + ], + lookup + ) + expect(walk.libraries.map((library) => library.folder)).toEqual(['H5P.Set-1.0', 'H5P.Text-1.1']) + expect(loadOrder(walk.libraries, walk.folderOf).scripts).toEqual(['H5P.Text-1.1/text.js', 'H5P.Set-1.0/set.js']) + }) + + it('breaks a dependency cycle where it closes, where h5p-standalone threw', async () => { + const libraries = new Map([ + ['H5P.A-1.0', { folder: 'H5P.A-1.0', dependencies: ['H5P.B-1.0'], js: ['a.js'], css: [] }], + ['H5P.B-1.0', { folder: 'H5P.B-1.0', dependencies: ['H5P.A-1.0'], js: ['b.js'], css: [] }] + ]) + expect(() => referenceOrder(['H5P.A-1.0'], libraries)).toThrow('Cyclic dependency') + const walk = await walkLibraries([dependencyOf('H5P.A-1.0')], lookupIn(libraries)) + // The sort enters at B, the last node first seen, and reaches A, whose edge back to B closes + // the cycle and is passed over: A counts as B's dependency, and loads first. + expect(loadOrder(walk.libraries, walk.folderOf).scripts).toEqual(['H5P.A-1.0/a.js', 'H5P.B-1.0/b.js']) + }) + + it('names what nothing answers for, and does not walk below it', async () => { + const libraries = new Map([['H5P.A-1.0', { folder: 'H5P.A-1.0', dependencies: ['H5P.Gone-2.3'], js: ['a.js'], css: [] }]]) + const walk = await walkLibraries([dependencyOf('H5P.A-1.0')], lookupIn(libraries)) + expect(walk.missing).toEqual(['H5P.Gone-2.3']) + expect(walk.libraries.map((library) => library.folder)).toEqual(['H5P.A-1.0']) + }) +}) + +describe('the sort', () => { + it('puts every node before the nodes its edges point at', () => { + expect(toposort([['a', 'b'], ['b', 'c'], ['a', 'c']])).toEqual(['a', 'b', 'c']) + }) +}) + +describe("a library's files", () => { + it('are named under its folder, in the order its library.json gives', () => { + expect(filesIn('H5P.A-1.0', [{ path: 'js/a.js' }, { path: 'dist/b.js' }])).toEqual(['H5P.A-1.0/js/a.js', 'H5P.A-1.0/dist/b.js']) + }) + + it('leave out a path that would leave the folder, and anything that is not one', () => { + const paths = ['../other/x.js', 'js/../../x.js', '/x.js', '\\\\x.js', 'https://cdn.example/x.js', 'c:x.js', '..\\\\x.js', '', 7, null] + expect(filesIn('H5P.A-1.0', paths.map((path) => ({ path })))).toEqual([]) + expect(filesIn('H5P.A-1.0', 'js/a.js')).toEqual([]) + }) +}) + +describe('what the frame is told to load', () => { + const walk = (folder: string, majorVersion = 1, minorVersion = 2) => + walkLibraries([{ machineName: 'H5P.Main', majorVersion: 1, minorVersion: 0 }], async () => ({ + folder, + library: { machineName: 'H5P.Main', majorVersion, minorVersion, preloadedJs: [{ path: 'main.js' }] } + })) + const manifest = { mainLibrary: 'H5P.Main', preloadedDependencies: [{ machineName: 'H5P.Main', majorVersion: 1, minorVersion: 0 }] } + + it('names the main library at the version served, in full', async () => { + const load = libraryLoad(manifest, await walk('H5P.Main-1.2')) + expect(load).toEqual({ library: 'H5P.Main 1.2', scripts: ['H5P.Main-1.2/main.js'], styles: [], directories: {} }) + // A minor of 0 is written, where h5p-standalone left it out and the core read the folder as + // `H5P.Main-1.undefined`. + expect(libraryLoad(manifest, await walk('H5P.Main-1.0', 1, 0))?.library).toBe('H5P.Main 1.0') + }) + + it("tells the core where a library is that a folder's name does not say", async () => { + // An older package keeps a library in a folder named without its version; the core's + // `H5P.getLibraryPath` builds the name with one. + expect(libraryLoad(manifest, await walk('H5P.Main'))?.directories).toEqual({ 'H5P.Main-1.2': 'H5P.Main' }) + }) + + it('is nothing when the main library is not among the dependencies', async () => { + expect(libraryLoad({ ...manifest, mainLibrary: 'H5P.Other' }, await walk('H5P.Main-1.2'))).toBeNull() + }) +}) diff --git a/packages/player/tests/unit/merged-manifest.test.ts b/packages/player/tests/unit/merged-manifest.test.ts index dc69e7c..eb1a662 100644 --- a/packages/player/tests/unit/merged-manifest.test.ts +++ b/packages/player/tests/unit/merged-manifest.test.ts @@ -92,7 +92,7 @@ describe('mergedManifest with the pack attached', () => { it('keeps a dependency the pack carries only in a newer minor, as the content wrote it', async () => { const merged = await (await withPack(stripped)).mergedManifest() // 1.20 and 1.16, not the pack's 1.21 and 1.17: the file server maps the folder, and the - // content's own version is what h5p-standalone asks for. + // content's own version is what the runtime asks for. expect(merged.preloadedDependencies?.[0]).toEqual({ machineName: 'H5P.QuestionSet', majorVersion: '1', minorVersion: '20' }) expect(merged.preloadedDependencies?.[1]).toMatchObject({ machineName: 'H5P.MultiChoice', minorVersion: 16 }) }) diff --git a/packages/player/tests/unit/routes.test.ts b/packages/player/tests/unit/routes.test.ts index f2f13d6..ff53447 100644 --- a/packages/player/tests/unit/routes.test.ts +++ b/packages/player/tests/unit/routes.test.ts @@ -33,6 +33,12 @@ describe('matchRoute', () => { expect(matchRoute(routes, `${routes.frame}${PKG}`)).toEqual({ kind: 'frame', pkgId: PKG }) }) + it("matches the frame's question of what to load, under the same load", () => { + expect(routes.boot).toBe('https://site.example/assets/h5p/boot/') + expect(matchRoute(routes, `${routes.boot}${loadSegment({ pkgId: PKG, view: VIEW })}`)).toEqual({ kind: 'boot', pkgId: PKG, view: VIEW }) + expect(matchRoute(routes, `${routes.boot}not-a-package`)).toEqual({ kind: 'none' }) + }) + it('matches an entry request and keeps the whole path', () => { expect(matchRoute(routes, `${routes.virtual}${PKG}/content/images/a.png`)).toEqual({ kind: 'entry', diff --git a/packages/runtime/NOTICE.md b/packages/runtime/NOTICE.md index 857d0f0..99f38f5 100644 --- a/packages/runtime/NOTICE.md +++ b/packages/runtime/NOTICE.md @@ -8,31 +8,35 @@ which is MIT, carries none of this: the player's element and workers exchange on ## The H5P runtime in `dist/` — GPL-3.0 -`dist/` holds the runtime the player loads inside its frame, taken from -[h5p-standalone](https://github.com/tunapanda/h5p-standalone): the scripts' code unmodified, -each given a leading licence comment, the stylesheet rebuilt into `h5p.css` with its icon fonts -and image inlined as data URLs and its Inter and Open Sans faces moved to `fonts/`, listed in -`MANIFEST.json` for the player's frame document to declare. h5p-standalone's own code is MIT -(Copyright (c) 2015 Tunapanda), but `frame.bundle.js` is built from the H5P core scripts in its -`vendor/h5p/js/` — `h5p.js`, `h5p-event-dispatcher.js`, `h5p-x-api.js`, `h5p-x-api-event.js`, -`h5p-content-type.js`, `h5p-confirmation-dialog.js`, `request-queue.js`, `h5p-action-bar.js`, -`h5p-tooltip.js` — which come from [h5p/h5p-php-library](https://github.com/h5p/h5p-php-library), -licensed under the **GNU General Public License v3.0**. The core stylesheet (`h5p.css`), the -`h5p-*` icon fonts and the throbber image inlined in it come from the same repository. Upstream -confirmed this in [tunapanda/h5p-standalone#188](https://github.com/tunapanda/h5p-standalone/issues/188); -its npm metadata still says MIT. +`dist/` holds the runtime the player loads inside its frame: the H5P core from +[h5p/h5p-php-library](https://github.com/h5p/h5p-php-library), licensed under the **GNU General +Public License v3.0**, at the commit `vendor/h5p-core/SOURCE.json` in this package's source names +(`cb64a1f3884408487c3178e31fc140f5d45ca165` of 2026-08-11 as of this writing; `dist/NOTICE.txt` +and `dist/MANIFEST.json` name the one a build took). `frame.bundle.js` is the core's scripts — +`jquery.js`, `h5p.js`, `h5p-event-dispatcher.js`, `h5p-x-api-event.js`, `h5p-x-api.js`, +`h5p-content-type.js`, `h5p-confirmation-dialog.js`, `h5p-action-bar.js`, `request-queue.js`, +`h5p-tooltip.js`, the list and the order upstream's `H5PCore::$scripts` gives — joined into one +file under a licence comment and minified. `h5p.css` is the core's stylesheets, the eight +`H5PCore::$styles` names, joined and rebuilt so that it stands alone: its icon fonts and image +inlined as data URLs, its Inter and Open Sans faces moved to `fonts/` and listed in +`MANIFEST.json` for the player's frame document to declare. `vendor/h5p-core/` in the +[repository](https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime) +holds the files as upstream has them, and `scripts/sync-core.mjs` takes them from upstream. + +Until 2026-10-18 the core came by way of [h5p-standalone](https://github.com/tunapanda/h5p-standalone), +whose copy of it records no upstream revision +([tunapanda/h5p-standalone#188](https://github.com/tunapanda/h5p-standalone/issues/188)); none of +h5p-standalone's own code is in this package any more. The full licence text is in [LICENSE](LICENSE) and, so that it travels with the files, in -`dist/LICENSE.txt`; `dist/NOTICE.txt` lists the files and names the h5p-standalone tag actually -vendored, which is the corresponding source. Keep both beside the directory when you copy or -serve it. h5p-standalone does not record which h5p-php-library revision `vendor/h5p/` was taken -from. +`dist/LICENSE.txt`; `dist/NOTICE.txt` lists the files and names the h5p-php-library commit they +were built from, which is the corresponding source together with this package's build. Keep both +beside the directory when you copy or serve it. Also inside `dist/`: -- jQuery 3.5.1, in `frame.bundle.js` — MIT, Copyright JS Foundation and other contributors, - . -- regenerator-runtime, in `main.bundle.js` — MIT, named in the file's own leading comment. +- jQuery 3.5.1, in `frame.bundle.js`, where the core keeps it in `js/jquery.js` — MIT, Copyright + JS Foundation and other contributors, . - Inter, `fonts/inter-*.woff2` — SIL Open Font License 1.1, see `fonts/Inter-LICENSE.txt`. - Open Sans, `fonts/open-sans-*.woff2` — SIL Open Font License 1.1, see `fonts/OpenSans-OFL.txt`. @@ -45,7 +49,10 @@ of the player's own code that runs inside the H5P document and calls the core's element, which talks to it over `postMessage` only. It includes `src/content-upgrade.ts`, which runs the libraries' own upgrade steps on content written for an older minor, by the rules of `js/h5p-content-upgrade-process.js` in h5p-php-library (GPL-3.0); it was written for this package -rather than copied from that file. +rather than copied from that file. And it carries the core's English interface strings, the +player's part of `getLocalization()` in `h5p.classes.php` at the same commit as the core, which +`scripts/sync-core.mjs` takes into `vendor/h5p-core/strings.json`: the settings it builds start +from them, and a language's strings from `l10n.js` are laid over them. It also includes `src/content-check.ts`, which checks the content of a package played `untrusted` against its libraries' semantics. That one is a port: of `H5PContentValidator` in `h5p.classes.php` @@ -73,7 +80,7 @@ had none or one that broke a placeholder. The file opens with a comment naming i A site that serves `dist/` to browsers is distributing GPL-3.0 code, with what that entails for keeping the notices and making the source available; the notices above and the files beside the directory are what it needs. With a bundler the files are emitted under hashed names and the -text files do not travel; `frame.bundle.js`, `main.bundle.js`, `h5p.css`, `frame-boot.js` and +text files do not travel; `frame.bundle.js`, `h5p.css`, `frame-boot.js` and `l10n.js` therefore open with a `/*! … */` comment naming their licences and the corresponding source, which bundlers leave in an emitted asset. diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 2538d87..de0be5d 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -26,10 +26,19 @@ Without one, point the element at a copy of `dist/`: Or copy `dist/` to `frame-assets/` next to `h5p-player.js`, where the element looks by default. -`dist/` holds `main.bundle.js` and `frame.bundle.js` (h5p-standalone's loader, the H5P core and -jQuery), `h5p.css`, `fonts/`, `frame-boot.js`, `l10n.js`, `MANIFEST.json`, `LICENSE.txt` and -`NOTICE.txt`. Use a runtime version within the range the player names as its optional peer -dependency. +`dist/` holds `frame.bundle.js` (the H5P core's scripts, jQuery among them), `h5p.css`, `fonts/`, +`frame-boot.js`, `l10n.js`, `MANIFEST.json`, `LICENSE.txt` and `NOTICE.txt`. Use a runtime version +within the range the player names as its optional peer dependency. + +## The H5P core + +The core is h5p-php-library's, the scripts and stylesheets an H5P site loads, at the commit +`vendor/h5p-core/SOURCE.json` names, with the list of files and their order taken from +`H5PCore::$scripts` and `H5PCore::$styles` at that commit. `pnpm core:sync master` in this package +moves it to upstream's newest commit, `pnpm core:sync ` to another, and the build joins the +files as they are. The boot script builds the core's settings and loads each library's scripts and +stylesheets in the order the player's Service Worker gives it, which is the order h5p-standalone +loaded them in until this package dropped it. ## Checking content diff --git a/packages/runtime/index.d.ts b/packages/runtime/index.d.ts index 5dddf68..1bdf9e2 100644 --- a/packages/runtime/index.d.ts +++ b/packages/runtime/index.d.ts @@ -17,9 +17,7 @@ export interface RuntimeFont { export interface FrameRuntime { /** This package's version. */ version: string - /** h5p-standalone `main.bundle.js`: the loader that walks dependencies and boots the runtime. */ - mainJs: string - /** h5p-standalone `frame.bundle.js`: h5p.js, jQuery and the core runtime. */ + /** `frame.bundle.js`: the H5P core's scripts, jQuery and h5p.js among them, from h5p-php-library. */ frameJs: string /** The core stylesheet, rebuilt to stand alone: icon fonts and images inlined. */ frameCss: string diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 3abafde..81943e1 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -34,21 +34,19 @@ ], "keywords": [ "h5p", - "h5p-standalone", + "h5p-core", "h5p-offline-player", "runtime" ], "scripts": { "build": "node scripts/build.mjs", + "core:sync": "node scripts/sync-core.mjs", "l10n": "node scripts/build-l10n.mjs", "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", "test": "pnpm typecheck && vitest run", "test:unit": "pnpm typecheck && vitest run", "prepublishOnly": "pnpm test && pnpm build" }, - "dependencies": { - "h5p-standalone": "^3.8.2" - }, "devDependencies": { "@types/node": "^26.6.5", "@zip.js/zip.js": "^2.23.0", diff --git a/packages/runtime/scripts/build.mjs b/packages/runtime/scripts/build.mjs index c6d0936..84e04b6 100644 --- a/packages/runtime/scripts/build.mjs +++ b/packages/runtime/scripts/build.mjs @@ -1,16 +1,22 @@ import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { build } from 'esbuild' +import { build, transform } from 'esbuild' +import { CORE_DIR } from './sync-core.mjs' /** - * Builds `dist/`: the h5p-standalone runtime vendored from `node_modules`, the frame's boot - * script compiled from `src/frame-boot.ts`, and the notices — everything the player loads inside - * its frame, and nothing the player itself is made of. Then writes `index.js`, which names every - * one of those files with a static `new URL` so that a bundler emits them. + * Builds `dist/`: the H5P core from `vendor/h5p-core/`, which `scripts/sync-core.mjs` takes from + * h5p/h5p-php-library at a pinned commit, the frame's boot script compiled from + * `src/frame-boot.ts`, and the notices — everything the player loads inside its frame, and nothing + * the player itself is made of. Then writes `index.js`, which names every one of those files with + * a static `new URL` so that a bundler emits them. * - * The runtime scripts' code is copied unmodified; each only gains a leading licence comment (see - * below). The player synthesizes the document they run in; this package does not patch H5P. + * `frame.bundle.js` is the core's scripts in the order `H5PCore::$scripts` gives, joined into one + * file under a licence comment and minified: the way an H5P site loads them, one global script + * after another. Minifying keeps every top-level name, which is what the scripts share, and only + * shortens the names inside functions; h5p-standalone's bundle was minified too, and unminified + * the file costs every frame's first load about 20 kB more, gzipped. The player synthesizes the + * document the scripts run in; this package does not patch H5P. * * Every file in `dist/` stands alone, because `index.js` names each one with its own * `new URL('./dist/', import.meta.url)` and a consumer's bundler emits each under a hashed @@ -33,9 +39,8 @@ import { build } from 'esbuild' * from the committed `l10n/`, which `scripts/build-l10n.mjs` writes. * * `dist/` also gets a `LICENSE.txt` and a `NOTICE.txt`, written here rather than kept by hand so - * that they always name the h5p-standalone version actually vendored — that tag is the - * corresponding source — and so that they land beside the files they describe wherever `dist/` - * is copied or served. + * that they always name the h5p-php-library commit actually vendored — the corresponding source — + * and so that they land beside the files they describe wherever `dist/` is copied or served. */ const rootDir = resolve(import.meta.dirname, '..') @@ -69,51 +74,46 @@ export const DIST_DIR = resolve(rootDir, 'dist') */ export async function buildRuntime({ quiet = false } = {}) { const packageJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf8')) - const standaloneDir = resolve(rootDir, 'node_modules', 'h5p-standalone', 'dist') - - if (!(await exists(standaloneDir))) { - fail('node_modules/h5p-standalone is missing. Run "pnpm install" at the repository root first.') - } - - const standaloneVersion = JSON.parse( - await readFile(resolve(rootDir, 'node_modules', 'h5p-standalone', 'package.json'), 'utf8') - ).version + const sourcePath = resolve(CORE_DIR, 'SOURCE.json') + if (!(await exists(sourcePath))) fail('vendor/h5p-core/ is missing. Run "pnpm core:sync" in packages/runtime.') + /** @type {{ repository: string, commit: string, date: string, coreApi: string, scripts: string[], styles: string[] }} */ + const core = JSON.parse(await readFile(sourcePath, 'utf8')) + const corresponding = `${core.repository}/tree/${core.commit}` + const coreName = `h5p/h5p-php-library at ${core.commit.slice(0, 10)} (${core.date.slice(0, 10)})` await rm(DIST_DIR, { recursive: true, force: true }) await mkdir(DIST_DIR, { recursive: true }) - // `main.bundle.js` walks the package's dependencies; `frame.bundle.js` is h5p.js, jQuery and the - // core runtime. Each is given a `/*! … */` header naming its licences, because a consumer's - // bundler emits each file on its own under a hashed name and none of the text files in this - // directory travel with it — a user's build of the player's 0.1.5 shipped `frame.bundle-*.js`, - // the GPL file, with no notice at all, and `main.bundle-*.js` pointing at a - // `main.bundle.js.LICENSE.txt` that was never emitted. Bundlers keep a `/*!` comment in an asset - // they emit as is, and webpack's Terser, which minifies emitted `.js` assets too, moves it into - // a `.LICENSE.txt` it does ship. The pointer webpack left in `main.bundle.js` is replaced by the - // notice it pointed at, so that file is not shipped. Nothing after the first line changes. - const corresponding = `https://github.com/tunapanda/h5p-standalone/tree/v${standaloneVersion}` - const headers = { - 'frame.bundle.js': `/*! H5P core runtime (h5p.js and its companions) from h5p/h5p-php-library, GPL-3.0-only (https://github.com/h5p/h5p-php-library). jQuery 3.5.1, MIT (https://jquery.org/license). h5p-standalone ${standaloneVersion}, MIT, Copyright (c) 2015 Tunapanda. Corresponding source: ${corresponding}. See NOTICE.md in @missing-elements/h5p-runtime. */`, - 'main.bundle.js': `/*! h5p-standalone ${standaloneVersion}, MIT, Copyright (c) 2015 Tunapanda (${corresponding}). See NOTICE.md in @missing-elements/h5p-runtime. */` + // The core's scripts, one after another. A `;` between them, so that a file ending without one + // cannot run into the next: `})` followed by `(function` would call the one with the other. + // The file opens with a `/*! … */` comment naming its licences, because a consumer's bundler + // emits each file on its own under a hashed name and none of the text files in this directory + // travel with it — a user's build of the player's 0.1.5 shipped the GPL runtime with no notice + // at all. Bundlers keep a `/*!` comment in an asset they emit as is, and webpack's Terser, which + // minifies emitted `.js` assets too, moves it into a `.LICENSE.txt` it does ship. + const scripts = [] + for (const path of core.scripts) { + const file = resolve(CORE_DIR, path) + if (!(await exists(file))) fail(`vendor/h5p-core/${path} is missing; run "pnpm core:sync" again`) + scripts.push(`/* ${path} */\n${(await readFile(file, 'utf8')).trimEnd()}\n;`) } - const WEBPACK_POINTER = '/*! For license information please see main.bundle.js.LICENSE.txt */\n' - - for (const [file, header] of Object.entries(headers)) { - const source = resolve(standaloneDir, file) - if (!(await exists(source))) fail(`Required runtime file missing: ${source}`) - let code = await readFile(source, 'utf8') - if (file === 'main.bundle.js') { - if (!code.startsWith(WEBPACK_POINTER)) fail(`${source} no longer starts with webpack's licence pointer`) - const pointed = (await readFile(resolve(standaloneDir, 'main.bundle.js.LICENSE.txt'), 'utf8')).trim() - code = `${pointed}\n${code.slice(WEBPACK_POINTER.length)}` - } - await writeFile(resolve(DIST_DIR, file), `${header}\n${code}`, 'utf8') + const jquery = /^\/\*! jQuery v(\d+\.\d+\.\d+)/.exec(await readFile(resolve(CORE_DIR, 'js', 'jquery.js'), 'utf8'))?.[1] + if (!jquery) fail("vendor/h5p-core/js/jquery.js no longer opens with jQuery's licence comment") + const frameHeader = `/*! H5P core runtime (${core.scripts.map((path) => path.slice('js/'.length)).join(', ')}) from ${coreName}, GPL-3.0-only. Includes jQuery ${jquery}, MIT (https://jquery.org/license). Corresponding source: ${corresponding}. See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` + // A plain script, not a module: no format is given, so top-level names stay as they are. The + // target is the one the boot script is built for; the core itself uses ES2020 syntax already. + const { code: frameCode } = await transform(scripts.join('\n'), { loader: 'js', minify: true, legalComments: 'inline', target: 'es2022' }) + await writeFile(resolve(DIST_DIR, 'frame.bundle.js'), `${frameHeader}\n${frameCode}`, 'utf8') + + // The core's stylesheets, one after another in the order `H5PCore::$styles` gives, as one sheet. + const stylesDir = resolve(CORE_DIR, 'styles') + const sheets = [] + for (const path of core.styles) { + const file = resolve(CORE_DIR, path) + if (!(await exists(file))) fail(`vendor/h5p-core/${path} is missing; run "pnpm core:sync" again`) + sheets.push(await readFile(file, 'utf8')) } - - // `styles/h5p.css` is the stylesheet the runtime loads; `h5p-fonts.css` beside it repeats its - // `@font-face` rules for the editor and is never requested by a player. - const stylesheet = resolve(standaloneDir, 'styles', 'h5p.css') - if (!(await exists(stylesheet))) fail(`Required runtime file missing: ${stylesheet}`) + const stylesheet = 'vendor/h5p-core/styles' // Rules are matched on the source text rather than parsed; `@font-face` bodies hold no braces. const FONT_FACE = /@font-face\s*\{([^}]*)\}/g @@ -123,7 +123,7 @@ export async function buildRuntime({ quiet = false } = {}) { // A face that does not fit the shape the player's frame document accepts fails the build here, // where it is seen, rather than being dropped by the frame at runtime, where nothing would say // why the text fell back. - const source = await readFile(stylesheet, 'utf8') + const source = sheets.join('\n') const fonts = [] const stripped = source.replace(FONT_FACE, (rule, body) => { const url = body.match(TEXT_FONT) @@ -138,7 +138,7 @@ export async function buildRuntime({ quiet = false } = {}) { // overwrite the first under this name, so it is refused until the naming accounts for it. const file = `${url[1]}-${weight}${style === 'italic' ? '-italic' : ''}.woff2` if (fonts.some((font) => font.file === file)) fail(`Two @font-face rules map to ${file} in ${stylesheet}: ${rule}`) - fonts.push({ family, style, weight, file, source: resolve(standaloneDir, 'fonts', url[1], url[2]) }) + fonts.push({ family, style, weight, file, source: resolve(CORE_DIR, 'fonts', url[1], url[2]) }) return '' }) if (fonts.length === 0) fail(`No Inter or Open Sans @font-face found in ${stylesheet}`) @@ -150,7 +150,7 @@ export async function buildRuntime({ quiet = false } = {}) { } await build({ - stdin: { contents: stripped, resolveDir: resolve(standaloneDir, 'styles'), loader: 'css', sourcefile: 'h5p.css' }, + stdin: { contents: stripped, resolveDir: stylesDir, loader: 'css', sourcefile: 'h5p.css' }, outfile: resolve(DIST_DIR, 'h5p.css'), bundle: true, minify: true, @@ -161,7 +161,7 @@ export async function buildRuntime({ quiet = false } = {}) { // reachable even where LICENSE.txt and NOTICE.txt beside it do not travel. legalComments: 'none', banner: { - css: `/*! H5P core stylesheet from h5p-standalone ${standaloneVersion}, GPL-3.0 (https://github.com/h5p/h5p-php-library). See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` + css: `/*! H5P core stylesheets (${core.styles.map((path) => path.slice('styles/'.length)).join(', ')}) from ${coreName}, GPL-3.0-only. Corresponding source: ${corresponding}. See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` }, loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.gif': 'dataurl', '.svg': 'dataurl', '.png': 'dataurl' } }) @@ -185,8 +185,8 @@ export async function buildRuntime({ quiet = false } = {}) { }) // The fonts' licences, which sit in their own folders upstream. - await cp(resolve(standaloneDir, 'fonts', 'inter', 'LICENSE.txt'), resolve(DIST_DIR, 'fonts', 'Inter-LICENSE.txt')) - await cp(resolve(standaloneDir, 'fonts', 'open-sans', 'OFL.txt'), resolve(DIST_DIR, 'fonts', 'OpenSans-OFL.txt')) + await cp(resolve(CORE_DIR, 'fonts', 'inter', 'LICENSE.txt'), resolve(DIST_DIR, 'fonts', 'Inter-LICENSE.txt')) + await cp(resolve(CORE_DIR, 'fonts', 'open-sans', 'OFL.txt'), resolve(DIST_DIR, 'fonts', 'OpenSans-OFL.txt')) // The core's strings in other languages, from `l10n/` (`scripts/build-l10n.mjs`), all in one // classic script with the aliases beside them: the player's frame document names it before the @@ -217,13 +217,13 @@ export async function buildRuntime({ quiet = false } = {}) { const manifest = { runtime: packageJson.version, - h5pStandalone: standaloneVersion, + core: { repository: core.repository, commit: core.commit, date: core.date, coreApi: core.coreApi }, fonts: fonts.map(({ family, style, weight, file }) => ({ family, style, weight, file })) } await writeFile(resolve(DIST_DIR, 'MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') await cp(resolve(rootDir, 'LICENSE'), resolve(DIST_DIR, 'LICENSE.txt')) - await writeFile(resolve(DIST_DIR, 'NOTICE.txt'), distNotice(standaloneVersion, packageJson.version), 'utf8') + await writeFile(resolve(DIST_DIR, 'NOTICE.txt'), distNotice(core, jquery, packageJson.version), 'utf8') // `index.js`: one static `new URL` per file, the one shape Vite, Rollup and webpack 5 emit as an // asset and rewrite. Generated, because the font list comes from the sheet. Written only when @@ -233,7 +233,7 @@ export async function buildRuntime({ quiet = false } = {}) { const currentIndex = (await exists(indexPath)) ? await readFile(indexPath, 'utf8') : '' if (currentIndex !== index) await writeFile(indexPath, index, 'utf8') - if (!quiet) console.log(`[h5p-runtime] h5p-standalone v${standaloneVersion} and the boot script -> dist/`) + if (!quiet) console.log(`[h5p-runtime] the H5P core from ${coreName}, core API ${core.coreApi}, and the boot script -> dist/`) return manifest } @@ -264,7 +264,6 @@ function unbundled(href) { */ export const runtime = { version: ${JSON.stringify(version)}, - mainJs: ${url('main.bundle.js')}, frameJs: ${url('frame.bundle.js')}, frameCss: ${url('h5p.css')}, bootJs: ${url('frame-boot.js')}, @@ -282,44 +281,54 @@ ${fonts ` } -function distNotice(standalone, runtime) { +function distNotice(core, jquery, runtime) { + /** A list of file names, as lines of the notice indented under its item. */ + const wrap = (names) => { + const lines = [''] + for (const [index, name] of names.entries()) { + const word = index < names.length - 1 ? `${name},` : name + if (lines.at(-1) && lines.at(-1).length + word.length + 1 > 84) lines.push('') + lines[lines.length - 1] = lines.at(-1) ? `${lines.at(-1)} ${word}` : word + } + return lines.join('\n ') + } return `@missing-elements/h5p-runtime ${runtime} -- third-party notice These files are the H5P runtime that loads inside its frame, and the script that -boots it there. The runtime was copied from h5p-standalone ${standalone} -(https://github.com/tunapanda/h5p-standalone, tag v${standalone}): the scripts' code unmodified, -each given a leading licence comment, the stylesheet rebuilt so that it stands alone, its icon -fonts and images inlined as data URLs, its Inter and Open Sans faces moved to fonts/ and listed -in MANIFEST.json for the player's frame document to declare. Keep this file and LICENSE.txt -beside them when you copy or serve the directory. +boots it there. The runtime is the H5P core from h5p/h5p-php-library at commit +${core.commit} (${core.date.slice(0, 10)}, core API ${core.coreApi}): +the scripts as upstream wrote them, joined into one file under a licence comment, and the +stylesheets joined and rebuilt so that the sheet stands alone, its icon fonts and image inlined +as data URLs and its Inter and Open Sans faces moved to fonts/ and listed in MANIFEST.json for +the player's frame document to declare. Keep this file and LICENSE.txt beside them when you copy +or serve the directory. frame.bundle.js - - H5P core: h5p.js, h5p-event-dispatcher.js, h5p-x-api.js, h5p-x-api-event.js, - h5p-content-type.js, h5p-confirmation-dialog.js, request-queue.js, h5p-action-bar.js, - h5p-tooltip.js, from https://github.com/h5p/h5p-php-library (vendor/h5p/js/ in - h5p-standalone). GNU General Public License v3.0 -- LICENSE.txt in this directory. - - jQuery 3.5.1. MIT, Copyright JS Foundation and other contributors, https://jquery.org/license - - h5p-standalone's own frame code. MIT, Copyright (c) 2015 Tunapanda. + - H5P core, from https://github.com/h5p/h5p-php-library: + ${wrap(core.scripts.map((path) => path.slice('js/'.length)))}. GNU General Public License v3.0 -- + LICENSE.txt in this directory. + - jQuery ${jquery}, in jquery.js above. MIT, Copyright JS Foundation and other contributors, + https://jquery.org/license h5p.css - - H5P core styles/h5p.css, with the h5p-core-30, h5p-hub-publish and h5p-theme icon fonts and - images/throbber.gif inlined. Same origin, GNU General Public License v3.0 -- LICENSE.txt. + - H5P core, from the same repository: + ${wrap(core.styles.map((path) => path.slice('styles/'.length)))}, + with the h5p-core-30, h5p-hub-publish and h5p-theme icon fonts and images/throbber.gif + inlined. Same origin, GNU General Public License v3.0 -- LICENSE.txt. fonts/inter-* Inter, SIL Open Font License 1.1 -- fonts/Inter-LICENSE.txt fonts/open-sans-* Open Sans, SIL Open Font License 1.1 -- fonts/OpenSans-OFL.txt -main.bundle.js - - h5p-standalone. MIT, Copyright (c) 2015 Tunapanda. Includes regenerator-runtime (MIT), - named in its own leading comment. - frame-boot.js - The frame's boot script, from src/frame-boot.ts in this package. Copyright (c) 2026 missing-elements, GNU General Public License v3.0 -- LICENSE.txt. It runs in the H5P document and calls the core's API, which is why it is part of this package and not of - the MIT player. It includes a port of H5PContentValidator from h5p/h5p-php-library - (GNU General Public License v3.0) and of the filter_xss functions of Drupal 7's - includes/common.inc (Copyright Drupal contributors, GNU General Public License v2.0 or - later), which check the content of a package played untrusted. + the MIT player. It builds the core's settings from the core's own English strings + (H5PCore::getLocalization() at the commit above), and includes a port of + H5PContentValidator from h5p/h5p-php-library (GNU General Public License v3.0) and of + the filter_xss functions of Drupal 7's includes/common.inc (Copyright Drupal + contributors, GNU General Public License v2.0 or later), which check the content of a + package played untrusted. l10n.js - The core's interface strings in other languages, taken from Moodle's language packs @@ -329,10 +338,10 @@ l10n.js when they were taken. Corresponding source for the GPL parts as built: -https://github.com/tunapanda/h5p-standalone/tree/v${standalone} (src/ and vendor/h5p/), and -https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime. -h5p-standalone does not record which h5p-php-library revision vendor/h5p/ was taken from -(https://github.com/tunapanda/h5p-standalone/issues/188). +${core.repository}/tree/${core.commit} +(js/ and styles/), and +https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime, whose +vendor/h5p-core/ holds the same files and scripts/ the build that joins them. ` } diff --git a/packages/runtime/scripts/l10n-rules.mjs b/packages/runtime/scripts/l10n-rules.mjs index 9d37d4d..bd5ee32 100644 --- a/packages/runtime/scripts/l10n-rules.mjs +++ b/packages/runtime/scripts/l10n-rules.mjs @@ -7,38 +7,20 @@ import { resolve } from 'node:path' */ /** - * The core's interface strings in English, by key: the `l10n.H5P` defaults h5p-standalone puts - * into `H5PIntegration`, which the frame lays a language's strings over. Read from the installed - * bundle, so the keys are the ones the vendored core knows, and in its order. + * The core's interface strings in English, by key: the player's part of `getLocalization()` in + * the vendored core, which the boot script puts into `H5PIntegration` and lays a language's + * strings over. `scripts/sync-core.mjs` writes it with the core, so the keys are the ones the + * vendored core knows, and in its order. They came from h5p-standalone's bundle until the core was + * vendored (#37): the same 67, word for word. * * @returns {Record} */ export function coreStrings() { - const path = resolve(import.meta.dirname, '..', 'node_modules', 'h5p-standalone', 'dist', 'main.bundle.js') - const bundle = readFileSync(path, 'utf8') - const start = bundle.indexOf('l10n:{H5P:{') - if (start < 0) throw new Error(`[h5p-runtime] ${path} no longer defines l10n.H5P where expected`) - - // The object literal, matched brace for brace; a brace inside a string does not count. - let depth = 0 - let quote = '' - let end = start + 'l10n:'.length - for (; end < bundle.length; end++) { - const char = bundle[end] - if (quote) { - if (char === '\\') end++ - else if (char === quote) quote = '' - } else if (char === '"' || char === "'") quote = char - else if (char === '{') depth++ - else if (char === '}' && --depth === 0) break - } - const literal = bundle.slice(start + 'l10n:'.length, end + 1) - if (!/^\{H5P:\{[\s\S]*\}\}$/.test(literal)) throw new Error(`[h5p-runtime] could not read l10n.H5P from ${path}`) - - /** @type {{ H5P: Record }} */ - const l10n = new Function(`return ${literal}`)() - const strings = Object.fromEntries(Object.entries(l10n.H5P).filter(([, value]) => typeof value === 'string')) - if (Object.keys(strings).length < 50) throw new Error(`[h5p-runtime] only ${Object.keys(strings).length} strings in l10n.H5P`) + const path = resolve(import.meta.dirname, '..', 'vendor', 'h5p-core', 'strings.json') + /** @type {Record} */ + const parsed = JSON.parse(readFileSync(path, 'utf8')) + const strings = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === 'string')) + if (Object.keys(strings).length < 50) throw new Error(`[h5p-runtime] only ${Object.keys(strings).length} strings in ${path}`) return /** @type {Record} */ (strings) } diff --git a/packages/runtime/scripts/sync-core.mjs b/packages/runtime/scripts/sync-core.mjs new file mode 100644 index 0000000..6b8b7a6 --- /dev/null +++ b/packages/runtime/scripts/sync-core.mjs @@ -0,0 +1,158 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, posix, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** + * Vendors the H5P core from h5p/h5p-php-library at a commit, into `vendor/h5p-core/`, which is + * committed: `pnpm core:sync master` moves it to upstream's newest commit, `pnpm core:sync ` + * to that one, and `pnpm core:sync` alone fetches the commit already recorded again, which leaves + * the tree as it was unless something upstream was rewritten. + * + * What is taken, and in what order, is upstream's own word rather than a list kept here: the + * scripts and stylesheets a player loads are `H5PCore::$scripts` and `H5PCore::$styles` in + * `h5p.classes.php` at that commit, the fonts and images are the ones those stylesheets reference, + * and the core's English strings are the player's part of `H5PCore::getLocalization()`, which + * `scripts/build.mjs` gives the frame as its defaults and `scripts/build-l10n.mjs` translates. + * The commit, its date and those lists go into `SOURCE.json`, which the build reads. + * + * Until 2026-10-18 the core came through h5p-standalone, which copies it about once a year and + * records no revision; issue #37. + */ + +const REPOSITORY = 'h5p/h5p-php-library' +const rootDir = resolve(import.meta.dirname, '..') +export const CORE_DIR = resolve(rootDir, 'vendor', 'h5p-core') + +/** + * `getLocalization()` lists the player's strings and then, from `mainTitle` on, the strings of the + * hub's sharing wizard, which only an editor shows. The player's are taken: the keys a frame's + * `H5PIntegration.l10n.H5P` has always had, and the ones Moodle's packs translate. + */ +const FIRST_EDITOR_STRING = 'mainTitle' + +const fail = (message) => { + throw new Error(`[h5p-core] ${message}`) +} + +/** GitHub's API, with a token when the environment has one: sixty requests an hour without. */ +async function api(path) { + const headers = { accept: 'application/vnd.github+json', 'user-agent': 'h5p-offline-player core sync' } + if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}` + const response = await fetch(`https://api.github.com/repos/${REPOSITORY}/${path}`, { headers }) + if (!response.ok) fail(`GitHub answered ${response.status} for ${path}`) + return response.json() +} + +async function raw(commit, path) { + const response = await fetch(`https://raw.githubusercontent.com/${REPOSITORY}/${commit}/${path}`) + if (!response.ok) fail(`${path} at ${commit}: ${response.status}`) + return new Uint8Array(await response.arrayBuffer()) +} + +/** A PHP string literal's value: single quotes take `\'` and `\\`, double quotes a few more. */ +function phpString(literal) { + const quote = literal[0] + const body = literal.slice(1, -1) + if (quote === "'") return body.replace(/\\(['\\])/g, '$1') + return body.replace(/\\(["\\$])/g, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t') +} + +const STRING = String.raw`'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"` + +/** The entries of `public static $ = array(…);` in `h5p.classes.php`, in order. */ +export function staticList(source, name) { + const match = new RegExp(String.raw`public static \$${name} = array\(([\s\S]*?)\);`).exec(source) + if (!match) fail(`h5p.classes.php no longer declares H5PCore::$${name}`) + return [...match[1].matchAll(new RegExp(STRING, 'g'))].map(([literal]) => phpString(literal)) +} + +/** `H5PCore::$coreApi`, as `major.minor`. */ +export function coreApi(source) { + const match = /public static \$coreApi = array\(\s*'majorVersion' => (\d+),\s*'minorVersion' => (\d+)/.exec(source) + if (!match) fail('h5p.classes.php no longer declares H5PCore::$coreApi') + return `${match[1]}.${match[2]}` +} + +/** The player's English strings from `getLocalization()`, by key, in upstream's order. */ +export function playerStrings(source) { + const start = source.indexOf('public function getLocalization()') + if (start < 0) fail('h5p.classes.php no longer has getLocalization()') + const strings = {} + const entry = new RegExp(String.raw`'([A-Za-z0-9]+)' => (?:\$this->h5pF->t\((${STRING})\)|array\()`, 'g') + entry.lastIndex = start + for (let match; (match = entry.exec(source)); ) { + if (match[1] === FIRST_EDITOR_STRING) return strings + // A nested array is the sharing wizard's too; the player has none. + if (match[2] === undefined) fail(`getLocalization() has a nested array before ${FIRST_EDITOR_STRING}: ${match[1]}`) + strings[match[1]] = phpString(match[2]) + } + return fail(`getLocalization() no longer has "${FIRST_EDITOR_STRING}", where the player's strings end`) +} + +/** The relative URLs a stylesheet reaches, `../fonts/…` and `../images/…`, without query or fragment. */ +function stylesheetAssets(css) { + return [...css.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)] + .map(([, url]) => url.split(/[?#]/)[0]) + .filter((url) => !/^(?:[a-z]+:|\/|#)/i.test(url)) +} + +export async function syncCore(ref) { + const recorded = await readFile(resolve(CORE_DIR, 'SOURCE.json'), 'utf8').then(JSON.parse, () => null) + const wanted = ref ?? recorded?.commit + if (!wanted) fail('No commit recorded yet: name one, as in "pnpm core:sync master"') + + const { sha: commit, commit: details } = await api(`commits/${encodeURIComponent(wanted)}`) + const classes = new TextDecoder().decode(await raw(commit, 'h5p.classes.php')) + const scripts = staticList(classes, 'scripts') + const styles = staticList(classes, 'styles') + const strings = playerStrings(classes) + + for (const path of scripts) if (!/^js\/[\w.-]+\.js$/.test(path)) fail(`H5PCore::$scripts names ${path}, not a script in js/`) + for (const path of styles) if (!/^styles\/[\w.-]+\.css$/.test(path)) fail(`H5PCore::$styles names ${path}, not a stylesheet in styles/`) + if (!scripts.includes('js/jquery.js') || !scripts.includes('js/h5p.js')) fail('H5PCore::$scripts no longer names jquery.js and h5p.js') + if (Object.keys(strings).length < 60 || !strings.fullscreen) fail(`only ${Object.keys(strings).length} strings before ${FIRST_EDITOR_STRING}`) + + const files = new Map() + for (const path of [...scripts, ...styles]) files.set(path, await raw(commit, path)) + + // The fonts and images the stylesheets reach, and the licence beside each font family's files. + const { tree } = await api(`git/trees/${commit}?recursive=1`) + const inTree = new Set(tree.filter((node) => node.type === 'blob').map((node) => node.path)) + const assets = new Set() + for (const path of styles) { + for (const url of stylesheetAssets(new TextDecoder().decode(files.get(path)))) { + const asset = posix.normalize(posix.join(posix.dirname(path), url)) + if (!/^(fonts|images)\//.test(asset) || !inTree.has(asset)) fail(`${path} reaches ${url}, which is not a font or an image at ${commit}`) + assets.add(asset) + } + } + const fontFolders = new Set([...assets].filter((path) => /^fonts\/[^/]+\//.test(path)).map(posix.dirname)) + for (const path of inTree) { + if (fontFolders.has(posix.dirname(path)) && /^(LICENSE|OFL)[^/]*$/i.test(posix.basename(path))) assets.add(path) + } + for (const path of [...assets].sort()) files.set(path, await raw(commit, path)) + + await rm(CORE_DIR, { recursive: true, force: true }) + for (const [path, bytes] of files) { + await mkdir(dirname(resolve(CORE_DIR, path)), { recursive: true }) + await writeFile(resolve(CORE_DIR, path), bytes) + } + await writeFile(resolve(CORE_DIR, 'strings.json'), `${JSON.stringify(strings, null, 2)}\n`) + const source = { + repository: `https://github.com/${REPOSITORY}`, + commit, + date: details.committer.date, + coreApi: coreApi(classes), + scripts, + styles + } + await writeFile(resolve(CORE_DIR, 'SOURCE.json'), `${JSON.stringify(source, null, 2)}\n`) + + const moved = recorded && recorded.commit !== commit ? ` (was ${recorded.commit.slice(0, 10)})` : '' + console.log(`[h5p-core] ${REPOSITORY} at ${commit.slice(0, 10)}${moved}, ${source.date}, core API ${source.coreApi}: ${scripts.length} scripts, ${styles.length} stylesheets, ${assets.size} fonts and images, ${Object.keys(strings).length} strings -> vendor/h5p-core/`) + return source +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await syncCore(process.argv[2]) +} diff --git a/packages/runtime/src/frame-boot.ts b/packages/runtime/src/frame-boot.ts index 60923ad..792d512 100644 --- a/packages/runtime/src/frame-boot.ts +++ b/packages/runtime/src/frame-boot.ts @@ -1,7 +1,13 @@ /** - * Runs inside the frame. It boots h5p-standalone against the virtual file server, forwards xAPI - * to the element and relays the worker's job requests — the Service Worker can only message a - * client, and the element is the one that owns the Jobs worker. + * Runs inside the frame. It boots the H5P core against the virtual file server, forwards xAPI to + * the element and relays the worker's job requests — the Service Worker can only message a client, + * and the element is the one that owns the Jobs worker. + * + * Booting is what a site's H5P plugin does for its iframe: it writes the core's settings, + * `H5PIntegration`, loads the core's scripts and stylesheets and then each library's, and calls + * `H5P.init`. What to load comes from the worker's `boot` route, which has walked the libraries + * (`LibraryLoad` in the player); until the core was vendored (#37) h5p-standalone did all of it + * here, and the settings are built as it built them, field for field, so content sees no change. * * Built by `scripts/build.mjs` to `dist/frame-boot.js`, a classic script the player's Service * Worker names with `'; + } + return tags; + }; + + return '' + + createStyleTags(H5PIntegration.core.styles) + + createStyleTags(H5PIntegration.contents['cid-' + contentId].styles) + + createScriptTags(H5PIntegration.core.scripts) + + createScriptTags(H5PIntegration.contents['cid-' + contentId].scripts) + + ''; +}; + +/** + * When embedded the communicator helps talk to the parent page. + * + * @type {Communicator} + */ +H5P.communicator = (function () { + /** + * @class + * @private + */ + function Communicator() { + var self = this; + + // Maps actions to functions + var actionHandlers = {}; + + // Register message listener + window.addEventListener('message', function receiveMessage(event) { + if (window.parent !== event.source || event.data.context !== 'h5p') { + return; // Only handle messages from parent and in the correct context + } + + if (actionHandlers[event.data.action] !== undefined) { + actionHandlers[event.data.action](event.data); + } + } , false); + + + /** + * Register action listener. + * + * @param {string} action What you are waiting for + * @param {function} handler What you want done + */ + self.on = function (action, handler) { + actionHandlers[action] = handler; + }; + + /** + * Send a message to the all mighty father. + * + * @param {string} action + * @param {Object} [data] payload + */ + self.send = function (action, data) { + if (data === undefined) { + data = {}; + } + data.context = 'h5p'; + data.action = action; + + // Parent origin can be anything + window.parent.postMessage(data, '*'); + }; + } + + return (window.postMessage && window.addEventListener ? new Communicator() : undefined); +})(); + +/** + * Enter semi fullscreen for the given H5P instance + * + * @param {H5P.jQuery} $element Content container. + * @param {Object} instance + * @param {function} exitCallback Callback function called when user exits fullscreen. + * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. + */ +H5P.semiFullScreen = function ($element, instance, exitCallback, body) { + H5P.fullScreen($element, instance, exitCallback, body, true); +}; + +/** + * Enter fullscreen for the given H5P instance. + * + * @param {H5P.jQuery} $element Content container. + * @param {Object} instance + * @param {function} exitCallback Callback function called when user exits fullscreen. + * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. + * @param {Boolean} forceSemiFullScreen + */ +H5P.fullScreen = function ($element, instance, exitCallback, body, forceSemiFullScreen) { + if (H5P.exitFullScreen !== undefined) { + return; // Cannot enter new fullscreen until previous is over + } + + if (H5P.isFramed && H5P.externalEmbed === false) { + // Trigger resize on wrapper in parent window. + window.parent.H5P.fullScreen($element, instance, exitCallback, H5P.$body.get(), forceSemiFullScreen); + H5P.isFullscreen = true; + H5P.exitFullScreen = function () { + window.parent.H5P.exitFullScreen(); + }; + H5P.on(instance, 'exitFullScreen', function () { + H5P.isFullscreen = false; + H5P.exitFullScreen = undefined; + }); + return; + } + + var $container = $element; + var $classes, $iframe, $body; + if (body === undefined) { + $body = H5P.$body; + } + else { + // We're called from an iframe. + $body = H5P.jQuery(body); + $classes = $body.add($element.get()); + var iframeSelector = '#h5p-iframe-' + $element.parent().data('content-id'); + $iframe = H5P.jQuery(iframeSelector); + $element = $iframe.parent(); // Put iframe wrapper in fullscreen, not container. + } + + $classes = $element.add(H5P.$body).add($classes); + + /** + * Prepare for resize by setting the correct styles. + * + * @private + * @param {string} classes CSS + */ + var before = function (classes) { + $classes.addClass(classes); + + if ($iframe !== undefined) { + // Set iframe to its default size(100%). + $iframe.css('height', ''); + } + }; + + /** + * Gets called when fullscreen mode has been entered. + * Resizes and sets focus on content. + * + * @private + */ + var entered = function () { + // Do not rely on window resize events. + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); + H5P.trigger(instance, 'enterFullScreen'); + }; + + /** + * Gets called when fullscreen mode has been exited. + * Resizes and sets focus on content. + * + * @private + * @param {string} classes CSS + */ + var done = function (classes) { + H5P.isFullscreen = false; + $classes.removeClass(classes); + + // Do not rely on window resize events. + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); + + H5P.exitFullScreen = undefined; + if (exitCallback !== undefined) { + exitCallback(); + } + + H5P.trigger(instance, 'exitFullScreen'); + }; + + H5P.isFullscreen = true; + if (H5P.fullScreenBrowserPrefix === undefined || forceSemiFullScreen === true) { + // Create semi fullscreen. + + if (H5P.isFramed) { + return; // TODO: Should we support semi-fullscreen for IE9 & 10 ? + } + + before('h5p-semi-fullscreen'); + var $disable = H5P.jQuery('
').appendTo($container.find('.h5p-content-controls')); + var keyup, disableSemiFullscreen = H5P.exitFullScreen = function () { + if (prevViewportContent) { + // Use content from the previous viewport tag + h5pViewport.content = prevViewportContent; + } + else { + // Remove viewport tag + head.removeChild(h5pViewport); + } + $disable.remove(); + $body.unbind('keyup', keyup); + done('h5p-semi-fullscreen'); + }; + keyup = function (event) { + if (event.keyCode === 27) { + disableSemiFullscreen(); + } + }; + $disable.click(disableSemiFullscreen); + $body.keyup(keyup); + + // Disable zoom + var prevViewportContent, h5pViewport; + var metaTags = document.getElementsByTagName('meta'); + for (var i = 0; i < metaTags.length; i++) { + if (metaTags[i].name === 'viewport') { + // Use the existing viewport tag + h5pViewport = metaTags[i]; + prevViewportContent = h5pViewport.content; + break; + } + } + if (!prevViewportContent) { + // Create a new viewport tag + h5pViewport = document.createElement('meta'); + h5pViewport.name = 'viewport'; + } + h5pViewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0'; + if (!prevViewportContent) { + // Insert the new viewport tag + var head = document.getElementsByTagName('head')[0]; + head.appendChild(h5pViewport); + } + + entered(); + } + else { + // Create real fullscreen. + + before('h5p-fullscreen'); + var first, eventName = (H5P.fullScreenBrowserPrefix === 'ms' ? 'MSFullscreenChange' : H5P.fullScreenBrowserPrefix + 'fullscreenchange'); + document.addEventListener(eventName, function fullscreenCallback() { + if (first === undefined) { + // We are entering fullscreen mode + first = false; + entered(); + return; + } + + // We are exiting fullscreen + done('h5p-fullscreen'); + document.removeEventListener(eventName, fullscreenCallback, false); + }); + + if (H5P.fullScreenBrowserPrefix === '') { + $element[0].requestFullscreen(); + } + else { + var method = (H5P.fullScreenBrowserPrefix === 'ms' ? 'msRequestFullscreen' : H5P.fullScreenBrowserPrefix + 'RequestFullScreen'); + var params = (H5P.fullScreenBrowserPrefix === 'webkit' && H5P.safariBrowser === 0 ? Element.ALLOW_KEYBOARD_INPUT : undefined); + $element[0][method](params); + } + + // Allows everone to exit + H5P.exitFullScreen = function () { + if (H5P.fullScreenBrowserPrefix === '') { + document.exitFullscreen(); + } + else if (H5P.fullScreenBrowserPrefix === 'moz') { + document.mozCancelFullScreen(); + } + else { + document[H5P.fullScreenBrowserPrefix + 'ExitFullscreen'](); + } + }; + } +}; + +(function () { + /** + * Helper for adding a query parameter to an existing path that may already + * contain one or a hash. + * + * @param {string} path + * @param {string} parameter + * @return {string} + */ + H5P.addQueryParameter = function (path, parameter) { + let newPath, secondSplit; + const firstSplit = path.split('?'); + if (firstSplit[1]) { + // There is already an existing query + secondSplit = firstSplit[1].split('#'); + newPath = firstSplit[0] + '?' + secondSplit[0] + '&'; + } + else { + // No existing query, just need to take care of the hash + secondSplit = firstSplit[0].split('#'); + newPath = secondSplit[0] + '?'; + } + newPath += parameter; + if (secondSplit[1]) { + // Add back the hash + newPath += '#' + secondSplit[1]; + } + return newPath; + }; + + /** + * Helper for setting the crossOrigin attribute + the complete correct source. + * Note: This will start loading the resource. + * + * @param {Element} element DOM element, typically img, video or audio + * @param {Object} source File object from parameters/json_content (created by H5PEditor) + * @param {number} contentId Needed to determine the complete correct file path + */ + H5P.setSource = function (element, source, contentId) { + let path = source.path; + + const crossOrigin = H5P.getCrossOrigin(source); + if (crossOrigin) { + element.crossOrigin = crossOrigin; + + if (H5PIntegration.crossoriginCacheBuster) { + // Some sites may want to add a cache buster in case the same resource + // is used elsewhere without the crossOrigin attribute + path = H5P.addQueryParameter(path, H5PIntegration.crossoriginCacheBuster); + } + } + else { + // In case this element has been used before. + element.removeAttribute('crossorigin'); + } + + element.src = H5P.getPath(path, contentId); + }; + + /** + * Check if the given path has a protocol. + * + * @private + * @param {string} path + * @return {string} + */ + var hasProtocol = function (path) { + return path.match(/^[a-z0-9]+:\/\//i); + }; + + /** + * Get the crossOrigin policy to use for img, video and audio tags on the current site. + * + * @param {Object|string} source File object from parameters/json_content - Can also be URL(deprecated usage) + * @returns {string|null} crossOrigin attribute value required by the source + */ + H5P.getCrossOrigin = function (source) { + if (typeof source !== 'object') { + // Deprecated usage. + return H5PIntegration.crossorigin && H5PIntegration.crossoriginRegex && source.match(H5PIntegration.crossoriginRegex) ? H5PIntegration.crossorigin : null; + } + + if (H5PIntegration.crossorigin && !hasProtocol(source.path)) { + // This is a local file, use the local crossOrigin policy. + return H5PIntegration.crossorigin; + // Note: We cannot use this for all external sources since we do not know + // each server's individual policy. We could add support for a list of + // external sources and their policy later on. + } + }; + + /** + * Find the path to the content files based on the id of the content. + * Also identifies and returns absolute paths. + * + * @param {string} path + * Relative to content folder or absolute. + * @param {number} contentId + * ID of the content requesting the path. + * @returns {string} + * Complete URL to path. + */ + H5P.getPath = function (path, contentId) { + if (hasProtocol(path)) { + return path; + } + + var prefix; + var isTmpFile = (path.substr(-4,4) === '#tmp'); + if (contentId !== undefined && !isTmpFile) { + // Check for custom override URL + if (H5PIntegration.contents !== undefined && + H5PIntegration.contents['cid-' + contentId]) { + prefix = H5PIntegration.contents['cid-' + contentId].contentUrl; + } + if (!prefix) { + prefix = H5PIntegration.url + '/content/' + contentId; + } + } + else if (window.H5PEditor !== undefined) { + prefix = H5PEditor.filesPath; + } + else { + return; + } + + if (!hasProtocol(prefix)) { + // Use absolute urls + prefix = window.location.protocol + "//" + window.location.host + prefix; + } + + return prefix + '/' + path; + }; +})(); + +/** + * THIS FUNCTION IS DEPRECATED, USE getPath INSTEAD + * Will be remove march 2016. + * + * Find the path to the content files folder based on the id of the content + * + * @deprecated + * Will be removed march 2016. + * @param contentId + * Id of the content requesting the path + * @returns {string} + * URL + */ +H5P.getContentPath = function (contentId) { + return H5PIntegration.url + '/content/' + contentId; +}; + +/** + * Get library class constructor from H5P by classname. + * Note that this class will only work for resolve "H5P.NameWithoutDot". + * Also check out {@link H5P.newRunnable} + * + * Used from libraries to construct instances of other libraries' objects by name. + * + * @param {string} name Name of library + * @returns {Object} Class constructor + */ +H5P.classFromName = function (name) { + var arr = name.split("."); + return this[arr[arr.length - 1]]; +}; + +/** + * A safe way of creating a new instance of a runnable H5P. + * + * @param {Object} library + * Library/action object form params. + * @param {number} contentId + * Identifies the content. + * @param {H5P.jQuery} [$attachTo] + * Element to attach the instance to. + * @param {boolean} [skipResize] + * Skip triggering of the resize event after attaching. + * @param {Object} [extras] + * Extra parameters for the H5P content constructor + * @returns {Object} + * Instance. + */ +H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { + var nameSplit, versionSplit, machineName; + try { + nameSplit = library.library.split(' ', 2); + machineName = nameSplit[0]; + versionSplit = nameSplit[1].split('.', 2); + } + catch (err) { + return H5P.error('Invalid library string: ' + library.library); + } + + if ((library.params instanceof Object) !== true || (library.params instanceof Array) === true) { + H5P.error('Invalid library params for: ' + library.library); + return H5P.error(library.params); + } + + // Find constructor function + var constructor; + try { + nameSplit = nameSplit[0].split('.'); + constructor = window; + for (var i = 0; i < nameSplit.length; i++) { + constructor = constructor[nameSplit[i]]; + } + if (typeof constructor !== 'function') { + throw null; + } + } + catch (err) { + return H5P.error('Unable to find constructor for: ' + library.library); + } + + if (extras === undefined) { + extras = {}; + } + if (library.subContentId) { + extras.subContentId = library.subContentId; + } + + if (library.userDatas && library.userDatas.state && H5PIntegration.saveFreq) { + extras.previousState = library.userDatas.state; + } + + if (library.metadata) { + extras.metadata = library.metadata; + } + + // Makes all H5P libraries extend H5P.ContentType: + var standalone = extras.standalone || false; + // This order makes it possible for an H5P library to override H5P.ContentType functions! + constructor.prototype = H5P.jQuery.extend({}, H5P.ContentType(standalone).prototype, constructor.prototype); + + var instance; + // Some old library versions have their own custom third parameter. + // Make sure we don't send them the extras. + // (they will interpret it as something else) + if (H5P.jQuery.inArray(library.library, ['H5P.CoursePresentation 1.0', 'H5P.CoursePresentation 1.1', 'H5P.CoursePresentation 1.2', 'H5P.CoursePresentation 1.3']) > -1) { + instance = new constructor(library.params, contentId); + } + else { + instance = new constructor(library.params, contentId, extras); + } + + if (instance.$ === undefined) { + instance.$ = H5P.jQuery(instance); + } + + if (instance.contentId === undefined) { + instance.contentId = contentId; + } + if (instance.subContentId === undefined && library.subContentId) { + instance.subContentId = library.subContentId; + } + if (instance.parent === undefined && extras && extras.parent) { + instance.parent = extras.parent; + } + if (instance.libraryInfo === undefined) { + instance.libraryInfo = { + versionedName: library.library, + versionedNameNoSpaces: machineName + '-' + versionSplit[0] + '.' + versionSplit[1], + machineName: machineName, + majorVersion: versionSplit[0], + minorVersion: versionSplit[1] + }; + } + + if ($attachTo !== undefined) { + $attachTo.toggleClass('h5p-standalone', standalone); + instance.attach($attachTo); + H5P.trigger(instance, 'domChanged', { + '$target': $attachTo, + 'library': machineName, + 'key': 'newLibrary' + }, {'bubbles': true, 'external': true}); + + if (skipResize === undefined || !skipResize) { + // Resize content. + H5P.trigger(instance, 'resize'); + } + } + return instance; +}; + +/** + * Used to print useful error messages. (to JavaScript error console) + * + * @param {*} err Error to print. + */ +H5P.error = function (err) { + if (window.console !== undefined && console.error !== undefined) { + console.error(err.stack ? err.stack : err); + } +}; + +/** + * Translate text strings. + * + * @param {string} key + * Translation identifier, may only contain a-zA-Z0-9. No spaces or special chars. + * @param {Object} [vars] + * Data for placeholders. + * @param {string} [ns] + * Translation namespace. Defaults to H5P. + * @returns {string} + * Translated text + */ +H5P.t = function (key, vars, ns) { + if (ns === undefined) { + ns = 'H5P'; + } + + if (H5PIntegration.l10n[ns] === undefined) { + return '[Missing translation namespace "' + ns + '"]'; + } + + if (H5PIntegration.l10n[ns][key] === undefined) { + return '[Missing translation "' + key + '" in "' + ns + '"]'; + } + + var translation = H5PIntegration.l10n[ns][key]; + + if (vars !== undefined) { + // Replace placeholder with variables. + for (var placeholder in vars) { + translation = translation.replace(placeholder, vars[placeholder]); + } + } + + return translation; +}; + +/** + * Creates a new popup dialog over the H5P content. + * + * @class + * @param {string} name + * Used for html class. + * @param {string} title + * Used for header. + * @param {string} content + * Displayed inside the dialog. + * @param {H5P.jQuery} $element + * Which DOM element the dialog should be inserted after. + * @param {H5P.jQuery} $returnElement + * Which DOM element the focus should be moved to on close + */ +H5P.Dialog = function (name, title, content, $element, $returnElement) { + /** @alias H5P.Dialog# */ + var self = this; + this.activeElement = document.activeElement; + var $dialog = H5P.jQuery('') + .insertAfter($element) + .click(function (e) { + if (e && e.originalEvent && e.originalEvent.preventClosing) { + return; + } + + self.close(); + }) + .children('.h5p-inner') + .click(function (e) { + e.originalEvent.preventClosing = true; + }) + .find('.h5p-close') + .click(function () { + self.close(); + }) + .keypress(function (e) { + if (e.which === 13 || e.which === 32) { + self.close(); + return false; + } + }) + .end() + .find('a') + .click(function (e) { + e.stopPropagation(); + }) + .end() + .end(); + + /** + * Opens the dialog. + */ + self.open = function (scrollbar) { + if (scrollbar) { + $dialog.css('height', '100%'); + } + setTimeout(function () { + $dialog.addClass('h5p-open'); // Fade in + // Triggering an event, in case something has to be done after dialog has been opened. + H5P.jQuery(self).trigger('dialog-opened', [$dialog]); + $dialog.focus(); + }, 1); + }; + + /** + * Closes the dialog. + */ + self.close = function () { + $dialog.removeClass('h5p-open'); // Fade out + setTimeout(function () { + $dialog.remove(); + H5P.jQuery(self).trigger('dialog-closed', [$dialog]); + $element.attr('tabindex', '-1'); + if ($returnElement) { + $returnElement.focus(); + } + else if(self.activeElement) { + self.activeElement.focus(); + } + else { + $element.focus(); + } + }, 200); + }; +}; + +/** + * Gather copyright information for the given content. + * + * @param {Object} instance + * H5P instance to get copyright information for. + * @param {Object} parameters + * Parameters of the content instance. + * @param {number} contentId + * Identifies the H5P content + * @param {Object} metadata + * Metadata of the content instance. + * @returns {string} Copyright information. + */ +H5P.getCopyrights = function (instance, parameters, contentId, metadata) { + var copyrights; + + if (instance.getCopyrights !== undefined) { + try { + // Use the instance's own copyright generator + copyrights = instance.getCopyrights(); + } + catch (err) { + // Failed, prevent crashing page. + } + } + + if (copyrights === undefined) { + // Create a generic flat copyright list + copyrights = new H5P.ContentCopyrights(); + H5P.findCopyrights(copyrights, parameters, contentId); + } + + var metadataCopyrights = H5P.buildMetadataCopyrights(metadata, instance.libraryInfo.machineName); + if (metadataCopyrights !== undefined) { + copyrights.addMediaInFront(metadataCopyrights); + } + + if (copyrights !== undefined) { + // Convert to string + copyrights = copyrights.toString(); + } + return copyrights; +}; + +/** + * Gather a flat list of copyright information from the given parameters. + * + * @param {H5P.ContentCopyrights} info + * Used to collect all information in. + * @param {(Object|Array)} parameters + * To search for file objects in. + * @param {number} contentId + * Used to insert thumbnails for images. + * @param {Object} extras - Extras. + * @param {object} extras.metadata - Metadata + * @param {object} extras.machineName - Library name of some kind. + * Metadata of the content instance. + */ +H5P.findCopyrights = function (info, parameters, contentId, extras) { + // If extras are + if (extras) { + extras.params = parameters; + buildFromMetadata(extras, extras.machineName, contentId); + } + + var lastContentTypeName; + // Cycle through parameters + for (var field in parameters) { + if (!parameters.hasOwnProperty(field)) { + continue; // Do not check + } + + /** + * @deprecated This hack should be removed after 2017-11-01 + * The code that was using this was removed by HFP-574 + * This note was seen on 2018-04-04, and consultation with + * higher authorities lead to keeping the code for now ;-) + */ + if (field === 'overrideSettings') { + console.warn("The semantics field 'overrideSettings' is DEPRECATED and should not be used."); + console.warn(parameters); + continue; + } + + var value = parameters[field]; + + if (value && value.library && typeof value.library === 'string') { + lastContentTypeName = value.library.split(' ')[0]; + } + else if (value && value.library && typeof value.library === 'object') { + lastContentTypeName = (value.library.library && typeof value.library.library === 'string') ? value.library.library.split(' ')[0] : lastContentTypeName; + } + + if (value instanceof Array) { + // Cycle through array + H5P.findCopyrights(info, value, contentId); + } + else if (value instanceof Object) { + buildFromMetadata(value, lastContentTypeName, contentId); + + // Check if object is a file with copyrights (old core) + if (value.copyright === undefined || + value.copyright.license === undefined || + value.path === undefined || + value.mime === undefined) { + + // Nope, cycle throught object + H5P.findCopyrights(info, value, contentId); + } + else { + // Found file, add copyrights + var copyrights = new H5P.MediaCopyright(value.copyright); + if (value.width !== undefined && value.height !== undefined) { + copyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(value.path, contentId), value.width, value.height)); + } + info.addMedia(copyrights); + } + } + } + + function buildFromMetadata(data, name, contentId) { + if (data.metadata) { + const metadataCopyrights = H5P.buildMetadataCopyrights(data.metadata, name); + if (metadataCopyrights !== undefined) { + if (data.params && data.params.contentName === 'Image' && data.params.file) { + const path = data.params.file.path; + const width = data.params.file.width; + const height = data.params.file.height; + metadataCopyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(path, contentId), width, height, data.params.alt)); + } + info.addMedia(metadataCopyrights); + } + } + } +}; + +H5P.buildMetadataCopyrights = function (metadata) { + if (metadata && metadata.license !== undefined && metadata.license !== 'U') { + var dataset = { + contentType: metadata.contentType, + title: metadata.title, + author: (metadata.authors && metadata.authors.length > 0) ? metadata.authors.map(function (author) { + return (author.role) ? author.name + ' (' + author.role + ')' : author.name; + }).join(', ') : undefined, + source: metadata.source, + year: (metadata.yearFrom) ? (metadata.yearFrom + ((metadata.yearTo) ? '-' + metadata.yearTo: '')) : undefined, + license: metadata.license, + version: metadata.licenseVersion, + licenseExtras: metadata.licenseExtras, + changes: (metadata.changes && metadata.changes.length > 0) ? metadata.changes.map(function (change) { + return change.log + (change.author ? ', ' + change.author : '') + (change.date ? ', ' + change.date : ''); + }).join(' / ') : undefined + }; + + return new H5P.MediaCopyright(dataset); + } +}; + +/** + * Display a dialog containing the download button and copy button. + * + * @param {H5P.jQuery} $element + * @param {Object} contentData + * @param {Object} library + * @param {Object} instance + * @param {number} contentId + */ +H5P.openReuseDialog = function ($element, contentData, library, instance, contentId) { + let html = ''; + let buttonCount = 0; + if (contentData.displayOptions.export) { + html += ''; + buttonCount += 1; + } + if (contentData.displayOptions.export && contentData.displayOptions.copy) { + html += '
or
'; + } + if (contentData.displayOptions.copy) { + html += ''; + buttonCount += 1; + } + + const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { + const scrollContent = $dialog[0].querySelector('.h5p-scroll-content'); + scrollContent.style.setProperty('--button-count', buttonCount); + H5P.jQuery('More Info').click(function (e) { + e.stopPropagation(); + }).appendTo($dialog.find('h2')); + $dialog.find('.h5p-download-button').click(function () { + window.location.href = contentData.exportUrl; + instance.triggerXAPI('downloaded'); + dialog.close(); + }); + $dialog.find('.h5p-copy-button').click(function () { + const item = new H5P.ClipboardItem(library); + item.contentId = contentId; + H5P.setClipboard(item); + instance.triggerXAPI('copied'); + dialog.close(); + H5P.attachToastTo( + H5P.jQuery('.h5p-content:first')[0], + H5P.t('contentCopied'), + { + position: { + horizontal: 'centered', + vertical: 'centered', + noOverflowX: true + } + } + ); + }); + H5P.trigger(instance, 'resize'); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Display a dialog containing the embed code. + * + * @param {H5P.jQuery} $element + * Element to insert dialog after. + * @param {string} embedCode + * The embed code. + * @param {string} resizeCode + * The advanced resize code + * @param {Object} size + * The content's size. + * @param {number} size.width + * @param {number} size.height + */ +H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { + var fullEmbedCode = embedCode + resizeCode; + var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
' + H5P.t('showAdvanced') + '

' + H5P.t('advancedHelp') + '

', $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { + var $inner = $dialog.find('.h5p-inner'); + var $scroll = $inner.find('.h5p-scroll-content'); + var diff = $scroll.outerHeight() - $scroll.innerHeight(); + var positionInner = function () { + H5P.trigger(instance, 'resize'); + }; + + // Handle changing of width/height + var $w = $dialog.find('.h5p-embed-size:eq(0)'); + var $h = $dialog.find('.h5p-embed-size:eq(1)'); + var getNum = function ($e, d) { + var num = parseFloat($e.val()); + if (isNaN(num)) { + return d; + } + return Math.ceil(num); + }; + var updateEmbed = function () { + $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); + }; + + $w.change(updateEmbed); + $h.change(updateEmbed); + updateEmbed(); + + // Select text and expand textareas + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { + H5P.jQuery(this).select(); + }); + }); + $dialog.find('.h5p-embed-code-container').eq(0).select(); + positionInner(); + + // Expand advanced embed + var expand = function () { + var $expander = H5P.jQuery(this); + var $content = $expander.next(); + if ($content.is(':visible')) { + $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')).attr('aria-expanded', 'true'); + $content.hide(); + } + else { + $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')).attr('aria-expanded', 'false'); + $content.show(); + } + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px'); + }); + positionInner(); + }; + $dialog.find('.h5p-expander').click(expand).keypress(function (event) { + if (event.keyCode === 32) { + expand.apply(this); + return false; + } + }); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Show a toast message. + * + * The reference element could be dom elements the toast should be attached to, + * or e.g. the document body for general toast messages. + * + * @param {DOM} element Reference element to show toast message for. + * @param {string} message Message to show. + * @param {object} [config] Configuration. + * @param {string} [config.style=h5p-toast] Style name for the tooltip. + * @param {number} [config.duration=3000] Toast message length in ms. + * @param {object} [config.position] Relative positioning of the toast. + * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [config.position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. + * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. + */ +H5P.attachToastTo = function (element, message, config) { + if (element === undefined || message === undefined) { + return; + } + + const eventPath = function (evt) { + var path = (evt.composedPath && evt.composedPath()) || evt.path; + var target = evt.target; + + if (path != null) { + // Safari doesn't include Window, but it should. + return (path.indexOf(window) < 0) ? path.concat(window) : path; + } + + if (target === window) { + return [window]; + } + + function getParents(node, memo) { + memo = memo || []; + var parentNode = node.parentNode; + + if (!parentNode) { + return memo; + } + else { + return getParents(parentNode, memo.concat(parentNode)); + } + } + + return [target].concat(getParents(target), window); + }; + + /** + * Handle click while toast is showing. + */ + const clickHandler = function (event) { + /* + * A common use case will be to attach toasts to buttons that are clicked. + * The click would remove the toast message instantly without this check. + * Children of the clicked element are also ignored. + */ + var path = eventPath(event); + if (path.indexOf(element) !== -1) { + return; + } + clearTimeout(timer); + removeToast(); + }; + + + + /** + * Remove the toast message. + */ + const removeToast = function () { + document.removeEventListener('click', clickHandler); + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }; + + /** + * Get absolute coordinates for the toast. + * + * @param {DOM} element Reference element to show toast message for. + * @param {DOM} toast Toast element. + * @param {object} [position={}] Relative positioning of the toast message. + * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. + * @return {object} + */ + const getToastCoordinates = function (element, toast, position) { + position = position || {}; + position.offsetHorizontal = position.offsetHorizontal || 0; + position.offsetVertical = position.offsetVertical || 0; + + const toastRect = toast.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + let left = 0; + let top = 0; + + // Compute horizontal position + switch (position.horizontal) { + case 'before': + left = elementRect.left - toastRect.width - position.offsetHorizontal; + break; + case 'after': + left = elementRect.left + elementRect.width + position.offsetHorizontal; + break; + case 'left': + left = elementRect.left + position.offsetHorizontal; + break; + case 'right': + left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; + break; + case 'centered': + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + break; + default: + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + } + + // Compute vertical position + switch (position.vertical) { + case 'above': + top = elementRect.top - toastRect.height - position.offsetVertical; + break; + case 'below': + top = elementRect.top + elementRect.height + position.offsetVertical; + break; + case 'top': + top = elementRect.top + position.offsetVertical; + break; + case 'bottom': + top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; + break; + case 'centered': + top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; + break; + default: + top = elementRect.top + elementRect.height + position.offsetVertical; + } + + // Prevent overflow + const overflowElement = document.body; + const bounds = overflowElement.getBoundingClientRect(); + if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { + left = bounds.x; + } + if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { + left = bounds.x + bounds.width - toastRect.width; + } + if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { + top = bounds.y; + } + if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { + left = bounds.y + bounds.height - toastRect.height; + } + + return {left: left, top: top}; + }; + + // Sanitization + config = config || {}; + config.style = config.style || 'h5p-toast'; + config.duration = config.duration || 3000; + + // Build toast + const toast = document.createElement('div'); + toast.setAttribute('id', config.style); + toast.classList.add('h5p-toast-disabled'); + toast.classList.add(config.style); + + const msg = document.createElement('span'); + msg.innerHTML = message; + toast.appendChild(msg); + + document.body.appendChild(toast); + + // The message has to be set before getting the coordinates + const coordinates = getToastCoordinates(element, toast, config.position); + toast.style.left = Math.round(coordinates.left) + 'px'; + toast.style.top = Math.round(coordinates.top) + 'px'; + + toast.classList.remove('h5p-toast-disabled'); + const timer = setTimeout(removeToast, config.duration); + + // The toast can also be removed by clicking somewhere + document.addEventListener('click', clickHandler); +}; + +/** + * Copyrights for a H5P Content Library. + * + * @class + */ +H5P.ContentCopyrights = function () { + var label; + var media = []; + var content = []; + + /** + * Set label. + * + * @param {string} newLabel + */ + this.setLabel = function (newLabel) { + label = newLabel; + }; + + /** + * Add sub content. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMedia = function (newMedia) { + if (newMedia !== undefined) { + media.push(newMedia); + } + }; + + /** + * Add sub content in front. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMediaInFront = function (newMedia) { + if (newMedia !== undefined) { + media.unshift(newMedia); + } + }; + + /** + * Add sub content. + * + * @param {H5P.ContentCopyrights} newContent + */ + this.addContent = function (newContent) { + if (newContent !== undefined) { + content.push(newContent); + } + }; + + /** + * Print content copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + // Add media rights + for (var i = 0; i < media.length; i++) { + html += media[i]; + } + + // Add sub content rights + for (i = 0; i < content.length; i++) { + html += content[i]; + } + + + if (html !== '') { + // Add a label to this info + if (label !== undefined) { + html = '

' + label + '

' + html; + } + + // Add wrapper + html = '
' + html + '
'; + } + + return html; + }; +}; + +/** + * A ordered list of copyright fields for media. + * + * @class + * @param {Object} copyright + * Copyright information fields. + * @param {Object} [labels] + * Translation of labels. + * @param {Array} [order] + * Order of the fields. + * @param {Object} [extraFields] + * Add extra copyright fields. + */ +H5P.MediaCopyright = function (copyright, labels, order, extraFields) { + var thumbnail; + var list = new H5P.DefinitionList(); + + /** + * Get translated label for field. + * + * @private + * @param {string} fieldName + * @returns {string} + */ + var getLabel = function (fieldName) { + if (labels === undefined || labels[fieldName] === undefined) { + return H5P.t(fieldName); + } + + return labels[fieldName]; + }; + + /** + * Get humanized value for the license field. + * + * @private + * @param {string} license + * @param {string} [version] + * @returns {string} + */ + var humanizeLicense = function (license, version) { + var copyrightLicense = H5P.copyrightLicenses[license]; + + // Build license string + var value = ''; + if (!(license === 'PD' && version)) { + // Add license label + value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); + } + + // Check for version info + var versionInfo; + if (copyrightLicense.versions) { + if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { + version = copyrightLicense.versions.default; + } + if (version && copyrightLicense.versions[version]) { + versionInfo = copyrightLicense.versions[version]; + } + } + + if (versionInfo) { + // Add license version + if (value) { + value += ' '; + } + value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); + } + + // Add link if specified + var link; + if (copyrightLicense.hasOwnProperty('link')) { + link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); + } + else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { + link = versionInfo.link; + } + if (link) { + value = '' + value + ''; + } + + // Generate parenthesis + var parenthesis = ''; + if (license !== 'PD' && license !== 'C') { + parenthesis += license; + } + if (version && version !== 'CC0 1.0') { + if (parenthesis && license !== 'GNU GPL') { + parenthesis += ' '; + } + parenthesis += version; + } + if (parenthesis) { + value += ' (' + parenthesis + ')'; + } + if (license === 'C') { + value += ' ©'; + } + + return value; + }; + + if (copyright !== undefined) { + // Add the extra fields + for (var field in extraFields) { + if (extraFields.hasOwnProperty(field)) { + copyright[field] = extraFields[field]; + } + } + + if (order === undefined) { + // Set default order + order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; + } + + for (var i = 0; i < order.length; i++) { + var fieldName = order[i]; + if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { + var humanValue = copyright[fieldName]; + if (fieldName === 'license') { + humanValue = humanizeLicense(copyright.license, copyright.version); + } + if (fieldName === 'source') { + humanValue = (humanValue) ? '' + humanValue + '' : undefined; + } + list.add(new H5P.Field(getLabel(fieldName), humanValue)); + } + } + } + + /** + * Set thumbnail. + * + * @param {H5P.Thumbnail} newThumbnail + */ + this.setThumbnail = function (newThumbnail) { + thumbnail = newThumbnail; + }; + + /** + * Checks if this copyright is undisclosed. + * I.e. only has the license attribute set, and it's undisclosed. + * + * @returns {boolean} + */ + this.undisclosed = function () { + if (list.size() === 1) { + var field = list.get(0); + if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { + return true; + } + } + return false; + }; + + /** + * Print media copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + if (this.undisclosed()) { + return html; // No need to print a copyright with a single undisclosed license. + } + + if (thumbnail !== undefined) { + html += thumbnail; + } + html += list; + + if (html !== '') { + html = ''; + } + + return html; + }; +}; + +/** + * A simple and elegant class for creating thumbnails of images. + * + * @class + * @param {string} source + * @param {number} width + * @param {number} height + * @param {string} alt + * alternative text for the thumbnail + */ +H5P.Thumbnail = function (source, width, height, alt) { + var thumbWidth, thumbHeight = 100; + if (width !== undefined) { + thumbWidth = Math.round(thumbHeight * (width / height)); + } + + /** + * Print thumbnail. + * + * @returns {string} HTML. + */ + this.toString = function () { + return '' + (alt ? alt : '') + ''; + }; +}; + +/** + * Simple data structure class for storing a single field. + * + * @class + * @param {string} label + * @param {string} value + */ +H5P.Field = function (label, value) { + /** + * Public. Get field label. + * + * @returns {String} + */ + this.getLabel = function () { + return label; + }; + + /** + * Public. Get field value. + * + * @returns {String} + */ + this.getValue = function () { + return value; + }; +}; + +/** + * Simple class for creating a definition list. + * + * @class + */ +H5P.DefinitionList = function () { + var fields = []; + + /** + * Add field to list. + * + * @param {H5P.Field} field + */ + this.add = function (field) { + fields.push(field); + }; + + /** + * Get Number of fields. + * + * @returns {number} + */ + this.size = function () { + return fields.length; + }; + + /** + * Get field at given index. + * + * @param {number} index + * @returns {H5P.Field} + */ + this.get = function (index) { + return fields[index]; + }; + + /** + * Print definition list. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + html += '
' + field.getLabel() + '
' + field.getValue() + '
'; + } + return (html === '' ? html : '
' + html + '
'); + }; +}; + +/** + * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. + * + * Helper object for keeping coordinates in the same format all over. + * + * @deprecated + * Will be removed march 2016. + * @class + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + */ +H5P.Coords = function (x, y, w, h) { + if ( !(this instanceof H5P.Coords) ) + return new H5P.Coords(x, y, w, h); + + /** @member {number} */ + this.x = 0; + /** @member {number} */ + this.y = 0; + /** @member {number} */ + this.w = 1; + /** @member {number} */ + this.h = 1; + + if (typeof(x) === 'object') { + this.x = x.x; + this.y = x.y; + this.w = x.w; + this.h = x.h; + } + else { + if (x !== undefined) { + this.x = x; + } + if (y !== undefined) { + this.y = y; + } + if (w !== undefined) { + this.w = w; + } + if (h !== undefined) { + this.h = h; + } + } + return this; +}; + +/** + * Parse library string into values. + * + * @param {string} library + * library in the format "machineName majorVersion.minorVersion" + * @returns {Object} + * library as an object with machineName, majorVersion and minorVersion properties + * return false if the library parameter is invalid + */ +H5P.libraryFromString = function (library) { + var regExp = /(.+)\s(\d+)\.(\d+)$/g; + var res = regExp.exec(library); + if (res !== null) { + return { + 'machineName': res[1], + 'majorVersion': parseInt(res[2]), + 'minorVersion': parseInt(res[3]) + }; + } + else { + return false; + } +}; + +/** + * Get the path to the library + * + * @param {string} library + * The library identifier in the format "machineName-majorVersion.minorVersion". + * @returns {string} + * The full path to the library. + */ +H5P.getLibraryPath = function (library) { + if (H5PIntegration && + H5PIntegration.libraryDirectories && + library in H5PIntegration.libraryDirectories) { + // Use H5PIntegration.libraryDirectories if it exists for this library + library = H5PIntegration.libraryDirectories[library]; + } + + if (H5PIntegration.urlLibraries !== undefined) { + // This is an override for those implementations that has a different libraries URL, e.g. Moodle + return H5PIntegration.urlLibraries + '/' + library; + } + else { + return H5PIntegration.url + '/libraries/' + library; + } +}; + +/** + * Recursivly clone the given object. + * + * @param {Object|Array} object + * Object to clone. + * @param {boolean} [recursive] + * @returns {Object|Array} + * A clone of object. + */ +H5P.cloneObject = function (object, recursive) { + // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? + var clone = object instanceof Array ? [] : {}; + + for (var i in object) { + if (object.hasOwnProperty(i)) { + if (recursive !== undefined && recursive && typeof object[i] === 'object') { + clone[i] = H5P.cloneObject(object[i], recursive); + } + else { + clone[i] = object[i]; + } + } + } + + return clone; +}; + +/** + * Remove all empty spaces before and after the value. + * + * @param {string} value + * @returns {string} + */ +H5P.trim = function (value) { + return value.replace(/^\s+|\s+$/g, ''); + + // TODO: Only include this or String.trim(). What is best? + // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ + // So should we make this function deprecated? +}; + +/** + * Recursive function that detects deep empty structures. + * + * @param {*} value + * @returns {bool} + */ +H5P.isEmpty = value => { + if (!value && value !== 0 && value !== false) { + return true; // undefined, null, NaN and empty strings. + } + else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (!H5P.isEmpty(value[i])) { + return false; // Array contains a non-empty value + } + } + return true; // Empty array + } + else if (typeof value === 'object') { + for (let prop in value) { + if (value.hasOwnProperty(prop) && !H5P.isEmpty(value[prop])) { + return false; // Object contains a non-empty value + } + } + return true; // Empty object + } + return false; +}; + +/** + * Check if JavaScript path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.jsLoaded = function (path) { + H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; +}; + +/** + * Check if styles path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.cssLoaded = function (path) { + H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; +}; + +/** + * Shuffle an array in place. + * + * @param {Array} array + * Array to shuffle + * @returns {Array} + * The passed array is returned for chaining. + */ +H5P.shuffleArray = function (array) { + // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. + if (!(array instanceof Array)) { + return; + } + + var i = array.length, j, tempi, tempj; + if ( i === 0 ) return false; + while ( --i ) { + j = Math.floor( Math.random() * ( i + 1 ) ); + tempi = array[i]; + tempj = array[j]; + array[i] = tempj; + array[j] = tempi; + } + return array; +}; + +/** + * Post finished results for user. + * + * @deprecated + * Do not use this function directly, trigger the finish event instead. + * Will be removed march 2016 + * @param {number} contentId + * Identifies the content + * @param {number} score + * Achieved score/points + * @param {number} maxScore + * The maximum score/points that can be achieved + * @param {number} [time] + * Reported time consumption/usage + */ +H5P.setFinished = function (contentId, score, maxScore, time) { + var validScore = typeof score === 'number' || score instanceof Number; + if (validScore && H5PIntegration.postUserStatistics === true) { + /** + * Return unix timestamp for the given JS Date. + * + * @private + * @param {Date} date + * @returns {Number} + */ + var toUnix = function (date) { + return Math.round(date.getTime() / 1000); + }; + + // Post the results + const data = { + contentId: contentId, + score: score, + maxScore: maxScore, + opened: toUnix(H5P.opened[contentId]), + finished: toUnix(new Date()), + time: time + }; + H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) + .fail(function () { + H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); + }); + } +}; + +// Add indexOf to browsers that lack them. (IEs) +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (needle) { + for (var i = 0; i < this.length; i++) { + if (this[i] === needle) { + return i; + } + } + return -1; + }; +} + +// Need to define trim() since this is not available on older IEs, +// and trim is used in several libs +if (String.prototype.trim === undefined) { + String.prototype.trim = function () { + return H5P.trim(this); + }; +} + +/** + * Trigger an event on an instance + * + * Helper function that triggers an event if the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to trigger + * @param {*} data + * @param {Object} extras + */ +H5P.trigger = function (instance, eventType, data, extras) { + // Try new event system first + if (instance.trigger !== undefined) { + instance.trigger(eventType, data, extras); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.trigger !== undefined) { + instance.$.trigger(eventType); + } +}; + +/** + * Register an event handler + * + * Helper function that registers an event handler for an event type if + * the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to listen for + * @param {H5P.EventCallback} handler + * Callback that gets triggered for events of the specified type + */ +H5P.on = function (instance, eventType, handler) { + // Try new event system first + if (instance.on !== undefined) { + instance.on(eventType, handler); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.on !== undefined) { + instance.$.on(eventType, handler); + } +}; + +/** + * Generate random UUID + * + * @returns {string} UUID + */ +H5P.createUUID = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); + return newChar.toString(16); + }); +}; + +/** + * Create title + * + * @param {string} rawTitle + * @param {number} maxLength + * @returns {string} + */ +H5P.createTitle = function (rawTitle, maxLength) { + if (!rawTitle) { + return ''; + } + if (maxLength === undefined) { + maxLength = 60; + } + var title = H5P.jQuery('
') + .text( + // Strip tags + rawTitle.replace(/(<([^>]+)>)/ig,"") + // Escape + ).text(); + if (title.length > maxLength) { + title = title.substr(0, maxLength - 3) + '...'; + } + return title; +}; + +// Wrap in privates +(function ($) { + + /** + * Creates ajax requests for inserting, updateing and deleteing + * content user data. + * + * @private + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + if (H5PIntegration.user === undefined) { + // Not logged in, no use in saving. + done('Not signed in.'); + return; + } + + var options = { + url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), + dataType: 'json', + async: async === undefined ? true : async + }; + if (data !== undefined) { + options.type = 'POST'; + options.data = { + data: (data === null ? 0 : data), + preload: (preload ? 1 : 0), + invalidate: (invalidate ? 1 : 0), + contentHash: (invalidate && H5PIntegration.contents['cid-' + contentId].jsonContentHash ? H5PIntegration.contents['cid-' + contentId].jsonContentHash : 0) + }; + } + else { + options.type = 'GET'; + } + if (done !== undefined) { + options.error = function (xhr, error) { + done(error); + }; + options.success = function (response) { + if (!response.success) { + done(response.message); + return; + } + + if (response.data === false || response.data === undefined) { + done(); + return; + } + + done(undefined, response.data); + }; + } + + $.ajax(options); + } + + /** + * Get user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {function} done + * Callback with error and data parameters. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.getUserData = function (contentId, dataId, done, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + H5PIntegration.contents = H5PIntegration.contents || {}; + var content = H5PIntegration.contents['cid-' + contentId] || {}; + var preloadedData = content.contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { + if (preloadedData[subContentId][dataId] === 'RESET') { + done(undefined, null); + return; + } + try { + done(undefined, JSON.parse(preloadedData[subContentId][dataId])); + } + catch (err) { + done(err); + } + } + else { + contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { + if (err || data === undefined) { + done(err, data); + return; // Error or no data + } + + // Cache in preloaded + if (content.contentUserData === undefined) { + content.contentUserData = preloadedData = {}; + } + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + preloadedData[subContentId][dataId] = data; + + // Done. Try to decode JSON + try { + done(undefined, JSON.parse(data)); + } + catch (e) { + done(e); + } + }); + } + }; + + /** + * Async error handling. + * + * @callback H5P.ErrorCallback + * @param {*} error + */ + + /** + * Set user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {Object} data + * The data that is to be stored. + * @param {Object} [extras] + * Extra properties + * @param {string} [extras.subContentId] + * Identifies which data belongs to sub content. + * @param {boolean} [extras.preloaded=true] + * If the data should be loaded when content is loaded. + * @param {boolean} [extras.deleteOnChange=false] + * If the data should be invalidated when the content changes. + * @param {H5P.ErrorCallback} [extras.errorCallback] + * Callback with error as parameters. + * @param {boolean} [extras.async=true] + */ + H5P.setUserData = function (contentId, dataId, data, extras) { + var options = H5P.jQuery.extend(true, {}, { + subContentId: 0, + preloaded: true, + deleteOnChange: false, + async: true + }, extras); + + try { + data = JSON.stringify(data); + } + catch (err) { + if (options.errorCallback) { + options.errorCallback(err); + } + return; // Failed to serialize. + } + + var content = H5PIntegration.contents['cid-' + contentId]; + if (content === undefined) { + content = H5PIntegration.contents['cid-' + contentId] = {}; + } + if (!content.contentUserData) { + content.contentUserData = {}; + } + var preloadedData = content.contentUserData; + if (preloadedData[options.subContentId] === undefined) { + preloadedData[options.subContentId] = {}; + } + if (data === preloadedData[options.subContentId][dataId]) { + return; // No need to save this twice. + } + + preloadedData[options.subContentId][dataId] = data; + contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { + if (options.errorCallback && error) { + options.errorCallback(error); + } + }, data, options.preloaded, options.deleteOnChange, options.async); + }; + + /** + * Delete user data for given content. + * + * @param {number} contentId + * What content to remove data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.deleteUserData = function (contentId, dataId, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + // Remove from preloaded/cache + var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + delete preloadedData[subContentId][dataId]; + } + + contentUserDataAjax(contentId, dataId, subContentId, undefined, null); + }; + + /** + * Function for getting content for a certain ID + * + * @param {number} contentId + * @return {Object} + */ + H5P.getContentForInstance = function (contentId) { + var key = 'cid-' + contentId; + var exists = H5PIntegration && H5PIntegration.contents && + H5PIntegration.contents[key]; + + return exists ? H5PIntegration.contents[key] : undefined; + }; + + /** + * Prepares the content parameters for storing in the clipboard. + * + * @class + * @param {Object} parameters The parameters for the content to store + * @param {string} [genericProperty] If only part of the parameters are generic, which part + * @param {string} [specificKey] If the parameters are specific, what content type does it fit + * @returns {Object} Ready for the clipboard + */ + H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { + var self = this; + + /** + * Set relative dimensions when params contains a file with a width and a height. + * Very useful to be compatible with wysiwyg editors. + * + * @private + */ + var setDimensionsFromFile = function () { + if (!self.generic) { + return; + } + var params = self.specific[self.generic]; + if (!params.params.file || !params.params.file.width || !params.params.file.height) { + return; + } + + self.width = 20; // % + self.height = (params.params.file.height / params.params.file.width) * self.width; + }; + + if (!genericProperty) { + genericProperty = 'action'; + parameters = { + action: parameters + }; + } + + self.specific = parameters; + + if (genericProperty && parameters[genericProperty]) { + self.generic = genericProperty; + } + if (specificKey) { + self.from = specificKey; + } + + if (window.H5PEditor && H5PEditor.contentId) { + self.contentId = H5PEditor.contentId; + } + + if (!self.specific.width && !self.specific.height) { + setDimensionsFromFile(); + } + }; + + /** + * Store item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|*} clipboardItem + */ + H5P.clipboardify = function (clipboardItem) { + if (!(clipboardItem instanceof H5P.ClipboardItem)) { + clipboardItem = new H5P.ClipboardItem(clipboardItem); + } + H5P.setClipboard(clipboardItem); + }; + + /** + * Retrieve parsed clipboard data. + * + * @return {Object} + */ + H5P.getClipboard = function () { + return parseClipboard(); + }; + + /** + * Set item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. + */ + H5P.setClipboard = function (clipboardItem) { + localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); + + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); + }; + + /** + * Get config for a library + * + * @param string machineName + * @return Object + */ + H5P.getLibraryConfig = function (machineName) { + var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; + return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; + }; + + /** + * Get item from the H5P Clipboard. + * + * @private + * @return {Object} + */ + var parseClipboard = function () { + var clipboardData = localStorage.getItem('h5pClipboard'); + if (!clipboardData) { + return; + } + + // Try to parse clipboard dat + try { + clipboardData = JSON.parse(clipboardData); + } + catch (err) { + console.error('Unable to parse JSON from clipboard.', err); + return; + } + + // Update file URLs and reset content Ids + recursiveUpdate(clipboardData.specific, function (path) { + var isTmpFile = (path.substr(-4, 4) === '#tmp'); + if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { + // Comes from existing content + + let prefix; + if (H5PEditor.contentId) { + // .. to existing content + prefix = '../' + clipboardData.contentId + '/'; + } + else { + // .. to new content + prefix = (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/'; + } + return path.substr(0, prefix.length) === prefix ? path : prefix + path; + } + + return path; // Will automatically be looked for in tmp folder + }); + + + if (clipboardData.generic) { + // Use reference instead of key + clipboardData.generic = clipboardData.specific[clipboardData.generic]; + } + + return clipboardData; + }; + + /** + * Update file URLs and reset content IDs. + * Useful when copying content. + * + * @private + * @param {object} params Reference + * @param {function} handler Modifies the path to work when pasted + */ + var recursiveUpdate = function (params, handler) { + for (var prop in params) { + if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { + var obj = params[prop]; + if (obj.path !== undefined && obj.mime !== undefined) { + obj.path = handler(obj.path); + } + else { + if (obj.library !== undefined && obj.subContentId !== undefined) { + // Avoid multiple content with same ID + delete obj.subContentId; + } + recursiveUpdate(obj, handler); + } + } + } + }; + + // Init H5P when page is fully loadded + $(document).ready(function () { + + window.addEventListener('storage', function (event) { + // Pick up clipboard changes from other tabs + if (event.key === 'h5pClipboard') { + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); + } + }); + + var ccVersions = { + 'default': '4.0', + '4.0': H5P.t('licenseCC40'), + '3.0': H5P.t('licenseCC30'), + '2.5': H5P.t('licenseCC25'), + '2.0': H5P.t('licenseCC20'), + '1.0': H5P.t('licenseCC10'), + }; + + /** + * Maps copyright license codes to their human readable counterpart. + * + * @type {Object} + */ + H5P.copyrightLicenses = { + 'U': H5P.t('licenseU'), + 'CC BY': { + label: H5P.t('licenseCCBY'), + link: 'http://creativecommons.org/licenses/by/:version', + versions: ccVersions + }, + 'CC BY-SA': { + label: H5P.t('licenseCCBYSA'), + link: 'http://creativecommons.org/licenses/by-sa/:version', + versions: ccVersions + }, + 'CC BY-ND': { + label: H5P.t('licenseCCBYND'), + link: 'http://creativecommons.org/licenses/by-nd/:version', + versions: ccVersions + }, + 'CC BY-NC': { + label: H5P.t('licenseCCBYNC'), + link: 'http://creativecommons.org/licenses/by-nc/:version', + versions: ccVersions + }, + 'CC BY-NC-SA': { + label: H5P.t('licenseCCBYNCSA'), + link: 'http://creativecommons.org/licenses/by-nc-sa/:version', + versions: ccVersions + }, + 'CC BY-NC-ND': { + label: H5P.t('licenseCCBYNCND'), + link: 'http://creativecommons.org/licenses/by-nc-nd/:version', + versions: ccVersions + }, + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'GNU GPL': { + label: H5P.t('licenseGPL'), + link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', + linkVersions: { + 'v3': '3.0', + 'v2': '2.0', + 'v1': '1.0' + }, + versions: { + 'default': 'v3', + 'v3': H5P.t('licenseV3'), + 'v2': H5P.t('licenseV2'), + 'v1': H5P.t('licenseV1') + } + }, + 'PD': { + label: H5P.t('licensePD'), + versions: { + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + } + } + }, + 'ODC PDDL': 'Public Domain Dedication and Licence', + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + }, + 'C': H5P.t('licenseC'), + }; + + /** + * Indicates if H5P is embedded on an external page using iframe. + * @member {boolean} H5P.externalEmbed + */ + + // Relay events to top window. This must be done before H5P.init + // since events may be fired on initialization. + if (H5P.isFramed && H5P.externalEmbed === false) { + H5P.externalDispatcher.on('*', function (event) { + window.parent.H5P.externalDispatcher.trigger.call(this, event); + }); + } + + /** + * Prevent H5P Core from initializing. Must be overriden before document ready. + * @member {boolean} H5P.preventInit + */ + if (!H5P.preventInit) { + // Note that this start script has to be an external resource for it to + // load in correct order in IE9. + H5P.init(document.body); + } + + if (H5PIntegration.saveFreq !== false) { + // When was the last state stored + var lastStoredOn = 0; + // Store the current state of the H5P when leaving the page. + var storeCurrentState = function () { + // Make sure at least 250 ms has passed since last save + var currentTime = new Date().getTime(); + if (currentTime - lastStoredOn > 250) { + lastStoredOn = currentTime; + for (var i = 0; i < H5P.instances.length; i++) { + var instance = H5P.instances[i]; + if (instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function') { + var state = instance.getCurrentState(); + if (state !== undefined) { + // Async is not used to prevent the request from being cancelled. + H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); + } + } + } + } + }; + // iPad does not support beforeunload, therefore using unload + H5P.$window.one('beforeunload unload', function () { + // Only want to do this once + H5P.$window.off('pagehide beforeunload unload'); + storeCurrentState(); + }); + // pagehide is used on iPad when tabs are switched + H5P.$window.on('pagehide', storeCurrentState); + } + }); + +})(H5P.jQuery); diff --git a/packages/runtime/vendor/h5p-core/js/jquery.js b/packages/runtime/vendor/h5p-core/js/jquery.js new file mode 100644 index 0000000..4f34d75 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/js/jquery.js @@ -0,0 +1,22 @@ +/*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 00'); + + dialog.querySelector('.h5p-confirmation-dialog-text').appendChild(countDownText); + const countDownNum = countDownText.querySelector('.count-down-num'); + + // Create throbber + const throbberWrapper = document.createElement('div'); + throbberWrapper.classList.add('throbber-wrapper'); + const throbber = document.createElement('div'); + throbber.classList.add('sending-requests-throbber'); + throbberWrapper.appendChild(throbber); + + requestQueue.on('requestQueued', function (e) { + // Already processing queue, wait until queue has finished processing before showing dialog + if (e.data && e.data.processingQueue) { + return; + } + + if (!isAttached) { + const rootContent = document.body.querySelector('.h5p-content'); + if (!rootContent) { + return; + } + offlineDialog.appendTo(rootContent); + rootContent.appendChild(throbberWrapper); + isAttached = true; + } + + startCountDown(); + }.bind(this)); + + requestQueue.on('queueEmptied', function (e) { + if (e.data && e.data.length) { + // New requests were added while processing queue or requests failed again. Re-queue requests. + startCountDown(true); + return; + } + + // Successfully emptied queue + clearInterval(currentInterval); + toggleThrobber(false); + intervalIndex = -1; + if (isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + requestQueue.displayToastMessage( + H5P.t('offlineSuccessfulSubmit'), + true, + { + position: { + vertical: 'top', + offsetVertical: '100', + } + } + ); + + }.bind(this)); + + offlineDialog.on('confirmed', function () { + // Show dialog on next render in case it is being hidden by the 'confirm' button + isShowing = false; + setTimeout(function () { + retryRequests(); + }, 100); + }.bind(this)); + + // Initialize listener for when requests are added to queue + window.addEventListener('online', function () { + retryRequests(); + }.bind(this)); + + // Listen for queued requests outside the iframe + window.addEventListener('message', function (event) { + const isValidQueueEvent = window.parent === event.source + && event.data.context === 'h5p' + && event.data.action === 'queueRequest'; + + if (!isValidQueueEvent) { + return; + } + + this.add(event.data.url, event.data.data); + }.bind(this)); + + /** + * Toggle throbber visibility + * + * @param {boolean} [forceShow] Will force throbber visibility if set + */ + const toggleThrobber = function (forceShow) { + isLoading = !isLoading; + if (forceShow !== undefined) { + isLoading = forceShow; + } + + if (isLoading && isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + if (isLoading) { + throbberWrapper.classList.add('show'); + } + else { + throbberWrapper.classList.remove('show'); + } + }; + /** + * Retries the failed requests + */ + const retryRequests = function () { + clearInterval(currentInterval); + toggleThrobber(true); + requestQueue.resumeQueue(); + }; + + /** + * Increments retry interval + */ + const incrementRetryInterval = function () { + intervalIndex += 1; + if (intervalIndex >= retryIntervals.length) { + intervalIndex = retryIntervals.length - 1; + } + }; + + /** + * Starts counting down to retrying queued requests. + * + * @param forceDelayedShow + */ + const startCountDown = function (forceDelayedShow) { + // Already showing, wait for retry + if (isShowing) { + return; + } + + toggleThrobber(false); + if (!isShowing) { + if (forceDelayedShow) { + // Must force delayed show since dialog may be hiding, and confirmation dialog does not + // support this. + setTimeout(function () { + offlineDialog.show(0); + }, 100); + } + else { + offlineDialog.show(0); + } + } + isShowing = true; + startTime = new Date().getTime(); + incrementRetryInterval(); + clearInterval(currentInterval); + currentInterval = setInterval(updateCountDown, 100); + }; + + /** + * Updates the count down timer. Retries requests when time expires. + */ + const updateCountDown = function () { + const time = new Date().getTime(); + const timeElapsed = Math.floor((time - startTime) / 1000); + const timeLeft = retryIntervals[intervalIndex] - timeElapsed; + countDownNum.textContent = timeLeft.toString(); + + // Retry interval reached, retry requests + if (timeLeft <= 0) { + retryRequests(); + } + }; + + /** + * Add request to offline request queue. Only supports posts for now. + * + * @param {string} url The request url + * @param {Object} data The request data + */ + this.add = function (url, data) { + // Only queue request if it failed because we are offline + if (window.navigator.onLine) { + return false; + } + + requestQueue.add(url, data); + }; + }; + + return offlineRequestQueue; +})(H5P.RequestQueue, H5P.ConfirmationDialog); diff --git a/packages/runtime/vendor/h5p-core/strings.json b/packages/runtime/vendor/h5p-core/strings.json new file mode 100644 index 0000000..f066a82 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/strings.json @@ -0,0 +1,69 @@ +{ + "fullscreen": "Fullscreen", + "disableFullscreen": "Disable fullscreen", + "download": "Download", + "copyrights": "Rights of use", + "embed": "Embed", + "size": "Size", + "showAdvanced": "Show advanced", + "hideAdvanced": "Hide advanced", + "advancedHelp": "Include this script on your website if you want dynamic sizing of the embedded content:", + "copyrightInformation": "Rights of use", + "close": "Close", + "title": "Title", + "author": "Author", + "year": "Year", + "source": "Source", + "license": "License", + "thumbnail": "Thumbnail", + "noCopyrights": "No copyright information available for this content.", + "reuse": "Reuse", + "reuseContent": "Reuse Content", + "reuseDescription": "Reuse this content.", + "downloadDescription": "Download this content as a H5P file.", + "copyrightsDescription": "View copyright information for this content.", + "embedDescription": "View the embed code for this content.", + "h5pDescription": "Visit H5P.org to check out more cool content.", + "contentChanged": "This content has changed since you last used it.", + "startingOver": "You'll be starting over.", + "by": "by", + "showMore": "Show more", + "showLess": "Show less", + "subLevel": "Sublevel", + "confirmDialogHeader": "Confirm action", + "confirmDialogBody": "Please confirm that you wish to proceed. This action is not reversible.", + "cancelLabel": "Cancel", + "confirmLabel": "Confirm", + "licenseU": "Undisclosed", + "licenseCCBY": "Attribution", + "licenseCCBYSA": "Attribution-ShareAlike", + "licenseCCBYND": "Attribution-NoDerivs", + "licenseCCBYNC": "Attribution-NonCommercial", + "licenseCCBYNCSA": "Attribution-NonCommercial-ShareAlike", + "licenseCCBYNCND": "Attribution-NonCommercial-NoDerivs", + "licenseCC40": "4.0 International", + "licenseCC30": "3.0 Unported", + "licenseCC25": "2.5 Generic", + "licenseCC20": "2.0 Generic", + "licenseCC10": "1.0 Generic", + "licenseGPL": "General Public License", + "licenseV3": "Version 3", + "licenseV2": "Version 2", + "licenseV1": "Version 1", + "licensePD": "Public Domain", + "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licensePDM": "Public Domain Mark", + "licenseC": "Copyright", + "contentType": "Content Type", + "licenseExtras": "License Extras", + "changes": "Changelog", + "contentCopied": "Content is copied to the clipboard", + "connectionLost": "Connection lost. Results will be stored and sent when you regain connection.", + "connectionReestablished": "Connection reestablished.", + "resubmitScores": "Attempting to submit stored results.", + "offlineDialogHeader": "Your connection to the server was lost", + "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "offlineDialogRetryMessage": "Retrying in :num....", + "offlineDialogRetryButtonLabel": "Retry now", + "offlineSuccessfulSubmit": "Successfully submitted results." +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css b/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css new file mode 100644 index 0000000..6e2efa6 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css @@ -0,0 +1,311 @@ +.h5p-confirmation-dialog-background { + position: fixed; + height: 100%; + width: 100%; + left: 0; + top: 0; + background: rgba(44, 44, 44, 0.9); + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); + opacity: 1; + visibility: visible; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0s; + z-index: 201; +} +.h5p-confirmation-dialog-background.hidden { + display: none; +} +.h5p-confirmation-dialog-background.hiding { + opacity: 0; + visibility: hidden; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0.1s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0.1s; +} +.h5p-confirmation-dialog-popup:focus { + outline: none; +} +.h5p-confirmation-dialog-popup { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + box-sizing: border-box; + max-width: 35em; + min-width: 25em; + top: 2em; + left: 50%; + -webkit-transform: translate(-50%, 0%); + -ms-transform: translate(-50%, 0%); + transform: translate(-50%, 0%); + color: #555; + -webkit-transition: transform 0.1s ease-in; + transition: transform 0.1s ease-in; +} +.h5p-confirmation-dialog-popup.hidden { + -webkit-transform: translate(-50%, 50%); + -ms-transform: translate(-50%, 50%); + transform: translate(-50%, 50%); +} +.h5p-confirmation-dialog-header { + padding: 1.5em; + background: #fff; + color: #356593; +} +.h5p-confirmation-dialog-header-text { + font-size: 1.25em; +} +.h5p-confirmation-dialog-body { + background: #fafbfc; + border-top: solid 1px #dde0e9; + padding: 1.25em 1.5em; +} +.h5p-confirmation-dialog-text { + margin-bottom: 1.5em; +} +.h5p-confirmation-dialog-buttons { + float: right; +} +button.h5p-confirmation-dialog-exit:visited, button.h5p-confirmation-dialog-exit:link, button.h5p-confirmation-dialog-exit { + position: absolute; + background: none; + border: none; + font-size: 2.5em; + top: -0.9em; + right: -1.15em; + color: #fff; + cursor: pointer; + text-decoration: none; +} +button.h5p-confirmation-dialog-exit:focus, button.h5p-confirmation-dialog-exit:hover { + color: #E4ECF5; +} +.h5p-confirmation-dialog-exit:before { + font-family: "H5P"; + content: "\e890"; +} +.h5p-core-button.h5p-confirmation-dialog-confirm-button { + @media screen and (max-width: 576px) { + padding-left: 1.5rem; + + span { + display: none; + } + } + + padding-left: 0.75em; + margin-bottom: 0; +} +.h5p-core-button.h5p-confirmation-dialog-confirm-button:before { + content: "\e601"; + margin-top: -6px; + display: inline-block; +} +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-buttons { + float: none; + text-align: center; +} +.h5p-confirmation-dialog-popup.offline .count-down { + font-family: Arial; + margin-top: 0.15em; + color: #000; +} +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-confirm-button:before { + content: "\e90b"; + font-weight: normal; + vertical-align: text-bottom; +} +.throbber-wrapper { + display: none; + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 1; + background: rgba(44, 44, 44, 0.9); + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); +} +.throbber-wrapper.show { + display: block; +} +.throbber-wrapper .throbber-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} +.throbber-wrapper .sending-requests-throbber { + position: absolute; + top: 7em; + left: 50%; + transform: translateX(-50%); +} +.throbber-wrapper .sending-requests-throbber:before { + display: block; + font-family: 'H5P'; + content: "\e90b"; + color: white; + font-size: 10em; + animation: request-throbber 1.5s infinite linear; +} +@keyframes request-throbber { + from { + transform: rotate(0); + } + to { + transform: rotate(359deg); + } +} + +@media (prefers-reduced-motion) { + .h5p-confirmation-dialog-background { + -webkit-transition: none; + transition: none; + + .h5p-confirmation-dialog-popup { + -webkit-transition: none; + transition: none; + } + + &.hiding { + .h5p-confirmation-dialog-popup { + opacity: 0; + } + } + } +} + +/* H5P Theme */ + +.h5p-theme .h5p-confirmation-dialog-popup { + max-width: 45rem; + width: calc(80% - 2*var(--h5p-theme-spacing-l)); + min-width: unset; + color: var(--h5p-theme-text-primary); +} + +.h5p-theme .h5p-confirmation-dialog-header { + display: flex; + align-items: center; + color: var(--h5p-theme-text-primary); + padding: var(--h5p-theme-spacing-m) var(--h5p-theme-spacing-l); + font-weight: bold; + background-color: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large) 0 0; +} + +.h5p-theme .h5p-confirmation-dialog-header-text { + font-size: var(--h5p-theme-font-size-xl); + margin-right: 3em; +} + +.h5p-theme .h5p-confirmation-dialog-body { + background: var(--h5p-theme-ui-base); + border-top: 0px; + padding: 0 var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l); + border-radius: 0 0 var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large); +} + +.h5p-theme button.h5p-confirmation-dialog-exit:visited, +.h5p-theme button.h5p-confirmation-dialog-exit:link, +.h5p-theme button.h5p-confirmation-dialog-exit { + font-size: var(--h5p-theme-font-size-xl); + color: var(--h5p-theme-text-primary); + top: var(--h5p-theme-spacing-s); + right: var(--h5p-theme-spacing-s); + line-height: calc(var(--h5p-theme-spacing-s) * 3.0836); + padding: 0; +} + +.h5p-theme button.h5p-confirmation-dialog-exit:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + border-radius: 100%; + outline-offset: 2px; +} + +.h5p-theme .h5p-confirmation-dialog-exit:before { + transform: translateY(-0.48em); + font-family: "h5p-theme"; + content: "\e910"; + padding: var(--h5p-theme-spacing-s); + border-radius: 50%; +} + +.h5p-theme .h5p-confirmation-dialog-exit:hover:before { + color: var(--h5p-theme-text-primary); + background-color: var(--h5p-theme-alternative-base); +} + +/* TODO: check if code above makes this redundant */ +.h5p-theme .h5p-core-button:focus-visible { + background-color: var(--h5p-theme-contrast-cta-white); +} + +.h5p-theme .h5p-confirmation-dialog-background { + background-color: rgba(0,0,0,0.85); /* Fallback color */ + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); +} + +.h5p-theme .h5p-confirmation-dialog-buttons { + container-type: inline-size; + container-name: h5p-confirmation-dialog-buttons; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--h5p-theme-spacing-s); + width: 100%; +} + +.h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta { + padding-inline: calc(var(--h5p-theme-spacing-l)); +} + +.h5p-theme .h5p-core-button.h5p-confirmation-dialog-confirm-button:before { + content: "\e908"; + margin-top: unset; + font-size: unset; + font-family: "h5p-theme"; + display: inline-block; + line-height: unset; + padding-right: unset; +} + +@container (max-width: 400px) { + .h5p-theme .h5p-confirmation-dialog-buttons button.h5p-theme-cancel::before { + content: "\e910"; + font-family: "h5p-theme"; + font-size: 90%; + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:hover { + padding-inline: var(--h5p-theme-spacing-l); + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:disabled:before, + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta:disabled:before, + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:before { + margin-right: 0; + opacity: 1; + padding: 0; + position: unset; + text-indent: 0; + transform: translate(0, 0); + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta:before { + margin-right: 0; + } + + .h5p-theme .h5p-confirmation-dialog-buttons span { + display: none; + } +} + +@container (max-width: 250px) { + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:hover { + padding-inline: var(--h5p-theme-spacing-s); + } + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta { + padding-inline: var(--h5p-theme-spacing-s); + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css b/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css new file mode 100644 index 0000000..b708729 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css @@ -0,0 +1,91 @@ +button.h5p-core-button:visited, +button.h5p-core-button:link, +button.h5p-core-button { + font-family: "Open Sans", sans-serif; + font-weight: 600; + font-size: 1em; + line-height: 1.2; + padding: 0.5em 1.25em; + border-radius: 2em; + + background: #2579c6; + color: #fff; + + cursor: pointer; + border: none; + box-shadow: none; + outline: none; + + display: inline-block; + text-align: center; + text-shadow: none; + vertical-align: baseline; + text-decoration: none; + + -webkit-transition: initial; + transition: initial; +} +button.h5p-core-button:focus { + background: #1f67a8; +} +button.h5p-core-button:hover { + background: rgba(31, 103, 168, 0.83); +} +button.h5p-core-button:active { + background: #104888; +} +button.h5p-core-button:before { + font-family: 'H5P'; + padding-right: 0.15em; + font-size: 1.5em; + vertical-align: middle; + line-height: 0.7; +} +button.h5p-core-cancel-button:visited, +button.h5p-core-cancel-button:link, +button.h5p-core-cancel-button { + border: none; + background: none; + color: #a00; + margin-right: 1em; + font-size: 1em; + text-decoration: none; + cursor: pointer; +} +button.h5p-core-cancel-button:hover, +button.h5p-core-cancel-button:focus { + background: none; + border: none; + color: #e40000; +} + +/* H5P theme */ + +.h5p-theme .h5p-core-cancel-button:focus { + color: var(--h5p-theme-main-cta-base); + border: solid 3px var(--h5p-theme-main-cta-base); +} + +.h5p-theme .h5p-core-cancel-button:focus:active { + color: var(--h5p-theme-contrast-cta); +} + +.h5p-theme .h5p-core-cancel-button:hover { + background-color: var(--h5p-theme-main-cta-base); + border: solid 3px var(--h5p-theme-main-cta-base); + color: var(--h5p-theme-contrast-cta); + cursor: pointer; +} + +.h5p-theme .h5p-core-button { + display: flex; +} + +.h5p-theme .h5p-core-button:before { + font-family: 'h5p-theme'; + padding-right: unset; + font-size: unset; + vertical-align: unset; + line-height: unset; +} + diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css b/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css new file mode 100644 index 0000000..9fd2ba1 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css @@ -0,0 +1,103 @@ +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + src: url('../fonts/inter/Inter-ExtraBold.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + src: url('../fonts/inter/Inter-ExtraBoldItalic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700italic.woff2') format('woff2'); +} +@font-face { + font-family: 'h5p'; + src: url('../fonts/h5p-core-30.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: 'h5p-hub-publish'; + src: url('../fonts/h5p-hub-publish.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + font-display: block; +} +@font-face { + font-family: 'h5p-theme'; + src: url('../fonts/h5p-theme.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} \ No newline at end of file diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-table.css b/packages/runtime/vendor/h5p-core/styles/h5p-table.css new file mode 100644 index 0000000..e781bdc --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-table.css @@ -0,0 +1,56 @@ +/* Table styling for content types to imitate ckeditor 5 */ +.h5p-iframe { + /* The figure around the table */ + figure.table { + display: table; + table-layout: fixed; + margin: 0 auto; + padding: 0; + float: left; + + /* The actual table */ + table { + border-collapse: collapse; + height: 100%; + width: 100%; + border-spacing: 0; + border-width: 1px; + border-color: #494949; + + td, th { + padding: 1px; + border-color: #494949; + border-bottom-style: solid; + } + + td { + border-width: 0.083em; + } + + th { + text-align: left; + border-width: .167em; + } + + tr:last-child > td { + border-bottom-style: none; + } + } + + figcaption { + background-color: transparent; + caption-side: top; + color: #333; + display: table-caption; + font-size: .75em; + outline-offset: -1px; + padding: .6em; + text-align: center; + word-break: break-word; + } + } + + .table-overflow-protection { + clear: both; + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css b/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css new file mode 100644 index 0000000..4b591e0 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css @@ -0,0 +1,139 @@ +:root { + --h5p-theme-main-cta-base: #006FBF; + --h5p-theme-secondary-cta-base: #E3E9F1; + --h5p-theme-alternative-base: #F1F5FB; + --h5p-theme-background: #F9FBFF; + --h5p-theme-focus: #006FBF; /*TODO: not yet in use */ + + --h5p-theme-main-cta-light: #0D74C0; /*Deviates from +/-5% lightness rule, but is needed for WCAG AA*/ + --h5p-theme-main-cta-dark: #005FA3; + --h5p-theme-contrast-cta: #EBF7FF; + --h5p-theme-contrast-cta-white: #006FBF; + + --h5p-theme-contrast-cta-light: color-mix(in srgb, var(--h5p-theme-main-cta-base), transparent 90%); + + --h5p-theme-secondary-cta-light: #F5F7FA; + --h5p-theme-secondary-cta-dark: #D3DCE9; + --h5p-theme-secondary-contrast-cta: #202122; + --h5p-theme-secondary-contrast-cta-hover: #EBF7FF; + + --h5p-theme-alternative-light: #F7F9FD; + --h5p-theme-alternative-dark: #DBE5F5; + --h5p-theme-alternative-darker: #C7D7EF; + + --h5p-theme-text-primary: #101729; + --h5p-theme-text-secondary: #354054; + --h5p-theme-text-third: #737373; + --h5p-theme-ui-base: #FFFFFF; + --h5p-theme-stroke-1: #DCDFFA; + --h5p-theme-stroke-2: #EDEEFB; + --h5p-theme-stroke-3: #E5E5E5; + --h5p-theme-border-radius-large: 0.5rem; + --h5p-theme-border-radius-medium: 0.375rem; + --h5p-theme-border-radius-small: 0.25rem; + --h5p-theme-font-name: "Inter", sans-serif; + + --h5p-theme-feedback-correct-main: #256D1D; + --h5p-theme-feedback-correct-secondary: #f3fcf0; + --h5p-theme-feedback-correct-third: #cff1c2; + + --h5p-theme-feedback-incorrect-main: #a13236; + --h5p-theme-feedback-incorrect-secondary: #faf0f4; + --h5p-theme-feedback-incorrect-third: #f6dce7; + + --h5p-theme-feedback-neutral-main: #E6C81D; + --h5p-theme-feedback-neutral-secondary: #5E4817; + --h5p-theme-feedback-neutral-third: #F0EBCB; + + /* SPACING */ + + /* Small (default) */ + --h5p-theme-spacing-xl-primary-small: calc(var(--h5p-theme-spacing-xl-primary-medium)*0.8); + --h5p-theme-spacing-primary-small: calc(var(--h5p-theme-spacing-primary-medium)*0.8); + --h5p-theme-spacing-secondary-small: calc(var(--h5p-theme-spacing-secondary-medium)*0.8); + --h5p-theme-spacing-third-small: calc(var(--h5p-theme-spacing-third-medium)*0.8); + --h5p-theme-spacing-fourth-small: calc(var(--h5p-theme-spacing-fourth-medium)*0.8); + --h5p-theme-spacing-fifth-small: calc(var(--h5p-theme-spacing-fifth-medium)*0.8); + + --h5p-theme-font-size-xxl-small: calc(var(--h5p-theme-font-size-xxl-medium)*1); + --h5p-theme-font-size-xl-small: calc(var(--h5p-theme-font-size-xl-medium)*1); + --h5p-theme-font-size-l-small: calc(var(--h5p-theme-font-size-l-medium)*1); + --h5p-theme-font-size-m-small: calc(var(--h5p-theme-font-size-m-medium)*1); + --h5p-theme-font-size-s-small: calc(var(--h5p-theme-font-size-s-medium)*1); + + /* Medium */ + --h5p-theme-spacing-xl-primary-medium: calc(var(--h5p-theme-spacing-xl-primary-large)*0.8); + --h5p-theme-spacing-primary-medium: calc(var(--h5p-theme-spacing-primary-large)*0.8); + --h5p-theme-spacing-secondary-medium: calc(var(--h5p-theme-spacing-secondary-large)*0.8); + --h5p-theme-spacing-third-medium: calc(var(--h5p-theme-spacing-third-large)*0.8); + --h5p-theme-spacing-fourth-medium: calc(var(--h5p-theme-spacing-fourth-large)*0.8); + --h5p-theme-spacing-fifth-medium: calc(var(--h5p-theme-spacing-fifth-large)*0.8); + + --h5p-theme-font-size-xxl-medium: calc(var(--h5p-theme-font-size-xxl-large)*0.9); + --h5p-theme-font-size-xl-medium: calc(var(--h5p-theme-font-size-xl-large)*0.9); + --h5p-theme-font-size-l-medium: calc(var(--h5p-theme-font-size-l-large)*0.9); + --h5p-theme-font-size-m-medium: calc(var(--h5p-theme-font-size-m-large)*0.9); + --h5p-theme-font-size-s-medium: calc(var(--h5p-theme-font-size-s-large)*0.9); + + /* Large */ + --h5p-theme-spacing-xl-primary-large: 3rem; + --h5p-theme-spacing-primary-large: 2rem; + --h5p-theme-spacing-secondary-large: 1.5rem; + --h5p-theme-spacing-third-large: 1rem; + --h5p-theme-spacing-fourth-large: 0.65rem; + --h5p-theme-spacing-fifth-large: 0.5rem; + + --h5p-theme-font-size-xxl-large: 1.5rem; + --h5p-theme-font-size-xl-large: 1.25rem; + --h5p-theme-font-size-l-large: 1.125rem; + --h5p-theme-font-size-m-large: 1rem; + --h5p-theme-font-size-s-large: 0.85rem; +} + +.h5p-content, +.h5peditor { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-small); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-small); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-small); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-small); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-small); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-small); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-small); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-small); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-small); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-small); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-small); + --h5p-theme-scaling: 0.6; +} + +.h5p-content.h5p-medium, +.h5peditor.h5p-medium { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-medium); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-medium); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-medium); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-medium); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-medium); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-medium); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-medium); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-medium); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-medium); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-medium); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-medium); + --h5p-theme-scaling: 0.8; +} + +.h5p-content.h5p-large, +.h5peditor.h5p-large { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-large); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-large); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-large); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-large); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-large); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-large); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-large); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-large); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-large); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-large); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-large); + --h5p-theme-scaling: 1; +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-theme.css b/packages/runtime/vendor/h5p-core/styles/h5p-theme.css new file mode 100644 index 0000000..5aec336 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-theme.css @@ -0,0 +1,184 @@ +.h5p-theme .h5p-content { + font-family: var(--h5p-theme-font-name); +} + +.h5p-theme *:before, .h5p-theme *:after { + font-weight: normal; +} + +/* Form elements don't naturally inherit font-family */ +.h5p-theme .h5p-content input, .h5p-theme .h5p-content textarea, .h5p-theme .h5p-content button { + font-family: inherit; +} + +/* DIALOGS */ +.h5p-content:has(.h5p-theme) .h5p-popup-dialog { + background-color: rgba(0,0,0,0.85); /* Fallback color */ + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); + container-name: h5p-popup-dialog; + container-type: inline-size; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-inner { + color: var(--h5p-theme-text-primary); + border-radius: var(--h5p-theme-border-radius-large); +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog .h5p-inner { + max-width: 45rem; + width: fit-content; +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog.h5p-popup-dialog.h5p-open .h5p-scroll-content { + border-top: 0; + padding-top: 4.5em; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-reuse-dialog .h5p-inner > h2 { + box-shadow: none; +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog .h5p-inner > h2 > a { + color: var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-inner > h2 { + background-color: var(--h5p-theme-ui-base); + display: flex; + align-items: center; + color: var(--h5p-theme-text-primary); + padding: var(--h5p-theme-spacing-m) var(--h5p-theme-spacing-l); + font-weight: bold; + border-bottom: 0; + background-color: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large) 0 0 ; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-open .h5p-scroll-content { + padding: var(--h5p-theme-spacing-xl) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l); + display: grid; + grid-template-columns: repeat(var(--button-count, 2), 1fr); + background: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large); + grid-gap: var(--h5p-theme-spacing-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-copyrights-dialog.h5p-open .h5p-scroll-content { + grid-template-columns: unset; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-embed-dialog.h5p-open .h5p-scroll-content, .h5p-expander-content, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-content-user-data-reset-dialog.h5p-open .h5p-scroll-content { + display: block; +} + +/* Same as primary CTA */ +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button { + padding: var(--h5p-theme-spacing-xs) var(--h5p-theme-spacing-l); + background-color: var(--h5p-theme-main-cta-base); + color: var(--h5p-theme-contrast-cta); + border-radius: var(--h5p-theme-border-radius-medium); + font-weight: 600; + cursor: pointer; + line-height: 1; + font-size: var(--h5p-theme-font-size-m); + transition: all 0.1s linear 0s; + transition-property: text-indent, padding; + border: solid 3px var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button:hover { + background-color: var(--h5p-theme-main-cta-light); + border-color: var(--h5p-theme-main-cta-light); +} + +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button:active { + background-color: var(--h5p-theme-main-cta-dark); + border-color: var(--h5p-theme-main-cta-dark); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close { + font-size: var(--h5p-theme-font-size-xl); + margin: var(--h5p-theme-spacing-xs) var(--h5p-theme-spacing-s); + color: var(--h5p-theme-text-primary); + text-align: center; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close { + display: inline-block; + padding: var(--h5p-theme-spacing-s); + border-radius: 50%; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close:after { + font-family: "h5p-theme"; + content: "\e910"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close:hover { + color: var(--h5p-theme-text-primary); + background-color: var(--h5p-theme-alternative-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-horizontal-line-text { + display: none; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:before { + position: unset; + font-family: 'h5p-theme'; + color: var(--h5p-theme-contrast-cta-white); + display: block; + margin-bottom: var(--h5p-theme-spacing-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-button-title { + color: var(--h5p-theme-contrast-cta-white); + font-size: var(--h5p-theme-font-size-l); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-button-description { + color: var(--h5p-theme-text-third); + font-size: var(--h5p-theme-font-size-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button { + padding: var(--h5p-theme-spacing-m); + background: var(--h5p-theme-ui-base); + border: solid 2px var(--h5p-theme-stroke-2); + max-width: 20rem; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-copy-button:before { + content: "\e912"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-download-button:before { + content: "\e911"; + width: max-content; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-download-button:disabled:before { + content: "\e90d"; + font-family: "h5p"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover { + background-color: var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover.h5p-big-button:before, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover .h5p-button-title, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover .h5p-button-description { + color: var(--h5p-theme-contrast-cta); +} + +@container h5p-popup-dialog (width < 480px) { + .h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button { + max-width: 100%; + } + + .h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-open .h5p-scroll-content { + grid-template-columns: auto; + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css b/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css new file mode 100644 index 0000000..e792008 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css @@ -0,0 +1,38 @@ +.h5p-tooltip { + display: none; + position: absolute; + + z-index: 110; + + font-size: 0.8rem; + line-height: 1.2; + text-align: left; + + padding: 0.25rem 0.5rem; + white-space: normal; + word-break: break-all; + width: max-content; + max-width: min(300px, 90%); + + background: #000; + color: #FFF; + + cursor: default; +} +.h5p-tooltip-narrow { + max-width: min(300px, 70%); +} +.h5p-tooltip-visible { + display: block; +} + +.h5p-iframe:has(.h5p-theme) .h5p-tooltip { + background-color: var(--h5p-theme-text-secondary); + color: var(--h5p-theme-ui-base); + font-size: var(--h5p-theme-font-size-m); + max-width: min(400px, 90%); + line-height: 1; + /*border: solid 1px var(--h5p-theme-stroke-1); + box-shadow: 0 0 0.5em #2c2c2c;*/ + border-radius: var(--h5p-theme-border-radius-medium); +} \ No newline at end of file diff --git a/packages/runtime/vendor/h5p-core/styles/h5p.css b/packages/runtime/vendor/h5p-core/styles/h5p.css new file mode 100644 index 0000000..ee798dc --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p.css @@ -0,0 +1,716 @@ +html.h5p-iframe, html.h5p-iframe > body { + font-family: Sans-Serif; /* Use the browser's default sans-serif font. (Since Heletica doesn't look nice on Windows, and Arial on OS X.) */ + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} +html.h5p-iframe .h5p-theme, +html.h5p-iframe > body .h5p-theme, +.h5peditor .h5p-theme { + font-family: var( + --h5p-theme-font-name + ); /* Use the browser's default sans-serif font. (Since Heletica doesn't look nice on Windows, and Arial on OS X.) */ +} +.h5p-semi-fullscreen, .h5p-fullscreen, html.h5p-iframe .h5p-container { + overflow: hidden; +} +.h5p-content { + position: relative; + background: #fefefe; + border: 1px solid #EEE; + border-bottom: none; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-noselect +{ + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} +html.h5p-iframe .h5p-content { + font-size: 16px; + line-height: 1.5em; + width: 100%; + height: auto; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} +html.h5p-iframe .h5p-fullscreen .h5p-content, +html.h5p-iframe .h5p-semi-fullscreen .h5p-content { + height: 100%; +} +.h5p-content.h5p-no-frame, +.h5p-fullscreen .h5p-content, +.h5p-semi-fullscreen .h5p-content { + border: 0; +} +.h5p-container { + position: relative; + z-index: 1; + overflow: hidden; +} +.h5p-iframe-wrapper.h5p-fullscreen { + background-color: #000; +} +body.h5p-semi-fullscreen { + position: fixed; + width: 100%; + height: 100%; +} +.h5p-container.h5p-semi-fullscreen { + position: fixed; + top: 0; + left: 0; + z-index: 101; + width: 100%; + height: 100%; + background-color: #FFF; +} + +.h5p-content-controls { + margin: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; +} +.h5p-fullscreen .h5p-content-controls { + display: none; +} + +.h5p-content-controls > a:link, .h5p-content-controls > a:visited, a.h5p-disable-fullscreen:link, a.h5p-disable-fullscreen:visited { + color: #e5eef6; +} + +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen:before { + font-family: 'h5p-theme'; + font-weight: bold; + content: "\e915"; +} +.h5p-theme .h5p-content-controls .h5p-disable-fullscreen:before { + font-family: 'h5p-theme'; + font-weight: bold; + content: "\e914"; +} +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen, .h5p-theme .h5p-content-controls .h5p-disable-fullscreen { + cursor: pointer; + color: #EEE; + background: rgb(0,0,0); + background: rgba(0,0,0,0.3); + font-size: var(--h5p-theme-font-size-m); + text-indent: 0.04em; + padding: var(--h5p-theme-spacing-xs); +} + +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen:focus-visible, .h5p-theme .h5p-content-controls .h5p-disable-fullscreen:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + border-radius: 4px; + outline-offset: 2px; +} + +.h5p-enable-fullscreen:before { + font-family: 'H5P'; + content: "\e88c"; +} +.h5p-disable-fullscreen:before { + font-family: 'H5P'; + content: "\e891"; +} +.h5p-enable-fullscreen, .h5p-disable-fullscreen { + cursor: pointer; + color: #EEE; + background: rgb(0,0,0); + background: rgba(0,0,0,0.3); + line-height: 0.975em; + font-size: 2em; + width: 1.125em; + height: 1em; + text-indent: 0.04em; +} +.h5p-disable-fullscreen { + line-height: 0.925em; + width: 1.1em; + height: 0.9em; +} + +.h5p-enable-fullscreen:focus, +.h5p-disable-fullscreen:focus { + outline-style: solid; + outline-width: 1px; + outline-offset: 0.25em; +} + +.h5p-enable-fullscreen:hover, .h5p-disable-fullscreen:hover { + background: rgba(0,0,0,0.5); +} +.h5p-content-controls.themed .h5p-enable-fullscreen:hover, .h5p-content-controls.themed .h5p-disable-fullscreen:hover { + background: rgba(0,0,0,0.5); +} +.h5p-semi-fullscreen .h5p-enable-fullscreen { + display: none; +} + +div.h5p-fullscreen { + width: 100%; + height: 100%; +} +.h5p-iframe-wrapper { + width: auto; + height: auto; +} + +.h5p-fullscreen .h5p-iframe-wrapper, +.h5p-semi-fullscreen .h5p-iframe-wrapper { + width: 100%; + height: 100%; +} + +.h5p-iframe-wrapper.h5p-semi-fullscreen { + width: auto; + height: auto; + background: black; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100001; +} +.h5p-iframe-wrapper.h5p-semi-fullscreen .buttons { + position: absolute; + top: 0; + right: 0; + z-index: 20; +} +.h5p-iframe-wrapper iframe.h5p-iframe { + /* Hack for IOS landscape / portrait */ + width: 10px; + min-width: 100%; + *width: 100%; + /* End of hack */ + height: 100%; + z-index: 10; + overflow: hidden; + border: 0; + display: block; +} + +.h5p-content ul.h5p-actions { + box-sizing: border-box; + -moz-box-sizing: border-box; + list-style: none; + padding: 0px 10px; + margin: 0; + height: 25px; + font-size: 12px; + background: #FAFAFA; + border-top: 1px solid #EEE; + border-bottom: 1px solid #EEE; + clear: both; + font-family: Sans-Serif; +} +.h5p-fullscreen .h5p-actions, .h5p-semi-fullscreen .h5p-actions { + display: none; +} +.h5p-actions > .h5p-button { + float: left; + cursor: pointer; + margin: 0 0.5em 0 0; + background: none; + padding: 0 0.75em 0 0.25em; + vertical-align: top; + color: #707070; + text-decoration: none; + outline: none; + line-height: 22px; +} +.h5p-actions button:hover { + color: #333; +} +.h5p-actions button:active, +.h5p-actions button:focus, +.h5p-actions .h5p-link:active, +.h5p-actions .h5p-link:focus { + color: #666; +} +.h5p-actions button { + display: inline-flex; + padding: 0; + margin: 0; + color: #6A6A6A; + position: relative; + + /* Disable default button style */ + background: none; + border: none; + font: inherit; + cursor: pointer; + + line-height: 2; +} +.h5p-actions button:focus, +.h5p-actions .h5p-link:focus { + outline-style: solid; + outline-width: thin; + outline-offset: -2px; + outline-color: #5981A1; +} +.h5p-actions button:before { + font-family: 'H5P'; + font-size: 20px; + line-height: 23px; + vertical-align: bottom; + padding-right: 0; +} +.h5p-actions > .h5p-button.h5p-export > button:before { + content: "\e90b"; +} +.h5p-actions > .h5p-button.h5p-copyrights > button:before { + content: "\e88f"; +} +.h5p-actions > .h5p-button.h5p-embed > button:before { + content: "\e892"; +} +.h5p-actions .h5p-link { + float: right; + margin-right: 0; + font-size: 2.0em; + line-height: 23px; + position: relative; + color: #6a6a6a; + text-decoration: none; + outline: none; +} +.h5p-actions .h5p-link:before { + font-family: 'H5P'; + content: "\e88e"; + vertical-align: bottom; +} +.h5p-actions > li { + margin: 0; + list-style: none; +} +.h5p-popup-dialog { + position: absolute; + top: 0; + left: 0; + width: 100%; + min-height: 100%; + z-index: 100; + padding: 2em; + box-sizing: border-box; + -moz-box-sizing: border-box; + opacity: 0; + -webkit-transition: opacity 0.2s; + -moz-transition: opacity 0.2s; + -o-transition: opacity 0.2s; + transition: opacity 0.2s; + background:#000; + background:rgba(0,0,0,0.75); +} +.h5p-popup-dialog.h5p-open { + opacity: 1; +} +.h5p-popup-dialog .h5p-inner { + box-sizing: border-box; + -moz-box-sizing: border-box; + background: #fff; + height: 100%; + max-height: 100%; + position: relative; +} +.h5p-popup-dialog .h5p-inner > h2 { + position: absolute; + box-sizing: border-box; + -moz-box-sizing: border-box; + width: 100%; + margin: 0; + background: #eee; + display: block; + color: #656565; + font-size: 1.25em; + padding: 0.325em 0.5em 0.25em; + line-height: 1.25em; + border-bottom: 1px solid #ccc; + z-index: 2; +} +.h5p-popup-dialog .h5p-inner > h2 > a { + font-size: 12px; + margin-left: 1em; +} +/* TODO: Fix the color of the link */ +.h5p-theme .h5p-popup-dialog .h5p-inner > h2 > a{ + color: var(--h5p-theme-main-cta-base); +} +.h5p-embed-dialog .h5p-inner, +.h5p-reuse-dialog .h5p-inner, +.h5p-content-user-data-reset-dialog .h5p-inner { + min-width: 316px; + max-width: 400px; + left: 50%; + top: 50%; + transform: translateX(-50%); +} +.h5p-theme .h5p-embed-dialog .h5p-inner, +.h5p-theme .h5p-reuse-dialog .h5p-inner, +.h5p-theme .h5p-content-user-data-reset-dialog .h5p-inner { + max-width: 45rem !important; + min-width: 35rem !important; +} +.h5p-content:has(.h5p-theme) .h5p-embed-dialog .h5p-embed-code-container, +.h5p-content:has(.h5p-theme) .h5p-embed-size{ + border: 1px solid var(--h5p-theme-stroke-1); + background-color: var(--h5p-theme-ui-base); + padding: var(--h5p-theme-spacing-xs); + font-size: var(--h5p-theme-font-size-m); + line-height: 1; + min-height: var(--h5p-theme-spacing-l); + letter-spacing: normal; + color: var(--h5p-theme-text-third); + border-radius: var(--h5p-theme-border-radius-medium); +} +.h5p-content:has(.h5p-theme) .h5p-content-copyrights { + border-left: none; + margin-left: 0; + padding-left: 0; + border-left: solid 2px var(--h5p-theme-alternative-dark); + padding: 5px; +} +.h5p-content:has(.h5p-theme) .h5p-media-copyright{ + border: solid 1px var(--h5p-theme-alternative-dark); + padding: var(--h5p-theme-spacing-s); + background: var(--h5p-theme-alternative-light); + border-radius: var(--h5p-theme-border-radius-medium); + margin-bottom: 1rem; +} +.h5p-embed-dialog .h5p-embed-code-container, +.h5p-embed-size { + resize: none; + outline: none; + width: 100%; + padding: 0.375em 0.5em 0.25em; + margin: 0; + overflow: hidden; + border: 1px solid #ccc; + box-shadow: 0 1px 2px 0 #d0d0d0 inset; + font-size: 0.875em; + letter-spacing: 0.065em; + font-family: sans-serif; + white-space: pre; + line-height: 1.5em; + height: 2.0714em; + background: #f5f5f5; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-content:has(.h5p-theme) .h5p-embed-dialog .h5p-embed-code-container{ + display: block; + +} +.h5p-embed-dialog .h5p-embed-code-container:focus { + height: 5em; +} +.h5p-embed-size { + width: 3.5em; + text-align: right; + margin: 0.5em 0; + line-height: 2em; +} +.h5p-popup-dialog .h5p-scroll-content { + border-top: 2.25em solid transparent; + padding: 1em; + box-sizing: border-box; + -moz-box-sizing: border-box; + color: #555555; + z-index: 1; +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-scroll-content { + color: var(--h5p-theme-text-secondary); +} +.h5p-popup-dialog.h5p-open .h5p-scroll-content { + overflow: auto; + overflow-x: hidden; + overflow-y: auto; + height: 100%; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar { + width: 8px; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-track { + background: #e0e0e0; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-thumb { + box-shadow: 0 0 10px #000 inset; + border-radius: 4px; +} +.h5p-popup-dialog .h5p-close { + cursor: pointer; + font-size: 2em; + position: absolute; + right: 0; + top: 0; + width: 1.125em; + height: 1.125em; + line-height: 1.125em; + color: #656565; + cursor: pointer; + text-indent: -0.065em; + z-index: 3 +} +.h5p-popup-dialog .h5p-close:after { + font-family: 'H5P'; + content: "\e894"; +} +.h5p-popup-dialog .h5p-close:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + outline-offset: 2px; +} +.h5p-popup-dialog .h5p-close:hover:after, +.h5p-popup-dialog .h5p-close:focus:after { + color: #454545; +} +.h5p-popup-dialog .h5p-close:active:after { + color: #252525; +} +.h5p-poopup-dialog h2 { + margin: 0.25em 0 0.5em; +} +.h5p-popup-dialog h3 { + margin: 0.75em 0 0.25em; +} +.h5p-popup-dialog dl { + margin: 0.25em 0 0.75em; +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog dl{ + margin: 0.25em 0 0.25em; +} + +.h5p-popup-dialog dt { + float: left; + margin: 0 0.75em 0 0; + font-weight: bold; +} +.h5p-popup-dialog dt:after { + content: ':'; +} +.h5p-popup-dialog dd { + margin: 0; +} +.h5p-expander { + cursor: pointer; + font-size: 1.125em; + margin: 0.5em 0 0; + display: inline-block; +} +.h5p-content:has(.h5p-theme) .h5p-expander{ + border: solid 1px var(--h5p-theme-stroke-1); + padding: var(--h5p-theme-spacing-xs); + border-radius: var(--h5p-theme-border-radius-medium); + font-size: var(--h5p-theme-font-size-m); + background-color: var(--h5p-theme-alternative-base); +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog dt{ + margin: 0 0.25em 0 0; +} +.h5p-expander:before { + content: "+"; + width: 1em; + display: inline-block; + font-weight: bold; +} +.h5p-expander.h5p-open:before { + content: "-"; + text-indent: 0.125em; +} +.h5p-expander:hover, +.h5p-expander:focus { + color: #303030; +} +.h5p-expander:active { + color: #202020; +} +.h5p-expander-content { + display: none; +} +.h5p-expander-content p { + margin: 0.5em 0; +} +.h5p-content-copyrights { + border-left: 0.25em solid #d0d0d0; + margin-left: 0.25em; + padding-left: 0.25em; +} +.h5p-throbber { + background: url('../images/throbber.gif?ver=1.2.1') 10px center no-repeat; + padding-left: 38px; + min-height: 30px; + line-height: 30px; +} +.h5p-dialog-ok-button { + cursor: default; + float: right; + outline: none; + border: 2px solid #ccc; + padding: 0.25em 0.75em 0.125em; + background: #eee; +} +.h5p-dialog-ok-button:hover, +.h5p-dialog-ok-button:focus { + background: #fafafa; +} +.h5p-dialog-ok-button:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + outline-offset: 2px; +} +.h5p-dialog-ok-button:active { + background: #eeffee; +} +.h5p-big-button { + line-height: 1.25; + display: block; + position: relative; + cursor: pointer; + width: 100%; + padding: 1em 1em 1em 3.75em; + text-align: left; + border: 1px solid #dedede; + background: linear-gradient(#ffffff, #f1f1f2); + border-radius: 0.25em; +} +.h5p-big-button:before { + font-family: 'h5p'; + content: "\e893"; + line-height: 1; + font-size: 3em; + color: #2747f7; + position: absolute; + left: 0.125em; + top: 0.125em; +} +.h5p-copy-button:before { + content: "\e905"; +} +.h5p-big-button:hover { + border: 1px solid #2747f7; + background: #eff1fe; +} +.h5p-big-button:active { + border: 1px solid #dedede; + background: #dfe4fe; +} +.h5p-button-title { + color: #2747f7; + font-size: 15px; + font-weight: bold; + margin-bottom: 0.5em; +} +.h5p-button-description { + color: #757575; +} +.h5p-horizontal-line-text { + border-top: 1px solid #dadada; + line-height: 1; + color: #474747; + text-align: center; + position: relative; + margin: 1.25em 0; +} +.h5p-horizontal-line-text > span { + background: white; + padding: 0.5em; + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); +} +.h5p-toast { + font-size: 0.75em; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + z-index: 110; + position: absolute; + padding: 0 0.5em; + line-height: 2; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + top: 0; + opacity: 1; + visibility: visible; + transition: opacity 1s; +} +.h5p-toast-disabled { + opacity: 0; + visibility: hidden; +} +.h5p-content code, +.h5peditor code { + color: #3d3d3d; + background: #e0e0e0; + border-radius: 2px; + padding: 0 5px; +} +.h5p-content pre > code, +.h5peditor pre > code { + background-color: #fafafa; + padding: 5px; + display: block; + line-height: normal; + border: 1px solid #c7c7c7; + border-left-width: 4px; + max-width: 100%; + white-space: pre; + overflow: auto; +} + + +/* This is loaded as part of Core and not Editor since this needs to be outside the editor iframe */ +.h5peditor-semi-fullscreen { + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 101; +} +iframe.h5peditor-semi-fullscreen { + background: #fff; + z-index: 100001; +} + +.h5p-content.using-mouse *:not(textarea):focus { + outline: none !important; +} + +.h5p-content-hub-button:before { + font-family: "h5p"; + margin-right: 0.5em; + font-size: 0.7em; + line-height: 1; +} + +.h5p-content-hub-button.unpublish:before { + content: "\e917"; +} + +.h5p-content-hub-button.waiting:before, +.h5p-content-hub-button.sync:before { + content: "\e916"; +} + +.h5p-content-hub-button.waiting:before { + display: inline-block; + animation: rotate 2s linear infinite; +} + +@keyframes rotate { + to { + transform: rotate(360deg); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e5a0b..d22f26b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,10 +155,6 @@ importers: version: 5.0.3(@types/node@26.6.5)(@vitest/browser-playwright@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)) packages/runtime: - dependencies: - h5p-standalone: - specifier: ^3.8.2 - version: 3.8.2 devDependencies: '@types/node': specifier: ^26.6.5