figure-modal.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. 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. console.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. const splitTestVariants = getMeta('ol-splitTestVariants', {})
  155. const figureModalEnabled = splitTestVariants['figure-modal'] === 'enabled'
  156. if (!figureModalEnabled) {
  157. return []
  158. }
  159. return EditorView.domEventHandlers({
  160. drop: evt => {
  161. if (!evt.dataTransfer || evt.dataTransfer.files.length === 0) {
  162. return
  163. }
  164. const file = evt.dataTransfer.files[0]
  165. if (!ALLOWED_MIME_TYPES.has(file.type)) {
  166. return
  167. }
  168. window.dispatchEvent(
  169. new CustomEvent<PastedImageData>('figure-modal:paste-image', {
  170. detail: {
  171. name: file.name,
  172. type: file.type,
  173. data: file,
  174. },
  175. })
  176. )
  177. },
  178. paste: evt => {
  179. if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
  180. return
  181. }
  182. const file = evt.clipboardData.files[0]
  183. if (!ALLOWED_MIME_TYPES.has(file.type)) {
  184. return
  185. }
  186. window.dispatchEvent(
  187. new CustomEvent<PastedImageData>('figure-modal:paste-image', {
  188. detail: {
  189. name: file.name,
  190. type: file.type,
  191. data: file,
  192. },
  193. })
  194. )
  195. },
  196. })
  197. }