useSpeak.ts 17 KB

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