inline-video.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { uploadImage } from 'app/lib/util/upload'
  2. import { genRnd } from 'app/lib/util/misc'
  3. import { alignFromAttrs } from 'app/lib/html-helpers'
  4. import { revokePastedBlobUri } from './editor-paste-images'
  5. import { inlineUploadError, type InlineUploadResult } from './upload-inline-image'
  6. /** UNA editor video storage + transcoders (`sys_videos_editor*`, see UNA install SQL). */
  7. const VIDEO_STORAGE = 'sys_videos_editor'
  8. const VIDEO_MP4 = 'sys_videos_editor_mp4'
  9. const VIDEO_POSTER = 'sys_videos_editor_poster'
  10. /**
  11. * Upload one body video through `upload_inline`: UNA stores it, queues mp4 + poster
  12. * transcoding and returns stable `image_transcoder.php` links that redirect once ready.
  13. */
  14. export function uploadInlineVideo(
  15. video: { uri: string; fileName?: string; mimeType?: string },
  16. onProgress?: (fraction: number) => void,
  17. ): Promise<InlineUploadResult> {
  18. const url = '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]='
  19. + `&a=upload_inline&uo=sys_html5&so=${VIDEO_STORAGE}&o=${VIDEO_STORAGE}`
  20. + `&vt=${VIDEO_MP4}&pt=${VIDEO_POSTER}&uid=${genRnd(8)}`
  21. return new Promise((resolve) => {
  22. uploadImage(
  23. video.uri,
  24. url,
  25. ({ result }) => {
  26. const data = result?.data || result
  27. if (!data?.link) console.warn('[editor] inline video upload rejected', result)
  28. resolve({ link: data?.link || null, error: data?.link ? null : inlineUploadError(data) })
  29. },
  30. { fileName: video.fileName, mimeType: video.mimeType },
  31. { onProgress },
  32. )
  33. .catch((err) => {
  34. console.warn('[editor] inline video upload failed', err)
  35. resolve({ link: null, error: null })
  36. })
  37. .finally(() => revokePastedBlobUri(video.uri))
  38. })
  39. }
  40. export type InlineSegment =
  41. | { type: 'html'; html: string }
  42. | ({ type: 'video'; src: string; poster: string } & InlineMediaLayout)
  43. | ({ type: 'embed'; url: string } & InlineMediaLayout)
  44. | ({ type: 'table'; markdown: string } & InlineMediaLayout)
  45. export type InlineMediaAlign = 'left' | 'center' | 'right'
  46. /** Size and paragraph alignment the editor gave the media image (`width`/`height`, `text-*` class or `text-align`). */
  47. export type InlineMediaLayout = { width?: number; height?: number; align?: InlineMediaAlign }
  48. /*
  49. * Video and embeds go into the body as images — the only atomic node of the editors on
  50. * web, iOS and Android (deleted as a whole, shown as a preview):
  51. * - video: the poster transcoder link `image_transcoder.php?o=sys_videos_editor_poster&h=ID`;
  52. * - embed: the page image (or UNA's embed-na.png) with the URL in a `#neo-embed=` fragment;
  53. * - table: UNA's table-na.png with the GFM Markdown of the table in a `#neo-table=` fragment.
  54. * The post view swaps them for a player / embed card / table.
  55. */
  56. const EMBED_MARK = 'neo-embed='
  57. const TABLE_MARK = 'neo-table='
  58. /** Image src for a table: the placeholder image carrying the table's Markdown in its fragment. */
  59. export function toTableImageSrc(image: string, markdown: string) {
  60. return `${image.split('#')[0]}#${TABLE_MARK}${encodeURIComponent(markdown)}`
  61. }
  62. /** The table Markdown an editor image src carries (`toTableImageSrc`), `null` for any other image. */
  63. export function tableMarkdownFromImageSrc(src: string | null | undefined): string | null {
  64. const hash = src ? src.indexOf(`#${TABLE_MARK}`) : -1
  65. if (!src || hash < 0) return null
  66. try {
  67. return decodeURIComponent(src.slice(hash + 1 + TABLE_MARK.length)) || null
  68. } catch {
  69. return null
  70. }
  71. }
  72. /** Image src for an embed: the preview image carrying the embedded URL in its fragment. */
  73. export function toEmbedImageSrc(image: string, url: string) {
  74. return `${image.split('#')[0]}#${EMBED_MARK}${encodeURIComponent(url)}`
  75. }
  76. /** Poster image src for an uploaded editor video (its mp4 link from `upload_inline`). */
  77. export function toVideoPosterSrc(mp4Link: string) {
  78. return mp4Link.replace(`o=${VIDEO_MP4}&`, `o=${VIDEO_POSTER}&`)
  79. }
  80. /** The embedded URL an editor image src carries (`toEmbedImageSrc`), `null` for a plain image. */
  81. export function embedUrlFromImageSrc(src: string | null | undefined): string | null {
  82. const hash = src ? src.indexOf(`#${EMBED_MARK}`) : -1
  83. if (!src || hash < 0) return null
  84. try {
  85. const url = decodeURIComponent(src.slice(hash + 1 + EMBED_MARK.length))
  86. return isHttpUrl(url) ? url : null
  87. } catch {
  88. return null
  89. }
  90. }
  91. const decodeAmp = (s: string) => s.replace(/&amp;/g, '&')
  92. /** Only http(s) URLs may be embedded: anything else (`javascript:`, `data:`…) is dropped. */
  93. export function isHttpUrl(url: string | null | undefined): boolean {
  94. if (!url) return false
  95. try {
  96. const { protocol } = new URL(url)
  97. return protocol === 'http:' || protocol === 'https:'
  98. } catch {
  99. return false
  100. }
  101. }
  102. const embedSegment = (url: string): InlineSegment | null => (isHttpUrl(url) ? { type: 'embed', url } : null)
  103. const IMG = '(<img\\b[^>]*\\bsrc="([^"]+)"[^>]*>)'
  104. // Legacy (link) format of the first builds: a paragraph with only a marked link.
  105. const LINK = '<a\\b[^>]*\\bhref="([^"]+)"[^>]*>[^<]*</a>'
  106. // Groups: 1 paragraph attributes, 2-3 its image tag and src, 4-5 a bare image tag and src, 6 a legacy link.
  107. const MEDIA = new RegExp(`<p([^>]*)>\\s*${IMG}\\s*</p>|${IMG}|<p[^>]*>\\s*${LINK}\\s*</p>`, 'gi')
  108. const dimension = (tag: string, name: string) => {
  109. const value = Number(tag.match(new RegExp(`\\b${name}="(\\d+)"`, 'i'))?.[1])
  110. return value > 0 ? value : undefined
  111. }
  112. function toLayout(imgTag: string | undefined, pAttrs: string | undefined): InlineMediaLayout {
  113. return {
  114. width: imgTag ? dimension(imgTag, 'width') : undefined,
  115. height: imgTag ? dimension(imgTag, 'height') : undefined,
  116. align: alignFromAttrs(pAttrs),
  117. }
  118. }
  119. function toSegment(rawSrc: string | undefined, isLink: boolean): InlineSegment | null {
  120. if (!rawSrc) return null
  121. const src = decodeAmp(rawSrc)
  122. const video = src.match(new RegExp(`image_transcoder\\.php\\?o=(${VIDEO_POSTER}|${VIDEO_MP4})&h=\\d+`))
  123. if (video && (isLink ? video[1] === VIDEO_MP4 : video[1] === VIDEO_POSTER)) {
  124. const poster = isLink ? toVideoPosterSrc(src) : src
  125. return { type: 'video', src: poster.replace(`o=${VIDEO_POSTER}&`, `o=${VIDEO_MP4}&`), poster }
  126. }
  127. const hash = src.indexOf('#')
  128. if (hash < 0) return null
  129. const fragment = src.slice(hash + 1)
  130. if (!isLink && fragment.startsWith(TABLE_MARK)) {
  131. const markdown = tableMarkdownFromImageSrc(src)
  132. return markdown ? { type: 'table', markdown } : null
  133. }
  134. if (!isLink && fragment.startsWith(EMBED_MARK)) {
  135. try {
  136. return embedSegment(decodeURIComponent(fragment.slice(EMBED_MARK.length)))
  137. } catch {
  138. return null
  139. }
  140. }
  141. // Legacy embed link: `URL#neo-embed` / `URL#frag&neo-embed`.
  142. if (isLink && /(^|&)neo-embed$/.test(fragment)) {
  143. return embedSegment(src.replace(/[#&]neo-embed$/, ''))
  144. }
  145. return null
  146. }
  147. /** Split post HTML into plain HTML chunks, inline editor videos, embeds and tables. */
  148. export function splitInlineMedia(html: string | null | undefined): InlineSegment[] {
  149. if (!html) return []
  150. const segments: InlineSegment[] = []
  151. let last = 0
  152. for (const match of html.matchAll(MEDIA)) {
  153. const found = toSegment(match[3] || match[5] || match[6], !!match[6])
  154. if (!found) continue
  155. const segment = found.type === 'html' ? found : { ...found, ...toLayout(match[2] || match[4], match[1]) }
  156. const index = match.index ?? 0
  157. if (index > last) segments.push({ type: 'html', html: html.slice(last, index) })
  158. segments.push(segment)
  159. last = index + match[0].length
  160. }
  161. if (last < html.length) segments.push({ type: 'html', html: html.slice(last) })
  162. return segments
  163. }