cursor-highlights.ts 5.4 KB

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