From 3b32c8141079aef951c57148bbba8a29da39c56f Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:07:50 +0100 Subject: [PATCH] Merge pull request #26583 from overleaf/td-editor-scope-values-to-context Move scope values starting with `editor.` to contexts GitOrigin-RevId: 7ca349ceff002228cf4e931c644c8c386eb6c597 --- .../components/editor/editor-pane.tsx | 7 +- .../context/editor-manager-context.tsx | 61 ++-------- .../context/editor-properties-context.tsx | 114 ++++++++++++++++++ .../ide-react/context/ide-react-context.tsx | 6 +- .../ide-react/context/react-context-root.tsx | 90 +++++++------- .../editor-manager-context-adapter.ts | 54 --------- .../ide-redesign/components/editor.tsx | 12 +- .../use-toolbar-menu-editor-commands.tsx | 4 +- .../components/review-tooltip-menu.tsx | 4 +- .../context/track-changes-state-context.tsx | 4 +- .../hooks/use-overview-file-collapsed.ts | 33 ++++- .../components/leavers-survey-alert.tsx | 2 +- .../settings/context/user-email-context.tsx | 4 +- .../components/editor-switch.tsx | 5 +- .../components/toolbar/toolbar-items.tsx | 6 +- .../extensions/before-change-doc.ts | 3 + .../source-editor/extensions/index.ts | 2 +- .../source-editor/extensions/search.ts | 34 ++++-- .../hooks/use-codemirror-scope.ts | 29 ++++- .../js/shared/context/editor-context.tsx | 8 -- .../shared/context/local-compile-context.tsx | 10 +- .../js/shared/hooks/use-exposed-state.ts | 12 +- .../js/shared/hooks/use-persisted-state.ts | 68 +++++------ .../shared/hooks/use-unstable-store-sync.ts | 11 ++ .../web/frontend/stories/decorators/scope.tsx | 2 +- .../source-editor/source-editor.stories.tsx | 36 +++++- .../codemirror-editor-figure-modal.spec.tsx | 17 ++- ...codemirror-editor-table-generator.spec.tsx | 16 ++- ...ror-editor-visual-command-tooltip.spec.tsx | 16 ++- .../codemirror-editor-visual-floats.spec.tsx | 16 ++- .../codemirror-editor-visual-list.spec.tsx | 16 ++- ...demirror-editor-visual-paste-html.spec.tsx | 16 ++- ...codemirror-editor-visual-readonly.spec.tsx | 15 ++- .../codemirror-editor-visual-toolbar.spec.tsx | 16 ++- ...codemirror-editor-visual-tooltips.spec.tsx | 16 ++- .../codemirror-editor-visual.spec.tsx | 17 ++- .../components/codemirror-editor.spec.tsx | 2 +- .../source-editor/helpers/mock-scope.ts | 1 - .../frontend/helpers/editor-providers.tsx | 67 +++++++++- .../shared/hooks/use-persisted-state.test.tsx | 33 ++++- 40 files changed, 593 insertions(+), 292 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/context/editor-properties-context.tsx delete mode 100644 services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts create mode 100644 services/web/frontend/js/features/source-editor/extensions/before-change-doc.ts create mode 100644 services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts diff --git a/services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx b/services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx index 72dd27262d..b93a18db9f 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx @@ -1,22 +1,21 @@ import { Panel, PanelGroup } from 'react-resizable-panels' import React, { FC, lazy, Suspense } from 'react' -import useScopeValue from '@/shared/hooks/use-scope-value' import SourceEditor from '@/features/source-editor/components/source-editor' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' -import { EditorScopeValue } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter' import classNames from 'classnames' import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' const SymbolPalettePane = lazy( () => import('@/features/ide-react/components/editor/symbol-palette-pane') ) export const EditorPane: FC = () => { - const [editor] = useScopeValue('editor') + const { showSymbolPalette } = useEditorPropertiesContext() const { selectedEntityCount, openEntity } = useFileTreeOpenContext() const { isLoading } = useEditorManagerContext() const { currentDocumentId } = useEditorOpenDocContext() @@ -41,7 +40,7 @@ export const EditorPane: FC = () => { {isLoading && } - {editor.showSymbolPalette && ( + {showSymbolPalette && ( <> EditorType | null - showSymbolPalette: boolean getCurrentDocValue: () => string | null getCurrentDocumentId: () => DocId | null setIgnoringExternalUpdates: (value: boolean) => void @@ -62,12 +60,7 @@ export type EditorManager = { openFileWithId: (fileId: string) => void openInitialDoc: (docId: string) => void isLoading: boolean - trackChanges: boolean jumpToLine: (options: GotoLineOptions) => void - wantTrackChanges: boolean - setWantTrackChanges: React.Dispatch< - React.SetStateAction - > debugTimers: React.MutableRefObject> } @@ -87,7 +80,6 @@ export const EditorManagerProvider: FC = ({ children, }) => { const { t } = useTranslation() - const { scopeStore } = useIdeContext() const { reportError, eventEmitter, projectId, setOutOfSync } = useIdeReactContext() const { socket, closeConnection, connectionState } = useConnectionContext() @@ -95,11 +87,15 @@ export const EditorManagerProvider: FC = ({ const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } = useModalsContext() const { id: userId } = useUserContext() - - const [showSymbolPalette, setShowSymbolPalette] = useScopeValue( - 'editor.showSymbolPalette' - ) - const [showVisual] = useScopeValue('editor.showVisual') + const { + showVisual, + opening, + setOpening, + errorState, + setErrorState, + setTrackChanges, + wantTrackChanges, + } = useEditorPropertiesContext() const { currentDocumentId, setCurrentDocumentId, @@ -107,15 +103,6 @@ export const EditorManagerProvider: FC = ({ currentDocument, setCurrentDocument, } = useEditorOpenDocContext() - const [opening, setOpening] = useScopeValue('editor.opening') - const [errorState, setIsInErrorState] = - useScopeValue('editor.error_state') - const [trackChanges, setTrackChanges] = useScopeValue( - 'editor.trackChanges' - ) - const [wantTrackChanges, setWantTrackChanges] = useScopeValue( - 'editor.wantTrackChanges' - ) const wantTrackChangesRef = useRef(wantTrackChanges) useEffect(() => { @@ -191,22 +178,6 @@ export const EditorManagerProvider: FC = ({ const editorOpenDocEpochRef = useRef(0) - // TODO: This looks dodgy because it wraps a state setter and is itself - // stored in React state in the scope store. The problem is that it needs to - // be exposed via the scope store because some components access it that way; - // it would be better to simply access it from a context, but the current - // implementation in EditorManager interacts with Angular scope to update - // the layout. Once Angular is gone, this can become a context method. - useEffect(() => { - scopeStore.set('editor.toggleSymbolPalette', () => { - setShowSymbolPalette(show => { - const newValue = !show - sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide') - return newValue - }) - }) - }, [scopeStore, setShowSymbolPalette]) - const getEditorType = useCallback((): EditorType | null => { if (!currentDocument) { return null @@ -587,7 +558,7 @@ export const EditorManagerProvider: FC = ({ reportError(error, meta) // Tell the user about the error state. - setIsInErrorState(true) + setErrorState(true) // Ensure that the editor is locked setOutOfSync(true) // Display the "out of sync" modal @@ -614,7 +585,7 @@ export const EditorManagerProvider: FC = ({ eventEmitter, openDoc, reportError, - setIsInErrorState, + setErrorState, showGenericMessageModal, showOutOfSyncModal, setOutOfSync, @@ -661,25 +632,20 @@ export const EditorManagerProvider: FC = ({ const value: EditorManager = useMemo( () => ({ getEditorType, - showSymbolPalette, getCurrentDocValue, getCurrentDocumentId, setIgnoringExternalUpdates, openDocWithId, openDoc, openDocs, - trackChanges, isLoading, openFileWithId, openInitialDoc, jumpToLine, - wantTrackChanges, - setWantTrackChanges, debugTimers, }), [ getEditorType, - showSymbolPalette, getCurrentDocValue, getCurrentDocumentId, setIgnoringExternalUpdates, @@ -688,11 +654,8 @@ export const EditorManagerProvider: FC = ({ openDocs, openFileWithId, openInitialDoc, - trackChanges, isLoading, jumpToLine, - wantTrackChanges, - setWantTrackChanges, debugTimers, ] ) diff --git a/services/web/frontend/js/features/ide-react/context/editor-properties-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-properties-context.tsx new file mode 100644 index 0000000000..e8fb82efa1 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/editor-properties-context.tsx @@ -0,0 +1,114 @@ +import { + createContext, + Dispatch, + FC, + PropsWithChildren, + SetStateAction, + useCallback, + useContext, + useState, +} from 'react' +import customLocalStorage from '@/infrastructure/local-storage' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import getMeta from '@/utils/meta' +import { useUnstableStoreSync } from '@/shared/hooks/use-unstable-store-sync' +import { sendMB } from '@/infrastructure/event-tracking' + +// Context value type +export type EditorPropertiesContextValue = { + showVisual: boolean + setShowVisual: Dispatch> + showSymbolPalette: boolean + setShowSymbolPalette: Dispatch> + toggleSymbolPalette: () => void + opening: boolean + setOpening: Dispatch> + trackChanges: boolean + setTrackChanges: Dispatch> + wantTrackChanges: boolean + setWantTrackChanges: Dispatch> + errorState: boolean + setErrorState: Dispatch> +} + +export const EditorPropertiesContext = createContext< + EditorPropertiesContextValue | undefined +>(undefined) + +function showVisualFallbackValue() { + const projectId = getMeta('ol-project_id') + const editorModeKey = `editor.mode.${projectId}` + const editorModeVal = customLocalStorage.getItem(editorModeKey) + + if (editorModeVal) { + // clean up the old key + customLocalStorage.removeItem(editorModeKey) + } + + return editorModeVal === 'rich-text' +} + +export const EditorPropertiesProvider: FC = ({ + children, +}) => { + const [showVisual, setShowVisual] = usePersistedState( + `editor.lastUsedMode`, + showVisualFallbackValue(), + { + converter: { + toPersisted: showVisual => (showVisual ? 'visual' : 'code'), + fromPersisted: mode => mode === 'visual', + }, + } + ) + + // Sync the showVisual state with the exposed store + useUnstableStoreSync('editor.showVisual', showVisual) + + const [showSymbolPalette, setShowSymbolPalette] = useState(false) + + const toggleSymbolPalette = useCallback(() => { + setShowSymbolPalette(show => { + const newValue = !show + sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide') + return newValue + }) + }, [setShowSymbolPalette]) + + const [opening, setOpening] = useState(true) + const [trackChanges, setTrackChanges] = useState(false) + const [wantTrackChanges, setWantTrackChanges] = useState(false) + const [errorState, setErrorState] = useState(false) + + const value = { + showVisual, + setShowVisual, + showSymbolPalette, + setShowSymbolPalette, + toggleSymbolPalette, + opening, + setOpening, + trackChanges, + setTrackChanges, + wantTrackChanges, + setWantTrackChanges, + errorState, + setErrorState, + } + + return ( + + {children} + + ) +} + +export const useEditorPropertiesContext = (): EditorPropertiesContextValue => { + const context = useContext(EditorPropertiesContext) + if (!context) { + throw new Error( + 'useEditorPropertiesContext is only available inside EditorPropertiesContext.Provider' + ) + } + return context +} diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index 27095adf00..147f683174 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx @@ -15,7 +15,6 @@ import { } from '@/features/ide-react/create-ide-event-emitter' import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload' import { useConnectionContext } from '@/features/ide-react/context/connection-context' -import { populateEditorScope } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter' import { postJSON } from '@/infrastructure/fetch-json' import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' import getMeta from '@/utils/meta' @@ -53,7 +52,7 @@ function populatePdfScope(store: ReactScopeValueStore) { store.allowNonExistentPath('pdf', true) } -export function createReactScopeValueStore(projectId: string) { +export function createReactScopeValueStore() { const scopeStore = new ReactScopeValueStore() // Populate the scope value store with default values that will be used by @@ -62,7 +61,6 @@ export function createReactScopeValueStore(projectId: string) { // initialization code together with the context and would only populate // necessary values in the store, but this is simpler for now populateIdeReactScope(scopeStore) - populateEditorScope(scopeStore, projectId) populateProjectScope(scopeStore) populatePdfScope(scopeStore) @@ -73,7 +71,7 @@ export function createReactScopeValueStore(projectId: string) { export const IdeReactProvider: FC = ({ children }) => { const projectId = getMeta('ol-project_id') - const [scopeStore] = useState(() => createReactScopeValueStore(projectId)) + const [scopeStore] = useState(() => createReactScopeValueStore()) const [eventEmitter] = useState(createIdeEventEmitter) const [permissionsLevel, setPermissionsLevel] = useState('readOnly') diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 99442eba5e..f85e2c40fa 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -5,6 +5,7 @@ import { DetachCompileProvider } from '@/shared/context/detach-compile-context' import { DetachProvider } from '@/shared/context/detach-context' import { EditorManagerProvider } from '@/features/ide-react/context/editor-manager-context' import { EditorOpenDocProvider } from '@/features/ide-react/context/editor-open-doc-context' +import { EditorPropertiesProvider } from '@/features/ide-react/context/editor-properties-context' import { EditorProvider } from '@/shared/context/editor-context' import { EditorViewProvider } from '@/features/ide-react/context/editor-view-context' import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context' @@ -42,6 +43,7 @@ export const ReactContextRoot: FC< DetachProvider, EditorManagerProvider, EditorOpenDocProvider, + EditorPropertiesProvider, EditorProvider, EditorViewProvider, FileTreeDataProvider, @@ -79,49 +81,51 @@ export const ReactContextRoot: FC< - - - - - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts b/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts deleted file mode 100644 index e4f0e5aa01..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' -import customLocalStorage from '@/infrastructure/local-storage' -import { DocumentContainer } from '@/features/ide-react/editor/document-container' - -export type EditorScopeValue = { - showSymbolPalette: false - toggleSymbolPalette: () => void - sharejs_doc: DocumentContainer | null - opening: boolean - trackChanges: boolean - wantTrackChanges: boolean - showVisual: boolean - error_state: boolean -} - -export function populateEditorScope( - store: ReactScopeValueStore, - projectId: string -) { - store.set('project.name', null) - - const editor: Omit = { - showSymbolPalette: false, - toggleSymbolPalette: () => {}, - sharejs_doc: null, - opening: true, - trackChanges: false, - wantTrackChanges: false, - error_state: false, - } - store.set('editor', editor) - - store.persisted( - 'editor.showVisual', - showVisualFallbackValue(projectId), - `editor.lastUsedMode`, - { - toPersisted: showVisual => (showVisual ? 'visual' : 'code'), - fromPersisted: mode => mode === 'visual', - } - ) -} - -function showVisualFallbackValue(projectId: string) { - const editorModeKey = `editor.mode.${projectId}` - const editorModeVal = customLocalStorage.getItem(editorModeKey) - - if (editorModeVal) { - // clean up the old key - customLocalStorage.removeItem(editorModeKey) - } - - return editorModeVal === 'rich-text' -} diff --git a/services/web/frontend/js/features/ide-redesign/components/editor.tsx b/services/web/frontend/js/features/ide-redesign/components/editor.tsx index ae861f9b6f..785ba1fb99 100644 --- a/services/web/frontend/js/features/ide-redesign/components/editor.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/editor.tsx @@ -1,8 +1,6 @@ import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' -import { EditorScopeValue } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter' import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' -import useScopeValue from '@/shared/hooks/use-scope-value' import classNames from 'classnames' import SourceEditor from '@/features/source-editor/components/source-editor' import { Panel, PanelGroup } from 'react-resizable-panels' @@ -10,9 +8,11 @@ import { VerticalResizeHandle } from '@/features/ide-react/components/resize/ver import { Suspense } from 'react' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' export const Editor = () => { - const [editor] = useScopeValue('editor') + const { opening, errorState, showSymbolPalette } = + useEditorPropertiesContext() const { selectedEntityCount, openEntity } = useFileTreeOpenContext() const { currentDocumentId, currentDocument } = useEditorOpenDocContext() @@ -21,9 +21,7 @@ export const Editor = () => { } const isLoading = Boolean( - (!currentDocument || editor.opening) && - !editor.error_state && - currentDocumentId + (!currentDocument || opening) && !errorState && currentDocumentId ) return ( @@ -44,7 +42,7 @@ export const Editor = () => { {isLoading && } - {editor.showSymbolPalette && ( + {showSymbolPalette && ( <> { isTeXFile, ]) - const { toggleSymbolPalette } = useEditorContext() + const { toggleSymbolPalette } = useEditorPropertiesContext() const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable') useCommandProvider(() => { if (!newEditor || !editorIsVisible) { diff --git a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx index 85fe5830f6..f26542ebe9 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx @@ -31,7 +31,7 @@ import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-curs import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import { useModalsContext } from '@/features/ide-react/context/modals-context' import { numberOfChangesInSelection } from '../utils/changes-in-selection' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import classNames from 'classnames' import useEventListener from '@/shared/hooks/use-event-listener' import useReviewPanelLayout from '../hooks/use-review-panel-layout' @@ -104,7 +104,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ const ranges = useRangesContext() const { acceptChanges, rejectChanges } = useRangesActionsContext() const { showGenericConfirmModal } = useModalsContext() - const { wantTrackChanges } = useEditorManagerContext() + const { wantTrackChanges } = useEditorPropertiesContext() const [tooltipStyle, setTooltipStyle] = useState() const [visible, setVisible] = useState(false) diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx index 73ffe78b5d..453fec51b7 100644 --- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx @@ -11,7 +11,7 @@ import { import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { useProjectContext } from '@/shared/context/project-context' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import { useUserContext } from '@/shared/context/user-context' import { postJSON } from '@/infrastructure/fetch-json' import useEventListener from '@/shared/hooks/use-event-listener' @@ -50,7 +50,7 @@ export const TrackChangesStateProvider: FC = ({ const { socket } = useConnectionContext() const project = useProjectContext() const user = useUserContext() - const { setWantTrackChanges } = useEditorManagerContext() + const { setWantTrackChanges } = useEditorPropertiesContext() // TODO: update project.trackChangesState instead? const [trackChangesValue, setTrackChangesValue] = useState< diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts index fcc3f9a053..47bce33e9f 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts @@ -2,12 +2,41 @@ import { useCallback } from 'react' import { DocId } from '../../../../../types/project-settings' import { useProjectContext } from '../../../shared/context/project-context' import usePersistedState from '../../../shared/hooks/use-persisted-state' +import { debugConsole } from '@/utils/debugging' + +const safeStringify = (value: unknown) => { + try { + return JSON.stringify(value) + } catch (e) { + debugConsole.error('double stringify exception', e) + return '' + } +} + +const safeParse = (value: string) => { + try { + return JSON.parse(value) + } catch (e) { + debugConsole.error('double parse exception', e) + return null + } +} export default function useOverviewFileCollapsed(docId: DocId) { const { _id: projectId } = useProjectContext() const [collapsedDocs, setCollapsedDocs] = usePersistedState< - Record - >(`docs_collapsed_state:${projectId}`, {}, false, true) + Record, + string + >( + `docs_collapsed_state:${projectId}`, + {}, + { + converter: { + fromPersisted: safeParse, + toPersisted: safeStringify, + }, + } + ) const toggleCollapsed = useCallback(() => { setCollapsedDocs((collapsedDocs: Record) => { diff --git a/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx b/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx index 55ada55ff0..a7668a2fca 100644 --- a/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx +++ b/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx @@ -20,7 +20,7 @@ export function LeaversSurveyAlert() { const [hide, setHide] = usePersistedState( 'hideInstitutionalLeaversSurvey', false, - true + { listen: true } ) function handleDismiss() { diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx index b43c1956e8..579c46143d 100644 --- a/services/web/frontend/js/features/settings/context/user-email-context.tsx +++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx @@ -224,7 +224,9 @@ function useUserEmails() { const [ showInstitutionalLeaversSurveyUntil, setShowInstitutionalLeaversSurveyUntil, - ] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true) + ] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, { + listen: true, + }) const [state, unsafeDispatch] = useReducer(reducer, initialState) const dispatch = useSafeDispatch(unsafeDispatch) const { data, isLoading, isError, isSuccess, runAsync } = diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx index 7e8c950db0..6b80f4f6f7 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx @@ -1,14 +1,15 @@ import { ChangeEvent, FC, memo, useCallback } from 'react' -import useScopeValue from '@/shared/hooks/use-scope-value' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import { sendMB } from '../../../infrastructure/event-tracking' import { isValidTeXFile } from '../../../main/is-valid-tex-file' import { useTranslation } from 'react-i18next' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' function EditorSwitch() { const { t } = useTranslation() - const [visual, setVisual] = useScopeValue('editor.showVisual') + const { showVisual: visual, setShowVisual: setVisual } = + useEditorPropertiesContext() const { openDocName } = useEditorOpenDocContext() const richTextAvailable = openDocName ? isValidTeXFile(openDocName) : false diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx index c5d9f3d3e4..3404976d44 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx @@ -15,6 +15,7 @@ import { withinFormattingCommand } from '@/features/source-editor/utils/tree-ope import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { isMac } from '@/shared/utils/os' import { useProjectContext } from '@/shared/context/project-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' export const ToolbarItems: FC<{ state: EditorState @@ -30,8 +31,9 @@ export const ToolbarItems: FC<{ listDepth, }) { const { t } = useTranslation() - const { toggleSymbolPalette, showSymbolPalette, writefullInstance } = - useEditorContext() + const { showSymbolPalette, toggleSymbolPalette } = + useEditorPropertiesContext() + const { writefullInstance } = useEditorContext() const { features } = useProjectContext() const isActive = withinFormattingCommand(state) diff --git a/services/web/frontend/js/features/source-editor/extensions/before-change-doc.ts b/services/web/frontend/js/features/source-editor/extensions/before-change-doc.ts new file mode 100644 index 0000000000..324e27600f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/before-change-doc.ts @@ -0,0 +1,3 @@ +import { StateEffect } from '@codemirror/state' + +export const beforeChangeDocEffect = StateEffect.define() 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 9cf6906b51..e3c0a28ccb 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -139,7 +139,7 @@ export const createExtensions = (options: Record): Extension[] => [ cursorHighlights(), autoPair(options.settings), editable(), - search(), + search(options.initialSearchQuery), phrases(options.phrases), spelling(options.spelling), shortcuts, diff --git a/services/web/frontend/js/features/source-editor/extensions/search.ts b/services/web/frontend/js/features/source-editor/extensions/search.ts index 48e58a4bca..17fc4b027d 100644 --- a/services/web/frontend/js/features/source-editor/extensions/search.ts +++ b/services/web/frontend/js/features/source-editor/extensions/search.ts @@ -15,6 +15,7 @@ import { KeyBinding, keymap, ViewPlugin, + ViewUpdate, } from '@codemirror/view' import { Annotation, @@ -29,6 +30,7 @@ import { } from '@codemirror/state' import { sendSearchEvent } from '@/features/event-tracking/search-events' import { isVisual } from '@/features/source-editor/extensions/visual/visual' +import { beforeChangeDocEffect } from '@/features/source-editor/extensions/before-change-doc' const restoreSearchQueryAnnotation = Annotation.define() @@ -110,10 +112,6 @@ const highlightSelectionMatchesExtension = highlightSelectionMatches({ wholeWords: true, }) -// store the search query for use when switching between files -// TODO: move this into EditorContext? -let searchQuery: SearchQuery | null - const scrollToMatch = (range: SelectionRange, view: EditorView) => { const coords = { from: view.coordsAtPos(range.from), @@ -153,7 +151,7 @@ const searchEventKeymap: KeyBinding[] = [ /** * A collection of extensions related to the search feature. */ -export const search = () => { +export const search = (initialSearchQuery: SearchQuery | null) => { let open = false return [ @@ -195,9 +193,7 @@ export const search = () => { } }, destroy() { - window.setTimeout(() => { - open = false // in a timeout, so the view plugin below can run its destroy method first - }, 0) + open = false }, } }, @@ -205,8 +201,8 @@ export const search = () => { // restore a stored search and re-open the search panel ViewPlugin.define(view => { - if (searchQuery) { - const _searchQuery = searchQuery + if (initialSearchQuery) { + const _searchQuery = initialSearchQuery window.setTimeout(() => { openSearchPanel(view) view.dispatch({ @@ -217,9 +213,21 @@ export const search = () => { } return { - destroy() { - // persist the current search query if the panel is open - searchQuery = open ? getSearchQuery(view.state) : null + // Fire an event containing the search query before a document change + // so that it can be persisted for the next document + update(update: ViewUpdate) { + for (const tr of update.transactions) { + for (const effect of tr.effects) { + if (effect.is(beforeChangeDocEffect)) { + const searchQuery = open ? getSearchQuery(view.state) : null + window.dispatchEvent( + new CustomEvent('search-panel-before-doc-change', { + detail: searchQuery, + }) + ) + } + } + } }, } }), 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 c0d27c354a..f894758de9 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 @@ -51,14 +51,14 @@ import { updateRanges } from '@/features/source-editor/extensions/ranges' import { useThreadsContext } from '@/features/review-panel-new/context/threads-context' import { useHunspell } from '@/features/source-editor/hooks/use-hunspell' import { Permissions } from '@/features/ide-react/types/permissions' -import { - GotoOffsetOptions, - useEditorManagerContext, -} from '@/features/ide-react/context/editor-manager-context' +import { GotoOffsetOptions } from '@/features/ide-react/context/editor-manager-context' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' +import { SearchQuery } from '@codemirror/search' +import { beforeChangeDocEffect } from '@/features/source-editor/extensions/before-change-doc' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() @@ -71,7 +71,6 @@ function useCodeMirrorScope(view: EditorView) { useCompileContext() const { openDocName, currentDocument } = useEditorOpenDocContext() - const { trackChanges } = useEditorManagerContext() const metadata = useMetadataContext() const { id: userId } = useUserContext() @@ -106,7 +105,7 @@ function useCodeMirrorScope(view: EditorView) { const hunspellManager = useHunspell(spellCheckLanguage) - const [visual] = useScopeValue('editor.showVisual') + const { showVisual: visual, trackChanges } = useEditorPropertiesContext() const { referenceKeys } = useReferencesContext() @@ -271,6 +270,16 @@ function useCodeMirrorScope(view: EditorView) { visual: showVisual, }) + // Persist the search query in this hook when the document changes by keeping + // a reference to the search query in sync with the editor state + const searchQueryRef = useRef(null) + useEventListener( + 'search-panel-before-doc-change', + useCallback((event: CustomEvent) => { + searchQueryRef.current = event.detail + }, []) + ) + const { showBoundary } = useErrorBoundary() const handleException = useCallback((exception: any) => { @@ -295,6 +304,13 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { debugConsole.log('creating new editor state') + // Warn any interested extension that the document is about to change, + // allowing it to perform any necessary actions before creating the new + // state. destroy() is too late because the new state is already created + view.dispatch({ + effects: beforeChangeDocEffect.of(null), + }) + const state = EditorState.create({ doc: currentDocument.getSnapshot(), extensions: createExtensions({ @@ -310,6 +326,7 @@ function useCodeMirrorScope(view: EditorView) { spelling: spellingRef.current, visual: visualRef.current, projectFeatures: projectFeaturesRef.current, + initialSearchQuery: searchQueryRef.current, showBoundary, handleException, }), diff --git a/services/web/frontend/js/shared/context/editor-context.tsx b/services/web/frontend/js/shared/context/editor-context.tsx index 5685951ad2..945c6b63af 100644 --- a/services/web/frontend/js/shared/context/editor-context.tsx +++ b/services/web/frontend/js/shared/context/editor-context.tsx @@ -27,8 +27,6 @@ export const EditorContext = createContext< cobranding?: Cobranding hasPremiumCompile?: boolean renameProject: (newName: string) => void - showSymbolPalette?: boolean - toggleSymbolPalette?: () => void insertSymbol?: (symbol: SymbolWithCharacter) => void isProjectOwner: boolean isRestrictedTokenMember?: boolean @@ -74,8 +72,6 @@ export const EditorProvider: FC = ({ children }) => { }, []) const [projectName, setProjectName] = useScopeValue('project.name') - const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette') - const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette') const [inactiveTutorials, setInactiveTutorials] = useState( () => getMeta('ol-inactiveTutorials') || [] @@ -183,8 +179,6 @@ export const EditorProvider: FC = ({ children }) => { isProjectOwner: owner?._id === userId, isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'), isPendingEditor, - showSymbolPalette, - toggleSymbolPalette, insertSymbol, inactiveTutorials, deactivateTutorial, @@ -204,8 +198,6 @@ export const EditorProvider: FC = ({ children }) => { userId, renameProject, isPendingEditor, - showSymbolPalette, - toggleSymbolPalette, insertSymbol, inactiveTutorials, deactivateTutorial, diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index ccc2c1453c..827688c2b2 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -248,17 +248,19 @@ export const LocalCompileProvider: FC = ({ const [autoCompile, setAutoCompile] = usePersistedState( `autocompile_enabled:${projectId}`, false, - true + { listen: true } ) // whether the compile should run in draft mode - const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true) + const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, { + listen: true, + }) // whether compiling should stop on first error const [stopOnFirstError, setStopOnFirstError] = usePersistedState( `stop_on_first_error:${projectId}`, false, - true + { listen: true } ) // whether the last compiles stopped on first error @@ -268,7 +270,7 @@ export const LocalCompileProvider: FC = ({ const [stopOnValidationError, setStopOnValidationError] = usePersistedState( `stop_on_validation_error:${projectId}`, true, - true + { listen: true } ) // whether the editor linter found errors diff --git a/services/web/frontend/js/shared/hooks/use-exposed-state.ts b/services/web/frontend/js/shared/hooks/use-exposed-state.ts index 520ed4356c..781d23698a 100644 --- a/services/web/frontend/js/shared/hooks/use-exposed-state.ts +++ b/services/web/frontend/js/shared/hooks/use-exposed-state.ts @@ -1,5 +1,5 @@ -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react' -import { useIdeContext } from '../context/ide-context' +import { type Dispatch, type SetStateAction, useState } from 'react' +import { useUnstableStoreSync } from '@/shared/hooks/use-unstable-store-sync' /** * Creates a state variable that is exposed via window.overleaf.unstable.store, @@ -13,13 +13,7 @@ export default function useExposedState( path: string ): [T, Dispatch>] { const [value, setValue] = useState(initialState) - - const { unstableStore } = useIdeContext() - - // Update the unstable store whenever the value changes - useEffect(() => { - unstableStore.set(path, value) - }, [unstableStore, path, value]) + useUnstableStoreSync(path, value) return [value, setValue] } diff --git a/services/web/frontend/js/shared/hooks/use-persisted-state.ts b/services/web/frontend/js/shared/hooks/use-persisted-state.ts index 1ff452ce4a..cfd2546ada 100644 --- a/services/web/frontend/js/shared/hooks/use-persisted-state.ts +++ b/services/web/frontend/js/shared/hooks/use-persisted-state.ts @@ -7,61 +7,59 @@ import { } from 'react' import _ from 'lodash' import localStorage from '../../infrastructure/local-storage' -import { debugConsole } from '@/utils/debugging' -const safeStringify = (value: unknown) => { - try { - return JSON.stringify(value) - } catch (e) { - debugConsole.error('double stringify exception', e) - return null +type UsePersistedStateOptions = { + listen?: boolean + converter?: { + toPersisted: (value: Value) => PersistedValue + fromPersisted: (persisted: PersistedValue) => Value } } -const safeParse = (value: string) => { - try { - return JSON.parse(value) - } catch (e) { - debugConsole.error('double parse exception', e) - return null - } -} - -function usePersistedState( +function usePersistedState( key: string, - defaultValue?: T, - listen = false, - // The option below is for backward compatibility with Angular - // which sometimes stringifies the values twice - doubleStringifyAndParse = false -): [T, Dispatch>] { + defaultValue?: Value, + options?: UsePersistedStateOptions +): [Value, Dispatch>] { + // Store the default value and options on first render so that they're stable + // and use them on subsequent renders. This is important for, for example, a + // non-primitive default value that should not change on every render. + const [allOptions] = useState<{ + defaultValue?: Value + options?: UsePersistedStateOptions + }>(() => ({ defaultValue, options })) + const listen = allOptions.options?.listen || false + const { toPersisted, fromPersisted } = allOptions.options?.converter || {} + const storedDefaultValue = allOptions.defaultValue + const getItem = useCallback( (key: string) => { const item = localStorage.getItem(key) - return doubleStringifyAndParse ? safeParse(item) : item + return fromPersisted ? fromPersisted(item) : item }, - [doubleStringifyAndParse] + [fromPersisted] ) const setItem = useCallback( - (key: string, value: unknown) => { - const val = doubleStringifyAndParse ? safeStringify(value) : value + (key: string, value: Value) => { + // Nested ternary is convenient for type inference + const val = toPersisted ? toPersisted(value) : value localStorage.setItem(key, val) }, - [doubleStringifyAndParse] + [toPersisted] ) - const [value, setValue] = useState(() => { - return getItem(key) ?? defaultValue + const [value, setValue] = useState(() => { + return getItem(key) ?? storedDefaultValue }) const updateFunction = useCallback( - (newValue: SetStateAction) => { + (newValue: SetStateAction) => { setValue(value => { const actualNewValue = _.isFunction(newValue) ? newValue(value) : newValue - if (actualNewValue === defaultValue) { + if (actualNewValue === storedDefaultValue) { localStorage.removeItem(key) } else { setItem(key, actualNewValue) @@ -70,7 +68,7 @@ function usePersistedState( return actualNewValue }) }, - [key, defaultValue, setItem] + [key, storedDefaultValue, setItem] ) useEffect(() => { @@ -79,7 +77,7 @@ function usePersistedState( if (event.key === key) { // note: this value is read via getItem rather than from event.newValue // because getItem handles deserializing the JSON that's stored in localStorage. - setValue(getItem(key) ?? defaultValue) + setValue(getItem(key) ?? storedDefaultValue) } } @@ -89,7 +87,7 @@ function usePersistedState( window.removeEventListener('storage', listener) } } - }, [defaultValue, key, listen, getItem]) + }, [storedDefaultValue, key, listen, getItem]) return [value, updateFunction] } diff --git a/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts b/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts new file mode 100644 index 0000000000..6285159c02 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts @@ -0,0 +1,11 @@ +import { useIdeContext } from '@/shared/context/ide-context' +import { useEffect } from 'react' + +export function useUnstableStoreSync(path: string, value: T) { + const { unstableStore } = useIdeContext() + + // Update the unstable store whenever the value changes + useEffect(() => { + unstableStore.set(path, value) + }, [unstableStore, path, value]) +} diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index cc89a31685..8977f582ab 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -195,7 +195,7 @@ const IdeReactProvider: FC = ({ children }) => { })) const [ideContextValue] = useState(() => { - const scopeStore = createReactScopeValueStore(projectId) + const scopeStore = createReactScopeValueStore() for (const [key, value] of Object.entries(initialScope)) { scopeStore.set(key, value) } diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index 7194adcef7..3f475944e4 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -10,6 +10,7 @@ import { EditorOpenDocContext } from '@/features/ide-react/context/editor-open-d import { DocId } from '../../../types/project-settings' import { StoryObj } from '@storybook/react' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { EditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' type Story = StoryObj @@ -83,6 +84,34 @@ const BibtexEditorOpenDocProvider: FC = ({ ) +const VisualEditorPropertiesProvider: FC = ({ + children, +}) => { + const [showVisual, setShowVisual] = useState(true) + + const value = { + showVisual, + setShowVisual, + showSymbolPalette: true, + setShowSymbolPalette: () => undefined, + toggleSymbolPalette: () => undefined, + opening: true, + setOpening: () => undefined, + trackChanges: false, + setTrackChanges: () => undefined, + wantTrackChanges: false, + setWantTrackChanges: () => undefined, + errorState: false, + setErrorState: () => undefined, + } + + return ( + + {children} + + ) +} + const FileTreePathProvider: FC = ({ children }) => ( { - // FIXME: useScope has no effect, so this does not default to the visual editor + // FIXME: useScope has no effect, so this does nothing useScope({ - editor: { - showVisual: true, - }, settings: { ...settings, overallTheme: globals.theme === 'default-' ? '' : globals.theme, @@ -235,7 +262,6 @@ export const Visual: Story = { }) useMeta({ - 'ol-showSymbolPalette': true, 'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js', 'ol-project_id': '63e21c07946dd8c76505f85a', }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx index 0d80f9bde8..bd8eebfb18 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx @@ -1,5 +1,8 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import { mockScope, rootFolderId } from '../helpers/mock-scope' import { FC } from 'react' import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' @@ -41,7 +44,6 @@ describe('', function () { function mount() { const content = '' const scope = mockScope(content) - scope.editor.showVisual = true const FileTreePathProvider: FC = ({ children, @@ -63,7 +65,16 @@ describe('', function () { cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx index 34f26f02cb..18422c1ff0 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx @@ -1,6 +1,9 @@ // Needed since eslint gets confused by mocha-each /* eslint-disable mocha/prefer-arrow-callback */ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import forEach from 'mocha-each' @@ -14,11 +17,18 @@ const mountEditor = (content: string | string[]) => { content = '\n' + content } const scope = mockScope(content) - scope.editor.showVisual = true cy.viewport(1000, 800) cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx index 28f1363829..720335c035 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx @@ -1,15 +1,25 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx index 0e406418fa..3433205a9f 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx @@ -1,15 +1,25 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx index f2ae1df68e..cc5bbdbe55 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx @@ -1,4 +1,7 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' @@ -6,11 +9,18 @@ import { isMac } from '@/shared/utils/os' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx index 9220d6ccdf..8173cefb9d 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -1,4 +1,7 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' @@ -7,11 +10,18 @@ const menuIconsText = 'content_copyexpand_more' const mountEditor = (content = '') => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx index 7faa740a67..5e05429212 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx @@ -1,5 +1,8 @@ import { mockScope } from '../helpers/mock-scope' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { FC } from 'react' import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' @@ -43,13 +46,19 @@ const mountEditor = (content: string) => { const scope = mockScope(content) scope.permissions.write = false scope.permissions.trackedWrite = false - scope.editor.showVisual = true cy.mount( diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index 63dd3da5b1..542f1ce662 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -1,4 +1,7 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' @@ -18,11 +21,18 @@ const clickToolbarButton = (name: string) => { const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx index 7dcc10d72c..2afed2b813 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx @@ -1,5 +1,8 @@ import { mockScope } from '../helpers/mock-scope' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { TestContainer } from '../helpers/test-container' @@ -11,11 +14,18 @@ describe(' tooltips in Visual mode', function () { cy.interceptEvents() const scope = mockScope('\n\n\n') - scope.editor.showVisual = true cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 7a9acebf07..45f68f359b 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -1,7 +1,10 @@ // Needed since eslint gets confused by mocha-each /* eslint-disable mocha/prefer-arrow-callback */ import { FC } from 'react' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import forEach from 'mocha-each' @@ -19,7 +22,6 @@ describe(' in Visual mode', function () { const content = '\n'.repeat(3) const scope = mockScope(content) - scope.editor.showVisual = true const FileTreePathProvider: FC = ({ children, @@ -41,7 +43,16 @@ describe(' in Visual mode', function () { cy.mount( - + diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx index 257cc96661..b64e66734f 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx @@ -622,7 +622,7 @@ describe('', { scrollBehavior: false }, function () { const rect = selection.getRangeAt(0).getBoundingClientRect() expect(Math.round(rect.top)).to.be.gte(100) - expect(Math.round(rect.left)).to.be.gte(90) + expect(Math.round(rect.left)).to.be.gte(80) }) }) }) diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts index 4b7d0fb4a1..c01d20f164 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts @@ -19,7 +19,6 @@ export const mockScope = ( sharejs_doc: mockDoc(content, docOptions), openDocName: 'test.tex', currentDocumentId: docId, - showVisual: false, wantTrackChanges: false, }, project: { diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index bad92019a2..a20c7f015f 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -27,6 +27,10 @@ import { ReactContextRoot } from '@/features/ide-react/context/react-context-roo import useEventListener from '@/shared/hooks/use-event-listener' import useDetachLayout from '@/shared/hooks/use-detach-layout' import useExposedState from '@/shared/hooks/use-exposed-state' +import { + EditorPropertiesContext, + EditorPropertiesContextValue, +} from '@/features/ide-react/context/editor-properties-context' import { type IdeLayout, type IdeView, @@ -160,6 +164,7 @@ export function EditorProviders({ } as any as DocumentContainer, openDocName: null, currentDocumentId: null, + wantTrackChanges: false, }, project: { _id: projectId, @@ -190,6 +195,9 @@ export function EditorProviders({ openDocName: scope.editor.openDocName, currentDocument: scope.editor.sharejs_doc, }), + EditorPropertiesProvider: makeEditorPropertiesProvider({ + wantTrackChanges: scope.editor.wantTrackChanges, + }), LayoutProvider: makeLayoutProvider(layoutContext), ...providers, }} @@ -251,7 +259,7 @@ const makeIdeReactProvider = ( })) const [ideContextValue] = useState(() => { - const scopeStore = createReactScopeValueStore(PROJECT_ID) + const scopeStore = createReactScopeValueStore() for (const [key, value] of Object.entries(scope)) { // TODO: path for nested entries scopeStore.set(key, value) @@ -438,3 +446,60 @@ const makeLayoutProvider = ( } return LayoutProvider } + +export function makeEditorPropertiesProvider( + initialValues: Partial< + Pick< + EditorPropertiesContextValue, + 'showVisual' | 'showSymbolPalette' | 'wantTrackChanges' + > + > +) { + const EditorPropertiesProvider: FC = ({ children }) => { + const { + showVisual: initialShowVisual, + showSymbolPalette: initialShowSymbolPalette, + wantTrackChanges: initialWantTrackChanges, + } = initialValues + + const [showVisual, setShowVisual] = useState(initialShowVisual || false) + const [showSymbolPalette, setShowSymbolPalette] = useState( + initialShowSymbolPalette || false + ) + + function toggleSymbolPalette() { + setShowSymbolPalette(show => !show) + } + + const [opening, setOpening] = useState(true) + const [trackChanges, setTrackChanges] = useState(false) + const [wantTrackChanges, setWantTrackChanges] = useState( + initialWantTrackChanges || false + ) + const [errorState, setErrorState] = useState(false) + + const value = { + showVisual, + setShowVisual, + showSymbolPalette, + setShowSymbolPalette, + toggleSymbolPalette, + opening, + setOpening, + trackChanges, + setTrackChanges, + wantTrackChanges, + setWantTrackChanges, + errorState, + setErrorState, + } + + return ( + + {children} + + ) + } + + return EditorPropertiesProvider +} diff --git a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx index 5b670698e3..9664148ce7 100644 --- a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx +++ b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx @@ -22,7 +22,7 @@ describe('usePersistedState', function () { expect(window.Storage.prototype.setItem).to.have.callCount(1) const Test = () => { - const [value] = usePersistedState(key) + const [value] = usePersistedState(key) return
{value}
} @@ -139,6 +139,35 @@ describe('usePersistedState', function () { expect(localStorage.getItem(key)).to.equal('foobar') }) + it('converts persisted value (string to boolean)', function () { + const key = 'test:convert' + localStorage.setItem(key, 'yep') + + const Test = () => { + const [value, setValue] = usePersistedState(key, true, { + converter: { + toPersisted(value) { + return value ? 'yep' : 'nope' + }, + fromPersisted(persistedValue) { + return persistedValue === 'yep' + }, + }, + }) + + useEffect(() => { + setValue(false) + }, [setValue]) + + return
{String(value)}
+ } + + render() + + screen.getByText('false') + expect(localStorage.getItem(key)).to.equal('nope') + }) + it('handles syncing values via storage event', async function () { const key = 'test:sync' localStorage.setItem(key, 'foo') @@ -149,7 +178,7 @@ describe('usePersistedState', function () { window.addEventListener('storage', storageEventListener) const Test = () => { - const [value, setValue] = usePersistedState(key, 'bar', true) + const [value, setValue] = usePersistedState(key, 'bar', { listen: true }) useEffect(() => { setValue('baz')