textModal.tsx 19 KB

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