diff --git a/resources/scripts/editor/selection.js b/resources/scripts/editor/selection.ts similarity index 57% rename from resources/scripts/editor/selection.js rename to resources/scripts/editor/selection.ts index 8a30830..c7a1cf1 100644 --- a/resources/scripts/editor/selection.js +++ b/resources/scripts/editor/selection.ts @@ -8,8 +8,51 @@ * a hard `Requires at least` floor higher than 6.5. */ +// ── wp.* surface used here ────────────────────────────────── +// Local types for the narrow `window.wp` API this module touches. The shape +// is read via a `window as { wp?: ... }` cast at the call site rather than +// `declare global` so we don't race with editor-bridge.ts's own +// (intentionally different) `wp.data` overloads. + +interface SelectionPoint { + clientId?: string; + offset?: number; + attributeKey?: string; +} + +interface Block { + clientId: string; + name: string; + attributes?: Record; +} + +interface BlockEditorSelect { + getSelectedBlockClientIds?: () => string[]; + getBlockName?: (clientId: string) => string | undefined; + getBlock?: (clientId: string) => Block | undefined; + getSelectionStart?: () => SelectionPoint | undefined; + getSelectionEnd?: () => SelectionPoint | undefined; +} + +interface RichTextValue { + text?: string; +} + +interface WpSurface { + data?: { + select?: (key: "core/block-editor") => BlockEditorSelect | undefined; + }; + richText?: { + create?: (opts: { html: string }) => RichTextValue | undefined; + }; +} + +function wp(): WpSurface | undefined { + return (window as unknown as { wp?: WpSurface }).wp; +} + /** Blocks where text-range selection is meaningful. */ -export const TEXT_BLOCKS = new Set([ +export const TEXT_BLOCKS = new Set([ "core/paragraph", "core/heading", "core/list", @@ -24,7 +67,7 @@ export const TEXT_BLOCKS = new Set([ * Friendly block label for human-facing strings ("core/paragraph" → "paragraph"). * @param name */ -export function blockLabel(name) { +export function blockLabel(name: string | undefined): string { return (name || "").replace(/^core\//, "").replace(/-/g, " "); } @@ -32,7 +75,7 @@ export function blockLabel(name) { * Strip HTML + collapse whitespace from a string. * @param value */ -export function stripHtml(value) { +export function stripHtml(value: unknown): string { if (typeof value !== "string") { return ""; } @@ -49,9 +92,9 @@ export function stripHtml(value) { * Normalise both to plain text. * @param attrValue */ -export function attrToPlainText(attrValue) { +export function attrToPlainText(attrValue: unknown): string { if (typeof attrValue === "string") { - const rich = window.wp?.richText?.create?.({ html: attrValue }); + const rich = wp()?.richText?.create?.({ html: attrValue }); if (typeof rich?.text === "string") { return rich.text; } @@ -60,9 +103,10 @@ export function attrToPlainText(attrValue) { if ( attrValue && typeof attrValue === "object" && - typeof attrValue.text === "string" + "text" in attrValue && + typeof (attrValue as { text: unknown }).text === "string" ) { - return attrValue.text; + return (attrValue as { text: string }).text; } return ""; } @@ -71,10 +115,8 @@ export function attrToPlainText(attrValue) { * Plain text of a block's primary content attribute, collapsed + trimmed. * @param clientId */ -export function getBlockText(clientId) { - const block = window.wp?.data - ?.select?.("core/block-editor") - ?.getBlock?.(clientId); +export function getBlockText(clientId: string): string { + const block = wp()?.data?.select?.("core/block-editor")?.getBlock?.(clientId); if (!block) { return ""; } @@ -90,8 +132,8 @@ export function getBlockText(clientId) { * after focus has left the editor surface. * @param clientId */ -export function getBlockSelectionText(clientId) { - const select = window.wp?.data?.select?.("core/block-editor"); +export function getBlockSelectionText(clientId: string): string { + const select = wp()?.data?.select?.("core/block-editor"); if (!select) { return ""; } @@ -114,7 +156,9 @@ export function getBlockSelectionText(clientId) { } const block = select.getBlock?.(clientId); - const attrValue = block?.attributes?.[start.attributeKey]; + const attrValue = start.attributeKey + ? block?.attributes?.[start.attributeKey] + : undefined; const text = attrToPlainText(attrValue); if (!text) { return ""; @@ -128,21 +172,44 @@ export function getBlockSelectionText(clientId) { /** * Snapshot of what the user has selected in the editor right now, or null when - * nothing is selected. Drives both the composer chip and the inline context we - * attach to outgoing messages. Three shapes: + * nothing is selected. Three shapes (discriminated on `mode`): * - * {mode: 'text-range', clientId, blockName, blockLabel, selectedText, blockText} - * A single text-bearing block with a non-empty text-range selection. - * - * {mode: 'whole-block', clientId, blockName, blockLabel, blockText} - * A single block selected with no text range (text-bearing or otherwise; - * `blockText` is empty for non-text blocks). - * - * {mode: 'multi-block', count, clientIds, blockNames, blockLabels} - * Multiple blocks selected at once. + * {mode: 'text-range', …} single text-bearing block with a range + * {mode: 'whole-block', …} single block with no range + * {mode: 'multi-block', …} multiple blocks selected at once */ -export function getCurrentSelectionContext() { - const select = window.wp?.data?.select?.("core/block-editor"); +export interface TextRangeSelection { + mode: "text-range"; + clientId: string; + blockName: string; + blockLabel: string; + selectedText: string; + blockText: string; +} + +export interface WholeBlockSelection { + mode: "whole-block"; + clientId: string; + blockName: string; + blockLabel: string; + blockText: string; +} + +export interface MultiBlockSelection { + mode: "multi-block"; + count: number; + clientIds: string[]; + blockNames: string[]; + blockLabels: string[]; +} + +export type SelectionContext = + | TextRangeSelection + | WholeBlockSelection + | MultiBlockSelection; + +export function getCurrentSelectionContext(): SelectionContext | null { + const select = wp()?.data?.select?.("core/block-editor"); if (!select) { return null; } @@ -152,7 +219,9 @@ export function getCurrentSelectionContext() { } if (ids.length > 1) { - const names = ids.map((id) => select.getBlockName?.(id)).filter(Boolean); + const names = ids + .map((id) => select.getBlockName?.(id)) + .filter((n): n is string => !!n); if (!names.length) { return null; } @@ -165,7 +234,7 @@ export function getCurrentSelectionContext() { }; } - const clientId = ids[0]; + const clientId = ids[0]!; const name = select.getBlockName?.(clientId); if (!name) { return null; diff --git a/resources/scripts/hooks/use-editor-selection.js b/resources/scripts/hooks/use-editor-selection.ts similarity index 65% rename from resources/scripts/hooks/use-editor-selection.js rename to resources/scripts/hooks/use-editor-selection.ts index 13e0e69..cfe51f5 100644 --- a/resources/scripts/hooks/use-editor-selection.js +++ b/resources/scripts/hooks/use-editor-selection.ts @@ -10,9 +10,15 @@ */ import { useEffect, useState } from "@wordpress/element"; -import { getCurrentSelectionContext } from "../editor/selection"; +import { + getCurrentSelectionContext, + type SelectionContext, +} from "../editor/selection"; -function sameSelection(a, b) { +function sameSelection( + a: SelectionContext | null, + b: SelectionContext | null, +): boolean { if (a === b) { return true; } @@ -22,21 +28,21 @@ function sameSelection(a, b) { if (a.mode !== b.mode) { return false; } - if (a.mode === "text-range") { + if (a.mode === "text-range" && b.mode === "text-range") { return ( a.clientId === b.clientId && a.selectedText === b.selectedText && a.blockText === b.blockText ); } - if (a.mode === "whole-block") { + if (a.mode === "whole-block" && b.mode === "whole-block") { return ( a.clientId === b.clientId && a.blockName === b.blockName && a.blockText === b.blockText ); } - if (a.mode === "multi-block") { + if (a.mode === "multi-block" && b.mode === "multi-block") { return ( a.count === b.count && (a.clientIds || []).join("|") === (b.clientIds || []).join("|") @@ -45,13 +51,18 @@ function sameSelection(a, b) { return false; } -export function useEditorSelection() { - const [selection, setSelection] = useState(() => +export function useEditorSelection(): SelectionContext | null { + const [selection, setSelection] = useState(() => getCurrentSelectionContext(), ); useEffect(() => { - const data = window.wp?.data; + // `wp.data` is typed elsewhere with the narrow surface we use; the + // top-level `subscribe` is the generic store-event hook, not on those + // narrowed select-key overloads. + const data = ( + window.wp as { data?: { subscribe?: (cb: () => void) => () => void } } + )?.data; if (!data?.subscribe) { return undefined; } diff --git a/resources/scripts/hooks/use-tts.js b/resources/scripts/hooks/use-tts.ts similarity index 84% rename from resources/scripts/hooks/use-tts.js rename to resources/scripts/hooks/use-tts.ts index 535b33a..db8f0d7 100644 --- a/resources/scripts/hooks/use-tts.js +++ b/resources/scripts/hooks/use-tts.ts @@ -22,9 +22,12 @@ const TTS_RATE = 1.35; // the composer as user input. const TTS_START_EVENT = "gds-assistant-tts-start"; const TTS_END_EVENT = "gds-assistant-tts-end"; -export const TTS_EVENTS = { start: TTS_START_EVENT, end: TTS_END_EVENT }; +export const TTS_EVENTS = { + start: TTS_START_EVENT, + end: TTS_END_EVENT, +} as const; -export function ttsSupported() { +export function ttsSupported(): boolean { return ( typeof window !== "undefined" && typeof window.speechSynthesis !== "undefined" && @@ -32,7 +35,7 @@ export function ttsSupported() { ); } -function readPref() { +function readPref(): boolean { try { return localStorage.getItem(ENABLED_KEY) === "1"; } catch { @@ -40,7 +43,7 @@ function readPref() { } } -function writePref(value) { +function writePref(value: boolean): void { try { if (value) { localStorage.setItem(ENABLED_KEY, "1"); @@ -57,11 +60,11 @@ function writePref(value) { } /** React hook returning [enabled, setEnabled] that survives full-page reloads. */ -export function useTtsEnabled() { - const [enabled, setEnabledState] = useState(readPref); +export function useTtsEnabled(): [boolean, (value: boolean) => void] { + const [enabled, setEnabledState] = useState(readPref); useEffect(() => { - const sync = () => setEnabledState(readPref()); + const sync = (): void => setEnabledState(readPref()); window.addEventListener("storage", sync); window.addEventListener(PREF_EVENT, sync); return () => { @@ -70,7 +73,7 @@ export function useTtsEnabled() { }; }, []); - const setEnabled = (value) => { + const setEnabled = (value: boolean): void => { const next = !!value; writePref(next); setEnabledState(next); @@ -85,7 +88,7 @@ export function useTtsEnabled() { // ── Voice mode preference (silence-based auto-send) ───────── -function readVoiceMode() { +function readVoiceMode(): boolean { try { return localStorage.getItem(VOICE_MODE_KEY) === "1"; } catch { @@ -93,7 +96,7 @@ function readVoiceMode() { } } -function writeVoiceMode(value) { +function writeVoiceMode(value: boolean): void { try { if (value) { localStorage.setItem(VOICE_MODE_KEY, "1"); @@ -111,11 +114,11 @@ function writeVoiceMode(value) { * the message after a short silence (Skype-style turn taking) instead of * just dictating into the composer. Mirrors useTtsEnabled in shape. */ -export function useVoiceMode() { - const [enabled, setEnabledState] = useState(readVoiceMode); +export function useVoiceMode(): [boolean, (value: boolean) => void] { + const [enabled, setEnabledState] = useState(readVoiceMode); useEffect(() => { - const sync = () => setEnabledState(readVoiceMode()); + const sync = (): void => setEnabledState(readVoiceMode()); window.addEventListener("storage", sync); window.addEventListener(VOICE_MODE_EVENT, sync); return () => { @@ -124,7 +127,7 @@ export function useVoiceMode() { }; }, []); - const setEnabled = (value) => { + const setEnabled = (value: boolean): void => { writeVoiceMode(!!value); setEnabledState(!!value); }; @@ -133,7 +136,7 @@ export function useVoiceMode() { } /** Read the currently-picked dictation/TTS language from localStorage, if any. */ -export function readVoiceLang() { +export function readVoiceLang(): string { try { return localStorage.getItem(LANG_KEY) || ""; } catch { @@ -147,7 +150,7 @@ export function readVoiceLang() { * TTS read-aloud should match what the user *hears*, not the raw markdown. * @param text */ -export function cleanForSpeech(text) { +export function cleanForSpeech(text: unknown): string { if (typeof text !== "string") { return ""; } @@ -166,12 +169,19 @@ export function cleanForSpeech(text) { .trim(); } +/** Minimal shape of an assistant-ui message the TTS extractor reads. */ +interface ReadableMessage { + content?: string | ReadonlyArray<{ type?: string; text?: string }>; +} + /** * Pull readable text out of an assistant message. Skips tool calls / results — * they're machinery, not the answer the user wants spoken. * @param message */ -export function extractAssistantText(message) { +export function extractAssistantText( + message: ReadableMessage | undefined, +): string { if (!message) { return ""; } @@ -185,7 +195,7 @@ export function extractAssistantText(message) { .filter( (part) => part && part.type === "text" && typeof part.text === "string", ) - .map((part) => part.text) + .map((part) => part.text as string) .join("\n\n") .trim(); } @@ -195,7 +205,7 @@ export function extractAssistantText(message) { * `getVoices()` returns [] until the `voiceschanged` event fires; without * this gate the first `speak()` may pick no voice and silently no-op. */ -function voicesReady() { +function voicesReady(): Promise { if (!ttsSupported()) { return Promise.resolve([]); } @@ -205,7 +215,7 @@ function voicesReady() { return Promise.resolve(have); } return new Promise((resolve) => { - const done = () => { + const done = (): void => { synth.removeEventListener?.("voiceschanged", done); resolve(synth.getVoices?.() || []); }; @@ -220,7 +230,10 @@ function voicesReady() { * @param voices * @param lang */ -function pickVoice(voices, lang) { +function pickVoice( + voices: SpeechSynthesisVoice[], + lang: string | undefined, +): SpeechSynthesisVoice | null { if (!lang || !voices?.length) { return null; } @@ -240,14 +253,14 @@ function pickVoice(voices, lang) { let outstandingUtterances = 0; let sessionActive = false; -function markUtteranceStart() { +function markUtteranceStart(): void { if (!sessionActive) { sessionActive = true; window.dispatchEvent(new CustomEvent(TTS_START_EVENT)); } } -function markUtteranceEnd() { +function markUtteranceEnd(): void { outstandingUtterances = Math.max(0, outstandingUtterances - 1); if (outstandingUtterances === 0 && sessionActive) { sessionActive = false; @@ -255,7 +268,7 @@ function markUtteranceEnd() { } } -function queueUtterances(chunks, lang) { +function queueUtterances(chunks: string[], lang: string | undefined): void { const synth = window.speechSynthesis; const voices = synth.getVoices?.() || []; const voice = pickVoice(voices, lang); @@ -284,7 +297,7 @@ function queueUtterances(chunks, lang) { * @param text * @param lang */ -export function speakAppend(text, lang) { +export function speakAppend(text: string, lang?: string): void { if (!ttsSupported()) { return; } @@ -303,7 +316,7 @@ export function speakAppend(text, lang) { * @param text * @param lang */ -export function speak(text, lang) { +export function speak(text: string, lang?: string): void { if (!ttsSupported()) { return; } @@ -318,7 +331,7 @@ export function speak(text, lang) { // speak past the next tick when there's anything to cancel, and gate on // voices having loaded so the first utterance after page load isn't a // no-op (Chrome returns [] from getVoices() until `voiceschanged` fires). - const launch = () => queueUtterances(chunks, lang); + const launch = (): void => queueUtterances(chunks, lang); const needCancel = synth.speaking || synth.pending; if (needCancel) { cancelTts(); @@ -332,7 +345,7 @@ export function speak(text, lang) { }); } -export function cancelTts() { +export function cancelTts(): void { if (!ttsSupported()) { return; } @@ -352,13 +365,13 @@ export function cancelTts() { } } -function chunkForUtterance(text, target) { +function chunkForUtterance(text: string, target: number): string[] { if (text.length <= target) { return [text]; } // Split into sentences first, then re-pack into ≤target-sized chunks. const sentences = text.split(/(?<=[.!?。!?])\s+/); - const chunks = []; + const chunks: string[] = []; let buf = ""; for (const sentence of sentences) { if (!sentence) { diff --git a/resources/scripts/hooks/use-voice-input.js b/resources/scripts/hooks/use-voice-input.js deleted file mode 100644 index 39a7b65..0000000 --- a/resources/scripts/hooks/use-voice-input.js +++ /dev/null @@ -1,83 +0,0 @@ -import { useState, useRef, useCallback } from "@wordpress/element"; - -/** - * Voice-to-text for the composer, backed by the browser's Web Speech API. - * - * The public surface ({supported, listening, start, stop, toggle} + an - * onResult callback) is deliberately backend-agnostic: a future server-side - * implementation (record audio → POST to a Whisper endpoint → resolve text) - * can replace the internals here without touching the mic button. onResult - * fires with the running transcript (interim + final) so the caller can live- - * update the input; isFinal marks when a phrase is committed. - * - * @param {Object} [opts] - * @param {Function} [opts.onResult] (transcript: string, isFinal: boolean) => void - * @param {string} [opts.lang] BCP-47 language; defaults to the page/browser language. - * @return {{supported: boolean, listening: boolean, start: Function, stop: Function, toggle: Function}} Voice control. - */ -export function useVoiceInput({ onResult, lang } = {}) { - const SpeechRecognition = - window.SpeechRecognition || window.webkitSpeechRecognition; - const supported = !!SpeechRecognition; - const [listening, setListening] = useState(false); - const recRef = useRef(null); - - const stop = useCallback(() => { - try { - recRef.current?.stop(); - } catch { - // already stopped - } - }, []); - - const start = useCallback(() => { - if (!SpeechRecognition || recRef.current) { - return; - } - const rec = new SpeechRecognition(); - rec.lang = - lang || document.documentElement.lang || navigator.language || "en"; - rec.interimResults = true; - rec.continuous = true; - - // Accumulate finalised phrases; append the in-progress interim each event - // so the caller always gets the full session transcript. - let finalText = ""; - rec.onresult = (event) => { - let interim = ""; - for (let i = event.resultIndex; i < event.results.length; i++) { - const seg = event.results[i][0].transcript; - if (event.results[i].isFinal) { - finalText += seg; - } else { - interim += seg; - } - } - onResult?.((finalText + interim).trim(), interim === ""); - }; - // onend always fires (after a normal stop or an error), so reset there. - rec.onend = () => { - recRef.current = null; - setListening(false); - }; - - recRef.current = rec; - setListening(true); - try { - rec.start(); - } catch { - recRef.current = null; - setListening(false); - } - }, [SpeechRecognition, lang, onResult]); - - const toggle = useCallback(() => { - if (recRef.current) { - stop(); - } else { - start(); - } - }, [start, stop]); - - return { supported, listening, start, stop, toggle }; -} diff --git a/resources/scripts/hooks/use-voice-input.ts b/resources/scripts/hooks/use-voice-input.ts new file mode 100644 index 0000000..702fc41 --- /dev/null +++ b/resources/scripts/hooks/use-voice-input.ts @@ -0,0 +1,135 @@ +import { useState, useRef, useCallback } from "@wordpress/element"; + +// ── Web Speech API surface ────────────────────────────────── +// lib.dom.d.ts ships SpeechRecognition / webkitSpeechRecognition on Window +// already, but the typings are spotty across browsers and TS releases. We +// cast through a narrow local shape for the bits we actually use rather than +// re-declaring the globals (which conflicts with the built-in types). + +interface SpeechRecognitionResult { + isFinal: boolean; + 0: { transcript: string }; +} + +interface SpeechRecognitionEvent { + resultIndex: number; + results: ArrayLike; +} + +interface SpeechRecognitionInstance { + lang: string; + interimResults: boolean; + continuous: boolean; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onend: (() => void) | null; + start: () => void; + stop: () => void; +} + +interface SpeechRecognitionConstructor { + new (): SpeechRecognitionInstance; +} + +function getSpeechRecognition(): SpeechRecognitionConstructor | undefined { + const w = window as unknown as { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; + }; + return w.SpeechRecognition || w.webkitSpeechRecognition; +} + +export interface UseVoiceInputOptions { + /** (transcript, isFinal) — fires on every result event (interim and final). */ + onResult?: (transcript: string, isFinal: boolean) => void; + /** BCP-47 language; defaults to the page/browser language. */ + lang?: string; +} + +export interface UseVoiceInput { + supported: boolean; + listening: boolean; + start: () => void; + stop: () => void; + toggle: () => void; +} + +/** + * Voice-to-text for the composer, backed by the browser's Web Speech API. + * + * The public surface is deliberately backend-agnostic: a future server-side + * implementation (record audio → POST to a Whisper endpoint → resolve text) + * can replace the internals here without touching the mic button. `onResult` + * fires with the running transcript (interim + final) so the caller can live- + * update the input; `isFinal` marks when a phrase is committed. + * @param root0 + * @param root0.onResult + * @param root0.lang + */ +export function useVoiceInput({ + onResult, + lang, +}: UseVoiceInputOptions = {}): UseVoiceInput { + const SpeechRecognition = getSpeechRecognition(); + const supported = !!SpeechRecognition; + const [listening, setListening] = useState(false); + const recRef = useRef(null); + + const stop = useCallback(() => { + try { + recRef.current?.stop(); + } catch { + // already stopped + } + }, []); + + const start = useCallback(() => { + if (!SpeechRecognition || recRef.current) { + return; + } + const rec = new SpeechRecognition(); + rec.lang = + lang || document.documentElement.lang || navigator.language || "en"; + rec.interimResults = true; + rec.continuous = true; + + // Accumulate finalised phrases; append the in-progress interim each event + // so the caller always gets the full session transcript. + let finalText = ""; + rec.onresult = (event) => { + let interim = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const seg = event.results[i]![0].transcript; + if (event.results[i]!.isFinal) { + finalText += seg; + } else { + interim += seg; + } + } + onResult?.((finalText + interim).trim(), interim === ""); + }; + // onend always fires (after a normal stop or an error), so reset there. + rec.onend = () => { + recRef.current = null; + setListening(false); + }; + + recRef.current = rec; + setListening(true); + try { + rec.start(); + } catch { + recRef.current = null; + setListening(false); + } + }, [SpeechRecognition, lang, onResult]); + + const toggle = useCallback(() => { + if (recRef.current) { + stop(); + } else { + start(); + } + }, [start, stop]); + + return { supported, listening, start, stop, toggle }; +}