theme.ts 8.3 KB

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