cursor-highlights.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import {
  2. RangeSet,
  3. RangeValue,
  4. StateEffect,
  5. StateField,
  6. TransactionSpec,
  7. } from '@codemirror/state'
  8. import {
  9. EditorView,
  10. hoverTooltip,
  11. layer,
  12. RectangleMarker,
  13. Tooltip,
  14. } from '@codemirror/view'
  15. import { findValidPosition } from '../utils/position'
  16. import { Highlight } from '../../../../../types/highlight'
  17. import { fullHeightCoordsAtPos, getBase } from '../utils/layer'
  18. export const cursorHighlights = () => {
  19. return [
  20. cursorHighlightsState,
  21. cursorHighlightsLayer,
  22. hoverTooltip(cursorTooltip, {
  23. hoverTime: 1,
  24. }),
  25. EditorView.theme({
  26. '.ol-cm-cursorHighlightsLayer': {
  27. zIndex: 100,
  28. contain: 'size style',
  29. pointerEvents: 'none',
  30. },
  31. '.ol-cm-cursorHighlight': {
  32. color: 'hsl(var(--hue), 70%, 50%)',
  33. borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
  34. display: 'inline-block',
  35. height: '1.6em',
  36. position: 'absolute',
  37. pointerEvents: 'none',
  38. },
  39. '.ol-cm-cursorHighlight:before': {
  40. content: "''",
  41. position: 'absolute',
  42. left: '-2px',
  43. top: '-5px',
  44. height: '5px',
  45. width: '5px',
  46. borderWidth: '3px 3px 2px 2px',
  47. borderStyle: 'solid',
  48. borderColor: 'inherit',
  49. },
  50. '.ol-cm-cursorHighlightLabel': {
  51. lineHeight: 1,
  52. backgroundColor: 'hsl(var(--hue), 70%, 50%)',
  53. padding: '1em 1em',
  54. fontSize: '0.8rem',
  55. fontFamily: 'Lato, sans-serif',
  56. color: 'white',
  57. fontWeight: 700,
  58. whiteSpace: 'nowrap',
  59. pointerEvents: 'none',
  60. },
  61. }),
  62. ]
  63. }
  64. class HighlightRangeValue extends RangeValue {
  65. constructor(public highlight: Highlight) {
  66. super()
  67. }
  68. }
  69. const cursorHighlightsState = StateField.define<RangeSet<HighlightRangeValue>>({
  70. create() {
  71. return RangeSet.empty
  72. },
  73. update(value, tr) {
  74. for (const effect of tr.effects) {
  75. if (effect.is(setCursorHighlightsEffect)) {
  76. const highlightRanges = []
  77. for (const highlight of effect.value) {
  78. // NOTE: other highlight types could be handled here
  79. if ('cursor' in highlight) {
  80. try {
  81. const { row, column } = highlight.cursor
  82. const pos = findValidPosition(tr.state.doc, row + 1, column)
  83. highlightRanges.push(
  84. new HighlightRangeValue(highlight).range(pos)
  85. )
  86. } catch (error) {
  87. // ignore invalid highlights
  88. console.debug('invalid highlight position', error)
  89. }
  90. }
  91. }
  92. return RangeSet.of(highlightRanges, true)
  93. }
  94. }
  95. if (tr.docChanged) {
  96. value = value.map(tr.changes)
  97. }
  98. return value
  99. },
  100. })
  101. const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => {
  102. const highlights: Highlight[] = []
  103. view.state
  104. .field(cursorHighlightsState)
  105. .between(pos, pos, (from, to, value) => {
  106. highlights.push(value.highlight)
  107. })
  108. if (highlights.length === 0) {
  109. return null
  110. }
  111. return {
  112. pos,
  113. end: pos,
  114. above: true,
  115. create: () => {
  116. const dom = document.createElement('div')
  117. dom.classList.add('ol-cm-cursorTooltip')
  118. for (const highlight of highlights) {
  119. const label = document.createElement('div')
  120. label.classList.add('ol-cm-cursorHighlightLabel')
  121. label.style.setProperty('--hue', highlight.hue)
  122. label.textContent = highlight.label
  123. dom.appendChild(label)
  124. }
  125. return { dom }
  126. },
  127. }
  128. }
  129. const setCursorHighlightsEffect = StateEffect.define<Highlight[]>()
  130. export const setCursorHighlights = (
  131. cursorHighlights: Highlight[] = []
  132. ): TransactionSpec => {
  133. return {
  134. effects: setCursorHighlightsEffect.of(cursorHighlights),
  135. }
  136. }
  137. class CursorMarker extends RectangleMarker {
  138. constructor(
  139. private highlight: Highlight,
  140. className: string,
  141. left: number,
  142. top: number,
  143. width: number | null,
  144. height: number
  145. ) {
  146. super(className, left, top, width, height)
  147. }
  148. draw(): HTMLDivElement {
  149. const element = super.draw()
  150. element.style.setProperty('--hue', this.highlight.hue)
  151. return element
  152. }
  153. }
  154. // draw the collaborator cursors in a separate layer, so they don't affect word wrapping
  155. const cursorHighlightsLayer = layer({
  156. above: true,
  157. class: 'ol-cm-cursorHighlightsLayer',
  158. update: (update, layer) => {
  159. return (
  160. update.docChanged ||
  161. update.selectionSet ||
  162. update.transactions.some(tr =>
  163. tr.effects.some(effect => effect.is(setCursorHighlightsEffect))
  164. )
  165. )
  166. },
  167. markers(view) {
  168. const markers: CursorMarker[] = []
  169. const highlightRanges = view.state.field(cursorHighlightsState)
  170. const base = getBase(view)
  171. const { from, to } = view.viewport
  172. highlightRanges.between(from, to, (from, to, { highlight }) => {
  173. const pos = fullHeightCoordsAtPos(view, from)
  174. if (pos) {
  175. markers.push(
  176. new CursorMarker(
  177. highlight,
  178. 'ol-cm-cursorHighlight',
  179. pos.left - base.left,
  180. pos.top - base.top,
  181. null,
  182. pos.bottom - pos.top
  183. )
  184. )
  185. }
  186. })
  187. return markers
  188. },
  189. })