layer.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. /**
  2. * This file is adapted from CodeMirror 6, licensed under the MIT license:
  3. * https://github.com/codemirror/view/blob/main/src/layer.ts
  4. */
  5. import {
  6. BlockInfo,
  7. BlockType,
  8. Direction,
  9. EditorView,
  10. Rect,
  11. RectangleMarker,
  12. } from '@codemirror/view'
  13. import { EditorSelection, SelectionRange } from '@codemirror/state'
  14. import { isVisual } from '../extensions/visual/visual'
  15. import { round } from 'lodash'
  16. function canAssumeUniformLineHeights(view: EditorView) {
  17. return !isVisual(view)
  18. }
  19. export const rectangleMarkerForRange = (
  20. view: EditorView,
  21. className: string,
  22. range: SelectionRange
  23. ): readonly RectangleMarker[] => {
  24. if (range.empty) {
  25. const pos = fullHeightCoordsAtPos(view, range.head, range.assoc || 1)
  26. if (!pos) {
  27. return []
  28. }
  29. const base = getBase(view)
  30. return [
  31. new RectangleMarker(
  32. className,
  33. pos.left - base.left,
  34. pos.top - base.top,
  35. null,
  36. pos.bottom - pos.top
  37. ),
  38. ]
  39. }
  40. return rectanglesForRange(view, className, range)
  41. }
  42. export function getBase(view: EditorView) {
  43. const rect = view.scrollDOM.getBoundingClientRect()
  44. const left =
  45. view.textDirection === Direction.LTR
  46. ? rect.left
  47. : rect.right - view.scrollDOM.clientWidth
  48. return {
  49. left: left - view.scrollDOM.scrollLeft,
  50. top: rect.top - view.scrollDOM.scrollTop,
  51. }
  52. }
  53. function wrappedLine(
  54. view: EditorView,
  55. pos: number,
  56. inside: { from: number; to: number }
  57. ) {
  58. const range = EditorSelection.cursor(pos)
  59. return {
  60. from: Math.max(
  61. inside.from,
  62. view.moveToLineBoundary(range, false, true).from
  63. ),
  64. to: Math.min(inside.to, view.moveToLineBoundary(range, true, true).from),
  65. type: BlockType.Text,
  66. }
  67. }
  68. function blockAt(view: EditorView, pos: number): BlockInfo {
  69. const line = view.lineBlockAt(pos)
  70. if (Array.isArray(line.type))
  71. for (const l of line.type) {
  72. if (
  73. l.to > pos ||
  74. (l.to === pos && (l.to === line.to || l.type === BlockType.Text))
  75. )
  76. return l
  77. }
  78. return line as any
  79. }
  80. // Like coordsAtPos, provides screen coordinates for a document position, but
  81. // unlike coordsAtPos, the top and bottom represent the full height of the
  82. // visual line rather than the top and bottom of the text. To do this, it relies
  83. // on the assumption that all text in the document has the same height and that
  84. // the line contains no widget or decoration that changes the height of the
  85. // line. This is, I am fairly certain, a safe assumption in source mode but not
  86. // in rich text, so in rich text mode this function just returns coordsAtPos.
  87. export function fullHeightCoordsAtPos(
  88. view: EditorView,
  89. pos: number,
  90. side?: -2 | -1 | 1 | 2 | undefined
  91. ): Rect | null {
  92. // @ts-ignore CodeMirror has incorrect type on coordsAtPos
  93. const coords = view.coordsAtPos(pos, side)
  94. if (!coords) {
  95. return null
  96. }
  97. if (!canAssumeUniformLineHeights(view)) {
  98. return coords
  99. }
  100. const { left, right } = coords
  101. const halfLeading =
  102. (view.defaultLineHeight - (coords.bottom - coords.top)) / 2
  103. return {
  104. left,
  105. right,
  106. top: round(coords.top - halfLeading, 2),
  107. bottom: round(coords.bottom + halfLeading, 2),
  108. }
  109. }
  110. // Added to range rectangle's vertical extent to prevent rounding
  111. // errors from introducing gaps in the rendered content.
  112. const Epsilon = 0.01
  113. function rectanglesForRange(
  114. view: EditorView,
  115. className: string,
  116. range: SelectionRange
  117. ): RectangleMarker[] {
  118. if (range.to <= view.viewport.from || range.from >= view.viewport.to) {
  119. return []
  120. }
  121. const from = Math.max(range.from, view.viewport.from)
  122. const to = Math.min(range.to, view.viewport.to)
  123. const ltr = view.textDirection === Direction.LTR
  124. const content = view.contentDOM
  125. const contentRect = content.getBoundingClientRect()
  126. const base = getBase(view)
  127. const lineElt = content.querySelector('.cm-line')
  128. const lineStyle = lineElt && window.getComputedStyle(lineElt)
  129. const leftSide =
  130. contentRect.left +
  131. (lineStyle
  132. ? parseInt(lineStyle.paddingLeft) +
  133. Math.min(0, parseInt(lineStyle.textIndent))
  134. : 0)
  135. const rightSide =
  136. contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0)
  137. const startBlock = blockAt(view, from)
  138. const endBlock = blockAt(view, to)
  139. let visualStart: { from: number; to: number } | null =
  140. startBlock.type === BlockType.Text ? startBlock : null
  141. let visualEnd: { from: number; to: number } | null =
  142. endBlock.type === BlockType.Text ? endBlock : null
  143. if (view.lineWrapping) {
  144. if (visualStart) visualStart = wrappedLine(view, from, visualStart)
  145. if (visualEnd) visualEnd = wrappedLine(view, to, visualEnd)
  146. }
  147. if (visualStart && visualEnd && visualStart.from === visualEnd.from) {
  148. return pieces(drawForLine(range.from, range.to, visualStart))
  149. } else {
  150. const top = visualStart
  151. ? drawForLine(range.from, null, visualStart)
  152. : drawForWidget(startBlock, false)
  153. const bottom = visualEnd
  154. ? drawForLine(null, range.to, visualEnd)
  155. : drawForWidget(endBlock, true)
  156. const between = []
  157. if (
  158. (visualStart || startBlock).to <
  159. (visualEnd || endBlock).from - (visualStart && visualEnd ? 1 : 0)
  160. )
  161. between.push(piece(leftSide, top.bottom, rightSide, bottom.top))
  162. else if (
  163. top.bottom < bottom.top &&
  164. view.elementAtHeight((top.bottom + bottom.top) / 2).type ===
  165. BlockType.Text
  166. )
  167. top.bottom = bottom.top = (top.bottom + bottom.top) / 2
  168. return pieces(top).concat(between).concat(pieces(bottom))
  169. }
  170. function piece(left: number, top: number, right: number, bottom: number) {
  171. return new RectangleMarker(
  172. className,
  173. left - base.left,
  174. top - base.top - Epsilon,
  175. right - left,
  176. bottom - top + Epsilon
  177. )
  178. }
  179. function pieces({
  180. top,
  181. bottom,
  182. horizontal,
  183. }: {
  184. top: number
  185. bottom: number
  186. horizontal: number[]
  187. }) {
  188. const pieces = []
  189. for (let i = 0; i < horizontal.length; i += 2)
  190. pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom))
  191. return pieces
  192. }
  193. // Gets passed from/to in line-local positions
  194. function drawForLine(
  195. from: null | number,
  196. to: null | number,
  197. line: { from: number; to: number }
  198. ) {
  199. let top = 1e9
  200. let bottom = -1e9
  201. const horizontal: number[] = []
  202. function addSpan(
  203. from: number,
  204. fromOpen: boolean,
  205. to: number,
  206. toOpen: boolean,
  207. dir: Direction
  208. ) {
  209. // Passing 2/-2 is a kludge to force the view to return
  210. // coordinates on the proper side of block widgets, since
  211. // normalizing the side there, though appropriate for most
  212. // coordsAtPos queries, would break selection drawing.
  213. const fromCoords = fullHeightCoordsAtPos(
  214. view,
  215. from,
  216. (from === line.to ? -2 : 2) as any
  217. )
  218. const toCoords = fullHeightCoordsAtPos(
  219. view,
  220. to,
  221. (to === line.from ? 2 : -2) as any
  222. )
  223. // coordsAtPos can sometimes return null even when the document position
  224. // is within the viewport. It's not clear exactly when this happens;
  225. // sometimes, the editor has previously failed to complete a measure.
  226. if (!fromCoords || !toCoords) {
  227. return
  228. }
  229. top = Math.min(fromCoords.top, toCoords.top, top)
  230. bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom)
  231. if (dir === Direction.LTR)
  232. horizontal.push(
  233. ltr && fromOpen ? leftSide : fromCoords.left,
  234. ltr && toOpen ? rightSide : toCoords.right
  235. )
  236. else
  237. horizontal.push(
  238. !ltr && toOpen ? leftSide : toCoords.left,
  239. !ltr && fromOpen ? rightSide : fromCoords.right
  240. )
  241. }
  242. const start = from ?? line.from
  243. const end = to ?? line.to
  244. // Split the range by visible range and document line
  245. for (const r of view.visibleRanges)
  246. if (r.to > start && r.from < end) {
  247. for (
  248. let pos = Math.max(r.from, start), endPos = Math.min(r.to, end);
  249. ;
  250. ) {
  251. const docLine = view.state.doc.lineAt(pos)
  252. for (const span of view.bidiSpans(docLine)) {
  253. const spanFrom = span.from + docLine.from
  254. const spanTo = span.to + docLine.from
  255. if (spanFrom >= endPos) break
  256. if (spanTo > pos)
  257. addSpan(
  258. Math.max(spanFrom, pos),
  259. from === null && spanFrom <= start,
  260. Math.min(spanTo, endPos),
  261. to === null && spanTo >= end,
  262. span.dir
  263. )
  264. }
  265. pos = docLine.to + 1
  266. if (pos >= endPos) break
  267. }
  268. }
  269. if (horizontal.length === 0)
  270. addSpan(start, from === null, end, to === null, view.textDirection)
  271. return { top, bottom, horizontal }
  272. }
  273. function drawForWidget(block: BlockInfo, top: boolean) {
  274. const y = contentRect.top + (top ? block.top : block.bottom)
  275. return { top: y, bottom: y, horizontal: [] }
  276. }
  277. }