Browse Source

Merge pull request #26583 from overleaf/td-editor-scope-values-to-context

Move scope values starting with `editor.` to contexts

GitOrigin-RevId: 7ca349ceff002228cf4e931c644c8c386eb6c597
Tim Down 1 year ago
parent
commit
132ccbc4cc
40 changed files with 593 additions and 292 deletions
  1. 3 4
      services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx
  2. 12 49
      services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
  3. 114 0
      services/web/frontend/js/features/ide-react/context/editor-properties-context.tsx
  4. 2 4
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  5. 47 43
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  6. 0 54
      services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
  7. 5 7
      services/web/frontend/js/features/ide-redesign/components/editor.tsx
  8. 2 2
      services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx
  9. 2 2
      services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx
  10. 2 2
      services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
  11. 31 2
      services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts
  12. 1 1
      services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx
  13. 3 1
      services/web/frontend/js/features/settings/context/user-email-context.tsx
  14. 3 2
      services/web/frontend/js/features/source-editor/components/editor-switch.tsx
  15. 4 2
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
  16. 3 0
      services/web/frontend/js/features/source-editor/extensions/before-change-doc.ts
  17. 1 1
      services/web/frontend/js/features/source-editor/extensions/index.ts
  18. 21 13
      services/web/frontend/js/features/source-editor/extensions/search.ts
  19. 23 6
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  20. 0 8
      services/web/frontend/js/shared/context/editor-context.tsx
  21. 6 4
      services/web/frontend/js/shared/context/local-compile-context.tsx
  22. 3 9
      services/web/frontend/js/shared/hooks/use-exposed-state.ts
  23. 33 35
      services/web/frontend/js/shared/hooks/use-persisted-state.ts
  24. 11 0
      services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts
  25. 1 1
      services/web/frontend/stories/decorators/scope.tsx
  26. 31 5
      services/web/frontend/stories/source-editor/source-editor.stories.tsx
  27. 14 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx
  28. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx
  29. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx
  30. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx
  31. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx
  32. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx
  33. 12 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx
  34. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx
  35. 13 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx
  36. 14 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx
  37. 1 1
      services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx
  38. 0 1
      services/web/test/frontend/features/source-editor/helpers/mock-scope.ts
  39. 66 1
      services/web/test/frontend/helpers/editor-providers.tsx
  40. 31 2
      services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx

+ 3 - 4
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<EditorScopeValue>('editor')
+  const { showSymbolPalette } = useEditorPropertiesContext()
   const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
   const { isLoading } = useEditorManagerContext()
   const { currentDocumentId } = useEditorOpenDocContext()
@@ -41,7 +40,7 @@ export const EditorPane: FC = () => {
           {isLoading && <LoadingPane />}
         </Panel>
 
-        {editor.showSymbolPalette && (
+        {showSymbolPalette && (
           <>
             <VerticalResizeHandle id="editor-symbol-palette" />
             <Panel

+ 12 - 49
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -9,8 +9,6 @@ import {
   useState,
 } from 'react'
 import { sendMB } from '@/infrastructure/event-tracking'
-import useScopeValue from '@/shared/hooks/use-scope-value'
-import { useIdeContext } from '@/shared/context/ide-context'
 import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
 import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
@@ -37,6 +35,7 @@ import { Update } from '@/features/history/services/types/update'
 import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker'
 import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
+import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 
 export interface GotoOffsetOptions {
   gotoOffset: number
@@ -52,7 +51,6 @@ interface OpenDocOptions
 
 export type EditorManager = {
   getEditorType: () => 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<EditorManager['wantTrackChanges']>
-  >
   debugTimers: React.MutableRefObject<Record<string, number>>
 }
 
@@ -87,7 +80,6 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
   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<React.PropsWithChildren> = ({
   const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
     useModalsContext()
   const { id: userId } = useUserContext()
-
-  const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
-    'editor.showSymbolPalette'
-  )
-  const [showVisual] = useScopeValue<boolean>('editor.showVisual')
+  const {
+    showVisual,
+    opening,
+    setOpening,
+    errorState,
+    setErrorState,
+    setTrackChanges,
+    wantTrackChanges,
+  } = useEditorPropertiesContext()
   const {
     currentDocumentId,
     setCurrentDocumentId,
@@ -107,15 +103,6 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
     currentDocument,
     setCurrentDocument,
   } = useEditorOpenDocContext()
-  const [opening, setOpening] = useScopeValue<boolean>('editor.opening')
-  const [errorState, setIsInErrorState] =
-    useScopeValue<boolean>('editor.error_state')
-  const [trackChanges, setTrackChanges] = useScopeValue<boolean>(
-    'editor.trackChanges'
-  )
-  const [wantTrackChanges, setWantTrackChanges] = useScopeValue<boolean>(
-    'editor.wantTrackChanges'
-  )
 
   const wantTrackChangesRef = useRef(wantTrackChanges)
   useEffect(() => {
@@ -191,22 +178,6 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
 
   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<React.PropsWithChildren> = ({
         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<React.PropsWithChildren> = ({
     eventEmitter,
     openDoc,
     reportError,
-    setIsInErrorState,
+    setErrorState,
     showGenericMessageModal,
     showOutOfSyncModal,
     setOutOfSync,
@@ -661,25 +632,20 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
   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<React.PropsWithChildren> = ({
       openDocs,
       openFileWithId,
       openInitialDoc,
-      trackChanges,
       isLoading,
       jumpToLine,
-      wantTrackChanges,
-      setWantTrackChanges,
       debugTimers,
     ]
   )

+ 114 - 0
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<SetStateAction<boolean>>
+  showSymbolPalette: boolean
+  setShowSymbolPalette: Dispatch<SetStateAction<boolean>>
+  toggleSymbolPalette: () => void
+  opening: boolean
+  setOpening: Dispatch<SetStateAction<boolean>>
+  trackChanges: boolean
+  setTrackChanges: Dispatch<SetStateAction<boolean>>
+  wantTrackChanges: boolean
+  setWantTrackChanges: Dispatch<SetStateAction<boolean>>
+  errorState: boolean
+  setErrorState: Dispatch<SetStateAction<boolean>>
+}
+
+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<PropsWithChildren> = ({
+  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 (
+    <EditorPropertiesContext.Provider value={value}>
+      {children}
+    </EditorPropertiesContext.Provider>
+  )
+}
+
+export const useEditorPropertiesContext = (): EditorPropertiesContextValue => {
+  const context = useContext(EditorPropertiesContext)
+  if (!context) {
+    throw new Error(
+      'useEditorPropertiesContext is only available inside EditorPropertiesContext.Provider'
+    )
+  }
+  return context
+}

+ 2 - 4
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<React.PropsWithChildren> = ({ children }) => {
   const projectId = getMeta('ol-project_id')
-  const [scopeStore] = useState(() => createReactScopeValueStore(projectId))
+  const [scopeStore] = useState(() => createReactScopeValueStore())
   const [eventEmitter] = useState(createIdeEventEmitter)
   const [permissionsLevel, setPermissionsLevel] =
     useState<PermissionsLevel>('readOnly')

+ 47 - 43
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<
                 <Providers.ProjectProvider>
                   <Providers.SnapshotProvider>
                     <Providers.DetachProvider>
-                      <Providers.EditorViewProvider>
-                        <Providers.EditorOpenDocProvider>
-                          <Providers.EditorProvider>
-                            <Providers.FileTreeDataProvider>
-                              <Providers.FileTreePathProvider>
-                                <Providers.ReferencesProvider>
-                                  <Providers.UserFeaturesProvider>
-                                    <Providers.PermissionsProvider>
-                                      <Providers.RailProvider>
-                                        <Providers.LayoutProvider>
-                                          <Providers.ProjectSettingsProvider>
-                                            <Providers.EditorManagerProvider>
-                                              <Providers.LocalCompileProvider>
-                                                <Providers.DetachCompileProvider>
-                                                  <Providers.ChatProvider>
-                                                    <Providers.FileTreeOpenProvider>
-                                                      <Providers.OnlineUsersProvider>
-                                                        <Providers.MetadataProvider>
-                                                          <Providers.OutlineProvider>
-                                                            <Providers.IdeRedesignSwitcherProvider>
-                                                              <Providers.CommandRegistryProvider>
-                                                                {children}
-                                                              </Providers.CommandRegistryProvider>
-                                                            </Providers.IdeRedesignSwitcherProvider>
-                                                          </Providers.OutlineProvider>
-                                                        </Providers.MetadataProvider>
-                                                      </Providers.OnlineUsersProvider>
-                                                    </Providers.FileTreeOpenProvider>
-                                                  </Providers.ChatProvider>
-                                                </Providers.DetachCompileProvider>
-                                              </Providers.LocalCompileProvider>
-                                            </Providers.EditorManagerProvider>
-                                          </Providers.ProjectSettingsProvider>
-                                        </Providers.LayoutProvider>
-                                      </Providers.RailProvider>
-                                    </Providers.PermissionsProvider>
-                                  </Providers.UserFeaturesProvider>
-                                </Providers.ReferencesProvider>
-                              </Providers.FileTreePathProvider>
-                            </Providers.FileTreeDataProvider>
-                          </Providers.EditorProvider>
-                        </Providers.EditorOpenDocProvider>
-                      </Providers.EditorViewProvider>
+                      <Providers.EditorPropertiesProvider>
+                        <Providers.EditorViewProvider>
+                          <Providers.EditorOpenDocProvider>
+                            <Providers.EditorProvider>
+                              <Providers.FileTreeDataProvider>
+                                <Providers.FileTreePathProvider>
+                                  <Providers.ReferencesProvider>
+                                    <Providers.UserFeaturesProvider>
+                                      <Providers.PermissionsProvider>
+                                        <Providers.RailProvider>
+                                          <Providers.LayoutProvider>
+                                            <Providers.ProjectSettingsProvider>
+                                              <Providers.EditorManagerProvider>
+                                                <Providers.LocalCompileProvider>
+                                                  <Providers.DetachCompileProvider>
+                                                    <Providers.ChatProvider>
+                                                      <Providers.FileTreeOpenProvider>
+                                                        <Providers.OnlineUsersProvider>
+                                                          <Providers.MetadataProvider>
+                                                            <Providers.OutlineProvider>
+                                                              <Providers.IdeRedesignSwitcherProvider>
+                                                                <Providers.CommandRegistryProvider>
+                                                                  {children}
+                                                                </Providers.CommandRegistryProvider>
+                                                              </Providers.IdeRedesignSwitcherProvider>
+                                                            </Providers.OutlineProvider>
+                                                          </Providers.MetadataProvider>
+                                                        </Providers.OnlineUsersProvider>
+                                                      </Providers.FileTreeOpenProvider>
+                                                    </Providers.ChatProvider>
+                                                  </Providers.DetachCompileProvider>
+                                                </Providers.LocalCompileProvider>
+                                              </Providers.EditorManagerProvider>
+                                            </Providers.ProjectSettingsProvider>
+                                          </Providers.LayoutProvider>
+                                        </Providers.RailProvider>
+                                      </Providers.PermissionsProvider>
+                                    </Providers.UserFeaturesProvider>
+                                  </Providers.ReferencesProvider>
+                                </Providers.FileTreePathProvider>
+                              </Providers.FileTreeDataProvider>
+                            </Providers.EditorProvider>
+                          </Providers.EditorOpenDocProvider>
+                        </Providers.EditorViewProvider>
+                      </Providers.EditorPropertiesProvider>
                     </Providers.DetachProvider>
                   </Providers.SnapshotProvider>
                 </Providers.ProjectProvider>

+ 0 - 54
services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts

@@ -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<EditorScopeValue, 'showVisual'> = {
-    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'
-}

+ 5 - 7
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<EditorScopeValue>('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 = () => {
           <SourceEditor />
           {isLoading && <LoadingPane />}
         </Panel>
-        {editor.showSymbolPalette && (
+        {showSymbolPalette && (
           <>
             <VerticalResizeHandle id="ide-redesign-editor-symbol-palette" />
             <Panel

+ 2 - 2
services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx

@@ -6,7 +6,7 @@ import {
 import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
 import * as commands from '@/features/source-editor/extensions/toolbar/commands'
 import { setSectionHeadingLevel } from '@/features/source-editor/extensions/toolbar/sections'
-import { useEditorContext } from '@/shared/context/editor-context'
+import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import getMeta from '@/utils/meta'
 import { redo, selectAll, undo } from '@codemirror/commands'
@@ -288,7 +288,7 @@ export const useToolbarMenuBarEditorCommands = () => {
     isTeXFile,
   ])
 
-  const { toggleSymbolPalette } = useEditorContext()
+  const { toggleSymbolPalette } = useEditorPropertiesContext()
   const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
   useCommandProvider(() => {
     if (!newEditor || !editorIsVisible) {

+ 2 - 2
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<CSSProperties | undefined>()
   const [visible, setVisible] = useState(false)
 

+ 2 - 2
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<React.PropsWithChildren> = ({
   const { socket } = useConnectionContext()
   const project = useProjectContext()
   const user = useUserContext()
-  const { setWantTrackChanges } = useEditorManagerContext()
+  const { setWantTrackChanges } = useEditorPropertiesContext()
 
   // TODO: update project.trackChangesState instead?
   const [trackChangesValue, setTrackChangesValue] = useState<

+ 31 - 2
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<DocId, boolean>
-  >(`docs_collapsed_state:${projectId}`, {}, false, true)
+    Record<DocId, boolean>,
+    string
+  >(
+    `docs_collapsed_state:${projectId}`,
+    {},
+    {
+      converter: {
+        fromPersisted: safeParse,
+        toPersisted: safeStringify,
+      },
+    }
+  )
 
   const toggleCollapsed = useCallback(() => {
     setCollapsedDocs((collapsedDocs: Record<DocId, boolean>) => {

+ 1 - 1
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() {

+ 3 - 1
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 } =

+ 3 - 2
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

+ 4 - 2
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)
 

+ 3 - 0
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()

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

@@ -139,7 +139,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   cursorHighlights(),
   autoPair(options.settings),
   editable(),
-  search(),
+  search(options.initialSearchQuery),
   phrases(options.phrases),
   spelling(options.spelling),
   shortcuts,

+ 21 - 13
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<boolean>()
 
@@ -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,
+                  })
+                )
+              }
+            }
+          }
         },
       }
     }),

+ 23 - 6
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<boolean>('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<SearchQuery | null>(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,
         }),

+ 0 - 8
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<React.PropsWithChildren> = ({ 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<React.PropsWithChildren> = ({ children }) => {
       isProjectOwner: owner?._id === userId,
       isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'),
       isPendingEditor,
-      showSymbolPalette,
-      toggleSymbolPalette,
       insertSymbol,
       inactiveTutorials,
       deactivateTutorial,
@@ -204,8 +198,6 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
       userId,
       renameProject,
       isPendingEditor,
-      showSymbolPalette,
-      toggleSymbolPalette,
       insertSymbol,
       inactiveTutorials,
       deactivateTutorial,

+ 6 - 4
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -248,17 +248,19 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   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<React.PropsWithChildren> = ({
   const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
     `stop_on_validation_error:${projectId}`,
     true,
-    true
+    { listen: true }
   )
 
   // whether the editor linter found errors

+ 3 - 9
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<T = any>(
   path: string
 ): [T, Dispatch<SetStateAction<T>>] {
   const [value, setValue] = useState<T>(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]
 }

+ 33 - 35
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<Value, PersistedValue> = {
+  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<T = any>(
+function usePersistedState<Value, PersistedValue = Value>(
   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<SetStateAction<T>>] {
+  defaultValue?: Value,
+  options?: UsePersistedStateOptions<Value, PersistedValue>
+): [Value, Dispatch<SetStateAction<Value>>] {
+  // 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<Value, PersistedValue>
+  }>(() => ({ 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<T>(() => {
-    return getItem(key) ?? defaultValue
+  const [value, setValue] = useState<Value>(() => {
+    return getItem(key) ?? storedDefaultValue
   })
 
   const updateFunction = useCallback(
-    (newValue: SetStateAction<T>) => {
+    (newValue: SetStateAction<Value>) => {
       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<T = any>(
         return actualNewValue
       })
     },
-    [key, defaultValue, setItem]
+    [key, storedDefaultValue, setItem]
   )
 
   useEffect(() => {
@@ -79,7 +77,7 @@ function usePersistedState<T = any>(
         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<T = any>(
         window.removeEventListener('storage', listener)
       }
     }
-  }, [defaultValue, key, listen, getItem])
+  }, [storedDefaultValue, key, listen, getItem])
 
   return [value, updateFunction]
 }

+ 11 - 0
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<T = any>(path: string, value: T) {
+  const { unstableStore } = useIdeContext()
+
+  // Update the unstable store whenever the value changes
+  useEffect(() => {
+    unstableStore.set(path, value)
+  }, [unstableStore, path, value])
+}

+ 1 - 1
services/web/frontend/stories/decorators/scope.tsx

@@ -195,7 +195,7 @@ const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
   }))
 
   const [ideContextValue] = useState(() => {
-    const scopeStore = createReactScopeValueStore(projectId)
+    const scopeStore = createReactScopeValueStore()
     for (const [key, value] of Object.entries(initialScope)) {
       scopeStore.set(key, value)
     }

+ 31 - 5
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<typeof SourceEditor>
 
@@ -83,6 +84,34 @@ const BibtexEditorOpenDocProvider: FC<React.PropsWithChildren> = ({
   </EditorOpenDocProvider>
 )
 
+const VisualEditorPropertiesProvider: FC<React.PropsWithChildren> = ({
+  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 (
+    <EditorPropertiesContext.Provider value={value}>
+      {children}
+    </EditorPropertiesContext.Provider>
+  )
+}
+
 const FileTreePathProvider: FC<React.PropsWithChildren> = ({ children }) => (
   <FileTreePathContext.Provider
     value={{
@@ -218,15 +247,13 @@ export const Visual: Story = {
         providers: {
           FileTreePathProvider,
           EditorOpenDocProvider: LatexEditorOpenDocProvider,
+          EditorPropertiesProvider: VisualEditorPropertiesProvider,
         },
       }),
 
     (Story, { globals }) => {
-      // 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',
       })

+ 14 - 3
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('<FigureModal />', function () {
   function mount() {
     const content = ''
     const scope = mockScope(content)
-    scope.editor.showVisual = true
 
     const FileTreePathProvider: FC<React.PropsWithChildren> = ({
       children,
@@ -63,7 +65,16 @@ describe('<FigureModal />', function () {
 
     cy.mount(
       <TestContainer>
-        <EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
+        <EditorProviders
+          scope={scope}
+          providers={{
+            FileTreePathProvider,
+            EditorPropertiesProvider: makeEditorPropertiesProvider({
+              showVisual: true,
+              showSymbolPalette: false,
+            }),
+          }}
+        >
           <CodemirrorEditor />
         </EditorProviders>
       </TestContainer>

+ 13 - 3
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(
     <TestContainer style={{ width: 1000, height: 800 }}>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 13 - 3
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(
     <TestContainer>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 13 - 3
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(
     <TestContainer>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 13 - 3
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(
     <TestContainer>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 13 - 3
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(
     <TestContainer>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 12 - 3
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(
     <TestContainer>
       <EditorProviders
         scope={scope}
-        providers={{ FileTreePathProvider, PermissionsProvider }}
+        providers={{
+          FileTreePathProvider,
+          PermissionsProvider,
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
       >
         <CodemirrorEditor />
       </EditorProviders>

+ 13 - 3
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(
     <TestContainer>
-      <EditorProviders scope={scope}>
+      <EditorProviders
+        scope={scope}
+        providers={{
+          EditorPropertiesProvider: makeEditorPropertiesProvider({
+            showVisual: true,
+            showSymbolPalette: false,
+          }),
+        }}
+      >
         <CodemirrorEditor />
       </EditorProviders>
     </TestContainer>

+ 13 - 3
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('<CodeMirrorEditor/> tooltips in Visual mode', function () {
     cy.interceptEvents()
 
     const scope = mockScope('\n\n\n')
-    scope.editor.showVisual = true
 
     cy.mount(
       <TestContainer>
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{
+            EditorPropertiesProvider: makeEditorPropertiesProvider({
+              showVisual: true,
+              showSymbolPalette: false,
+            }),
+          }}
+        >
           <CodemirrorEditor />
         </EditorProviders>
       </TestContainer>

+ 14 - 3
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('<CodeMirrorEditor/> in Visual mode', function () {
     const content = '\n'.repeat(3)
 
     const scope = mockScope(content)
-    scope.editor.showVisual = true
 
     const FileTreePathProvider: FC<React.PropsWithChildren> = ({
       children,
@@ -41,7 +43,16 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
 
     cy.mount(
       <TestContainer>
-        <EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
+        <EditorProviders
+          scope={scope}
+          providers={{
+            FileTreePathProvider,
+            EditorPropertiesProvider: makeEditorPropertiesProvider({
+              showVisual: true,
+              showSymbolPalette: false,
+            }),
+          }}
+        >
           <CodemirrorEditor />
         </EditorProviders>
       </TestContainer>

+ 1 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx

@@ -622,7 +622,7 @@ describe('<CodeMirrorEditor/>', { 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)
       })
   })
 })

+ 0 - 1
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: {

+ 66 - 1
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<PropsWithChildren> = ({ 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 (
+      <EditorPropertiesContext.Provider value={value}>
+        {children}
+      </EditorPropertiesContext.Provider>
+    )
+  }
+
+  return EditorPropertiesProvider
+}

+ 31 - 2
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<string>(key)
 
       return <div>{value}</div>
     }
@@ -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 <div>{String(value)}</div>
+    }
+
+    render(<Test />)
+
+    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')