theme.ts 9.7 KB

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