entity-text.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { Platform } from 'react-native';
  3. import { useForm, FormProvider } from 'react-hook-form';
  4. import { View, Pressable } from 'app/design/view';
  5. import Image, { POST_ENTRY_COVER_SIZES, POST_ENTRY_COVER_WIDTH_CAP } from 'app/ui/atoms/image';
  6. import { getCoverAspectClass } from 'app/lib/image/upload-settings';
  7. import Html from 'app/ui/atoms/html';
  8. import { splitInlineMedia } from 'app/lib/editor/inline-video';
  9. import { Text, H1C } from 'app/design/typography';
  10. import { Input } from 'app/design/controls';
  11. import { appSetting, clearLinks, getYouTubeVideoId, cn } from 'app/lib/util'
  12. import { useEditableRequest } from 'app/lib/form/form-helpers'
  13. import { ContentMore } from 'app/ui/molecules/content/content-more';
  14. import EntityAttachments from './entity-attachments';
  15. import TextMore from 'app/ui/molecules/content/text-more';
  16. import Video from 'app/ui/atoms/video';
  17. import Youtube from 'app/ui/molecules/content/youtube'
  18. import InlineVideo from 'app/ui/molecules/content/inline-video'
  19. import InlineEmbed from 'app/ui/molecules/content/inline-embed'
  20. import InlineTable from 'app/ui/molecules/content/inline-table'
  21. import { BlockWrapper } from 'app/components/block-wrapper'
  22. import RftText from 'app/components/form-fields/editor-rft-text'
  23. const isWeb = Platform.OS === 'web'
  24. function useClickOutside(enabled, ref, onOutside) {
  25. const onOutsideRef = useRef(onOutside)
  26. onOutsideRef.current = onOutside
  27. useEffect(() => {
  28. if (!enabled || !isWeb || typeof document === 'undefined') return
  29. const handle = (event) => {
  30. const node = ref.current
  31. if (!node) return
  32. const target = event.target
  33. if (node.contains?.(target)) return
  34. onOutsideRef.current?.()
  35. }
  36. // Skip the same click that opened edit mode
  37. const timer = setTimeout(() => {
  38. document.addEventListener('mousedown', handle)
  39. document.addEventListener('touchstart', handle)
  40. }, 0)
  41. return () => {
  42. clearTimeout(timer)
  43. document.removeEventListener('mousedown', handle)
  44. document.removeEventListener('touchstart', handle)
  45. }
  46. }, [enabled, ref])
  47. }
  48. function EditableTitleField({
  49. editable,
  50. requestUrl,
  51. fieldName = 'title',
  52. initialValue,
  53. inputClassName,
  54. children,
  55. }) {
  56. const { value, commit } = useEditableRequest({
  57. initialValue: initialValue ?? '',
  58. requestUrl,
  59. fieldName,
  60. toParam: (v) => encodeURIComponent(String(v ?? '')),
  61. })
  62. const [editing, setEditing] = useState(false)
  63. const draftRef = useRef(value ?? '')
  64. const wrapRef = useRef(null)
  65. const saveAndClose = useCallback(async () => {
  66. await commit(draftRef.current)
  67. setEditing(false)
  68. }, [commit])
  69. useClickOutside(editing, wrapRef, saveAndClose)
  70. if (!editable) {
  71. return typeof children === 'function' ? children(initialValue) : children
  72. }
  73. if (editing) {
  74. return (
  75. <View ref={wrapRef} className="w-full">
  76. <Input
  77. autoFocus
  78. defaultValue={String(value ?? '')}
  79. onChangeText={(text) => { draftRef.current = text }}
  80. onBlur={saveAndClose}
  81. onSubmitEditing={saveAndClose}
  82. className={cn(
  83. 'w-full border border-border/60 bg-input/40 web:focus:bg-input/70 rounded-lg px-3 py-2',
  84. inputClassName,
  85. )}
  86. />
  87. </View>
  88. )
  89. }
  90. return (
  91. <Pressable
  92. onPress={() => {
  93. draftRef.current = value ?? ''
  94. setEditing(true)
  95. }}
  96. accessibilityRole="button"
  97. accessibilityLabel="Edit title"
  98. className="w-full web:cursor-pointer rounded-lg"
  99. >
  100. {typeof children === 'function' ? children(value) : children}
  101. </Pressable>
  102. )
  103. }
  104. function EditableTextField({
  105. editable,
  106. requestUrl,
  107. fieldName = 'text',
  108. initialValue,
  109. children,
  110. }) {
  111. const { value, commit } = useEditableRequest({
  112. initialValue: initialValue ?? '',
  113. requestUrl,
  114. fieldName,
  115. toParam: (v) => encodeURIComponent(String(v ?? '')),
  116. })
  117. const [editing, setEditing] = useState(false)
  118. const wrapRef = useRef(null)
  119. const methods = useForm({
  120. defaultValues: { [fieldName]: value ?? '' },
  121. })
  122. const { isDirty } = methods.formState
  123. const saveAndClose = useCallback(async () => {
  124. if (isDirty) {
  125. await commit(methods.getValues(fieldName) ?? '')
  126. }
  127. setEditing(false)
  128. }, [commit, fieldName, isDirty, methods])
  129. useClickOutside(editing, wrapRef, saveAndClose)
  130. useEffect(() => {
  131. if (editing) {
  132. methods.reset({ [fieldName]: value ?? '' })
  133. }
  134. }, [editing, fieldName, methods, value])
  135. if (!editable) {
  136. return typeof children === 'function' ? children(initialValue) : children
  137. }
  138. if (editing) {
  139. return (
  140. <View ref={wrapRef} className="w-full">
  141. <FormProvider {...methods}>
  142. <RftText
  143. name={fieldName}
  144. value={value ?? ''}
  145. html={2}
  146. autofocus
  147. initialHeight={160}
  148. maxHeight={420}
  149. caption=""
  150. placeholder=""
  151. />
  152. </FormProvider>
  153. </View>
  154. )
  155. }
  156. return (
  157. <Pressable
  158. onPress={() => setEditing(true)}
  159. accessibilityRole="button"
  160. accessibilityLabel="Edit text"
  161. className="w-full web:cursor-pointer rounded-lg"
  162. >
  163. {typeof children === 'function' ? children(value) : children}
  164. </Pressable>
  165. )
  166. }
  167. export default function EntityTextBlock ({blockWrapperProps, data, block, showPad, sidebar, editable, afterTitle}) {
  168. const isEditable = !!(editable || data?.editable)
  169. const view = appSetting('entry', 'default_view');
  170. switch (view) {
  171. case 'small':
  172. return <Small blockWrapperProps={blockWrapperProps} data={data} showPad={showPad} editable={isEditable} afterTitle={afterTitle} />;
  173. default:
  174. return <Default blockWrapperProps={blockWrapperProps} block={block} data={data} showPad={showPad} sidebar={sidebar} editable={isEditable} afterTitle={afterTitle} />;
  175. }
  176. }
  177. function Small({ data, editable, afterTitle }) {
  178. const requestUrl = data?.params?.request_url
  179. const text = clearLinks(data.entry_text);
  180. return (
  181. <View className="bg-card sm:border-x w-full mx-auto">
  182. <View className=' bg-primary/10 sm:bg-transparent rounded-lg flex-col px-2.5 py-2 sm:p-0 mx-4 mb-2 mt-4'>
  183. <EditableTitleField
  184. editable={editable}
  185. requestUrl={requestUrl}
  186. initialValue={data.entry_title}
  187. inputClassName="font-bold text-popover-foreground text-base sm:text-xl"
  188. >
  189. {(title) => (
  190. <Text className="font-bold text-popover-foreground text-base sm:text-xl ">{title}</Text>
  191. )}
  192. </EditableTitleField>
  193. {afterTitle}
  194. <EditableTextField
  195. editable={editable}
  196. requestUrl={requestUrl}
  197. initialValue={text}
  198. >
  199. {(body) => (
  200. <ContentMore content={body} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
  201. )}
  202. </EditableTextField>
  203. </View>
  204. </View>
  205. );
  206. }
  207. const getImagesData = (data) => {
  208. if (!data["bx_if:show_screenshots"] || !data["bx_if:show_screenshots"].condition) {
  209. return [];
  210. }
  211. const screenshots = data["bx_if:show_screenshots"].content.screenshots;
  212. const att = screenshots.map(screenshot => ({
  213. type: 'image',
  214. data: { 'src': screenshot.url_bg }
  215. }));
  216. return att;
  217. };
  218. /** Post body with editor videos, embeds (standalone link paragraphs) and tables rendered inline. */
  219. function HtmlWithMedia({ html }) {
  220. // Split the raw HTML: clearLinks would turn UNA hrefs into relative ones.
  221. const segments = useMemo(() => splitInlineMedia(html), [html])
  222. if (!segments.some((s) => s.type !== 'html'))
  223. return <Html data={clearLinks(html)} />
  224. return segments.map((s, i) => {
  225. if (s.type === 'video') return <InlineVideo key={i} src={s.src} poster={s.poster} width={s.width} height={s.height} align={s.align} />
  226. if (s.type === 'embed') return <InlineEmbed key={i} url={s.url} width={s.width} align={s.align} />
  227. if (s.type === 'table') return <InlineTable key={i} markdown={s.markdown} />
  228. return <Html key={i} data={clearLinks(s.html)} />
  229. })
  230. }
  231. function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, afterTitle }) {
  232. const att = getImagesData(data);
  233. const isSmall = block?.module == "bx_market";
  234. const checked = _checkEmpty(data);
  235. const text = checked?.text ?? clearLinks(data.entry_text);
  236. const videoId = checked?.videoId ?? null;
  237. const requestUrl = data?.params?.request_url;
  238. if (!editable && !text && !data.video && !data.image && !videoId)
  239. return null
  240. return (
  241. <BlockWrapper {...blockWrapperProps}>
  242. <View className="w-full">
  243. {(!!data.video?.src_mp4) && <View className='w-full aspect-video rounded-xl overflow-hidden mb-3'>
  244. <Video poster={data.video.src_poster} src={data.video.src_mp4} cover={true} controls={true} muted={"muted"} />
  245. </View>}
  246. {(videoId) && <View className='w-full aspect-video rounded-xl overflow-hidden mb-3'>
  247. <Youtube videoId={videoId} size={3} />
  248. </View>}
  249. {(!!data.image && !data.video) && (
  250. <View className={`w-full ${getCoverAspectClass(block?.module)} mb-4 sm:rounded-xl overflow-hidden`}>
  251. <Image
  252. {...data.image}
  253. alt={data.title}
  254. className="u-cover"
  255. view="cover"
  256. sizes={POST_ENTRY_COVER_SIZES}
  257. optimizedWidthCap={POST_ENTRY_COVER_WIDTH_CAP}
  258. />
  259. </View>
  260. )}
  261. <View className={`mx-auto w-full ${(showPad == false || sidebar ? '' : ' ')}`}>
  262. <EditableTitleField
  263. editable={editable}
  264. requestUrl={requestUrl}
  265. initialValue={data.entry_title}
  266. inputClassName="text-2xl sm:text-3xl font-bold tracking-tight text-foreground font-title"
  267. >
  268. {(title) => (
  269. isSmall
  270. ? <TextMore tagName='h1' text={title} numberOfLines={2} className="font-bold tracking-tight text-foreground"></TextMore>
  271. : <H1C>{title}</H1C>
  272. )}
  273. </EditableTitleField>
  274. {afterTitle}
  275. <EditableTextField
  276. editable={editable}
  277. requestUrl={requestUrl}
  278. initialValue={data.entry_text}
  279. >
  280. {(body) => {
  281. return isSmall
  282. ? <ContentMore showLess={true} content={clearLinks(body)} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
  283. : <HtmlWithMedia html={body} />
  284. }}
  285. </EditableTextField>
  286. </View>
  287. <EntityAttachments data={att} />
  288. </View>
  289. </BlockWrapper>
  290. );
  291. }
  292. const _checkEmpty = (data) => {
  293. if (!data) return false
  294. const text = clearLinks(data.entry_text);
  295. const videoId = data.video_embed && getYouTubeVideoId(data.video_embed) || null;
  296. if (!text && !data.video && !data.image && !videoId)
  297. return false
  298. return {text: text, videoId: videoId}
  299. }
  300. EntityTextBlock.checkEmpty = (item) => {
  301. if (item?.editable || item?.data?.editable) {
  302. return _checkEmpty(item.data) || true
  303. }
  304. return _checkEmpty(item.data)
  305. };