Просмотр исходного кода

Merge pull request #32642 from overleaf/ab-non-blinking-cursor-editor-setting

[web] Add non-blinking cursor editor setting

GitOrigin-RevId: 078efca21c1e87905894aadc1430c1728a38685d
Alexandre Bourdin 6 месяцев назад
Родитель
Сommit
50db96878d

+ 1 - 0
services/web/app/src/Features/Project/UserSettingsHelper.mjs

@@ -44,6 +44,7 @@ async function buildUserSettings(req, res, user) {
     overallTheme: await getOverallTheme(req, res, user),
     mathPreview: user.ace.mathPreview,
     breadcrumbs: user.ace.breadcrumbs,
+    nonBlinkingCursor: user.ace.nonBlinkingCursor ?? false,
     referencesSearchMode: user.ace.referencesSearchMode,
     darkModePdf: user.ace.darkModePdf ?? false,
     zotero: user.ace.zotero,

+ 3 - 0
services/web/app/src/Features/User/UserController.mjs

@@ -421,6 +421,9 @@ async function updateUserSettings(req, res, next) {
   if (body.breadcrumbs != null) {
     user.ace.breadcrumbs = Boolean(body.breadcrumbs)
   }
+  if (body.nonBlinkingCursor != null) {
+    user.ace.nonBlinkingCursor = Boolean(body.nonBlinkingCursor)
+  }
   if (body.referencesSearchMode != null) {
     const mode = body.referencesSearchMode === 'simple' ? 'simple' : 'advanced'
     user.ace.referencesSearchMode = mode

+ 1 - 0
services/web/app/src/models/User.mjs

@@ -106,6 +106,7 @@ export const UserSchema = new Schema(
       lineHeight: { type: String },
       mathPreview: { type: Boolean, default: true },
       breadcrumbs: { type: Boolean, default: true },
+      nonBlinkingCursor: { type: Boolean, default: false },
       referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple'
       darkModePdf: { type: Boolean, default: false },
       zotero: refProviderSettingsSchema,

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

@@ -1231,6 +1231,7 @@
   "no_symbols_found": "",
   "no_thanks_cancel_now": "",
   "no_token": "",
+  "non_blinking_cursor": "",
   "normal": "",
   "normally_x_price_per_month": "",
   "normally_x_price_per_year": "",
@@ -1497,6 +1498,7 @@
   "redirect_url": "",
   "redo": "",
   "reduce_costs_group_licenses": "",
+  "reduces_visual_distraction_by_keeping_the_cursor_solid": "",
   "reference_error_relink_hint": "",
   "reference_manager_linked": "",
   "reference_manager_lowercase": "",

+ 9 - 0
services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx

@@ -33,6 +33,9 @@ type ProjectSettingsSetterContextValue = {
   setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void
   setMathPreview: (mathPreview: UserSettings['mathPreview']) => void
   setBreadcrumbs: (breadcrumbs: UserSettings['breadcrumbs']) => void
+  setNonBlinkingCursor: (
+    nonBlinkingCursor: UserSettings['nonBlinkingCursor']
+  ) => void
   setDarkModePdf: (darkModePdf: UserSettings['darkModePdf']) => void
   setZotero: (zotero: UserSettings['zotero']) => void
   setMendeley: (mendeley: UserSettings['mendeley']) => void
@@ -92,6 +95,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
     setMathPreview,
     breadcrumbs,
     setBreadcrumbs,
+    nonBlinkingCursor,
+    setNonBlinkingCursor,
     darkModePdf,
     setDarkModePdf,
     zotero,
@@ -144,6 +149,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
       setMathPreview,
       breadcrumbs,
       setBreadcrumbs,
+      nonBlinkingCursor,
+      setNonBlinkingCursor,
       darkModePdf,
       setDarkModePdf,
       zotero,
@@ -192,6 +199,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
       setMathPreview,
       breadcrumbs,
       setBreadcrumbs,
+      nonBlinkingCursor,
+      setNonBlinkingCursor,
       darkModePdf,
       setDarkModePdf,
       zotero,

+ 10 - 0
services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx

@@ -24,6 +24,7 @@ export default function useUserWideSettings() {
     pdfViewer,
     mathPreview,
     breadcrumbs,
+    nonBlinkingCursor,
     darkModePdf,
     zotero,
     mendeley,
@@ -129,6 +130,13 @@ export default function useUserWideSettings() {
     [saveUserSettings]
   )
 
+  const setNonBlinkingCursor = useCallback(
+    (nonBlinkingCursor: UserSettings['nonBlinkingCursor']) => {
+      saveUserSettings('nonBlinkingCursor', nonBlinkingCursor)
+    },
+    [saveUserSettings]
+  )
+
   const setDarkModePdf = useCallback(
     (darkModePdf: UserSettings['darkModePdf']) => {
       saveUserSettings('darkModePdf', darkModePdf)
@@ -188,6 +196,8 @@ export default function useUserWideSettings() {
     setMathPreview,
     breadcrumbs,
     setBreadcrumbs,
+    nonBlinkingCursor,
+    setNonBlinkingCursor,
     darkModePdf,
     setDarkModePdf,
     zotero,

+ 19 - 0
services/web/frontend/js/features/settings/components/editor-settings/non-blinking-cursor-setting.tsx

@@ -0,0 +1,19 @@
+import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
+import ToggleSetting from '../toggle-setting'
+import { useTranslation } from 'react-i18next'
+
+export default function NonBlinkingCursorSetting() {
+  const { nonBlinkingCursor, setNonBlinkingCursor } =
+    useProjectSettingsContext()
+  const { t } = useTranslation()
+
+  return (
+    <ToggleSetting
+      id="non-blinking-cursor"
+      label={t('non_blinking_cursor')}
+      description={t('reduces_visual_distraction_by_keeping_the_cursor_solid')}
+      checked={nonBlinkingCursor}
+      onChange={setNonBlinkingCursor}
+    />
+  )
+}

+ 5 - 0
services/web/frontend/js/features/settings/context/settings-modal-context.tsx

@@ -11,6 +11,7 @@ import SpellCheckSetting from '@/features/settings/components/editor-settings/sp
 import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
 import { useTranslation } from 'react-i18next'
 import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
+import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
 import MathPreviewSetting from '@/features/settings/components/editor-settings/math-preview-setting'
 import RootDocumentSetting from '@/features/settings/components/compiler-settings/root-document-setting'
 import CompilerSetting from '@/features/settings/components/compiler-settings/compiler-setting'
@@ -111,6 +112,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
                 key: 'autoPairDelimiters',
                 component: <AutoCloseBracketsSetting />,
               },
+              {
+                key: 'nonBlinkingCursor',
+                component: <NonBlinkingCursorSetting />,
+              },
               {
                 key: 'syntaxValidation',
                 component: <CodeCheckSetting />,

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/draw-selection.ts

@@ -9,6 +9,8 @@ import browser from './browser'
  * copied to make use of a custom version of rectangleMarkerForRange which calls
  * fullHeightCoordsAtPos when in Source mode, extending the top and bottom
  * of the coords to cover the full line height.
+ *
+ * Whenever possible, we should avoid making changes to this extension.
  */
 export const drawSelection = () => {
   return [cursorLayer, selectionLayer, Prec.highest(hideNativeSelection)]

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -35,6 +35,7 @@ import importOverleafModules from '../../../../macros/import-overleaf-module.mac
 import { emptyLineFiller } from './empty-line-filler'
 import { goToLinePanel } from './go-to-line'
 import { drawSelection } from './draw-selection'
+import { nonBlinkingCursor } from './non-blinking-cursor'
 import { sourceOnly, visual } from './visual/visual'
 import { inlineBackground } from './inline-background'
 import { indentationMarkers } from './indentation-markers'
@@ -75,6 +76,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
     closedText: '▸',
   }),
   drawSelection(),
+  nonBlinkingCursor(),
   // A built-in facet that is set to true to allow multiple selections.
   // This makes the editor more like a code editor than Google Docs or Microsoft Word,
   // which only have single selections.

+ 36 - 0
services/web/frontend/js/features/source-editor/extensions/non-blinking-cursor.ts

@@ -0,0 +1,36 @@
+import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'
+import { EditorView } from '@codemirror/view'
+
+const nonBlinkingCursorEffect = StateEffect.define<boolean>()
+
+const nonBlinkingCursorTheme = EditorView.baseTheme({
+  '&.cm-non-blinking-cursor .cm-cursorLayer': {
+    animationName: 'none !important',
+  },
+})
+
+const nonBlinkingCursorField = StateField.define<boolean>({
+  create: () => false,
+  update: (value, tr) => {
+    for (const effect of tr.effects) {
+      if (effect.is(nonBlinkingCursorEffect)) {
+        value = effect.value
+      }
+    }
+    return value
+  },
+  provide(field) {
+    return [
+      EditorView.editorAttributes.from(field, field => ({
+        class: field ? 'cm-non-blinking-cursor' : '',
+      })),
+      nonBlinkingCursorTheme,
+    ]
+  },
+})
+
+export const setNonBlinkingCursor = (enabled: boolean): TransactionSpec => ({
+  effects: nonBlinkingCursorEffect.of(enabled),
+})
+
+export const nonBlinkingCursor = () => nonBlinkingCursorField

+ 10 - 0
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -44,6 +44,7 @@ import { useMetadataContext } from '@/features/ide-react/context/metadata-contex
 import { useUserContext } from '@/shared/context/user-context'
 import { useReferencesContext } from '@/features/ide-react/context/references-context'
 import { setMathPreview } from '@/features/source-editor/extensions/math-preview'
+import { setNonBlinkingCursor } from '@/features/source-editor/extensions/non-blinking-cursor'
 import { useRangesContext } from '@/features/review-panel/context/ranges-context'
 import { updateRanges } from '@/features/source-editor/extensions/ranges'
 import { useThreadsContext } from '@/features/review-panel/context/threads-context'
@@ -88,6 +89,7 @@ function useCodeMirrorScope(view: EditorView) {
     mode,
     syntaxValidation,
     mathPreview,
+    nonBlinkingCursor,
     referencesSearchMode,
   } = userSettings
   const activeOverallTheme = useActiveOverallTheme()
@@ -158,6 +160,7 @@ function useCodeMirrorScope(view: EditorView) {
     mode,
     syntaxValidation,
     mathPreview,
+    nonBlinkingCursor,
     referencesSearchMode,
   })
 
@@ -467,6 +470,13 @@ function useCodeMirrorScope(view: EditorView) {
     })
   }, [view, mathPreview])
 
+  useEffect(() => {
+    settingsRef.current.nonBlinkingCursor = nonBlinkingCursor
+    window.setTimeout(() => {
+      view.dispatch(setNonBlinkingCursor(nonBlinkingCursor))
+    })
+  }, [view, nonBlinkingCursor])
+
   useEffect(() => {
     settingsRef.current.referencesSearchMode = referencesSearchMode
   }, [referencesSearchMode])

+ 1 - 0
services/web/frontend/js/shared/context/user-settings-context.tsx

@@ -31,6 +31,7 @@ export const defaultSettings: UserSettings = {
   mathPreview: true,
   referencesSearchMode: 'advanced',
   breadcrumbs: true,
+  nonBlinkingCursor: false,
   darkModePdf: false,
   zotero: {
     enabled: true,

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

@@ -1594,6 +1594,7 @@
   "no_thanks_cancel_now": "No thanks, I still want to cancel",
   "no_token": "No token",
   "no_update_email": "No, update email",
+  "non_blinking_cursor": "Non-blinking cursor",
   "non_deletable_entity": "The specified entity may not be deleted",
   "normal": "Normal",
   "normally_x_price_per_month": "Normally __price__ per month",
@@ -1952,6 +1953,7 @@
   "redirecting": "Redirecting",
   "redo": "Redo",
   "reduce_costs_group_licenses": "You can cut down on paperwork and reduce costs with our discounted group licenses.",
+  "reduces_visual_distraction_by_keeping_the_cursor_solid": "Reduces visual distraction by keeping the cursor solid",
   "refer_to_doc_link": "If needed, you can refer to the <a href=\"__link__\">Overleaf API documentation</a>.",
   "reference_error_relink_hint": "If this error persists, try re-linking your account here:",
   "reference_manager_linked": "Reference manager linked",

+ 51 - 0
services/web/test/frontend/features/settings-modal/settings/non-blinking-cursor-setting.test.tsx

@@ -0,0 +1,51 @@
+import { screen, render } from '@testing-library/react'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import { SettingsModalProvider } from '@/features/settings/context/settings-modal-context'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
+
+describe('<NonBlinkingCursorSetting />', function () {
+  afterEach(function () {
+    fetchMock.removeRoutes().clearHistory()
+  })
+
+  it('can toggle', async function () {
+    render(
+      <EditorProviders>
+        <SettingsModalProvider>
+          <NonBlinkingCursorSetting />
+        </SettingsModalProvider>
+      </EditorProviders>
+    )
+
+    const saveSettingsMock = fetchMock.post(
+      `express:/user/settings`,
+      {
+        status: 200,
+      },
+      { delay: 0 }
+    )
+
+    const toggle = screen.getByLabelText('Non-blinking cursor')
+    const startingCheckedValue = (toggle as HTMLInputElement).checked
+
+    // Toggle the checkbox
+    toggle.click()
+    expect((toggle as HTMLInputElement).checked).to.equal(!startingCheckedValue)
+    expect(
+      saveSettingsMock.callHistory.called(`/user/settings`, {
+        body: { nonBlinkingCursor: !startingCheckedValue },
+      })
+    ).to.be.true
+
+    // Toggle back to original value
+    toggle.click()
+    expect((toggle as HTMLInputElement).checked).to.equal(startingCheckedValue)
+    expect(
+      saveSettingsMock.callHistory.called(`/user/settings`, {
+        body: { nonBlinkingCursor: startingCheckedValue },
+      })
+    ).to.be.true
+  })
+})

+ 1 - 0
services/web/types/user-settings.ts

@@ -27,6 +27,7 @@ export type UserSettings = {
   mathPreview: boolean
   referencesSearchMode: 'advanced' | 'simple'
   breadcrumbs: boolean
+  nonBlinkingCursor: boolean
   darkModePdf: boolean
   zotero: RefProviderSettings
   mendeley: RefProviderSettings