Sfoglia il codice sorgente

Ensure that each editor theme is only created once (#17095)

GitOrigin-RevId: 3551e02fab44fae7fcab5cb12886d45969e3990f
Alf Eaton 2 anni fa
parent
commit
3f29aa2195

+ 30 - 19
services/web/frontend/js/features/history/extensions/theme.ts

@@ -13,6 +13,7 @@ export type Options = {
 const optionsThemeConf = new Compartment()
 
 export const theme = (options: Options) => [
+  baseTheme,
   optionsThemeConf.of(createThemeFromOptions(options)),
 ]
 
@@ -35,36 +36,46 @@ const createThemeFromOptions = ({
   // Theme styles that depend on settings
   const fontFamilyValue = fontFamilies[fontFamily]?.join(', ')
   return [
-    EditorView.theme({
-      '&.cm-editor': {
+    EditorView.editorAttributes.of({
+      style: Object.entries({
         '--font-size': `${fontSize}px`,
         '--source-font-family': fontFamilyValue,
         '--line-height': lineHeights[lineHeight],
-      },
-      '.cm-content': {
-        fontSize: 'var(--font-size)',
-        fontFamily: 'var(--source-font-family)',
-        lineHeight: 'var(--line-height)',
-        color: '#000',
-      },
-      '.cm-gutters': {
-        fontSize: 'var(--font-size)',
-        lineHeight: 'var(--line-height)',
-      },
+      })
+        .map(([key, value]) => `${key}: ${value}`)
+        .join(';'),
+    }),
+    // Set variables for tooltips, which are outside the editor
+    // TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
+    EditorView.baseTheme({
       '.cm-tooltip': {
-        // Set variables for tooltips, which are outside the editor
         '--font-size': `${fontSize}px`,
         '--source-font-family': fontFamilyValue,
-        // NOTE: fontFamily is not set here, as most tooltips use the UI font
-        fontSize: 'var(--font-size)',
-      },
-      '.cm-lineNumbers': {
-        fontFamily: 'var(--source-font-family)',
       },
     }),
   ]
 }
 
+const baseTheme = EditorView.baseTheme({
+  '.cm-content': {
+    fontSize: 'var(--font-size)',
+    fontFamily: 'var(--source-font-family)',
+    lineHeight: 'var(--line-height)',
+    color: '#000',
+  },
+  '.cm-gutters': {
+    fontSize: 'var(--font-size)',
+    lineHeight: 'var(--line-height)',
+  },
+  '.cm-lineNumbers': {
+    fontFamily: 'var(--source-font-family)',
+  },
+  '.cm-tooltip': {
+    // NOTE: fontFamily is not set here, as most tooltips use the UI font
+    fontSize: 'var(--font-size)',
+  },
+})
+
 export const setOptionsTheme = (options: Options): TransactionSpec => {
   return {
     effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),

+ 10 - 8
services/web/frontend/js/features/source-editor/extensions/annotations.ts

@@ -24,16 +24,18 @@ export const annotations = () => [
   lintGutter({
     hoverTime: 0,
   }),
-  /**
-   * A theme which moves the lint gutter outside the line numbers.
-   */
-  EditorView.baseTheme({
-    '.cm-gutter-lint': {
-      order: -1,
-    },
-  }),
+  annotationsTheme,
 ]
 
+/**
+ * A theme which moves the lint gutter outside the line numbers.
+ */
+const annotationsTheme = EditorView.baseTheme({
+  '.cm-gutter-lint': {
+    order: -1,
+  },
+})
+
 export const lintSourceConfig = {
   delay: 100,
   // Show highlights only for errors

+ 7 - 5
services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts

@@ -134,9 +134,11 @@ export const bracketSelection = (): Extension[] => [
       return false
     },
   }),
-  EditorView.baseTheme({
-    '.cm-matchingBracket': {
-      pointerEvents: 'none',
-    },
-  }),
+  matchingBracketTheme,
 ]
+
+const matchingBracketTheme = EditorView.baseTheme({
+  '.cm-matchingBracket': {
+    pointerEvents: 'none',
+  },
+})

+ 39 - 37
services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts

@@ -26,49 +26,51 @@ export const cursorHighlights = () => {
   return [
     cursorHighlightsState,
     cursorHighlightsLayer,
+    cursorHighlightsTheme,
     hoverTooltip(cursorTooltip, {
       hoverTime: 1,
     }),
-    EditorView.theme({
-      '.ol-cm-cursorHighlightsLayer': {
-        zIndex: 100,
-        contain: 'size style',
-        pointerEvents: 'none',
-      },
-      '.ol-cm-cursorHighlight': {
-        color: 'hsl(var(--hue), 70%, 50%)',
-        borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
-        display: 'inline-block',
-        height: '1.6em',
-        position: 'absolute',
-        pointerEvents: 'none',
-      },
-      '.ol-cm-cursorHighlight:before': {
-        content: "''",
-        position: 'absolute',
-        left: '-2px',
-        top: '-5px',
-        height: '5px',
-        width: '5px',
-        borderWidth: '3px 3px 2px 2px',
-        borderStyle: 'solid',
-        borderColor: 'inherit',
-      },
-      '.ol-cm-cursorHighlightLabel': {
-        lineHeight: 1,
-        backgroundColor: 'hsl(var(--hue), 70%, 50%)',
-        padding: '1em 1em',
-        fontSize: '0.8rem',
-        fontFamily: 'Lato, sans-serif',
-        color: 'white',
-        fontWeight: 700,
-        whiteSpace: 'nowrap',
-        pointerEvents: 'none',
-      },
-    }),
   ]
 }
 
+const cursorHighlightsTheme = EditorView.theme({
+  '.ol-cm-cursorHighlightsLayer': {
+    zIndex: 100,
+    contain: 'size style',
+    pointerEvents: 'none',
+  },
+  '.ol-cm-cursorHighlight': {
+    color: 'hsl(var(--hue), 70%, 50%)',
+    borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
+    display: 'inline-block',
+    height: '1.6em',
+    position: 'absolute',
+    pointerEvents: 'none',
+  },
+  '.ol-cm-cursorHighlight:before': {
+    content: "''",
+    position: 'absolute',
+    left: '-2px',
+    top: '-5px',
+    height: '5px',
+    width: '5px',
+    borderWidth: '3px 3px 2px 2px',
+    borderStyle: 'solid',
+    borderColor: 'inherit',
+  },
+  '.ol-cm-cursorHighlightLabel': {
+    lineHeight: 1,
+    backgroundColor: 'hsl(var(--hue), 70%, 50%)',
+    padding: '1em 1em',
+    fontSize: '0.8rem',
+    fontFamily: 'Lato, sans-serif',
+    color: 'white',
+    fontWeight: 700,
+    whiteSpace: 'nowrap',
+    pointerEvents: 'none',
+  },
+})
+
 class HighlightRangeValue extends RangeValue {
   mapMode = MapMode.Simple
 

+ 11 - 13
services/web/frontend/js/features/source-editor/extensions/draw-selection.ts

@@ -12,24 +12,22 @@ import { updateHasReviewPanelToggledEffect } from './changes/change-manager'
  * of the coords to cover the full line height.
  */
 export const drawSelection = () => {
-  return [cursorLayer, selectionLayer, hideNativeSelection]
+  return [cursorLayer, selectionLayer, Prec.highest(hideNativeSelection)]
 }
 
 const canHidePrimary = !browser.ios
 
-const hideNativeSelection = Prec.highest(
-  EditorView.theme({
-    '.cm-line': {
-      'caret-color': canHidePrimary ? 'transparent !important' : null,
-      '& ::selection': {
-        backgroundColor: 'transparent !important',
-      },
-      '&::selection': {
-        backgroundColor: 'transparent !important',
-      },
+const hideNativeSelection = EditorView.theme({
+  '.cm-line': {
+    'caret-color': canHidePrimary ? 'transparent !important' : null,
+    '& ::selection': {
+      backgroundColor: 'transparent !important',
     },
-  })
-)
+    '&::selection': {
+      backgroundColor: 'transparent !important',
+    },
+  },
+})
 
 const cursorLayer = layer({
   above: true,

+ 7 - 5
services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts

@@ -70,10 +70,12 @@ export const emptyLineFiller = () => {
         },
       }
     ),
-    EditorView.baseTheme({
-      '.ol-cm-filler': {
-        padding: '0 2px',
-      },
-    }),
+    emptyLineFillerTheme,
   ]
 }
+
+const emptyLineFillerTheme = EditorView.baseTheme({
+  '.ol-cm-filler': {
+    padding: '0 2px',
+  },
+})

+ 44 - 42
services/web/frontend/js/features/source-editor/extensions/go-to-line.ts

@@ -17,49 +17,51 @@ export const goToLinePanel = () => {
         },
       ])
     ),
-    EditorView.baseTheme({
-      '.cm-panel.cm-gotoLine': {
-        padding: '10px',
+    gotoLineTheme,
+  ]
+}
+
+const gotoLineTheme = EditorView.baseTheme({
+  '.cm-panel.cm-gotoLine': {
+    padding: '10px',
+    fontSize: '14px',
+    '& label': {
+      margin: 0,
+      fontSize: '14px',
+      '& .cm-textfield': {
+        margin: '0 10px',
+        maxWidth: '100px',
+        height: '34px',
+        padding: '5px 16px',
         fontSize: '14px',
-        '& label': {
-          margin: 0,
-          fontSize: '14px',
-          '& .cm-textfield': {
-            margin: '0 10px',
-            maxWidth: '100px',
-            height: '34px',
-            padding: '5px 16px',
-            fontSize: '14px',
-            fontWeight: 'normal',
-            lineHeight: 'var(--line-height-base)',
-            color: 'var(--input-color)',
-            backgroundColor: '#fff',
-            backgroundImage: 'none',
-            borderRadius: 'var(--input-border-radius)',
-            boxShadow: 'inset 0 1px 1px rgb(0 0 0 / 8%)',
-            transition:
-              'border-color ease-in-out .15s, box-shadow ease-in-out .15s',
-            '&:focus-visible': {
-              outline: 'none',
-            },
-            '&:focus': {
-              borderColor: 'var(--input-border-focus)',
-            },
-          },
+        fontWeight: 'normal',
+        lineHeight: 'var(--line-height-base)',
+        color: 'var(--input-color)',
+        backgroundColor: '#fff',
+        backgroundImage: 'none',
+        borderRadius: 'var(--input-border-radius)',
+        boxShadow: 'inset 0 1px 1px rgb(0 0 0 / 8%)',
+        transition:
+          'border-color ease-in-out .15s, box-shadow ease-in-out .15s',
+        '&:focus-visible': {
+          outline: 'none',
         },
-        '& .cm-button': {
-          padding: '4px 16px 5px',
-          textTransform: 'capitalize',
-          fontSize: '14px',
-          lineHeight: 'var(--line-height-base)',
-          userSelect: 'none',
-          backgroundImage: 'none',
-          backgroundColor: 'var(--btn-default-bg)',
-          borderRadius: 'var(--btn-border-radius-base)',
-          border: '0 solid transparent',
-          color: '#fff',
+        '&:focus': {
+          borderColor: 'var(--input-border-focus)',
         },
       },
-    }),
-  ]
-}
+    },
+    '& .cm-button': {
+      padding: '4px 16px 5px',
+      textTransform: 'capitalize',
+      fontSize: '14px',
+      lineHeight: 'var(--line-height-base)',
+      userSelect: 'none',
+      backgroundImage: 'none',
+      backgroundColor: 'var(--btn-default-bg)',
+      borderRadius: 'var(--btn-border-radius-base)',
+      border: '0 solid transparent',
+      color: '#fff',
+    },
+  },
+})

+ 7 - 5
services/web/frontend/js/features/source-editor/extensions/highlight-active-line.ts

@@ -20,14 +20,16 @@ export const highlightActiveLine = (visual: boolean) => {
   return sourceOnly(visual, [
     activeLineLayer,
     singleLineHighlighter,
-    EditorView.baseTheme({
-      '.ol-cm-activeLineLayer': {
-        pointerEvents: 'none',
-      },
-    }),
+    highlightActiveLineTheme,
   ])
 }
 
+const highlightActiveLineTheme = EditorView.baseTheme({
+  '.ol-cm-activeLineLayer': {
+    pointerEvents: 'none',
+  },
+})
+
 /**
  * Line decoration approach used for non-wrapped lines, adapted from built-in
  * CodeMirror 6 highlightActiveLine, licensed under the MIT license:

+ 2 - 4
services/web/frontend/js/features/source-editor/extensions/inline-background.ts

@@ -36,10 +36,8 @@ function measureHalfLeading(view: EditorView) {
 }
 
 function createTheme(halfLeading: number) {
-  return EditorView.theme({
-    '.cm-content': {
-      '--half-leading': halfLeading + 'px',
-    },
+  return EditorView.contentAttributes.of({
+    style: `--half-leading: ${halfLeading}px`,
   })
 }
 

+ 120 - 120
services/web/frontend/js/features/source-editor/extensions/search.ts

@@ -251,126 +251,126 @@ export const search = () => {
         }
       }
     }),
+    searchFormTheme,
+  ]
+}
 
-    // search form theme
-    EditorView.theme({
-      '.ol-cm-search-form': {
-        padding: '10px',
-        display: 'flex',
-        gap: '10px',
-        background: 'var(--ol-blue-gray-1)',
-        '--ol-cm-search-form-focus-shadow':
-          'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
-        '--ol-cm-search-form-error-shadow':
-          'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--input-shadow-danger-color)',
-      },
-      '.ol-cm-search-controls': {
-        display: 'grid',
-        gridTemplateColumns: 'auto auto',
-        gridTemplateRows: 'auto auto',
-        gap: '10px',
-      },
-      '.ol-cm-search-form-row': {
-        display: 'flex',
-        gap: '10px',
-        justifyContent: 'space-between',
-      },
-      '.ol-cm-search-form-group': {
-        display: 'flex',
-        gap: '10px',
-        alignItems: 'center',
-      },
-      '.ol-cm-search-input-group': {
-        border: '1px solid var(--input-border)',
-        borderRadius: '20px',
-        background: 'white',
-        width: '100%',
-        maxWidth: '25em',
-        '& input[type="text"]': {
-          background: 'none',
-          boxShadow: 'none',
-        },
-        '& input[type="text"]:focus': {
-          outline: 'none',
-          boxShadow: 'none',
-        },
-        '& .btn.btn': {
-          background: 'var(--ol-blue-gray-0)',
-          color: 'var(--ol-blue-gray-3)',
-          borderRadius: '50%',
-          height: '2em',
-          display: 'inline-flex',
-          alignItems: 'center',
-          justifyContent: 'center',
-          width: '2em',
-          marginRight: '3px',
-          '&.checked': {
-            color: '#fff',
-            backgroundColor: 'var(--ol-blue)',
-          },
-          '&:active': {
-            boxShadow: 'none',
-          },
-        },
-        '&:focus-within': {
-          borderColor: 'var(--input-border-focus)',
-          boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
-        },
-      },
-      '.ol-cm-search-input-group.ol-cm-search-input-error': {
-        '&:focus-within': {
-          borderColor: 'var(--input-border-danger)',
-          boxShadow: 'var(--ol-cm-search-form-error-shadow)',
-        },
-      },
-      '.input-group .ol-cm-search-form-input': {
-        border: 'none',
-      },
-      '.ol-cm-search-input-button': {
-        background: '#fff',
-        color: 'inherit',
-        border: 'none',
-      },
-      '.ol-cm-search-input-button.focused': {
-        borderColor: 'var(--input-border-focus)',
-        boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
-      },
-      '.ol-cm-search-form-button-group': {
-        flexShrink: 0,
-      },
-      '.ol-cm-search-form-position': {
-        flexShrink: 0,
-        color: 'var(--ol-blue-gray-4)',
-      },
-      '.ol-cm-search-hidden-inputs': {
-        position: 'absolute',
-        left: '-10000px',
-      },
-      '.ol-cm-search-form-close': {
-        flex: 1,
-      },
-      '.ol-cm-search-replace-input': {
-        order: 3,
-      },
-      '.ol-cm-search-replace-buttons': {
-        order: 4,
-      },
-      '.ol-cm-stored-selection': {
-        background: 'rgba(125, 125, 125, 0.1)',
-        paddingTop: 'var(--half-leading)',
-        paddingBottom: 'var(--half-leading)',
-      },
-      // set the default "match" style
-      '.cm-selectionMatch, .cm-searchMatch': {
-        backgroundColor: 'transparent',
-        outlineOffset: '-1px',
-        paddingTop: 'var(--half-leading)',
-        paddingBottom: 'var(--half-leading)',
+const searchFormTheme = EditorView.theme({
+  '.ol-cm-search-form': {
+    padding: '10px',
+    display: 'flex',
+    gap: '10px',
+    background: 'var(--ol-blue-gray-1)',
+    '--ol-cm-search-form-focus-shadow':
+      'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
+    '--ol-cm-search-form-error-shadow':
+      'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--input-shadow-danger-color)',
+  },
+  '.ol-cm-search-controls': {
+    display: 'grid',
+    gridTemplateColumns: 'auto auto',
+    gridTemplateRows: 'auto auto',
+    gap: '10px',
+  },
+  '.ol-cm-search-form-row': {
+    display: 'flex',
+    gap: '10px',
+    justifyContent: 'space-between',
+  },
+  '.ol-cm-search-form-group': {
+    display: 'flex',
+    gap: '10px',
+    alignItems: 'center',
+  },
+  '.ol-cm-search-input-group': {
+    border: '1px solid var(--input-border)',
+    borderRadius: '20px',
+    background: 'white',
+    width: '100%',
+    maxWidth: '25em',
+    '& input[type="text"]': {
+      background: 'none',
+      boxShadow: 'none',
+    },
+    '& input[type="text"]:focus': {
+      outline: 'none',
+      boxShadow: 'none',
+    },
+    '& .btn.btn': {
+      background: 'var(--ol-blue-gray-0)',
+      color: 'var(--ol-blue-gray-3)',
+      borderRadius: '50%',
+      height: '2em',
+      display: 'inline-flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      width: '2em',
+      marginRight: '3px',
+      '&.checked': {
+        color: '#fff',
+        backgroundColor: 'var(--ol-blue)',
       },
-      // make sure selectionMatch inside searchMatch doesn't have a background colour
-      '.cm-searchMatch .cm-selectionMatch': {
-        backgroundColor: 'transparent !important',
+      '&:active': {
+        boxShadow: 'none',
       },
-    }),
-  ]
-}
+    },
+    '&:focus-within': {
+      borderColor: 'var(--input-border-focus)',
+      boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
+    },
+  },
+  '.ol-cm-search-input-group.ol-cm-search-input-error': {
+    '&:focus-within': {
+      borderColor: 'var(--input-border-danger)',
+      boxShadow: 'var(--ol-cm-search-form-error-shadow)',
+    },
+  },
+  '.input-group .ol-cm-search-form-input': {
+    border: 'none',
+  },
+  '.ol-cm-search-input-button': {
+    background: '#fff',
+    color: 'inherit',
+    border: 'none',
+  },
+  '.ol-cm-search-input-button.focused': {
+    borderColor: 'var(--input-border-focus)',
+    boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
+  },
+  '.ol-cm-search-form-button-group': {
+    flexShrink: 0,
+  },
+  '.ol-cm-search-form-position': {
+    flexShrink: 0,
+    color: 'var(--ol-blue-gray-4)',
+  },
+  '.ol-cm-search-hidden-inputs': {
+    position: 'absolute',
+    left: '-10000px',
+  },
+  '.ol-cm-search-form-close': {
+    flex: 1,
+  },
+  '.ol-cm-search-replace-input': {
+    order: 3,
+  },
+  '.ol-cm-search-replace-buttons': {
+    order: 4,
+  },
+  '.ol-cm-stored-selection': {
+    background: 'rgba(125, 125, 125, 0.1)',
+    paddingTop: 'var(--half-leading)',
+    paddingBottom: 'var(--half-leading)',
+  },
+  // set the default "match" style
+  '.cm-selectionMatch, .cm-searchMatch': {
+    backgroundColor: 'transparent',
+    outlineOffset: '-1px',
+    paddingTop: 'var(--half-leading)',
+    paddingBottom: 'var(--half-leading)',
+  },
+  // make sure selectionMatch inside searchMatch doesn't have a background colour
+  '.cm-searchMatch .cm-selectionMatch': {
+    backgroundColor: 'transparent !important',
+  },
+})

+ 15 - 13
services/web/frontend/js/features/source-editor/extensions/spelling/index.ts

@@ -31,19 +31,7 @@ type Options = { spellCheckLanguage?: string }
  */
 export const spelling = ({ spellCheckLanguage }: Options) => {
   return [
-    EditorView.baseTheme({
-      '.ol-cm-spelling-error': {
-        textDecorationColor: 'red',
-        textDecorationLine: 'underline',
-        textDecorationStyle: 'dotted',
-        textDecorationThickness: '2px',
-        textDecorationSkipInk: 'none',
-        textUnderlineOffset: '0.2em',
-      },
-      '.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
-        borderWidth: '0',
-      },
-    }),
+    spellingTheme,
     parserWatcher,
     spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
     spellCheckField,
@@ -54,6 +42,20 @@ export const spelling = ({ spellCheckLanguage }: Options) => {
   ]
 }
 
+const spellingTheme = EditorView.baseTheme({
+  '.ol-cm-spelling-error': {
+    textDecorationColor: 'red',
+    textDecorationLine: 'underline',
+    textDecorationStyle: 'dotted',
+    textDecorationThickness: '2px',
+    textDecorationSkipInk: 'none',
+    textUnderlineOffset: '0.2em',
+  },
+  '.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
+    borderWidth: '0',
+  },
+})
+
 const spellCheckField = StateField.define<SpellChecker | null>({
   create(state) {
     const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)

+ 50 - 38
services/web/frontend/js/features/source-editor/extensions/theme.ts

@@ -74,44 +74,21 @@ const createThemeFromOptions = ({
   return [
     EditorView.editorAttributes.of({
       class: overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light',
-    }),
-    EditorView.theme({
-      '&.cm-editor': {
-        // set variables
+      style: Object.entries({
         '--font-size': `${fontSize}px`,
         '--source-font-family': fontFamilies[fontFamily]?.join(', '),
         '--line-height': lineHeights[lineHeight],
-      },
-      '.cm-content': {
-        fontSize: 'var(--font-size)',
-        fontFamily: 'var(--source-font-family)',
-        lineHeight: 'var(--line-height)',
-      },
-      '.cm-cursor-primary': {
-        fontSize: 'var(--font-size)',
-        fontFamily: 'var(--source-font-family)',
-        lineHeight: 'var(--line-height)',
-      },
-      '.cm-gutters': {
-        fontSize: 'var(--font-size)',
-        lineHeight: 'var(--line-height)',
-      },
+      })
+        .map(([key, value]) => `${key}: ${value}`)
+        .join(';'),
+    }),
+    // set variables for tooltips, which are outside the editor
+    // TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
+    EditorView.theme({
       '.cm-tooltip': {
-        // set variables for tooltips, which are outside the editor
         '--font-size': `${fontSize}px`,
         '--source-font-family': fontFamilies[fontFamily]?.join(', '),
         '--line-height': lineHeights[lineHeight],
-        // NOTE: fontFamily is not set here, as most tooltips use the UI font
-        fontSize: 'var(--font-size)',
-      },
-      '.cm-panel': {
-        fontSize: 'var(--font-size)',
-      },
-      '.cm-foldGutter .cm-gutterElement > span': {
-        height: 'calc(var(--font-size) * var(--line-height))',
-      },
-      '.cm-lineNumbers': {
-        fontFamily: 'var(--source-font-family)',
       },
     }),
   ]
@@ -121,6 +98,33 @@ const createThemeFromOptions = ({
  * Base styles that can have &dark and &light variants
  */
 const baseTheme = EditorView.baseTheme({
+  '.cm-content': {
+    fontSize: 'var(--font-size)',
+    fontFamily: 'var(--source-font-family)',
+    lineHeight: 'var(--line-height)',
+  },
+  '.cm-cursor-primary': {
+    fontSize: 'var(--font-size)',
+    fontFamily: 'var(--source-font-family)',
+    lineHeight: 'var(--line-height)',
+  },
+  '.cm-gutters': {
+    fontSize: 'var(--font-size)',
+    lineHeight: 'var(--line-height)',
+  },
+  '.cm-tooltip': {
+    // NOTE: fontFamily is not set here, as most tooltips use the UI font
+    fontSize: 'var(--font-size)',
+  },
+  '.cm-panel': {
+    fontSize: 'var(--font-size)',
+  },
+  '.cm-foldGutter .cm-gutterElement > span': {
+    height: 'calc(var(--font-size) * var(--line-height))',
+  },
+  '.cm-lineNumbers': {
+    fontFamily: 'var(--source-font-family)',
+  },
   // use a background color for lint error ranges
   '.cm-lintRange-error': {
     padding: 'var(--half-leading, 0) 0',
@@ -255,17 +259,25 @@ const staticTheme = EditorView.theme({
   },
 })
 
+const themeCache = new Map<string, any>()
+
 const loadSelectedTheme = async (editorTheme: string) => {
   if (!editorTheme) {
     editorTheme = 'textmate' // use the default theme if unset
   }
 
-  const { theme, highlightStyle, dark } = await import(
-    /* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
-  )
+  if (!themeCache.has(editorTheme)) {
+    const { theme, highlightStyle, dark } = await import(
+      /* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
+    )
 
-  return [
-    EditorView.theme(theme, { dark }),
-    EditorView.theme(highlightStyle, { dark }),
-  ]
+    const extension = [
+      EditorView.theme(theme, { dark }),
+      EditorView.theme(highlightStyle, { dark }),
+    ]
+
+    themeCache.set(editorTheme, extension)
+  }
+
+  return themeCache.get(editorTheme)
 }

+ 244 - 245
services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts

@@ -21,285 +21,284 @@ export function createToolbarPanel() {
   return { dom, top: true }
 }
 
-/**
- * A panel which contains the editor toolbar, provided by a state field which allows the toolbar to be toggled,
- * and styles for the toolbar.
- */
-export const toolbarPanel = () => [
-  toolbarState,
-  EditorView.theme({
-    '.ol-cm-toolbar': {
-      backgroundColor: 'var(--editor-toolbar-bg)',
-      color: 'var(--toolbar-btn-color)',
-      flex: 1,
-      display: 'flex',
-      overflowX: 'hidden',
+const toolbarTheme = EditorView.theme({
+  '.ol-cm-toolbar': {
+    backgroundColor: 'var(--editor-toolbar-bg)',
+    color: 'var(--toolbar-btn-color)',
+    flex: 1,
+    display: 'flex',
+    overflowX: 'hidden',
+  },
+  '&.overall-theme-dark .ol-cm-toolbar': {
+    '& img': {
+      filter: 'invert(1)',
     },
-    '&.overall-theme-dark .ol-cm-toolbar': {
-      '& img': {
-        filter: 'invert(1)',
-      },
+  },
+  '.ol-cm-toolbar-overflow': {
+    display: 'flex',
+    flexWrap: 'wrap',
+  },
+  '#popover-toolbar-overflow': {
+    padding: 0,
+    borderColor: 'rgba(125, 125, 125, 0.2)',
+    backgroundColor: 'var(--editor-toolbar-bg)',
+    color: 'var(--toolbar-btn-color)',
+    '& .popover-content': {
+      padding: 0,
     },
-    '.ol-cm-toolbar-overflow': {
-      display: 'flex',
-      flexWrap: 'wrap',
+    '& .arrow': {
+      borderBottomColor: 'rgba(125, 125, 125, 0.2)',
+      '&:after': {
+        borderBottomColor: 'var(--editor-toolbar-bg)',
+      },
     },
-    '#popover-toolbar-overflow': {
+  },
+  '.ol-cm-toolbar-button-menu-popover': {
+    '& > .popover-content': {
       padding: 0,
-      borderColor: 'rgba(125, 125, 125, 0.2)',
+    },
+    '& .arrow': {
+      display: 'none',
+    },
+    '& .list-group': {
+      marginBottom: 0,
       backgroundColor: 'var(--editor-toolbar-bg)',
+      borderRadius: '4px',
+    },
+    '& .list-group-item': {
+      width: '100%',
+      textAlign: 'start',
+      display: 'flex',
+      alignItems: 'center',
+      gap: '5px',
       color: 'var(--toolbar-btn-color)',
-      '& .popover-content': {
-        padding: 0,
-      },
-      '& .arrow': {
-        borderBottomColor: 'rgba(125, 125, 125, 0.2)',
-        '&:after': {
-          borderBottomColor: 'var(--editor-toolbar-bg)',
-        },
+      borderColor: 'var(--editor-toolbar-bg)',
+      background: 'none',
+      '&:hover, &:focus': {
+        backgroundColor: 'rgba(125, 125, 125, 0.2)',
       },
     },
-    '.ol-cm-toolbar-button-menu-popover': {
-      '& > .popover-content': {
-        padding: 0,
-      },
-      '& .arrow': {
-        display: 'none',
-      },
-      '& .list-group': {
-        marginBottom: 0,
-        backgroundColor: 'var(--editor-toolbar-bg)',
-        borderRadius: '4px',
+  },
+  '.ol-cm-toolbar-button-group': {
+    display: 'flex',
+    alignItems: 'center',
+    whiteSpace: 'nowrap',
+    flexWrap: 'nowrap',
+    padding: '0 4px',
+    margin: '4px 0',
+    lineHeight: '1',
+    '&:not(:first-of-type)': {
+      borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
+      '&.ol-cm-toolbar-end': {
+        borderLeft: 'none',
       },
-      '& .list-group-item': {
-        width: '100%',
-        textAlign: 'start',
-        display: 'flex',
-        alignItems: 'center',
-        gap: '5px',
-        color: 'var(--toolbar-btn-color)',
-        borderColor: 'var(--editor-toolbar-bg)',
-        background: 'none',
-        '&:hover, &:focus': {
-          backgroundColor: 'rgba(125, 125, 125, 0.2)',
-        },
-      },
-    },
-    '.ol-cm-toolbar-button-group': {
-      display: 'flex',
-      alignItems: 'center',
-      whiteSpace: 'nowrap',
-      flexWrap: 'nowrap',
-      padding: '0 4px',
-      margin: '4px 0',
-      lineHeight: '1',
-      '&:not(:first-of-type)': {
-        borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
-        '&.ol-cm-toolbar-end': {
-          borderLeft: 'none',
-        },
-        '&.ol-cm-toolbar-stretch': {
-          flex: 1,
-        },
-        '&.overflow-hidden': {
-          borderLeft: 'none',
-        },
+      '&.ol-cm-toolbar-stretch': {
+        flex: 1,
       },
       '&.overflow-hidden': {
-        width: 0,
-        padding: 0,
+        borderLeft: 'none',
       },
     },
-    '.formatting-buttons-wrapper': {
-      flex: 1,
+    '&.overflow-hidden': {
+      width: 0,
+      padding: 0,
     },
-    '.ol-cm-toolbar-button': {
-      display: 'inline-flex',
-      alignItems: 'center',
-      justifyContent: 'center',
-      padding: '0',
-      margin: '0 1px',
-      backgroundColor: 'transparent',
-      border: 'none',
-      borderRadius: '1px',
-      lineHeight: '1',
-      width: '24px',
-      height: '24px',
-      overflow: 'hidden',
-      '&:hover, &:focus, &:active, &.active': {
-        backgroundColor: 'rgba(125, 125, 125, 0.1)',
-        color: 'inherit',
-        boxShadow: 'none',
-        '&[aria-disabled="true"]': {
-          opacity: '0.2',
-        },
-      },
-      '&.active, &:active': {
-        backgroundColor: 'rgba(125, 125, 125, 0.2)',
-      },
+  },
+  '.formatting-buttons-wrapper': {
+    flex: 1,
+  },
+  '.ol-cm-toolbar-button': {
+    display: 'inline-flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    padding: '0',
+    margin: '0 1px',
+    backgroundColor: 'transparent',
+    border: 'none',
+    borderRadius: '1px',
+    lineHeight: '1',
+    width: '24px',
+    height: '24px',
+    overflow: 'hidden',
+    '&:hover, &:focus, &:active, &.active': {
+      backgroundColor: 'rgba(125, 125, 125, 0.1)',
+      color: 'inherit',
+      boxShadow: 'none',
       '&[aria-disabled="true"]': {
         opacity: '0.2',
-        cursor: 'not-allowed',
-      },
-      '.overflow-hidden &': {
-        display: 'none',
-      },
-      '&.ol-cm-toolbar-button-math': {
-        fontFamily: '"Noto Serif", serif',
-        fontSize: '16px',
-        fontWeight: 700,
       },
     },
-    '&.overall-theme-dark .ol-cm-toolbar-button': {
-      opacity: 0.8,
-      '&:hover, &:focus, &:active, &.active': {
-        backgroundColor: 'rgba(125, 125, 125, 0.2)',
-      },
-      '&.active, &:active': {
-        backgroundColor: 'rgba(125, 125, 125, 0.4)',
-      },
-      '&[aria-disabled="true"]': {
-        opacity: 0.2,
-      },
+    '&.active, &:active': {
+      backgroundColor: 'rgba(125, 125, 125, 0.2)',
     },
-    '.ol-cm-toolbar-end': {
-      justifyContent: 'flex-end',
-      '& .badge': {
-        marginRight: '5px',
-      },
+    '&[aria-disabled="true"]': {
+      opacity: '0.2',
+      cursor: 'not-allowed',
     },
-    '.ol-cm-toolbar-overflow-toggle': {
+    '.overflow-hidden &': {
       display: 'none',
-      '&.ol-cm-toolbar-overflow-toggle-visible': {
-        display: 'flex',
-      },
     },
-    '.ol-cm-toolbar-menu-toggle': {
-      background: 'transparent',
-      boxShadow: 'none !important',
-      border: 'none',
-      whiteSpace: 'nowrap',
+    '&.ol-cm-toolbar-button-math': {
+      fontFamily: '"Noto Serif", serif',
+      fontSize: '16px',
+      fontWeight: 700,
+    },
+  },
+  '&.overall-theme-dark .ol-cm-toolbar-button': {
+    opacity: 0.8,
+    '&:hover, &:focus, &:active, &.active': {
+      backgroundColor: 'rgba(125, 125, 125, 0.2)',
+    },
+    '&.active, &:active': {
+      backgroundColor: 'rgba(125, 125, 125, 0.4)',
+    },
+    '&[aria-disabled="true"]': {
+      opacity: 0.2,
+    },
+  },
+  '.ol-cm-toolbar-end': {
+    justifyContent: 'flex-end',
+    '& .badge': {
+      marginRight: '5px',
+    },
+  },
+  '.ol-cm-toolbar-overflow-toggle': {
+    display: 'none',
+    '&.ol-cm-toolbar-overflow-toggle-visible': {
+      display: 'flex',
+    },
+  },
+  '.ol-cm-toolbar-menu-toggle': {
+    background: 'transparent',
+    boxShadow: 'none !important',
+    border: 'none',
+    whiteSpace: 'nowrap',
+    color: 'inherit',
+    borderRadius: '0',
+    opacity: 0.8,
+    width: '120px',
+    fontSize: '13px',
+    fontFamily: 'Lato',
+    fontWeight: '700',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    padding: '5px 6px',
+    '&:hover, &:focus, &.active': {
+      backgroundColor: 'rgba(125, 125, 125, 0.1)',
+      opacity: '1',
       color: 'inherit',
-      borderRadius: '0',
-      opacity: 0.8,
+    },
+    '& .caret': {
+      marginTop: '0',
+    },
+  },
+  '.ol-cm-toolbar-menu-popover': {
+    border: 'none',
+    borderRadius: '0',
+    borderBottomLeftRadius: '4px',
+    borderBottomRightRadius: '4px',
+    boxShadow: '0 2px 5px rgb(0 0 0 / 20%)',
+    backgroundColor: 'var(--editor-toolbar-bg)',
+    color: 'var(--toolbar-btn-color)',
+    padding: '0',
+    '&.bottom': {
+      marginTop: '1px',
+    },
+    '&.top': {
+      marginBottom: '1px',
+    },
+    '& .arrow': {
+      display: 'none',
+    },
+    '& .popover-content': {
+      padding: '0',
+    },
+    '& .ol-cm-toolbar-menu': {
       width: '120px',
-      fontSize: '13px',
-      fontFamily: 'Lato',
-      fontWeight: '700',
       display: 'flex',
-      alignItems: 'center',
-      justifyContent: 'space-between',
-      padding: '5px 6px',
-      '&:hover, &:focus, &.active': {
-        backgroundColor: 'rgba(125, 125, 125, 0.1)',
-        opacity: '1',
-        color: 'inherit',
-      },
-      '& .caret': {
-        marginTop: '0',
-      },
+      flexDirection: 'column',
+      boxSizing: 'border-box',
+      fontSize: '14px',
     },
-    '.ol-cm-toolbar-menu-popover': {
+    '& .ol-cm-toolbar-menu-item': {
       border: 'none',
-      borderRadius: '0',
-      borderBottomLeftRadius: '4px',
-      borderBottomRightRadius: '4px',
-      boxShadow: '0 2px 5px rgb(0 0 0 / 20%)',
-      backgroundColor: 'var(--editor-toolbar-bg)',
-      color: 'var(--toolbar-btn-color)',
-      padding: '0',
-      '&.bottom': {
-        marginTop: '1px',
-      },
-      '&.top': {
-        marginBottom: '1px',
+      background: 'none',
+      padding: '4px 12px',
+      height: '40px',
+      display: 'flex',
+      alignItems: 'center',
+      fontWeight: 'bold',
+      '&.ol-cm-toolbar-menu-item-active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.1)',
       },
-      '& .arrow': {
-        display: 'none',
+      '&:hover': {
+        backgroundColor: 'rgba(125, 125, 125, 0.2)',
+        color: 'inherit',
       },
-      '& .popover-content': {
-        padding: '0',
+      '&.section-level-section': {
+        fontSize: '1.44em',
       },
-      '& .ol-cm-toolbar-menu': {
-        width: '120px',
-        display: 'flex',
-        flexDirection: 'column',
-        boxSizing: 'border-box',
-        fontSize: '14px',
+      '&.section-level-subsection': {
+        fontSize: '1.2em',
       },
-      '& .ol-cm-toolbar-menu-item': {
-        border: 'none',
-        background: 'none',
-        padding: '4px 12px',
-        height: '40px',
-        display: 'flex',
-        alignItems: 'center',
-        fontWeight: 'bold',
-        '&.ol-cm-toolbar-menu-item-active': {
-          backgroundColor: 'rgba(125, 125, 125, 0.1)',
-        },
-        '&:hover': {
-          backgroundColor: 'rgba(125, 125, 125, 0.2)',
-          color: 'inherit',
-        },
-        '&.section-level-section': {
-          fontSize: '1.44em',
-        },
-        '&.section-level-subsection': {
-          fontSize: '1.2em',
-        },
-        '&.section-level-body': {
-          fontWeight: 'normal',
-        },
+      '&.section-level-body': {
+        fontWeight: 'normal',
       },
     },
-    '&.overall-theme-dark .ol-cm-toolbar-table-grid-popover': {
-      color: '#fff',
-    },
-    '&.overall-theme-dark .ol-cm-toolbar-table-grid': {
-      '& td.active': {
-        outlineColor: 'white',
-        background: 'rgb(125, 125, 125)',
-      },
+  },
+  '&.overall-theme-dark .ol-cm-toolbar-table-grid-popover': {
+    color: '#fff',
+  },
+  '&.overall-theme-dark .ol-cm-toolbar-table-grid': {
+    '& td.active': {
+      outlineColor: 'white',
+      background: 'rgb(125, 125, 125)',
     },
-    '.ol-cm-toolbar-table-grid': {
-      borderCollapse: 'separate',
-      tableLayout: 'fixed',
-      fontSize: '6px',
-      cursor: 'pointer',
-      width: '160px',
-      '& td': {
-        outline: '1px solid #E7E9EE',
-        outlineOffset: '-2px',
-        width: '16px',
-        height: '16px',
+  },
+  '.ol-cm-toolbar-table-grid': {
+    borderCollapse: 'separate',
+    tableLayout: 'fixed',
+    fontSize: '6px',
+    cursor: 'pointer',
+    width: '160px',
+    '& td': {
+      outline: '1px solid #E7E9EE',
+      outlineOffset: '-2px',
+      width: '16px',
+      height: '16px',
 
-        '&.active': {
-          outlineColor: '#3265B2',
-          background: '#F1F4F9',
-        },
+      '&.active': {
+        outlineColor: '#3265B2',
+        background: '#F1F4F9',
       },
     },
-    '.ol-cm-toolbar-table-size-label': {
-      maxWidth: '160px',
-      fontFamily: 'Lato, sans-serif',
-      fontSize: '12px',
-    },
-    '.ol-cm-toolbar-table-grid-popover': {
-      maxWidth: 'unset',
-      padding: '8px',
-      boxShadow: '0 5px 10px rgba(0, 0, 0, 0.2)',
-      borderRadius: '4px',
-      backgroundColor: 'var(--editor-toolbar-bg)',
-      pointerEvents: 'all',
-    },
-    '.ol-cm-toolbar-button-menu-popover-unstyled': {
-      maxWidth: 'unset',
-      background: 'transparent',
-      border: 0,
-      padding: '0 8px 8px 160px',
-      boxShadow: 'none',
-      pointerEvents: 'none',
-    },
-  }),
-]
+  },
+  '.ol-cm-toolbar-table-size-label': {
+    maxWidth: '160px',
+    fontFamily: 'Lato, sans-serif',
+    fontSize: '12px',
+  },
+  '.ol-cm-toolbar-table-grid-popover': {
+    maxWidth: 'unset',
+    padding: '8px',
+    boxShadow: '0 5px 10px rgba(0, 0, 0, 0.2)',
+    borderRadius: '4px',
+    backgroundColor: 'var(--editor-toolbar-bg)',
+    pointerEvents: 'all',
+  },
+  '.ol-cm-toolbar-button-menu-popover-unstyled': {
+    maxWidth: 'unset',
+    background: 'transparent',
+    border: 0,
+    padding: '0 8px 8px 160px',
+    boxShadow: 'none',
+    pointerEvents: 'none',
+  },
+})
+
+/**
+ * A panel which contains the editor toolbar, provided by a state field which allows the toolbar to be toggled,
+ * and styles for the toolbar.
+ */
+export const toolbarPanel = () => [toolbarState, toolbarTheme]

+ 54 - 52
services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx

@@ -42,6 +42,59 @@ export const storePastedContent = (
   effects: pastedContentEffect.of({ content, formatted }),
 })
 
+const pastedContentTheme = EditorView.baseTheme({
+  '.ol-cm-pasted-content-menu-toggle': {
+    background: 'none',
+    borderRadius: '8px',
+    border: '1px solid rgb(125, 125, 125)',
+    margin: '0 4px',
+    opacity: '0.7',
+    '&:hover': {
+      opacity: '1',
+    },
+  },
+  '.ol-cm-pasted-content-menu-popover': {
+    maxWidth: 'unset',
+    '& .popover-content': {
+      padding: 0,
+    },
+  },
+  '&dark .ol-cm-pasted-content-menu-popover': {
+    background: 'rgba(0, 0, 0)',
+  },
+  '.ol-cm-pasted-content-menu': {
+    display: 'flex',
+    flexDirection: 'column',
+    boxSizing: 'border-box',
+    fontSize: '14px',
+    fontFamily: '"Lato", sans-serif',
+  },
+  '.ol-cm-pasted-content-menu-item': {
+    border: 'none',
+    background: 'none',
+    padding: '8px 16px',
+    width: '100%',
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    whiteSpace: 'nowrap',
+    gap: '12px',
+    '&[aria-disabled="true"]': {
+      color: 'rgba(125, 125, 125, 0.5)',
+    },
+    '&:hover': {
+      backgroundColor: 'rgba(125, 125, 125, 0.2)',
+    },
+  },
+  '.ol-cm-pasted-content-menu-item-label': {
+    flex: 1,
+    textAlign: 'left',
+  },
+  '.ol-cm-pasted-content-menu-item-shortcut': {
+    textAlign: 'right',
+  },
+})
+
 export const pastedContent = StateField.define<{
   content: PastedContent
   formatted: boolean
@@ -88,58 +141,7 @@ export const pastedContent = StateField.define<{
 
         return Decoration.set(decorations, true)
       }),
-      EditorView.baseTheme({
-        '.ol-cm-pasted-content-menu-toggle': {
-          background: 'none',
-          borderRadius: '8px',
-          border: '1px solid rgb(125, 125, 125)',
-          margin: '0 4px',
-          opacity: '0.7',
-          '&:hover': {
-            opacity: '1',
-          },
-        },
-        '.ol-cm-pasted-content-menu-popover': {
-          maxWidth: 'unset',
-          '& .popover-content': {
-            padding: 0,
-          },
-        },
-        '&dark .ol-cm-pasted-content-menu-popover': {
-          background: 'rgba(0, 0, 0)',
-        },
-        '.ol-cm-pasted-content-menu': {
-          display: 'flex',
-          flexDirection: 'column',
-          boxSizing: 'border-box',
-          fontSize: '14px',
-          fontFamily: '"Lato", sans-serif',
-        },
-        '.ol-cm-pasted-content-menu-item': {
-          border: 'none',
-          background: 'none',
-          padding: '8px 16px',
-          width: '100%',
-          display: 'flex',
-          justifyContent: 'space-between',
-          alignItems: 'center',
-          whiteSpace: 'nowrap',
-          gap: '12px',
-          '&[aria-disabled="true"]': {
-            color: 'rgba(125, 125, 125, 0.5)',
-          },
-          '&:hover': {
-            backgroundColor: 'rgba(125, 125, 125, 0.2)',
-          },
-        },
-        '.ol-cm-pasted-content-menu-item-label': {
-          flex: 1,
-          textAlign: 'left',
-        },
-        '.ol-cm-pasted-content-menu-item-shortcut': {
-          textAlign: 'right',
-        },
-      }),
+      pastedContentTheme,
     ]
   },
 })

+ 2 - 4
services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts

@@ -439,10 +439,8 @@ const currentWidth = Facet.define<string, string>({
 function createContentWidthTheme(contentWidth: string) {
   return [
     currentWidth.of(contentWidth),
-    EditorView.theme({
-      '&.cm-editor': {
-        '--content-width': contentWidth,
-      },
+    EditorView.editorAttributes.of({
+      style: `--content-width: ${contentWidth}`,
     }),
   ]
 }