cursor-highlights.ts 5.3 KB

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