cursor-highlights.ts 5.4 KB

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