Source: user_interactions.js

  1. /**
  2. * General file to control dynamic interactions with the user.
  3. *
  4. * @author HugoFara <Hugo.Farajallah@protonmail.com>
  5. * @license Unlicense <http://unlicense.org/>
  6. * @since 2.0.3-fork
  7. */
  8. /**
  9. * Redirect the user to a specific page depending on the value
  10. */
  11. function quickMenuRedirection (value) {
  12. const qm = document.getElementById('quickmenu');
  13. qm.selectedIndex = 0;
  14. if (value == '') { return; }
  15. if (value == 'INFO') {
  16. top.location.href = 'docs/info.html';
  17. } else if (value == 'rss_import') {
  18. top.location.href = 'do_feeds.php?check_autoupdate=1';
  19. } else {
  20. top.location.href = value + '.php';
  21. }
  22. }
  23. /**
  24. * Create an interactable to add a new expression.
  25. *
  26. * WARNING! This function was not properly tested!
  27. *
  28. * @param {string[]} text An array of words forming the expression
  29. * @param {string} attrs A group of attributes to add
  30. * @param {int} length Number of words, should correspond to WoWordCount
  31. * @param {string} hex Lowercase formatted version of the text.
  32. * @param {bool} showallwords true: multi-word is a superscript, show mw index + words
  33. * false: only show the multiword, hide the words
  34. * @returns {undefined}
  35. *
  36. * @since 2.5.2-fork Don't hide multi-word index when inserting new multi-word.
  37. */
  38. function newExpressionInteractable (text, attrs, length, hex, showallwords) {
  39. const context = window.parent.document;
  40. // From each multi-word group
  41. for (key in text) {
  42. // Remove any previous multi-word of same length + same position
  43. $('#ID-' + key + '-' + length, context).remove();
  44. // From text, select the first mword smaller than this one, or the first
  45. // word in this mword
  46. let next_term_key = '';
  47. for (let j = length - 1; j > 0; j--) {
  48. if (j == 1) { next_term_key = '#ID-' + key + '-1'; }
  49. if ($('#ID-' + key + '-' + j, context).length) {
  50. next_term_key = '#ID-' + key + '-' + j;
  51. break;
  52. }
  53. }
  54. // Add the multi-word marker before
  55. $(next_term_key, context)
  56. .before(
  57. '<span id="ID-' + key + '-' + length + '"' + attrs + '>' + text[key] +
  58. '</span>'
  59. );
  60. // Change multi-word properties
  61. const multi_word = $('#ID-' + key + '-' + length, context);
  62. multi_word.addClass('order' + key).attr('data_order', key);
  63. const txt = multi_word
  64. .nextUntil(
  65. $('#ID-' + (parseInt(key) + length * 2 - 1) + '-1', context),
  66. '[id$="-1"]'
  67. )
  68. .map(function () {
  69. return $(this).text();
  70. })
  71. .get().join('');
  72. const pos = $('#ID-' + key + '-1', context).attr('data_pos');
  73. multi_word.attr('data_text', txt).attr('data_pos', pos);
  74. // Hide the next words if necessary
  75. if (showallwords) {
  76. return;
  77. }
  78. const next_words = [];
  79. // TODO: overlapsing multi-words
  80. for (let i = 0; i < length * 2 - 1; i++) {
  81. next_words.push('span[id="ID-' + (parseInt(key) + i) + '-1"]');
  82. }
  83. $(next_words.join(','), context).hide();
  84. }
  85. }
  86. /**
  87. * Scroll to a specific reading position
  88. *
  89. * @since 2.0.3-fork
  90. */
  91. function goToLastPosition () {
  92. // Last registered position to go to
  93. const lookPos = LWT_DATA.text.reading_position;
  94. // Position to scroll to
  95. let pos = 0;
  96. if (lookPos > 0) {
  97. const posObj = $('.wsty[data_pos=' + lookPos + ']').not('.hide').eq(0);
  98. if (posObj.attr('data_pos') === undefined) {
  99. pos = $('.wsty').not('.hide').filter(function () {
  100. return $(this).attr('data_pos') <= lookPos;
  101. }).eq(-1);
  102. }
  103. }
  104. $(document).scrollTo(pos);
  105. focus();
  106. setTimeout(overlib, 10);
  107. setTimeout(cClick, 100);
  108. }
  109. /**
  110. * Save the current reading position.
  111. *
  112. * @param {int} text_id Text id
  113. * @param {int} position Position to save
  114. *
  115. * @since 2.9.0-fork
  116. */
  117. function saveReadingPosition (text_id, position) {
  118. $.post(
  119. 'api.php/v1/texts/' + text_id + '/reading-position',
  120. { position: position }
  121. );
  122. }
  123. /**
  124. * Save audio position
  125. */
  126. function saveAudioPosition (text_id, pos) {
  127. $.post(
  128. 'api.php/v1/texts/' + text_id + '/audio-position',
  129. { position: pos }
  130. );
  131. }
  132. /**
  133. * Get the phonetic version of a text.
  134. *
  135. * @param {string} text Text to convert to phonetics.
  136. * @param {string} lang Language, either two letters code or four letters (BCP 47).
  137. *
  138. * @deprecated Since 2.10.0 use getPhoneticTextAsync
  139. */
  140. function getPhoneticText (text, lang) {
  141. let phoneticText;
  142. $.ajax(
  143. 'api.php/v1/phonetic-reading',
  144. {
  145. async: false,
  146. data: {
  147. text: text,
  148. lang: lang
  149. },
  150. dataType: 'json',
  151. type: 'GET'
  152. }
  153. )
  154. .done(
  155. function (data) {
  156. phoneticText = data.phonetic_reading;
  157. }
  158. );
  159. return phoneticText;
  160. }
  161. /**
  162. * Get the phonetic version of a text, asynchronous.
  163. *
  164. * @param {string} text Text to convert to phonetics.
  165. * @param {string|number} lang Language, either two letters code or four letters (BCP 47), or language ID
  166. */
  167. async function getPhoneticTextAsync (text, lang) {
  168. const parameters = {
  169. text: text
  170. };
  171. if (typeof lang == 'number') {
  172. parameters.lang_id = lang;
  173. } else {
  174. parameters.lang = lang;
  175. }
  176. return $.getJSON(
  177. 'api.php/v1/phonetic-reading',
  178. parameters
  179. );
  180. }
  181. /**
  182. * Replace any searchValue on object value by replaceValue with deepth.
  183. *
  184. * @param {dict} obj Object to search in
  185. * @param {string} searchValue Value to find
  186. * @param {string} replaceValue Value to replace with
  187. * */
  188. function deepReplace (obj, searchValue, replaceValue) {
  189. for (let key in obj) {
  190. if (typeof obj[key] === 'object') {
  191. // Recursively search nested objects
  192. deepReplace(obj[key], searchValue, replaceValue);
  193. } else if (typeof obj[key] === 'string' && obj[key].includes(searchValue)) {
  194. // If the property is a string and contains the searchValue, replace it
  195. obj[key] = obj[key].replace(searchValue, replaceValue);
  196. }
  197. }
  198. }
  199. /**
  200. * Find the first string starting with searchValue in object.
  201. *
  202. * @param {dict} obj Object to search in
  203. * @param {string} searchValue Value to search
  204. */
  205. function deepFindValue (obj, searchValue) {
  206. for (const key in obj) {
  207. if (obj.hasOwnProperty(key)) {
  208. if (typeof obj[key] === 'string' && obj[key].startsWith(searchValue)) {
  209. return obj[key];
  210. } else if (typeof obj[key] === 'object') {
  211. const result = deepFindValue(obj[key], searchValue);
  212. if (result) {
  213. return result;
  214. }
  215. }
  216. }
  217. }
  218. return null; // Return null if no matching string is found
  219. }
  220. function readTextWithExternal (text, voice_api, lang) {
  221. const fetchRequest = JSON.parse(voice_api);
  222. // TODO: can expose more vars to Request
  223. deepReplace(fetchRequest, 'lwt_term', text)
  224. deepReplace(fetchRequest, 'lwt_lang', lang)
  225. fetchRequest.options.body = JSON.stringify(fetchRequest.options.body)
  226. fetch(fetchRequest.input, fetchRequest.options)
  227. .then(response => response.json())
  228. .then(data => {
  229. const encodeString = deepFindValue(data, 'data:')
  230. const utter = new Audio(encodeString)
  231. utter.play()
  232. })
  233. .catch(error => {
  234. console.error(error)
  235. });
  236. }
  237. function cookieTTSSettings (language) {
  238. const prefix = 'tts[' + language;
  239. const lang_settings = {};
  240. const num_vals = ['Rate', 'Pitch'];
  241. const cookies = ['Rate', 'Pitch', 'Voice'];
  242. let cookie_val;
  243. for (const cook in cookies) {
  244. cookie_val = getCookie(prefix + cook + ']');
  245. if (cookie_val) {
  246. if (num_vals.includes(cook)) {
  247. lang_settings[cook.toLowerCase()] = parseFloat(cookie_val);
  248. } else {
  249. lang_settings[cook.toLowerCase()] = cookie_val;
  250. }
  251. }
  252. }
  253. return lang_settings;
  254. }
  255. /**
  256. * Read a text aloud, works with a phonetic version only.
  257. *
  258. * @param {string} text Text to read, won't be parsed further.
  259. * @param {string} lang Language code with BCP 47 convention
  260. * (e. g. "en-US" for English with an American accent)
  261. * @param {number} rate Reading rate
  262. * @param {number} pitch Pitch value
  263. *
  264. * @return {SpeechSynthesisUtterance} The spoken message object
  265. *
  266. * @since 2.9.0 Accepts "voice" as a new optional argument
  267. */
  268. function readRawTextAloud (text, lang, rate, pitch, voice) {
  269. const msg = new SpeechSynthesisUtterance();
  270. const tts_settings = cookieTTSSettings(lang.substring(0, 2));
  271. msg.text = text;
  272. if (lang) {
  273. msg.lang = lang;
  274. }
  275. // Voice is a string but we have to assign a SpeechSynthesysVoice
  276. const useVoice = voice || tts_settings.voice;
  277. if (useVoice) {
  278. const voices = window.speechSynthesis.getVoices();
  279. for (let i = 0; i < voices.length; i++) {
  280. if (voices[i].name === useVoice) {
  281. msg.voice = voices[i];
  282. }
  283. }
  284. }
  285. if (rate) {
  286. msg.rate = rate;
  287. } else if (tts_settings.rate) {
  288. msg.rate = tts_settings.rate;
  289. }
  290. if (pitch) {
  291. msg.pitch = pitch;
  292. } else if (tts_settings.pitch) {
  293. msg.pitch = tts_settings.pitch;
  294. }
  295. window.speechSynthesis.speak(msg);
  296. return msg;
  297. }
  298. /**
  299. * Read a text aloud, may parse the text to get a phonetic version.
  300. *
  301. * @param {string} text Text to read, do not need to be phonetic
  302. * @param {string} lang Language code with BCP 47 convention
  303. * (e. g. "en-US" for English with an American accent)
  304. * @param {number} rate Reading rate
  305. * @param {number} pitch Pitch value
  306. * @param {string} voice Optional voice, the result will depend on the browser used
  307. *
  308. * @since 2.9.0 Accepts "voice" as a new optional argument
  309. */
  310. function readTextAloud (text, lang, rate, pitch, voice, convert_to_phonetic) {
  311. if (convert_to_phonetic) {
  312. getPhoneticTextAsync(text, lang)
  313. .then(
  314. function (data) {
  315. readRawTextAloud(
  316. data.phonetic_reading, lang, rate, pitch, voice
  317. );
  318. }
  319. );
  320. } else {
  321. readRawTextAloud(text, lang, rate, pitch, voice);
  322. }
  323. }
  324. function handleReadingConfiguration(language, term, lang_id) {
  325. if (language.reading_mode == "direct" || language.reading_mode == "internal") {
  326. const lang_settings = cookieTTSSettings(language.name);
  327. if (language.reading_mode == "direct") {
  328. // No reparsing needed
  329. readRawTextAloud(
  330. term,
  331. language.abbreviation,
  332. lang_settings.rate,
  333. lang_settings.pitch,
  334. lang_settings.voice
  335. );
  336. } else {
  337. // Server handled reparsing
  338. getPhoneticTextAsync(term, parseInt(lang_id, 10))
  339. .then(
  340. function (reparsed_text) {
  341. readRawTextAloud(
  342. reparsed_text.phonetic_reading,
  343. language.abbreviation,
  344. lang_settings.rate,
  345. lang_settings.pitch,
  346. lang_settings.voice
  347. );
  348. }
  349. );
  350. }
  351. } else if (language.reading_mode == "external") {
  352. // Use external API
  353. readTextWithExternal(term, language.voiceapi, language.name);
  354. }
  355. }
  356. function speechDispatcher (term, lang_id) {
  357. return $.getJSON(
  358. 'api.php/v1/languages/' + lang_id + '/reading-configuration',
  359. { lang_id },
  360. (data) => handleReadingConfiguration(data, term, parseInt(lang_id, 10))
  361. );
  362. }