editor-inner.js 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006
  1. import { useFormContext } from 'react-hook-form'
  2. import { useState, useRef, useEffect, useMemo } from 'react'
  3. import { View, Pressable } from 'app/design/view'
  4. import {
  5. useEditorBridge,
  6. RichText,
  7. darkEditorTheme,
  8. TenTapStartKit,
  9. LinkBridge,
  10. CodeBridge,
  11. useEditorContent,
  12. ImageBridge,
  13. DropCursorBridge,
  14. PlaceholderBridge,
  15. } from '@10play/tentap-editor'
  16. import { EditorToolbar } from 'app/lib/editor/editor-toolbar'
  17. import { useTentapToolbar } from 'app/lib/editor/editor-toolbar-tentap'
  18. import { Platform } from 'react-native'
  19. import { useThemeName, useThemeValue } from 'app/design/theme'
  20. import { stripTags, appSetting, cn } from 'app/lib/util'
  21. import { commitEditorHtml, normalizeEditorHtml } from 'app/lib/editor/editor-form'
  22. import { useEditorFormField } from 'app/lib/form/use-form-field'
  23. import emitter, { EVENTS } from 'app/context/emitter'
  24. import { TextInput } from 'react-native'
  25. import { useEditorMentions } from 'app/lib/editor/use-editor-mentions'
  26. import { MentionSuggestionsDropdown } from 'app/lib/editor/mention-suggestions-dropdown'
  27. import { buildUnaMentionHtml } from 'app/lib/editor/editor-mention-shared'
  28. import { useIsDesktop } from 'app/context/measure'
  29. import { getEditorPasteImagesMode } from 'app/lib/editor/editor-paste-images'
  30. const inputSettings = appSetting('theme', 'inputs');
  31. export default function RftText(props) {
  32. const {
  33. value = '',
  34. initialHeight = 120,
  35. maxHeight = 300,
  36. onFocus,
  37. html,
  38. bg,
  39. enableSubmitOnEnter = false,
  40. placeholder,
  41. form_name,
  42. container_class,
  43. kb_stay_open,
  44. onEnterSubmit,
  45. disabled,
  46. classes,
  47. autofocus
  48. } = props
  49. const { name, field, readOnly } = useEditorFormField(props)
  50. const { resetField, formState } = useFormContext()
  51. const isDisabled = !!(disabled || readOnly)
  52. const isWeb = Platform.OS === 'web'
  53. const isDesktop = useIsDesktop()
  54. const unicFormName = `${form_name}` // for catch images in editor
  55. const isToolBar = html == 2 || html == 1
  56. // UNA html=3: rich editor, no toolbar. Images paste into attachments, not <img>.
  57. const isLimitedHtml = html == 3
  58. const suggestionsHeight = 130
  59. const [inputKey, setInputKey] = useState(0);
  60. // Shared mention pipeline (same fetch/recents/UI as enriched).
  61. const {
  62. suggestions,
  63. setTrigger,
  64. clearTrigger,
  65. moveSelected,
  66. selectedSuggestion,
  67. recordMention,
  68. queryToken,
  69. } = useEditorMentions({ fieldName: name, mentions: props.mentions })
  70. // Layout coords from iframe mention messages: [left, bottom]
  71. const [mentionCoords, setMentionCoords] = useState([0, 0])
  72. const [editorHeight, setEditorHeight] = useState(initialHeight)
  73. const [isEnter, setIsEnter] = useState(false)
  74. const [suggestionsSize, setSuggestionsSize] = useState([0, 0])
  75. const isCommentsEditor = container_class === 'comments'
  76. // text-base (16px) — inputs below 16px trigger mobile web zoom on focus
  77. const editorFontSize = '16px'
  78. const editorLineHeight = '24px'
  79. // Match published feed font (Inter via --font-main) so the editor looks identical to posts.
  80. const editorFontFamily =
  81. 'var(--font-main, "Inter", "Inter Variable", "InterVariable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif)'
  82. const themeName = useThemeName() || 'light'
  83. const editorPalette = {
  84. light: {
  85. text: 'rgba(30, 40, 55, 1)',
  86. background: 'rgba(255, 255, 255, 1)',
  87. },
  88. dark: {
  89. text: 'rgba(225, 230, 240, 1)',
  90. background: 'rgba(15, 25, 40, 1)',
  91. },
  92. }
  93. const editorTextColor = useThemeValue(editorPalette.light.text, editorPalette.dark.text)
  94. const buildEditorCSS = (mode) => {
  95. const isDark = mode === 'dark'
  96. const cssOverrides = isDark
  97. ? appSetting('editor', 'css_dark')
  98. : appSetting('editor', 'css')
  99. return `
  100. /* Load Inter inside the editor iframe to match published posts */
  101. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
  102. :root {
  103. --editor-font: ${editorFontFamily};
  104. --color-text-light: ${editorPalette.light.text};
  105. --color-text-dark: ${editorPalette.dark.text};
  106. --color-background-light: ${editorPalette.light.background};
  107. --color-background-dark: ${editorPalette.dark.background};
  108. --color-text: ${isDark ? editorPalette.dark.text : editorPalette.light.text};
  109. --color-background: ${isDark ? editorPalette.dark.background : editorPalette.light.background};
  110. }
  111. @media (prefers-color-scheme: dark) {
  112. :root {
  113. --color-text: var(--color-text-dark);
  114. --color-background: var(--color-background-dark);
  115. }
  116. }
  117. html, body, *, *::before, *::after {
  118. font-family: var(--editor-font) !important;
  119. color: var(--color-text);
  120. }
  121. html, body {
  122. overflow: hidden !important;
  123. margin: 0;
  124. padding: 0;
  125. height: 100%;
  126. overscroll-behavior: none;
  127. }
  128. body {
  129. font-size: ${editorFontSize};
  130. line-height: ${editorLineHeight};
  131. color: var(--color-text);
  132. background-color: transparent;
  133. white-space: pre-wrap;
  134. word-wrap: break-word;
  135. overflow-wrap: break-word;
  136. }
  137. img {
  138. max-width: 100%;
  139. height: auto;
  140. display: block;
  141. border-radius: 8px;
  142. }
  143. body P, body p {
  144. margin-bottom: 12px;
  145. margin-top: 12px;
  146. font-family: inherit !important;
  147. color: var(--color-text);
  148. }
  149. body P:first-child, body p:first-child {
  150. margin-top: 0px;
  151. }
  152. body P:last-child, body p:last-child {
  153. margin-bottom: 0px;
  154. }
  155. .is-editor-empty:first-child::before {
  156. float: none !important;
  157. position: fixed;
  158. }
  159. .ProseMirror, .tiptap, .ProseMirror p, .tiptap p, .ProseMirror *, .tiptap * {
  160. font-family: var(--editor-font) !important;
  161. color: var(--color-text);
  162. background-color: transparent;
  163. box-sizing: border-box;
  164. padding: 0 !important;
  165. margin: 0 !important;
  166. }
  167. .ProseMirror, .tiptap {
  168. cursor: text;
  169. }
  170. .ProseMirror a, .tiptap a {
  171. cursor: pointer;
  172. }
  173. .mention-list {
  174. position: absolute;
  175. background: var(--color-background);
  176. border: 1px solid #ccc;
  177. list-style: none;
  178. padding: 5px;
  179. margin: 0;
  180. max-height: 150px;
  181. overflow-y: auto;
  182. color: var(--color-text);
  183. }
  184. .mention-list li {
  185. padding: 5px;
  186. cursor: pointer;
  187. }
  188. .mention-list li:hover,
  189. .mention-list li.active {
  190. background: lightblue;
  191. }
  192. ${cssOverrides}
  193. .tiptap, #root > div:nth-of-type(1) {
  194. scrollbar-width: none;
  195. overflow: hidden !important;
  196. }
  197. .tiptap, #root > div:nth-of-type(1):focus-within {
  198. scrollbar-width: auto;
  199. overflow-y: auto !important;
  200. }
  201. .ProseMirror.tiptap{
  202. margin-right:20px;
  203. }
  204. .ProseMirror-focused.tiptap{
  205. margin-right:0px;
  206. }
  207. /*#root, #root > div {
  208. overflow: hidden !important;
  209. }
  210. .tiptap::-webkit-scrollbar, #root > div:nth-of-type(1)::-webkit-scrollbar {
  211. display: none;
  212. width: 0;
  213. height: 0;
  214. }*/
  215. .ProseMirror.tiptap {
  216. scrollbar-width: none;
  217. height: auto !important;
  218. /*overflow: visible !important;*/
  219. min-height: auto !important;
  220. }
  221. `
  222. }
  223. const wheelEventForwarder = `
  224. (function() {
  225. if (window.__neoModalWheelBound) return;
  226. window.__neoModalWheelBound = true;
  227. window.addEventListener('wheel', function(e) {
  228. if (window.parent === window) return;
  229. e.preventDefault();
  230. e.stopPropagation();
  231. window.parent.postMessage(JSON.stringify({
  232. type: 'neo-modal-wheel',
  233. deltaY: e.deltaY,
  234. deltaX: e.deltaX,
  235. deltaMode: e.deltaMode,
  236. }), '*');
  237. }, { passive: false, capture: true });
  238. })();
  239. `
  240. const applyIframeTheme = (mode) => {
  241. const css = JSON.stringify(buildEditorCSS(mode))
  242. const themeAttr = mode === 'dark' ? 'dark' : 'light'
  243. return `
  244. (function() {
  245. const css = ${css};
  246. const styleId = 'neo-editor-font-style';
  247. const headEl = document.head || document.getElementsByTagName('head')[0];
  248. if (!headEl) return;
  249. let styleTag = document.getElementById(styleId);
  250. if (!styleTag) {
  251. styleTag = document.createElement('style');
  252. styleTag.id = styleId;
  253. headEl.appendChild(styleTag);
  254. }
  255. styleTag.innerHTML = css;
  256. const setThemeAttr = (target) => {
  257. if (target) target.setAttribute('data-theme', '${themeAttr}');
  258. };
  259. setThemeAttr(document.documentElement);
  260. setThemeAttr(document.body);
  261. })();
  262. `
  263. }
  264. // Get the editor settings for toolbar configuration
  265. const editorSettings = appSetting('editor', 'toolbar')
  266. let customCodeBlockCSS = buildEditorCSS(themeName)
  267. if (isLimitedHtml) {
  268. customCodeBlockCSS += `
  269. b, strong, font, u, s, i, em, span, code, h1, h2, h3, h4, h5, h6{
  270. font-weight: normal !important;
  271. font-style: normal !important;
  272. text-decoration: none !important;
  273. color: inherit !important;
  274. background: transparent !important;
  275. }
  276. blockquote{
  277. all: unset;
  278. display: block;
  279. border:none !important;
  280. padding:0 !important;
  281. }
  282. `
  283. }
  284. //TODO FIX ONE SIDE REPLY/ ANOTHER NOT CLEAR TEXT AFTER POST
  285. /*useEffect(() => {
  286. if (editor && (field?.value == '' || field?.value?.startsWith("<!--INITED-->")) && editor.getHTML() != field.value) {
  287. setTimeout(() => {
  288. editor.setContent(field.value.replaceAll("<!--INITED-->", ''))
  289. }, 500);
  290. }
  291. }, [field.value])*/
  292. useEffect(() => {
  293. if (editor && editor.getHTML() != value) {
  294. editor.setContent(value)
  295. }
  296. }, [value])
  297. const baseExtensions = useMemo(() => [
  298. ImageBridge.configureExtension({
  299. inline: false,
  300. allowBase64: false,
  301. }),
  302. DropCursorBridge,
  303. LinkBridge.configureExtension({
  304. HTMLAttributes: {
  305. class: 'bx-mention-link',
  306. },
  307. }),
  308. PlaceholderBridge.configureExtension({
  309. placeholder: placeholder,
  310. }),
  311. CodeBridge.configureCSS(customCodeBlockCSS),
  312. ], [placeholder, customCodeBlockCSS])
  313. // Extract toolbar styling values from settings
  314. const toolbarPadding = editorSettings?.padding || 8
  315. const toolbarColors = editorSettings?.colors || {}
  316. // Light mode colors
  317. const toolbarBgColor = toolbarColors.background || 'rgba(248, 249, 250, 1)'
  318. const toolbarIconColor = toolbarColors.icon || 'rgba(209, 213, 219, 1)'
  319. // Dark mode colors
  320. const toolbarBgColorDark =
  321. toolbarColors.backgroundDark || 'rgba(33, 37, 41, 1)'
  322. const toolbarIconColorDark = toolbarColors.iconDark || 'rgba(75, 85, 99, 1)'
  323. // Check if background colors are transparent and determine active colors
  324. const isLightBgTransparent = toolbarBgColor.includes(', 0)')
  325. const isDarkBgTransparent = toolbarBgColorDark.includes(', 0)')
  326. const lightActiveColor = isLightBgTransparent
  327. ? toolbarIconColor
  328. : toolbarBgColor
  329. const darkActiveColor = isDarkBgTransparent
  330. ? toolbarIconColorDark
  331. : toolbarBgColorDark
  332. // Create theme configurations
  333. const lightTheme = {
  334. toolbar: {
  335. iconWrapper: {
  336. backgroundColor: toolbarIconColor,
  337. borderRadius: 4,
  338. padding: 4,
  339. marginHorizontal: 2,
  340. },
  341. toolbarButton: {
  342. backgroundColor: toolbarBgColor,
  343. paddingHorizontal: toolbarPadding,
  344. borderRadius: 4,
  345. alignItems: 'center',
  346. justifyContent: 'center',
  347. padding: 4,
  348. },
  349. iconWrapperActive: {
  350. backgroundColor: lightActiveColor,
  351. borderRadius: 4,
  352. opacity: isLightBgTransparent ? 0.6 : 0.8,
  353. },
  354. iconWrapperDisabled: {
  355. backgroundColor: toolbarIconColor,
  356. opacity: 0.5,
  357. borderRadius: 4,
  358. },
  359. },
  360. }
  361. const darkThemeCustom = {
  362. toolbar: {
  363. iconWrapper: {
  364. backgroundColor: toolbarIconColorDark,
  365. borderRadius: 4,
  366. padding: 4,
  367. marginHorizontal: 2,
  368. },
  369. toolbarButton: {
  370. backgroundColor: toolbarBgColorDark,
  371. paddingHorizontal: toolbarPadding,
  372. borderRadius: 4,
  373. alignItems: 'center',
  374. justifyContent: 'center',
  375. padding: 4,
  376. },
  377. iconWrapperActive: {
  378. backgroundColor: darkActiveColor,
  379. borderRadius: 4,
  380. opacity: isDarkBgTransparent ? 0.6 : 0.8,
  381. },
  382. iconWrapperDisabled: {
  383. backgroundColor: toolbarIconColorDark,
  384. opacity: 0.5,
  385. borderRadius: 4,
  386. },
  387. },
  388. }
  389. const customEditorTheme = useThemeValue(lightTheme, {
  390. ...darkEditorTheme,
  391. toolbar: {
  392. ...darkEditorTheme.toolbar,
  393. ...darkThemeCustom.toolbar,
  394. },
  395. })
  396. const handleSubmit = () => {
  397. if (onEnterSubmit) {
  398. onEnterSubmit()
  399. }
  400. }
  401. // Filter duplicate extensions to prevent TipTap warnings.
  402. // Drop color/highlight — TenTapStartKit includes ColorBridge (TextStyle), which
  403. // keeps pasted docs colors (`style="color: rgb(…)"`) in saved HTML.
  404. const EXCLUDED_BRIDGES = new Set(['color', 'highlight'])
  405. const allExtensions = [...baseExtensions, ...TenTapStartKit];
  406. const seenNames = new Set();
  407. const uniqueExtensions = allExtensions.filter((ext) => {
  408. const name = ext?.name || ext?.tiptapExtension?.name;
  409. if (name && EXCLUDED_BRIDGES.has(name)) return false;
  410. if (name && seenNames.has(name)) {
  411. return false;
  412. }
  413. if (name) seenNames.add(name);
  414. return true;
  415. });
  416. const editor = useEditorBridge({
  417. autofocus: autofocus,
  418. avoidIosKeyboard: isWeb ? false : autofocus,
  419. dynamicHeight: false, //!!! true not work in IOS if true
  420. theme: customEditorTheme,
  421. initialContent: field.value,
  422. bridgeExtensions: uniqueExtensions,
  423. })
  424. const { items: toolbarItems, linkBar } = useTentapToolbar({
  425. editor,
  426. enabled: isToolBar,
  427. })
  428. const lastAppliedThemeRef = useRef(null)
  429. useEffect(() => {
  430. if (!editor) return
  431. if (lastAppliedThemeRef.current === themeName) return
  432. lastAppliedThemeRef.current = themeName
  433. editor.injectJS(applyIframeTheme(themeName))
  434. // Wheel forwarder is injected once on editor-ready (iframe must be loaded).
  435. }, [editor, themeName])
  436. useEffect(() => {
  437. if (editor && placeholder) {
  438. // TenTap Editor bug: setPlaceholder() does not update the DOM in the iframe
  439. // CSS injection is the only working workaround
  440. const t = setTimeout(() => {
  441. editor.injectCSS(`
  442. .tiptap.ProseMirror p.is-editor-empty:first-child::before {
  443. content: "${placeholder.replace(/"/g, '\\"')}" !important;
  444. }
  445. `, 'placeholder-dynamic')
  446. }, 200);
  447. return () => clearTimeout(t);
  448. }
  449. }, [editor, placeholder])
  450. const openKeyboard = () => {
  451. setInputKey(k => k + 1); // recreate TextInput
  452. };
  453. useEffect(() => {
  454. const subscription = emitter.addListener(EVENTS.editor, (data) => {
  455. if (data.action == 'blur') {
  456. if (data.timeout) {
  457. setTimeout(() => {
  458. editor.blur()
  459. }, data.timeout)
  460. } else {
  461. editor.blur()
  462. }
  463. }
  464. if (data.action == 'focus') {
  465. // Prefer direct bridge focus (no ghost TextInput remount) to avoid
  466. // keyboard bounce. Remount fallback when timeout is set (deep link /
  467. // cold open after dismiss, where focus() alone often no-ops on iOS).
  468. if (data.timeout) {
  469. setTimeout(() => openKeyboard(), data.timeout);
  470. } else {
  471. editor.focus('end');
  472. }
  473. }
  474. if (data.action == 'set_content') {
  475. editor.setContent(data.value)
  476. }
  477. if (data.action == 'insert_inline_image') {
  478. if (data.form_name && form_name && data.form_name !== form_name) return
  479. if (!data.src || isLimitedHtml) return
  480. editor.setImage(data.src)
  481. }
  482. })
  483. //
  484. return () => {
  485. subscription.remove()
  486. }
  487. }, [])
  488. useEffect(() => {
  489. if (formState.isSubmitted && kb_stay_open != true) {
  490. editor?.blur?.()
  491. }
  492. }, [formState.isSubmitted])
  493. const htmlContent = useEditorContent(editor, { type: 'html' })
  494. const isInitialHtmlContent = useRef(true)
  495. useEffect(() => {
  496. if (htmlContent === undefined || htmlContent === null) return
  497. if (stripTags(htmlContent)) {
  498. if (onFocus) onFocus()
  499. }
  500. commitEditorHtml({
  501. next: normalizeEditorHtml(htmlContent, { isLimitedHtml }),
  502. field,
  503. name,
  504. resetField,
  505. isInitialRef: isInitialHtmlContent,
  506. })
  507. }, [htmlContent])
  508. const processImages = (src, formName) => {
  509. let images = []
  510. const fileName = src.split('/').pop() + '.png'
  511. const fileTypeMatch = src.match(/\.([a-z0-9]+)$/i)
  512. const fileType = fileTypeMatch
  513. ? `image/${fileTypeMatch[1]}`
  514. : 'image/png'
  515. images.push({
  516. uri: src,
  517. fileName: fileName,
  518. mimeType: fileType,
  519. })
  520. if (images.length > 0) {
  521. emitter.emit(EVENTS.form(formName), {
  522. action: 'pasted_images',
  523. images,
  524. ...(isLimitedHtml ? { inlinePaste: false } : null),
  525. })
  526. }
  527. }
  528. const insertMention = async (user, query) => {
  529. const html = await editor.getHTML()
  530. const mentionLink = buildUnaMentionHtml(user) + '&shy;'
  531. const replacementStringWithNbsp = mentionLink + '&nbsp;'
  532. const updatedContent = html.replace(query, replacementStringWithNbsp)
  533. editor.setContent(updatedContent)
  534. recordMention(user)
  535. clearTrigger()
  536. }
  537. useEffect(() => {
  538. if (isEnter && suggestions.length > 0) {
  539. const sel = selectedSuggestion || suggestions[0]
  540. if (sel) insertMention(sel, queryToken)
  541. }
  542. setIsEnter(false)
  543. }, [isEnter])
  544. const onMessage = async (event) => {
  545. try {
  546. const message = JSON.parse(event.nativeEvent.data)
  547. if (message?.type == 'paste') {
  548. if (getEditorPasteImagesMode(unicFormName) === 'off') return
  549. processImages(message.payload, message.form_name)
  550. }
  551. if (message?.type == 'height') {
  552. if (message.payload >= initialHeight && message.payload <= maxHeight) {
  553. setEditorHeight(message.payload)
  554. }
  555. if (message.payload < initialHeight) {
  556. setEditorHeight(initialHeight)
  557. }
  558. }
  559. if (message?.type == 'focus') {
  560. if (onFocus) onFocus()
  561. }
  562. if (message?.type == 'blur') {
  563. // console.log('blur')
  564. }
  565. if (message?.type == 'enter') {
  566. setIsEnter(true)
  567. }
  568. if (message?.type === 'requestSubmit') {
  569. if (isDesktop && onEnterSubmit) {
  570. onEnterSubmit()
  571. }
  572. }
  573. if (message?.type === 'requestNewline') {
  574. if (editor && editor.chain) {
  575. editor.chain().focus().setHardBreak().run()
  576. }
  577. }
  578. if (message?.type == 'arrow') {
  579. moveSelected(
  580. ['ArrowDown', 'ArrowRight'].includes(message.payload)
  581. ? 'down'
  582. : 'up'
  583. )
  584. }
  585. if (message?.type == 'mention') {
  586. setTrigger(message.payload, message.sym)
  587. setMentionCoords([message.left, message.bottom])
  588. }
  589. if (message?.type == 'mention_hide') {
  590. clearTrigger()
  591. }
  592. if (message?.type == 'editor-ready') {
  593. if (isWeb) {
  594. editor.injectJS(wheelEventForwarder)
  595. }
  596. const submitOnEnter = isDesktop && (
  597. isCommentsEditor
  598. ? (enableSubmitOnEnter || appSetting('comments', 'submit_comment_on_enter'))
  599. : enableSubmitOnEnter
  600. )
  601. editor.injectJS(`
  602. let formName = "${unicFormName}";
  603. let lastSelectionRange = null;
  604. let mentionVisible = false;
  605. var editorConfig = {
  606. submitOnEnterEnabled: ${!!submitOnEnter},
  607. platformOS: '${Platform.OS}'
  608. };
  609. const editorElement = document.getElementsByClassName("tiptap")[0];
  610. const useParagraphBreakOnShiftEnter = ${isCommentsEditor ? 'true' : 'false'};
  611. editorElement.setAttribute('autocomplete', 'off');
  612. editorElement.setAttribute('autocorrect', 'off');
  613. editorElement.setAttribute('autocapitalize', 'off');
  614. editorElement.setAttribute('spellcheck', 'false');
  615. ${applyIframeTheme(themeName)}
  616. document.addEventListener('keydown', function(event) {
  617. if (event.key === 'Enter' || event.code === 'Enter') {
  618. if (mentionVisible) {
  619. event.preventDefault();
  620. event.stopPropagation();
  621. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'enter' }));
  622. return false;
  623. }
  624. if ((event.shiftKey || event.altKey) && useParagraphBreakOnShiftEnter) {
  625. event.preventDefault();
  626. event.stopPropagation();
  627. insertParagraphBreak();
  628. syncHeightAfterParagraphBreak();
  629. return false;
  630. }
  631. if (editorConfig.submitOnEnterEnabled) {
  632. event.preventDefault();
  633. event.stopPropagation();
  634. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestSubmit' }));
  635. return false;
  636. }
  637. const isModKeyPressed = event.metaKey || event.ctrlKey;
  638. if (isModKeyPressed) {
  639. event.preventDefault();
  640. event.stopPropagation();
  641. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'requestSubmit' }));
  642. return false;
  643. }
  644. } else if (event.key === 'Tab' && mentionVisible) {
  645. event.preventDefault();
  646. event.stopPropagation();
  647. window.ReactNativeWebView.postMessage(JSON.stringify({
  648. type: 'arrow',
  649. payload: event.shiftKey ? 'ArrowUp' : 'ArrowDown'
  650. }));
  651. return false;
  652. } else if (event.key === 'Escape') {
  653. if (mentionVisible) {
  654. event.preventDefault();
  655. event.stopPropagation();
  656. mentionVisible = false;
  657. editorElement.removeAttribute('query');
  658. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'mention_hide' }));
  659. return false;
  660. }
  661. if (window.parent && window.parent !== window) {
  662. window.parent.postMessage(JSON.stringify({ type: 'neo-modal-escape' }), '*');
  663. }
  664. }
  665. }, true);
  666. function insertParagraphBreak() {
  667. editorElement.focus();
  668. document.execCommand('insertParagraph', false, null);
  669. }
  670. function syncHeightAfterParagraphBreak() {
  671. requestAnimationFrame(() => {
  672. updateHeight();
  673. setTimeout(updateHeight, 0);
  674. });
  675. }
  676. function updateHeight() {
  677. requestAnimationFrame(() => {
  678. setTimeout(() => {
  679. const currentHeight = editorElement.scrollHeight;
  680. window.ReactNativeWebView.postMessage(JSON.stringify({
  681. type: 'height',
  682. payload: currentHeight,
  683. }));
  684. }, 0);
  685. });
  686. }
  687. const observer = new MutationObserver(() => {
  688. updateHeight();
  689. });
  690. observer.observe(editorElement, {
  691. childList: true,
  692. subtree: true,
  693. characterData: true // Don't observe character data changes
  694. });
  695. editorElement.addEventListener("blur", () => {
  696. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'blur' }));
  697. const selection = window.getSelection();
  698. if (selection.rangeCount > 0) {
  699. lastSelectionRange = selection.getRangeAt(0).cloneRange();
  700. }
  701. updateHeight(true);
  702. });
  703. editorElement.addEventListener("focus", () => {
  704. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'focus' }));
  705. if (lastSelectionRange) {
  706. const selection = window.getSelection();
  707. selection.removeAllRanges();
  708. selection.addRange(lastSelectionRange);
  709. }
  710. updateHeight(true);
  711. });
  712. function getTextBeforeCursor() {
  713. const selection = window.getSelection();
  714. if (!selection.rangeCount) return "";
  715. const range = selection.getRangeAt(0);
  716. const text = range.startContainer.textContent.substring(0, range.startOffset);
  717. return text.split(" ").pop();
  718. }
  719. editorElement.addEventListener("keydown", function (event) {
  720. if (event.key === "Backspace") {
  721. const selection = window.getSelection();
  722. if (selection.rangeCount === 0) return;
  723. const range = selection.getRangeAt(0);
  724. const node = range.startContainer;
  725. const link = node.nodeType === 3 ? node.parentElement.closest("a") : node.closest("a");
  726. if (link) {
  727. event.preventDefault();
  728. link.remove();
  729. const newRange = document.createRange();
  730. newRange.setStartBefore(link.nextSibling || editorElement);
  731. newRange.collapse(true);
  732. selection.removeAllRanges();
  733. selection.addRange(newRange);
  734. }
  735. }
  736. if ((event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "ArrowLeft" || event.key === "ArrowRight") && mentionVisible) {
  737. window.ReactNativeWebView.postMessage(JSON.stringify({
  738. type: 'arrow',
  739. payload: event.key,
  740. }));
  741. }
  742. });
  743. editorElement.addEventListener("input", function (event) {
  744. const text = getTextBeforeCursor();
  745. const symbol = text.charAt(0);
  746. if (symbol === '@' || symbol === '#') {
  747. const query = text.substring(1).toLowerCase();
  748. editorElement.setAttribute('query', symbol + query);
  749. const selection = window.getSelection();
  750. let range;
  751. let rect;
  752. if (selection.rangeCount > 0) {
  753. range = selection.getRangeAt(0);
  754. rect = range.getBoundingClientRect();
  755. editorElement.setAttribute('queryX', rect.left);
  756. editorElement.setAttribute('queryY', rect.bottom);
  757. }
  758. if (!mentionVisible) {
  759. mentionVisible = true;
  760. }
  761. window.ReactNativeWebView.postMessage(JSON.stringify({
  762. type: 'mention',
  763. payload: query,
  764. sym: symbol,
  765. left:rect.left,
  766. bottom:rect.bottom
  767. }));
  768. } else if (mentionVisible) {
  769. mentionVisible = false;
  770. editorElement.removeAttribute('query');
  771. window.ReactNativeWebView.postMessage(JSON.stringify({
  772. type: 'mention_hide'
  773. }));
  774. }
  775. });
  776. const mentionList = document.createElement("ul");
  777. mentionList.id = "mention-list";
  778. mentionList.className = "mention-list";
  779. mentionList.style.display = "none";
  780. mentionList.style.position = "absolute";
  781. document.body.appendChild(mentionList);
  782. document.addEventListener("click", function (event) {
  783. if (!mentionList.contains(event.target)) {
  784. mentionList.style.display = "none";
  785. editorElement.focus();
  786. }
  787. });
  788. document.addEventListener('click', (event) => {
  789. if (event.target.tagName === 'A') {
  790. event.preventDefault();
  791. }
  792. });
  793. document.addEventListener('paste', (event) => {
  794. if (!event.clipboardData?.items?.length) return;
  795. for (let item of event.clipboardData.items) {
  796. if (item.kind === 'file') {
  797. event.preventDefault();
  798. const file = item.getAsFile();
  799. if (file) {
  800. const reader = new FileReader();
  801. reader.onload = function (e) {
  802. window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'paste', form_name: formName, payload: e.target.result }));
  803. };
  804. reader.readAsDataURL(file);
  805. }
  806. }
  807. }
  808. })`)
  809. }
  810. } catch (error) { }
  811. }
  812. const style = { left: 0 }
  813. const handleLayout = (event) => {
  814. const { width, height, x, y } = event.nativeEvent.layout
  815. setSuggestionsSize([width, height, x, y])
  816. }
  817. if (
  818. mentionCoords[1] - 24 < suggestionsHeight &&
  819. suggestionsSize[1] > suggestionsHeight
  820. ) {
  821. style.top = suggestionsSize[3] + 24
  822. } else {
  823. style.bottom =
  824. suggestionsSize[1] - (mentionCoords[1] > 0 ? mentionCoords[1] - 24 : 0)
  825. }
  826. return (
  827. <View
  828. onLayout={handleLayout}
  829. className={`relative flex-auto ${isToolBar
  830. ? ' px-3 py-2 bg-input/60 shadow-input-outline dark:shadow-input-outline-deep rounded-xl focus:bg-card focus:ring-border flex-auto overflow-hidden placeholder-muted-foreground text-card-foreground web:duration-100 '
  831. : (bg == 'transparent' ? '' : cn(inputSettings.base, inputSettings.size.regular))
  832. }`}
  833. >
  834. <MentionSuggestionsDropdown
  835. suggestions={suggestions}
  836. onSelect={(user) => insertMention(user, queryToken)}
  837. style={style}
  838. />
  839. <Pressable
  840. style={{ height: editorHeight }}
  841. className={isWeb ? 'web:cursor-text' : undefined}
  842. {...(!isWeb && {
  843. onPress: (event) => {
  844. event.stopPropagation()
  845. },
  846. })}
  847. >
  848. <RichText
  849. exclusivelyUseCustomOnMessage={false}
  850. hideKeyboardAccessoryView={true}
  851. style={{
  852. backgroundColor: 'transparent',
  853. color: editorTextColor,
  854. fontFamily: editorFontFamily,
  855. }}
  856. editor={editor}
  857. onMessage={onMessage}
  858. editable={!isDisabled}
  859. scrollEnabled={false}
  860. showsVerticalScrollIndicator={false}
  861. showsHorizontalScrollIndicator={false}
  862. nestedScrollEnabled={false}
  863. editorProps={{
  864. attributes: {
  865. class: `prose-mirror ${isCommentsEditor
  866. ? 'tiptap-comments'
  867. : 'tiptap-default'
  868. } ${classes || ''}`,
  869. style: `font-family: ${editorFontFamily}; color: ${editorTextColor};`,
  870. },
  871. autocomplete: 'off',
  872. autocorrect: 'off',
  873. autocapitalize: 'off',
  874. spellcheck: 'false',
  875. }}
  876. onDebouncedUpdate={(editor) => {
  877. // ... existing code ...
  878. }}
  879. />
  880. </Pressable>
  881. {isToolBar ? (
  882. <EditorToolbar items={toolbarItems} linkBar={linkBar} />
  883. ) : null}
  884. <TextInput
  885. key={inputKey}
  886. autoFocus={inputKey > 0}
  887. textContentType="none"
  888. autoComplete="off"
  889. autoCorrect={false}
  890. spellCheck={false}
  891. onFocus={() => {
  892. // Keyboard is open — forward IME to WebView
  893. setTimeout(() => {
  894. editor.focus();
  895. }, 50);
  896. }}
  897. style={{ position: 'absolute', width: 0, height: 0, opacity: 0 }}
  898. pointerEvents="none"
  899. />
  900. </View>
  901. )
  902. }