theme.ts 9.1 KB

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