From 820a81efec3b8cb592ef44293fc9e6e922413985 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 13 Nov 2023 11:03:03 +0000 Subject: [PATCH] Merge pull request #15610 from overleaf/td-ide-page-editor-events React IDE page: hook up events GitOrigin-RevId: 1121a30755fc600023f06925ca3eafa7a8e1ee14 --- .../ide-react/components/layout/ide-page.tsx | 24 ++---- .../context/editor-manager-context.tsx | 37 ++++++++- .../ide-react/editor/types/editor-type.ts | 1 + .../hooks/use-editing-session-heartbeat.ts | 82 +++++++++++++++++++ .../ide-react/hooks/use-has-linting-error.ts | 12 +++ .../hooks/use-register-user-activity.ts | 10 +++ .../js/shared/hooks/use-dom-event-listener.ts | 19 +++++ 7 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/editor/types/editor-type.ts create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-has-linting-error.ts create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-register-user-activity.ts create mode 100644 services/web/frontend/js/shared/hooks/use-dom-event-listener.ts diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 37139c2ef8..101992a4d5 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/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 = ( diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index db42757589..dd61bcd445 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/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('editor.showVisual') - // eslint-disable-next-line no-unused-vars const [currentDocument, setCurrentDocument] = useScopeValue('editor.sharejs_doc') const [openDocId, setOpenDocId] = useScopeValue( @@ -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, diff --git a/services/web/frontend/js/features/ide-react/editor/types/editor-type.ts b/services/web/frontend/js/features/ide-react/editor/types/editor-type.ts new file mode 100644 index 0000000000..dc7dfdfe82 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/editor/types/editor-type.ts @@ -0,0 +1 @@ +export type EditorType = 'cm6' | 'cm6-rich-text' diff --git a/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts b/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts new file mode 100644 index 0000000000..9d17905215 --- /dev/null +++ b/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 = { + 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 +) { + 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) +} diff --git a/services/web/frontend/js/features/ide-react/hooks/use-has-linting-error.ts b/services/web/frontend/js/features/ide-react/hooks/use-has-linting-error.ts new file mode 100644 index 0000000000..266fc1fedb --- /dev/null +++ b/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) + }) +} diff --git a/services/web/frontend/js/features/ide-react/hooks/use-register-user-activity.ts b/services/web/frontend/js/features/ide-react/hooks/use-register-user-activity.ts new file mode 100644 index 0000000000..4014cc2804 --- /dev/null +++ b/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) +} diff --git a/services/web/frontend/js/shared/hooks/use-dom-event-listener.ts b/services/web/frontend/js/shared/hooks/use-dom-event-listener.ts new file mode 100644 index 0000000000..b1a6c6ca16 --- /dev/null +++ b/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]) +}