@redacto.io/consent-sdk-react 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,8 @@ import logo from "./assets/redacto-logo.png";
32
32
  import { injectCheckboxStyles } from "./injectStyles";
33
33
  import { styles } from "./styles";
34
34
  import { useMediaQuery } from "./useMediaQuery";
35
+ import { useTextToSpeech } from "./useTextToSpeech";
36
+ import type { TextSegment } from "./types";
35
37
 
36
38
  // =============================================================================
37
39
  // SUB-COMPONENTS
@@ -121,6 +123,7 @@ const PurposeItem: React.FC<PurposeItemProps> = ({
121
123
  </div>
122
124
  <div style={styles.optionTextContainer}>
123
125
  <h2
126
+ id={`purpose-name-${purpose.uuid}`}
124
127
  style={{
125
128
  ...styles.optionTitle,
126
129
  ...responsiveStyles.optionTitle,
@@ -130,6 +133,7 @@ const PurposeItem: React.FC<PurposeItemProps> = ({
130
133
  {getTranslatedText(`purposes.name`, purpose.name, purpose.uuid)}
131
134
  </h2>
132
135
  <h3
136
+ id={`purpose-description-${purpose.uuid}`}
133
137
  style={{
134
138
  ...styles.optionDescription,
135
139
  ...responsiveStyles.optionDescription,
@@ -198,6 +202,7 @@ const PurposeItem: React.FC<PurposeItemProps> = ({
198
202
  {purpose.data_elements.map((dataElement: any) => (
199
203
  <div key={dataElement.uuid} style={styles.dataElementItem}>
200
204
  <span
205
+ id={`data-element-${purpose.uuid}-${dataElement.uuid}`}
201
206
  style={{
202
207
  ...styles.dataElementText,
203
208
  ...responsiveStyles.dataElementText,
@@ -694,7 +699,7 @@ export const RedactoNoticeConsent = ({
694
699
  onError,
695
700
  settings,
696
701
  applicationId,
697
- checkMode = "all",
702
+ validateAgainst = "all",
698
703
  }: Props) => {
699
704
  // =============================================================================
700
705
  // PROP VALIDATION
@@ -969,7 +974,8 @@ export const RedactoNoticeConsent = ({
969
974
  ): string => {
970
975
  if (!content) return defaultText;
971
976
 
972
- if (selectedLanguage === content.default_language) {
977
+ // If English is selected, use the base content from active_config
978
+ if (selectedLanguage === "English" || selectedLanguage === "en") {
973
979
  if (
974
980
  key === "privacy_policy_anchor_text" &&
975
981
  content.privacy_policy_anchor_text
@@ -979,6 +985,7 @@ export const RedactoNoticeConsent = ({
979
985
  return defaultText;
980
986
  }
981
987
 
988
+ // For other languages, check supported_languages_and_translations
982
989
  if (!content.supported_languages_and_translations) {
983
990
  return defaultText;
984
991
  }
@@ -1043,13 +1050,16 @@ export const RedactoNoticeConsent = ({
1043
1050
  baseUrl,
1044
1051
  language,
1045
1052
  specific_uuid: applicationId,
1046
- check_mode: checkMode,
1053
+ validate_against: validateAgainst,
1047
1054
  signal: abortControllerRef.current?.signal,
1048
1055
  });
1049
1056
  setContent(consentContentData.detail.active_config);
1050
1057
  if (consentContentData.detail.active_config.default_language) {
1058
+ // Normalize "en" to "English" for consistency
1059
+ const defaultLang =
1060
+ consentContentData.detail.active_config.default_language;
1051
1061
  setSelectedLanguage(
1052
- consentContentData.detail.active_config.default_language
1062
+ defaultLang === "en" || defaultLang === "EN" ? "English" : defaultLang
1053
1063
  );
1054
1064
  }
1055
1065
  const initialCollapsedState =
@@ -1178,7 +1188,7 @@ export const RedactoNoticeConsent = ({
1178
1188
  refreshToken,
1179
1189
  language,
1180
1190
  applicationId,
1181
- checkMode,
1191
+ validateAgainst,
1182
1192
  // Removed onError and isRefreshingToken from dependencies to prevent duplicate API calls
1183
1193
  // onError is only called on errors, not needed for initial data fetching
1184
1194
  ]);
@@ -1437,16 +1447,217 @@ export const RedactoNoticeConsent = ({
1437
1447
  // User is under 18, show guardian form
1438
1448
  }, []);
1439
1449
 
1440
- const availableLanguages = useMemo(
1441
- () =>
1442
- content
1443
- ? [
1444
- content.default_language,
1445
- ...Object.keys(content.supported_languages_and_translations || {}),
1446
- ].filter((value, index, self) => self.indexOf(value) === index)
1447
- : [language],
1448
- [content, language]
1449
- );
1450
+ const availableLanguages = useMemo(() => {
1451
+ if (!content) return [language];
1452
+
1453
+ // English is always available since it's in active_config
1454
+ const languages = ["English"];
1455
+
1456
+ // Add default language if it's not English
1457
+ if (
1458
+ content.default_language &&
1459
+ content.default_language !== "English" &&
1460
+ content.default_language !== "en"
1461
+ ) {
1462
+ languages.push(content.default_language);
1463
+ }
1464
+
1465
+ // Add all supported languages from translations
1466
+ if (content.supported_languages_and_translations) {
1467
+ Object.keys(content.supported_languages_and_translations).forEach(
1468
+ (lang) => {
1469
+ if (!languages.includes(lang)) {
1470
+ languages.push(lang);
1471
+ }
1472
+ }
1473
+ );
1474
+ }
1475
+
1476
+ return languages;
1477
+ }, [content, language]);
1478
+
1479
+ // =============================================================================
1480
+ // TEXT-TO-SPEECH FUNCTIONALITY
1481
+ // =============================================================================
1482
+
1483
+ // Create text segments for TTS in reading order
1484
+ const textSegments = useMemo<TextSegment[]>(() => {
1485
+ if (!content) return [];
1486
+
1487
+ const segments: TextSegment[] = [];
1488
+
1489
+ // 1. Notice banner heading
1490
+ const noticeHeading = getTranslatedText(
1491
+ "notice_banner_heading",
1492
+ content.notice_banner_heading || "Privacy Notice"
1493
+ );
1494
+ if (noticeHeading) {
1495
+ segments.push({
1496
+ text: noticeHeading,
1497
+ elementId: "privacy-notice-title",
1498
+ });
1499
+ }
1500
+
1501
+ // 2. Notice text
1502
+ const noticeText = getTranslatedText("notice_text", content.notice_text);
1503
+ if (noticeText) {
1504
+ segments.push({
1505
+ text: noticeText,
1506
+ elementId: "privacy-notice-text",
1507
+ });
1508
+ }
1509
+
1510
+ // 3. Privacy policy text
1511
+ const privacyPrefix = getTranslatedText(
1512
+ "privacy_policy_prefix_text",
1513
+ content.privacy_policy_prefix_text || ""
1514
+ );
1515
+ const privacyAnchor = getTranslatedText(
1516
+ "privacy_policy_anchor_text",
1517
+ content.privacy_policy_anchor_text || "Privacy Policy"
1518
+ );
1519
+ if (privacyPrefix || privacyAnchor) {
1520
+ const privacyText = `${privacyPrefix} ${privacyAnchor}`.trim();
1521
+ if (privacyText) {
1522
+ segments.push({
1523
+ text: privacyText,
1524
+ elementId: "privacy-policy-text",
1525
+ });
1526
+ }
1527
+ }
1528
+
1529
+ // 4. Purpose section heading
1530
+ const purposeHeading = getTranslatedText(
1531
+ "purpose_section_heading",
1532
+ "Manage What You Share"
1533
+ );
1534
+ if (purposeHeading) {
1535
+ segments.push({
1536
+ text: purposeHeading,
1537
+ elementId: "purpose-section-heading",
1538
+ });
1539
+ }
1540
+
1541
+ // 5. Purposes and data elements
1542
+ const purposesToRead = categorizedPurposes
1543
+ ? [
1544
+ ...categorizedPurposes.alreadyConsented,
1545
+ ...categorizedPurposes.needsConsent,
1546
+ ]
1547
+ : content.purposes || [];
1548
+
1549
+ purposesToRead.forEach((purpose) => {
1550
+ // Purpose name
1551
+ const purposeName = getTranslatedText(
1552
+ `purposes.name`,
1553
+ purpose.name,
1554
+ purpose.uuid
1555
+ );
1556
+ if (purposeName) {
1557
+ segments.push({
1558
+ text: purposeName,
1559
+ elementId: `purpose-name-${purpose.uuid}`,
1560
+ });
1561
+ }
1562
+
1563
+ // Purpose description
1564
+ const purposeDescription = getTranslatedText(
1565
+ `purposes.description`,
1566
+ purpose.description,
1567
+ purpose.uuid
1568
+ );
1569
+ if (purposeDescription) {
1570
+ segments.push({
1571
+ text: purposeDescription,
1572
+ elementId: `purpose-description-${purpose.uuid}`,
1573
+ });
1574
+ }
1575
+
1576
+ // Data elements
1577
+ if (purpose.data_elements && purpose.data_elements.length > 0) {
1578
+ purpose.data_elements.forEach((dataElement) => {
1579
+ const elementName = getTranslatedText(
1580
+ `data_elements.name`,
1581
+ dataElement.name,
1582
+ dataElement.uuid
1583
+ );
1584
+ if (elementName) {
1585
+ const requiredText = dataElement.required ? " (required)" : "";
1586
+ segments.push({
1587
+ text: `${elementName}${requiredText}`,
1588
+ elementId: `data-element-${purpose.uuid}-${dataElement.uuid}`,
1589
+ });
1590
+ }
1591
+ });
1592
+ }
1593
+ });
1594
+
1595
+ return segments;
1596
+ }, [content, categorizedPurposes, getTranslatedText]);
1597
+
1598
+ // Initialize TTS hook
1599
+ const tts = useTextToSpeech(textSegments, selectedLanguage);
1600
+
1601
+ // Auto-expand collapsed purposes when TTS is reading their content
1602
+ // This ensures accessibility - users can see what's being read even if sections were collapsed
1603
+ useEffect(() => {
1604
+ if (!tts.isPlaying || !content || textSegments.length === 0) return;
1605
+
1606
+ const currentSegment = textSegments[tts.currentIndex];
1607
+ if (!currentSegment) return;
1608
+
1609
+ // Extract purpose UUID from element ID
1610
+ let purposeUuid: string | null = null;
1611
+
1612
+ // Check if current segment is a data element (format: data-element-{purposeUuid}-{elementUuid})
1613
+ const dataElementMatch = currentSegment.elementId.match(
1614
+ /^data-element-(.+?)-(.+)$/
1615
+ );
1616
+ if (dataElementMatch) {
1617
+ purposeUuid = dataElementMatch[1];
1618
+ } else {
1619
+ // Check if current segment is a purpose name or description (format: purpose-name-{purposeUuid} or purpose-description-{purposeUuid})
1620
+ const purposeNameMatch =
1621
+ currentSegment.elementId.match(/^purpose-name-(.+)$/);
1622
+ const purposeDescMatch = currentSegment.elementId.match(
1623
+ /^purpose-description-(.+)$/
1624
+ );
1625
+ if (purposeNameMatch) {
1626
+ purposeUuid = purposeNameMatch[1];
1627
+ } else if (purposeDescMatch) {
1628
+ purposeUuid = purposeDescMatch[1];
1629
+ }
1630
+ }
1631
+
1632
+ // Expand the purpose if it's collapsed
1633
+ if (purposeUuid && collapsedPurposes[purposeUuid]) {
1634
+ setCollapsedPurposes((prev) => ({
1635
+ ...prev,
1636
+ [purposeUuid!]: false,
1637
+ }));
1638
+ }
1639
+ }, [
1640
+ tts.isPlaying,
1641
+ tts.currentIndex,
1642
+ textSegments,
1643
+ content,
1644
+ collapsedPurposes,
1645
+ ]);
1646
+
1647
+ // Stop TTS when language changes
1648
+ useEffect(() => {
1649
+ tts.stop();
1650
+ }, [selectedLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
1651
+
1652
+ // Stop TTS when content changes
1653
+ useEffect(() => {
1654
+ tts.stop();
1655
+ }, [content]); // eslint-disable-line react-hooks/exhaustive-deps
1656
+
1657
+ // Handle TTS button click
1658
+ const handleTTSButtonClick = useCallback(() => {
1659
+ tts.toggle();
1660
+ }, [tts]);
1450
1661
 
1451
1662
  const modalStyle = {
1452
1663
  borderRadius: settings?.borderRadius || "8px",
@@ -1806,7 +2017,10 @@ export const RedactoNoticeConsent = ({
1806
2017
  ...componentHeadingStyle,
1807
2018
  }}
1808
2019
  >
1809
- {content?.notice_banner_heading}
2020
+ {getTranslatedText(
2021
+ "notice_banner_heading",
2022
+ content?.notice_banner_heading || "Privacy Notice"
2023
+ )}
1810
2024
  </h2>
1811
2025
  </div>
1812
2026
  <div style={styles.languageSelectorContainer}>
@@ -1851,37 +2065,76 @@ export const RedactoNoticeConsent = ({
1851
2065
  </svg>
1852
2066
  </button>
1853
2067
  </div>
1854
- <button
1855
- style={{
1856
- ...styles.topRight,
1857
- ...responsiveStyles.topRight,
1858
- ...languageSelectorStyle,
1859
- padding: "3px 9px",
1860
- }}
1861
- aria-label="Talkback"
1862
- title="Talkback"
1863
- >
1864
- <svg
1865
- xmlns="http://www.w3.org/2000/svg"
1866
- width="16"
1867
- height="16"
1868
- viewBox="0 0 24 24"
1869
- fill="none"
1870
- stroke={
1871
- settings?.button?.language?.textColor ||
1872
- "currentColor"
2068
+ {(selectedLanguage === "English" ||
2069
+ selectedLanguage === "en") && (
2070
+ <button
2071
+ onClick={handleTTSButtonClick}
2072
+ style={{
2073
+ ...styles.topRight,
2074
+ ...responsiveStyles.topRight,
2075
+ ...languageSelectorStyle,
2076
+ padding: "3px 9px",
2077
+ opacity: textSegments.length === 0 ? 0.5 : 1,
2078
+ }}
2079
+ aria-label={
2080
+ tts.isPlaying && !tts.isPaused
2081
+ ? "Pause text-to-speech"
2082
+ : tts.isPaused
2083
+ ? "Resume text-to-speech"
2084
+ : "Start text-to-speech"
1873
2085
  }
1874
- strokeWidth="2"
1875
- strokeLinecap="round"
1876
- strokeLinejoin="round"
1877
- aria-hidden="true"
2086
+ title={
2087
+ tts.isPlaying && !tts.isPaused
2088
+ ? "Pause Talkback"
2089
+ : tts.isPaused
2090
+ ? "Resume Talkback"
2091
+ : "Start Talkback"
2092
+ }
2093
+ disabled={textSegments.length === 0}
1878
2094
  >
1879
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
1880
- <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
1881
- <line x1="12" y1="19" x2="12" y2="23" />
1882
- <line x1="8" y1="23" x2="16" y2="23" />
1883
- </svg>
1884
- </button>
2095
+ {tts.isPlaying && !tts.isPaused ? (
2096
+ // Pause icon
2097
+ <svg
2098
+ xmlns="http://www.w3.org/2000/svg"
2099
+ width="16"
2100
+ height="16"
2101
+ viewBox="0 0 24 24"
2102
+ fill="none"
2103
+ stroke={
2104
+ settings?.button?.language?.textColor ||
2105
+ "currentColor"
2106
+ }
2107
+ strokeWidth="2"
2108
+ strokeLinecap="round"
2109
+ strokeLinejoin="round"
2110
+ aria-hidden="true"
2111
+ >
2112
+ <rect x="6" y="4" width="4" height="16" />
2113
+ <rect x="14" y="4" width="4" height="16" />
2114
+ </svg>
2115
+ ) : (
2116
+ // Play/Sound icon
2117
+ <svg
2118
+ xmlns="http://www.w3.org/2000/svg"
2119
+ width="16"
2120
+ height="16"
2121
+ viewBox="0 0 24 24"
2122
+ fill="none"
2123
+ stroke={
2124
+ settings?.button?.language?.textColor ||
2125
+ "currentColor"
2126
+ }
2127
+ strokeWidth="2"
2128
+ strokeLinecap="round"
2129
+ strokeLinejoin="round"
2130
+ aria-hidden="true"
2131
+ >
2132
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
2133
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
2134
+ </svg>
2135
+ )}
2136
+ </button>
2137
+ )}
1885
2138
  </div>
1886
2139
  {isLanguageDropdownOpen && (
1887
2140
  <ul
@@ -1931,43 +2184,35 @@ export const RedactoNoticeConsent = ({
1931
2184
  ...componentTextStyle,
1932
2185
  }}
1933
2186
  >
1934
- {content
1935
- ? getTranslatedText("notice_text", content.notice_text)
1936
- : ""}
2187
+ <span id="privacy-notice-text">
2188
+ {content
2189
+ ? getTranslatedText("notice_text", content.notice_text)
2190
+ : ""}
2191
+ </span>
1937
2192
  <br />
1938
- Learn more in our{" "}
1939
- <a
1940
- style={{ ...styles.link, ...linkStyle }}
1941
- href={content?.privacy_policy_url || "#"}
1942
- target="_blank"
1943
- rel="noopener noreferrer"
1944
- aria-label="Privacy Policy (opens in new tab)"
1945
- >
1946
- [
2193
+ <span id="privacy-policy-text">
1947
2194
  {getTranslatedText(
1948
- "privacy_policy_anchor_text",
1949
- content?.privacy_policy_anchor_text || "Privacy Policy"
1950
- )}
1951
- ]
1952
- </a>{" "}
1953
- and{" "}
1954
- <a
1955
- style={{ ...styles.link, ...linkStyle }}
1956
- href={content?.sub_processors_url || "#"}
1957
- target="_blank"
1958
- rel="noopener noreferrer"
1959
- aria-label="Vendors List (opens in new tab)"
1960
- >
1961
- [
1962
- {getTranslatedText(
1963
- "vendors_list_anchor_text",
1964
- content?.vendors_list_anchor_text || "Vendors List"
1965
- )}
1966
- ]
1967
- </a>
1968
- .
2195
+ "privacy_policy_prefix_text",
2196
+ content?.privacy_policy_prefix_text || ""
2197
+ )}{" "}
2198
+ <a
2199
+ id="privacy-policy-link"
2200
+ style={{ ...styles.link, ...linkStyle }}
2201
+ href={content?.privacy_policy_url || "#"}
2202
+ target="_blank"
2203
+ rel="noopener noreferrer"
2204
+ aria-label="Privacy Policy (opens in new tab)"
2205
+ >
2206
+ {getTranslatedText(
2207
+ "privacy_policy_anchor_text",
2208
+ content?.privacy_policy_anchor_text ||
2209
+ "Privacy Policy"
2210
+ )}
2211
+ </a>
2212
+ </span>
1969
2213
  </p>
1970
2214
  <h2
2215
+ id="purpose-section-heading"
1971
2216
  style={{
1972
2217
  ...styles.subTitle,
1973
2218
  ...responsiveStyles.subTitle,
@@ -64,12 +64,12 @@ export const fetchConsentContent = async ({
64
64
  baseUrl,
65
65
  language = "en",
66
66
  specific_uuid,
67
- check_mode = "all",
67
+ validate_against = "all",
68
68
  signal, // AbortSignal for request cancellation
69
69
  }: FetchConsentContentParams & { signal?: AbortSignal }): Promise<ConsentContent> => {
70
70
  try {
71
71
  // Validate required parameters
72
- if (!noticeId || !accessToken) {
72
+ if (!noticeId || !accessToken || !validate_against) {
73
73
  throw new Error("noticeId and accessToken are required");
74
74
  }
75
75
 
@@ -86,10 +86,10 @@ export const fetchConsentContent = async ({
86
86
  const apiBaseUrl = baseUrl || BASE_URL;
87
87
 
88
88
  // Create cache key for this request - include accessToken to ensure different users get different cache
89
- const cacheKey = `${accessToken}-${noticeId}-${check_mode}-${language}-${specific_uuid || ''}`;
89
+ const cacheKey = `${accessToken}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}`;
90
90
 
91
91
  // Check cache first (skip for reconsent requests as they need fresh data)
92
- if (check_mode === "all" && !specific_uuid) {
92
+ if (validate_against === "all" && !specific_uuid) {
93
93
  const cachedData = getCachedData(cacheKey);
94
94
  if (cachedData) {
95
95
  return cachedData;
@@ -103,7 +103,7 @@ export const fetchConsentContent = async ({
103
103
  if (specific_uuid) {
104
104
  url.searchParams.append("specific_uuid", specific_uuid);
105
105
  }
106
- url.searchParams.append("check_mode", check_mode);
106
+ url.searchParams.append("validate_against", validate_against);
107
107
 
108
108
  const response = await fetch(url.toString(), {
109
109
  method: "GET",
@@ -138,7 +138,7 @@ export const fetchConsentContent = async ({
138
138
  const data: ConsentContent = await response.json();
139
139
 
140
140
  // Cache successful responses (skip for reconsent as data may change)
141
- if (check_mode === "all" && !specific_uuid) {
141
+ if (validate_against === "all" && !specific_uuid) {
142
142
  setCachedData(cacheKey, data);
143
143
  }
144
144
 
@@ -219,7 +219,7 @@ export const submitConsentEvent = async ({
219
219
  const apiBaseUrl = baseUrl || BASE_URL;
220
220
 
221
221
  const response = await fetch(
222
- `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/by-token`,
222
+ `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/submit-consent`,
223
223
  {
224
224
  method: "POST",
225
225
  headers: {
@@ -153,7 +153,7 @@ export type FetchConsentContentParams = {
153
153
  baseUrl?: string;
154
154
  language?: string;
155
155
  specific_uuid?: string;
156
- check_mode?: "all" | "required";
156
+ validate_against?: "all" | "required";
157
157
  };
158
158
 
159
159
  export type ConsentEventPayload = {
@@ -95,6 +95,22 @@ export const injectCheckboxStyles = () => {
95
95
  outline: 2px solid #4f87ff !important;
96
96
  outline-offset: 2px !important;
97
97
  }
98
+
99
+ /* Text-to-Speech Highlight Styles */
100
+ .redacto-tts-highlight {
101
+ background-color: #FFF9C4 !important;
102
+ transition: background-color 0.3s ease !important;
103
+ border-radius: 2px !important;
104
+ padding: 2px 4px !important;
105
+ }
106
+
107
+ /* Word-by-word highlighting */
108
+ .redacto-tts-word-highlight {
109
+ background-color: #FFF9C4 !important;
110
+ transition: background-color 0.2s ease !important;
111
+ border-radius: 2px !important;
112
+ padding: 0 1px !important;
113
+ }
98
114
  `;
99
115
 
100
116
  document.head.appendChild(style);
@@ -12,7 +12,7 @@ export type Props = Readonly<{
12
12
  onDecline: () => void;
13
13
  onError?: (error: Error) => void;
14
14
  applicationId?: string;
15
- checkMode?: "all" | "required";
15
+ validateAgainst?: "all" | "required";
16
16
  }>;
17
17
 
18
18
  export type PurposeTranslations = {
@@ -43,3 +43,26 @@ export type TranslationObject = {
43
43
  | Record<string, string>
44
44
  | undefined;
45
45
  };
46
+
47
+ export type TextSegment = {
48
+ text: string;
49
+ elementId: string;
50
+ elementRef?: React.RefObject<HTMLElement>;
51
+ };
52
+
53
+ export type UseTextToSpeechReturn = {
54
+ isPlaying: boolean;
55
+ isPaused: boolean;
56
+ currentIndex: number;
57
+ play: () => void;
58
+ pause: () => void;
59
+ stop: () => void;
60
+ toggle: () => void;
61
+ };
62
+
63
+ export type WordSpan = {
64
+ word: string;
65
+ span: HTMLSpanElement;
66
+ startIndex: number;
67
+ endIndex: number;
68
+ };