line-wrapping-indentation.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import {
  2. Decoration,
  3. DecorationSet,
  4. EditorView,
  5. ViewPlugin,
  6. ViewUpdate,
  7. } from '@codemirror/view'
  8. import { type Range, StateEffect, StateField } from '@codemirror/state'
  9. import { sourceOnly } from './visual/visual'
  10. const MAX_INDENT_FRACTION = 0.9
  11. const setMaxIndentEffect = StateEffect.define<number>()
  12. /**
  13. * Calculates the indentation needed for each line based on the spaces and tabs at the start of the line.
  14. * Decorates each line with a `style` attribute, to ensure that wrapped lines share the same indentation.
  15. *
  16. * NOTE: performance increases linearly with document size, but decorating only the viewport causes unacceptable layout shifts when scrolling.
  17. */
  18. export const lineWrappingIndentation = (visual: boolean) => {
  19. // this extension should only be active in the source editor
  20. return sourceOnly(visual, [
  21. // store the current maxIndent, based on the clientWidth
  22. StateField.define<number>({
  23. create() {
  24. return 0
  25. },
  26. update(value, tr) {
  27. for (const effect of tr.effects) {
  28. if (effect.is(setMaxIndentEffect)) {
  29. value = effect.value
  30. }
  31. }
  32. return value
  33. },
  34. provide(field) {
  35. return [
  36. // calculate the max indent when the geometry changes
  37. ViewPlugin.define(view => {
  38. const measure = {
  39. key: 'line-wrapping-indentation-max-indent',
  40. read(view: EditorView) {
  41. return (
  42. (view.contentDOM.clientWidth / view.defaultCharacterWidth) *
  43. MAX_INDENT_FRACTION
  44. )
  45. },
  46. write(value: number, view: EditorView) {
  47. if (view.state.field(field) !== value) {
  48. window.setTimeout(() => {
  49. view.dispatch({
  50. effects: setMaxIndentEffect.of(value),
  51. })
  52. })
  53. }
  54. },
  55. }
  56. return {
  57. update(update: ViewUpdate) {
  58. if (update.geometryChanged) {
  59. view.requestMeasure(measure)
  60. }
  61. },
  62. }
  63. }),
  64. // rebuild the decorations when needed
  65. ViewPlugin.define<{ decorations: DecorationSet }>(
  66. view => {
  67. let previousMaxIndent = 0
  68. const value = {
  69. decorations: buildDecorations(view, view.state.field(field)),
  70. update(update: ViewUpdate) {
  71. const maxIndent = view.state.field(field)
  72. if (maxIndent !== previousMaxIndent) {
  73. value.decorations = buildDecorations(view, maxIndent)
  74. } else if (update.geometryChanged || update.viewportChanged) {
  75. value.decorations = updateDecorations(
  76. value.decorations,
  77. update,
  78. maxIndent
  79. )
  80. }
  81. previousMaxIndent = maxIndent
  82. },
  83. }
  84. return value
  85. },
  86. {
  87. decorations: value => value.decorations,
  88. }
  89. ),
  90. ]
  91. },
  92. }),
  93. ])
  94. }
  95. export const buildDecorations = (view: EditorView, maxIndent: number) => {
  96. const { state } = view
  97. const { doc, tabSize } = state
  98. const decorations: Range<Decoration>[] = []
  99. let from = 0
  100. for (const line of doc.iterLines()) {
  101. const indent = calculateIndent(line, tabSize, maxIndent)
  102. if (indent) {
  103. decorations.push(lineIndentDecoration(indent).range(from))
  104. }
  105. from += line.length + 1
  106. }
  107. return Decoration.set(decorations)
  108. }
  109. export const updateDecorations = (
  110. decorations: DecorationSet,
  111. update: ViewUpdate,
  112. maxIndent: number
  113. ) => {
  114. const add: Range<Decoration>[] = []
  115. const { doc: startDoc } = update.startState
  116. const { doc, tabSize } = update.state
  117. const changedLinesFrom = new Set()
  118. let filterFrom = doc.length
  119. let filterTo = 0
  120. update.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
  121. // remove changed lines
  122. const fromALineNumber = startDoc.lineAt(fromA).number
  123. const toALineNumber = startDoc.lineAt(toA).number
  124. for (
  125. let lineNumber = fromALineNumber;
  126. lineNumber <= toALineNumber;
  127. lineNumber++
  128. ) {
  129. const line = startDoc.line(lineNumber)
  130. changedLinesFrom.add(line.from)
  131. filterFrom = Math.min(line.from, filterFrom)
  132. filterTo = Math.max(line.from, filterTo)
  133. }
  134. // add changed lines
  135. const fromBLineNumber = doc.lineAt(fromB).number
  136. const toBLineNumber = doc.lineAt(toB).number
  137. for (
  138. let lineNumber = fromBLineNumber;
  139. lineNumber <= toBLineNumber;
  140. lineNumber++
  141. ) {
  142. const line = doc.line(lineNumber)
  143. const indent = calculateIndent(line.text, tabSize, maxIndent)
  144. if (indent) {
  145. add.push(lineIndentDecoration(indent).range(line.from))
  146. }
  147. }
  148. })
  149. return decorations
  150. .update({
  151. filter(from) {
  152. return !changedLinesFrom.has(from)
  153. },
  154. filterFrom,
  155. filterTo,
  156. })
  157. .map(update.changes)
  158. .update({ add })
  159. }
  160. const lineIndentDecoration = (indent: number) =>
  161. Decoration.line({
  162. attributes: {
  163. // style: `text-indent: ${indent}ch hanging`, // "hanging" would be ideal, when browsers support it
  164. style: `text-indent: -${indent}ch; padding-left: calc(${indent}ch + 6px)`, // add 6px to account for existing padding-left
  165. },
  166. })
  167. // calculate the character width of whitespace at the start of a line
  168. const calculateIndent = (line: string, tabSize: number, maxIndent: number) => {
  169. let indent = 0
  170. for (const char of line) {
  171. if (char === ' ') {
  172. indent++
  173. } else if (char === '\t') {
  174. indent += tabSize - (indent % tabSize)
  175. } else {
  176. break
  177. }
  178. }
  179. return Math.min(indent, maxIndent)
  180. }