figure-modal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import {
  2. OLModal,
  3. OLModalBody,
  4. OLModalFooter,
  5. OLModalHeader,
  6. OLModalTitle,
  7. } from '@/shared/components/ol/ol-modal'
  8. import {
  9. FigureModalProvider,
  10. FigureModalSource,
  11. useFigureModalContext,
  12. useFigureModalExistingFigureContext,
  13. } from './figure-modal-context'
  14. import { FigureModalFooter } from './figure-modal-footer'
  15. import { lazy, memo, Suspense, useCallback, useEffect } from 'react'
  16. import { useCodeMirrorViewContext } from '../codemirror-context'
  17. import { ChangeSpec } from '@codemirror/state'
  18. import { snippet } from '@codemirror/autocomplete'
  19. import {
  20. FigureData,
  21. editFigureData,
  22. editFigureDataEffect,
  23. } from '../../extensions/figure-modal'
  24. import { ensureEmptyLine } from '../../extensions/toolbar/commands'
  25. import { useTranslation } from 'react-i18next'
  26. import useEventListener from '../../../../shared/hooks/use-event-listener'
  27. import { prepareLines } from '../../utils/prepare-lines'
  28. import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
  29. import { isSvgFile } from '../../utils/file'
  30. import { PastedImageData } from '../../utils/paste-image'
  31. const FigureModalBody = lazy(() => import('./figure-modal-body'))
  32. export const FigureModal = memo(function FigureModal() {
  33. return (
  34. <FigureModalProvider>
  35. <FigureModalContent />
  36. </FigureModalProvider>
  37. )
  38. })
  39. const FigureModalContent = () => {
  40. const { t } = useTranslation()
  41. const getTitle = useCallback(
  42. (state: FigureModalSource) => {
  43. switch (state) {
  44. case FigureModalSource.FILE_UPLOAD:
  45. return t('upload_from_computer')
  46. case FigureModalSource.FILE_TREE:
  47. return t('insert_from_project_files')
  48. case FigureModalSource.FROM_URL:
  49. return t('insert_from_url')
  50. case FigureModalSource.OTHER_PROJECT:
  51. return t('insert_from_another_project')
  52. case FigureModalSource.EDIT_FIGURE:
  53. return t('edit_figure')
  54. default:
  55. return t('insert_image')
  56. }
  57. },
  58. [t]
  59. )
  60. const {
  61. source,
  62. dispatch,
  63. helpShown,
  64. getPath,
  65. width,
  66. includeCaption,
  67. includeLabel,
  68. sourcePickerShown,
  69. } = useFigureModalContext()
  70. const listener = useCallback(
  71. (event: Event) => {
  72. const { detail } = event as CustomEvent<{
  73. source: FigureModalSource
  74. fileId?: string
  75. filePath?: string
  76. }>
  77. dispatch({
  78. source: detail.source,
  79. selectedItemId: detail.fileId,
  80. getPath: detail.filePath ? async () => detail.filePath! : undefined,
  81. })
  82. },
  83. [dispatch]
  84. )
  85. useEffect(() => {
  86. window.addEventListener('figure-modal:open', listener)
  87. return () => {
  88. window.removeEventListener('figure-modal:open', listener)
  89. }
  90. }, [listener])
  91. const { dispatch: updateExistingFigure } =
  92. useFigureModalExistingFigureContext()
  93. const view = useCodeMirrorViewContext()
  94. const hide = useCallback(() => {
  95. dispatch({ source: FigureModalSource.NONE })
  96. view.requestMeasure()
  97. // Wait for the modal to close before focusing the editor
  98. window.setTimeout(() => view.focus(), 0)
  99. }, [dispatch, view])
  100. useEventListener(
  101. 'figure-modal:open-modal',
  102. useCallback(() => {
  103. const figure = view.state.field<FigureData>(editFigureData, false)
  104. if (!figure) {
  105. return
  106. }
  107. updateExistingFigure({
  108. name: figure.file.path,
  109. // The empty string should *not* be a complex argument
  110. hasComplexGraphicsArgument: Boolean(figure.unknownGraphicsArguments),
  111. })
  112. dispatch({
  113. source: FigureModalSource.EDIT_FIGURE,
  114. width: figure.width,
  115. includeCaption: figure.caption !== null,
  116. includeLabel: figure.label !== null,
  117. })
  118. }, [view, dispatch, updateExistingFigure])
  119. )
  120. useEventListener(
  121. 'figure-modal:paste-image',
  122. useCallback(
  123. (image: CustomEvent<PastedImageData>) => {
  124. dispatch({
  125. source: FigureModalSource.FILE_UPLOAD,
  126. pastedImageData: image.detail,
  127. })
  128. },
  129. [dispatch]
  130. )
  131. )
  132. const insert = useCallback(async () => {
  133. const replaceGraphicsCommand = (
  134. figure: FigureData,
  135. changes: ChangeSpec[],
  136. isSvg: boolean,
  137. insertPath: string
  138. ) => {
  139. // Replace the entire graphics command when switching between SVG and non-SVG
  140. const graphicsCommandName = isSvg ? 'includesvg' : 'includegraphics'
  141. let widthArgument = ''
  142. if (figure.unknownGraphicsArguments) {
  143. widthArgument = `[${figure.unknownGraphicsArguments}]`
  144. } else if (width) {
  145. widthArgument = `[width=${width}\\linewidth]`
  146. }
  147. changes.push({
  148. from: figure.graphicsCommand.from,
  149. to: figure.graphicsCommand.to,
  150. insert: `\\${graphicsCommandName}${widthArgument}{${insertPath}}`,
  151. })
  152. }
  153. const updateGraphicsArgs = (
  154. figure: FigureData,
  155. changes: ChangeSpec[],
  156. insertPath?: string
  157. ) => {
  158. if (!figure.unknownGraphicsArguments && width) {
  159. // We understood the arguments, and should update the width
  160. if (figure.graphicsCommandArguments !== null) {
  161. changes.push({
  162. from: figure.graphicsCommandArguments.from,
  163. to: figure.graphicsCommandArguments.to,
  164. insert: `width=${width}\\linewidth`,
  165. })
  166. } else {
  167. // Insert new args
  168. changes.push({
  169. from: figure.file.from - 1,
  170. insert: `[width=${width}\\linewidth]`,
  171. })
  172. }
  173. }
  174. changes.push({
  175. from: figure.file.from,
  176. to: figure.file.to,
  177. insert: insertPath,
  178. })
  179. }
  180. const updateCaptionAndLabel = (
  181. figure: FigureData,
  182. changes: ChangeSpec[]
  183. ) => {
  184. const labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
  185. const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
  186. const hadCaptionBefore = figure.caption !== null
  187. const hadLabelBefore = figure.label !== null
  188. if (!hadCaptionBefore && includeCaption) {
  189. // We should insert a caption
  190. changes.push({
  191. from: figure.graphicsCommand.to,
  192. insert: prepareLines(
  193. ['', captionCommand],
  194. view.state,
  195. figure.graphicsCommand.to
  196. ),
  197. })
  198. }
  199. if (!hadLabelBefore && includeLabel) {
  200. const from = figure.caption?.to ?? figure.graphicsCommand.to
  201. // We should insert a label
  202. changes.push({
  203. from,
  204. insert: prepareLines(['', labelCommand], view.state, from),
  205. })
  206. }
  207. if (hadCaptionBefore && !includeCaption) {
  208. // We should remove the caption
  209. changes.push({
  210. from: figure.caption!.from,
  211. to: figure.caption!.to,
  212. insert: '',
  213. })
  214. }
  215. if (hadLabelBefore && !includeLabel) {
  216. // We should remove th label
  217. changes.push({
  218. from: figure.label!.from,
  219. to: figure.label!.to,
  220. insert: '',
  221. })
  222. }
  223. }
  224. const insertNewFigure = (insertPath: string, isSvg: boolean) => {
  225. const { pos, suffix } = ensureEmptyLine(
  226. view.state,
  227. view.state.selection.main
  228. )
  229. const widthArgument =
  230. width !== undefined ? `[width=${width}\\linewidth]` : ''
  231. const caption = includeCaption ? `\n\t\\caption{\${Enter Caption}}` : ''
  232. const label = includeLabel ? `\n\t\\label{\${fig:placeholder}}` : ''
  233. const graphicsCommand = isSvg ? 'includesvg' : 'includegraphics'
  234. snippet(
  235. `\\begin{figure}
  236. \t\\centering
  237. \t\\${graphicsCommand}${widthArgument}{${insertPath}}${caption}${label}
  238. \\end{figure}${suffix}\${}`
  239. )(
  240. { state: view.state, dispatch: view.dispatch },
  241. { label: 'figure' },
  242. pos,
  243. pos
  244. )
  245. }
  246. const figure = view.state.field<FigureData>(editFigureData, false)
  247. if (!getPath) {
  248. throw new Error('Cannot insert figure without a file path')
  249. }
  250. let path: string
  251. try {
  252. path = await getPath()
  253. } catch (error) {
  254. dispatch({ error: String(error) })
  255. return
  256. }
  257. const isSvg = isSvgFile(path)
  258. // For SVG files, strip the .svg extension as \includesvg expects it without
  259. const insertPath = isSvg ? path.replace(/\.svg$/i, '') : path
  260. if (figure) {
  261. // Updating existing figure
  262. const changes: ChangeSpec[] = []
  263. // Update caption and label as needed
  264. updateCaptionAndLabel(figure, changes)
  265. // Check if we're switching to/from SVG, which requires changing the command
  266. const wasSvgBefore = isSvgFile(figure.file.path)
  267. const needsCommandChange = isSvg !== wasSvgBefore
  268. if (needsCommandChange) {
  269. // Replace the entire graphics command when switching between SVG and non-SVG
  270. replaceGraphicsCommand(figure, changes, isSvg, insertPath)
  271. } else {
  272. // No command change needed, do surgical edits
  273. updateGraphicsArgs(figure, changes, insertPath)
  274. }
  275. view.dispatch({
  276. changes: view.state.changes(changes),
  277. effects: editFigureDataEffect.of(null),
  278. })
  279. } else {
  280. // Inserting new figure
  281. insertNewFigure(insertPath, isSvg)
  282. }
  283. hide()
  284. }, [getPath, view, hide, includeCaption, includeLabel, width, dispatch])
  285. const onDelete = useCallback(() => {
  286. const figure = view.state.field<FigureData>(editFigureData, false)
  287. if (!figure) {
  288. dispatch({ error: "Couldn't remove figure" })
  289. return
  290. }
  291. view.dispatch({
  292. effects: editFigureDataEffect.of(null),
  293. changes: view.state.changes({
  294. from: figure.from,
  295. to: figure.to,
  296. insert: '',
  297. }),
  298. })
  299. dispatch({ sourcePickerShown: false })
  300. hide()
  301. }, [view, hide, dispatch])
  302. const onCancel = useCallback(() => {
  303. dispatch({ sourcePickerShown: false })
  304. view.dispatch({ effects: editFigureDataEffect.of(null) })
  305. hide()
  306. }, [hide, view, dispatch])
  307. if (source === FigureModalSource.NONE) {
  308. return null
  309. }
  310. return (
  311. <OLModal
  312. onHide={hide}
  313. className="figure-modal"
  314. show
  315. returnFocusOnDeactivate={false}
  316. >
  317. <OLModalHeader>
  318. <OLModalTitle>
  319. {helpShown
  320. ? t('help')
  321. : sourcePickerShown
  322. ? t('replace_figure')
  323. : getTitle(source)}{' '}
  324. </OLModalTitle>
  325. </OLModalHeader>
  326. <OLModalBody>
  327. <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
  328. <FigureModalBody />
  329. </Suspense>
  330. </OLModalBody>
  331. <OLModalFooter>
  332. <FigureModalFooter
  333. onInsert={insert}
  334. onCancel={onCancel}
  335. onDelete={onDelete}
  336. />
  337. </OLModalFooter>
  338. </OLModal>
  339. )
  340. }