figure-modal.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {
  2. ChangeSet,
  3. Extension,
  4. StateEffect,
  5. StateField,
  6. } from '@codemirror/state'
  7. import { EditorView } from '@codemirror/view'
  8. import { addEffectListener, removeEffectListener } from './effect-listeners'
  9. import { setMetadataEffect } from './language'
  10. import { debugConsole } from '@/utils/debugging'
  11. type NestedReadonly<T> = {
  12. readonly [P in keyof T]: NestedReadonly<T[P]>
  13. }
  14. type FigureDataProps = {
  15. from: number
  16. to: number
  17. caption: {
  18. from: number
  19. to: number
  20. } | null
  21. label: { from: number; to: number } | null
  22. width?: number
  23. unknownGraphicsArguments?: string
  24. graphicsCommandArguments: {
  25. from: number
  26. to: number
  27. } | null
  28. graphicsCommand: { from: number; to: number }
  29. file: {
  30. from: number
  31. to: number
  32. path: string
  33. }
  34. }
  35. function mapFromTo<T extends { from: number; to: number } | null>(
  36. position: T,
  37. changes: ChangeSet
  38. ) {
  39. if (!position) {
  40. return position
  41. }
  42. return {
  43. ...position,
  44. from: changes.mapPos(position.from),
  45. to: changes.mapPos(position.to),
  46. }
  47. }
  48. export class FigureData {
  49. // eslint-disable-next-line no-useless-constructor
  50. constructor(private props: NestedReadonly<FigureDataProps>) {}
  51. public get from() {
  52. return this.props.from
  53. }
  54. public get to() {
  55. return this.props.to
  56. }
  57. public get caption() {
  58. return this.props.caption
  59. }
  60. public get label() {
  61. return this.props.label
  62. }
  63. public get width() {
  64. return this.props.width
  65. }
  66. public get unknownGraphicsArguments() {
  67. return this.props.unknownGraphicsArguments
  68. }
  69. public get graphicsCommandArguments() {
  70. return this.props.graphicsCommandArguments
  71. }
  72. public get graphicsCommand() {
  73. return this.props.graphicsCommand
  74. }
  75. public get file() {
  76. return this.props.file
  77. }
  78. map(changes: ChangeSet): FigureData {
  79. return new FigureData({
  80. from: changes.mapPos(this.from),
  81. to: changes.mapPos(this.to),
  82. caption: mapFromTo(this.caption, changes),
  83. label: mapFromTo(this.label, changes),
  84. graphicsCommand: mapFromTo(this.graphicsCommand, changes),
  85. width: this.width,
  86. file: mapFromTo(this.file, changes),
  87. graphicsCommandArguments: mapFromTo(
  88. this.graphicsCommandArguments,
  89. changes
  90. ),
  91. unknownGraphicsArguments: this.unknownGraphicsArguments,
  92. })
  93. }
  94. }
  95. export const editFigureDataEffect = StateEffect.define<FigureData | null>()
  96. export const editFigureData = StateField.define<FigureData | null>({
  97. create: () => null,
  98. update: (current, transaction) => {
  99. let value: FigureData | null | undefined
  100. for (const effect of transaction.effects) {
  101. if (effect.is(editFigureDataEffect)) {
  102. value = effect.value
  103. }
  104. }
  105. // Allow setting to null
  106. if (value !== undefined) {
  107. return value
  108. }
  109. if (!current) {
  110. return current
  111. }
  112. return current.map(transaction.changes)
  113. },
  114. })
  115. export const figureModal = (): Extension => [editFigureData]
  116. export function waitForFileTreeUpdate(view: EditorView) {
  117. const abortController = new AbortController()
  118. const promise = new Promise<void>(resolve => {
  119. const abort = () => {
  120. debugConsole.warn('Aborting wait for file tree update')
  121. removeEffectListener(view, setMetadataEffect, listener)
  122. resolve()
  123. }
  124. function listener() {
  125. if (abortController.signal.aborted) {
  126. // We've already handled this
  127. return
  128. }
  129. abortController.signal.removeEventListener('abort', abort)
  130. resolve()
  131. }
  132. abortController.signal.addEventListener('abort', abort, { once: true })
  133. addEffectListener(view, setMetadataEffect, listener, { once: true })
  134. })
  135. return {
  136. withTimeout(afterMs = 500) {
  137. setTimeout(() => abortController.abort(), afterMs)
  138. return promise
  139. },
  140. promise,
  141. }
  142. }
  143. const ALLOWED_MIME_TYPES = new Set([
  144. 'image/jpeg',
  145. 'image/png',
  146. 'application/pdf',
  147. ])
  148. export type PastedImageData = {
  149. name: string
  150. type: string
  151. data: Blob
  152. }
  153. export const figureModalPasteHandler = (): Extension => {
  154. return EditorView.domEventHandlers({
  155. drop: evt => {
  156. if (!evt.dataTransfer || evt.dataTransfer.files.length === 0) {
  157. return
  158. }
  159. const file = evt.dataTransfer.files[0]
  160. if (!ALLOWED_MIME_TYPES.has(file.type)) {
  161. return
  162. }
  163. window.dispatchEvent(
  164. new CustomEvent<PastedImageData>('figure-modal:paste-image', {
  165. detail: {
  166. name: file.name,
  167. type: file.type,
  168. data: file,
  169. },
  170. })
  171. )
  172. },
  173. paste: evt => {
  174. if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
  175. return
  176. }
  177. if (evt.clipboardData.types.includes('text/plain')) {
  178. return // allow pasted text to be handled even if there's also a file on the clipboard
  179. }
  180. const file = evt.clipboardData.files[0]
  181. if (!ALLOWED_MIME_TYPES.has(file.type)) {
  182. return
  183. }
  184. window.dispatchEvent(
  185. new CustomEvent<PastedImageData>('figure-modal:paste-image', {
  186. detail: {
  187. name: file.name,
  188. type: file.type,
  189. data: file,
  190. },
  191. })
  192. )
  193. },
  194. })
  195. }