Sfoglia il codice sorgente

Merge pull request #15610 from overleaf/td-ide-page-editor-events

React IDE page: hook up events

GitOrigin-RevId: 1121a30755fc600023f06925ca3eafa7a8e1ee14
Tim Down 2 anni fa
parent
commit
1c820de200

+ 8 - 16
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -1,6 +1,5 @@
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
-import useEventListener from '@/shared/hooks/use-event-listener'
-import { useCallback, useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
 import { Alerts } from '@/features/ide-react/components/alerts/alerts'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import MainLayout from '@/features/ide-react/components/layout/main-layout'
@@ -12,12 +11,18 @@ import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-ev
 import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
 import { useOpenFile } from '@/features/ide-react/hooks/use-open-file'
+import { useEditingSessionHeartbeat } from '@/features/ide-react/hooks/use-editing-session-heartbeat'
+import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register-user-activity'
+import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
 
 // This is filled with placeholder content while the real content is migrated
 // away from Angular
 export default function IdePage() {
   useLayoutEventTracking()
   useSocketListeners()
+  useEditingSessionHeartbeat()
+  useRegisterUserActivity()
+  useHasLintingError()
 
   // This returns a function to open a binary file but for now we just use the
   // fact that it also patches in ide.binaryFilesManager. Once Angular is gone,
@@ -26,7 +31,7 @@ export default function IdePage() {
   useOpenFile()
 
   const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
-  const { connectionState, registerUserActivity } = useConnectionContext()
+  const { connectionState } = useConnectionContext()
   const { showLockEditorMessageModal } = useModalsContext()
 
   // Show modal when editor is forcefully disconnected
@@ -40,19 +45,6 @@ export default function IdePage() {
     showLockEditorMessageModal,
   ])
 
-  // Inform the connection manager when the user is active
-  const listener = useCallback(
-    () => registerUserActivity(),
-    [registerUserActivity]
-  )
-
-  useEventListener('cursor:editor:update', listener)
-
-  useEffect(() => {
-    document.body.addEventListener('click', listener)
-    return () => document.body.removeEventListener('click', listener)
-  }, [listener])
-
   const { chatIsOpen } = useLayoutContext()
 
   const mainContent = (

+ 33 - 4
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -28,6 +28,8 @@ import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
 import { useTranslation } from 'react-i18next'
 import customLocalStorage from '@/infrastructure/local-storage'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import { EditorType } from '@/features/ide-react/editor/types/editor-type'
 
 interface GotoOffsetOptions {
   gotoOffset: number
@@ -41,7 +43,7 @@ interface OpenDocOptions
 }
 
 type EditorManager = {
-  getEditorType: () => 'cm6' | 'cm6-rich-text' | null
+  getEditorType: () => EditorType | null
   showSymbolPalette: boolean
   currentDocument: Document
   currentDocumentId: string | null
@@ -113,7 +115,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   const ide = useIdeContext()
   const { projectId } = useIdeReactContext()
   const { reportError, eventEmitter, eventLog } = useIdeReactContext()
-  const { socket, disconnect } = useConnectionContext()
+  const { socket, disconnect, connectionState } = useConnectionContext()
   const { view, setView } = useLayoutContext()
   const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
     useModalsContext()
@@ -122,7 +124,6 @@ export const EditorManagerProvider: FC = ({ children }) => {
     'editor.showSymbolPalette'
   )
   const [showVisual] = useScopeValue<boolean>('editor.showVisual')
-  // eslint-disable-next-line no-unused-vars
   const [currentDocument, setCurrentDocument] =
     useScopeValue<Document>('editor.sharejs_doc')
   const [openDocId, setOpenDocId] = useScopeValue<string | null>(
@@ -207,7 +208,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
     })
   }, [ide.scopeStore, setShowSymbolPalette])
 
-  const getEditorType = useCallback(() => {
+  const getEditorType = useCallback((): EditorType | null => {
     if (!currentDocument) {
       return null
     }
@@ -581,6 +582,34 @@ export const EditorManagerProvider: FC = ({ children }) => {
     t,
   ])
 
+  useEventListener('editor:insert-symbol', () => {
+    sendMB('symbol-palette-insert')
+  })
+
+  useEventListener('flush-changes', () => {
+    openDocs.flushAll()
+  })
+
+  useEventListener('blur', () => {
+    openDocs.flushAll()
+  })
+
+  // Flush changes before disconnecting
+  useEffect(() => {
+    if (connectionState.forceDisconnected) {
+      openDocs.flushAll()
+    }
+  }, [connectionState.forceDisconnected, openDocs])
+
+  // Watch for changes in wantTrackChanges
+  const previousWantTrackChangesRef = useRef(wantTrackChanges)
+  useEffect(() => {
+    if (wantTrackChanges !== previousWantTrackChangesRef.current) {
+      previousWantTrackChangesRef.current = wantTrackChanges
+      syncTrackChangesState(currentDocument)
+    }
+  }, [currentDocument, syncTrackChangesState, wantTrackChanges])
+
   const editorManager = useMemo(
     () => ({
       getEditorType,

+ 1 - 0
services/web/frontend/js/features/ide-react/editor/types/editor-type.ts

@@ -0,0 +1 @@
+export type EditorType = 'cm6' | 'cm6-rich-text'

+ 82 - 0
services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts

@@ -0,0 +1,82 @@
+import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { EditorType } from '@/features/ide-react/editor/types/editor-type'
+import { reportCM6Perf } from '@/infrastructure/cm6-performance'
+import { putJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import moment from 'moment'
+import { useCallback, useState } from 'react'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
+
+function createEditingSessionHeartbeatData(editorType: EditorType) {
+  const segmentation: Record<string, unknown> = {
+    editorType,
+  }
+  const cm6PerfData = reportCM6Perf()
+
+  // Ignore if no typing has happened
+  if (cm6PerfData.numberOfEntries > 0) {
+    for (const [key, value] of Object.entries(cm6PerfData)) {
+      const segmentationPropName =
+        'cm6Perf' + key.charAt(0).toUpperCase() + key.slice(1)
+      if (value !== null) {
+        segmentation[segmentationPropName] = value
+      }
+    }
+  }
+
+  return segmentation
+}
+
+function sendEditingSessionHeartbeat(
+  projectId: string,
+  segmentation: Record<string, unknown>
+) {
+  putJSON(`/editingSession/${projectId}`, {
+    body: { segmentation },
+  }).catch(debugConsole.error)
+}
+
+export function useEditingSessionHeartbeat() {
+  const { projectId } = useIdeReactContext()
+  const { getEditorType } = useEditorManagerContext()
+
+  // Keep track of how many heartbeats we've sent so that we can calculate how
+  // long wait until the next one
+  const [heartbeatsSent, setHeartbeatsSent] = useState(0)
+  const [nextHeartbeatAt, setNextHeartbeatAt] = useState(() => new Date())
+
+  const editingSessionHeartbeat = useCallback(() => {
+    debugConsole.log('[Event] heartbeat trigger')
+
+    const editorType = getEditorType()
+    if (editorType === null) return
+
+    // If the next heartbeat is in the future, stop
+    if (nextHeartbeatAt > new Date()) return
+
+    const segmentation = createEditingSessionHeartbeatData(editorType)
+
+    debugConsole.log('[Event] send heartbeat request', segmentation)
+    sendEditingSessionHeartbeat(projectId, segmentation)
+
+    setHeartbeatsSent(heartbeatsSent => heartbeatsSent + 1)
+
+    // Send two first heartbeats at 0 and 30s then increase the backoff time
+    // 1min per call until we reach 5 min
+    const backoffSecs =
+      heartbeatsSent <= 2
+        ? 30
+        : heartbeatsSent <= 6
+        ? (heartbeatsSent - 2) * 60
+        : 300
+
+    setNextHeartbeatAt(moment().add(backoffSecs, 'seconds').toDate())
+  }, [getEditorType, heartbeatsSent, nextHeartbeatAt, projectId])
+
+  // Hook the heartbeat up to editor events
+  useEventListener('cursor:editor:update', editingSessionHeartbeat)
+  useEventListener('scroll:editor:update', editingSessionHeartbeat)
+  useDomEventListener(document, 'click', editingSessionHeartbeat)
+}

+ 12 - 0
services/web/frontend/js/features/ide-react/hooks/use-has-linting-error.ts

@@ -0,0 +1,12 @@
+import useEventListener from '@/shared/hooks/use-event-listener'
+import { useLocalCompileContext } from '@/shared/context/local-compile-context'
+
+export function useHasLintingError() {
+  const { setHasLintingError } = useLocalCompileContext()
+
+  // Listen for editor:lint event from CM6 linter and keep compile context
+  // up to date
+  useEventListener('editor:lint', (event: CustomEvent) => {
+    setHasLintingError(event.detail.hasLintingError)
+  })
+}

+ 10 - 0
services/web/frontend/js/features/ide-react/hooks/use-register-user-activity.ts

@@ -0,0 +1,10 @@
+import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
+
+export function useRegisterUserActivity() {
+  const { registerUserActivity } = useConnectionContext()
+
+  useEventListener('cursor:editor:update', registerUserActivity)
+  useDomEventListener(document.body, 'click', registerUserActivity)
+}

+ 19 - 0
services/web/frontend/js/shared/hooks/use-dom-event-listener.ts

@@ -0,0 +1,19 @@
+import { useEffect } from 'react'
+
+// The use of the EventListener type means that this can only be used for
+// built-in DOM event types rather than custom events.
+// There are libraries such as usehooks-ts that provide hooks like this with
+// support for type-safe custom events that we may want to look into.
+export default function useDomEventListener(
+  eventTarget: EventTarget,
+  eventName: string,
+  listener: EventListener
+) {
+  useEffect(() => {
+    eventTarget.addEventListener(eventName, listener)
+
+    return () => {
+      eventTarget.removeEventListener(eventName, listener)
+    }
+  }, [eventTarget, eventName, listener])
+}