figure-modal.ts 4.8 KB

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