figure-modal.ts 5.3 KB

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