From 50db96878d5a760f6a385f232388adcf21e7fdb5 Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Fri, 10 Apr 2026 10:45:33 +0200 Subject: [PATCH] Merge pull request #32642 from overleaf/ab-non-blinking-cursor-editor-setting [web] Add non-blinking cursor editor setting GitOrigin-RevId: 078efca21c1e87905894aadc1430c1728a38685d --- .../Features/Project/UserSettingsHelper.mjs | 1 + .../app/src/Features/User/UserController.mjs | 3 ++ services/web/app/src/models/User.mjs | 1 + .../web/frontend/extracted-translations.json | 2 + .../context/project-settings-context.tsx | 9 ++++ .../hooks/use-user-wide-settings.tsx | 10 ++++ .../non-blinking-cursor-setting.tsx | 19 +++++++ .../context/settings-modal-context.tsx | 5 ++ .../extensions/draw-selection.ts | 2 + .../source-editor/extensions/index.ts | 2 + .../extensions/non-blinking-cursor.ts | 36 +++++++++++++ .../hooks/use-codemirror-scope.ts | 10 ++++ .../shared/context/user-settings-context.tsx | 1 + services/web/locales/en.json | 2 + .../non-blinking-cursor-setting.test.tsx | 51 +++++++++++++++++++ services/web/types/user-settings.ts | 1 + 16 files changed, 155 insertions(+) create mode 100644 services/web/frontend/js/features/settings/components/editor-settings/non-blinking-cursor-setting.tsx create mode 100644 services/web/frontend/js/features/source-editor/extensions/non-blinking-cursor.ts create mode 100644 services/web/test/frontend/features/settings-modal/settings/non-blinking-cursor-setting.test.tsx diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index 3b78eb9ae7..7908670acd 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/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, diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index 97dbaab941..ae2d116827 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/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 diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 134bb72b00..4e21612f18 100644 --- a/services/web/app/src/models/User.mjs +++ b/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, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b84eb68dcb..eb66ba4d28 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index cf1109c33f..d8396a9a61 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/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 = ({ setMathPreview, breadcrumbs, setBreadcrumbs, + nonBlinkingCursor, + setNonBlinkingCursor, darkModePdf, setDarkModePdf, zotero, @@ -144,6 +149,8 @@ export const ProjectSettingsProvider: FC = ({ setMathPreview, breadcrumbs, setBreadcrumbs, + nonBlinkingCursor, + setNonBlinkingCursor, darkModePdf, setDarkModePdf, zotero, @@ -192,6 +199,8 @@ export const ProjectSettingsProvider: FC = ({ setMathPreview, breadcrumbs, setBreadcrumbs, + nonBlinkingCursor, + setNonBlinkingCursor, darkModePdf, setDarkModePdf, zotero, diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index 5b49066b3b..ce6e162ba8 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/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, diff --git a/services/web/frontend/js/features/settings/components/editor-settings/non-blinking-cursor-setting.tsx b/services/web/frontend/js/features/settings/components/editor-settings/non-blinking-cursor-setting.tsx new file mode 100644 index 0000000000..90fca36eac --- /dev/null +++ b/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 ( + + ) +} diff --git a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx index d9539e3e7f..13b9f76d0d 100644 --- a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx +++ b/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 = ({ key: 'autoPairDelimiters', component: , }, + { + key: 'nonBlinkingCursor', + component: , + }, { key: 'syntaxValidation', component: , diff --git a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts index 413317ec0a..856290d3e7 100644 --- a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts +++ b/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)] diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 472a460d1e..8858b5812b 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/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): 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. diff --git a/services/web/frontend/js/features/source-editor/extensions/non-blinking-cursor.ts b/services/web/frontend/js/features/source-editor/extensions/non-blinking-cursor.ts new file mode 100644 index 0000000000..a602ef2898 --- /dev/null +++ b/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() + +const nonBlinkingCursorTheme = EditorView.baseTheme({ + '&.cm-non-blinking-cursor .cm-cursorLayer': { + animationName: 'none !important', + }, +}) + +const nonBlinkingCursorField = StateField.define({ + 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 diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 33f5f2fc71..e9fa66935e 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/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]) diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index 3431134eef..4ef15d47c9 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/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, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 36ec97bd4a..221a163490 100644 --- a/services/web/locales/en.json +++ b/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 Overleaf API documentation.", "reference_error_relink_hint": "If this error persists, try re-linking your account here:", "reference_manager_linked": "Reference manager linked", diff --git a/services/web/test/frontend/features/settings-modal/settings/non-blinking-cursor-setting.test.tsx b/services/web/test/frontend/features/settings-modal/settings/non-blinking-cursor-setting.test.tsx new file mode 100644 index 0000000000..e71d7ab437 --- /dev/null +++ b/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('', function () { + afterEach(function () { + fetchMock.removeRoutes().clearHistory() + }) + + it('can toggle', async function () { + render( + + + + + + ) + + 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 + }) +}) diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 3a53d69e3d..5051fc783c 100644 --- a/services/web/types/user-settings.ts +++ b/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