theme.ts 8.0 KB

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