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

Move scope values starting with `editor.` to contexts

GitOrigin-RevId: 7ca349ceff002228cf4e931c644c8c386eb6c597
This commit is contained in:
Tim Down
2025-07-08 09:07:50 +01:00
committed by Copybot
parent 25848ee7a4
commit 3b32c81410
40 changed files with 593 additions and 292 deletions

View File

@@ -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

View File

@@ -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,
]
)

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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>

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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<

View File

@@ -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>) => {

View File

@@ -20,7 +20,7 @@ export function LeaversSurveyAlert() {
const [hide, setHide] = usePersistedState(
'hideInstitutionalLeaversSurvey',
false,
true
{ listen: true }
)
function handleDismiss() {

View File

@@ -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 } =

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
import { StateEffect } from '@codemirror/state'
export const beforeChangeDocEffect = StateEffect.define()

View File

@@ -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,

View File

@@ -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,
})
)
}
}
}
},
}
}),

View File

@@ -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,
}),

View File

@@ -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,

View File

@@ -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

View File

@@ -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]
}

View File

@@ -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]
}

View File

@@ -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])
}

View File

@@ -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)
}

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
})
})
})

View File

@@ -19,7 +19,6 @@ export const mockScope = (
sharejs_doc: mockDoc(content, docOptions),
openDocName: 'test.tex',
currentDocumentId: docId,
showVisual: false,
wantTrackChanges: false,
},
project: {

View File

@@ -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
}

View File

@@ -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')