From d617fd0754299defd19f25624f0ed5cbb015da54 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 9 Jul 2025 13:50:09 +0100 Subject: [PATCH] Add visual preview (#26947) GitOrigin-RevId: f77b59219909971b11416f196783b3ab7198ed91 --- .../src/Features/Project/ProjectController.js | 1 + .../pdf-preview/components/pdf-preview.tsx | 6 + .../components/pdf-synctex-controls.tsx | 6 + .../pdf-preview/components/visual-preview.tsx | 165 ++++++++++++++++++ .../components/codemirror-toolbar.tsx | 4 +- .../source-editor/extensions/language.ts | 2 +- .../source-editor/extensions/visual/visual.ts | 2 +- 7 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index cab5c3a230..fc65da7b44 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -334,6 +334,7 @@ const _ProjectController = { const splitTests = [ 'compile-log-events', + 'visual-preview', 'external-socket-heartbeat', 'null-test-share-modal', 'populate-clsi-cache', diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview.tsx index b0e692e201..e7757e99cd 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview.tsx @@ -3,10 +3,16 @@ import { memo } from 'react' import withErrorBoundary from '../../../infrastructure/error-boundary' import PdfPreviewErrorBoundaryFallback from './pdf-preview-error-boundary-fallback' import { useLayoutContext } from '../../../shared/context/layout-context' +import { VisualPreview } from './visual-preview' +import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context' +import { useFeatureFlag } from '@/shared/context/split-test-context' function PdfPreview() { const { detachRole } = useLayoutContext() + const { view } = useEditorViewContext() + const visualPreviewEnabled = useFeatureFlag('visual-preview') if (detachRole === 'detacher') return null + if (visualPreviewEnabled && view) return return } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx index 0167a98db1..aee9a12549 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx @@ -10,6 +10,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { Spinner } from 'react-bootstrap' import { Placement } from 'react-bootstrap/types' import useSynctex from '../hooks/use-synctex' +import { useFeatureFlag } from '@/shared/context/split-test-context' const GoToCodeButton = memo(function GoToCodeButton({ syncToCode, @@ -135,6 +136,11 @@ function PdfSynctexControls() { syncToPdfInFlight, canSyncToPdf, } = useSynctex() + const visualPreviewEnabled = useFeatureFlag('visual-preview') + + if (visualPreviewEnabled) { + return null + } if (!position) { return null diff --git a/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx b/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx new file mode 100644 index 0000000000..b166ca4563 --- /dev/null +++ b/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx @@ -0,0 +1,165 @@ +import { FC, useEffect, useRef, useState } from 'react' +import { + CodeMirrorStateContext, + CodeMirrorViewContext, +} from '@/features/source-editor/components/codemirror-context' +import { EditorView } from '@codemirror/view' +import { EditorState, StateEffect } from '@codemirror/state' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { docName } from '@/features/source-editor/extensions/doc-name' +import { + language, + Metadata, + setMetadata, +} from '@/features/source-editor/extensions/language' +import { showContentWhenParsed } from '@/features/source-editor/extensions/visual/visual' +import { usePhrases } from '@/features/source-editor/hooks/use-phrases' +import { + setEditorTheme, + theme, +} from '@/features/source-editor/extensions/theme' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { + visualHighlightStyle, + visualTheme, +} from '@/features/source-editor/extensions/visual/visual-theme' +import { tableGeneratorTheme } from '@/features/source-editor/extensions/visual/table-generator' +import { atomicDecorations } from '@/features/source-editor/extensions/visual/atomic-decorations' +import { markDecorations } from '@/features/source-editor/extensions/visual/mark-decorations' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { isValidTeXFile } from '@/main/is-valid-tex-file' +import { mousedown } from '@/features/source-editor/extensions/visual/selection' + +export const VisualPreview: FC<{ view: EditorView }> = ({ view }) => { + const [previewState, setPreviewState] = useState() + + const { fileTreeData } = useFileTreeData() + const { previewByPath } = useFileTreePathContext() + const { currentDocument, openDocName } = useEditorOpenDocContext() + const phrases = usePhrases() + + const isMountedRef = useIsMounted() + const viewRef = useRef() + const containerRef = useRef(null) + const previewByPathRef = useRef(previewByPath) + const metadataRef = useRef({ + labels: new Set(), + packageNames: new Set(), + referenceKeys: new Set(), + commands: [], + fileTreeData, + }) + + useEffect(() => { + if (!currentDocument) { + return + } + + const state = EditorState.create({ + doc: view.state.doc, + extensions: [ + EditorView.lineWrapping, + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorState.phrases.of(phrases), + docName('main.tex'), + language('main.tex', metadataRef.current, { syntaxValidation: false }), + theme({ + fontSize: 14, + fontFamily: 'monaco', + lineHeight: 'normal', + overallTheme: 'light-', + }), + EditorView.theme({ + '&.cm-editor': { + background: '#fff', + }, + '.ol-cm-preamble-wrapper, .ol-cm-end-document-widget': { + visibility: 'hidden', + }, + }), + visualTheme, + visualHighlightStyle, + tableGeneratorTheme, + mousedown, + atomicDecorations({ + previewByPath: previewByPathRef.current, + }), + markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets + showContentWhenParsed, + EditorView.contentAttributes.of({ 'aria-label': 'Visual preview' }), + ], + }) + + const preview = new EditorView({ + state, + dispatchTransactions(trs) { + preview.update(trs) + if (isMountedRef.current) { + setPreviewState(preview.state) + } + }, + scrollTo: EditorView.scrollIntoView(state.selection.main, { + y: 'center', + }), + }) + + setEditorTheme('overleaf').then(spec => { + preview.dispatch(spec) + }) + + containerRef.current?.replaceChildren(preview.dom) + + viewRef.current = preview + + view.dispatch({ + effects: StateEffect.appendConfig.of([ + EditorView.updateListener.of(update => { + if (update.docChanged) { + for (const tr of update.transactions) { + preview.dispatch({ + changes: tr.changes, + }) + } + } + + if (update.selectionSet) { + preview.dispatch({ + effects: EditorView.scrollIntoView(update.state.selection.main, { + y: 'center', + }), + }) + } + }), + ]), + }) + }, [phrases, view, currentDocument, isMountedRef]) + + useEffect(() => { + if (fileTreeData) { + metadataRef.current.fileTreeData = fileTreeData + window.setTimeout(() => { + viewRef.current?.dispatch(setMetadata(metadataRef.current)) + }) + } + }, [fileTreeData, view]) + + useEffect(() => { + return () => { + viewRef.current?.destroy() + } + }, [view]) + + if (!openDocName || !isValidTeXFile(openDocName)) { + return null + } + + return ( + + +
+ + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx index 5cf95763f4..b106968c60 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx @@ -26,6 +26,7 @@ import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor- import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs' import classNames from 'classnames' import { useUserSettingsContext } from '@/shared/context/user-settings-context' +import { useFeatureFlag } from '@/shared/context/split-test-context' export const CodeMirrorToolbar = () => { const view = useCodeMirrorViewContext() @@ -45,6 +46,7 @@ const Toolbar = memo(function Toolbar() { const { userSettings: { breadcrumbs }, } = useUserSettingsContext() + const visualPreviewEnabled = useFeatureFlag('visual-preview') const [overflowed, setOverflowed] = useState(false) @@ -157,7 +159,7 @@ const Toolbar = memo(function Toolbar() { className="ol-cm-toolbar toolbar-editor" ref={elementRef} > - + {!visualPreviewEnabled && } {showActions && ( packageNames: Set commands: Command[] diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts index af6d2e559a..f0e4a25ff7 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -94,7 +94,7 @@ const parsedAttributesConf = new Compartment() * A view plugin which shows the editor content, makes it focusable, * and restores the scroll position, once the initial decorations have been applied. */ -const showContentWhenParsed = [ +export const showContentWhenParsed = [ parsedAttributesConf.of([EditorView.editable.of(false)]), ViewPlugin.define(view => { const showContent = () => {