theme.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { EditorView } from '@codemirror/view'
  2. import { Annotation, Compartment, TransactionSpec } from '@codemirror/state'
  3. import { syntaxHighlighting } from '@codemirror/language'
  4. import { classHighlighter } from './class-highlighter'
  5. import classNames from 'classnames'
  6. import {
  7. FontFamily,
  8. LineHeight,
  9. OverallTheme,
  10. userStyles,
  11. } from '@/shared/utils/styles'
  12. const optionsThemeConf = new Compartment()
  13. const selectedThemeConf = new Compartment()
  14. export const themeOptionsChange = Annotation.define<boolean>()
  15. type Options = {
  16. fontSize: number
  17. fontFamily: FontFamily
  18. lineHeight: LineHeight
  19. overallTheme: OverallTheme
  20. }
  21. export const theme = (options: Options) => [
  22. baseTheme,
  23. staticTheme,
  24. /**
  25. * Syntax highlighting, using a highlighter which maps tags to class names.
  26. */
  27. syntaxHighlighting(classHighlighter),
  28. optionsThemeConf.of(createThemeFromOptions(options)),
  29. selectedThemeConf.of([]),
  30. ]
  31. export const setOptionsTheme = (options: Options): TransactionSpec => {
  32. return {
  33. effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),
  34. annotations: themeOptionsChange.of(true),
  35. }
  36. }
  37. export const setEditorTheme = async (
  38. editorTheme: string
  39. ): Promise<TransactionSpec> => {
  40. const theme = await loadSelectedTheme(editorTheme)
  41. return {
  42. effects: selectedThemeConf.reconfigure(theme),
  43. }
  44. }
  45. const svgUrl = (content: string) =>
  46. `url('data:image/svg+xml,${encodeURIComponent(
  47. `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">${content}</svg>`
  48. )}')`
  49. const createThemeFromOptions = ({
  50. fontSize = 12,
  51. fontFamily = 'monaco',
  52. lineHeight = 'normal',
  53. overallTheme = '',
  54. }: Options) => {
  55. // Theme styles that depend on settings.
  56. const styles = userStyles({ fontSize, fontFamily, lineHeight })
  57. return [
  58. EditorView.editorAttributes.of({
  59. class: classNames(
  60. overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light'
  61. ),
  62. style: Object.entries({
  63. '--font-size': styles.fontSize,
  64. '--source-font-family': styles.fontFamily,
  65. '--line-height': styles.lineHeight,
  66. })
  67. .map(([key, value]) => `${key}: ${value}`)
  68. .join(';'),
  69. }),
  70. // set variables for tooltips, which are outside the editor
  71. // TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
  72. EditorView.theme({
  73. '.cm-tooltip': {
  74. '--font-size': styles.fontSize,
  75. '--source-font-family': styles.fontFamily,
  76. '--line-height': styles.lineHeight,
  77. },
  78. }),
  79. ]
  80. }
  81. /**
  82. * Base styles that can have &dark and &light variants
  83. */
  84. const baseTheme = EditorView.baseTheme({
  85. '.cm-content': {
  86. fontSize: 'var(--font-size)',
  87. fontFamily: 'var(--source-font-family)',
  88. lineHeight: 'var(--line-height)',
  89. },
  90. '.cm-cursor-primary': {
  91. fontSize: 'var(--font-size)',
  92. fontFamily: 'var(--source-font-family)',
  93. lineHeight: 'var(--line-height)',
  94. },
  95. '.cm-gutters': {
  96. fontSize: 'var(--font-size)',
  97. lineHeight: 'var(--line-height)',
  98. },
  99. '.cm-tooltip': {
  100. // NOTE: fontFamily is not set here, as most tooltips use the UI font
  101. fontSize: 'var(--font-size)',
  102. },
  103. '.cm-panel': {
  104. fontSize: 'var(--font-size)',
  105. },
  106. '.cm-foldGutter .cm-gutterElement > span': {
  107. height: 'calc(var(--font-size) * var(--line-height))',
  108. },
  109. '.cm-lineNumbers': {
  110. fontFamily: 'var(--source-font-family)',
  111. },
  112. // double the specificity to override the underline squiggle
  113. '.cm-lintRange.cm-lintRange': {
  114. backgroundImage: 'none',
  115. },
  116. // use a background color for lint error ranges
  117. '.cm-lintRange-error': {
  118. padding: 'var(--half-leading, 0) 0',
  119. background: 'rgba(255, 0, 0, 0.2)',
  120. // avoid highlighting nested error ranges
  121. '& .cm-lintRange-error': {
  122. background: 'none',
  123. },
  124. },
  125. '.cm-specialChar': {
  126. color: 'red',
  127. backgroundColor: 'rgba(255, 0, 0, 0.1)',
  128. },
  129. '.cm-widgetBuffer': {
  130. height: '1.3em',
  131. },
  132. '.cm-snippetFieldPosition': {
  133. display: 'inline-block',
  134. height: '1.3em',
  135. },
  136. // style the gutter fold button on hover
  137. '&dark .cm-foldGutter .cm-gutterElement > span:hover': {
  138. boxShadow: '0 1px 1px rgba(255, 255, 255, 0.2)',
  139. backgroundColor: 'rgba(255, 255, 255, 0.1)',
  140. },
  141. '&light .cm-foldGutter .cm-gutterElement > span:hover': {
  142. borderColor: 'rgba(0, 0, 0, 0.3)',
  143. boxShadow: '0 1px 1px rgba(255, 255, 255, 0.7)',
  144. backgroundColor: 'rgba(255, 255, 255, 0.2)',
  145. },
  146. '.cm-diagnosticSource': {
  147. display: 'none',
  148. },
  149. '.ol-cm-diagnostic-actions': {
  150. marginTop: '4px',
  151. },
  152. '.cm-diagnostic:last-of-type .ol-cm-diagnostic-actions': {
  153. marginBottom: '4px',
  154. },
  155. '.cm-vim-panel input': {
  156. color: 'inherit',
  157. },
  158. })
  159. /**
  160. * Theme styles that don't depend on settings.
  161. */
  162. // TODO: move some/all of these into baseTheme?
  163. const staticTheme = EditorView.theme({
  164. // make the editor fill the available height
  165. '&': {
  166. height: '100%',
  167. textRendering: 'optimizeSpeed',
  168. fontVariantNumeric: 'slashed-zero',
  169. },
  170. // remove the outline from the focused editor
  171. '&.cm-editor.cm-focused:not(:focus-visible)': {
  172. outline: 'none',
  173. },
  174. // override default styles for the search panel
  175. '.cm-panel.cm-search label': {
  176. display: 'inline-flex',
  177. alignItems: 'center',
  178. fontWeight: 'normal',
  179. },
  180. '.cm-selectionLayer': {
  181. zIndex: -10,
  182. },
  183. // remove the right-hand border from the gutter
  184. // ensure the gutter doesn't shrink
  185. '.cm-gutters': {
  186. borderRight: 'none',
  187. flexShrink: 0,
  188. },
  189. // style the gutter fold button
  190. // TODO: add a class to this element for easier theming
  191. '.cm-foldGutter .cm-gutterElement > span': {
  192. border: '1px solid transparent',
  193. borderRadius: '3px',
  194. display: 'inline-flex',
  195. flexDirection: 'column',
  196. justifyContent: 'center',
  197. color: 'rgba(109, 109, 109, 0.7)',
  198. },
  199. // reduce the padding around line numbers
  200. '.cm-lineNumbers .cm-gutterElement': {
  201. padding: '0',
  202. userSelect: 'none',
  203. },
  204. // make cursor visible with reduced opacity when the editor is not focused
  205. '&:not(.cm-focused) > .cm-scroller > .cm-cursorLayer .cm-cursor': {
  206. display: 'block',
  207. opacity: 0.2,
  208. },
  209. // make the cursor wider, and use the themed color
  210. '.cm-cursor, .cm-dropCursor': {
  211. borderWidth: '2px',
  212. marginLeft: '-1px', // half the border width
  213. borderLeftColor: 'inherit',
  214. },
  215. // remove border from hover tooltips (e.g. cursor highlights)
  216. '.cm-tooltip-hover': {
  217. border: 'none',
  218. },
  219. // use the same style as Ace for snippet fields
  220. '.cm-snippetField': {
  221. background: 'rgba(194, 193, 208, 0.09)',
  222. border: '1px dotted rgba(211, 208, 235, 0.62)',
  223. },
  224. // style the fold placeholder
  225. '.cm-foldPlaceholder': {
  226. boxSizing: 'border-box',
  227. display: 'inline-block',
  228. height: '11px',
  229. width: '1.8em',
  230. marginTop: '-2px',
  231. verticalAlign: 'middle',
  232. backgroundImage:
  233. 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII="),url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=")',
  234. backgroundRepeat: 'no-repeat, repeat-x',
  235. backgroundPosition: 'center center, top left',
  236. color: 'transparent',
  237. border: '1px solid black',
  238. borderRadius: '2px',
  239. },
  240. // align the lint icons with the line numbers
  241. '.cm-gutter-lint .cm-gutterElement': {
  242. padding: '0.3em',
  243. },
  244. // reset the default style for the lint gutter error marker, which uses :before
  245. '.cm-lint-marker-error:before': {
  246. content: 'normal',
  247. },
  248. // set a new icon for the lint gutter error marker
  249. '.cm-lint-marker-error': {
  250. content: svgUrl(
  251. `<circle cx="20" cy="20" r="15" fill="#f87" stroke="#f43" stroke-width="6"/>`
  252. ),
  253. },
  254. // set a new icon for the lint gutter warning marker
  255. '.cm-lint-marker-warning': {
  256. content: svgUrl(
  257. `<path fill="#FCC483" stroke="#DE8014" stroke-width="6" stroke-linejoin="round" d="M20 6L37 35L3 35Z"/>`
  258. ),
  259. },
  260. })
  261. const themeCache = new Map<string, any>()
  262. const loadSelectedTheme = async (editorTheme: string) => {
  263. if (!editorTheme) {
  264. editorTheme = 'textmate' // use the default theme if unset
  265. }
  266. if (!themeCache.has(editorTheme)) {
  267. const { theme, highlightStyle, dark } = await import(
  268. /* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
  269. )
  270. const extension = [
  271. EditorView.theme(theme, { dark }),
  272. EditorView.theme(highlightStyle, { dark }),
  273. ]
  274. themeCache.set(editorTheme, extension)
  275. }
  276. return themeCache.get(editorTheme)
  277. }