sections.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
  2. import { EditorView } from '@codemirror/view'
  3. import { syntaxTree } from '@codemirror/language'
  4. import { ancestorOfNodeWithType } from '../../utils/tree-operations/ancestors'
  5. import { SyntaxNode } from '@lezer/common'
  6. export const findCurrentSectionHeadingLevel = (state: EditorState) => {
  7. const selections = state.selection.ranges.map(range =>
  8. rangeInfo(state, range)
  9. )
  10. const currentLevels = new Set(selections.map(item => item.level))
  11. return currentLevels.size === 1 ? selections[0] : null
  12. }
  13. type RangeInfo = {
  14. range: SelectionRange
  15. command?: SyntaxNode
  16. ctrlSeq?: SyntaxNode
  17. level: string
  18. }
  19. export const rangeInfo = (
  20. state: EditorState,
  21. range: SelectionRange
  22. ): RangeInfo => {
  23. const tree = syntaxTree(state)
  24. const fromNode = tree.resolveInner(range.from, 1)
  25. const fromAncestor = ancestorOfNodeWithType(fromNode, 'SectioningCommand')
  26. const toNode = tree.resolveInner(range.to, -1)
  27. const toAncestor = ancestorOfNodeWithType(toNode, 'SectioningCommand')
  28. const command = fromAncestor ?? toAncestor
  29. // from and to are both outside section heading
  30. if (!command) {
  31. return { range, level: 'text' }
  32. }
  33. if (fromAncestor && toAncestor) {
  34. // from and to are inside different section headings
  35. if (fromAncestor !== toAncestor) {
  36. return { range, level: 'text' }
  37. }
  38. } else {
  39. // the range isn't empty and only one end is inside a section heading
  40. if (!range.empty) {
  41. return { range, level: 'text' }
  42. }
  43. }
  44. const ctrlSeq = command.firstChild
  45. if (!ctrlSeq) {
  46. return { range, level: 'text' }
  47. }
  48. const level = state.sliceDoc(ctrlSeq.from + 1, ctrlSeq.to).trim()
  49. return { command, ctrlSeq, level, range }
  50. }
  51. export const setSectionHeadingLevel = (view: EditorView, level: string) => {
  52. view.dispatch(
  53. view.state.changeByRange(range => {
  54. const info = rangeInfo(view.state, range)
  55. if (level === info.level) {
  56. return { range }
  57. }
  58. if (level === 'text' && info.command) {
  59. // remove
  60. const argument = info.command.getChild('SectioningArgument')
  61. if (argument) {
  62. const content = view.state.sliceDoc(
  63. argument.from + 1,
  64. argument.to - 1
  65. )
  66. // map through the prefix only
  67. const changedRange = range.map(
  68. view.state.changes([
  69. { from: info.command.from, to: argument.from + 1, insert: '' },
  70. ]),
  71. 1
  72. )
  73. return {
  74. range: changedRange,
  75. changes: [
  76. {
  77. from: info.command.from,
  78. to: info.command.to,
  79. insert: content,
  80. },
  81. ],
  82. }
  83. }
  84. return { range }
  85. } else if (info.level === 'text') {
  86. // add
  87. const insert = {
  88. prefix: `\\${level}{`,
  89. suffix: '}',
  90. }
  91. const originalRange = range
  92. const line = view.state.doc.lineAt(range.anchor)
  93. if (range.empty) {
  94. // expand range to cover the whole line
  95. range = EditorSelection.range(line.from, line.to)
  96. } else {
  97. if (range.from !== line.from) {
  98. insert.prefix = '\n' + insert.prefix
  99. }
  100. if (range.to !== line.to) {
  101. insert.suffix += '\n'
  102. }
  103. }
  104. const content = view.state.sliceDoc(range.from, range.to)
  105. // map through the prefix only
  106. const changedRange = originalRange.map(
  107. view.state.changes([
  108. { from: range.from, insert: `${insert.prefix}` },
  109. ]),
  110. 1
  111. )
  112. return {
  113. range: changedRange,
  114. // create a single change, including the content
  115. changes: [
  116. {
  117. from: range.from,
  118. to: range.to,
  119. insert: `${insert.prefix}${content}${insert.suffix}`,
  120. },
  121. ],
  122. }
  123. } else {
  124. // change
  125. if (!info.ctrlSeq) {
  126. return { range }
  127. }
  128. const changes = view.state.changes([
  129. {
  130. from: info.ctrlSeq.from + 1,
  131. to: info.ctrlSeq.to,
  132. insert: level,
  133. },
  134. ])
  135. return {
  136. range: range.map(changes),
  137. changes,
  138. }
  139. }
  140. }),
  141. { scrollIntoView: true }
  142. )
  143. }