diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 37914b18..19d27680 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,14 +1,14 @@ import type { Preview } from "@storybook/react"; const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, }, - }, }; export default preview; diff --git a/src/activities/ActivityContext.ts b/src/activities/ActivityContext.ts index 95cb99f6..f8ea86ec 100644 --- a/src/activities/ActivityContext.ts +++ b/src/activities/ActivityContext.ts @@ -1,170 +1,169 @@ -import { - reportScoreForCurrentPage, - getPageData, - storePageData -} from "../page-api"; -import rightAnswer from "./right_answer.mp3"; -import wrongAnswer from "./wrong_answer.mp3"; - -// This is passed to an activity to give it things that it needs. It's mostly -// a wrapper so that activities don't have direct knowledge of how parts outside -// of them are arranged. -export class ActivityContext { - public pageElement: HTMLElement; - public pageIndex: number; - public analyticsCategory: string; - // Typically, indices of all pages with the same analytics category. - // (This is necessary to be able to report analytics for this category as a group.) - public pagesToGroupForAnalytics: number[] | undefined; - - private listeners = new Array<{ - name: string; - target: Element; - listener: EventListener; - }>(); - - constructor( - pageIndex: number, - pageDiv: HTMLElement, - analyticsCategory: string, - pagesToGroupForAnalytics?: number[] - ) { - this.pageIndex = pageIndex; - this.pageElement = pageDiv; - this.analyticsCategory = analyticsCategory; - this.pagesToGroupForAnalytics = pagesToGroupForAnalytics; - } - - // Report a score that can be used for analytics. The caller can call this repeatedly without worrying - // about the logic of whether we only report the user's first attempt. - public reportScore(possiblePoints: number, actualPoints: number) { - // please leave this log in... if we could make it only show in storybook, we would - console.log( - `ActivityContext.reportScoreForCurrentPage(, ${possiblePoints}, ${actualPoints},${this.analyticsCategory})` - ); - reportScoreForCurrentPage( - this.pageIndex, - possiblePoints, - actualPoints, - this.analyticsCategory, - this.pagesToGroupForAnalytics - ); - } - - // Get data used during this current reading of the book. The `key` parameter only needs to be - // unique to the activity's page. - public getSessionPageData(key: string): string { - return getPageData(this.pageIndex, key); - } - - // Set data used during this current reading of the book that you can read if the - // they come back to this page. The `key` parameter only needs to be - // unique to the activity's page. - public storeSessionPageData(key: string, value: string) { - // please leave this log in... if we could make it only show in storybook, we would - console.log( - `ActivityContext.storePageData(, '${key}', '${value}')` - ); - storePageData(this.pageIndex, key, value); - } - - public playCorrect() { - let path = rightAnswer; - if (path.startsWith("/")) { - // I can't figure out how to get rid of the leading slash in the path in the production build. - path = path.substring(1); - } - this.playSound(path); - } - - public playWrong() { - let path = wrongAnswer; - if (path.startsWith("/")) { - // I can't figure out how to get rid of the leading slash in the path in the production build. - path = path.substring(1); - } - this.playSound(path); - } - - private getPagePlayer(): any { - let player = document.getElementById("activity-sound-player") as any; - if (player && !player.play) { - player.remove(); - player = null; - } - if (!player) { - player = document.createElement("audio"); - player.setAttribute("id", "activity-sound-player"); - document.body.appendChild(player); - } - return player; - } - - public playSound(url: string) { - const player = this.getPagePlayer(); - player.setAttribute("src", url); - player.play(); - } - - public addActivityStylesForPage(css: string) { - if (!this.pageElement.querySelector("[data-activity-stylesheet]")) { - const style = this.pageElement.ownerDocument!.createElement( - "style" - ); - style.setAttribute("data-activity-stylesheet", ""); // value doesn't matter - // REVIEW: Scoped styles have been removed from the spec, so we are using - // a polyfill, https://github.com/samthor/scoped, which is not very commonly used - // (only 56 stars at the moment). So we should not really be depending on this... it - // could break or whatever. - // Also, it's not working in Bloom Editor (maybe the polyfill could be added there?). - // I think it's better to just scope by hand using a class that - // uniquely matches the page. So I'm going to remove this. - style.setAttribute("scoped", "true"); - style.innerText = css; - this.pageElement.parentNode!.insertBefore(style, this.pageElement); //NB: will be added even if firstChild is null - } - } - - // Activities should use this to attach listeners so that we can detach them when the page is no longer - // showing. Among other things, this prevents double-attaching. - public addEventListener( - name: string, - target: Element, - listener: EventListener, - options?: AddEventListenerOptions | undefined - ) { - // store the info we need in order to detach the listener when we are stop()ed - this.listeners.push({ - name, - target, - listener - }); - target.addEventListener(name, listener, options); - } - - // this is called by the activity manager after it stops the activity. - public stop() { - // detach all the listeners - this.listeners.forEach(l => - l.target.removeEventListener(l.name, l.listener) - ); - } - - private sendMessageToPlayer(message: string) { - const activityMessage = { - messageType: "control", - controlAction: message - }; - //console.log(`Sent activity navigation message to Player: ${message}`); - const messageJson = JSON.stringify(activityMessage); - window.postMessage(messageJson, "*"); // any window may receive - } - - public navigateToNextPage() { - this.sendMessageToPlayer("navigate-to-next-page"); - } - - public navigateToPreviousPage() { - this.sendMessageToPlayer("navigate-to-previous-page"); - } -} +import { + reportScoreForCurrentPage, + getPageData, + storePageData, +} from "../page-api"; +import rightAnswer from "./right_answer.mp3"; +import wrongAnswer from "./wrong_answer.mp3"; + +// This is passed to an activity to give it things that it needs. It's mostly +// a wrapper so that activities don't have direct knowledge of how parts outside +// of them are arranged. +export class ActivityContext { + public pageElement: HTMLElement; + public pageIndex: number; + public analyticsCategory: string; + // Typically, indices of all pages with the same analytics category. + // (This is necessary to be able to report analytics for this category as a group.) + public pagesToGroupForAnalytics: number[] | undefined; + + private listeners = new Array<{ + name: string; + target: Element; + listener: EventListener; + }>(); + + constructor( + pageIndex: number, + pageDiv: HTMLElement, + analyticsCategory: string, + pagesToGroupForAnalytics?: number[], + ) { + this.pageIndex = pageIndex; + this.pageElement = pageDiv; + this.analyticsCategory = analyticsCategory; + this.pagesToGroupForAnalytics = pagesToGroupForAnalytics; + } + + // Report a score that can be used for analytics. The caller can call this repeatedly without worrying + // about the logic of whether we only report the user's first attempt. + public reportScore(possiblePoints: number, actualPoints: number) { + // please leave this log in... if we could make it only show in storybook, we would + console.log( + `ActivityContext.reportScoreForCurrentPage(, ${possiblePoints}, ${actualPoints},${this.analyticsCategory})`, + ); + reportScoreForCurrentPage( + this.pageIndex, + possiblePoints, + actualPoints, + this.analyticsCategory, + this.pagesToGroupForAnalytics, + ); + } + + // Get data used during this current reading of the book. The `key` parameter only needs to be + // unique to the activity's page. + public getSessionPageData(key: string): string { + return getPageData(this.pageIndex, key); + } + + // Set data used during this current reading of the book that you can read if the + // they come back to this page. The `key` parameter only needs to be + // unique to the activity's page. + public storeSessionPageData(key: string, value: string) { + // please leave this log in... if we could make it only show in storybook, we would + console.log( + `ActivityContext.storePageData(, '${key}', '${value}')`, + ); + storePageData(this.pageIndex, key, value); + } + + public playCorrect() { + let path = rightAnswer; + if (path.startsWith("/")) { + // I can't figure out how to get rid of the leading slash in the path in the production build. + path = path.substring(1); + } + this.playSound(path); + } + + public playWrong() { + let path = wrongAnswer; + if (path.startsWith("/")) { + // I can't figure out how to get rid of the leading slash in the path in the production build. + path = path.substring(1); + } + this.playSound(path); + } + + private getPagePlayer(): any { + let player = document.getElementById("activity-sound-player") as any; + if (player && !player.play) { + player.remove(); + player = null; + } + if (!player) { + player = document.createElement("audio"); + player.setAttribute("id", "activity-sound-player"); + document.body.appendChild(player); + } + return player; + } + + public playSound(url: string) { + const player = this.getPagePlayer(); + player.setAttribute("src", url); + player.play(); + } + + public addActivityStylesForPage(css: string) { + if (!this.pageElement.querySelector("[data-activity-stylesheet]")) { + const style = + this.pageElement.ownerDocument!.createElement("style"); + style.setAttribute("data-activity-stylesheet", ""); // value doesn't matter + // REVIEW: Scoped styles have been removed from the spec, so we are using + // a polyfill, https://github.com/samthor/scoped, which is not very commonly used + // (only 56 stars at the moment). So we should not really be depending on this... it + // could break or whatever. + // Also, it's not working in Bloom Editor (maybe the polyfill could be added there?). + // I think it's better to just scope by hand using a class that + // uniquely matches the page. So I'm going to remove this. + style.setAttribute("scoped", "true"); + style.innerText = css; + this.pageElement.parentNode!.insertBefore(style, this.pageElement); //NB: will be added even if firstChild is null + } + } + + // Activities should use this to attach listeners so that we can detach them when the page is no longer + // showing. Among other things, this prevents double-attaching. + public addEventListener( + name: string, + target: Element, + listener: EventListener, + options?: AddEventListenerOptions | undefined, + ) { + // store the info we need in order to detach the listener when we are stop()ed + this.listeners.push({ + name, + target, + listener, + }); + target.addEventListener(name, listener, options); + } + + // this is called by the activity manager after it stops the activity. + public stop() { + // detach all the listeners + this.listeners.forEach((l) => + l.target.removeEventListener(l.name, l.listener), + ); + } + + private sendMessageToPlayer(message: string) { + const activityMessage = { + messageType: "control", + controlAction: message, + }; + //console.log(`Sent activity navigation message to Player: ${message}`); + const messageJson = JSON.stringify(activityMessage); + window.postMessage(messageJson, "*"); // any window may receive + } + + public navigateToNextPage() { + this.sendMessageToPlayer("navigate-to-next-page"); + } + + public navigateToPreviousPage() { + this.sendMessageToPlayer("navigate-to-previous-page"); + } +} diff --git a/src/activities/activityManager.ts b/src/activities/activityManager.ts index 076d5e2f..71ee47ef 100644 --- a/src/activities/activityManager.ts +++ b/src/activities/activityManager.ts @@ -1,361 +1,354 @@ -import { loadDynamically } from "./loadDynamically"; -import { ActivityContext } from "./ActivityContext"; -import iframeModule from "./iframeActivity"; -import simpleDomChoiceActivityModule from "./domActivities/SimpleDomChoice"; -import simpleCheckboxQuizModule from "./domActivities/SimpleCheckboxQuiz"; -import dragToDestinationModule from "./dragActivities/DragToDestination"; - -// This is the module that the activity has to implement (the file must export these functions) -export interface IActivityModule { - // this is a weird typescript thing... the activity just needs to export a class as its default export, and that - // class should implement IActivityObject; - default: IActivityObject; - activityRequirements: () => IActivityRequirements; -} - -// This is the class that the activity module has to implement -export interface IActivityObject { - // Do one-time initialization of the page's html. In the current implementation, - // this is acting on a copy of the html, not the real DOM which the user will interact with. - // So we can't do things like set up event handlers. (See showingPage below.) - initializePageHtml: (context: ActivityContext) => void; - - // Do any setup needed each time the activity becomes the active page. - // This is acting on the real DOM, so this is the time to set up event handlers, etc. - showingPage: (context: ActivityContext) => void; - - // This is called in place of the normal code that plays sound and animations when the page first appears, - // if the activity's requirements specify soundManagement: true. - doInitialSoundAndAnimation?: (context: ActivityContext) => void; - - stop: () => void; -} -// Constructing stuff from interfaces has problems with typescript at the moment. -// The class should have this constructor, but should not claim this interface. -// See https://stackoverflow.com/a/13408029/723299. -export interface IActivityObjectConstructable { - new (element: HTMLElement): IActivityObject; -} -export interface IActivityRequirements { - dragging?: boolean; - clicking?: boolean; - typing?: boolean; - // suppress normal sound (and music, and animation) - // If this is true, the activity should implement doInitialSoundAndAnimation - // if anything should autoplay when the page appears. - soundManagement?: boolean; -} - -// This is the object (implemented by us, not the activity) that represents our own -// record of what we know about the activity. -export interface IActivityInformation { - name: string; // from data-activity attribute of the page - module: IActivityModule | undefined; // the module of code we loaded from {name}.js - runningObject: IActivityObject | undefined; // an instance of the default class exported by the module (but only if it's the current activity) - requirements: IActivityRequirements; // returned by the module's activityRequirements() function - context: ActivityContext | undefined; -} - -export class ActivityManager { - private builtInActivities: { [id: string]: IActivityModule } = {}; - private previousPageElement: HTMLElement; - private bookActivityGroupings: { [id: string]: number[] } = {}; - - constructor() { - this.builtInActivities["iframe"] = iframeModule as IActivityModule; - this.builtInActivities[ - "simple-dom-choice" - ] = simpleDomChoiceActivityModule as IActivityModule; - this.builtInActivities[ - simpleCheckboxQuizModule.dataActivityID - ] = simpleCheckboxQuizModule as IActivityModule; - - // Review: currently these all use the same module. A lot of stuff is shared, all the way down to the - // prepareActivity() function in dragActivityRuntime. But a good many specialized TOP types are - // specific to one of them and not needed for the others. It may be helpful to tease things - // apart more, for example, three separate implementations of IActivityModule and PrepareActivity - // which call common code for the setup tasks common to all three. OTOH, in some ways it is simpler - // to have it all in one place, and just do the appropriate initialization based on what kind of - // draggables we find. - this.builtInActivities[ - "drag-to-destination" // not currently used - ] = dragToDestinationModule as IActivityModule; - this.builtInActivities[ - "drag-letter-to-target" - ] = dragToDestinationModule as IActivityModule; - this.builtInActivities[ - "drag-image-to-target" - ] = dragToDestinationModule as IActivityModule; - this.builtInActivities[ - "drag-sort-sentence" - ] = dragToDestinationModule as IActivityModule; - this.builtInActivities[ - "word-chooser-slider" // not used yet - ] = dragToDestinationModule as IActivityModule; - } - public getActivityAbsorbsDragging(): boolean { - return ( - !!this.currentActivity && - !!this.currentActivity.requirements.dragging - ); - } - public getActivityAbsorbsClicking(): boolean { - return ( - !!this.currentActivity && - !!this.currentActivity.requirements.clicking - ); - } - public getActivityAbsorbsTyping(): boolean { - return ( - !!this.currentActivity && !!this.currentActivity.requirements.typing - ); - } - public getActivityManagesSound(): boolean { - return ( - !!this.currentActivity && - !!this.currentActivity.requirements.soundManagement - ); - } - private currentActivity: IActivityInformation | undefined; - private loadedActivityScripts: { - [name: string]: IActivityInformation; - } = {}; - - private getActivityIdOfPage(pageDiv: HTMLElement) { - let activityID = pageDiv.getAttribute("data-activity") || ""; - - // Handle "simple comprehension quizzes", which in 4.6 (and maybe into 4.7 and beyond?) don't have data-activity but - // instead have a