upload-inline-image.ts 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { uploadImage } from 'app/lib/util/upload'
  2. import { genRnd } from 'app/lib/util/misc'
  3. const INLINE_STORAGE = 'sys_images_editor'
  4. /** `upload_inline` outcome: the file link, or UNA's error text (`null` when it gave none). */
  5. export type InlineUploadResult = { link: string | null; error: string | null }
  6. /** UNA's text from a failed `upload_inline` answer; `null` for a bare `'1'` or a non-JSON reply. */
  7. export function inlineUploadError(data: any): string | null {
  8. const error = data?.error
  9. return typeof error === 'string' && error !== '' && error !== '1' ? error : null
  10. }
  11. /**
  12. * Upload one body image the way UNA's Quill does (`storage.php?a=upload`): straight into
  13. * the public editor storage with the ghost record dropped, so the URL is permanent and the
  14. * file never becomes a post attachment.
  15. */
  16. export function uploadInlineImage(image: {
  17. uri: string
  18. fileName?: string
  19. mimeType?: string
  20. }): Promise<InlineUploadResult> {
  21. const url = '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]='
  22. + '&a=upload_inline&uo=sys_html5'
  23. + `&so=${INLINE_STORAGE}&o=${INLINE_STORAGE}&t=${INLINE_STORAGE}&uid=${genRnd(8)}`
  24. return new Promise((resolve) => {
  25. uploadImage(
  26. image.uri,
  27. url,
  28. ({ result }) => {
  29. const data = result?.data || result
  30. if (!data?.link) console.warn('[editor] inline image upload rejected', result)
  31. resolve({ link: data?.link || null, error: data?.link ? null : inlineUploadError(data) })
  32. },
  33. { fileName: image.fileName, mimeType: image.mimeType },
  34. )
  35. .catch((err) => {
  36. console.warn('[editor] inline image upload failed', err)
  37. resolve({ link: null, error: null })
  38. })
  39. })
  40. }
  41. const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  42. /**
  43. * Replace the placeholder image `from` with `to`, or remove it when `to` is null.
  44. * Web edits the TipTap node in place (keeps the caret); native round-trips the HTML.
  45. */
  46. export async function swapEditorImageSrc(editorRef: any, tipTap: any, from: string, to: string | null) {
  47. if (tipTap?.state && tipTap?.view) {
  48. const { state } = tipTap
  49. let tr = state.tr
  50. const hits: Array<{ pos: number; node: any }> = []
  51. state.doc.descendants((node: any, pos: number) => {
  52. if (node.type?.name?.toLowerCase().includes('image') && node.attrs?.src === from) hits.push({ pos, node })
  53. })
  54. // Back to front so earlier positions stay valid on delete.
  55. hits.reverse().forEach(({ pos, node }) => {
  56. tr = to
  57. ? tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: to })
  58. : tr.delete(pos, pos + node.nodeSize)
  59. })
  60. if (hits.length) {
  61. tipTap.view.dispatch(tr)
  62. return
  63. }
  64. }
  65. const ed = editorRef?.current
  66. if (!ed?.getHTML) return
  67. try {
  68. let html = String(await ed.getHTML())
  69. if (!html.includes(from)) return
  70. html = to
  71. ? html.split(from).join(to)
  72. : html.replace(new RegExp(`<img[^>]*src="${escapeRegExp(from)}"[^>]*>`, 'g'), '')
  73. ed.setValue(html.replace(/^<html>/i, '').replace(/<\/html>$/i, ''))
  74. } catch {
  75. // editor unmounted mid-upload
  76. }
  77. }