entity-text.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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, Row, 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, decodeText } 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. import { NeoButton } from 'app/design/controls'
  24. import Loading from 'app/ui/atoms/loading'
  25. import { useProfileImageUpload } from 'app/lib/image/use-profile-image-upload'
  26. const isWeb = Platform.OS === 'web'
  27. function useClickOutside(enabled, ref, onOutside) {
  28. const onOutsideRef = useRef(onOutside)
  29. onOutsideRef.current = onOutside
  30. useEffect(() => {
  31. if (!enabled || !isWeb || typeof document === 'undefined') return
  32. const handle = (event) => {
  33. const node = ref.current
  34. if (!node) return
  35. const target = event.target
  36. if (node.contains?.(target)) return
  37. onOutsideRef.current?.()
  38. }
  39. // Skip the same click that opened edit mode
  40. const timer = setTimeout(() => {
  41. document.addEventListener('mousedown', handle)
  42. document.addEventListener('touchstart', handle)
  43. }, 0)
  44. return () => {
  45. clearTimeout(timer)
  46. document.removeEventListener('mousedown', handle)
  47. document.removeEventListener('touchstart', handle)
  48. }
  49. }, [enabled, ref])
  50. }
  51. function EditableTitleField({
  52. editable,
  53. requestUrl,
  54. fieldName = 'title',
  55. initialValue,
  56. inputClassName,
  57. children,
  58. }) {
  59. // `initialValue` is plain text: callers decode UNA's HTML-escaped title,
  60. // or an edit would save a literal `&`.
  61. const { value, commit } = useEditableRequest({
  62. initialValue: initialValue ?? '',
  63. requestUrl,
  64. fieldName,
  65. toParam: (v) => encodeURIComponent(String(v ?? '')),
  66. })
  67. const [editing, setEditing] = useState(false)
  68. const draftRef = useRef(value ?? '')
  69. const wrapRef = useRef(null)
  70. const saveAndClose = useCallback(async () => {
  71. await commit(draftRef.current)
  72. setEditing(false)
  73. }, [commit])
  74. useClickOutside(editing, wrapRef, saveAndClose)
  75. if (!editable) {
  76. return typeof children === 'function' ? children(initialValue) : children
  77. }
  78. if (editing) {
  79. return (
  80. <View ref={wrapRef} className="w-full">
  81. <Input
  82. autoFocus
  83. defaultValue={String(value ?? '')}
  84. onChangeText={(text) => { draftRef.current = text }}
  85. onBlur={saveAndClose}
  86. onSubmitEditing={saveAndClose}
  87. className={cn(
  88. 'w-full border border-border/60 bg-input/40 web:focus:bg-input/70 rounded-lg px-3 py-2',
  89. inputClassName,
  90. )}
  91. />
  92. </View>
  93. )
  94. }
  95. return (
  96. <Pressable
  97. onPress={() => {
  98. draftRef.current = value ?? ''
  99. setEditing(true)
  100. }}
  101. accessibilityRole="button"
  102. accessibilityLabel="Edit title"
  103. className="w-full web:cursor-pointer rounded-lg"
  104. >
  105. {typeof children === 'function' ? children(value) : children}
  106. </Pressable>
  107. )
  108. }
  109. function EditableTextField({
  110. editable,
  111. requestUrl,
  112. fieldName = 'text',
  113. initialValue,
  114. children,
  115. }) {
  116. const { value, commit } = useEditableRequest({
  117. initialValue: initialValue ?? '',
  118. requestUrl,
  119. fieldName,
  120. toParam: (v) => encodeURIComponent(String(v ?? '')),
  121. })
  122. const [editing, setEditing] = useState(false)
  123. const wrapRef = useRef(null)
  124. const methods = useForm({
  125. defaultValues: { [fieldName]: value ?? '' },
  126. })
  127. const { isDirty } = methods.formState
  128. const saveAndClose = useCallback(async () => {
  129. if (isDirty) {
  130. await commit(methods.getValues(fieldName) ?? '')
  131. }
  132. setEditing(false)
  133. }, [commit, fieldName, isDirty, methods])
  134. useClickOutside(editing, wrapRef, saveAndClose)
  135. useEffect(() => {
  136. if (editing) {
  137. methods.reset({ [fieldName]: value ?? '' })
  138. }
  139. }, [editing, fieldName, methods, value])
  140. if (!editable) {
  141. return typeof children === 'function' ? children(initialValue) : children
  142. }
  143. if (editing) {
  144. return (
  145. <View ref={wrapRef} className="w-full">
  146. <FormProvider {...methods}>
  147. <RftText
  148. name={fieldName}
  149. value={value ?? ''}
  150. html={2}
  151. autofocus
  152. initialHeight={160}
  153. maxHeight={420}
  154. caption=""
  155. placeholder=""
  156. />
  157. </FormProvider>
  158. </View>
  159. )
  160. }
  161. return (
  162. <Pressable
  163. onPress={() => setEditing(true)}
  164. accessibilityRole="button"
  165. accessibilityLabel="Edit text"
  166. className="w-full web:cursor-pointer rounded-lg"
  167. >
  168. {typeof children === 'function' ? children(value) : children}
  169. </Pressable>
  170. )
  171. }
  172. export default function EntityTextBlock ({blockWrapperProps, data, block, showPad, sidebar, editable, afterTitle}) {
  173. const isEditable = !!(editable || data?.editable)
  174. const view = appSetting('entry', 'default_view');
  175. switch (view) {
  176. case 'small':
  177. return <Small blockWrapperProps={blockWrapperProps} data={data} showPad={showPad} editable={isEditable} afterTitle={afterTitle} />;
  178. default:
  179. return <Default blockWrapperProps={blockWrapperProps} block={block} data={data} showPad={showPad} sidebar={sidebar} editable={isEditable} afterTitle={afterTitle} />;
  180. }
  181. }
  182. function Small({ data, editable, afterTitle }) {
  183. const requestUrl = data?.params?.request_url
  184. const text = clearLinks(data.entry_text);
  185. return (
  186. <View className="bg-card sm:border-x w-full mx-auto">
  187. <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'>
  188. <EditableTitleField
  189. editable={editable}
  190. requestUrl={requestUrl}
  191. initialValue={decodeText(data.entry_title)}
  192. inputClassName="font-bold text-popover-foreground text-base sm:text-xl"
  193. >
  194. {(title) => (
  195. <Text className="font-bold text-popover-foreground text-base sm:text-xl ">{title}</Text>
  196. )}
  197. </EditableTitleField>
  198. {afterTitle}
  199. <EditableTextField
  200. editable={editable}
  201. requestUrl={requestUrl}
  202. initialValue={text}
  203. >
  204. {(body) => (
  205. <ContentMore content={body} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
  206. )}
  207. </EditableTextField>
  208. </View>
  209. </View>
  210. );
  211. }
  212. const getImagesData = (data) => {
  213. if (!data["bx_if:show_screenshots"] || !data["bx_if:show_screenshots"].condition) {
  214. return [];
  215. }
  216. const screenshots = data["bx_if:show_screenshots"].content.screenshots;
  217. const att = screenshots.map(screenshot => ({
  218. type: 'image',
  219. data: { 'src': screenshot.url_bg }
  220. }));
  221. return att;
  222. };
  223. /**
  224. * Upload state for an entry image UNA lets the viewer change on the page (`images_edit[kind]`:
  225. * field, storage, uploader). A changed image has no UNA sizes yet, so it comes back as `{ src }`.
  226. */
  227. function useEntryImageEdit({ kind, edit, module, contentId, image }) {
  228. const { imageUrl, isUploading, pick, modal } = useProfileImageUpload({
  229. kind,
  230. profileId: contentId,
  231. moduleName: module || '',
  232. storage: edit?.storage || '',
  233. uploader: edit?.uploader,
  234. field: edit?.field,
  235. initialUrl: image?.src || '',
  236. })
  237. const shown = imageUrl && imageUrl !== image?.src ? { src: imageUrl } : (image || null)
  238. return { image: shown, isUploading, pick, modal }
  239. }
  240. function EntryCover({ data, module, edit }) {
  241. const { image, isUploading, pick, modal } = useEntryImageEdit({
  242. kind: 'cover', edit, module, contentId: data?.id, image: data?.image,
  243. })
  244. if (!image?.src && !edit) return null
  245. return (
  246. <>
  247. <View className={`w-full ${image?.src ? getCoverAspectClass(module) : 'h-24 bg-accent/50'} mb-4 sm:rounded-xl overflow-hidden`}>
  248. {image?.src ? (
  249. <Image
  250. {...image}
  251. alt={data.title}
  252. className="u-cover"
  253. view="cover"
  254. sizes={POST_ENTRY_COVER_SIZES}
  255. optimizedWidthCap={POST_ENTRY_COVER_WIDTH_CAP}
  256. />
  257. ) : null}
  258. {isUploading ? (
  259. <View className="absolute inset-0 opacity-60 bg-card justify-center items-center">
  260. <Loading />
  261. </View>
  262. ) : null}
  263. {edit ? (
  264. <View className="absolute top-3 right-3">
  265. <NeoButton
  266. image="Camera"
  267. style="glass"
  268. controlSize="regular"
  269. borderShape="circle"
  270. disabled={isUploading}
  271. onPress={pick}
  272. />
  273. </View>
  274. ) : null}
  275. </View>
  276. {modal}
  277. </>
  278. )
  279. }
  280. function EntryIcon({ data, module, edit }) {
  281. const { image, isUploading, pick, modal } = useEntryImageEdit({
  282. kind: 'picture', edit, module, contentId: data?.id, image: data?.icon,
  283. })
  284. if (!image?.src && !edit) return null
  285. return (
  286. <>
  287. <View className="relative mr-3 shrink-0">
  288. <View className="w-12 h-12 rounded-xl overflow-hidden bg-accent/50">
  289. {image?.src ? (
  290. <Image
  291. {...image}
  292. alt={data.title}
  293. className="u-cover"
  294. view="cover"
  295. />
  296. ) : null}
  297. {isUploading ? (
  298. <View className="absolute inset-0 bg-card/80 items-center justify-center">
  299. <Loading />
  300. </View>
  301. ) : null}
  302. </View>
  303. {edit ? (
  304. <View className="absolute -bottom-1 -right-1">
  305. <NeoButton
  306. image="Camera"
  307. style="glass"
  308. controlSize="mini"
  309. borderShape="circle"
  310. disabled={isUploading}
  311. onPress={pick}
  312. />
  313. </View>
  314. ) : null}
  315. </View>
  316. {modal}
  317. </>
  318. )
  319. }
  320. /** Post body with editor videos, embeds (standalone link paragraphs) and tables rendered inline. */
  321. function HtmlWithMedia({ html }) {
  322. // Split the raw HTML: clearLinks would turn UNA hrefs into relative ones.
  323. const segments = useMemo(() => splitInlineMedia(html), [html])
  324. if (!segments.some((s) => s.type !== 'html'))
  325. return <Html data={clearLinks(html)} />
  326. return segments.map((s, i) => {
  327. if (s.type === 'video') return <InlineVideo key={i} src={s.src} poster={s.poster} width={s.width} height={s.height} align={s.align} />
  328. if (s.type === 'embed') return <InlineEmbed key={i} url={s.url} width={s.width} align={s.align} />
  329. if (s.type === 'table') return <InlineTable key={i} markdown={s.markdown} />
  330. return <Html key={i} data={clearLinks(s.html)} />
  331. })
  332. }
  333. function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, afterTitle }) {
  334. const att = getImagesData(data);
  335. const isSmall = block?.module == "bx_market";
  336. const checked = _checkEmpty(data);
  337. const text = checked?.text ?? clearLinks(data.entry_text);
  338. const videoId = checked?.videoId ?? null;
  339. const requestUrl = data?.params?.request_url;
  340. if (!editable && !text && !data.video && !data.image && !videoId)
  341. return null
  342. return (
  343. <BlockWrapper {...blockWrapperProps}>
  344. <View className="w-full">
  345. {(!!data.video?.src_mp4) && <View className='w-full aspect-video rounded-xl overflow-hidden mb-3'>
  346. <Video poster={data.video.src_poster} src={data.video.src_mp4} cover={true} controls={true} muted={"muted"} />
  347. </View>}
  348. {(videoId) && <View className='w-full aspect-video rounded-xl overflow-hidden mb-3'>
  349. <Youtube videoId={videoId} size={3} />
  350. </View>}
  351. {!data.video && (
  352. <EntryCover data={data} module={block?.module} edit={data.images_edit?.cover} />
  353. )}
  354. <View className={`mx-auto w-full ${(showPad == false || sidebar ? '' : ' ')}`}>
  355. <Row className="w-full items-center">
  356. <EntryIcon data={data} module={block?.module} edit={data.images_edit?.picture} />{/* Market product icon */}
  357. {/* min-w-0: a long title wraps next to the icon instead of pushing out of the block. */}
  358. <View className="flex-1 min-w-0">
  359. <EditableTitleField
  360. editable={editable}
  361. requestUrl={requestUrl}
  362. initialValue={decodeText(data.entry_title)}
  363. inputClassName="text-2xl sm:text-3xl font-bold tracking-tight text-foreground font-title"
  364. >
  365. {(title) => (
  366. isSmall
  367. ? <TextMore tagName='h1' text={title} numberOfLines={2} className="font-bold tracking-tight text-foreground"></TextMore>
  368. : <H1C>{title}</H1C>
  369. )}
  370. </EditableTitleField>
  371. </View>
  372. </Row>
  373. {afterTitle}
  374. <EditableTextField
  375. editable={editable}
  376. requestUrl={requestUrl}
  377. initialValue={data.entry_text}
  378. >
  379. {(body) => {
  380. return isSmall
  381. ? <ContentMore showLess={true} content={clearLinks(body)} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
  382. : <HtmlWithMedia html={body} />
  383. }}
  384. </EditableTextField>
  385. </View>
  386. <EntityAttachments data={att} />
  387. </View>
  388. </BlockWrapper>
  389. );
  390. }
  391. const _checkEmpty = (data) => {
  392. if (!data) return false
  393. const text = clearLinks(data.entry_text);
  394. const videoId = data.video_embed && getYouTubeVideoId(data.video_embed) || null;
  395. if (!text && !data.video && !data.image && !videoId)
  396. return false
  397. return {text: text, videoId: videoId}
  398. }
  399. EntityTextBlock.checkEmpty = (item) => {
  400. if (item?.editable || item?.data?.editable) {
  401. return _checkEmpty(item.data) || true
  402. }
  403. return _checkEmpty(item.data)
  404. };