소스 검색

Merge pull request #26326 from overleaf/td-clean-up-scope-store

Create separate window.overleaf.unstable.store based on React context values

GitOrigin-RevId: 68f070a6fc5e2965a82720024d91b16fa622b28b
Tim Down 1 년 전
부모
커밋
904fac958d
34개의 변경된 파일과 621개의 추가작업 그리고 251개의 파일을 삭제
  1. 3 1
      services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx
  2. 8 20
      services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
  3. 63 0
      services/web/frontend/js/features/ide-react/context/editor-open-doc-context.tsx
  4. 49 0
      services/web/frontend/js/features/ide-react/context/editor-view-context.tsx
  5. 3 2
      services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
  6. 8 0
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  7. 2 2
      services/web/frontend/js/features/ide-react/context/metadata-context.tsx
  8. 2 2
      services/web/frontend/js/features/ide-react/context/online-users-context.tsx
  9. 2 2
      services/web/frontend/js/features/ide-react/context/outline-context.tsx
  10. 49 41
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  11. 0 4
      services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
  12. 4 4
      services/web/frontend/js/features/ide-redesign/components/editor.tsx
  13. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-provider.tsx
  14. 3 2
      services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts
  15. 2 2
      services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx
  16. 2 2
      services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
  17. 4 3
      services/web/frontend/js/features/source-editor/components/codemirror-view.tsx
  18. 2 2
      services/web/frontend/js/features/source-editor/components/editor-switch.tsx
  19. 3 2
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  20. 3 1
      services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx
  21. 2 3
      services/web/frontend/js/shared/context/file-tree-data-context.tsx
  22. 8 6
      services/web/frontend/js/shared/context/ide-context.tsx
  23. 3 1
      services/web/frontend/js/shared/context/local-compile-context.tsx
  24. 5 14
      services/web/frontend/js/shared/context/user-settings-context.tsx
  25. 25 0
      services/web/frontend/js/shared/hooks/use-exposed-state.ts
  26. 6 1
      services/web/frontend/stories/decorators/scope.tsx
  27. 21 3
      services/web/frontend/stories/pdf-log-entry.stories.tsx
  28. 202 88
      services/web/frontend/stories/source-editor/source-editor.stories.tsx
  29. 30 11
      services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx
  30. 38 10
      services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx
  31. 0 1
      services/web/test/frontend/components/pdf-preview/scope.tsx
  32. 14 15
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx
  33. 2 2
      services/web/test/frontend/features/source-editor/helpers/mock-scope.ts
  34. 52 3
      services/web/test/frontend/helpers/editor-providers.jsx

+ 3 - 1
services/web/frontend/js/features/ide-react/components/editor/editor-pane.tsx

@@ -3,6 +3,7 @@ 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'
@@ -17,7 +18,8 @@ const SymbolPalettePane = lazy(
 export const EditorPane: FC = () => {
   const [editor] = useScopeValue<EditorScopeValue>('editor')
   const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
-  const { currentDocumentId, isLoading } = useEditorManagerContext()
+  const { isLoading } = useEditorManagerContext()
+  const { currentDocumentId } = useEditorOpenDocContext()
 
   if (!currentDocumentId) {
     return null

+ 8 - 20
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -37,6 +37,7 @@ import { Update } from '@/features/history/services/types/update'
 import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 
 export interface GotoOffsetOptions {
   gotoOffset: number
@@ -53,8 +54,6 @@ interface OpenDocOptions
 export type EditorManager = {
   getEditorType: () => EditorType | null
   showSymbolPalette: boolean
-  currentDocument: DocumentContainer | null
-  currentDocumentId: DocId | null
   getCurrentDocValue: () => string | null
   getCurrentDocumentId: () => DocId | null
   setIgnoringExternalUpdates: (value: boolean) => void
@@ -63,8 +62,6 @@ export type EditorManager = {
   openDocs: OpenDocuments
   openFileWithId: (fileId: string) => void
   openInitialDoc: (docId: string) => void
-  openDocName: string | null
-  setOpenDocName: (openDocName: string) => void
   isLoading: boolean
   trackChanges: boolean
   jumpToLine: (options: GotoLineOptions) => void
@@ -104,14 +101,13 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
     'editor.showSymbolPalette'
   )
   const [showVisual] = useScopeValue<boolean>('editor.showVisual')
-  const [currentDocument, setCurrentDocument] =
-    useScopeValue<DocumentContainer | null>('editor.sharejs_doc')
-  const [currentDocumentId, setCurrentDocumentId] = useScopeValue<DocId | null>(
-    'editor.open_doc_id'
-  )
-  const [openDocName, setOpenDocName] = useScopeValue<string | null>(
-    'editor.open_doc_name'
-  )
+  const {
+    currentDocumentId,
+    setCurrentDocumentId,
+    setOpenDocName,
+    currentDocument,
+    setCurrentDocument,
+  } = useEditorOpenDocContext()
   const [opening, setOpening] = useScopeValue<boolean>('editor.opening')
   const [errorState, setIsInErrorState] =
     useScopeValue<boolean>('editor.error_state')
@@ -667,16 +663,12 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
     () => ({
       getEditorType,
       showSymbolPalette,
-      currentDocument,
-      currentDocumentId,
       getCurrentDocValue,
       getCurrentDocumentId,
       setIgnoringExternalUpdates,
       openDocWithId,
       openDoc,
       openDocs,
-      openDocName,
-      setOpenDocName,
       trackChanges,
       isLoading,
       openFileWithId,
@@ -689,8 +681,6 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
     [
       getEditorType,
       showSymbolPalette,
-      currentDocument,
-      currentDocumentId,
       getCurrentDocValue,
       getCurrentDocumentId,
       setIgnoringExternalUpdates,
@@ -699,8 +689,6 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
       openDocs,
       openFileWithId,
       openInitialDoc,
-      openDocName,
-      setOpenDocName,
       trackChanges,
       isLoading,
       jumpToLine,

+ 63 - 0
services/web/frontend/js/features/ide-react/context/editor-open-doc-context.tsx

@@ -0,0 +1,63 @@
+import {
+  createContext,
+  Dispatch,
+  FC,
+  PropsWithChildren,
+  SetStateAction,
+  useContext,
+  useState,
+} from 'react'
+import { DocId } from '../../../../../types/project-settings'
+import useExposedState from '@/shared/hooks/use-exposed-state'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+
+type EditorOpenDocContextValue = {
+  currentDocumentId: DocId | null
+  setCurrentDocumentId: Dispatch<SetStateAction<DocId | null>>
+  openDocName: string | null
+  setOpenDocName: Dispatch<SetStateAction<string | null>>
+  currentDocument: DocumentContainer | null
+  setCurrentDocument: Dispatch<SetStateAction<DocumentContainer | null>>
+}
+
+export const EditorOpenDocContext = createContext<
+  EditorOpenDocContextValue | undefined
+>(undefined)
+
+export const EditorOpenDocProvider: FC<PropsWithChildren> = ({ children }) => {
+  const [currentDocumentId, setCurrentDocumentId] =
+    useExposedState<DocId | null>(null, 'editor.open_doc_id')
+  const [openDocName, setOpenDocName] = useExposedState<string | null>(
+    null,
+    'editor.open_doc_name'
+  )
+  const [currentDocument, setCurrentDocument] =
+    useState<DocumentContainer | null>(null)
+
+  const value = {
+    currentDocumentId,
+    setCurrentDocumentId,
+    openDocName,
+    setOpenDocName,
+    currentDocument,
+    setCurrentDocument,
+  }
+
+  return (
+    <EditorOpenDocContext.Provider value={value}>
+      {children}
+    </EditorOpenDocContext.Provider>
+  )
+}
+
+export const useEditorOpenDocContext = (): EditorOpenDocContextValue => {
+  const context = useContext(EditorOpenDocContext)
+
+  if (!context) {
+    throw new Error(
+      'useEditorOpenDocContext is only available inside EditorOpenDocContext.Provider'
+    )
+  }
+
+  return context
+}

+ 49 - 0
services/web/frontend/js/features/ide-react/context/editor-view-context.tsx

@@ -0,0 +1,49 @@
+import {
+  createContext,
+  Dispatch,
+  FC,
+  PropsWithChildren,
+  SetStateAction,
+  useContext,
+} from 'react'
+import { EditorView } from '@codemirror/view'
+import useExposedState from '@/shared/hooks/use-exposed-state'
+
+export type EditorContextValue = {
+  view: EditorView | null
+  setView: Dispatch<SetStateAction<EditorView | null>>
+}
+
+// This provides access to the CodeMirror EditorView instance outside the editor
+// component itself, including external extensions (in particular, Writefull)
+export const EditorViewContext = createContext<EditorContextValue | undefined>(
+  undefined
+)
+
+export const EditorViewProvider: FC<PropsWithChildren> = ({ children }) => {
+  const [view, setView] = useExposedState<EditorView | null>(
+    null,
+    'editor.view'
+  )
+
+  const value = {
+    view,
+    setView,
+  }
+
+  return (
+    <EditorViewContext.Provider value={value}>
+      {children}
+    </EditorViewContext.Provider>
+  )
+}
+
+export const useEditorViewContext = (): EditorContextValue => {
+  const context = useContext(EditorViewContext)
+  if (!context) {
+    throw new Error(
+      'useEditorViewContext is only available inside EditorViewProvider'
+    )
+  }
+  return context
+}

+ 3 - 2
services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx

@@ -11,6 +11,7 @@ import {
 import { useProjectContext } from '@/shared/context/project-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import {
   FileTreeDocumentFindResult,
   FileTreeFileRefFindResult,
@@ -40,8 +41,8 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
 }) => {
   const { rootDocId, owner } = useProjectContext()
   const { eventEmitter, projectJoined } = useIdeReactContext()
-  const { openDocWithId, currentDocumentId, openInitialDoc } =
-    useEditorManagerContext()
+  const { openDocWithId, openInitialDoc } = useEditorManagerContext()
+  const { currentDocumentId } = useEditorOpenDocContext()
   const { setOpenFile } = useLayoutContext()
   const [openEntity, setOpenEntity] = useState<
     FileTreeDocumentFindResult | FileTreeFileRefFindResult | null

+ 8 - 0
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -81,6 +81,13 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const [scopeEventEmitter] = useState(
     () => new ReactScopeEventEmitter(eventEmitter)
   )
+  const [unstableStore] = useState(() => {
+    const store = new ReactScopeValueStore()
+    // Add dummy editor.ready key for Writefull, that relies on this calling
+    // back once after watching it
+    store.set('editor.ready', undefined)
+    return store
+  })
   const [startedFreeTrial, setStartedFreeTrial] = useState(false)
   const release = getMeta('ol-ExposedSettings')?.sentryRelease ?? null
 
@@ -179,6 +186,7 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
         ide={ide}
         scopeStore={scopeStore}
         scopeEventEmitter={scopeEventEmitter}
+        unstableStore={unstableStore}
       >
         {children}
       </IdeProvider>

+ 2 - 2
services/web/frontend/js/features/ide-react/context/metadata-context.tsx

@@ -10,7 +10,7 @@ import {
 } from 'react'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { getJSON, postJSON } from '@/infrastructure/fetch-json'
 import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
 import { useEditorContext } from '@/shared/context/editor-context'
@@ -54,7 +54,7 @@ export const MetadataProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const { onlineUsersCount } = useOnlineUsersContext()
   const { permissionsLevel } = useEditorContext()
   const permissions = usePermissionsContext()
-  const { currentDocument } = useEditorManagerContext()
+  const { currentDocument } = useEditorOpenDocContext()
   const { showGenericMessageModal } = useModalsContext()
 
   const [documents, setDocuments] = useState<DocumentsMetadata>({})

+ 2 - 2
services/web/frontend/js/features/ide-react/context/online-users-context.tsx

@@ -18,7 +18,7 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { debugConsole } from '@/utils/debugging'
 import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
 import { getHueForUserId } from '@/shared/utils/colors'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 
 export type OnlineUser = {
   id: string
@@ -70,7 +70,7 @@ export const OnlineUsersProvider: FC<React.PropsWithChildren> = ({
 }) => {
   const { eventEmitter } = useIdeReactContext()
   const { socket } = useConnectionContext()
-  const { currentDocumentId } = useEditorManagerContext()
+  const { currentDocumentId } = useEditorOpenDocContext()
   const { fileTreeData } = useFileTreeData()
 
   const [onlineUsers, setOnlineUsers] = useState<Record<string, OnlineUser>>({})

+ 2 - 2
services/web/frontend/js/features/ide-react/context/outline-context.tsx

@@ -14,7 +14,7 @@ import * as eventTracking from '@/infrastructure/event-tracking'
 import { isValidTeXFile } from '@/main/is-valid-tex-file'
 import localStorage from '@/infrastructure/local-storage'
 import { useProjectContext } from '@/shared/context/project-context'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 
 export type PartialFlatOutline = {
   level: number
@@ -118,7 +118,7 @@ export const OutlineProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [flatOutline, currentlyHighlightedLine]
   )
 
-  const { openDocName } = useEditorManagerContext()
+  const { openDocName } = useEditorOpenDocContext()
   const isTexFile = useMemo(
     () => (openDocName ? isValidTeXFile(openDocName) : false),
     [openDocName]

+ 49 - 41
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -4,7 +4,9 @@ import { ConnectionProvider } from './connection-context'
 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 { 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'
 import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
@@ -39,7 +41,9 @@ export const ReactContextRoot: FC<
     DetachCompileProvider,
     DetachProvider,
     EditorManagerProvider,
+    EditorOpenDocProvider,
     EditorProvider,
+    EditorViewProvider,
     FileTreeDataProvider,
     FileTreeOpenProvider,
     FileTreePathProvider,
@@ -74,47 +78,51 @@ export const ReactContextRoot: FC<
               <Providers.UserSettingsProvider>
                 <Providers.ProjectProvider>
                   <Providers.SnapshotProvider>
-                    <Providers.FileTreeDataProvider>
-                      <Providers.FileTreePathProvider>
-                        <Providers.ReferencesProvider>
-                          <Providers.DetachProvider>
-                            <Providers.EditorProvider>
-                              <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.EditorProvider>
-                          </Providers.DetachProvider>
-                        </Providers.ReferencesProvider>
-                      </Providers.FileTreePathProvider>
-                    </Providers.FileTreeDataProvider>
+                    <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.DetachProvider>
                   </Providers.SnapshotProvider>
                 </Providers.ProjectProvider>
               </Providers.UserSettingsProvider>

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

@@ -6,8 +6,6 @@ export type EditorScopeValue = {
   showSymbolPalette: false
   toggleSymbolPalette: () => void
   sharejs_doc: DocumentContainer | null
-  open_doc_id: string | null
-  open_doc_name: string | null
   opening: boolean
   trackChanges: boolean
   wantTrackChanges: boolean
@@ -25,8 +23,6 @@ export function populateEditorScope(
     showSymbolPalette: false,
     toggleSymbolPalette: () => {},
     sharejs_doc: null,
-    open_doc_id: null,
-    open_doc_name: null,
     opening: true,
     trackChanges: false,
     wantTrackChanges: false,

+ 4 - 4
services/web/frontend/js/features/ide-redesign/components/editor.tsx

@@ -1,5 +1,5 @@
 import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane'
-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 { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
 import useScopeValue from '@/shared/hooks/use-scope-value'
@@ -14,16 +14,16 @@ import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-pal
 export const Editor = () => {
   const [editor] = useScopeValue<EditorScopeValue>('editor')
   const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
-  const { currentDocumentId } = useEditorManagerContext()
+  const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
 
   if (!currentDocumentId) {
     return null
   }
 
   const isLoading = Boolean(
-    (!editor.sharejs_doc || editor.opening) &&
+    (!currentDocument || editor.opening) &&
       !editor.error_state &&
-      editor.open_doc_id
+      currentDocumentId
   )
 
   return (

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-preview-provider.tsx

@@ -12,7 +12,7 @@ export const usePdfPreviewContext = () => {
   const context = useContext(PdfPreviewContext)
   if (!context) {
     throw new Error(
-      'usePdfPreviewContext is only avalable inside PdfPreviewProvider'
+      'usePdfPreviewContext is only available inside PdfPreviewProvider'
     )
   }
   return context

+ 3 - 2
services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts

@@ -13,6 +13,7 @@ import * as eventTracking from '../../../infrastructure/event-tracking'
 import { debugConsole } from '@/utils/debugging'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import { CursorPosition } from '@/features/ide-react/types/cursor-position'
 import { isValidTeXFile } from '@/main/is-valid-tex-file'
@@ -34,8 +35,8 @@ export default function useSynctex(): {
 
   const { selectedEntities } = useFileTreeData()
   const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
-  const { getCurrentDocumentId, openDocWithId, openDocName } =
-    useEditorManagerContext()
+  const { openDocName } = useEditorOpenDocContext()
+  const { getCurrentDocumentId, openDocWithId } = useEditorManagerContext()
 
   const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(
     () => {

+ 2 - 2
services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx

@@ -21,7 +21,7 @@ import { useIdeReactContext } from '@/features/ide-react/context/ide-react-conte
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { throttle } from 'lodash'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 
 export type Ranges = {
   docId: string
@@ -81,7 +81,7 @@ const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
 export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const view = useCodeMirrorViewContext()
   const { projectId } = useIdeReactContext()
-  const { currentDocument } = useEditorManagerContext()
+  const { currentDocument } = useEditorOpenDocContext()
   const { socket } = useConnectionContext()
   const [ranges, setRanges] = useState<Ranges | undefined>(() =>
     buildRanges(currentDocument)

+ 2 - 2
services/web/frontend/js/features/review-panel-new/context/threads-context.tsx

@@ -20,7 +20,7 @@ import { UserId } from '../../../../../types/user'
 import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
 import RangesTracker from '@overleaf/ranges-tracker'
 import { CommentOperation } from '../../../../../types/change'
-import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { debugConsole } from '@/utils/debugging'
 import { captureException } from '@/infrastructure/error-reporter'
@@ -50,7 +50,7 @@ const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
 
 export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const { _id: projectId } = useProjectContext()
-  const { currentDocument } = useEditorManagerContext()
+  const { currentDocument } = useEditorOpenDocContext()
   const { isRestrictedTokenMember } = useEditorContext()
 
   // const [error, setError] = useState<Error>()

+ 4 - 3
services/web/frontend/js/features/source-editor/components/codemirror-view.tsx

@@ -1,12 +1,12 @@
 import { memo, useCallback, useEffect } from 'react'
 import { useCodeMirrorViewContext } from './codemirror-context'
 import useCodeMirrorScope from '../hooks/use-codemirror-scope'
-import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
+import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
 
 function CodeMirrorView() {
   const view = useCodeMirrorViewContext()
 
-  const [, setView] = useScopeValueSetterOnly('editor.view')
+  const { setView } = useEditorViewContext()
 
   // append the editor view dom to the container node when mounted
   const containerRef = useCallback(
@@ -25,7 +25,8 @@ function CodeMirrorView() {
     }
   }, [view])
 
-  // add the editor view to the scope value store, so it can be accessed by external extensions
+  // Add the CodeMirror view to the editor view context so that it can be
+  // accessed outside the editor component
   useEffect(() => {
     setView(view)
   }, [setView, view])

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

@@ -4,12 +4,12 @@ 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 { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 
 function EditorSwitch() {
   const { t } = useTranslation()
   const [visual, setVisual] = useScopeValue('editor.showVisual')
-  const { openDocName } = useEditorManagerContext()
+  const { openDocName } = useEditorOpenDocContext()
 
   const richTextAvailable = openDocName ? isValidTeXFile(openDocName) : false
 

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

@@ -57,6 +57,7 @@ import {
 } 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'
 
 function useCodeMirrorScope(view: EditorView) {
   const { fileTreeData } = useFileTreeData()
@@ -68,8 +69,8 @@ function useCodeMirrorScope(view: EditorView) {
   const { logEntryAnnotations, editedSinceCompileStarted, compiling } =
     useCompileContext()
 
-  const { currentDocument, openDocName, trackChanges } =
-    useEditorManagerContext()
+  const { openDocName, currentDocument } = useEditorOpenDocContext()
+  const { trackChanges } = useEditorManagerContext()
   const metadata = useMetadataContext()
 
   const { id: userId } = useUserContext()

+ 3 - 1
services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx

@@ -6,6 +6,7 @@ import { useProjectContext } from '@/shared/context/project-context'
 import useAbortController from '@/shared/hooks/use-abort-controller'
 import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { debugConsole } from '@/utils/debugging'
 import { signalWithTimeout } from '@/utils/abort-signal'
@@ -20,7 +21,8 @@ export const WordCountClient: FC = () => {
   const [data, setData] = useState<WordCountData | null>(null)
   const { projectSnapshot, rootDocId } = useProjectContext()
   const { spellCheckLanguage } = useProjectSettingsContext()
-  const { openDocs, currentDocument } = useEditorManagerContext()
+  const { openDocs } = useEditorManagerContext()
+  const { currentDocument } = useEditorOpenDocContext()
   const { pathInFolder } = useFileTreePathContext()
 
   const { signal } = useAbortController()

+ 2 - 3
services/web/frontend/js/shared/context/file-tree-data-context.tsx

@@ -18,7 +18,7 @@ import {
 import { countFiles } from '../../features/file-tree/util/count-in-tree'
 import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
-import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { Folder } from '../../../../types/folder'
 import { Project } from '../../../../types/project'
 import { MainDocument } from '../../../../types/project-settings'
@@ -180,8 +180,7 @@ export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
   children,
 }) => {
   const [project] = useScopeValue<Project>('project')
-  const [currentDocumentId] = useScopeValue('editor.open_doc_id')
-  const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
+  const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext()
   const [permissionsLevel] = useScopeValue('permissionsLevel')
   const { fileTreeFromHistory, snapshot, snapshotVersion } =
     useSnapshotContext()

+ 8 - 6
services/web/frontend/js/shared/context/ide-context.tsx

@@ -10,6 +10,7 @@ export type Ide = {
 type IdeContextValue = Ide & {
   scopeStore: ScopeValueStore
   scopeEventEmitter: ScopeEventEmitter
+  unstableStore: ScopeValueStore
 }
 
 export const IdeContext = createContext<IdeContextValue | undefined>(undefined)
@@ -19,14 +20,14 @@ export const IdeProvider: FC<
     ide: Ide
     scopeStore: ScopeValueStore
     scopeEventEmitter: ScopeEventEmitter
+    unstableStore: ScopeValueStore
   }>
-> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
+> = ({ ide, scopeStore, scopeEventEmitter, unstableStore, children }) => {
   /**
-   * Expose scopeStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions.
+   * Expose unstableStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions.
    *
    * These properties are expected to be available:
    *   - `editor.view`
-   *   - `project.spellcheckLanguage`
    *   - `editor.open_doc_name`,
    *   - `editor.open_doc_id`,
    *   - `settings.theme`
@@ -40,18 +41,19 @@ export const IdeProvider: FC<
       ...window.overleaf,
       unstable: {
         ...window.overleaf?.unstable,
-        store: scopeStore,
+        store: unstableStore,
       },
     }
-  }, [scopeStore])
+  }, [unstableStore])
 
   const value = useMemo<IdeContextValue>(() => {
     return {
       ...ide,
       scopeStore,
       scopeEventEmitter,
+      unstableStore,
     }
-  }, [ide, scopeStore, scopeEventEmitter])
+  }, [ide, scopeStore, scopeEventEmitter, unstableStore])
 
   return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
 }

+ 3 - 1
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -39,6 +39,7 @@ import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { getJSON } from '@/infrastructure/fetch-json'
 import { CompileResponseData } from '../../../../types/compile'
 import {
@@ -127,7 +128,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   children,
 }) => {
   const { hasPremiumCompile, isProjectOwner } = useEditorContext()
-  const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
+  const { openDocWithId, openDocs } = useEditorManagerContext()
+  const { currentDocument } = useEditorOpenDocContext()
   const { role } = useDetachContext()
 
   const {

+ 5 - 14
services/web/frontend/js/shared/context/user-settings-context.tsx

@@ -9,11 +9,11 @@ import {
   useEffect,
 } from 'react'
 
-import { UserSettings, Keybindings } from '../../../../types/user-settings'
+import { UserSettings } from '../../../../types/user-settings'
 import getMeta from '@/utils/meta'
-import useScopeValue from '@/shared/hooks/use-scope-value'
 import { userStyles } from '../utils/styles'
 import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils'
+import { useIdeContext } from '@/shared/context/ide-context'
 
 const defaultSettings: UserSettings = {
   pdfViewer: 'pdfjs',
@@ -39,15 +39,6 @@ type UserSettingsContextValue = {
   >
 }
 
-type ScopeSettings = {
-  overallTheme: 'light' | 'dark'
-  keybindings: Keybindings
-  fontSize: number
-  fontFamily: string
-  lineHeight: number
-  isNewEditor: boolean
-}
-
 export const UserSettingsContext = createContext<
   UserSettingsContextValue | undefined
 >(undefined)
@@ -60,10 +51,10 @@ export const UserSettingsProvider: FC<React.PropsWithChildren> = ({
   )
 
   // update the global scope 'settings' value, for extensions
-  const [, setScopeSettings] = useScopeValue<ScopeSettings>('settings')
+  const { unstableStore } = useIdeContext()
   useEffect(() => {
     const { fontFamily, lineHeight } = userStyles(userSettings)
-    setScopeSettings({
+    unstableStore.set('settings', {
       overallTheme: userSettings.overallTheme === 'light-' ? 'light' : 'dark',
       keybindings: userSettings.mode === 'none' ? 'default' : userSettings.mode,
       fontFamily,
@@ -71,7 +62,7 @@ export const UserSettingsProvider: FC<React.PropsWithChildren> = ({
       fontSize: userSettings.fontSize,
       isNewEditor: canUseNewEditor() && userSettings.enableNewEditor,
     })
-  }, [setScopeSettings, userSettings])
+  }, [unstableStore, userSettings])
 
   const value = useMemo<UserSettingsContextValue>(
     () => ({

+ 25 - 0
services/web/frontend/js/shared/hooks/use-exposed-state.ts

@@ -0,0 +1,25 @@
+import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'
+import { useIdeContext } from '../context/ide-context'
+
+/**
+ * Creates a state variable that is exposed via window.overleaf.unstable.store,
+ * which is used by Writefull (and only Writefull). Once Writefull is integrated
+ * into our codebase, it should be able to hook directly into our React
+ * contexts and we would then be able to remove this hook, replacing it with
+ * useState.
+ */
+export default function useExposedState<T = any>(
+  initialState: T | (() => T),
+  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])
+
+  return [value, setValue]
+}

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

@@ -15,6 +15,7 @@ import {
   createReactScopeValueStore,
 } from '@/features/ide-react/context/ide-react-context'
 import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
+import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
 import { ConnectionContext } from '@/features/ide-react/context/connection-context'
 import { Socket } from '@/features/ide-react/connection/types/socket'
@@ -65,6 +66,8 @@ const initialScope = {
   },
   editor: {
     richText: false,
+
+    // FIXME: This is pretty useless because the editor relies on a much more fleshed-out document, so we rely on overriding it in individual stories
     sharejs_doc: {
       doc_id: 'test-doc',
       getSnapshot: () => 'some doc content',
@@ -195,12 +198,13 @@ const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
       scopeStore.set(key, value)
     }
     const scopeEventEmitter = new ReactScopeEventEmitter(new IdeEventEmitter())
+    const unstableStore = new ReactScopeValueStore()
 
     window.overleaf = {
       ...window.overleaf,
       unstable: {
         ...window.overleaf?.unstable,
-        store: scopeStore,
+        store: unstableStore,
       },
     }
 
@@ -208,6 +212,7 @@ const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
       socket,
       scopeStore,
       scopeEventEmitter,
+      unstableStore,
     }
   })
 

+ 21 - 3
services/web/frontend/stories/pdf-log-entry.stories.tsx

@@ -4,7 +4,7 @@ import { ruleIds } from '@/ide/human-readable-logs/HumanReadableLogsHints'
 import { ScopeDecorator } from './decorators/scope'
 import { useMeta } from './hooks/use-meta'
 import { FC, ReactNode } from 'react'
-import { useScope } from './hooks/use-scope'
+import { EditorViewContext } from '@/features/ide-react/context/editor-view-context'
 import { EditorView } from '@codemirror/view'
 import { LogEntry } from '@/features/pdf-preview/util/types'
 
@@ -58,12 +58,30 @@ export default meta
 
 type Story = StoryObj<typeof PdfLogEntry>
 
+const MockEditorViewProvider: FC<React.PropsWithChildren> = ({ children }) => {
+  const value = {
+    view: new EditorView({
+      doc: '\\begin{document',
+    }),
+    setView: () => {},
+  }
+
+  return (
+    <EditorViewContext.Provider value={value}>
+      {children}
+    </EditorViewContext.Provider>
+  )
+}
+
 const Provider: FC<React.PropsWithChildren<{ children: ReactNode }>> = ({
   children,
 }) => {
   useMeta({ 'ol-showAiErrorAssistant': true })
-  useScope({ 'editor.view': new EditorView({ doc: '\\begin{document' }) })
-  return <div className="logs-pane p-2">{children}</div>
+  return (
+    <MockEditorViewProvider>
+      <div className="logs-pane p-2">{children}</div>
+    </MockEditorViewProvider>
+  )
 }
 
 export const PdfLogEntryWithControls: Story = {

+ 202 - 88
services/web/frontend/stories/source-editor/source-editor.stories.tsx

@@ -2,9 +2,86 @@ import SourceEditor from '../../js/features/source-editor/components/source-edit
 import { ScopeDecorator } from '../decorators/scope'
 import { useScope } from '../hooks/use-scope'
 import { useMeta } from '../hooks/use-meta'
-import { FC } from 'react'
+import React, { FC, useState } from 'react'
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import RangesTracker from '@overleaf/ranges-tracker'
+import useExposedState from '@/shared/hooks/use-exposed-state'
+import { EditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
+import { DocId } from '../../../types/project-settings'
+import { StoryObj } from '@storybook/react'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+
+type Story = StoryObj<typeof SourceEditor>
+
+const EditorOpenDocProvider: FC<
+  React.PropsWithChildren<{
+    initialOpenDocName: string | null
+    initialDocument: DocumentContainer
+  }>
+> = ({ children, initialOpenDocName, initialDocument }) => {
+  const [currentDocumentId, setCurrentDocumentId] =
+    useExposedState<DocId | null>(null, 'editor.open_doc_id')
+  const [openDocName, setOpenDocName] = useExposedState<string | null>(
+    initialOpenDocName,
+    'editor.open_doc_name'
+  )
+  const [currentDocument, setCurrentDocument] =
+    useState<DocumentContainer | null>(initialDocument)
+
+  const value = {
+    currentDocumentId,
+    setCurrentDocumentId,
+    openDocName,
+    setOpenDocName,
+    currentDocument,
+    setCurrentDocument,
+  }
+
+  return (
+    <EditorOpenDocContext.Provider value={value}>
+      {children}
+    </EditorOpenDocContext.Provider>
+  )
+}
+
+const LatexEditorOpenDocProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => (
+  <EditorOpenDocProvider
+    initialOpenDocName="example.tex"
+    initialDocument={
+      mockDoc(content.tex, changes.tex) as unknown as DocumentContainer
+    }
+  >
+    {children}
+  </EditorOpenDocProvider>
+)
+
+const MarkdownEditorOpenDocProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => (
+  <EditorOpenDocProvider
+    initialOpenDocName="example.md"
+    initialDocument={
+      mockDoc(content.md, changes.md) as unknown as DocumentContainer
+    }
+  >
+    {children}
+  </EditorOpenDocProvider>
+)
+
+const BibtexEditorOpenDocProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => (
+  <EditorOpenDocProvider
+    initialOpenDocName="example.bib"
+    initialDocument={
+      mockDoc(content.bib, changes.bib) as unknown as DocumentContainer
+    }
+  >
+    {children}
+  </EditorOpenDocProvider>
+)
 
 const FileTreePathProvider: FC<React.PropsWithChildren> = ({ children }) => (
   <FileTreePathContext.Provider
@@ -29,11 +106,6 @@ export default {
   title: 'Editor / Source Editor',
   component: SourceEditor,
   decorators: [
-    (Story: any) =>
-      ScopeDecorator(Story, {
-        mockCompileOnLoad: true,
-        providers: { FileTreePathProvider },
-      }),
     (Story: any) => (
       <div style={{ height: '90vh' }}>
         <Story />
@@ -59,102 +131,144 @@ const permissions = {
   write: true,
 }
 
-export const Latex = (args: any, { globals: { theme } }: any) => {
-  // FIXME: useScope has no effect
-  useScope({
-    editor: {
-      sharejs_doc: mockDoc(content.tex, changes.tex),
-      open_doc_name: 'example.tex',
-    },
-    rootFolder: {
-      name: 'rootFolder',
-      id: 'root-folder-id',
-      type: 'folder',
-      children: [
-        {
-          name: 'example.tex.tex',
-          id: 'example-doc-id',
-          type: 'doc',
+export const Latex: Story = {
+  decorators: [
+    Story =>
+      ScopeDecorator(Story, {
+        mockCompileOnLoad: true,
+        providers: {
+          FileTreePathProvider,
+          EditorOpenDocProvider: LatexEditorOpenDocProvider,
+        },
+      }),
+
+    (Story, { globals }) => {
+      // FIXME: useScope has no effect
+      useScope({
+        rootFolder: {
+          name: 'rootFolder',
+          id: 'root-folder-id',
+          type: 'folder',
+          children: [
+            {
+              name: 'example.tex.tex',
+              id: 'example-doc-id',
+              type: 'doc',
+              selected: false,
+              $$hashKey: 'object:89',
+            },
+            {
+              name: 'frog.jpg',
+              id: 'frog-image-id',
+              type: 'file',
+              linkedFileData: null,
+              created: '2023-05-04T16:11:04.352Z',
+              $$hashKey: 'object:108',
+            },
+          ],
           selected: false,
-          $$hashKey: 'object:89',
         },
-        {
-          name: 'frog.jpg',
-          id: 'frog-image-id',
-          type: 'file',
-          linkedFileData: null,
-          created: '2023-05-04T16:11:04.352Z',
-          $$hashKey: 'object:108',
+        settings: {
+          ...settings,
+          overallTheme: globals.theme === 'default-' ? '' : globals.theme,
         },
-      ],
-      selected: false,
-    },
-    settings: {
-      ...settings,
-      overallTheme: theme === 'default-' ? '' : theme,
-    },
-    permissions,
-  })
+        permissions,
+      })
 
-  useMeta({
-    'ol-showSymbolPalette': true,
-  })
+      useMeta({
+        'ol-showSymbolPalette': true,
+      })
 
-  return <SourceEditor />
+      return <Story />
+    },
+  ],
 }
 
-export const Markdown = (args: any, { globals: { theme } }: any) => {
-  useScope({
-    editor: {
-      sharejs_doc: mockDoc(content.md, changes.md),
-      open_doc_name: 'example.md',
-    },
-    settings: {
-      ...settings,
-      overallTheme: theme === 'default-' ? '' : theme,
-    },
-    permissions,
-  })
+export const Markdown: Story = {
+  decorators: [
+    Story =>
+      ScopeDecorator(Story, {
+        mockCompileOnLoad: true,
+        providers: {
+          FileTreePathProvider,
+          EditorOpenDocProvider: MarkdownEditorOpenDocProvider,
+        },
+      }),
 
-  return <SourceEditor />
-}
+    (Story, { globals }) => {
+      // FIXME: useScope has no effect
+      useScope({
+        settings: {
+          ...settings,
+          overallTheme: globals.theme === 'default-' ? '' : globals.theme,
+        },
+        permissions,
+      })
 
-export const Visual = (args: any, { globals: { theme } }: any) => {
-  useScope({
-    editor: {
-      sharejs_doc: mockDoc(content.tex, changes.tex),
-      open_doc_name: 'example.tex',
-      showVisual: true,
+      return <Story />
     },
-    settings: {
-      ...settings,
-      overallTheme: theme === 'default-' ? '' : theme,
-    },
-    permissions,
-  })
-  useMeta({
-    'ol-showSymbolPalette': true,
-    'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
-    'ol-project_id': '63e21c07946dd8c76505f85a',
-  })
-
-  return <SourceEditor />
+  ],
 }
 
-export const Bibtex = (args: any, { globals: { theme } }: any) => {
-  useScope({
-    editor: {
-      sharejs_doc: mockDoc(content.bib, changes.bib),
-      open_doc_name: 'example.bib',
-    },
-    settings: {
-      ...settings,
-      overallTheme: theme === 'default-' ? '' : theme,
+export const Visual: Story = {
+  decorators: [
+    Story =>
+      ScopeDecorator(Story, {
+        mockCompileOnLoad: true,
+        providers: {
+          FileTreePathProvider,
+          EditorOpenDocProvider: LatexEditorOpenDocProvider,
+        },
+      }),
+
+    (Story, { globals }) => {
+      // FIXME: useScope has no effect, so this does not default to the visual editor
+      useScope({
+        editor: {
+          showVisual: true,
+        },
+        settings: {
+          ...settings,
+          overallTheme: globals.theme === 'default-' ? '' : globals.theme,
+        },
+        permissions,
+      })
+
+      useMeta({
+        'ol-showSymbolPalette': true,
+        'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
+        'ol-project_id': '63e21c07946dd8c76505f85a',
+      })
+
+      return <Story />
     },
-    permissions,
-  })
+  ],
+}
 
-  return <SourceEditor />
+export const Bibtex: Story = {
+  decorators: [
+    Story =>
+      ScopeDecorator(Story, {
+        mockCompileOnLoad: true,
+        providers: {
+          FileTreePathProvider,
+          EditorOpenDocProvider: BibtexEditorOpenDocProvider,
+        },
+      }),
+
+    (Story, { globals }) => {
+      // FIXME: useScope has no effect
+      useScope({
+        settings: {
+          ...settings,
+          overallTheme: globals.theme === 'default-' ? '' : globals.theme,
+        },
+        permissions,
+      })
+
+      return <Story />
+    },
+  ],
 }
 
 const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength

+ 30 - 11
services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx

@@ -11,6 +11,7 @@ import {
 import { EditorView } from '@codemirror/view'
 import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
 import { LogEntry } from '@/features/pdf-preview/util/types'
+import { EditorViewContext } from '@/features/ide-react/context/editor-view-context'
 
 describe('<PdfLogsEntries/>', function () {
   const fakeFindEntityResult: FindResult = {
@@ -48,6 +49,19 @@ describe('<PdfLogsEntries/>', function () {
     )
   }
 
+  const EditorViewProvider: FC<React.PropsWithChildren> = ({ children }) => {
+    const value = {
+      view: new EditorView({ doc: '\\documentclass{article}' }),
+      setView: cy.stub(),
+    }
+
+    return (
+      <EditorViewContext.Provider value={value}>
+        {children}
+      </EditorViewContext.Provider>
+    )
+  }
+
   const logEntries: LogEntry[] = [
     {
       file: 'main.tex',
@@ -62,10 +76,6 @@ describe('<PdfLogsEntries/>', function () {
     },
   ]
 
-  const scope = {
-    'editor.view': new EditorView({ doc: '\\documentclass{article}' }),
-  }
-
   beforeEach(function () {
     cy.interceptCompile()
     cy.interceptEvents()
@@ -73,7 +83,7 @@ describe('<PdfLogsEntries/>', function () {
 
   it('displays human readable hint', function () {
     cy.mount(
-      <EditorProviders scope={scope}>
+      <EditorProviders providers={{ EditorViewProvider }}>
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
     )
@@ -84,8 +94,11 @@ describe('<PdfLogsEntries/>', function () {
   it('opens doc on click', function () {
     cy.mount(
       <EditorProviders
-        scope={scope}
-        providers={{ EditorManagerProvider, FileTreePathProvider }}
+        providers={{
+          EditorManagerProvider,
+          FileTreePathProvider,
+          EditorViewProvider,
+        }}
       >
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
@@ -114,8 +127,11 @@ describe('<PdfLogsEntries/>', function () {
 
     cy.mount(
       <EditorProviders
-        scope={scope}
-        providers={{ EditorManagerProvider, FileTreePathProvider }}
+        providers={{
+          EditorManagerProvider,
+          FileTreePathProvider,
+          EditorViewProvider,
+        }}
       >
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>
@@ -154,8 +170,11 @@ describe('<PdfLogsEntries/>', function () {
 
     cy.mount(
       <EditorProviders
-        scope={scope}
-        providers={{ EditorManagerProvider, FileTreePathProvider }}
+        providers={{
+          EditorManagerProvider,
+          FileTreePathProvider,
+          EditorViewProvider,
+        }}
       >
         <PdfLogsEntries entries={logEntries} />
       </EditorProviders>

+ 38 - 10
services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx

@@ -3,7 +3,10 @@ import { cloneDeep } from 'lodash'
 import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
 import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
 import { useEffect } from 'react'
-import { EditorProviders } from '../../helpers/editor-providers'
+import {
+  EditorProviders,
+  makeEditorOpenDocProvider,
+} from '../../helpers/editor-providers'
 import { mockScope } from './scope'
 import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
 import { FindResult } from '@/features/file-tree/util/path'
@@ -73,6 +76,22 @@ const WithSelectedEntities = ({
   return null
 }
 
+function mockProviders() {
+  return {
+    EditorOpenDocProvider: makeEditorOpenDocProvider({
+      openDocName: 'main.tex',
+      currentDocument: {
+        doc_id: 'test-doc',
+        getSnapshot: () => 'some doc content',
+        hasBufferedOps: () => false,
+        on: () => {},
+        off: () => {},
+        leaveAndCleanUpPromise: () => Promise.resolve(),
+      },
+    }),
+  }
+}
+
 describe('<PdfSynctexControls/>', function () {
   beforeEach(function () {
     window.metaAttributesCache.set('ol-project_id', 'test-project')
@@ -84,9 +103,10 @@ describe('<PdfSynctexControls/>', function () {
     cy.interceptCompile()
 
     const scope = mockScope()
+    const providers = mockProviders()
 
     cy.mount(
-      <EditorProviders scope={scope}>
+      <EditorProviders scope={scope} providers={providers}>
         <WithPosition mockPosition={mockPosition} />
         <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
         <PdfSynctexControls />
@@ -145,9 +165,10 @@ describe('<PdfSynctexControls/>', function () {
     cy.interceptCompile()
 
     const scope = mockScope()
+    const providers = mockProviders()
 
     cy.mount(
-      <EditorProviders scope={scope}>
+      <EditorProviders scope={scope} providers={providers}>
         <WithPosition mockPosition={mockPosition} />
         <WithSelectedEntities
           mockSelectedEntities={
@@ -169,9 +190,10 @@ describe('<PdfSynctexControls/>', function () {
     cy.interceptCompile()
 
     const scope = mockScope()
+    const providers = mockProviders()
 
     cy.mount(
-      <EditorProviders scope={scope}>
+      <EditorProviders scope={scope} providers={providers}>
         <WithPosition mockPosition={mockPosition} />
         <WithSelectedEntities
           mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
@@ -196,9 +218,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <WithPosition mockPosition={mockPosition} />
           <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
@@ -218,9 +241,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <WithPosition mockPosition={mockPosition} />
           <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
@@ -279,9 +303,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <WithPosition mockPosition={mockPosition} />
           <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
           <PdfSynctexControls />
@@ -317,9 +342,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <WithPosition mockPosition={mockPosition} />
           <PdfSynctexControls />
         </EditorProviders>
@@ -338,9 +364,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <PdfSynctexControls />
         </EditorProviders>
       )
@@ -385,9 +412,10 @@ describe('<PdfSynctexControls/>', function () {
       cy.interceptCompile()
 
       const scope = mockScope()
+      const providers = mockProviders()
 
       cy.mount(
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} providers={providers}>
           <WithPosition mockPosition={mockPosition} />
           <PdfSynctexControls />
         </EditorProviders>

+ 0 - 1
services/web/test/frontend/components/pdf-preview/scope.tsx

@@ -6,7 +6,6 @@ export const mockScope = () => ({
     pdfViewer: 'pdfjs',
   },
   editor: {
-    open_doc_name: 'main.tex',
     sharejs_doc: {
       doc_id: 'test-doc',
       getSnapshot: () => 'some doc content',

+ 14 - 15
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

@@ -12,6 +12,7 @@ import {
   USER_ID,
 } from '../../../helpers/editor-providers'
 import { location } from '@/shared/components/location'
+import useScopeValue from '@/shared/hooks/use-scope-value'
 
 async function changePrivilegeLevel(screen, { current, next }) {
   const select = screen.getByDisplayValue(current)
@@ -820,7 +821,14 @@ describe('<ShareProjectModal/>', function () {
     fetchMock.get(`/project/${project._id}/tokens`, {})
     fetchMock.post('express:/project/:projectId/settings/admin', 204)
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+    let setPublicAccessLevel = function () {}
+
+    function WrappedModal() {
+      setPublicAccessLevel = useScopeValue('project.publicAccesLevel')[1]
+      return <ShareProjectModal {...modalProps} />
+    }
+
+    renderWithEditorContext(<WrappedModal />, {
       scope: {
         project: { ...project, publicAccesLevel: 'private' },
       },
@@ -839,13 +847,10 @@ describe('<ShareProjectModal/>', function () {
       publicAccessLevel: 'tokenBased',
     })
 
-    // NOTE: updating the scoped project data manually,
-    // as the project data is usually updated via the websocket connection
-    window.overleaf.unstable.store.set('project', {
-      ...project,
-      publicAccesLevel: 'tokenBased',
-    })
-    // watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' })
+    // NOTE: the project data is usually updated via the websocket connection
+    // but we can't do that so we're doing it via the scope value store (this
+    // will be via the project context when this value has been migrated)
+    setPublicAccessLevel('tokenBased')
 
     await screen.findByText('Link sharing is on')
     const disableButton = await screen.findByRole('button', {
@@ -859,13 +864,7 @@ describe('<ShareProjectModal/>', function () {
       publicAccessLevel: 'private',
     })
 
-    // NOTE: updating the scoped project data manually,
-    // as the project data is usually updated via the websocket connection
-    window.overleaf.unstable.store.set('project', {
-      ...project,
-      publicAccesLevel: 'private',
-    })
-    // watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
+    setPublicAccessLevel('private')
 
     await screen.findByText('Link sharing is off')
   })

+ 2 - 2
services/web/test/frontend/features/source-editor/helpers/mock-scope.ts

@@ -17,8 +17,8 @@ export const mockScope = (
   return {
     editor: {
       sharejs_doc: mockDoc(content, docOptions),
-      open_doc_name: 'test.tex',
-      open_doc_id: docId,
+      openDocName: 'test.tex',
+      currentDocumentId: docId,
       showVisual: false,
       wantTrackChanges: false,
     },

+ 52 - 3
services/web/test/frontend/helpers/editor-providers.jsx

@@ -9,12 +9,15 @@ import {
   IdeReactContext,
 } from '@/features/ide-react/context/ide-react-context'
 import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
+import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
 import { ConnectionContext } from '@/features/ide-react/context/connection-context'
+import { EditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import useDetachLayout from '@/shared/hooks/use-detach-layout'
 import { LayoutContext } from '@/shared/context/layout-context'
+import useExposedState from '@/shared/hooks/use-exposed-state'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -119,6 +122,8 @@ export function EditorProviders({
           off: () => {},
           leaveAndCleanUpPromise: async () => {},
         },
+        openDocName: null,
+        currentDocumentId: null,
       },
       project: {
         _id: projectId,
@@ -144,6 +149,11 @@ export function EditorProviders({
       providers={{
         ConnectionProvider: makeConnectionProvider(socket),
         IdeReactProvider: makeIdeReactProvider(scope, socket),
+        EditorOpenDocProvider: makeEditorOpenDocProvider({
+          openDocId: scope.editor.currentDocumentId,
+          openDocName: scope.editor.openDocName,
+          currentDocument: scope.editor.sharejs_doc,
+        }),
         LayoutProvider: makeLayoutProvider(layoutContext),
         ...providers,
       }}
@@ -202,15 +212,16 @@ const makeIdeReactProvider = (scope, socket) => {
         // TODO: path for nested entries
         scopeStore.set(key, value)
       }
-      scopeStore.set('editor.sharejs_doc', scope.editor.sharejs_doc)
       const scopeEventEmitter = new ReactScopeEventEmitter(
         new IdeEventEmitter()
       )
+      const unstableStore = new ReactScopeValueStore()
 
       return {
         socket,
         scopeStore,
         scopeEventEmitter,
+        unstableStore,
       }
     })
 
@@ -219,10 +230,10 @@ const makeIdeReactProvider = (scope, socket) => {
         ...window.overleaf,
         unstable: {
           ...window.overleaf?.unstable,
-          store: ideContextValue.scopeStore,
+          store: ideContextValue.unstableStore,
         },
       }
-    }, [ideContextValue.scopeStore])
+    }, [ideContextValue.unstableStore])
 
     return (
       <IdeReactContext.Provider value={ideReactContextValue}>
@@ -235,6 +246,44 @@ const makeIdeReactProvider = (scope, socket) => {
   return IdeReactProvider
 }
 
+export function makeEditorOpenDocProvider(initialValues) {
+  const {
+    currentDocumentId: initialCurrentDocumentId,
+    openDocName: initialOpenDocName,
+    currentDocument: initialCurrentDocument,
+  } = initialValues
+  const EditorOpenDocProvider = ({ children }) => {
+    const [currentDocumentId, setCurrentDocumentId] = useExposedState(
+      initialCurrentDocumentId,
+      'editor.open_doc_id'
+    )
+    const [openDocName, setOpenDocName] = useExposedState(
+      initialOpenDocName,
+      'editor.open_doc_name'
+    )
+    const [currentDocument, setCurrentDocument] = useState(
+      initialCurrentDocument
+    )
+
+    const value = {
+      currentDocumentId,
+      setCurrentDocumentId,
+      openDocName,
+      setOpenDocName,
+      currentDocument,
+      setCurrentDocument,
+    }
+
+    return (
+      <EditorOpenDocContext.Provider value={value}>
+        {children}
+      </EditorOpenDocContext.Provider>
+    )
+  }
+
+  return EditorOpenDocProvider
+}
+
 const makeLayoutProvider = layoutContextOverrides => {
   const layout = {
     ...layoutContextDefault,