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