cursor-highlights.ts 5.2 KB

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