qwTextModal.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { Button, Input, App, Modal, Popover, Radio, Space } from 'antd'
  2. import React, { useCallback, useEffect, useRef, useState } from 'react'
  3. import Expression from '@/components/Expression'
  4. import styles from './text.less'
  5. import sanitizeHtml from 'sanitize-html';
  6. import { RadioChangeEvent } from 'antd/lib/radio';
  7. type Props = {
  8. visible: boolean,
  9. onCancel: () => void,
  10. onOK: (props: any, type?: number) => void
  11. defaultData?: any,
  12. hdLink?: boolean,
  13. fansName?: boolean,
  14. }
  15. const sanitizeConf: sanitizeHtml.IOptions = {
  16. allowedTags: ['br', 'a'],
  17. allowedAttributes: { a: ["href", "_href", "data-miniprogram-appid", "data-miniprogram-path"] },
  18. allowedSchemes: ['http', 'https', 'weixin']
  19. };
  20. /**文本弹窗 */
  21. const WxTextModal = React.memo((props: Props) => {
  22. const { message } = App.useApp()
  23. const { visible, onCancel, onOK, fansName = true, hdLink = true } = props
  24. const [value, setValue] = useState<string>('')//存放连接文字
  25. const [content, setContent] = useState<string>('')//存放发送文字
  26. const [isShow, setIsShow] = useState<boolean>(false)//互动弹窗
  27. const [range, setRange] = useState<Range>()//丢失焦点存放焦点位置
  28. const [ref, setRef] = useState<any>(null)//存放编辑框的实例
  29. const [aLink, setAlink] = useState<string>('')//存放页面链接
  30. const [appID, setAppID] = useState<string>('')//存放小程序APPID
  31. const [path, setPath] = useState<string>('')//存放小程序路径
  32. const [userId, setUserId] = useState<any>(2)//小程序路径插入userID
  33. const [textData, setTextData] = useState<any>({ range: null, left: -100, top: -100, text: '' })
  34. const [isLink, setIsLink] = useState<boolean>(false)
  35. const [isWxLink, setIsWxLink] = useState<boolean>(false)
  36. const [isHtml, setIsHtml] = useState<boolean>(false)
  37. const text = useRef('');
  38. const [phoneType, setPhoneType] = useState<1 | 2 | 3 | 4>(3)
  39. /**
  40. * 发送处理
  41. */
  42. const callback = useCallback(() => {
  43. if (ref.innerHTML) {
  44. console.log(ref.innerHTML)
  45. let textContent: any = sanitizeHtml(ref.innerHTML, sanitizeConf);
  46. console.log('textContent====>', textContent)
  47. textContent = textContent.replace(/&quot;/ig, '"')
  48. .replace(/&amp;/ig, '&')
  49. .replace(/&lt;/ig, '<')
  50. .replace(/&gt;/ig, '>')
  51. .replace(/<\s+/g, '<')
  52. // .replace(/[\f\n\r\t\v]/g, '<br/>')//将回车变成br
  53. .replace(/\&nbsp;(<br>)?/g, '')
  54. .replace(/<[/]?myspan[^>]*>/ig, '#')
  55. .replace(/#<br>/g, '#')
  56. .replace(/<([a-z]+?)(?:\s+?[^>]*?)?>[\s(<br>)]*?<\/\1>/ig, '<br/>')//替换所有空标签或空标签带<br>的标签为<br>
  57. .replace(/(\b<div>(<br>)?)|((<br>)?<div>)/ig, '<br/>')//<div>or<br><div>or<div><br>转br
  58. .replace(/<\/div>/ig, '')//</div>转‘’
  59. .replace(/_href="\s+/ig, '_href="')//清除href头部空格
  60. .replace(/href=weixin/ig, 'href="weixin')
  61. .replace(/msgmenuid="/ig, 'msgmenuid= "')
  62. .replace(/"=""/ig, '')
  63. // .replace(/\s+"/ig, '"')//清除href尾部空格
  64. .split('<br/>')
  65. onOK({
  66. textContent: textContent, indexId: props.defaultData?.indexId //textContent.filter((str)=> str!=='') 注释保留空格和换行
  67. }, 4)
  68. } else {
  69. alert('元素获取失败请复制内容刷新页面')
  70. message.error('请输入文字')
  71. }
  72. handleClose()
  73. }, [ref])
  74. /**
  75. * 互动连接文字
  76. */
  77. const textChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
  78. let v = e.target.value
  79. setValue(v)
  80. }, [])
  81. /**
  82. * 互动发送文本
  83. */
  84. const contentChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  85. let v = e.target.value
  86. setContent(v)
  87. }, [])
  88. /**
  89. * 网页小程序连接地址
  90. */
  91. const aLinkChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  92. let v = e.target.value
  93. setAlink(v)
  94. }, [])
  95. /**
  96. * 小程序路径
  97. */
  98. const pathChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  99. let v = e.target.value
  100. setPath(v)
  101. }, [])
  102. /**
  103. * 小程序ID
  104. */
  105. const appIDChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  106. let v = e.target.value
  107. setAppID(v)
  108. }, [])
  109. /**
  110. * 插入表情
  111. */
  112. const getStr = useCallback((str: string, range: Range) => {
  113. if (range) {
  114. let selection = window.getSelection()
  115. selection?.empty()//清空选择range
  116. selection?.addRange(range)//插入新的range
  117. document.execCommand('insertHtml', false, str + '&nbsp;');
  118. }
  119. }, [])
  120. /**
  121. * 光标丢失记录位置
  122. */
  123. const onBlur = useCallback(() => {
  124. try {
  125. let selection = window.getSelection()
  126. let range = selection?.getRangeAt(0)
  127. setRange(range)
  128. } catch (err) {
  129. }
  130. }, [text])
  131. /**
  132. * 插入粉丝昵称
  133. */
  134. const pushName = useCallback(() => {
  135. let selection = window.getSelection()
  136. selection?.empty()//清空选择range
  137. selection?.addRange(range as Range)//插入新的range
  138. document.execCommand('insertHtml', false, `#粉丝昵称#`);
  139. selection?.collapseToEnd()//光标插入到末尾
  140. }, [range])
  141. /**
  142. * 插入互动链
  143. */
  144. const pushLink = useCallback(() => {
  145. if (!value || !content) {
  146. setIsShow(false)
  147. return
  148. }
  149. let selection = window.getSelection()
  150. selection?.empty()//清空选择range
  151. selection?.addRange(range as Range)//插入新的range
  152. if (phoneType === 4) {
  153. document.execCommand('insertHtml', false, `<a href="a href=weixin://bizmsgmenu?msgmenucontent=${content}&msgmenuid=+++">${value}</a>`);
  154. } else {
  155. document.execCommand('insertHtml', false, `<a href='weixin://${phoneType === 1 ? 'kefumenu' : 'bizmsgmenu'}?${phoneType === 1 ? `kefumenucontent=${content}` : `msgmenucontent=${content}`}&${phoneType === 1 ? 'kefumenuid=0' : phoneType === 2 ? `msgmenuid= ` : 'msgmenuid=0'}' >${value}</a>`);
  156. }
  157. selection?.collapseToEnd()//光标插入到末尾
  158. setValue('')
  159. setContent('')
  160. setIsShow(false)
  161. }, [range, value, content, phoneType])
  162. /**
  163. * 初始光标选中
  164. */
  165. useEffect(() => {
  166. console.log('ref')
  167. if (ref) {
  168. ref?.focus()
  169. }
  170. }, [ref])
  171. //复制只取纯文本
  172. function textPaste(event: any) {
  173. event.preventDefault();
  174. let text;
  175. let clp = (event.originalEvent || event).clipboardData;
  176. // 兼容chorme或hotfire
  177. text = (clp.getData('text/plain') || clp.getData('text'))
  178. .replace(/&quot;/ig, '"')
  179. .replace(/&amp;/ig, '&')
  180. .replace(/&lt;/ig, '<')
  181. .replace(/&gt;/ig, '>')
  182. .replace(/<\s+/g, '<')
  183. .replace(/href="weixin/ig, 'href=weixin')
  184. text = sanitizeHtml(text, sanitizeConf) || "";
  185. if (text !== "") {
  186. document.execCommand('insertHtml', false, text);
  187. }
  188. }
  189. /**
  190. * 编辑默认内容写入
  191. */
  192. useEffect(() => {
  193. console.log('编辑默认内容写入', props?.defaultData?.textContent)
  194. if (props?.defaultData?.textContent && ref) {
  195. let text = '';
  196. if (Array.isArray(props?.defaultData?.textContent)) {
  197. props?.defaultData?.textContent?.map((key: any, index: number) => {
  198. key = key.replace(/href="weixin/ig, 'href=weixin')
  199. if (key.indexOf('&lt;') !== -1) {//假如存在就是原文
  200. setIsHtml(true)
  201. }
  202. text += index !== props?.defaultData?.textContent?.length - 1 ? `${key}<br/>` : key
  203. })
  204. } else {
  205. text = `${props?.defaultData?.textContent}`
  206. }
  207. document.execCommand('insertHtml', true, text);
  208. }
  209. }, [ref])
  210. /**切换文本原文 */
  211. const onHtml = useCallback(() => {
  212. let str: string = ref.innerHTML
  213. str = str.replace(/&quot;/ig, '"').replace(/&amp;/ig, '&').replace(/&lt;/ig, '<').replace(/&gt;/ig, '>').replace(/<\s+/g, '<').replace(/""/ig, '"').replace(/"="/ig, '')
  214. if (isHtml) {
  215. console.log('str1', str)
  216. setIsHtml(false)
  217. ref.innerHTML = ''
  218. ref.innerHTML = str
  219. } else {
  220. console.log('str2', str)
  221. setIsHtml(true)
  222. ref.innerHTML = ''
  223. ref.innerText = str.replace(/<br[\/]?>/ig, '\n').replace(/&nbsp;/ig, ' ')
  224. }
  225. }, [ref, text, isHtml])
  226. //文本选中
  227. let handleSelectText = useCallback((event: React.SyntheticEvent<HTMLPreElement, Event>) => {
  228. let selection = window.getSelection ? window.getSelection() : (document.getSelection ? document.getSelection() : ((document as any)?.selection ? (document as any)?.selection.createRange().text : ""))
  229. let text = selection.toString() || selection.text
  230. if (text) {//存在文本弹窗
  231. let range = selection?.getRangeAt(0)
  232. let { top, left } = range?.getBoundingClientRect()
  233. setTextData({ range, top, left, text })
  234. } else {
  235. handleClose()//关闭弹窗
  236. }
  237. }, [])
  238. //点击设置连接转换输入,清空连接处理
  239. let handleLink = useCallback((type: number) => {
  240. console.log('点击设置连接转换输入,清空连接处理')
  241. switch (type) {
  242. case 1:
  243. setIsLink(true)
  244. break;
  245. case 2:
  246. setIsWxLink(true)
  247. break;
  248. default:
  249. let selection = window.getSelection()
  250. selection?.empty()//清空选择range
  251. selection?.addRange(textData?.range)//插入新的range
  252. document.execCommand('unlink')//清除连接
  253. selection?.collapseToEnd()//光标插入到末尾
  254. handleClose()//关闭弹窗
  255. break
  256. }
  257. }, [textData])
  258. //清空数据并关闭弹窗
  259. let handleClose = useCallback(() => {
  260. console.log('清空数据并关闭弹窗')
  261. setTextData({ range: null, left: -100, top: -100, text: '' })
  262. setIsWxLink(false)
  263. setIsLink(false)
  264. setAppID('')
  265. setAlink('')
  266. setPath('')
  267. }, [])
  268. //ok插入连接数据
  269. let handleOk = useCallback(() => {
  270. let selection = window.getSelection()
  271. selection?.empty()//清空选择range
  272. selection?.addRange(textData?.range)//插入新的range
  273. if (isWxLink) {
  274. if (aLink && appID && path) {
  275. let Apath = path
  276. if (userId) {
  277. if (userId === 1 && path && !path.includes('#USER_ID#')) {
  278. Apath = Apath + '#USER_ID#'
  279. } else if (userId === 2 && path && !path.includes('#QC_USER_ID#')) {
  280. Apath = Apath + '#QC_USER_ID#'
  281. }
  282. }
  283. document.execCommand('insertHTML', true, `<a href='${aLink}' data-miniprogram-appid='${appID}' data-miniprogram-path='${Apath}' >${textData?.text}</a>`);//插入连接
  284. selection?.collapseToEnd()
  285. handleClose()
  286. } else {
  287. message.error('请填写完整')
  288. }
  289. } else {
  290. if (aLink && aLink.search(/http[s]?:\/\//ig) !== -1) {//
  291. document.execCommand('createLink', false, aLink);//插入连接
  292. selection?.collapseToEnd()
  293. handleClose()
  294. } else {
  295. message.error('请填入正确的连接')
  296. }
  297. }
  298. }, [textData, aLink, appID, path, isWxLink, range, userId])
  299. //处理选中的文本
  300. return <Modal
  301. title='编辑文字内容'
  302. open={visible}
  303. width={1100}
  304. onCancel={() => {
  305. handleClose()
  306. text.current = ''
  307. onCancel()
  308. }}
  309. onOk={callback}
  310. destroyOnClose
  311. >
  312. <div className={styles.box}>
  313. <div className={styles.header}>
  314. <Space>
  315. <Expression getStr={getStr} range={range as Range} />
  316. {
  317. // fansName && <Button size='small' onClick={pushName}>粉丝昵称</Button>
  318. }
  319. {
  320. hdLink && <Popover
  321. placement="right"
  322. title={'新建互动链'}
  323. visible={isShow}
  324. onVisibleChange={(visible) => { setIsShow(visible) }}
  325. content={<div className={styles.popover}>
  326. <div>
  327. <label>连接文字:</label>
  328. <Input.TextArea rows={2} placeholder='互动链显示的文字' onChange={textChange} value={value} />
  329. </div>
  330. <div>
  331. <label>点击发送:</label>
  332. <Input placeholder='粉丝点击互动链后,自动向公众号发送的消息' onChange={contentChange} value={content} />
  333. </div>
  334. <div>
  335. <label>系&nbsp;&nbsp;统:</label>
  336. <Radio.Group
  337. onChange={(e: RadioChangeEvent) => {
  338. setPhoneType(e.target.value)
  339. }}
  340. value={phoneType}
  341. >
  342. <Radio value={3}>安卓</Radio>
  343. {/* <Radio value={1}>安卓1</Radio>
  344. <Radio value={4}>安卓2</Radio>
  345. <Radio value={2}>苹果</Radio> */}
  346. </Radio.Group>
  347. </div>
  348. <div>
  349. <label>提&nbsp;&nbsp;示:</label>
  350. {
  351. phoneType === 1 ?
  352. <strong style={{ color: 'red' }}>
  353. 8.0.9——8.0.11(最新版本),共3个版本均可激活48小时互动!<br />
  354. 8.0.7及以下版本,点击新蓝链会跳转空页面,无法激活!<br />
  355. iOS用户点击新蓝链,系统无法模拟用户回复消息!!!
  356. </strong>
  357. :
  358. phoneType === 3 ?
  359. <strong>
  360. 原来的篮字方式无法激活48小时互动但所有机型通用
  361. </strong>
  362. : phoneType === 4 ? <strong>
  363. 安卓用户可以看到,ios看不到
  364. </strong> : <strong>
  365. 只对IOS有效
  366. </strong>
  367. }
  368. </div>
  369. <Button onClick={pushLink} type='primary'>确定</Button>
  370. </div>}
  371. trigger="click"
  372. >
  373. <Button size='small' >互动链</Button>
  374. </Popover>
  375. }
  376. <Button size='small' onClick={onHtml}>文本转译</Button>
  377. </Space>
  378. </div>
  379. <pre
  380. className={styles.editable} //样式
  381. onBlur={onBlur}//焦点丢失
  382. contentEditable="true"
  383. dangerouslySetInnerHTML={{ __html: text.current }}
  384. onSelect={handleSelectText}//选中事件
  385. ref={(ref: any) => { setRef(ref) }}
  386. onKeyDown={(e: React.KeyboardEvent<HTMLPreElement>) => {
  387. if (e.key === 'Enter') {
  388. document.execCommand('insertHTML', false, '\n')
  389. e.preventDefault()
  390. }
  391. }}
  392. onKeyUp={(e: React.KeyboardEvent<HTMLPreElement>) => {
  393. if (e.key === 'Enter') {
  394. e.preventDefault()
  395. }
  396. }}
  397. onPaste={textPaste}
  398. />
  399. </div>
  400. <div
  401. className={styles.fixed_pop}
  402. style={{ left: textData?.left, top: textData?.top + 30, display: textData.left > 0 ? 'flex' : '' }}
  403. >
  404. <>
  405. {isLink && <div className={styles.fixed_pop_link}>
  406. <Input placeholder='输入链接,以http://或https://开头' onChange={aLinkChange} />
  407. <a onClick={handleOk}>✓</a>
  408. <a onClick={handleClose}>×</a>
  409. </div>}
  410. {
  411. isWxLink && <div className={styles.fixed_pop_wx}>
  412. <span>
  413. <Input placeholder='填写小程序AppID,跳转小程序需与当前公众号绑定关联关系' onChange={appIDChange} />
  414. <Input placeholder='填写小程序路径,例如:pages/index' onChange={pathChange} />
  415. <Input placeholder='备用网页,以http://或https://开头' onChange={aLinkChange} />
  416. <div className={styles.fixed_pop_wx_radio}>
  417. <span>路径插入用户:</span>
  418. <Radio.Group onChange={(e: any) => { setUserId(Number(e.target.value)) }} value={userId} >
  419. <Radio value={1}>普通用户</Radio>
  420. <Radio value={2}>趣程用户</Radio>
  421. <Radio value={0}>否</Radio>
  422. </Radio.Group>
  423. </div>
  424. </span>
  425. <span>
  426. <a onClick={handleOk}>✓</a>
  427. <a onClick={handleClose}>×</a>
  428. </span>
  429. </div>
  430. }
  431. {!isLink && !isWxLink && <>
  432. <span onClick={() => { handleLink(1) }}>设置连接</span>
  433. <span onClick={() => { handleLink(2) }}>设置小程序</span>
  434. <span onClick={() => { handleLink(3) }}>清空连接</span>
  435. </>
  436. }
  437. </>
  438. </div>
  439. </Modal >
  440. })
  441. export default WxTextModal