diff --git a/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts b/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts index c2d5fb8d4014..13f5448897fe 100644 --- a/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts +++ b/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts @@ -1,7 +1,7 @@ /// /// -import AudioRecording from "../toolbox/talkingBook/audioRecording"; +import { createValidXhtmlUniqueId } from "../js/xhtmlIdUtils"; import { get, post } from "../../utils/bloomApi"; import BloomMessageBoxSupport from "../../utils/bloomMessageBoxSupport"; import { tryProcessHyperlink } from "./hyperlinks"; @@ -520,7 +520,7 @@ export default class BloomField { nodelist.forEach( (span: Element, key: number, parent: NodeListOf) => { const oldId = span.getAttribute("id"); - const newId = AudioRecording.createValidXhtmlUniqueId(); + const newId = createValidXhtmlUniqueId(); span.setAttribute("id", newId); post(`audio/copyAudioFile?oldId=${oldId}&newId=${newId}`); }, diff --git a/src/BloomBrowserUI/bookEdit/js/audioUtils.ts b/src/BloomBrowserUI/bookEdit/js/audioUtils.ts new file mode 100644 index 000000000000..ab2325e9c72d --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/audioUtils.ts @@ -0,0 +1,24 @@ +// taken out of audioRecording.ts to avoid the need for other +// files to import that big file just to use a little bit of +// code. + +import axios, { AxiosResponse } from "axios"; + +export const kAnyRecordingApiUrl = "/bloom/api/audio/checkForAnyRecording?ids="; + +export async function audioExistsForIdsAsync(ids: string[]): Promise { + try { + const response: AxiosResponse = await axios.get( + `${kAnyRecordingApiUrl}${ids}`, + ); + return doesNarrationExist(response); + } catch { + return false; + } +} + +// Given a response from kAnyRecordingApiUrl, determines whether narration audio exists +// for any of the specified IDs. +export function doesNarrationExist(response: AxiosResponse): boolean { + return response && response.data === true; +} diff --git a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts index 9a0cd0579a6b..7165d4d90e86 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts @@ -21,7 +21,7 @@ import { updateCanvasElementClass } from "../toolbox/canvas/canvasElementDomUtil import { farthest } from "../../utils/elementUtils"; import { EditableDivUtils } from "./editableDivUtils"; import { playingBloomGame } from "../toolbox/games/DragActivityTabControl"; -import { kPlaybackOrderContainerClass } from "../toolbox/talkingBook/audioRecording"; +import { kPlaybackOrderContainerClass } from "./talkingBookMarkupConstants"; import { getWorkspaceBundleExports } from "./workspaceFrames"; import { changeImage, diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx index ec60a37290df..03f3f908a2a6 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx @@ -21,7 +21,7 @@ import { BloomTooltip } from "../../../react_components/BloomToolTip"; import { useL10n } from "../../../react_components/l10nHooks"; import { kBloomDisabledOpacity } from "../../../utils/colorUtils"; import { getAsync, useApiObject } from "../../../utils/bloomApi"; -import AudioRecording from "../../toolbox/talkingBook/audioRecording"; +import { audioExistsForIdsAsync } from "../audioUtils"; import { getAudioSentencesOfVisibleEditables } from "bloom-player"; import { canvasElementControlRegistry } from "../../toolbox/canvas/canvasElementControlRegistry"; import { buildCanvasElementControlRegistryContext } from "../../toolbox/canvas/buildCanvasElementControlRegistryContext"; @@ -156,7 +156,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ props.canvasElement, ); const ids = audioSentences.map((sentence) => sentence.id); - AudioRecording.audioExistsForIdsAsync(ids) + audioExistsForIdsAsync(ids) .then((audioExists) => { setTextHasAudio(audioExists); }) diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts index 35848ef349cb..343ca6816adf 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts @@ -5,7 +5,7 @@ import { kBloomButtonClass, kImageFitModeAttribute, } from "../../toolbox/canvas/canvasElementConstants"; -import AudioRecording from "../../toolbox/talkingBook/audioRecording"; +import { createValidXhtmlUniqueId } from "../xhtmlIdUtils"; import { postData, postJson } from "../../../utils/bloomApi"; import { cloneCanvasElementHtmlStructure } from "./canvasElementCloneCleanup"; @@ -369,7 +369,7 @@ export class CanvasElementDuplication { sourceId: string, copiedElement: Element, ): void { - const newId = AudioRecording.createValidXhtmlUniqueId(); + const newId = createValidXhtmlUniqueId(); copiedElement.setAttribute("id", newId); void copyAudioFileAsync(sourceId, newId); // we don't need to wait for this to finish const duration = sourceElement.getAttribute("data-duration"); diff --git a/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts b/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts new file mode 100644 index 000000000000..b0fdc4258623 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts @@ -0,0 +1,26 @@ +// taken out of audioRecording.ts to avoid the need for other +// files to import that big file just to use a little bit of +// code. + +import { getMd5 } from "../toolbox/talkingBook/md5Util"; + +export function getChecksum(message: string): string { + if (message === null || message === undefined) { + // should not normally happen, but seems to in tests. + // The function is supposed to return a string, and I don't want to change + // all the callers, so making it return a string that's a bit unique so if + // we ever see it in production we can search for it. + return "undefind"; + } + // Vertical line character ("|") acts as a phrase delimiter in Talking Books. + // To perform phrase-level recording, the user can insert a temporary "|" character where he wants a phrase split to happen. + // This is now recognized in the list of sentence delimiters, so it will be broken up as an audio-sentence. + // Then the user records the audio. + // Then the user deletes the vertical line characters. + // Now the text should be the desired final state, and audio recordings are possible at a sub-sentence level. + // However, we don't want the sentence markup to be updated because the checksums differ (since a character was deleted). + // + // Thus, our checksum function needs to ignore the vertical line character when computing the checksum. + const adjustedMessage = message.replace("|", ""); + return getMd5(adjustedMessage); +} diff --git a/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts b/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts new file mode 100644 index 000000000000..89660b3e6a0a --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts @@ -0,0 +1,8 @@ +// taken out of audioRecording.ts to avoid the need for other +// files to import that big file just to use a little bit of +// code. + +export const kPlaybackOrderContainerClass: string = + "bloom-playbackOrderControlsContainer"; + +export const kAudioCurrent = "ui-audioCurrent"; diff --git a/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts b/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts new file mode 100644 index 000000000000..c7b7e14a9fd1 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts @@ -0,0 +1,14 @@ +// taken out of audioRecording.ts to avoid the need for other +// files to import that big file just to use a little bit of +// code. + +import { EditableDivUtils } from "./editableDivUtils"; + +export function createValidXhtmlUniqueId(): string { + let newId = EditableDivUtils.createUuid(); + if (/^\d/.test(newId)) { + newId = "i" + newId; // valid ID in XHTML can't start with digit + } + + return newId; +} diff --git a/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx b/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx index 5b0cc5975668..69ffae4b0990 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx @@ -69,10 +69,6 @@ const toolIconPathByToolId: Record = { "/bloom/bookEdit/toolbox/impairmentVisualizer/blind-eye-white.svg", }; -const legacyToolSubPathByToolId: Record = { - talkingBook: "talkingBook/talkingBookToolboxTool.html", -}; - const toolboxHeaderIconStyles = css` width: 16px; height: 16px; @@ -154,7 +150,6 @@ const makeSectionFromToolId = (toolId: string): ToolboxSection => { id: toolId, englishLabel: labelInfo.englishLabel, l10nKey: labelInfo.l10nKey, - legacyToolHtmlSubPath: legacyToolSubPathByToolId[toolId], }; }; @@ -261,12 +256,7 @@ const ensureReactToolBodyElement = ( } const normalizedToolId = normalizeToolId(toolId); - // Do not create elements for legacy tools; they load their content from - // legacyToolHtmlSubPath and their makeRootElement() implementations throw - // "Method not implemented." if called directly. - if (legacyToolSubPathByToolId[normalizedToolId]) { - return undefined; - } + const tool = getMasterToolList().find((candidate) => { return candidate.id() === normalizedToolId; }); @@ -457,36 +447,6 @@ export const ToolboxRoot: React.FunctionComponent = () => { return; } - const legacyToolHtmlSubPath = legacyToolSubPathByToolId[toolId]; - if (legacyToolHtmlSubPath) { - try { - const legacyToolBodyHtml = await loadLegacyToolBodyHtml({ - id: toolId, - englishLabel: "", - l10nKey: "", - legacyToolHtmlSubPath, - }); - setSections((previousSections) => - previousSections.map((section) => { - if (section.id !== toolId) { - return section; - } - return { - ...section, - legacyToolBodyHtml, - }; - }), - ); - } catch (error) { - hydratedToolIds.current.delete(toolId); - console.error( - `Failed to load legacy toolbox HTML for ${toolId}.`, - error, - ); - } - return; - } - hydratedToolIds.current.delete(toolId); }, []); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts index 070f04e7fbe2..0801848597cc 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts @@ -73,7 +73,7 @@ import { playSound, showDialogToChooseSoundFileAsync, } from "../games/GameTool"; -import AudioRecording from "../talkingBook/audioRecording"; +import { showTalkingBookTool } from "../talkingBook/showTalkingBookTool"; import { showLinkTargetChooserDialog } from "../../../react_components/LinkTargetChooser/LinkTargetChooserDialogLauncher"; import { kBloomBlue } from "../../../bloomMaterialUITheme"; import { @@ -334,7 +334,7 @@ const makeChooseAudioMenuItemForText = ( englishLabel: "Use Talking Book Tool", onSelect: () => { runtime.closeMenu(false); - AudioRecording.showTalkingBookTool(); + showTalkingBookTool(); }, }, ], diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx index b5a5d5794f39..4880e11540cb 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx @@ -20,7 +20,7 @@ import { } from "../../../react_components/BloomDialog/commonDialogComponents"; import { useL10n } from "../../../react_components/l10nHooks"; import { getAsync, postBoolean, postJsonAsync } from "../../../utils/bloomApi"; -import { kAudioCurrent } from "./audioRecording"; +import { kAudioCurrent } from "../../js/talkingBookMarkupConstants"; import { AdjustTimingsControl, TimedTextSegment } from "./AdjustTimingsControl"; import { getUrlPrefixFromWindowHref, diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts index 3d7979f2ca91..341595c94fc4 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts @@ -3,11 +3,16 @@ // anything implementation-specific. import { RecordingMode } from "./recordingMode"; // only holds the RecordingMode enum +import { TalkingBookUiState } from "./TalkingBookUiState"; export interface IAudioRecorder { autoSegmentBasedOnTextLength: () => number[]; markAudioSplit: () => void; + insertSegmentMarker(): void; + setShowPlaybackOrder(isOn: boolean): Promise; setShowingImageDescriptions: (boolean) => void; + setRecordingMode(recordingMode: RecordingMode): Promise; + handleImportRecordingClick(): void; removeRecordingSetup: () => void; getUpdateMarkupAction: () => Promise<() => void>; setupForRecordingAsync: () => Promise; @@ -22,4 +27,21 @@ export interface IAudioRecorder { getPageDocBody: () => HTMLElement | null; getCurrentTextBox: () => HTMLElement | null; recordingMode: RecordingMode; + setLevelCanvas(canvas: HTMLCanvasElement | null): void; + closeDeviceSelectMenu(): void; + changeInputDevice(): void; + setInputDevice(device: any): void; + startRecordCurrentAsync(): Promise; + endRecordCurrentAsync(): Promise; + togglePlayCurrentAsync(): Promise; + playESpeakPreview(): void; + showAdjustTimingsDialog(): Promise; + moveToNextAudioElement(): Promise; + moveToPrevAudioElementAsync(): Promise; + clearRecordingAsync(): Promise; + listenAsync(canvasToExclude?: HTMLElement): Promise; + getTalkingBookUiState(): TalkingBookUiState; + registerStateListener( + listener: (state: TalkingBookUiState) => void, + ): () => void; } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md new file mode 100644 index 000000000000..86395fd1f9a6 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md @@ -0,0 +1,532 @@ +# Plan: Convert the Talking Book tool to React + +**Status:** proposed / not yet started. +**Scope of this document:** Phase A (the React conversion, one PR) and Phase B (moving the +engine into the page iframe, a separate follow-up PR). Step 6 (retiring the old non-React +toolbox framework) is a third PR, described briefly at the end so it isn't lost. + +Talking Book is the **last** non-React toolbox tool. Converting it lets us later delete +the entire legacy non-React tool framework (Step 6). + +## 0. Sequencing rationale — why the engine move is a separate PR + +An earlier draft of this plan did the React conversion and the engine move in one PR. +They are now split, for two reasons: + +1. **The engine move is not needed for the goal.** Deleting the legacy framework only + requires the tool *UI* to be React. The engine can keep living in the toolbox iframe, + reaching into the page exactly as it does today. +2. **The page iframe reloads on every page switch.** `switchContentPage` in + `workspaceRoot.ts` (~line 134) sets `iframe.src = newSource`, destroying the page + iframe's whole JS realm. Today `theOneAudioRecorder` lives in the toolbox iframe and + **survives for the whole editing session**; moved page-side, it would be torn down and + reconstructed on *every page switch*. That is a real architectural change (see §6), not + the mechanical "flip the wiring" the earlier draft implied — it needs its own design + and its own PR. + +The split is also what makes Phase B safe: after Phase A the engine no longer touches any +toolbox DOM (it talks to the UI only through a state object and a couple of registered +element refs), so Phase B becomes a pure relocation-and-lifetime problem. + +--- + +## 1. Current architecture (verified against source, 2026-07) + +### 1.1 `talkingBook.ts` +`TalkingBookTool implements ITool` directly (not via `ToolboxToolReactAdaptor`). Its +`makeRootElement()` **throws** — the tool's UI is the pug file +`talkingBookToolboxTool.pug`, routed in through two legacy hard-coded maps: + +- `subpath` in `toolbox.ts` (~line 1273): `talkingBookTool: "talkingBook/talkingBookToolboxTool.html"` +- `legacyToolSubPathByToolId` in `ToolboxRoot.tsx` (~line 71): `talkingBook: "talkingBook/talkingBookToolboxTool.html"` + +It is a thin lifecycle shim over the `AudioRecording` singleton, but note the exact +forwards (the conversion must preserve all of them): + +- `showTool()` → module-level `initializeTalkingBookToolAsync()` (lazily constructs the + singleton) then `getAudioRecorder().setupForRecordingAsync()`. +- `newPageReady()` → `showImageDescriptionsIfAny()` (its own page-DOM logic, BL-8515) + then `getAudioRecorder().handleNewPageReady(TalkingBookTool.deshroudPhraseDelimiters)`. +- `hideTool()` → `handleToolHiding()`. +- `detachFromPage()` → `removeRecordingSetup()`, then `hideImageDescriptions(page)` and + `TalkingBookTool.enshroudPhraseDelimiters(page)`. +- `updateMarkupAsync()` → `getAudioRecorder().getUpdateMarkupAction()`; + `isUpdateMarkupAsync()` returns `true`. +- `beginRestoreSettings()` → `beginLoadSynphonySettings()` (shares sentence-ending + punctuation settings with the Leveled Reader tool — easy to lose in conversion, since + the adaptor's default `beginRestoreSettings` is a no-op). +- `isAlwaysEnabled()` returns `true` (adaptor default is `false` — must override). +- The static `enshroudPhraseDelimiters`/`deshroudPhraseDelimiters` helpers operate on the + *page* DOM and stay with the tool. + +The lifecycle comment at the top of the file (lines ~38–48) enumerates when +showTool/newPageReady/updateMarkup fire — it is the manual-test matrix (§8). + +### 1.2 `audioRecording.ts` (~4,900 lines) — engine **and** tool UI, running in the toolbox iframe +The important, counter-intuitive fact: **the `AudioRecording` object runs in the +_toolbox_ iframe, not the page iframe.** Evidence: + +- The constructor reads toolbox controls via its own `document`: `#audio-split` + (→ `this.audioSplitButton`) and `#audio-meter` (→ `this.levelCanvas`), and calls + `updateDisplay()` which touches `#audio-split-wrapper` and + `#advanced-talking-book-controls-react-container`. (It does *not* touch `#player`; + that is wired in `initializeTalkingBookToolAsync`, and `#disablingOverlay` is grabbed + in `setupForRecordingAsync`.) +- `initializeTalkingBookToolAsync` (the class method, ~line 229) wires jQuery handlers to + the toolbox buttons: `#audio-record` mousedown/mouseup, click handlers on + `#audio-play` (with a **ctrl-click eSpeak-preview easter egg** — preserve it), + `#audio-split` (opens the Adjust Timings dialog via `getWorkspaceBundleExports()`), + `#audio-next`, `#audio-prev`, `#audio-clear`, `#audio-listen`, `#audio-input-dev`; + plus `#player` events (`onended`, `onerror`, `ondurationchange`), `toastr.options` + (position `toast-toolbox-bottom`), a `WholeTextBoxAudio` feature-status fetch, and + `pullDefaultRecordingModeAsync()`. +- It reaches _into_ the page iframe for content via `getPageFrame()` + (`parent.window.document.getElementById("page")`, ~line 2366) and `getPageDocBody()` + — ~26 direct call sites plus ~9 via `getPageDocBodyJQuery()`. +- It also installs listeners **in the page document**: a capture-phase `mousedown` on the + page body (`moveRecordingHighlightToClick`) and a `MutationObserver` watching + visibility-affecting class changes (`watchElementsThatMightChangeAffectingVisibility`). + These already run cross-iframe today and are re-installed in `handleNewPageReady`. + +So the object owns two very different kinds of responsibility: + +1. **Tool UI** (toolbox side): the 7 main buttons, their counters, the level meter + canvas, device selection (`#audio-input-dev` icon + `#audio-devlist` jQuery menu), + the `