فهرست منبع

Merge pull request #20436 from overleaf/ii-bs5-editor-toolbar

[web] BS5 editor toolbar

GitOrigin-RevId: a517fd52d648d165e89231d6f5551c026a951c43
ilkin-overleaf 1 سال پیش
والد
کامیت
35728d7681
59فایلهای تغییر یافته به همراه1762 افزوده شده و 762 حذف شده
  1. 9 0
      services/web/frontend/extracted-translations.json
  2. 16 10
      services/web/frontend/js/features/pdf-preview/components/switch-to-editor-button.tsx
  3. 16 5
      services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
  4. 7 4
      services/web/frontend/js/features/source-editor/components/editor-switch.tsx
  5. 2 4
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx
  6. 57 46
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx
  7. 107 53
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx
  8. 40 36
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx
  9. 16 12
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
  10. 22 16
      services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx
  11. 21 24
      services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx
  12. 48 46
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx
  13. 25 22
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-project-source.tsx
  14. 96 71
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx
  15. 22 24
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx
  16. 16 8
      services/web/frontend/js/features/source-editor/components/switch-to-pdf-button.jsx
  17. 19 28
      services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx
  18. 34 15
      services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx
  19. 6 7
      services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
  20. 23 15
      services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx
  21. 18 7
      services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx
  22. 13 13
      services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
  23. 25 10
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx
  24. 46 13
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
  25. 14 6
      services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
  26. 1 1
      services/web/frontend/js/features/source-editor/languages/latex/codemirror-dev-tools.ts
  27. 99 0
      services/web/frontend/js/features/ui/components/bootstrap-3/toggle-button-group.tsx
  28. 5 1
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  29. 12 3
      services/web/frontend/js/features/ui/components/ol/ol-button.tsx
  30. 2 0
      services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx
  31. 42 0
      services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx
  32. 34 0
      services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx
  33. 60 0
      services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx
  34. 85 0
      services/web/frontend/js/features/ui/components/ol/ol-popover.tsx
  35. 42 0
      services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx
  36. 48 0
      services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx
  37. 20 13
      services/web/frontend/js/shared/components/beta-badge.tsx
  38. 153 69
      services/web/frontend/js/shared/components/select.tsx
  39. 0 61
      services/web/frontend/js/shared/components/switcher.tsx
  40. 42 15
      services/web/frontend/stories/switcher.stories.tsx
  41. 25 50
      services/web/frontend/stylesheets/app/editor/figure-modal.less
  42. 2 2
      services/web/frontend/stylesheets/app/editor/toolbar.less
  43. 28 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
  44. 2 0
      services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss
  45. 4 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  46. 11 0
      services/web/frontend/stylesheets/bootstrap-5/components/beta-badges.scss
  47. 1 1
      services/web/frontend/stylesheets/bootstrap-5/components/form.scss
  48. 3 0
      services/web/frontend/stylesheets/bootstrap-5/components/list-group.scss
  49. 15 0
      services/web/frontend/stylesheets/bootstrap-5/components/popover.scss
  50. 11 0
      services/web/frontend/stylesheets/bootstrap-5/components/select.scss
  51. 1 0
      services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
  52. 89 0
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/figure-modal.scss
  53. 6 1
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss
  54. 164 0
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
  55. 14 0
      services/web/frontend/stylesheets/components/button-groups.less
  56. 0 37
      services/web/frontend/stylesheets/components/switcher.less
  57. 0 1
      services/web/frontend/stylesheets/main-style.less
  58. 9 0
      services/web/locales/en.json
  59. 14 12
      services/web/test/frontend/components/shared/split-test-badge.spec.tsx

+ 9 - 0
services/web/frontend/extracted-translations.json

@@ -1551,7 +1551,9 @@
   "took_a_while": "",
   "toolbar_bullet_list": "",
   "toolbar_choose_section_heading_level": "",
+  "toolbar_code_visual_editor_switch": "",
   "toolbar_decrease_indent": "",
+  "toolbar_editor": "",
   "toolbar_format_bold": "",
   "toolbar_format_italic": "",
   "toolbar_increase_indent": "",
@@ -1562,7 +1564,10 @@
   "toolbar_insert_inline_math": "",
   "toolbar_insert_link": "",
   "toolbar_insert_math": "",
+  "toolbar_insert_math_and_symbols": "",
+  "toolbar_insert_misc": "",
   "toolbar_insert_table": "",
+  "toolbar_list_indentation": "",
   "toolbar_numbered_list": "",
   "toolbar_redo": "",
   "toolbar_selected_projects": "",
@@ -1571,8 +1576,12 @@
   "toolbar_selected_projects_restore": "",
   "toolbar_table_insert_size_table": "",
   "toolbar_table_insert_table_lowercase": "",
+  "toolbar_text_formatting": "",
+  "toolbar_text_style": "",
   "toolbar_toggle_symbol_palette": "",
   "toolbar_undo": "",
+  "toolbar_undo_redo_actions": "",
+  "toolbar_visibility": "",
   "tooltip_hide_filetree": "",
   "tooltip_hide_pdf": "",
   "tooltip_show_filetree": "",

+ 16 - 10
services/web/frontend/js/features/pdf-preview/components/switch-to-editor-button.tsx

@@ -1,6 +1,8 @@
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 
 function SwitchToEditorButton() {
@@ -21,17 +23,21 @@ function SwitchToEditorButton() {
   }
 
   return (
-    <Button
-      bsStyle={null}
-      bsSize="xs"
+    <OLButton
+      variant="secondary"
+      size="small"
       onClick={handleClick}
-      className="switch-to-editor-btn toolbar-btn-secondary btn-secondary"
+      bs3Props={{
+        bsSize: 'xsmall',
+        className: 'switch-to-editor-btn toolbar-btn-secondary',
+      }}
     >
-      <Icon type="code" className="toolbar-btn-secondary-icon" />
-      <span className="toolbar-btn-secondary-text">
-        {t('switch_to_editor')}
-      </span>
-    </Button>
+      <BootstrapVersionSwitcher
+        bs3={<Icon type="code" className="toolbar-btn-secondary-icon" />}
+        bs5={<MaterialIcon type="code" />}
+      />
+      {t('switch_to_editor')}
+    </OLButton>
   )
 }
 

+ 16 - 5
services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx

@@ -5,7 +5,7 @@ import {
   useCodeMirrorViewContext,
 } from './codemirror-context'
 import { searchPanelOpen } from '@codemirror/search'
-import { useResizeObserver } from '../../../shared/hooks/use-resize-observer'
+import { useResizeObserver } from '@/shared/hooks/use-resize-observer'
 import { ToolbarButton } from './toolbar/toolbar-button'
 import { ToolbarItems } from './toolbar/toolbar-items'
 import * as commands from '../extensions/toolbar/commands'
@@ -21,6 +21,8 @@ import { isVisual } from '../extensions/visual/visual'
 import { language } from '@codemirror/language'
 import { minimumListDepthForSelection } from '../utils/tree-operations/ancestors'
 import { debugConsole } from '@/utils/debugging'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import { useTranslation } from 'react-i18next'
 
 export const CodeMirrorToolbar = () => {
   const view = useCodeMirrorViewContext()
@@ -34,6 +36,7 @@ export const CodeMirrorToolbar = () => {
 }
 
 const Toolbar = memo(function Toolbar() {
+  const { t } = useTranslation()
   const state = useCodeMirrorStateContext()
   const view = useCodeMirrorViewContext()
 
@@ -140,7 +143,12 @@ const Toolbar = memo(function Toolbar() {
   const showActions = !state.readOnly && !insideTable
 
   return (
-    <div className="ol-cm-toolbar toolbar-editor" ref={elementRef}>
+    <div
+      role="toolbar"
+      aria-label={t('toolbar_editor')}
+      className="ol-cm-toolbar toolbar-editor"
+      ref={elementRef}
+    >
       <EditorSwitch />
       {showActions && (
         <ToolbarItems
@@ -179,19 +187,22 @@ const Toolbar = memo(function Toolbar() {
           label="Toggle Search"
           command={commands.toggleSearch}
           active={searchPanelOpen(state)}
-          icon="search"
+          icon={bsVersion({ bs5: 'search', bs3: 'search' }) as string}
         />
 
         <SwitchToPDFButton />
         <DetacherSynctexControl />
         <DetachCompileButtonWrapper />
       </div>
-      <div className="ol-cm-toolbar-button-group hidden">
+      <div
+        className="ol-cm-toolbar-button-group hidden"
+        aria-label={t('toolbar_visibility')}
+      >
         <ToolbarButton
           id="toolbar-expand-less"
           label="Hide Toolbar"
           command={toggleToolbar}
-          icon="caret-up"
+          icon={bsVersion({ bs5: 'arrow_drop_up', bs3: 'caret-up' }) as string}
           hidden // enable this once there's a way to show the toolbar again
         />
       </div>

+ 7 - 4
services/web/frontend/js/features/source-editor/components/editor-switch.tsx

@@ -1,6 +1,6 @@
 import { ChangeEvent, FC, memo, useCallback } from 'react'
 import useScopeValue from '@/shared/hooks/use-scope-value'
-import Tooltip from '@/shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import useTutorial from '@/shared/hooks/promotions/use-tutorial'
 import { sendMB } from '../../../infrastructure/event-tracking'
 import isValidTeXFile from '../../../main/is-valid-tex-file'
@@ -45,7 +45,10 @@ function EditorSwitch() {
   )
 
   return (
-    <div className="editor-toggle-switch">
+    <div
+      className="editor-toggle-switch"
+      aria-label={t('toolbar_code_visual_editor_switch')}
+    >
       <fieldset className="toggle-switch">
         <legend className="sr-only">Editor mode.</legend>
 
@@ -101,14 +104,14 @@ const RichTextToggle: FC<{
 
   if (disabled) {
     return (
-      <Tooltip
+      <OLTooltip
         description={t('visual_editor_is_only_available_for_tex_files')}
         id="rich-text-toggle-tooltip"
         overlayProps={{ placement: 'bottom' }}
         tooltipProps={{ className: 'tooltip-wide' }}
       >
         {toggle}
-      </Tooltip>
+      </OLTooltip>
     )
   }
 

+ 2 - 4
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-body.tsx

@@ -1,4 +1,3 @@
-import { Alert } from 'react-bootstrap'
 import {
   FigureModalSource,
   useFigureModalContext,
@@ -12,6 +11,7 @@ import { FigureModalCurrentProjectSource } from './file-sources/figure-modal-pro
 import { FigureModalUploadFileSource } from './file-sources/figure-modal-upload-source'
 import { FigureModalUrlSource } from './file-sources/figure-modal-url-source'
 import { useCallback } from 'react'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 const sourceModes = new Map([
   [FigureModalSource.FILE_TREE, FigureModalCurrentProjectSource],
@@ -44,9 +44,7 @@ export default function FigureModalBody() {
   return (
     <>
       {error && (
-        <Alert bsStyle="danger" onDismiss={onDismiss}>
-          {error}
-        </Alert>
+        <OLNotification type="error" onDismiss={onDismiss} content={error} />
       )}
       <Body />
       <FigureModalFigureOptions />

+ 57 - 46
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-footer.tsx

@@ -1,4 +1,3 @@
-import { Button } from 'react-bootstrap'
 import {
   FigureModalSource,
   useFigureModalContext,
@@ -7,6 +6,11 @@ import Icon from '../../../../shared/components/icon'
 import { FC } from 'react'
 import { useTranslation } from 'react-i18next'
 import { sendMB } from '../../../../infrastructure/event-tracking'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
 
 export const FigureModalFooter: FC<{
   onInsert: () => void
@@ -14,23 +18,16 @@ export const FigureModalFooter: FC<{
   onDelete: () => void
 }> = ({ onInsert, onCancel, onDelete }) => {
   const { t } = useTranslation()
+
   return (
-    <div className="figure-modal-footer">
-      <div className="figure-modal-help-buttons">
-        <HelpToggle />
-      </div>
-      <div className="figure-modal-actions">
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          type="button"
-          onClick={onCancel}
-        >
-          {t('cancel')}
-        </Button>
-        <FigureModalAction onInsert={onInsert} onDelete={onDelete} />
-      </div>
-    </div>
+    <>
+      <HelpToggle />
+      <OLButton variant="secondary" onClick={onCancel}>
+        {t('cancel')}
+      </OLButton>
+      <FigureModalAction onInsert={onInsert} onDelete={onDelete} />
+      <BootstrapVersionSwitcher bs3={<div className="clearfix" />} />
+    </>
   )
 }
 
@@ -39,25 +36,48 @@ const HelpToggle = () => {
   const { helpShown, dispatch } = useFigureModalContext()
   if (helpShown) {
     return (
-      <Button
-        bsStyle={null}
-        className="btn-link figure-modal-help-link"
+      <OLButton
+        variant="link"
+        className={classnames(
+          'figure-modal-help-link',
+          bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })
+        )}
         onClick={() => dispatch({ helpShown: false })}
       >
-        <Icon type="arrow-left" fw />
-        &nbsp;{t('back')}
-      </Button>
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="arrow-left" fw />}
+          bs5={
+            <span>
+              <MaterialIcon
+                type="arrow_left_alt"
+                className="align-text-bottom"
+              />
+            </span>
+          }
+        />{' '}
+        {t('back')}
+      </OLButton>
     )
   }
   return (
-    <Button
-      bsStyle={null}
-      className="btn-link figure-modal-help-link"
+    <OLButton
+      variant="link"
+      className={classnames(
+        'figure-modal-help-link',
+        bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })
+      )}
       onClick={() => dispatch({ helpShown: true })}
     >
-      <Icon type="question-circle" fw />
-      &nbsp;{t('help')}
-    </Button>
+      <BootstrapVersionSwitcher
+        bs3={<Icon type="question-circle" fw />}
+        bs5={
+          <span>
+            <MaterialIcon type="help" className="align-text-bottom" />
+          </span>
+        }
+      />{' '}
+      {t('help')}
+    </OLButton>
   )
 }
 
@@ -75,38 +95,29 @@ const FigureModalAction: FC<{
 
   if (sourcePickerShown) {
     return (
-      <Button
-        bsStyle={null}
-        className="btn-danger"
-        type="button"
-        onClick={onDelete}
-      >
+      <OLButton variant="danger" onClick={onDelete}>
         {t('delete_figure')}
-      </Button>
+      </OLButton>
     )
   }
 
   if (source === FigureModalSource.EDIT_FIGURE) {
     return (
-      <Button
-        bsStyle={null}
-        className="btn-success"
-        type="button"
+      <OLButton
+        variant="primary"
         onClick={() => {
           onInsert()
           sendMB('figure-modal-edit')
         }}
       >
         {t('done')}
-      </Button>
+      </OLButton>
     )
   }
 
   return (
-    <Button
-      bsStyle={null}
-      className="btn-success"
-      type="button"
+    <OLButton
+      variant="primary"
       disabled={getPath === undefined}
       onClick={() => {
         onInsert()
@@ -114,6 +125,6 @@ const FigureModalAction: FC<{
       }}
     >
       {t('insert_figure')}
-    </Button>
+    </OLButton>
   )
 }

+ 107 - 53
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-options.tsx

@@ -1,12 +1,18 @@
 import { FC } from 'react'
-import Icon from '../../../../shared/components/icon'
-import Tooltip from '../../../../shared/components/tooltip'
+import Icon from '@/shared/components/icon'
 import {
   useFigureModalContext,
   useFigureModalExistingFigureContext,
 } from './figure-modal-context'
-import { Switcher, SwitcherItem } from '../../../../shared/components/switcher'
 import { useTranslation } from 'react-i18next'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLToggleButtonGroup from '@/features/ui/components/ol/ol-toggle-button-group'
+import OLToggleButton from '@/features/ui/components/ol/ol-toggle-button'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 export const FigureModalFigureOptions: FC = () => {
   const { t } = useTranslation()
@@ -16,71 +22,119 @@ export const FigureModalFigureOptions: FC = () => {
   const { hasComplexGraphicsArgument } = useFigureModalExistingFigureContext()
   return (
     <>
-      <div className="figure-modal-checkbox-input">
-        <input
-          type="checkbox"
+      <OLFormGroup>
+        <OLFormCheckbox
           id="figure-modal-caption"
           data-cy="include-caption-option"
           defaultChecked={includeCaption}
           onChange={event => dispatch({ includeCaption: event.target.checked })}
+          className={bsVersion({ bs3: 'figure-modal-checkbox-input' })}
+          label={t('include_caption')}
         />
-        <label className="figure-modal-label" htmlFor="figure-modal-caption">
-          {t('include_caption')}
-        </label>
-      </div>
-      <div className="figure-modal-checkbox-input">
-        <input
-          type="checkbox"
+      </OLFormGroup>
+      <OLFormGroup>
+        <OLFormCheckbox
           id="figure-modal-label"
           data-cy="include-label-option"
           defaultChecked={includeLabel}
           onChange={event => dispatch({ includeLabel: event.target.checked })}
+          className={bsVersion({ bs3: 'figure-modal-checkbox-input' })}
+          label={
+            <span className="figure-modal-label-content">
+              {t('include_label')}
+              <br />
+              <small className="text-muted">
+                {t(
+                  'used_when_referring_to_the_figure_elsewhere_in_the_document'
+                )}
+              </small>
+            </span>
+          }
         />
-        <label htmlFor="figure-modal-label" className="mb-0 figure-modal-label">
-          {t('include_label')}
-          <br />
-          <span className="text-muted text-small figure-modal-label-description">
-            {t('used_when_referring_to_the_figure_elsewhere_in_the_document')}
-          </span>
-        </label>
-      </div>
-      <div className="figure-modal-switcher-input">
-        <div>
-          {t('image_width')}{' '}
-          {hasComplexGraphicsArgument ? (
-            <Tooltip
-              id="figure-modal-image-width-warning-tooltip"
-              description={t('a_custom_size_has_been_used_in_the_latex_code')}
-              overlayProps={{ delay: 0, placement: 'top' }}
-            >
-              <Icon type="exclamation-triangle" fw />
-            </Tooltip>
-          ) : (
-            <Tooltip
-              id="figure-modal-image-width-tooltip"
-              description={t(
-                'the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document'
-              )}
-              overlayProps={{ delay: 0, placement: 'bottom' }}
-            >
-              <Icon type="question-circle" fw />
-            </Tooltip>
-          )}
-        </div>
-        <div>
-          <Switcher
+      </OLFormGroup>
+      <OLFormGroup className="mb-0">
+        <div className="figure-modal-switcher-input">
+          <div>
+            {t('image_width')}{' '}
+            {hasComplexGraphicsArgument ? (
+              <OLTooltip
+                id="figure-modal-image-width-warning-tooltip"
+                description={t('a_custom_size_has_been_used_in_the_latex_code')}
+                overlayProps={{ delay: 0, placement: 'top' }}
+              >
+                <span>
+                  <BootstrapVersionSwitcher
+                    bs3={<Icon type="exclamation-triangle" fw />}
+                    bs5={
+                      <MaterialIcon
+                        type="warning"
+                        className="align-text-bottom"
+                      />
+                    }
+                  />
+                </span>
+              </OLTooltip>
+            ) : (
+              <OLTooltip
+                id="figure-modal-image-width-tooltip"
+                description={t(
+                  'the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document'
+                )}
+                overlayProps={{ delay: 0, placement: 'bottom' }}
+              >
+                <span>
+                  <BootstrapVersionSwitcher
+                    bs3={<Icon type="question-circle" fw />}
+                    bs5={
+                      <MaterialIcon type="help" className="align-text-bottom" />
+                    }
+                  />
+                </span>
+              </OLTooltip>
+            )}
+          </div>
+          <OLToggleButtonGroup
+            type="radio"
             name="figure-width"
             onChange={value => dispatch({ width: parseFloat(value) })}
             defaultValue={width === 1 ? '1.0' : width?.toString()}
-            disabled={hasComplexGraphicsArgument}
+            aria-label={t('image_width')}
           >
-            <SwitcherItem value="0.25" label={t('1_4_width')} />
-            <SwitcherItem value="0.5" label={t('1_2_width')} />
-            <SwitcherItem value="0.75" label={t('3_4_width')} />
-            <SwitcherItem value="1.0" label={t('full_width')} />
-          </Switcher>
+            <OLToggleButton
+              variant="secondary"
+              id="width-25p"
+              disabled={hasComplexGraphicsArgument}
+              value="0.25"
+            >
+              {t('1_4_width')}
+            </OLToggleButton>
+            <OLToggleButton
+              variant="secondary"
+              id="width-50p"
+              disabled={hasComplexGraphicsArgument}
+              value="0.5"
+            >
+              {t('1_2_width')}
+            </OLToggleButton>
+            <OLToggleButton
+              variant="secondary"
+              id="width-75p"
+              disabled={hasComplexGraphicsArgument}
+              value="0.75"
+            >
+              {t('3_4_width')}
+            </OLToggleButton>
+            <OLToggleButton
+              variant="secondary"
+              id="width-100p"
+              disabled={hasComplexGraphicsArgument}
+              value="1.0"
+            >
+              {t('full_width')}
+            </OLToggleButton>
+          </OLToggleButtonGroup>
         </div>
-      </div>
+      </OLFormGroup>
     </>
   )
 }

+ 40 - 36
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-source-picker.tsx

@@ -4,9 +4,11 @@ import {
   useFigureModalContext,
 } from './figure-modal-context'
 import Icon from '../../../../shared/components/icon'
-import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import getMeta from '@/utils/meta'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 export const FigureModalSourcePicker: FC = () => {
   const { t } = useTranslation()
@@ -16,33 +18,31 @@ export const FigureModalSourcePicker: FC = () => {
     hasLinkUrlFeature,
   } = getMeta('ol-ExposedSettings')
   return (
-    <div className="figure-modal-source-selector">
-      <div className="figure-modal-source-button-grid">
+    <div className="figure-modal-source-button-grid">
+      <FigureModalSourceButton
+        type={FigureModalSource.FILE_UPLOAD}
+        title={t('replace_from_computer')}
+        icon={bsVersion({ bs3: 'upload', bs5: 'upload' }) as string}
+      />
+      <FigureModalSourceButton
+        type={FigureModalSource.FILE_TREE}
+        title={t('replace_from_project_files')}
+        icon={bsVersion({ bs3: 'archive', bs5: 'inbox' }) as string}
+      />
+      {(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
         <FigureModalSourceButton
-          type={FigureModalSource.FILE_UPLOAD}
-          title={t('replace_from_computer')}
-          icon="upload"
+          type={FigureModalSource.OTHER_PROJECT}
+          title={t('replace_from_another_project')}
+          icon={bsVersion({ bs3: 'folder-open', bs5: 'folder_open' }) as string}
         />
+      )}
+      {hasLinkUrlFeature && (
         <FigureModalSourceButton
-          type={FigureModalSource.FILE_TREE}
-          title={t('replace_from_project_files')}
-          icon="archive"
+          type={FigureModalSource.FROM_URL}
+          title={t('replace_from_url')}
+          icon={bsVersion({ bs3: 'globe', bs5: 'public' }) as string}
         />
-        {(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
-          <FigureModalSourceButton
-            type={FigureModalSource.OTHER_PROJECT}
-            title={t('replace_from_another_project')}
-            icon="folder-open"
-          />
-        )}
-        {hasLinkUrlFeature && (
-          <FigureModalSourceButton
-            type={FigureModalSource.FROM_URL}
-            title={t('replace_from_url')}
-            icon="globe"
-          />
-        )}
-      </div>
+      )}
     </div>
   )
 }
@@ -54,25 +54,29 @@ const FigureModalSourceButton: FC<{
 }> = ({ type, title, icon }) => {
   const { dispatch } = useFigureModalContext()
   return (
-    <Button
-      bsStyle={null}
-      bsClass=""
+    <button
+      type="button"
       className="figure-modal-source-button"
       onClick={() => {
         dispatch({ source: type, sourcePickerShown: false, getPath: undefined })
       }}
     >
-      <Icon
-        type={icon}
-        className="figure-modal-source-button-icon source-icon"
-        fw
+      <BootstrapVersionSwitcher
+        bs3={
+          <Icon type={icon} className="figure-modal-source-button-icon" fw />
+        }
+        bs5={
+          <MaterialIcon
+            type={icon}
+            className="figure-modal-source-button-icon"
+          />
+        }
       />
       <span className="figure-modal-source-button-title">{title}</span>
-      <Icon
-        type="chevron-right"
-        className="figure-modal-source-button-icon"
-        fw
+      <BootstrapVersionSwitcher
+        bs3={<Icon type="chevron-right" fw />}
+        bs5={<MaterialIcon type="chevron_right" />}
       />
-    </Button>
+    </button>
   )
 }

+ 16 - 12
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx

@@ -1,5 +1,9 @@
-import { Modal } from 'react-bootstrap'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
 import {
   FigureModalProvider,
   FigureModalSource,
@@ -268,9 +272,9 @@ const FigureModalContent = () => {
     return null
   }
   return (
-    <AccessibleModal onHide={hide} className="figure-modal" show>
-      <Modal.Header closeButton>
-        <Modal.Title>
+    <OLModal onHide={hide} className="figure-modal" show>
+      <OLModalHeader closeButton>
+        <OLModalTitle>
           {helpShown
             ? t('help')
             : sourcePickerShown
@@ -281,22 +285,22 @@ const FigureModalContent = () => {
             url="https://forms.gle/PfEtwceYBNQ32DF4A"
             text="Please click to give feedback about editing figures."
           />
-        </Modal.Title>
-      </Modal.Header>
+        </OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
           <FigureModalBody />
         </Suspense>
-      </Modal.Body>
+      </OLModalBody>
 
-      <Modal.Footer>
+      <OLModalFooter>
         <FigureModalFooter
           onInsert={insert}
           onCancel={onCancel}
           onDelete={onDelete}
         />
-      </Modal.Footer>
-    </AccessibleModal>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 22 - 16
services/web/frontend/js/features/source-editor/components/figure-modal/file-name-input.tsx

@@ -1,19 +1,16 @@
-import {
-  DetailedHTMLProps,
-  InputHTMLAttributes,
-  useCallback,
-  useEffect,
-  useState,
-} from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import { File, FileOrDirectory } from '../../utils/file'
-import { Alert } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 type FileNameInputProps = Omit<
-  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
+  React.ComponentProps<typeof OLFormControl>,
   'onFocus'
-> & { targetFolder: File | null }
+> & { targetFolder: File | null; label: string }
 
 function findFile(
   folder: { id: string; name: string },
@@ -52,6 +49,8 @@ function hasOverlap(
 }
 
 export const FileNameInput = ({
+  id,
+  label,
   targetFolder,
   ...props
 }: FileNameInputProps) => {
@@ -82,12 +81,19 @@ export const FileNameInput = ({
   }, [])
   return (
     <>
-      <input {...props} type="text" onFocus={onFocus} />
-      {overlap && (
-        <Alert bsStyle="warning" className="mt-1 mb-0">
-          {t('a_file_with_that_name_already_exists_and_will_be_overriden')}
-        </Alert>
-      )}
+      <OLFormGroup controlId={id}>
+        <OLFormLabel>{label}</OLFormLabel>
+        <OLFormControl onFocus={onFocus} {...props} />
+        {overlap && (
+          <OLNotification
+            type="warning"
+            content={t(
+              'a_file_with_that_name_already_exists_and_will_be_overriden'
+            )}
+            className="mt-1 mb-0"
+          />
+        )}
+      </OLFormGroup>
     </>
   )
 }

+ 21 - 24
services/web/frontend/js/features/source-editor/components/figure-modal/file-relocator.tsx

@@ -4,6 +4,7 @@ import { File } from '../../utils/file'
 import { Select } from '../../../../shared/components/select'
 import { useCurrentProjectFolders } from '../../hooks/use-current-project-folders'
 import { useTranslation } from 'react-i18next'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 
 export const FileRelocator = ({
   name,
@@ -49,40 +50,36 @@ export const FileRelocator = ({
 
   return (
     <>
-      <label
-        htmlFor="figure-modal-relocated-file-name"
-        className="figure-modal-input-label"
-      >
-        {t('file_name_in_this_project_figure_modal')}
-      </label>
       <FileNameInput
         id="figure-modal-relocated-file-name"
         type="text"
-        className="form-control figure-modal-input-field"
+        label={t('file_name_in_this_project_figure_modal')}
         value={name}
         disabled={nameDisabled}
         placeholder="example.jpg"
         onChange={nameChanged}
         targetFolder={folder}
       />
-      <Select
-        items={folders || []}
-        itemToString={item => {
-          if (item?.path === '' && item?.name === 'rootFolder') {
+      <OLFormGroup>
+        <Select
+          items={folders || []}
+          itemToString={item => {
+            if (item?.path === '' && item?.name === 'rootFolder') {
+              return t('no_folder')
+            }
+            if (item) {
+              return `${item.path}${item.name}`
+            }
             return t('no_folder')
-          }
-          if (item) {
-            return `${item.path}${item.name}`
-          }
-          return t('no_folder')
-        }}
-        itemToSubtitle={item => item?.path ?? ''}
-        itemToKey={item => item.id}
-        defaultText={t('select_folder_from_project')}
-        label={t('folder_location')}
-        optionalLabel
-        onSelectedItemChanged={selectedFolderChanged}
-      />
+          }}
+          itemToSubtitle={item => item?.path ?? ''}
+          itemToKey={item => item.id}
+          defaultText={t('select_folder_from_project')}
+          label={t('folder_location')}
+          optionalLabel
+          onSelectedItemChanged={selectedFolderChanged}
+        />
+      </OLFormGroup>
     </>
   )
 }

+ 48 - 46
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx

@@ -13,7 +13,6 @@ import {
   OutputEntity,
   useProjectOutputFiles,
 } from '../../../../file-tree/hooks/use-project-output-files'
-import { Button } from 'react-bootstrap'
 import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
 import { File, isImageEntity } from '../../../utils/file'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
@@ -23,6 +22,8 @@ import { useTranslation } from 'react-i18next'
 import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
 import { useCodeMirrorViewContext } from '../../codemirror-context'
 import getMeta from '@/utils/meta'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 
 function suggestName(path: string) {
   const parts = path.split('/')
@@ -132,54 +133,55 @@ export const FigureModalOtherProjectSource: FC = () => {
 
   return (
     <>
-      <Select
-        items={projects ?? []}
-        itemToString={project => (project ? project.name : '')}
-        itemToKey={item => item._id}
-        defaultText={t('select_a_project_figure_modal')}
-        label={t('project_figure_modal')}
-        disabled={projectsLoading}
-        onSelectedItemChanged={item => {
-          const suggestion = nameDirty ? name : ''
-          setName(suggestion)
-          setSelectedProject(item ?? null)
-          setFile(null)
-          updateDispatch({
-            newSelectedProject: item ?? null,
-            newFile: null,
-            newName: suggestion,
-          })
-        }}
-      />
-      <FileSelector
-        projectId={selectedProject?._id}
-        onSelectedItemChange={item => {
-          const suggestion = nameDirty ? name : suggestName(item?.path ?? '')
-          setName(suggestion)
-          setFile(item ?? null)
-          updateDispatch({
-            newFile: item ?? null,
-            newName: suggestion,
-          })
-        }}
-      />
-      {hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature && (
-        <div>
-          or{' '}
-          <Button
-            className="p-0"
-            bsStyle="link"
-            type="button"
-            onClick={() => setUsingOutputFiles(value => !value)}
-          >
-            <span>
+      <OLFormGroup>
+        <Select
+          items={projects ?? []}
+          itemToString={project => (project ? project.name : '')}
+          itemToKey={item => item._id}
+          defaultText={t('select_a_project_figure_modal')}
+          label={t('project_figure_modal')}
+          disabled={projectsLoading}
+          onSelectedItemChanged={item => {
+            const suggestion = nameDirty ? name : ''
+            setName(suggestion)
+            setSelectedProject(item ?? null)
+            setFile(null)
+            updateDispatch({
+              newSelectedProject: item ?? null,
+              newFile: null,
+              newName: suggestion,
+            })
+          }}
+        />
+      </OLFormGroup>
+      <OLFormGroup>
+        <FileSelector
+          projectId={selectedProject?._id}
+          onSelectedItemChange={item => {
+            const suggestion = nameDirty ? name : suggestName(item?.path ?? '')
+            setName(suggestion)
+            setFile(item ?? null)
+            updateDispatch({
+              newFile: item ?? null,
+              newName: suggestion,
+            })
+          }}
+        />
+        {hasLinkedProjectFileFeature && hasLinkedProjectOutputFileFeature && (
+          <div>
+            or{' '}
+            <OLButton
+              variant="link"
+              onClick={() => setUsingOutputFiles(value => !value)}
+              className="p-0 select-from-files-btn"
+            >
               {usingOutputFiles
                 ? t('select_from_project_files')
                 : t('select_from_output_files')}
-            </span>
-          </Button>
-        </div>
-      )}
+            </OLButton>
+          </div>
+        )}
+      </OLFormGroup>
       <FileRelocator
         folder={folder}
         name={name}

+ 25 - 22
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-project-source.tsx

@@ -4,6 +4,7 @@ import { useFigureModalContext } from '../figure-modal-context'
 import { filterFiles, isImageFile } from '../../../utils/file'
 import { useTranslation } from 'react-i18next'
 import { useCurrentProjectFolders } from '@/features/source-editor/hooks/use-current-project-folders'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 
 export const FigureModalCurrentProjectSource: FC = () => {
   const { t } = useTranslation()
@@ -15,27 +16,29 @@ export const FigureModalCurrentProjectSource: FC = () => {
   const { dispatch, selectedItemId } = useFigureModalContext()
   const noFiles = files?.length === 0
   return (
-    <Select
-      items={files || []}
-      itemToString={file => (file ? file.name : '')}
-      itemToSubtitle={item => item?.path ?? ''}
-      itemToKey={item => item.id}
-      defaultItem={
-        files && selectedItemId
-          ? files.find(item => item.id === selectedItemId)
-          : undefined
-      }
-      defaultText={
-        noFiles
-          ? t('no_image_files_found')
-          : t('select_image_from_project_files')
-      }
-      label="Image file"
-      onSelectedItemChanged={item => {
-        dispatch({
-          getPath: item ? async () => `${item.path}${item.name}` : undefined,
-        })
-      }}
-    />
+    <OLFormGroup>
+      <Select
+        items={files || []}
+        itemToString={file => (file ? file.name : '')}
+        itemToSubtitle={item => item?.path ?? ''}
+        itemToKey={item => item.id}
+        defaultItem={
+          files && selectedItemId
+            ? files.find(item => item.id === selectedItemId)
+            : undefined
+        }
+        defaultText={
+          noFiles
+            ? t('no_image_files_found')
+            : t('select_image_from_project_files')
+        }
+        label="Image file"
+        onSelectedItemChanged={item => {
+          dispatch({
+            getPath: item ? async () => `${item.path}${item.name}` : undefined,
+          })
+        }}
+      />
+    </OLFormGroup>
   )
 }

+ 96 - 71
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx

@@ -11,12 +11,17 @@ import { refreshProjectMetadata } from '../../../../file-tree/util/api'
 import { useProjectContext } from '../../../../../shared/context/project-context'
 import Icon from '../../../../../shared/components/icon'
 import classNames from 'classnames'
-import { Button } from 'react-bootstrap'
 import { FileRelocator } from '../file-relocator'
 import { useTranslation } from 'react-i18next'
 import { useCodeMirrorViewContext } from '../../codemirror-context'
 import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
 import getMeta from '@/utils/meta'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import { Spinner } from 'react-bootstrap-5'
+import { isBootstrap5 } from '@/features/utils/bootstrap-5'
 
 /* eslint-disable no-unused-vars */
 export enum FileUploadStatus {
@@ -25,6 +30,7 @@ export enum FileUploadStatus {
   NOT_ATTEMPTED,
   UPLOADING,
 }
+
 /* eslint-enable no-unused-vars */
 
 export const FigureModalUploadFileSource: FC = () => {
@@ -199,50 +205,52 @@ export const FigureModalUploadFileSource: FC = () => {
 
   return (
     <>
-      <div className="figure-modal-upload">
-        {file ? (
-          <FileContainer
-            name={file.name}
-            size={file.size}
-            status={
-              uploading
-                ? FileUploadStatus.UPLOADING
-                : uploadError
-                  ? FileUploadStatus.ERROR
-                  : FileUploadStatus.NOT_ATTEMPTED
-            }
-            onDelete={() => {
-              uppy.removeFile(file.id)
-              setFile(null)
-              const newName = nameDirty ? name : ''
-              setName(newName)
-              dispatchUploadAction(newName, null, folder)
-            }}
-          />
-        ) : (
-          <Dashboard
-            uppy={uppy}
-            showProgressDetails
-            height={120}
-            width="100%"
-            showLinkToFileUploadResult={false}
-            proudlyDisplayPoweredByUppy={false}
-            showSelectedFiles={false}
-            hideUploadButton
-            locale={{
-              strings: {
-                // Text to show on the droppable area.
-                // `%{browseFiles}` is replaced with a link that opens the system file selection dialog.
-                dropPasteFiles: `${t(
-                  'drag_here_paste_an_image_or'
-                )} %{browseFiles}`,
-                // Used as the label for the link that opens the system file selection dialog.
-                browseFiles: t('select_from_your_computer'),
-              },
-            }}
-          />
-        )}
-      </div>
+      <OLFormGroup>
+        <div className="figure-modal-upload">
+          {file ? (
+            <FileContainer
+              name={file.name}
+              size={file.size}
+              status={
+                uploading
+                  ? FileUploadStatus.UPLOADING
+                  : uploadError
+                    ? FileUploadStatus.ERROR
+                    : FileUploadStatus.NOT_ATTEMPTED
+              }
+              onDelete={() => {
+                uppy.removeFile(file.id)
+                setFile(null)
+                const newName = nameDirty ? name : ''
+                setName(newName)
+                dispatchUploadAction(newName, null, folder)
+              }}
+            />
+          ) : (
+            <Dashboard
+              uppy={uppy}
+              showProgressDetails
+              height={120}
+              width="100%"
+              showLinkToFileUploadResult={false}
+              proudlyDisplayPoweredByUppy={false}
+              showSelectedFiles={false}
+              hideUploadButton
+              locale={{
+                strings: {
+                  // Text to show on the droppable area.
+                  // `%{browseFiles}` is replaced with a link that opens the system file selection dialog.
+                  dropPasteFiles: `${t(
+                    'drag_here_paste_an_image_or'
+                  )} %{browseFiles}`,
+                  // Used as the label for the link that opens the system file selection dialog.
+                  browseFiles: t('select_from_your_computer'),
+                },
+              }}
+            />
+          )}
+        </div>
+      </OLFormGroup>
       <FileRelocator
         folder={folder}
         name={name}
@@ -266,50 +274,67 @@ export const FileContainer: FC<{
   onDelete?: () => any
 }> = ({ name, size, status, onDelete }) => {
   const { t } = useTranslation()
-  let icon
+  let icon = ''
   switch (status) {
     case FileUploadStatus.ERROR:
-      icon = 'times-circle'
+      icon = isBootstrap5() ? 'cancel' : 'times-circle'
       break
     case FileUploadStatus.SUCCESS:
-      icon = 'check-circle'
+      icon = isBootstrap5() ? 'check_circle' : 'check-circle'
       break
     case FileUploadStatus.NOT_ATTEMPTED:
-      icon = 'picture-o'
+      icon = isBootstrap5() ? 'imagesmode' : 'picture-o'
       break
-    case FileUploadStatus.UPLOADING:
-      icon = 'spinner'
   }
+
   return (
     <div className="file-container">
       <div className="file-container-file">
-        <Icon
-          spin={status === FileUploadStatus.UPLOADING}
-          type={icon}
-          className={classNames(
-            {
-              'text-success': status === FileUploadStatus.SUCCESS,
-              'text-danger': status === FileUploadStatus.ERROR,
-            },
-            'file-icon'
+        <span
+          className={classNames({
+            'text-success': status === FileUploadStatus.SUCCESS,
+            'text-danger': status === FileUploadStatus.ERROR,
+          })}
+        >
+          {status === FileUploadStatus.UPLOADING ? (
+            <BootstrapVersionSwitcher
+              bs3={<Icon spin type="spinner" className="file-icon" />}
+              bs5={
+                <Spinner
+                  animation="border"
+                  aria-hidden="true"
+                  as="span"
+                  role="status"
+                  size="sm"
+                />
+              }
+            />
+          ) : (
+            <BootstrapVersionSwitcher
+              bs3={<Icon type={icon} className="file-icon" />}
+              bs5={<MaterialIcon type={icon} className="align-text-bottom" />}
+            />
           )}
-        />
+        </span>
         <div className="file-info">
           <span className="file-name" aria-label={t('file_name_figure_modal')}>
             {name}
           </span>
-          {size !== undefined && (
-            <FileSize size={size} className="text-small" />
-          )}
+          {size !== undefined && <FileSize size={size} />}
         </div>
-        <Button
-          bsStyle={null}
-          className="btn btn-link p-0"
+        <OLButton
+          variant="link"
+          className="p-0 text-decoration-none"
           aria-label={t('remove_or_replace_figure')}
           onClick={() => onDelete && onDelete()}
         >
-          <Icon fw type="times-circle" className="file-action file-icon" />
-        </Button>
+          <BootstrapVersionSwitcher
+            bs3={
+              <Icon fw type="times-circle" className="file-action file-icon" />
+            }
+            bs5={<MaterialIcon type="cancel" />}
+          />
+        </OLButton>
       </div>
     </div>
   )
@@ -336,8 +361,8 @@ const FileSize: FC<{ size: number; className?: string }> = ({
   const [label, bytesPerUnit] = BYTE_UNITS[labelIndex]
   const sizeInUnits = Math.round(size / bytesPerUnit)
   return (
-    <span aria-label={t('file_size')} className={className}>
+    <small aria-label={t('file_size')} className={className}>
       {sizeInUnits} {label}
-    </span>
+    </small>
   )
 }

+ 22 - 24
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx

@@ -9,6 +9,9 @@ import { useTranslation } from 'react-i18next'
 import { useCodeMirrorViewContext } from '../../codemirror-context'
 import { EditorView } from '@codemirror/view'
 import { waitForFileTreeUpdate } from '../../../extensions/figure-modal'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
 
 function generateLinkedFileFetcher(
   projectId: string,
@@ -72,30 +75,25 @@ export const FigureModalUrlSource: FC = () => {
 
   return (
     <>
-      <label
-        htmlFor="figure-modal-url-url"
-        className="figure-modal-input-label"
-      >
-        {t('image_url')}
-      </label>
-      <input
-        id="figure-modal-url-url"
-        type="text"
-        className="form-control figure-modal-input-field"
-        placeholder={t('enter_image_url')}
-        value={url}
-        onChange={e => {
-          setUrl(e.target.value)
-          let newName = name
-          if (!nameDirty) {
-            // TODO: Improve this
-            const parts = e.target.value.split('/')
-            newName = parts[parts.length - 1] ?? ''
-            setName(newName)
-          }
-          ensureButtonActivation(e.target.value, newName, folder)
-        }}
-      />
+      <OLFormGroup controlId="figure-modal-url-url">
+        <OLFormLabel>{t('image_url')}</OLFormLabel>
+        <OLFormControl
+          type="text"
+          placeholder={t('enter_image_url')}
+          value={url}
+          onChange={e => {
+            setUrl(e.target.value)
+            let newName = name
+            if (!nameDirty) {
+              // TODO: Improve this
+              const parts = e.target.value.split('/')
+              newName = parts[parts.length - 1] ?? ''
+              setName(newName)
+            }
+            ensureButtonActivation(e.target.value, newName, folder)
+          }}
+        />
+      </OLFormGroup>
       <FileRelocator
         folder={folder}
         name={name}

+ 16 - 8
services/web/frontend/js/features/source-editor/components/switch-to-pdf-button.jsx

@@ -1,6 +1,8 @@
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 
 function SwitchToPDFButton() {
@@ -21,15 +23,21 @@ function SwitchToPDFButton() {
   }
 
   return (
-    <Button
-      bsStyle={null}
-      bsSize="xs"
+    <OLButton
+      variant="secondary"
+      size="small"
       onClick={handleClick}
-      className="switch-to-pdf-btn toolbar-btn-secondary btn-secondary"
+      bs3Props={{
+        bsSize: 'xsmall',
+        className: 'switch-to-pdf-btn toolbar-btn-secondary',
+      }}
     >
-      <Icon type="file-pdf-o" className="toolbar-btn-secondary-icon" />
-      <span className="toolbar-btn-secondary-text">{t('switch_to_pdf')}</span>
-    </Button>
+      <BootstrapVersionSwitcher
+        bs3={<Icon type="file-pdf-o" className="toolbar-btn-secondary-icon" />}
+        bs5={<MaterialIcon type="picture_as_pdf" />}
+      />
+      {t('switch_to_pdf')}
+    </OLButton>
   )
 }
 

+ 19 - 28
services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx

@@ -1,37 +1,28 @@
 import { FC, memo, useRef } from 'react'
-import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
-import Icon from '../../../../shared/components/icon'
 import useDropdown from '../../../../shared/hooks/use-dropdown'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLListGroup from '@/features/ui/components/ol/ol-list-group'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
 import { EditorView } from '@codemirror/view'
 import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
 import { useCodeMirrorViewContext } from '../codemirror-context'
-import MaterialIcon from '../../../../shared/components/material-icon'
 
 export const ToolbarButtonMenu: FC<{
   id: string
   label: string
-  icon: string
-  materialIcon?: boolean
+  icon: React.ReactNode
   altCommand?: (view: EditorView) => void
-}> = memo(function ButtonMenu({
-  icon,
-  id,
-  label,
-  materialIcon,
-  altCommand,
-  children,
-}) {
+}> = memo(function ButtonMenu({ icon, id, label, altCommand, children }) {
   const target = useRef<any>(null)
   const { open, onToggle, ref } = useDropdown()
   const view = useCodeMirrorViewContext()
 
   const button = (
-    <Button
+    <button
       type="button"
-      className="ol-cm-toolbar-button"
+      className="ol-cm-toolbar-button btn"
       aria-label={label}
-      bsStyle={null}
       onMouseDown={event => {
         event.preventDefault()
         event.stopPropagation()
@@ -48,36 +39,36 @@ export const ToolbarButtonMenu: FC<{
       }}
       ref={target}
     >
-      {materialIcon ? <MaterialIcon type={icon} /> : <Icon type={icon} fw />}
-    </Button>
+      {icon}
+    </button>
   )
 
   const overlay = (
-    <Overlay
+    <OLOverlay
       show={open}
       target={target.current}
       placement="bottom"
       container={view.dom}
       containerPadding={0}
-      animation
+      transition
       rootClose
       onHide={() => onToggle(false)}
     >
-      <Popover
+      <OLPopover
         id={`${id}-menu`}
         ref={ref}
         className="ol-cm-toolbar-button-menu-popover"
       >
-        <ListGroup
+        <OLListGroup
           role="menu"
           onClick={() => {
             onToggle(false)
           }}
         >
           {children}
-        </ListGroup>
-      </Popover>
-    </Overlay>
+        </OLListGroup>
+      </OLPopover>
+    </OLOverlay>
   )
 
   if (!label) {
@@ -91,14 +82,14 @@ export const ToolbarButtonMenu: FC<{
 
   return (
     <>
-      <Tooltip
+      <OLTooltip
         hidden={open}
         id={id}
         description={<div>{label}</div>}
         overlayProps={{ placement: 'bottom' }}
       >
         {button}
-      </Tooltip>
+      </OLTooltip>
       {overlay}
     </>
   )

+ 34 - 15
services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx

@@ -1,6 +1,8 @@
-import { ListGroupItem } from 'react-bootstrap'
 import { ToolbarButtonMenu } from './button-menu'
-import Icon from '../../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
 import { memo, useCallback } from 'react'
 import { FigureModalSource } from '../figure-modal/figure-modal-context'
 import { useTranslation } from 'react-i18next'
@@ -32,44 +34,61 @@ export const InsertFigureDropdown = memo(function InsertFigureDropdown() {
     <ToolbarButtonMenu
       id="toolbar-figure"
       label={t('toolbar_insert_figure')}
-      icon="picture-o"
+      icon={
+        <BootstrapVersionSwitcher
+          bs3={<Icon fw type="picture-o" />}
+          bs5={<MaterialIcon type="imagesmode" />}
+        />
+      }
       altCommand={insertFigure}
     >
-      <ListGroupItem
+      <OLListGroupItem
         onClick={() =>
           openFigureModal(FigureModalSource.FILE_UPLOAD, 'file-upload')
         }
       >
-        <Icon type="upload" fw />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="upload" fw />}
+          bs5={<MaterialIcon type="upload" />}
+        />
         {t('upload_from_computer')}
-      </ListGroupItem>
-      <ListGroupItem
+      </OLListGroupItem>
+      <OLListGroupItem
         onClick={() =>
           openFigureModal(FigureModalSource.FILE_TREE, 'current-project')
         }
       >
-        <Icon type="archive" fw />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="archive" fw />}
+          bs5={<MaterialIcon type="inbox" />}
+        />
         {t('from_project_files')}
-      </ListGroupItem>
+      </OLListGroupItem>
       {(hasLinkedProjectFileFeature || hasLinkedProjectOutputFileFeature) && (
-        <ListGroupItem
+        <OLListGroupItem
           onClick={() =>
             openFigureModal(FigureModalSource.OTHER_PROJECT, 'other-project')
           }
         >
-          <Icon type="folder-open" fw />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="folder-open" fw />}
+            bs5={<MaterialIcon type="folder_open" />}
+          />
           {t('from_another_project')}
-        </ListGroupItem>
+        </OLListGroupItem>
       )}
       {hasLinkUrlFeature && (
-        <ListGroupItem
+        <OLListGroupItem
           onClick={() =>
             openFigureModal(FigureModalSource.FROM_URL, 'from-url')
           }
         >
-          <Icon type="globe" fw />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="globe" fw />}
+            bs5={<MaterialIcon type="public" />}
+          />
           {t('from_url')}
-        </ListGroupItem>
+        </OLListGroupItem>
       )}
     </ToolbarButtonMenu>
   )

+ 6 - 7
services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx

@@ -1,4 +1,3 @@
-import { ListGroupItem } from 'react-bootstrap'
 import { ToolbarButtonMenu } from './button-menu'
 import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
 import MaterialIcon from '../../../../shared/components/material-icon'
@@ -9,6 +8,7 @@ import {
   wrapInInlineMath,
 } from '../../extensions/toolbar/commands'
 import { memo } from 'react'
+import OLListGroupItem from '@/features/ui/components/ol/ol-list-group-item'
 
 export const MathDropdown = memo(function MathDropdown() {
   const { t } = useTranslation()
@@ -18,10 +18,9 @@ export const MathDropdown = memo(function MathDropdown() {
     <ToolbarButtonMenu
       id="toolbar-math"
       label={t('toolbar_insert_math')}
-      icon="calculate"
-      materialIcon
+      icon={<MaterialIcon type="calculate" />}
     >
-      <ListGroupItem
+      <OLListGroupItem
         aria-label={t('toolbar_insert_inline_math')}
         onClick={event => {
           emitToolbarEvent(view, 'toolbar-inline-math')
@@ -32,8 +31,8 @@ export const MathDropdown = memo(function MathDropdown() {
       >
         <MaterialIcon type="123" />
         <span>{t('toolbar_insert_inline_math')}</span>
-      </ListGroupItem>
-      <ListGroupItem
+      </OLListGroupItem>
+      <OLListGroupItem
         aria-label={t('toolbar_insert_display_math')}
         onClick={event => {
           emitToolbarEvent(view, 'toolbar-display-math')
@@ -44,7 +43,7 @@ export const MathDropdown = memo(function MathDropdown() {
       >
         <MaterialIcon type="view_day" />
         <span>{t('toolbar_insert_display_math')}</span>
-      </ListGroupItem>
+      </OLListGroupItem>
     </ToolbarButtonMenu>
   )
 })

+ 23 - 15
services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx

@@ -1,21 +1,26 @@
-import { FC, LegacyRef, useRef } from 'react'
-import { Button, Overlay, Popover } from 'react-bootstrap'
+import { FC, useRef } from 'react'
 import classnames from 'classnames'
 import Icon from '../../../../shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import { useCodeMirrorViewContext } from '../codemirror-context'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 export const ToolbarOverflow: FC<{
   overflowed: boolean
   overflowOpen: boolean
   setOverflowOpen: (open: boolean) => void
-  overflowRef?: LegacyRef<Popover>
+  overflowRef?: React.Ref<HTMLDivElement>
 }> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => {
-  const buttonRef = useRef<Button>(null)
+  const buttonRef = useRef<HTMLButtonElement>(null)
   const view = useCodeMirrorViewContext()
 
   const className = classnames(
     'ol-cm-toolbar-button',
     'ol-cm-toolbar-overflow-toggle',
+    bsVersion({ bs3: 'btn' }),
     {
       'ol-cm-toolbar-overflow-toggle-visible': overflowed,
     }
@@ -23,13 +28,12 @@ export const ToolbarOverflow: FC<{
 
   return (
     <>
-      <Button
+      <button
         ref={buttonRef}
         type="button"
         id="toolbar-more"
         className={className}
         aria-label="More"
-        bsStyle={null}
         onMouseDown={event => {
           event.preventDefault()
           event.stopPropagation()
@@ -38,22 +42,26 @@ export const ToolbarOverflow: FC<{
           setOverflowOpen(!overflowOpen)
         }}
       >
-        <Icon type="ellipsis-h" fw />
-      </Button>
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="ellipsis-h" fw />}
+          bs5={<MaterialIcon type="more_horiz" />}
+        />
+      </button>
 
-      <Overlay
+      <OLOverlay
         show={overflowOpen}
-        target={buttonRef.current ?? undefined}
+        target={buttonRef.current}
         placement="bottom"
         container={view.dom}
-        containerPadding={0}
-        animation
+        // containerPadding={0}
+        transition
+        rootClose
         onHide={() => setOverflowOpen(false)}
       >
-        <Popover id="popover-toolbar-overflow" ref={overflowRef}>
+        <OLPopover id="popover-toolbar-overflow" ref={overflowRef}>
           <div className="ol-cm-toolbar-overflow">{children}</div>
-        </Popover>
-      </Overlay>
+        </OLPopover>
+      </OLOverlay>
     </>
   )
 }

+ 18 - 7
services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx

@@ -8,7 +8,8 @@ import {
   setSectionHeadingLevel,
 } from '../../extensions/toolbar/sections'
 import { useCallback, useMemo, useRef } from 'react'
-import { Overlay, Popover } from 'react-bootstrap'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
 import useEventListener from '../../../../shared/hooks/use-event-listener'
 import useDropdown from '../../../../shared/hooks/use-dropdown'
 import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
@@ -69,17 +70,27 @@ export const SectionHeadingDropdown = () => {
       </button>
 
       {overflowOpen && (
-        <Overlay
+        <OLOverlay
           show
           onHide={() => setOverflowOpen(false)}
-          animation={false}
+          transition={false}
           container={view.dom}
           containerPadding={0}
           placement="bottom"
           rootClose
-          target={toggleButtonRef.current ?? undefined}
+          target={toggleButtonRef.current}
+          popperConfig={{
+            modifiers: [
+              {
+                name: 'offset',
+                options: {
+                  offset: [0, 1],
+                },
+              },
+            ],
+          }}
         >
-          <Popover
+          <OLPopover
             id="popover-toolbar-section-heading"
             className="ol-cm-toolbar-menu-popover"
           >
@@ -113,8 +124,8 @@ export const SectionHeadingDropdown = () => {
                 </button>
               ))}
             </div>
-          </Popover>
-        </Overlay>
+          </OLPopover>
+        </OLOverlay>
       )}
     </>
   )

+ 13 - 13
services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx

@@ -2,9 +2,10 @@ import { FC, memo, useCallback, useRef, useState } from 'react'
 import * as commands from '../../extensions/toolbar/commands'
 import { useTranslation } from 'react-i18next'
 import useDropdown from '../../../../shared/hooks/use-dropdown'
-import { Button, Overlay, Popover } from 'react-bootstrap'
 import { useCodeMirrorViewContext } from '../codemirror-context'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
 import MaterialIcon from '../../../../shared/components/material-icon'
 import classNames from 'classnames'
 import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
@@ -27,17 +28,16 @@ export const TableInserterDropdown = memo(() => {
 
   return (
     <>
-      <Tooltip
+      <OLTooltip
         hidden={open}
         id="toolbar-table"
         description={<div>{t('toolbar_insert_table')}</div>}
         overlayProps={{ placement: 'bottom' }}
       >
-        <Button
+        <button
           type="button"
-          className="ol-cm-toolbar-button"
+          className="ol-cm-toolbar-button btn"
           aria-label={t('toolbar_insert_table')}
-          bsStyle={null}
           onMouseDown={event => {
             event.preventDefault()
             event.stopPropagation()
@@ -48,19 +48,19 @@ export const TableInserterDropdown = memo(() => {
           ref={target}
         >
           <MaterialIcon type="table_chart" />
-        </Button>
-      </Tooltip>
-      <Overlay
+        </button>
+      </OLTooltip>
+      <OLOverlay
         show={open}
         target={target.current}
         placement="bottom"
         container={view.dom}
         containerPadding={0}
-        animation
+        transition
         rootClose
         onHide={() => onToggle(false)}
       >
-        <Popover
+        <OLPopover
           id="toolbar-table-menu"
           ref={ref}
           className="ol-cm-toolbar-button-menu-popover ol-cm-toolbar-button-menu-popover-unstyled"
@@ -68,8 +68,8 @@ export const TableInserterDropdown = memo(() => {
           <div className="ol-cm-toolbar-table-grid-popover">
             <SizeGrid sizeX={10} sizeY={10} onSizeSelected={onSizeSelected} />
           </div>
-        </Popover>
-      </Overlay>
+        </OLPopover>
+      </OLOverlay>
     </>
   )
 })

+ 25 - 10
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx

@@ -1,11 +1,13 @@
 import { memo, useCallback } from 'react'
 import { EditorView } from '@codemirror/view'
 import { useCodeMirrorViewContext } from '../codemirror-context'
-import { Button } from 'react-bootstrap'
 import classnames from 'classnames'
-import Tooltip from '../../../../shared/components/tooltip'
 import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics'
 import Icon from '../../../../shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 export const ToolbarButton = memo<{
   id: string
@@ -49,18 +51,31 @@ export const ToolbarButton = memo<{
   )
 
   const button = (
-    <Button
-      className={classnames('ol-cm-toolbar-button', className, { hidden })}
+    <button
+      className={classnames(
+        'ol-cm-toolbar-button',
+        bsVersion({ bs3: 'btn' }),
+        className,
+        {
+          active,
+          hidden,
+        }
+      )}
       aria-label={label}
       onMouseDown={handleMouseDown}
       onClick={!disabled ? handleClick : undefined}
-      bsStyle={null}
-      active={active}
       aria-disabled={disabled}
       type="button"
     >
-      {textIcon ? icon : <Icon type={icon} fw accessibilityLabel={label} />}
-    </Button>
+      {textIcon ? (
+        icon
+      ) : (
+        <BootstrapVersionSwitcher
+          bs3={<Icon type={icon} fw accessibilityLabel={label} />}
+          bs5={<MaterialIcon type={icon} accessibilityLabel={label} />}
+        />
+      )}
+    </button>
   )
 
   if (!label) {
@@ -75,12 +90,12 @@ export const ToolbarButton = memo<{
   )
 
   return (
-    <Tooltip
+    <OLTooltip
       id={id}
       description={description}
       overlayProps={{ placement: 'bottom' }}
     >
       {button}
-    </Tooltip>
+    </OLTooltip>
   )
 })

+ 46 - 13
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx

@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'
 import { MathDropdown } from './math-dropdown'
 import { TableInserterDropdown } from './table-inserter-dropdown'
 import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 const isMac = /Mac/.test(window.navigator?.platform)
 
@@ -37,19 +38,22 @@ export const ToolbarItems: FC<{
   return (
     <>
       {showGroup('group-history') && (
-        <div className="ol-cm-toolbar-button-group">
+        <div
+          className="ol-cm-toolbar-button-group"
+          aria-label={t('toolbar_undo_redo_actions')}
+        >
           <ToolbarButton
             id="toolbar-undo"
             label={t('toolbar_undo')}
             command={undo}
-            icon="undo"
+            icon={bsVersion({ bs5: 'undo', bs3: 'undo' }) as string}
             shortcut={isMac ? '⌘Z' : 'Ctrl+Z'}
           />
           <ToolbarButton
             id="toolbar-redo"
             label={t('toolbar_redo')}
             command={redo}
-            icon="repeat"
+            icon={bsVersion({ bs5: 'redo', bs3: 'repeat' }) as string}
             shortcut={isMac ? '⇧⌘Z' : 'Ctrl+Y'}
           />
         </div>
@@ -60,18 +64,22 @@ export const ToolbarItems: FC<{
             <div
               className="ol-cm-toolbar-button-group"
               data-overflow="group-section"
+              aria-label={t('toolbar_text_formatting')}
             >
               <SectionHeadingDropdown />
             </div>
           )}
           {showGroup('group-format') && (
-            <div className="ol-cm-toolbar-button-group">
+            <div
+              className="ol-cm-toolbar-button-group"
+              aria-label={t('toolbar_text_style')}
+            >
               <ToolbarButton
                 id="toolbar-format-bold"
                 label={t('toolbar_format_bold')}
                 command={commands.toggleBold}
                 active={isActive('\\textbf')}
-                icon="bold"
+                icon={bsVersion({ bs5: 'format_bold', bs3: 'bold' }) as string}
                 shortcut={isMac ? '⌘B' : 'Ctrl+B'}
               />
               <ToolbarButton
@@ -79,7 +87,9 @@ export const ToolbarItems: FC<{
                 label={t('toolbar_format_italic')}
                 command={commands.toggleItalic}
                 active={isActive('\\textit')}
-                icon="italic"
+                icon={
+                  bsVersion({ bs5: 'format_italic', bs3: 'italic' }) as string
+                }
                 shortcut={isMac ? '⌘I' : 'Ctrl+I'}
               />
             </div>
@@ -88,6 +98,7 @@ export const ToolbarItems: FC<{
             <div
               className="ol-cm-toolbar-button-group"
               data-overflow="group-math"
+              aria-label={t('toolbar_insert_math_and_symbols')}
             >
               <MathDropdown />
               {symbolPaletteAvailable && (
@@ -107,24 +118,25 @@ export const ToolbarItems: FC<{
             <div
               className="ol-cm-toolbar-button-group"
               data-overflow="group-misc"
+              aria-label={t('toolbar_insert_misc')}
             >
               <ToolbarButton
                 id="toolbar-href"
                 label={t('toolbar_insert_link')}
                 command={commands.wrapInHref}
-                icon="link"
+                icon={bsVersion({ bs5: 'link', bs3: 'link' }) as string}
               />
               <ToolbarButton
                 id="toolbar-ref"
                 label={t('toolbar_insert_cross_reference')}
                 command={commands.insertRef}
-                icon="tag"
+                icon={bsVersion({ bs5: 'sell', bs3: 'tag' }) as string}
               />
               <ToolbarButton
                 id="toolbar-cite"
                 label={t('toolbar_insert_citation')}
                 command={commands.insertCite}
-                icon="book"
+                icon={bsVersion({ bs5: 'menu_book', bs3: 'book' }) as string}
               />
               <InsertFigureDropdown />
               <TableInserterDropdown />
@@ -134,24 +146,40 @@ export const ToolbarItems: FC<{
             <div
               className="ol-cm-toolbar-button-group"
               data-overflow="group-list"
+              aria-label={t('toolbar_list_indentation')}
             >
               <ToolbarButton
                 id="toolbar-bullet-list"
                 label={t('toolbar_bullet_list')}
                 command={commands.toggleBulletList}
-                icon="list-ul"
+                icon={
+                  bsVersion({
+                    bs5: 'format_list_bulleted',
+                    bs3: 'list-ul',
+                  }) as string
+                }
               />
               <ToolbarButton
                 id="toolbar-numbered-list"
                 label={t('toolbar_numbered_list')}
                 command={commands.toggleNumberedList}
-                icon="list-ol"
+                icon={
+                  bsVersion({
+                    bs5: 'format_list_numbered',
+                    bs3: 'list-ol',
+                  }) as string
+                }
               />
               <ToolbarButton
                 id="toolbar-format-indent-decrease"
                 label={t('toolbar_decrease_indent')}
                 command={commands.indentDecrease}
-                icon="outdent"
+                icon={
+                  bsVersion({
+                    bs5: 'format_indent_decrease',
+                    bs3: 'outdent',
+                  }) as string
+                }
                 shortcut={visual ? (isMac ? '⌘[' : 'Ctrl+[') : undefined}
                 disabled={listDepth < 2}
               />
@@ -159,7 +187,12 @@ export const ToolbarItems: FC<{
                 id="toolbar-format-indent-increase"
                 label={t('toolbar_increase_indent')}
                 command={commands.indentIncrease}
-                icon="indent"
+                icon={
+                  bsVersion({
+                    bs5: 'format_indent_increase',
+                    bs3: 'indent',
+                  }) as string
+                }
                 shortcut={visual ? (isMac ? '⌘]' : 'Ctrl+]') : undefined}
                 disabled={listDepth < 1}
               />

+ 14 - 6
services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts

@@ -43,10 +43,13 @@ const toolbarTheme = EditorView.theme({
     borderColor: 'rgba(125, 125, 125, 0.2)',
     backgroundColor: 'var(--editor-toolbar-bg)',
     color: 'var(--toolbar-btn-color)',
-    '& .popover-content': {
+    '& .popover-content, & .popover-body': {
       padding: 0,
     },
-    '& .arrow': {
+    '& .popover-body': {
+      color: 'inherit',
+    },
+    '& .arrow, & .popover-arrow': {
       borderBottomColor: 'rgba(125, 125, 125, 0.2)',
       '&:after': {
         borderBottomColor: 'var(--editor-toolbar-bg)',
@@ -54,10 +57,12 @@ const toolbarTheme = EditorView.theme({
     },
   },
   '.ol-cm-toolbar-button-menu-popover': {
-    '& > .popover-content': {
+    backgroundColor: 'initial',
+    '& > .popover-content, & > .popover-body': {
       padding: 0,
+      color: 'initial',
     },
-    '& .arrow': {
+    '& .arrow, & .popover-arrow': {
       display: 'none',
     },
     '& .list-group': {
@@ -116,6 +121,7 @@ const toolbarTheme = EditorView.theme({
     width: '24px',
     height: '24px',
     overflow: 'hidden',
+    color: 'inherit',
     '&:hover, &:focus, &:active, &.active': {
       backgroundColor: 'rgba(125, 125, 125, 0.1)',
       color: 'inherit',
@@ -204,11 +210,12 @@ const toolbarTheme = EditorView.theme({
     '&.top': {
       marginBottom: '1px',
     },
-    '& .arrow': {
+    '& .arrow, & .popover-arrow': {
       display: 'none',
     },
-    '& .popover-content': {
+    '& .popover-content, & > .popover-body': {
       padding: '0',
+      color: 'inherit',
     },
     '& .ol-cm-toolbar-menu': {
       width: '120px',
@@ -225,6 +232,7 @@ const toolbarTheme = EditorView.theme({
       display: 'flex',
       alignItems: 'center',
       fontWeight: 'bold',
+      color: 'inherit',
       '&.ol-cm-toolbar-menu-item-active': {
         backgroundColor: 'rgba(125, 125, 125, 0.1)',
       },

+ 1 - 1
services/web/frontend/js/features/source-editor/languages/latex/codemirror-dev-tools.ts

@@ -27,7 +27,7 @@ const devToolsButton = ViewPlugin.define(view => {
 
   const addButton = () => {
     const button = document.createElement('button')
-    button.classList.add('btn', 'formatting-btn', 'formatting-btn--icon')
+    button.classList.add('btn', 'formatting-btn', 'formatting-btn-icon')
     button.id = 'cm6-dev-tools-button'
     button.textContent = '🦧'
     button.style.border = 'none'

+ 99 - 0
services/web/frontend/js/features/ui/components/bootstrap-3/toggle-button-group.tsx

@@ -0,0 +1,99 @@
+import {
+  Children,
+  cloneElement,
+  isValidElement,
+  useState,
+  useEffect,
+} from 'react'
+import {
+  ToggleButtonGroup as BS3ToggleButtonGroup,
+  ToggleButtonGroupProps as BS3ToggleButtonGroupProps,
+  ToggleButtonProps as BS3ToggleButtonProps,
+} from 'react-bootstrap'
+
+function ToggleButtonGroup<T extends string | number>({
+  children,
+  value,
+  defaultValue,
+  onChange,
+  ...props
+}: BS3ToggleButtonGroupProps) {
+  const [selectedValue, setSelectedValue] = useState<T | T[] | null>(
+    defaultValue || (props.type === 'checkbox' ? [] : null)
+  )
+  const isControlled = value !== undefined
+
+  useEffect(() => {
+    if (isControlled) {
+      if (props.type === 'radio') {
+        setSelectedValue(value)
+      } else {
+        if (Array.isArray(value)) {
+          setSelectedValue(Array.from(value))
+        } else {
+          setSelectedValue([value])
+        }
+      }
+    }
+  }, [isControlled, value, props.type])
+
+  const handleButtonClick = (buttonValue: T) => {
+    if (props.type === 'radio') {
+      if (!isControlled) {
+        setSelectedValue(buttonValue)
+      }
+
+      onChange?.(buttonValue as any)
+    } else if (props.type === 'checkbox') {
+      const newValue = Array.isArray(selectedValue)
+        ? selectedValue.includes(buttonValue)
+          ? selectedValue.filter(val => val !== buttonValue) // Deselect
+          : [...selectedValue, buttonValue] // Select
+        : [buttonValue] // Initial selection if value is not array yet
+
+      if (!isControlled) {
+        setSelectedValue(newValue)
+      }
+
+      onChange?.(newValue)
+    }
+  }
+
+  // Clone children and add custom onClick handlers
+  const modifiedChildren = Children.map(children, child => {
+    if (isValidElement(child)) {
+      const childElement = child as React.ReactElement<
+        BS3ToggleButtonProps & { active?: boolean }
+      >
+
+      const isActive =
+        props.type === 'radio'
+          ? selectedValue === childElement.props.value
+          : Array.isArray(selectedValue) &&
+            selectedValue.includes(childElement.props.value as T)
+
+      return cloneElement(childElement, {
+        onClick: () => {
+          handleButtonClick(childElement.props.value as T)
+        },
+        active: isActive,
+      })
+    }
+
+    return child
+  })
+
+  return (
+    <BS3ToggleButtonGroup
+      {...props}
+      value={isControlled ? value : undefined}
+      defaultValue={defaultValue}
+      // Ignore the broken onChange handler
+      onChange={() => {}}
+    >
+      {modifiedChildren}
+    </BS3ToggleButtonGroup>
+  )
+}
+
+export default ToggleButtonGroup

+ 5 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -17,6 +17,7 @@ import type {
 } from '@/features/ui/components/types/dropdown-menu-props'
 import MaterialIcon from '@/shared/components/material-icon'
 import { fixedForwardRef } from '@/utils/react'
+import classnames from 'classnames'
 
 export function Dropdown({ ...props }: DropdownProps) {
   return <BS5Dropdown {...props} />
@@ -26,6 +27,7 @@ function DropdownItem(
   {
     active,
     children,
+    className,
     description,
     leadingIcon,
     trailingIcon,
@@ -74,7 +76,9 @@ function DropdownItem(
   return (
     <BS5DropdownItem
       active={active}
-      className={description ? 'dropdown-item-description-container' : ''}
+      className={classnames(className, {
+        'dropdown-item-description-container': description,
+      })}
       role="menuitem"
       {...props}
       ref={ref}

+ 12 - 3
services/web/frontend/js/features/ui/components/ol/ol-button.tsx

@@ -13,6 +13,7 @@ export type OLButtonProps = ButtonProps & {
     loading?: React.ReactNode
     bsSize?: BS3ButtonSize
     block?: boolean
+    className?: string
   }
 }
 
@@ -43,8 +44,8 @@ export const mapBsButtonSizes = (
 ): 'sm' | 'lg' | undefined =>
   size === 'small' ? 'sm' : size === 'large' ? 'lg' : undefined
 
-export default function OLButton(props: OLButtonProps) {
-  const { bs3Props, ...rest } = props
+export default function OLButton({ bs3Props = {}, ...rest }: OLButtonProps) {
+  const { className: _, ...restBs3Props } = bs3Props
 
   // Get all `aria-*` and `data-*` attributes
   const extraProps = getAriaAndDataProps(rest)
@@ -52,7 +53,15 @@ export default function OLButton(props: OLButtonProps) {
   return (
     <BootstrapVersionSwitcher
       bs3={
-        <BS3Button {...bs3ButtonProps(rest)} {...bs3Props} {...extraProps}>
+        <BS3Button
+          {...bs3ButtonProps({
+            ...rest,
+            // Override the `className` with bs3 specific className (if provided)
+            className: bs3Props?.className ?? rest.className,
+          })}
+          {...restBs3Props}
+          {...extraProps}
+        >
           {bs3Props?.loading || rest.children}
         </BS3Button>
       }

+ 2 - 0
services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx

@@ -20,6 +20,8 @@ function OLFormCheckbox(props: OLFormCheckboxProps) {
     inline: rest.inline,
     title: rest.title,
     autoComplete: rest.autoComplete,
+    defaultChecked: rest.defaultChecked,
+    className: rest.className,
     onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
     inputRef: node => {
       if (inputRef) {

+ 42 - 0
services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx

@@ -0,0 +1,42 @@
+import { ListGroupItem, ListGroupItemProps } from 'react-bootstrap-5'
+import {
+  ListGroupItem as BS3ListGroupItem,
+  ListGroupItemProps as BS3ListGroupItemProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type OLListGroupItemProps = ListGroupItemProps & {
+  bs3Props?: BS3ListGroupItemProps
+}
+
+function OLListGroupItem(props: OLListGroupItemProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ListGroupItemProps: BS3ListGroupItemProps = {
+    children: rest.children,
+    active: rest.active,
+    disabled: rest.disabled,
+    href: rest.href,
+    onClick: rest.onClick as BS3ListGroupItemProps['onClick'],
+    ...bs3Props,
+  }
+
+  const extraProps = getAriaAndDataProps(rest)
+  const as = rest.as ?? 'button'
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3ListGroupItem {...bs3ListGroupItemProps} {...extraProps} />}
+      bs5={
+        <ListGroupItem
+          {...rest}
+          as={as}
+          type={as === 'button' ? 'button' : undefined}
+        />
+      }
+    />
+  )
+}
+
+export default OLListGroupItem

+ 34 - 0
services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx

@@ -0,0 +1,34 @@
+import { ListGroup, ListGroupProps } from 'react-bootstrap-5'
+import {
+  ListGroup as BS3ListGroup,
+  ListGroupProps as BS3ListGroupProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type OLListGroupProps = ListGroupProps & {
+  bs3Props?: BS3ListGroupProps
+}
+
+function OLListGroup(props: OLListGroupProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ListGroupProps: BS3ListGroupProps = {
+    children: rest.children,
+    role: rest.role,
+    componentClass: rest.as,
+    ...bs3Props,
+  }
+
+  const extraProps = getAriaAndDataProps(rest)
+  const as = rest.as ?? 'div'
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3ListGroup {...bs3ListGroupProps} {...extraProps} />}
+      bs5={<ListGroup {...rest} as={as} />}
+    />
+  )
+}
+
+export default OLListGroup

+ 60 - 0
services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx

@@ -0,0 +1,60 @@
+import { Overlay, OverlayProps } from 'react-bootstrap-5'
+import {
+  Overlay as BS3Overlay,
+  OverlayProps as BS3OverlayProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type OLOverlayProps = OverlayProps & {
+  bs3Props?: BS3OverlayProps
+}
+
+function OLOverlay(props: OLOverlayProps) {
+  const { bs3Props, ...bs5Props } = props
+
+  let bs3OverlayProps: BS3OverlayProps = {
+    children: bs5Props.children,
+    target: bs5Props.target as BS3OverlayProps['target'],
+    container: bs5Props.container,
+    containerPadding: bs5Props.containerPadding,
+    show: bs5Props.show,
+    rootClose: bs5Props.rootClose,
+    animation: bs5Props.transition,
+    onHide: bs5Props.onHide as BS3OverlayProps['onHide'],
+    onEnter: bs5Props.onEnter as BS3OverlayProps['onEnter'],
+    onEntering: bs5Props.onEntering as BS3OverlayProps['onEntering'],
+    onEntered: bs5Props.onEntered as BS3OverlayProps['onEntered'],
+    onExit: bs5Props.onExit as BS3OverlayProps['onExit'],
+    onExiting: bs5Props.onExiting as BS3OverlayProps['onExiting'],
+    onExited: bs5Props.onExited as BS3OverlayProps['onExited'],
+  }
+
+  if (bs5Props.placement) {
+    const bs3PlacementOptions = [
+      'top',
+      'right',
+      'bottom',
+      'left',
+    ] satisfies Array<
+      Extract<OverlayProps['placement'], BS3OverlayProps['placement']>
+    >
+
+    for (const placement of bs3PlacementOptions) {
+      if (placement === bs5Props.placement) {
+        bs3OverlayProps.placement = bs5Props.placement
+        break
+      }
+    }
+  }
+
+  bs3OverlayProps = { ...bs3OverlayProps, ...bs3Props }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3Overlay {...bs3OverlayProps} />}
+      bs5={<Overlay {...bs5Props} />}
+    />
+  )
+}
+
+export default OLOverlay

+ 85 - 0
services/web/frontend/js/features/ui/components/ol/ol-popover.tsx

@@ -0,0 +1,85 @@
+import { forwardRef } from 'react'
+import { Popover, PopoverProps } from 'react-bootstrap-5'
+import {
+  Popover as BS3Popover,
+  PopoverProps as BS3PopoverProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type OLPopoverProps = PopoverProps & {
+  title?: React.ReactNode
+  bs3Props?: BS3PopoverProps
+}
+
+const OLPopover = forwardRef<HTMLDivElement, OLPopoverProps>((props, ref) => {
+  // BS3 passes in some props automatically so the `props`
+  // type should be adjusted to reflect the actual received object
+  const propsCombinedWithAutoInjectedBs3Props = props as OLPopoverProps &
+    Pick<
+      BS3PopoverProps,
+      'arrowOffsetLeft' | 'arrowOffsetTop' | 'positionLeft' | 'positionTop'
+    >
+
+  const {
+    bs3Props,
+    title,
+    children,
+    arrowOffsetLeft,
+    arrowOffsetTop,
+    positionLeft,
+    positionTop,
+    ...bs5Props
+  } = propsCombinedWithAutoInjectedBs3Props
+
+  let bs3PopoverProps: BS3PopoverProps = {
+    children,
+    arrowOffsetLeft,
+    arrowOffsetTop,
+    positionLeft,
+    positionTop,
+    title,
+    id: bs5Props.id,
+    className: bs5Props.className,
+    style: bs5Props.style,
+  }
+
+  if (bs5Props.placement) {
+    const bs3PlacementOptions = [
+      'top',
+      'right',
+      'bottom',
+      'left',
+    ] satisfies Array<
+      Extract<PopoverProps['placement'], BS3PopoverProps['placement']>
+    >
+
+    for (const placement of bs3PlacementOptions) {
+      if (placement === bs5Props.placement) {
+        bs3PopoverProps.placement = bs5Props.placement
+        break
+      }
+    }
+  }
+
+  bs3PopoverProps = { ...bs3PopoverProps, ...bs3Props }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Popover
+          {...bs3PopoverProps}
+          ref={ref as React.LegacyRef<BS3Popover>}
+        />
+      }
+      bs5={
+        <Popover {...bs5Props} ref={ref}>
+          {title && <Popover.Header>{title}</Popover.Header>}
+          <Popover.Body>{children}</Popover.Body>
+        </Popover>
+      }
+    />
+  )
+})
+OLPopover.displayName = 'OLPopover'
+
+export default OLPopover

+ 42 - 0
services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx

@@ -0,0 +1,42 @@
+import { ToggleButtonGroup, ToggleButtonGroupProps } from 'react-bootstrap-5'
+import BS3ToggleButtonGroup from '@/features/ui/components/bootstrap-3/toggle-button-group'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type BS3ToggleButtonGroupProps = React.ComponentProps<
+  typeof BS3ToggleButtonGroup
+>
+
+type OLToggleButtonGroupProps<T> = ToggleButtonGroupProps<T> & {
+  bs3Props?: BS3ToggleButtonGroupProps
+}
+
+function OLToggleButtonGroup<T>(props: OLToggleButtonGroupProps<T>) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ToggleButtonGroupProps = {
+    name: rest.name,
+    type: rest.type,
+    value: rest.value,
+    onChange: rest.onChange,
+    children: rest.children,
+    className: rest.className,
+    defaultValue: rest.defaultValue,
+    defaultChecked: rest.defaultChecked,
+    ...bs3Props,
+  } as BS3ToggleButtonGroupProps
+
+  // Get all `aria-*` and `data-*` attributes
+  const extraProps = getAriaAndDataProps(rest)
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3ToggleButtonGroup {...extraProps} {...bs3ToggleButtonGroupProps} />
+      }
+      bs5={<ToggleButtonGroup {...rest} />}
+    />
+  )
+}
+
+export default OLToggleButtonGroup

+ 48 - 0
services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx

@@ -0,0 +1,48 @@
+import { ToggleButton, ToggleButtonProps } from 'react-bootstrap-5'
+import {
+  ToggleButton as BS3ToggleButton,
+  ToggleButtonProps as BS3ToggleButtonProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
+
+type OLToggleButtonProps = ToggleButtonProps & {
+  bs3Props?: BS3ToggleButtonProps
+}
+
+function OLToggleButton(props: OLToggleButtonProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ToggleButtonProps: BS3ToggleButtonProps & { active?: boolean } = {
+    type: rest.type,
+    name: rest.name,
+    active: rest.active,
+    checked: rest.checked,
+    disabled: rest.disabled,
+    onChange: rest.onChange as BS3ToggleButtonProps['onChange'],
+    onClick: rest.onClick as BS3ToggleButtonProps['onClick'],
+    value: rest.value as BS3ToggleButtonProps['value'],
+    children: rest.children,
+    className: classnames(`btn-${props.variant || 'primary'}`, rest.className),
+    ...bs3Props,
+  }
+
+  // Get all `aria-*` and `data-*` attributes
+  const extraProps = getAriaAndDataProps(rest)
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3ToggleButton
+          {...extraProps}
+          {...bs3ToggleButtonProps}
+          bsStyle={null}
+        />
+      }
+      bs5={<ToggleButton {...rest} />}
+    />
+  )
+}
+
+export default OLToggleButton

+ 20 - 13
services/web/frontend/js/shared/components/beta-badge.tsx

@@ -1,13 +1,16 @@
 import type { FC, ReactNode } from 'react'
 import classnames from 'classnames'
-import Tooltip from './tooltip'
-import { OverlayTriggerProps } from 'react-bootstrap'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type TooltipProps = {
   id: string
   text: ReactNode
-  placement?: OverlayTriggerProps['placement']
   className?: string
+  placement?: NonNullable<
+    React.ComponentProps<typeof OLTooltip>['overlayProps']
+  >['placement']
 }
 
 const BetaBadge: FC<{
@@ -15,7 +18,7 @@ const BetaBadge: FC<{
   url?: string
   phase?: string
 }> = ({ tooltip, url = '/beta/participate', phase = 'beta' }) => {
-  let badgeClass
+  let badgeClass: 'info-badge' | 'alpha-badge' | 'beta-badge'
   switch (phase) {
     case 'release':
       badgeClass = 'info-badge'
@@ -29,24 +32,28 @@ const BetaBadge: FC<{
   }
 
   return (
-    <Tooltip
+    <OLTooltip
       id={tooltip.id}
       description={tooltip.text}
       tooltipProps={{ className: tooltip.className }}
       overlayProps={{
         placement: tooltip.placement || 'bottom',
-        delayHide: 100,
+        delay: 100,
       }}
     >
-      <a
-        href={url}
-        target="_blank"
-        rel="noopener noreferrer"
-        className={classnames('badge', badgeClass)}
-      >
+      <a href={url} target="_blank" rel="noopener noreferrer">
         <span className="sr-only">{tooltip.text}</span>
+        <BootstrapVersionSwitcher
+          bs3={<span className={classnames('badge', badgeClass)} />}
+          bs5={
+            <MaterialIcon
+              type="info"
+              className={classnames('align-middle', badgeClass)}
+            />
+          }
+        />
       </a>
-    </Tooltip>
+    </OLTooltip>
   )
 }
 

+ 153 - 69
services/web/frontend/js/shared/components/select.tsx

@@ -12,6 +12,11 @@ import classNames from 'classnames'
 import { useSelect } from 'downshift'
 import Icon from './icon'
 import { useTranslation } from 'react-i18next'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { Form, Spinner } from 'react-bootstrap-5'
+import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
+import MaterialIcon from '@/shared/components/material-icon'
+import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
 
 export type SelectProps<T> = {
   // The items rendered as dropdown options.
@@ -79,6 +84,7 @@ export const Select = <T,>({
     getItemProps,
     highlightedIndex,
     openMenu,
+    closeMenu,
   } = useSelect({
     items: items ?? [],
     itemToString,
@@ -117,9 +123,16 @@ export const Select = <T,>({
     }
   }, [name, itemToString, selectedItem, defaultItem])
 
+  const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
+    if (event.key === 'Escape' && isOpen) {
+      event.stopPropagation()
+      closeMenu()
+    }
+  }
+
   const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
     event => {
-      if (event.key === 'Enter' && !isOpen) {
+      if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
         event.preventDefault()
         ;(event.nativeEvent as any).preventDownshiftDefault = true
         openMenu()
@@ -135,77 +148,148 @@ export const Select = <T,>({
     value = defaultText
   }
   return (
-    <div className="select-wrapper" ref={rootRef}>
-      <div>
-        {label ? (
-          <label {...getLabelProps()}>
-            {label}{' '}
-            {optionalLabel && (
-              <span className="select-optional-label text-muted">
-                ({t('optional')})
-              </span>
-            )}{' '}
-            {loading && <Icon data-testid="spinner" fw type="spinner" spin />}
-          </label>
-        ) : null}
-        <div
-          className={classNames({ disabled }, 'select-trigger')}
-          // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
-          tabIndex={0}
-          {...getToggleButtonProps({
-            disabled,
-            onKeyDown,
-          })}
-        >
-          <div>{value}</div>
+    <BootstrapVersionSwitcher
+      bs3={
+        <div className="select-wrapper" ref={rootRef}>
           <div>
-            {isOpen ? (
-              <Icon type="chevron-up" fw />
-            ) : (
-              <Icon type="chevron-down" fw />
-            )}
+            {label ? (
+              <label {...getLabelProps()}>
+                {label}{' '}
+                {optionalLabel && (
+                  <span className="select-optional-label text-muted">
+                    ({t('optional')})
+                  </span>
+                )}{' '}
+                {loading && (
+                  <Icon data-testid="spinner" fw type="spinner" spin />
+                )}
+              </label>
+            ) : null}
+            <div
+              className={classNames({ disabled }, 'select-trigger')}
+              // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+              tabIndex={0}
+              {...getToggleButtonProps({
+                disabled,
+                onKeyDown,
+              })}
+            >
+              <div>{value}</div>
+              <div>
+                {isOpen ? (
+                  <Icon type="chevron-up" fw />
+                ) : (
+                  <Icon type="chevron-down" fw />
+                )}
+              </div>
+            </div>
           </div>
-        </div>
-      </div>
-      <ul
-        className={classNames({ hidden: !isOpen }, 'select-items')}
-        {...getMenuProps({ disabled })}
-      >
-        {isOpen &&
-          items?.map((item, index) => {
-            const isDisabled = itemToDisabled && itemToDisabled(item)
-            return (
-              <li
-                className={classNames({
-                  'select-highlighted': highlightedIndex === index,
-                  'selected-active': selectedItem === item,
-                  'select-icon': selectedIcon,
-                  'select-disabled': isDisabled,
-                })}
-                key={itemToKey(item)}
-                {...getItemProps({ item, index, disabled: isDisabled })}
-              >
-                <span className="select-item-title">
-                  {selectedIcon && (
-                    <div className="select-item-icon">
-                      {(selectedItem === item ||
-                        (!selectedItem && defaultItem === item)) && (
-                        <Icon type="check" fw />
+          <ul
+            className={classNames({ hidden: !isOpen }, 'select-items')}
+            {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
+          >
+            {isOpen &&
+              items?.map((item, index) => {
+                const isDisabled = itemToDisabled && itemToDisabled(item)
+                return (
+                  <li
+                    className={classNames({
+                      'select-highlighted': highlightedIndex === index,
+                      'selected-active': selectedItem === item,
+                      'select-icon': selectedIcon,
+                      'select-disabled': isDisabled,
+                    })}
+                    key={itemToKey(item)}
+                    {...getItemProps({ item, index, disabled: isDisabled })}
+                  >
+                    <span className="select-item-title">
+                      {selectedIcon && (
+                        <div className="select-item-icon">
+                          {(selectedItem === item ||
+                            (!selectedItem && defaultItem === item)) && (
+                            <Icon type="check" fw />
+                          )}
+                        </div>
                       )}
-                    </div>
-                  )}
-                  {itemToString(item)}
-                </span>
+                      {itemToString(item)}
+                    </span>
 
-                {itemToSubtitle ? (
-                  <span className="text-muted select-item-subtitle">
-                    {itemToSubtitle(item)}
-                  </span>
-                ) : null}
-              </li>
-            )
-          })}
-      </ul>
-    </div>
+                    {itemToSubtitle ? (
+                      <span className="text-muted select-item-subtitle">
+                        {itemToSubtitle(item)}
+                      </span>
+                    ) : null}
+                  </li>
+                )
+              })}
+          </ul>
+        </div>
+      }
+      bs5={
+        <div className="select-wrapper" ref={rootRef}>
+          {label ? (
+            <Form.Label {...getLabelProps()}>
+              {label}{' '}
+              {optionalLabel && (
+                <span className="fw-normal">({t('optional')})</span>
+              )}{' '}
+              {loading && (
+                <span data-testid="spinner">
+                  <Spinner
+                    animation="border"
+                    aria-hidden="true"
+                    as="span"
+                    role="status"
+                    size="sm"
+                  />
+                </span>
+              )}
+            </Form.Label>
+          ) : null}
+          <FormControl
+            {...getToggleButtonProps({
+              disabled,
+              onKeyDown,
+              className: 'select-trigger',
+            })}
+            value={value}
+            readOnly
+            append={
+              <MaterialIcon
+                type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
+                className="align-text-bottom"
+              />
+            }
+          />
+          <ul
+            {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
+            className={classNames('dropdown-menu w-100', { show: isOpen })}
+          >
+            {isOpen &&
+              items?.map((item, index) => {
+                const isDisabled = itemToDisabled && itemToDisabled(item)
+                return (
+                  <li role="none" key={itemToKey(item)}>
+                    <DropdownItem
+                      as="button"
+                      className={classNames({
+                        'select-highlighted': highlightedIndex === index,
+                      })}
+                      active={selectedItem === item}
+                      trailingIcon={selectedItem === item ? 'check' : undefined}
+                      description={
+                        itemToSubtitle ? itemToSubtitle(item) : undefined
+                      }
+                      {...getItemProps({ item, index, disabled: isDisabled })}
+                    >
+                      {itemToString(item)}
+                    </DropdownItem>
+                  </li>
+                )
+              })}
+          </ul>
+        </div>
+      }
+    />
   )
 }

+ 0 - 61
services/web/frontend/js/shared/components/switcher.tsx

@@ -1,61 +0,0 @@
-import { FC, createContext, useContext } from 'react'
-
-const SwitcherContext = createContext<
-  | {
-      name: string
-      onChange?: (value: string) => any
-      defaultValue?: string
-      disabled: boolean
-    }
-  | undefined
->(undefined)
-
-export const Switcher: FC<{
-  name: string
-  onChange?: (value: string) => any
-  defaultValue?: string
-  disabled?: boolean
-}> = ({ name, children, onChange, defaultValue, disabled = false }) => {
-  return (
-    <SwitcherContext.Provider
-      value={{ name, onChange, defaultValue, disabled }}
-    >
-      <fieldset>{children}</fieldset>
-    </SwitcherContext.Provider>
-  )
-}
-
-export const SwitcherItem: FC<{
-  value: string
-  label: string
-  checked?: boolean
-}> = ({ value, label, checked = false }) => {
-  const ctx = useContext(SwitcherContext)
-  if (!ctx) {
-    throw new Error('SwitcherItem must be a child of Switcher')
-  }
-  const { name, onChange, defaultValue, disabled } = ctx
-
-  const id = `${name}-option-${value.replace(/\W/g, '')}`
-  return (
-    <>
-      <input
-        type="radio"
-        value={value}
-        id={id}
-        className="switcher-input"
-        name={name}
-        defaultChecked={!disabled && (checked || defaultValue === value)}
-        disabled={disabled}
-        onChange={evt => {
-          if (onChange) {
-            onChange(evt.target.value)
-          }
-        }}
-      />
-      <label htmlFor={id} className="switcher-label" aria-disabled={disabled}>
-        <span>{label}</span>
-      </label>
-    </>
-  )
-}

+ 42 - 15
services/web/frontend/stories/switcher.stories.tsx

@@ -1,28 +1,55 @@
-import { Switcher, SwitcherItem } from '../js/shared/components/switcher'
+import OLToggleButton from '@/features/ui/components/ol/ol-toggle-button'
+import OLToggleButtonGroup from '@/features/ui/components/ol/ol-toggle-button-group'
 
 export const Base = () => {
   return (
-    <Switcher name="figure-width" defaultValue="0.5">
-      <SwitcherItem value="0.25" label="¼ width" />
-      <SwitcherItem value="0.5" label="½ width" />
-      <SwitcherItem value="0.75" label="¾ width" />
-      <SwitcherItem value="1.0" label="Full width" />
-    </Switcher>
+    <OLToggleButtonGroup
+      type="radio"
+      name="figure-width"
+      defaultValue="0.5"
+      aria-label="Image width"
+    >
+      <OLToggleButton variant="secondary" id="width-25p" value="0.25">
+        ¼ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-50p" value="0.5">
+        ½ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-75p" value="0.75">
+        ¾ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-100p" value="1.0">
+        Full width
+      </OLToggleButton>
+    </OLToggleButtonGroup>
   )
 }
 
 export const Disabled = () => {
   return (
-    <Switcher name="figure-width" defaultValue="0.5" disabled>
-      <SwitcherItem value="0.25" label="¼ width" />
-      <SwitcherItem value="0.5" label="½ width" />
-      <SwitcherItem value="0.75" label="¾ width" />
-      <SwitcherItem value="1.0" label="Full width" />
-    </Switcher>
+    <OLToggleButtonGroup
+      type="radio"
+      name="figure-width"
+      defaultValue="0.5"
+      aria-label="Image width"
+    >
+      <OLToggleButton variant="secondary" id="width-25p" disabled value="0.25">
+        ¼ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-50p" disabled value="0.5">
+        ½ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-75p" disabled value="0.75">
+        ¾ width
+      </OLToggleButton>
+      <OLToggleButton variant="secondary" id="width-100p" disabled value="1.0">
+        Full width
+      </OLToggleButton>
+    </OLToggleButtonGroup>
   )
 }
 
 export default {
-  title: 'Shared / Components / Switcher',
-  component: Switcher,
+  title: 'Shared / Components / Toggle Button Group',
+  component: OLToggleButtonGroup,
 }

+ 25 - 50
services/web/frontend/stylesheets/app/editor/figure-modal.less

@@ -1,27 +1,3 @@
-.figure-modal-label {
-  font-weight: normal;
-  line-height: 100%;
-}
-
-.figure-modal-form tr,
-.figure-modal-form td {
-  vertical-align: top;
-}
-
-.figure-modal-footer {
-  display: flex;
-  justify-content: space-between;
-}
-
-.figure-modal-help-buttons,
-.figure-modal-actions {
-  flex: 1 1 auto;
-}
-
-.figure-modal-help-buttons {
-  text-align: left;
-}
-
 .figure-modal-help-link,
 .figure-modal-help-link:hover,
 .figure-modal-help-link:focus,
@@ -34,29 +10,32 @@
   padding-left: 0;
 }
 
-.figure-modal-switcher-input {
-  display: flex;
-}
+.figure-modal-checkbox-input {
+  label {
+    display: inline-block;
+    font-weight: normal;
+    line-height: 100%;
 
-.figure-modal-checkbox-input,
-.figure-modal-switcher-input {
-  margin-top: 16px;
-}
+    .figure-modal-label-content {
+      display: block;
+      overflow: hidden;
+    }
+  }
 
-.figure-modal-checkbox-input input[type='checkbox'] {
-  vertical-align: top;
-  margin-right: 12px;
-  accent-color: @ol-green;
+  input[type='checkbox'] {
+    margin-right: 12px;
+    margin-top: 2px;
+    vertical-align: top;
+    accent-color: @ol-green;
+  }
 }
 
 .figure-modal-switcher-input {
+  position: relative;
+  z-index: 0;
   display: flex;
   justify-content: space-between;
-  vertical-align: middle;
-}
-
-.figure-modal-input-field {
-  width: 100%;
+  align-items: center;
 }
 
 .file-container {
@@ -71,6 +50,7 @@
 
 .file-container-file {
   display: flex;
+  align-items: center;
   border: 1px solid @neutral-20;
   border-radius: 8px;
   background-color: white;
@@ -92,7 +72,6 @@
 
 .file-icon {
   font-size: 20px;
-  line-height: 40px;
 }
 
 .file-action {
@@ -129,17 +108,13 @@
 }
 
 .figure-modal-source-button-icon {
-  flex: 0 0 auto;
-  &.source-icon {
-    margin-right: 6px;
-  }
-}
-
-.figure-modal-input-label:not(:first-child),
-.figure-modal .select-wrapper:not(:first-child) {
-  margin-top: 16px;
+  margin-right: 6px;
 }
 
 .figure-modal-upload .uppy-Dashboard-AddFiles-list {
   display: none;
 }
+
+.select-from-files-btn {
+  vertical-align: baseline;
+}

+ 2 - 2
services/web/frontend/stylesheets/app/editor/toolbar.less

@@ -387,12 +387,12 @@
   }
 }
 
-.formatting-btn--icon {
+.formatting-btn-icon {
   min-width: 32px;
   width: 32px;
 }
 
-.formatting-btn--icon:last-of-type {
+.formatting-btn-icon:last-of-type {
   border-right: 1px solid @formatting-btn-border;
 }
 

+ 28 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss

@@ -175,6 +175,24 @@ $tooltip-border-radius: $border-radius-base;
 $tooltip-padding-y: $spacing-04;
 $tooltip-padding-x: $spacing-06;
 
+// Popovers
+$popover-font-size: var(--font-size-02);
+$popover-bg: var(--bg-dark-primary);
+$popover-max-width: 320px;
+$popover-border-width: 0px; /* stylelint-disable-line length-zero-no-unit */
+$popover-border-radius: var(--border-radius-base);
+$popover-header-font-size: var(--font-size-02);
+$popover-header-bg: var(--bg-dark-primary);
+$popover-header-color: var(--content-primary-dark);
+$popover-header-padding-y: var(--spacing-04);
+$popover-header-padding-x: var(--spacing-06);
+$popover-body-color: var(--content-primary-dark);
+$popover-body-padding-y: var(--spacing-04);
+$popover-body-padding-x: var(--spacing-06);
+$popover-arrow-width: var(--spacing-04);
+$popover-arrow-height: var(--spacing-02);
+$popover-arrow-color: var(--bg-dark-primary);
+
 // Links. Ideally we'd point these to CSS variables but Bootstrap performs
 // calculations on link color during compilation.
 $link-color: $link-ui;
@@ -228,3 +246,13 @@ $dropdown-padding-y: var(--spacing-02);
 $dropdown-item-padding-x: var(--spacing-04);
 $dropdown-item-padding-y: var(--spacing-05);
 $dropdown-header-color: var(--content-secondary);
+
+// List group
+$list-group-color: var(--content-secondary);
+$list-group-border-width: 0;
+$list-group-border-radius: (--border-radius-base);
+$list-group-item-padding-y: var(--spacing-04);
+$list-group-item-padding-x: var(--spacing-05);
+$list-group-hover-bg: var(--bg-light-secondary);
+$list-group-disabled-color: var(--content-disabled);
+$list-group-disabled-bg: var(--bg-light-primary);

+ 2 - 0
services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss

@@ -33,8 +33,10 @@
 @import 'bootstrap-5/scss/dropdown';
 @import 'bootstrap-5/scss/button-group';
 @import 'bootstrap-5/scss/badge';
+@import 'bootstrap-5/scss/list-group';
 @import 'bootstrap-5/scss/modal';
 @import 'bootstrap-5/scss/tooltip';
+@import 'bootstrap-5/scss/popover';
 @import 'bootstrap-5/scss/spinners';
 @import 'bootstrap-5/scss/card';
 @import 'bootstrap-5/scss/close';

+ 4 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -6,6 +6,7 @@
 @import 'notifications';
 @import 'system-messages';
 @import 'tooltip';
+@import 'popover';
 @import 'card';
 @import 'badge';
 @import 'form';
@@ -20,3 +21,6 @@
 @import 'table';
 @import 'blog-posts';
 @import 'tabs';
+@import 'beta-badges';
+@import 'list-group';
+@import 'select';

+ 11 - 0
services/web/frontend/stylesheets/bootstrap-5/components/beta-badges.scss

@@ -0,0 +1,11 @@
+.info-badge {
+  color: var(--blue-50);
+}
+
+.alpha-badge {
+  color: var(--green-50);
+}
+
+.beta-badge {
+  color: var(--yellow-40);
+}

+ 1 - 1
services/web/frontend/stylesheets/bootstrap-5/components/form.scss

@@ -84,7 +84,7 @@
 }
 
 .form-label {
-  &:has(+ .form-control[disabled]) {
+  &:has(+ .form-control[disabled], + .form-control-wrapper-disabled) {
     color: var(--content-disabled);
   }
 }

+ 3 - 0
services/web/frontend/stylesheets/bootstrap-5/components/list-group.scss

@@ -0,0 +1,3 @@
+.list-group-item {
+  min-height: 48px;
+}

+ 15 - 0
services/web/frontend/stylesheets/bootstrap-5/components/popover.scss

@@ -0,0 +1,15 @@
+.popover {
+  @include shadow-md;
+
+  line-height: var(--line-height-02);
+}
+
+.popover-header {
+  padding-bottom: 0;
+  margin-bottom: var(--spacing-02);
+  font-weight: 600;
+
+  & + .popover-body {
+    padding-top: 0;
+  }
+}

+ 11 - 0
services/web/frontend/stylesheets/bootstrap-5/components/select.scss

@@ -0,0 +1,11 @@
+.select-wrapper {
+  position: relative;
+}
+
+.select-trigger {
+  cursor: default;
+}
+
+.select-highlighted {
+  background-color: var(--bg-light-secondary);
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/all.scss

@@ -10,4 +10,5 @@
 @import 'editor/loading-screen';
 @import 'editor/outline';
 @import 'editor/file-tree';
+@import 'editor/figure-modal';
 @import 'website-redesign';

+ 89 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/figure-modal.scss

@@ -0,0 +1,89 @@
+.figure-modal-help-link,
+.figure-modal-help-link:hover,
+.figure-modal-help-link:focus,
+.figure-modal-help-link:active {
+  color: var(--neutral-90) !important;
+  text-decoration: none;
+}
+
+.figure-modal-help-link {
+  padding-left: 0;
+}
+
+.figure-modal-switcher-input {
+  position: relative;
+  z-index: 0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.file-container {
+  width: 100%;
+  height: 120px;
+  padding: var(--spacing-07) var(--spacing-08);
+  border: 1px dashed #eaeaea;
+  background-color: #fafafa;
+  justify-content: space-between;
+  border-radius: var(--border-radius-medium);
+}
+
+.file-container-file {
+  display: flex;
+  align-items: center;
+  border: 1px solid var(--neutral-20);
+  border-radius: var(--border-radius-medium);
+  background-color: white;
+  height: 100%;
+  padding: var(--spacing-06) var(--spacing-07);
+}
+
+.file-info {
+  margin-left: var(--spacing-07);
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+}
+
+.file-name {
+  font-weight: bold;
+}
+
+.figure-modal-source-button-grid {
+  display: grid;
+  justify-content: space-between;
+  gap: 8px;
+  grid-template-columns: 1fr 1fr;
+  margin: 0 auto;
+
+  &:not(:first-of-type) {
+    margin-top: 8px;
+  }
+}
+
+.figure-modal-source-button {
+  display: flex;
+  flex: 1 1 0;
+  align-items: center;
+  box-shadow: 0 2px 4px 0 #1e253029;
+  line-height: 44px;
+  background-color: var(--white);
+  border-radius: var(--border-radius-base);
+  border: none;
+  padding: 0 var(--spacing-04);
+
+  &-title {
+    flex: 1 1 auto;
+    text-align: left;
+  }
+}
+
+.figure-modal-source-button-icon {
+  margin-right: var(--spacing-03);
+}
+
+/* stylelint-disable selector-class-pattern */
+.figure-modal-upload .uppy-Dashboard-AddFiles-list {
+  display: none;
+}

+ 6 - 1
services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss

@@ -79,7 +79,7 @@
 }
 
 .ide-panel-group-resizing {
-  background-color: white;
+  background-color: var(--white);
 
   // Hide panel contents while resizing
   .ide-react-editor-content,
@@ -188,3 +188,8 @@
     opacity: 0.5;
   }
 }
+
+.full-size {
+  position: absolute;
+  inset: 0;
+}

+ 164 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss

@@ -5,12 +5,18 @@
   --toolbar-btn-color: var(--white);
   --toolbar-btn-hover-bg-color: var(--neutral-80);
   --toolbar-btn-hover-color: var(--white);
+  --toolbar-btn-active-color: var(--white);
+  --toolbar-btn-active-bg-color: var(--green-50);
+  --formatting-btn-color: var(--white);
+  --formatting-btn-bg: var(--neutral-80);
+  --formatting-btn-border: var(--neutral-70);
   --project-name-color: var(--neutral-40);
   --toolbar-filetree-bg-color: var(--neutral-80);
   --project-rename-link-color: var(--neutral-40);
   --project-rename-link-color-hover: var(--neutral-20);
   --editor-header-logo-background: url(../../../../../public/img/ol-brand/overleaf-o-white.svg)
     center / contain no-repeat;
+  --editor-toolbar-bg: var(--neutral-80);
 }
 
 @include theme('light') {
@@ -20,12 +26,17 @@
   --toolbar-btn-color: var(--neutral-70);
   --toolbar-btn-hover-bg-color: var(--neutral-10);
   --toolbar-btn-hover-color: var(--neutral-70);
+  --toolbar-btn-active-bg-color: var(--green-50);
+  --formatting-btn-color: var(--neutral-70);
+  --formatting-btn-bg: transparent;
+  --formatting-btn-border: var(--neutral-20);
   --project-name-color: var(--neutral-70);
   --toolbar-filetree-bg-color: var(--white);
   --project-rename-link-color: var(--neutral-70);
   --project-rename-link-color-hover: var(--neutral-70);
   --editor-header-logo-background: url(../../../../../public/img/ol-brand/overleaf-o.svg)
     center / contain no-repeat;
+  --editor-toolbar-bg: var(--white);
 }
 
 .toolbar {
@@ -300,3 +311,156 @@
     }
   }
 }
+
+.toolbar-editor {
+  height: 32px;
+  background-color: var(--editor-toolbar-bg);
+  padding: 0 5px;
+  overflow: hidden;
+  position: relative;
+  z-index: 10; // Prevent track changes showing over toolbar
+}
+
+/**************************************
+	Toggle Switch
+***************************************/
+
+.toggle-switch {
+  display: inline-flex;
+  align-items: center;
+  height: 26px;
+  margin-right: var(--spacing-03);
+  border-radius: var(--border-radius-full);
+  background-color: var(--neutral-20);
+  padding: var(--spacing-01);
+}
+
+.toggle-switch-label {
+  display: inline-block;
+  float: left;
+  font-weight: normal;
+  height: 100%;
+  text-align: center;
+  margin: 0;
+  cursor: pointer;
+  user-select: none;
+  color: var(--content-secondary);
+  border-radius: var(--border-radius-full);
+  transition:
+    color 0.12s ease-out,
+    background-color 0.12s ease-out,
+    box-shadow 0.12s ease-out;
+  overflow: hidden;
+
+  span {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    width: 100%;
+    padding: 0 var(--spacing-08);
+    background-size: 200% 100%;
+    background-position: 0 0;
+    transition: background-position 0.12s ease-out;
+    font-size: var(--font-size-02);
+    font-weight: bold;
+  }
+}
+
+.toggle-switch-input {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+}
+
+.toggle-switch-input:disabled + .toggle-switch-label {
+  cursor: not-allowed;
+}
+
+.toggle-switch-input:checked + .toggle-switch-label {
+  span {
+    background-position: -100% 0;
+  }
+
+  color: var(--white);
+  background-color: var(--bg-accent-01);
+  border-radius: var(--border-radius-full);
+  box-shadow: 0 2px 4px rgb(30 37 48 / 16%);
+}
+
+.toggle-switch-input:checked:nth-child(2) + .toggle-switch-label {
+  span {
+    background-position: 100% 0;
+  }
+}
+
+.editor-toggle-switch {
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+
+  .toggle-switch {
+    margin-left: var(--spacing-03);
+  }
+
+  .toggle-switch-label span {
+    background: none;
+    transition: background 0.12s ease-out;
+  }
+
+  .toggle-switch-label:first-of-type span {
+    padding-left: var(--spacing-04);
+  }
+
+  .toggle-switch-label:last-of-type span {
+    padding-right: var(--spacing-04);
+    border-right: none;
+  }
+}
+
+/**************************************
+	Formatting buttons
+***************************************/
+.formatting-btn {
+  color: var(--formatting-btn-color);
+  background-color: var(--formatting-btn-bg);
+  padding: 0;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: none;
+  border: none;
+  border-left: 1px solid var(--formatting-btn-border);
+  border-radius: 0;
+
+  &:hover {
+    color: var(--formatting-btn-color);
+  }
+
+  &.active {
+    color: var(--white);
+    background-color: var(--toolbar-btn-active-bg-color);
+    box-shadow: none;
+
+    &:focus {
+      color: var(--toolbar-btn-active-color);
+
+      &:not(:focus-visible) {
+        outline: none;
+      }
+    }
+  }
+
+  &:focus {
+    color: var(--formatting-btn-color);
+  }
+}
+
+.formatting-btn-icon {
+  min-width: 32px;
+  width: 32px;
+}
+
+.formatting-btn-icon:last-of-type {
+  border-right: 1px solid var(--formatting-btn-border);
+}

+ 14 - 0
services/web/frontend/stylesheets/components/button-groups.less

@@ -25,6 +25,20 @@
   }
 }
 
+.btn-group {
+  .btn.active {
+    background-color: @neutral-20;
+  }
+
+  .btn[disabled] {
+    cursor: not-allowed;
+    background-color: @neutral-20;
+    border-color: @neutral-20;
+    opacity: 1;
+    color: @neutral-90;
+  }
+}
+
 // Prevent double borders when buttons are next to each other
 .btn-group {
   .btn + .btn,

+ 0 - 37
services/web/frontend/stylesheets/components/switcher.less

@@ -1,37 +0,0 @@
-.switcher-input {
-  position: absolute;
-  opacity: 0;
-  pointer-events: none;
-}
-
-.switcher-label {
-  border: 1px solid black;
-  padding: 4px 16px;
-  user-select: none;
-}
-
-.switcher-label:first-of-type {
-  border-top-left-radius: 999px;
-  border-bottom-left-radius: 999px;
-}
-
-.switcher-label:last-of-type {
-  border-top-right-radius: 999px;
-  border-bottom-right-radius: 999px;
-}
-
-.switcher-label:not(:first-of-type) {
-  border-left-width: 0px;
-}
-
-.switcher-input:checked + .switcher-label {
-  background-color: @neutral-20;
-}
-
-.switcher-input:disabled + .switcher-label {
-  cursor: not-allowed;
-  background-color: @neutral-20;
-  border-color: @neutral-20;
-  opacity: 1;
-  color: @neutral-90;
-}

+ 0 - 1
services/web/frontend/stylesheets/main-style.less

@@ -72,7 +72,6 @@
 @import 'components/list-group.less';
 @import 'components/select.less';
 @import 'components/switch.less';
-@import 'components/switcher.less';
 @import 'components/stepper.less';
 @import 'components/radio-chip.less';
 @import 'components/interstitial.less';

+ 9 - 0
services/web/locales/en.json

@@ -2143,7 +2143,9 @@
   "took_a_while": "That took a while...",
   "toolbar_bullet_list": "Bullet List",
   "toolbar_choose_section_heading_level": "Choose section heading level",
+  "toolbar_code_visual_editor_switch": "Code and visual editor switch",
   "toolbar_decrease_indent": "Decrease Indent",
+  "toolbar_editor": "Editor tools",
   "toolbar_format_bold": "Format Bold",
   "toolbar_format_italic": "Format Italic",
   "toolbar_increase_indent": "Increase Indent",
@@ -2154,7 +2156,10 @@
   "toolbar_insert_inline_math": "Insert Inline Math",
   "toolbar_insert_link": "Insert Link",
   "toolbar_insert_math": "Insert Math",
+  "toolbar_insert_math_and_symbols": "Insert Math and Symbols",
+  "toolbar_insert_misc": "Insert Misc (links, citations, cross-references, figures, tables)",
   "toolbar_insert_table": "Insert Table",
+  "toolbar_list_indentation": "List and Indentation",
   "toolbar_numbered_list": "Numbered List",
   "toolbar_redo": "Redo",
   "toolbar_selected_projects": "Selected projects",
@@ -2163,8 +2168,12 @@
   "toolbar_selected_projects_restore": "Restore selected projects",
   "toolbar_table_insert_size_table": "Insert __size__ table",
   "toolbar_table_insert_table_lowercase": "Insert table",
+  "toolbar_text_formatting": "Text formatting",
+  "toolbar_text_style": "Text style",
   "toolbar_toggle_symbol_palette": "Toggle Symbol Palette",
   "toolbar_undo": "Undo",
+  "toolbar_undo_redo_actions": "Undo/Redo actions",
+  "toolbar_visibility": "Toolbar visibility",
   "tooltip_hide_filetree": "Click to hide the file tree",
   "tooltip_hide_pdf": "Click to hide the PDF",
   "tooltip_show_filetree": "Click to show the file tree",

+ 14 - 12
services/web/test/frontend/components/shared/split-test-badge.spec.tsx

@@ -28,9 +28,9 @@ describe('split test badge', function () {
       </EditorProviders>
     )
 
-    cy.get('a.badge.alpha-badge[href="/alpha/participate"]').contains(
-      'This is an alpha feature'
-    )
+    cy.findByRole('link', { name: /this is an alpha feature/i })
+      .should('have.attr', 'href', '/alpha/participate')
+      .find('.badge.alpha-badge')
   })
 
   it('does not render the alpha badge when user is not assigned to the variant', function () {
@@ -86,9 +86,9 @@ describe('split test badge', function () {
       </EditorProviders>
     )
 
-    cy.get('a.badge.beta-badge[href="/beta/participate"]').contains(
-      'This is a beta feature'
-    )
+    cy.findByRole('link', { name: /this is a beta feature/i })
+      .should('have.attr', 'href', '/beta/participate')
+      .find('.badge.beta-badge')
   })
 
   it('does not render the beta badge when user is not assigned to the variant', function () {
@@ -144,9 +144,9 @@ describe('split test badge', function () {
       </EditorProviders>
     )
 
-    cy.get('a.badge.info-badge[href="/feedback/form"]').contains(
-      'This is a new feature'
-    )
+    cy.findByRole('link', { name: /this is a new feature/i })
+      .should('have.attr', 'href', '/feedback/form')
+      .find('.badge.info-badge')
   })
 
   it('does not render the info badge when user is not assigned to the variant', function () {
@@ -218,8 +218,10 @@ describe('split test badge', function () {
       </EditorProviders>
     )
 
-    cy.get('a.badge.info-badge[href="/beta/participate"]')
-      .contains('We are testing this new feature.')
-      .contains('Click to give feedback')
+    cy.findByRole('link', {
+      name: /we are testing this new feature.*click to give feedback/i,
+    })
+      .should('have.attr', 'href', '/beta/participate')
+      .find('.badge.info-badge')
   })
 })