useSpeak.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { onMounted, onUnmounted, reactive, toRefs } from 'vue';
  2. /**
  3. * 朗读API
  4. * @param musicContent 阅读内容
  5. * @returns
  6. */
  7. export const useSpeak = (musicContent?: string) => {
  8. const _musicContent = musicContent ? '#' + musicContent : '#musicContent';
  9. const state = reactive({
  10. showDom: false,
  11. synth: null as any,
  12. // 选中的索引
  13. selectOptions: {
  14. startIndex: 0,
  15. anchorOffset: 0, // 开始段落的偏移值
  16. endIndex: 0,
  17. focusOffset: 0 // 结束段落的偏移值
  18. },
  19. isSpeak: false // 是否在播放
  20. });
  21. // 函数:递归处理节点
  22. const processNode = (node: any) => {
  23. const result = document.createDocumentFragment();
  24. node.childNodes?.forEach((child: any) => {
  25. if (child.nodeType === Node.TEXT_NODE) {
  26. // 按标点符号分割文本
  27. const sentences = child.textContent.split(/(?<=[,,;;。])\s*/);
  28. sentences?.forEach((sentence: any) => {
  29. if (sentence.trim()) {
  30. const customTag = document.createElement('label');
  31. customTag.textContent = sentence.trim();
  32. customTag.classList.add('speak-label');
  33. result.appendChild(customTag);
  34. }
  35. });
  36. } else if (child.nodeType === Node.ELEMENT_NODE) {
  37. const element = document.createElement(child.nodeName.toLowerCase());
  38. // 复制原有的属性
  39. Array.from(child.attributes).forEach((attr: any) => {
  40. element.setAttribute(attr.name, attr.value);
  41. });
  42. // 递归处理子节点
  43. const processedChildren = processNode(child);
  44. element.appendChild(processedChildren);
  45. result.appendChild(element);
  46. }
  47. });
  48. return result;
  49. };
  50. // 选中的方向
  51. const checkSelectionDirection = (selection: any) => {
  52. if (selection.rangeCount > 0) {
  53. const anchorNode = selection.anchorNode;
  54. const anchorOffset = selection.anchorOffset;
  55. const focusNode = selection.focusNode;
  56. const focusOffset = selection.focusOffset;
  57. // 检查是否在同一节点内选择
  58. if (anchorNode === focusNode) {
  59. if (anchorOffset < focusOffset) {
  60. return 'up';
  61. } else {
  62. return 'down';
  63. }
  64. } else {
  65. // 检查不同节点的选择情况
  66. const range = selection.getRangeAt(0);
  67. const startContainer = range.startContainer;
  68. const endContainer = range.endContainer;
  69. if (startContainer === anchorNode && endContainer === focusNode) {
  70. return 'up';
  71. } else {
  72. return 'down';
  73. }
  74. }
  75. } else {
  76. return 'up';
  77. }
  78. };
  79. const getSelectText = () => {
  80. const selection: any = window.getSelection();
  81. const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
  82. // console.log(selection, 'selection');
  83. if (selection.toString().length > 0) {
  84. state.showDom = true;
  85. const textContainer: any = document.querySelector(_musicContent);
  86. const sentences: any =
  87. textContainer?.querySelectorAll('label.speak-label');
  88. let startIndex = 0,
  89. anchorOffset = 0,
  90. endIndex = 0,
  91. focusOffset = 0;
  92. console.log(selection, 'selection');
  93. const firstNode =
  94. checkSelectionDirection(selection) === 'up'
  95. ? selection.anchorNode.parentNode
  96. : selection.focusNode.parentNode;
  97. const endNode =
  98. checkSelectionDirection(selection) === 'down'
  99. ? selection.anchorNode.parentNode
  100. : selection.focusNode.parentNode;
  101. if (checkSelectionDirection(selection) === 'up') {
  102. anchorOffset = selection.anchorOffset;
  103. focusOffset = selection.focusOffset;
  104. } else {
  105. anchorOffset = selection.focusOffset;
  106. focusOffset = selection.anchorOffset;
  107. }
  108. sentences?.forEach((element: any, index: number) => {
  109. if (element === firstNode) {
  110. startIndex = index;
  111. anchorOffset =
  112. checkSelectionDirection(selection) === 'up'
  113. ? selection.anchorOffset
  114. : selection.focusOffset;
  115. }
  116. if (element === endNode) {
  117. endIndex = index;
  118. focusOffset =
  119. checkSelectionDirection(selection) === 'down'
  120. ? selection.anchorOffset
  121. : selection.focusOffset;
  122. // focusOffset = selection.focusOffset;
  123. }
  124. });
  125. state.selectOptions.startIndex = startIndex;
  126. state.selectOptions.anchorOffset = anchorOffset;
  127. state.selectOptions.endIndex = endIndex;
  128. state.selectOptions.focusOffset = focusOffset;
  129. } else {
  130. state.showDom = false;
  131. }
  132. // 判断选中的类型
  133. setTimeout(() => {
  134. if (selection.type !== 'Range') {
  135. state.showDom = false;
  136. }
  137. }, 200);
  138. if (range && !selection.isCollapsed) {
  139. // 获取选中文本的坐标
  140. // const rect = range.getBoundingClientRect();
  141. const rects = range.getClientRects();
  142. if (rects.length > 0) {
  143. const firstRect = rects[0];
  144. const x = firstRect.left;
  145. const y = firstRect.top;
  146. const bottom = firstRect.bottom;
  147. const fHeight = firstRect.height;
  148. const musicContent: any = document.querySelector(_musicContent);
  149. const parentRect: any = musicContent?.getBoundingClientRect();
  150. const showDom: any = document.getElementById('selectionCouser');
  151. const showDomRect = showDom.getBoundingClientRect();
  152. // 判断 上边超出边界
  153. // if (y - parentRect?.top > showDomRect.height + fHeight / 2) {
  154. // showDom.style.top =
  155. // (
  156. // y -
  157. // parentRect?.top -
  158. // (showDomRect.height + fHeight / 2) +
  159. // musicContent?.scrollTop
  160. // ).toFixed(2) + 'px';
  161. // } else {
  162. // console.log(
  163. // false,
  164. // parentRect?.bottom -
  165. // bottom +
  166. // (showDomRect.height + fHeight / 2) +
  167. // musicContent?.scrollTop
  168. // );
  169. showDom.style.top =
  170. (
  171. y -
  172. parentRect?.top +
  173. (showDomRect.height + fHeight / 2) +
  174. musicContent?.scrollTop
  175. ).toFixed(2) + 'px';
  176. // }
  177. // console.log({
  178. // parentRectWidth: parentRect?.width,
  179. // firstRectLeft: x,
  180. // parentRectLeft: parentRect?.left,
  181. // parentRectStatus:
  182. // parentRect?.width - (x - parentRect?.left) > showDomRect.width,
  183. // diff: parentRect?.width - (x - parentRect?.left),
  184. // showDomRect: showDomRect.width
  185. // });
  186. if (parentRect?.width - (x - parentRect?.left) > showDomRect.width) {
  187. // 判断是否选择到最右边 超出边界
  188. showDom.style.left = (x - parentRect?.left).toFixed(2) + 'px';
  189. showDom.style.right = 'auto';
  190. } else {
  191. showDom.style.right = '0px';
  192. showDom.style.left = 'auto';
  193. }
  194. }
  195. }
  196. };
  197. /** 开始朗读 */
  198. const onTextStart = () => {
  199. onCloseSpeak();
  200. onSpeak({
  201. startIndex: state.selectOptions.startIndex,
  202. anchorOffset: state.selectOptions.anchorOffset
  203. });
  204. };
  205. /** 只读这段 */
  206. const onTextReadOnly = () => {
  207. onCloseSpeak();
  208. onSpeak(state.selectOptions);
  209. };
  210. function clearSelection() {
  211. if (window.getSelection) {
  212. window.getSelection()?.removeAllRanges(); // 清除选中区域
  213. } else if ((document as any).selection) {
  214. (document as any).selection.empty(); // 清除选中区域(IE 8 或更早)
  215. }
  216. state.showDom = false;
  217. // state.selectOptions.startIndex = 0;
  218. // state.selectOptions.anchorOffset = 0;
  219. // state.selectOptions.endIndex = 0;
  220. // state.selectOptions.focusOffset = 0;
  221. }
  222. /** 关闭朗读 */
  223. const onCloseSpeak = () => {
  224. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  225. // @ts-ignore
  226. // responsiveVoice.cancel();
  227. state.synth?.cancel();
  228. state.isSpeak = false;
  229. const textContainer: any = document.querySelector(_musicContent);
  230. const sentences: any = textContainer?.querySelectorAll('label.speak-label');
  231. sentences?.forEach((sentence: any, i: number) => {
  232. sentence.classList.toggle('highlight', i === -1);
  233. });
  234. clearSelection();
  235. };
  236. /** 全文朗读 */
  237. const onAllSpeak = () => {
  238. clearSelection();
  239. try {
  240. onSpeak({
  241. startIndex: 0
  242. });
  243. } catch (e: any) {
  244. console.log(e, '12');
  245. }
  246. };
  247. // 开始播放
  248. const onSpeak = (options: {
  249. startIndex: number;
  250. endIndex?: number;
  251. anchorOffset?: number;
  252. focusOffset?: number;
  253. }) => {
  254. const textContainer: any = document.querySelector(_musicContent);
  255. const sentences: any = textContainer?.querySelectorAll('label.speak-label');
  256. // console.log(options, '--endIndex');
  257. let currentSentenceIndex = options.startIndex || 0;
  258. const end =
  259. options.endIndex === undefined ? sentences.length - 1 : options.endIndex;
  260. // 高亮显示
  261. const highlightSentence = (index: number) => {
  262. sentences?.forEach((sentence: any, i: number) => {
  263. sentence.classList.toggle('highlight', i === index);
  264. });
  265. // 滚动到高亮的部分
  266. const highlightText = textContainer?.querySelector('.highlight');
  267. // console.log(highlightText, 'highlight');
  268. highlightText?.scrollIntoView({
  269. behavior: 'smooth',
  270. block: 'center'
  271. });
  272. };
  273. // 开始播放
  274. const speaker = () => {
  275. try {
  276. state.synth = window.speechSynthesis;
  277. // 获取可用的 voice 列表
  278. // const voices = speechSynthesis.getVoices();
  279. // 选择一个特定的 voice
  280. // const voice = voices.find(voice => voice.lang === 'zh-CN');
  281. // console.log(voice, 'voice');
  282. // 如果当前正在播放,先暂停
  283. if (state.synth.speaking) {
  284. state.synth.cancel(); // 取消当前播放
  285. }
  286. let sentence = sentences[currentSentenceIndex].textContent;
  287. if (sentence.length <= 0) {
  288. console.error('暂无播放内容');
  289. return;
  290. }
  291. // 判断是否为选中的内容播放
  292. if (
  293. options.startIndex === options.endIndex &&
  294. options.endIndex !== undefined
  295. ) {
  296. sentence = sentence.substr(
  297. options.anchorOffset,
  298. (options.focusOffset || 0) - (options.anchorOffset || 0)
  299. );
  300. } else {
  301. if (options.startIndex === currentSentenceIndex) {
  302. sentence = sentence.substr(options.anchorOffset, sentence.length);
  303. }
  304. if (options.endIndex === currentSentenceIndex) {
  305. sentence = sentence.substr(0, options.focusOffset);
  306. }
  307. }
  308. const replaceText = ['长笛', '曲'];
  309. const afterReplaceText = ['尝笛', '取'];
  310. if (sentence) {
  311. replaceText.forEach((item: string, index: number) => {
  312. if (sentence.includes(item)) {
  313. const regex = new RegExp(item, 'g');
  314. sentence = sentence.replace(regex, afterReplaceText[index]);
  315. }
  316. });
  317. }
  318. console.log(sentence, currentSentenceIndex, end, '---------');
  319. const utterance = new SpeechSynthesisUtterance(sentence);
  320. utterance.lang = 'zh-CN';
  321. utterance.volume = 1;
  322. utterance.rate = 0.8; // 语速 0.1到10
  323. utterance.pitch = 1.5; // 范围从0(最小)到2(最大)
  324. // utterance.text = sentence;
  325. if (utterance) {
  326. utterance.onstart = null;
  327. utterance.onend = null;
  328. utterance.onerror = null;
  329. }
  330. // console.log(sentence, utterance);
  331. utterance.onstart = () => {
  332. state.isSpeak = true;
  333. highlightSentence(currentSentenceIndex);
  334. };
  335. utterance.onend = () => {
  336. console.log('朗读结束');
  337. currentSentenceIndex++;
  338. if (currentSentenceIndex <= end && state.isSpeak) {
  339. speaker(); // 继续下一个句子
  340. } else {
  341. currentSentenceIndex = 0; // 结束后重置索引
  342. highlightSentence(-1); // 清除高亮
  343. state.isSpeak = false;
  344. }
  345. };
  346. utterance.onerror = () => {
  347. currentSentenceIndex++;
  348. if (currentSentenceIndex <= end && state.isSpeak) {
  349. speaker(); // 继续下一个句子
  350. } else {
  351. state.isSpeak = false;
  352. }
  353. };
  354. setTimeout(() => {
  355. state.synth.speak(utterance);
  356. }, 80);
  357. } catch (e) {
  358. console.log(e, 'e');
  359. }
  360. // 事件监听(如果需要)
  361. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  362. // @ts-ignore
  363. // responsiveVoice.speak(sentence, 'Chinese Male', {
  364. // onstart: () => {
  365. // console.log('开始朗读');
  366. // state.isSpeak = true;
  367. // highlightSentence(currentSentenceIndex);
  368. // },
  369. // onend: () => {
  370. // console.log('朗读结束');
  371. // currentSentenceIndex++;
  372. // if (currentSentenceIndex <= end && state.isSpeak) {
  373. // speaker(); // 继续下一个句子
  374. // } else {
  375. // currentSentenceIndex = 0; // 结束后重置索引
  376. // highlightSentence(-1); // 清除高亮
  377. // state.isSpeak = false;
  378. // }
  379. // },
  380. // onerror: (e: any) => {
  381. // console.error('朗读错误:', e);
  382. // currentSentenceIndex++;
  383. // if (currentSentenceIndex <= end && state.isSpeak) {
  384. // speaker(); // 继续下一个句子
  385. // } else {
  386. // state.isSpeak = false;
  387. // }
  388. // }
  389. // });
  390. };
  391. speaker();
  392. };
  393. onMounted(async () => {
  394. document.addEventListener('mouseup', getSelectText);
  395. document.addEventListener('touchend', getSelectText);
  396. });
  397. onUnmounted(() => {
  398. document.removeEventListener('mouseup', getSelectText);
  399. document.addEventListener('touchend', getSelectText);
  400. onCloseSpeak();
  401. });
  402. return {
  403. ...toRefs(state),
  404. onAllSpeak,
  405. onTextStart,
  406. onCloseSpeak,
  407. onTextReadOnly,
  408. processNode
  409. };
  410. };