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 index c5d93598b7..2095f439ca 100644 --- a/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx @@ -7,6 +7,10 @@ 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 { + filePreview, + setFilePreview, +} from '@/features/source-editor/extensions/file-preview' import { language, Metadata, @@ -88,9 +92,8 @@ export const VisualPreview: FC<{ view: EditorView }> = ({ view }) => { visualHighlightStyle, tableGeneratorTheme, mousedown, - atomicDecorations({ - previewByPath: previewByPathRef.current, - }), + filePreview(previewByPathRef.current), + atomicDecorations, markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets showContentWhenParsed, EditorView.contentAttributes.of({ 'aria-label': 'Visual preview' }), @@ -150,6 +153,16 @@ export const VisualPreview: FC<{ view: EditorView }> = ({ view }) => { } }, [fileTreeData, view]) + // The ref is what the preview state is built from, so keep it current for the + // next time the state is created, and push the new resolver into the existing + // state so later decoration rebuilds use it. + useEffect(() => { + previewByPathRef.current = previewByPath + window.setTimeout(() => { + viewRef.current?.dispatch(setFilePreview(previewByPath)) + }) + }, [previewByPath]) + useEffect(() => { return () => { viewRef.current?.destroy() diff --git a/services/web/frontend/js/features/source-editor/extensions/file-preview.ts b/services/web/frontend/js/features/source-editor/extensions/file-preview.ts new file mode 100644 index 0000000000..316807eace --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/file-preview.ts @@ -0,0 +1,30 @@ +import { + Compartment, + Extension, + Facet, + TransactionSpec, +} from '@codemirror/state' +import { PreviewPath } from '../../../../../types/preview-path' + +export type PreviewByPath = (path: string) => PreviewPath | null + +const previewByPathConf = new Compartment() + +/** + * Resolves a path in the project to a preview URL. The value comes from the + * file tree, so it changes whenever the file tree does. Read it from the state + * at the point of use rather than capturing it, so decorations and widgets + * don't hold a resolver for a stale file tree. + */ +export const previewByPathFacet = Facet.define({ + combine: values => values[0] ?? (() => null), +}) + +export const filePreview = (previewByPath: PreviewByPath): Extension => + previewByPathConf.of(previewByPathFacet.of(previewByPath)) + +export const setFilePreview = ( + previewByPath: PreviewByPath +): TransactionSpec => ({ + effects: previewByPathConf.reconfigure(previewByPathFacet.of(previewByPath)), +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index df3ee6a051..47905f2a5c 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -48,6 +48,7 @@ import { toolbarPanel } from './toolbar/toolbar-panel' import { breadcrumbPanel } from './breadcrumbs-panel' import { geometryChangeEvent } from './geometry-change-event' import { docName } from './doc-name' +import { filePreview } from './file-preview' import { fileTreeItemDrop } from './file-tree-item-drop' import { mathPreview } from './math-preview' import { ranges } from './ranges' @@ -66,7 +67,7 @@ const moduleExtensions: Array<(options: Record) => Extension> = export const createExtensions = (options: Record): Extension[] => [ lineNumbers(), - highlightSpecialChars(options.visual.visual), + highlightSpecialChars(options.showVisual), // The built-in extension that manages the history stack, // configured to increase the maximum delay between adjacent grouped edits history({ newGroupDelay: 250 }), @@ -85,13 +86,13 @@ export const createExtensions = (options: Record): Extension[] => [ // A built-in extension that enables soft line wrapping. EditorView.lineWrapping, sourceOnly( - options.visual.visual, + options.showVisual, EditorView.contentAttributes.of({ 'aria-label': 'Source Editor editing' }) ), // A built-in extension that re-indents input if the language defines an indentOnInput field in its language data. indentOnInput(), - lineWrappingIndentation(options.visual.visual), - indentationMarkers(options.visual.visual), + lineWrappingIndentation(options.showVisual), + indentationMarkers(options.showVisual), bracketMatching(), bracketSelection(), // A built-in extension that enables rectangular selections, created by dragging a new selection while holding down Alt. @@ -133,6 +134,7 @@ export const createExtensions = (options: Record): Extension[] => [ keybindings(), docName(options.docName), + filePreview(options.previewByPath), // NOTE: `annotations` needs to be before `language` annotations(), @@ -141,7 +143,7 @@ export const createExtensions = (options: Record): Extension[] => [ theme(options.theme), realtime(options.currentDoc, options.handleError), cursorPosition(options.currentDoc), - scrollPosition(options.currentDoc, options.visual), + scrollPosition(options.currentDoc, options.showVisual), cursorHighlights(), autoPair(options.settings), editable(), @@ -157,17 +159,17 @@ export const createExtensions = (options: Record): Extension[] => [ ? historyOT(options.currentDoc.currentDocument) : ranges(), trackDetachedComments(options.currentDoc), - visual(options.docName, options.visual), + visual(options.docName, options.showVisual), mathPreview(options.settings.mathPreview), reviewTooltip(options.settings.floatingMenu), contextMenu(), toolbarPanel(), breadcrumbPanel(), verticalOverflow(), - highlightActiveLine(options.visual.visual), + highlightActiveLine(options.showVisual), // The built-in extension that highlights the active line in the gutter. highlightActiveLineGutter(), - inlineBackground(options.visual.visual), + inlineBackground(options.showVisual), codemirrorDevTools(), // Send exceptions to Sentry EditorView.exceptionSink.of(options.handleException), diff --git a/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts b/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts index b4966d353f..0133603309 100644 --- a/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts +++ b/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts @@ -29,7 +29,7 @@ export const scrollPosition = ( }: { currentDoc: { doc_id: string } }, - { visual }: { visual: boolean } + visual: boolean ) => { // store lineInfo for use on unload, when the DOM has already been unmounted let lineInfo: LineInfo diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts index 31c261fe59..463cd2507a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts @@ -1,4 +1,4 @@ -import { EditorState, Range, StateField } from '@codemirror/state' +import { EditorState, Extension, Range, StateField } from '@codemirror/state' import { Decoration, DecorationSet, @@ -69,7 +69,6 @@ import { skipPreambleWithCursor } from './skip-preamble-cursor' import { TableRenderingErrorWidget } from './visual-widgets/table-rendering-error' import { GraphicsWidget } from './visual-widgets/graphics' import { InlineGraphicsWidget } from './visual-widgets/inline-graphics' -import { PreviewPath } from '../../../../../../types/preview-path' import { selectDecoratedArgument } from './select-decorated-argument' import { generateTable, @@ -87,10 +86,7 @@ import { parseMathContainer, } from '../../utils/tree-operations/math' import { lineContainsOnlyNode } from './utils/line' - -type Options = { - previewByPath: (path: string) => PreviewPath | null -} +import { previewByPathFacet } from '../file-preview' function shouldDecorate( state: EditorState, @@ -149,368 +145,393 @@ function decorateArgumentBraces( const hasClosingBrace = (node: SyntaxNode) => node.getChild('EnvNameGroup')?.getChild('CloseBrace') -/** - * A state field that decorates ranges of text (including multiple lines) with Widget or Line decorations. - * Atomic decorations replace a range of content with an uneditable widget. - * Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin. - */ -export const atomicDecorations = (options: Options) => { - const { previewByPath } = options - const createDecorations = ( - state: EditorState, - tree: Tree - ): { decorations: DecorationSet; preamble: Preamble } => { - const decorations: Range[] = [] +const createDecorations = ( + state: EditorState, + tree: Tree +): { decorations: DecorationSet; preamble: Preamble } => { + const previewByPath = state.facet(previewByPathFacet) + const decorations: Range[] = [] - const listEnvironmentStack: ListEnvironmentName[] = [] - let currentListEnvironment: ListEnvironmentName | undefined + const listEnvironmentStack: ListEnvironmentName[] = [] + let currentListEnvironment: ListEnvironmentName | undefined - const ordinalStack: number[] = [] - let currentOrdinal = 0 + const ordinalStack: number[] = [] + let currentOrdinal = 0 - let listDepth = 0 + let listDepth = 0 - const theoremEnvironments = new Map([ - ['theorem', 'Theorem'], - ['corollary', 'Corollary'], - ['lemma', 'lemma'], - ['proof', 'Proof'], - ]) + const theoremEnvironments = new Map([ + ['theorem', 'Theorem'], + ['corollary', 'Corollary'], + ['lemma', 'lemma'], + ['proof', 'Proof'], + ]) - let commandDefinitions = '' + let commandDefinitions = '' - const preamble: Preamble = { from: 0, to: 0, authors: [] } + const preamble: Preamble = { from: 0, to: 0, authors: [] } - const startListEnvironment = (envName: ListEnvironmentName) => { - if (currentListEnvironment) { - listEnvironmentStack.push(currentListEnvironment) - ordinalStack.push(currentOrdinal) - } - currentListEnvironment = envName - currentOrdinal = 0 + const startListEnvironment = (envName: ListEnvironmentName) => { + if (currentListEnvironment) { + listEnvironmentStack.push(currentListEnvironment) + ordinalStack.push(currentOrdinal) } + currentListEnvironment = envName + currentOrdinal = 0 + } - const endListEnvironment = () => { - currentListEnvironment = listEnvironmentStack.pop() - currentOrdinal = ordinalStack.pop() ?? 0 - } + const endListEnvironment = () => { + currentListEnvironment = listEnvironmentStack.pop() + currentOrdinal = ordinalStack.pop() ?? 0 + } - let seenDocumentEnvironment = false + let seenDocumentEnvironment = false - tree.iterate({ - enter(nodeRef) { - if (nodeRef.node.type.is('Maketitle')) { - // end the preamble at \maketitle, if it's directly inside the document environment - const parentEnvironment = ancestorOfNodeWithType( - nodeRef.node, - '$Environment' - ) - if (parentEnvironment?.type.is('DocumentEnvironment')) { - preamble.to = nodeRef.node.from - } - } else if (nodeRef.node.type.is('DocumentEnvironment')) { - // only count the first instance of DocumentEnvironment - if (!seenDocumentEnvironment) { - preamble.to = - nodeRef.node.getChild('Content')?.from ?? nodeRef.node.from - seenDocumentEnvironment = true - } - } else if (nodeRef.node.type.is('Title')) { - const node = nodeRef.node.getChild('TextArgument') - if (node) { - const content = state.sliceDoc(node.from, node.to) - preamble.title = { node, content } - preamble.to = nodeRef.node.to - } - } else if (nodeRef.node.type.is('Author')) { - const node = nodeRef.node.getChild('TextArgument') - if (node) { - const content = state.sliceDoc(node.from, node.to) - preamble.authors.push({ node, content }) - preamble.to = nodeRef.node.to - } - } else if ( - nodeRef.node.type.is('Affil') || - nodeRef.node.type.is('Affiliation') - ) { - const node = nodeRef.node.getChild('TextArgument') - if (node) { - preamble.to = nodeRef.node.to - } + tree.iterate({ + enter(nodeRef) { + if (nodeRef.node.type.is('Maketitle')) { + // end the preamble at \maketitle, if it's directly inside the document environment + const parentEnvironment = ancestorOfNodeWithType( + nodeRef.node, + '$Environment' + ) + if (parentEnvironment?.type.is('DocumentEnvironment')) { + preamble.to = nodeRef.node.from } + } else if (nodeRef.node.type.is('DocumentEnvironment')) { + // only count the first instance of DocumentEnvironment + if (!seenDocumentEnvironment) { + preamble.to = + nodeRef.node.getChild('Content')?.from ?? nodeRef.node.from + seenDocumentEnvironment = true + } + } else if (nodeRef.node.type.is('Title')) { + const node = nodeRef.node.getChild('TextArgument') + if (node) { + const content = state.sliceDoc(node.from, node.to) + preamble.title = { node, content } + preamble.to = nodeRef.node.to + } + } else if (nodeRef.node.type.is('Author')) { + const node = nodeRef.node.getChild('TextArgument') + if (node) { + const content = state.sliceDoc(node.from, node.to) + preamble.authors.push({ node, content }) + preamble.to = nodeRef.node.to + } + } else if ( + nodeRef.node.type.is('Affil') || + nodeRef.node.type.is('Affiliation') + ) { + const node = nodeRef.node.getChild('TextArgument') + if (node) { + preamble.to = nodeRef.node.to + } + } - if (nodeRef.type.is('$Environment')) { - const envName = getUnstarredEnvironmentName(nodeRef.node, state) - const hideInEnvironmentTypes = [ - 'figure', - 'table', - 'verbatim', - 'lstlisting', - 'quote', - 'quotation', - 'quoting', - 'displayquote', - ] - if (envName && hideInEnvironmentTypes.includes(envName)) { - const beginNode = nodeRef.node.getChild('BeginEnv') - const endNode = nodeRef.node.getChild('EndEnv') - if ( - beginNode && - endNode && - hasClosingBrace(beginNode) && - hasClosingBrace(endNode) - ) { - const beginLine = state.doc.lineAt(beginNode.from) - const endLine = state.doc.lineAt(endNode.from) + if (nodeRef.type.is('$Environment')) { + const envName = getUnstarredEnvironmentName(nodeRef.node, state) + const hideInEnvironmentTypes = [ + 'figure', + 'table', + 'verbatim', + 'lstlisting', + 'quote', + 'quotation', + 'quoting', + 'displayquote', + ] + if (envName && hideInEnvironmentTypes.includes(envName)) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) - const begin = { - from: beginLine.from, - to: extendForwardsOverEmptyLines(state.doc, beginLine), - } - const end = { - from: extendBackwardsOverEmptyLines(state.doc, endLine), - to: endLine.to, - } + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } + + if (shouldDecorate(state, { from: begin.from, to: end.to })) { + decorations.push( + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'begin'), + block: true, + }).range(begin.from, begin.to), + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'end'), + block: true, + }).range(end.from, end.to) + ) + + const centeringNode = centeringNodeForEnvironment(nodeRef) + + if (centeringNode) { + const line = state.doc.lineAt(centeringNode.from) + const from = extendBackwardsOverEmptyLines(state.doc, line) + const to = extendForwardsOverEmptyLines(state.doc, line) - if (shouldDecorate(state, { from: begin.from, to: end.to })) { decorations.push( Decoration.replace({ - widget: new EnvironmentLineWidget(envName, 'begin'), block: true, - }).range(begin.from, begin.to), + }).range(from, to) + ) + } + } + } + } else if (nodeRef.type.is('ListEnvironment')) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) + + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } + + if ( + !selectionIntersects(state.selection, begin) && + !selectionIntersects(state.selection, end) && + getListItems(nodeRef.node).length > 0 // not empty + ) { + if (lineContainsOnlyNode(beginLine, beginNode)) { + decorations.push( + Decoration.replace({ + block: true, + }).range(begin.from, begin.to) + ) + } + if (lineContainsOnlyNode(endLine, endNode)) { + decorations.push( Decoration.replace({ - widget: new EnvironmentLineWidget(envName, 'end'), block: true, }).range(end.from, end.to) ) - - const centeringNode = centeringNodeForEnvironment(nodeRef) - - if (centeringNode) { - const line = state.doc.lineAt(centeringNode.from) - const from = extendBackwardsOverEmptyLines(state.doc, line) - const to = extendForwardsOverEmptyLines(state.doc, line) - - decorations.push( - Decoration.replace({ - block: true, - }).range(from, to) - ) - } - } - } - } else if (nodeRef.type.is('ListEnvironment')) { - const beginNode = nodeRef.node.getChild('BeginEnv') - const endNode = nodeRef.node.getChild('EndEnv') - - if ( - beginNode && - endNode && - hasClosingBrace(beginNode) && - hasClosingBrace(endNode) - ) { - const beginLine = state.doc.lineAt(beginNode.from) - const endLine = state.doc.lineAt(endNode.from) - - const begin = { - from: beginLine.from, - to: extendForwardsOverEmptyLines(state.doc, beginLine), - } - const end = { - from: extendBackwardsOverEmptyLines(state.doc, endLine), - to: endLine.to, - } - - if ( - !selectionIntersects(state.selection, begin) && - !selectionIntersects(state.selection, end) && - getListItems(nodeRef.node).length > 0 // not empty - ) { - if (lineContainsOnlyNode(beginLine, beginNode)) { - decorations.push( - Decoration.replace({ - block: true, - }).range(begin.from, begin.to) - ) - } - if (lineContainsOnlyNode(endLine, endNode)) { - decorations.push( - Decoration.replace({ - block: true, - }).range(end.from, end.to) - ) - } - } - } - } else if (nodeRef.type.is('TabularEnvironment')) { - if (shouldDecorate(state, nodeRef)) { - const tabularNode = nodeRef.node - const tableNode = ancestorOfNodeWithType( - tabularNode, - 'TableEnvironment' - ) - const directChild = isDirectChildOfEnvironment( - tabularNode.parent, - tableNode - ) - - let parsedTableData: ParsedTableData | null = null - let validTable = false - try { - parsedTableData = generateTable(tabularNode, state) - validTable = validateParsedTable(parsedTableData) - } catch (e) { - debugConsole.error(e) - } - - if (parsedTableData && validTable) { - decorations.push( - Decoration.replace({ - widget: new TabularWidget( - parsedTableData, - tabularNode, - state.doc.sliceString( - (tableNode ?? tabularNode).from, - (tableNode ?? tabularNode).to - ), - tableNode, - directChild - ), - block: true, - }).range(nodeRef.from, nodeRef.to) - ) - return false - } else { - // Show error message - decorations.push( - Decoration.widget({ - widget: new TableRenderingErrorWidget(tableNode), - block: true, - }).range(nodeRef.from, nodeRef.from) - ) } } } - } else if (nodeRef.type.is('BeginEnv')) { - // the beginning of an environment, with an environment name argument - const envName = getUnstarredEnvironmentName(nodeRef.node, state) + } else if (nodeRef.type.is('TabularEnvironment')) { + if (shouldDecorate(state, nodeRef)) { + const tabularNode = nodeRef.node + const tableNode = ancestorOfNodeWithType( + tabularNode, + 'TableEnvironment' + ) + const directChild = isDirectChildOfEnvironment( + tabularNode.parent, + tableNode + ) - if (envName) { - switch (envName) { - case 'itemize': - case 'enumerate': - case 'description': - startListEnvironment(envName) - listDepth++ - break + let parsedTableData: ParsedTableData | null = null + let validTable = false + try { + parsedTableData = generateTable(tabularNode, state) + validTable = validateParsedTable(parsedTableData) + } catch (e) { + debugConsole.error(e) + } + + if (parsedTableData && validTable) { + decorations.push( + Decoration.replace({ + widget: new TabularWidget( + parsedTableData, + tabularNode, + state.doc.sliceString( + (tableNode ?? tabularNode).from, + (tableNode ?? tabularNode).to + ), + tableNode, + directChild + ), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + return false + } else { + // Show error message + decorations.push( + Decoration.widget({ + widget: new TableRenderingErrorWidget(tableNode), + block: true, + }).range(nodeRef.from, nodeRef.from) + ) + } + } + } + } else if (nodeRef.type.is('BeginEnv')) { + // the beginning of an environment, with an environment name argument + const envName = getUnstarredEnvironmentName(nodeRef.node, state) + + if (envName) { + switch (envName) { + case 'itemize': + case 'enumerate': + case 'description': + startListEnvironment(envName) + listDepth++ + break + + case 'abstract': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new BeginWidget(envName), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'frame': + if (shouldDecorate(state, nodeRef)) { + const parent = nodeRef.node.parent + if (parent?.type.is('Environment')) { + const args = getEnvironmentArguments(parent) + if (!args) { + break + } + + if (args.length > 0) { + const title = args[0] + if (!title) { + break + } + let to = title.to + const titleTextNode = title.getChild('LongArg') + if (!titleTextNode) { + break + } + const frame: Frame = { + title: { + node: title, + content: state.sliceDoc( + titleTextNode.from, + titleTextNode.to + ), + }, + } + if (args.length > 1) { + // We have a subtitle too + const subtitle = args[1] + if (subtitle) { + const subtitleTextNode = subtitle.getChild('LongArg') + if (subtitleTextNode) { + to = subtitle.to + frame.subtitle = { + node: subtitle, + content: state.sliceDoc( + subtitleTextNode.from, + subtitleTextNode.to + ), + } + } + } + } + decorations.push( + Decoration.replace({ + widget: new FrameWidget(frame), + block: true, + }).range(nodeRef.from, to) + ) + } + } + } + break + default: + { + const theoremName = theoremEnvironments.get(envName) + + if (theoremName && shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('OptionalArgument') + ?.getChild('ShortOptionalArg') - case 'abstract': - if (shouldDecorate(state, nodeRef)) { decorations.push( Decoration.replace({ - widget: new BeginWidget(envName), + widget: new BeginTheoremWidget( + envName, + theoremName, + argumentNode + ), block: true, }).range(nodeRef.from, nodeRef.to) ) } - break - case 'frame': - if (shouldDecorate(state, nodeRef)) { - const parent = nodeRef.node.parent - if (parent?.type.is('Environment')) { - const args = getEnvironmentArguments(parent) - if (!args) { - break - } - if (args.length > 0) { - const title = args[0] - if (!title) { - break - } - let to = title.to - const titleTextNode = title.getChild('LongArg') - if (!titleTextNode) { - break - } - const frame: Frame = { - title: { - node: title, - content: state.sliceDoc( - titleTextNode.from, - titleTextNode.to - ), - }, - } - if (args.length > 1) { - // We have a subtitle too - const subtitle = args[1] - if (subtitle) { - const subtitleTextNode = subtitle.getChild('LongArg') - if (subtitleTextNode) { - to = subtitle.to - frame.subtitle = { - node: subtitle, - content: state.sliceDoc( - subtitleTextNode.from, - subtitleTextNode.to - ), - } - } - } - } - decorations.push( - Decoration.replace({ - widget: new FrameWidget(frame), - block: true, - }).range(nodeRef.from, to) - ) - } - } - } - break - default: - { - const theoremName = theoremEnvironments.get(envName) - - if (theoremName && shouldDecorate(state, nodeRef)) { - const argumentNode = nodeRef.node - .getChild('OptionalArgument') - ?.getChild('ShortOptionalArg') - - decorations.push( - Decoration.replace({ - widget: new BeginTheoremWidget( - envName, - theoremName, - argumentNode - ), - block: true, - }).range(nodeRef.from, nodeRef.to) - ) - } - - // do nothing - } - break - } + // do nothing + } + break } - } else if (nodeRef.type.is('EndEnv')) { - // the end of an environment, with an environment name argument - const envName = getEnvironmentName(nodeRef.node, state) + } + } else if (nodeRef.type.is('EndEnv')) { + // the end of an environment, with an environment name argument + const envName = getEnvironmentName(nodeRef.node, state) - if (envName) { - switch (envName) { - case 'itemize': - case 'enumerate': - case 'description': - if (currentListEnvironment === envName) { - endListEnvironment() - } - listDepth-- - break + if (envName) { + switch (envName) { + case 'itemize': + case 'enumerate': + case 'description': + if (currentListEnvironment === envName) { + endListEnvironment() + } + listDepth-- + break - case 'abstract': + case 'abstract': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new EndWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'document': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new EndDocumentWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'frame': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new DividerWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + default: + if (theoremEnvironments.has(envName)) { if (shouldDecorate(state, nodeRef)) { decorations.push( Decoration.replace({ @@ -519,840 +540,783 @@ export const atomicDecorations = (options: Options) => { }).range(nodeRef.from, nodeRef.to) ) } - break - case 'document': - if (shouldDecorate(state, nodeRef)) { - decorations.push( - Decoration.replace({ - widget: new EndDocumentWidget(), - block: true, - }).range(nodeRef.from, nodeRef.to) - ) - } - break - case 'frame': - if (shouldDecorate(state, nodeRef)) { - decorations.push( - Decoration.replace({ - widget: new DividerWidget(), - block: true, - }).range(nodeRef.from, nodeRef.to) - ) - } - break - default: - if (theoremEnvironments.has(envName)) { - if (shouldDecorate(state, nodeRef)) { - decorations.push( - Decoration.replace({ - widget: new EndWidget(), - block: true, - }).range(nodeRef.from, nodeRef.to) - ) - } - } - // do nothing - break - } + } + // do nothing + break } - } else if (nodeRef.type.is('$SectioningCtrlSeq')) { - const ancestorNode = ancestorNodeOfType( - state, - nodeRef.to, - 'SectioningCommand' - ) - if (ancestorNode) { - // a section (or subsection, etc) command - const argumentNode = ancestorNode.getChild('SectioningArgument') - if (argumentNode) { - const openBrace = argumentNode.getChild(OpenBrace) - const closeBrace = argumentNode.getChild(CloseBrace) - if (!openBrace || !closeBrace) { - return false - } - const sectionCtrlSeqNode = ancestorNode.getChild('$CtrlSeq') - if (!sectionCtrlSeqNode) { - return false - } - const titleNode = argumentNode.getChild('LongArg') - if (!titleNode) { - return false - } - const title = state.sliceDoc(titleNode.from, titleNode.to) - if (!title.trim()) { - return false - } - - const showBraces = - selectionIntersects(state.selection, sectionCtrlSeqNode) || - selectionIntersects(state.selection, openBrace) || - selectionIntersects(state.selection, closeBrace) - - decorations.push( - Decoration.replace({ - widget: new BraceWidget(showBraces ? '{' : ''), - }).range(nodeRef.from, titleNode.from) - ) - - decorations.push( - Decoration.replace({ - widget: new BraceWidget(showBraces ? '}' : ''), - }).range(closeBrace.from, closeBrace.to) - ) - + } + } else if (nodeRef.type.is('$SectioningCtrlSeq')) { + const ancestorNode = ancestorNodeOfType( + state, + nodeRef.to, + 'SectioningCommand' + ) + if (ancestorNode) { + // a section (or subsection, etc) command + const argumentNode = ancestorNode.getChild('SectioningArgument') + if (argumentNode) { + const openBrace = argumentNode.getChild(OpenBrace) + const closeBrace = argumentNode.getChild(CloseBrace) + if (!openBrace || !closeBrace) { + return false + } + const sectionCtrlSeqNode = ancestorNode.getChild('$CtrlSeq') + if (!sectionCtrlSeqNode) { + return false + } + const titleNode = argumentNode.getChild('LongArg') + if (!titleNode) { + return false + } + const title = state.sliceDoc(titleNode.from, titleNode.to) + if (!title.trim()) { return false } - } - } else if (nodeRef.type.is('VerbCommand')) { - if (shouldDecorate(state, nodeRef)) { - // \verb content (text only) - const contentNode = nodeRef.node.getChild('VerbContent') - if (contentNode) { - if (contentNode.to - contentNode.from > 2) { - decorations.push( - Decoration.replace({}).range( - nodeRef.from, - contentNode.from + 1 - ) - ) - - decorations.push( - Decoration.replace({}).range(nodeRef.to - 1, nodeRef.to) - ) - } - } - } - - return false // no markup in verbatim content - } else if ( - nodeRef.type.is('NewCommand') || - nodeRef.type.is('RenewCommand') || - nodeRef.type.is('Def') - ) { - const nameNode = - nodeRef.node.getChild('LiteralArgContent') ?? - nodeRef.node.getChild('Csname') ?? - nodeRef.node.getChild('CtrlSym') - if (nameNode) { - const name = state.sliceDoc(nameNode.from, nameNode.to).trim() - if (/^\\\w+/.test(name)) { - const content = state.sliceDoc(nodeRef.from, nodeRef.to) - if (content) { - commandDefinitions += `${content}\n` - } - } - } - } else if ( - nodeRef.type.is('RenewEnvironment') || - nodeRef.type.is('NewEnvironment') - ) { - const nameNode = nodeRef.node.getChild('LiteralArgContent') - if (nameNode) { - const name = state.sliceDoc(nameNode.from, nameNode.to).trim() - if (/^\w+/.test(name)) { - const content = state.sliceDoc(nodeRef.from, nodeRef.to) - if (content) { - commandDefinitions += `${content}\n` - } - } - } - } else if (nodeRef.type.is('Let')) { - const commandNodes = nodeRef.node.getChildren('Csname') - if (commandNodes.length !== 2) { - return - } - const nameNode = commandNodes[0] - if (nameNode) { - // We support more flexible names in let (Csname) than in newcommand - const name = state.sliceDoc(nameNode.from, nameNode.to).trim() - if (name.length > 1 && name.startsWith('\\')) { - const content = state.sliceDoc(nodeRef.from, nodeRef.to) - if (content) { - commandDefinitions += `${content}\n` - } - } - } - } else if (nodeRef.type.is('Cite')) { - // \cite command with a bibkey argument - if (shouldDecorate(state, nodeRef)) { - const argumentNode = nodeRef.node - .getChild('BibKeyArgument') - ?.getChild('ShortTextArgument') + const showBraces = + selectionIntersects(state.selection, sectionCtrlSeqNode) || + selectionIntersects(state.selection, openBrace) || + selectionIntersects(state.selection, closeBrace) decorations.push( - ...decorateArgumentBraces( - new IconBraceWidget('📚'), - argumentNode, - nodeRef.from - ) + Decoration.replace({ + widget: new BraceWidget(showBraces ? '{' : ''), + }).range(nodeRef.from, titleNode.from) ) + + decorations.push( + Decoration.replace({ + widget: new BraceWidget(showBraces ? '}' : ''), + }).range(closeBrace.from, closeBrace.to) + ) + + return false } + } + } else if (nodeRef.type.is('VerbCommand')) { + if (shouldDecorate(state, nodeRef)) { + // \verb content (text only) + const contentNode = nodeRef.node.getChild('VerbContent') - return false // no markup in cite content - } else if (nodeRef.type.is('Ref')) { - // \ref command with a ref label argument + if (contentNode) { + if (contentNode.to - contentNode.from > 2) { + decorations.push( + Decoration.replace({}).range(nodeRef.from, contentNode.from + 1) + ) + decorations.push( + Decoration.replace({}).range(nodeRef.to - 1, nodeRef.to) + ) + } + } + } + + return false // no markup in verbatim content + } else if ( + nodeRef.type.is('NewCommand') || + nodeRef.type.is('RenewCommand') || + nodeRef.type.is('Def') + ) { + const nameNode = + nodeRef.node.getChild('LiteralArgContent') ?? + nodeRef.node.getChild('Csname') ?? + nodeRef.node.getChild('CtrlSym') + if (nameNode) { + const name = state.sliceDoc(nameNode.from, nameNode.to).trim() + if (/^\\\w+/.test(name)) { + const content = state.sliceDoc(nodeRef.from, nodeRef.to) + if (content) { + commandDefinitions += `${content}\n` + } + } + } + } else if ( + nodeRef.type.is('RenewEnvironment') || + nodeRef.type.is('NewEnvironment') + ) { + const nameNode = nodeRef.node.getChild('LiteralArgContent') + if (nameNode) { + const name = state.sliceDoc(nameNode.from, nameNode.to).trim() + if (/^\w+/.test(name)) { + const content = state.sliceDoc(nodeRef.from, nodeRef.to) + if (content) { + commandDefinitions += `${content}\n` + } + } + } + } else if (nodeRef.type.is('Let')) { + const commandNodes = nodeRef.node.getChildren('Csname') + if (commandNodes.length !== 2) { + return + } + const nameNode = commandNodes[0] + if (nameNode) { + // We support more flexible names in let (Csname) than in newcommand + const name = state.sliceDoc(nameNode.from, nameNode.to).trim() + if (name.length > 1 && name.startsWith('\\')) { + const content = state.sliceDoc(nodeRef.from, nodeRef.to) + if (content) { + commandDefinitions += `${content}\n` + } + } + } + } else if (nodeRef.type.is('Cite')) { + // \cite command with a bibkey argument + if (shouldDecorate(state, nodeRef)) { const argumentNode = nodeRef.node - .getChild('RefArgument') + .getChild('BibKeyArgument') ?.getChild('ShortTextArgument') - const shouldShowBraces = - !shouldDecorate(state, nodeRef) || - argumentNode?.from === argumentNode?.to - decorations.push( ...decorateArgumentBraces( - new IconBraceWidget(shouldShowBraces ? '🏷{' : '🏷'), + new IconBraceWidget('📚'), argumentNode, - nodeRef.from, - true, - new BraceWidget(shouldShowBraces ? '}' : '') + nodeRef.from ) ) + } - return false // no markup in ref content - } else if (nodeRef.type.is('Label')) { - // \label definition - if (shouldDecorate(state, nodeRef)) { - const argumentNode = nodeRef.node - .getChild('LabelArgument') - ?.getChild('ShortTextArgument') + return false // no markup in cite content + } else if (nodeRef.type.is('Ref')) { + // \ref command with a ref label argument - decorations.push( - ...decorateArgumentBraces( - new IconBraceWidget('🏷'), - argumentNode, - nodeRef.from - ) + const argumentNode = nodeRef.node + .getChild('RefArgument') + ?.getChild('ShortTextArgument') + + const shouldShowBraces = + !shouldDecorate(state, nodeRef) || + argumentNode?.from === argumentNode?.to + + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget(shouldShowBraces ? '🏷{' : '🏷'), + argumentNode, + nodeRef.from, + true, + new BraceWidget(shouldShowBraces ? '}' : '') + ) + ) + + return false // no markup in ref content + } else if (nodeRef.type.is('Label')) { + // \label definition + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('LabelArgument') + ?.getChild('ShortTextArgument') + + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('🏷'), + argumentNode, + nodeRef.from ) - } + ) + } - return false // no markup in label content - } else if (nodeRef.type.is('Include')) { - // \include (a file path) - if (shouldDecorate(state, nodeRef)) { - const argumentNode = nodeRef.node - .getChild('IncludeArgument') - ?.getChild('FilePathArgument') - decorations.push( - ...decorateArgumentBraces( - new IconBraceWidget('🔗'), - argumentNode, - nodeRef.from - ) + return false // no markup in label content + } else if (nodeRef.type.is('Include')) { + // \include (a file path) + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('IncludeArgument') + ?.getChild('FilePathArgument') + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('🔗'), + argumentNode, + nodeRef.from ) - } + ) + } - return false // no markup in include content - } else if (nodeRef.type.is('Input')) { - // \input (a file path) - // TODO: Ensure this works with BareFilePathArgument - if (shouldDecorate(state, nodeRef)) { - const contentNode = nodeRef.node.getChild('InputArgument') + return false // no markup in include content + } else if (nodeRef.type.is('Input')) { + // \input (a file path) + // TODO: Ensure this works with BareFilePathArgument + if (shouldDecorate(state, nodeRef)) { + const contentNode = nodeRef.node.getChild('InputArgument') - if (contentNode) { - if (contentNode.to - contentNode.from > 2) { - decorations.push( - Decoration.replace({ - widget: new IconBraceWidget('🔗'), - }).range(nodeRef.from, contentNode.from + 1) - ) - - decorations.push( - Decoration.replace({ - widget: new BraceWidget(), - }).range(nodeRef.to - 1, nodeRef.to) - ) - } - } - } - - return false // no markup in input content - } else if (nodeRef.type.is('Math')) { - // math equations - const ancestorNode = mathAncestorNode(state, nodeRef.from) - - if ( - ancestorNode && - (ancestorNode.type.is('$Environment') - ? shouldDecorateFromLineEdges(state, ancestorNode) - : shouldDecorate(state, ancestorNode)) - ) { - const math = parseMathContainer(state, nodeRef, ancestorNode) - - if (math && math.passToMathJax) { + if (contentNode) { + if (contentNode.to - contentNode.from > 2) { decorations.push( Decoration.replace({ - widget: new MathWidget( - math.content, - math.displayMode, - commandDefinitions - ), - block: math.displayMode, - }).range(ancestorNode.from, ancestorNode.to) + widget: new IconBraceWidget('🔗'), + }).range(nodeRef.from, contentNode.from + 1) ) - } - } - return false // never decorate inside math - } else if (nodeRef.type.is('HrefCommand')) { - // a hyperlink with URL and content arguments - const urlArgumentNode = nodeRef.node.getChild('UrlArgument') - const urlNode = urlArgumentNode?.getChild('LiteralArgContent') - const contentArgumentNode = nodeRef.node.getChild('ShortTextArgument') - const contentNode = contentArgumentNode?.getChild('ShortArg') - - if ( - urlArgumentNode && - urlNode && - contentArgumentNode && - contentNode - ) { - const shouldShowBraces = - !shouldDecorate(state, nodeRef) || - contentNode.from === contentNode.to - - const url = state.sliceDoc(urlNode.from, urlNode.to) - - // avoid decorating when the URL spans multiple lines, as the argument node is probably unclosed - if (!url.includes('\n')) { decorations.push( - ...decorateArgumentBraces( - new BraceWidget(shouldShowBraces ? '{' : ''), - contentArgumentNode, - nodeRef.from, - true, - new BraceWidget(shouldShowBraces ? '}' : '') - ) + Decoration.replace({ + widget: new BraceWidget(), + }).range(nodeRef.to - 1, nodeRef.to) ) } } - } else if (nodeRef.type.is('UrlCommand')) { - // a hyperlink with URL and content arguments - const argumentNode = nodeRef.node.getChild('UrlArgument') + } - if (argumentNode) { - const contentNode = argumentNode.getChild('LiteralArgContent') + return false // no markup in input content + } else if (nodeRef.type.is('Math')) { + // math equations + const ancestorNode = mathAncestorNode(state, nodeRef.from) - const shouldShowBraces = - !shouldDecorate(state, nodeRef) || - contentNode?.from === contentNode?.to + if ( + ancestorNode && + (ancestorNode.type.is('$Environment') + ? shouldDecorateFromLineEdges(state, ancestorNode) + : shouldDecorate(state, ancestorNode)) + ) { + const math = parseMathContainer(state, nodeRef, ancestorNode) + if (math && math.passToMathJax) { + decorations.push( + Decoration.replace({ + widget: new MathWidget( + math.content, + math.displayMode, + commandDefinitions + ), + block: math.displayMode, + }).range(ancestorNode.from, ancestorNode.to) + ) + } + } + + return false // never decorate inside math + } else if (nodeRef.type.is('HrefCommand')) { + // a hyperlink with URL and content arguments + const urlArgumentNode = nodeRef.node.getChild('UrlArgument') + const urlNode = urlArgumentNode?.getChild('LiteralArgContent') + const contentArgumentNode = nodeRef.node.getChild('ShortTextArgument') + const contentNode = contentArgumentNode?.getChild('ShortArg') + + if (urlArgumentNode && urlNode && contentArgumentNode && contentNode) { + const shouldShowBraces = + !shouldDecorate(state, nodeRef) || + contentNode.from === contentNode.to + + const url = state.sliceDoc(urlNode.from, urlNode.to) + + // avoid decorating when the URL spans multiple lines, as the argument node is probably unclosed + if (!url.includes('\n')) { decorations.push( ...decorateArgumentBraces( new BraceWidget(shouldShowBraces ? '{' : ''), - argumentNode, + contentArgumentNode, nodeRef.from, - false, + true, new BraceWidget(shouldShowBraces ? '}' : '') ) ) } - } else if (nodeRef.type.is('Tilde')) { - // a tilde (non-breaking space) - if (shouldDecorate(state, nodeRef)) { - decorations.push( - Decoration.replace({ - widget: new TildeWidget(), - }).range(nodeRef.from, nodeRef.to) + } + } else if (nodeRef.type.is('UrlCommand')) { + // a hyperlink with URL and content arguments + const argumentNode = nodeRef.node.getChild('UrlArgument') + + if (argumentNode) { + const contentNode = argumentNode.getChild('LiteralArgContent') + + const shouldShowBraces = + !shouldDecorate(state, nodeRef) || + contentNode?.from === contentNode?.to + + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(shouldShowBraces ? '{' : ''), + argumentNode, + nodeRef.from, + false, + new BraceWidget(shouldShowBraces ? '}' : '') ) - } - } else if (nodeRef.type.is('LineBreak')) { - // line break - const optionalArgument = nodeRef.node.getChild('OptionalArgument') - if (!optionalArgument || shouldDecorate(state, optionalArgument)) { - decorations.push( - Decoration.replace({ - widget: new IndicatorWidget('\u21A9'), - }).range(nodeRef.from, nodeRef.to) + ) + } + } else if (nodeRef.type.is('Tilde')) { + // a tilde (non-breaking space) + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new TildeWidget(), + }).range(nodeRef.from, nodeRef.to) + ) + } + } else if (nodeRef.type.is('LineBreak')) { + // line break + const optionalArgument = nodeRef.node.getChild('OptionalArgument') + if (!optionalArgument || shouldDecorate(state, optionalArgument)) { + decorations.push( + Decoration.replace({ + widget: new IndicatorWidget('\u21A9'), + }).range(nodeRef.from, nodeRef.to) + ) + } + return false + } else if (nodeRef.type.is('Caption')) { + if (shouldDecorate(state, nodeRef)) { + // a caption + const argumentNode = nodeRef.node.getChild('TextArgument') + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + argumentNode, + nodeRef.from ) - } - return false - } else if (nodeRef.type.is('Caption')) { - if (shouldDecorate(state, nodeRef)) { - // a caption - const argumentNode = nodeRef.node.getChild('TextArgument') - decorations.push( - ...decorateArgumentBraces( - new BraceWidget(), - argumentNode, - nodeRef.from - ) + ) + } + } else if ( + nodeRef.type.is('IncludeGraphics') || + nodeRef.type.is('IncludeSvg') + ) { + // \includegraphics or \includesvg with a file path argument + const isIncludeSvg = nodeRef.type.is('IncludeSvg') + if (shouldDecorate(state, nodeRef)) { + const argumentNodeName = isIncludeSvg + ? 'IncludeSvgArgument' + : 'IncludeGraphicsArgument' + const filePathArgument = nodeRef.node + .getChild(argumentNodeName) + ?.getChild('FilePathArgument') + ?.getChild('LiteralArgContent') + + if (filePathArgument) { + const filePath = state.doc.sliceString( + filePathArgument.from, + filePathArgument.to ) - } - } else if ( - nodeRef.type.is('IncludeGraphics') || - nodeRef.type.is('IncludeSvg') - ) { - // \includegraphics or \includesvg with a file path argument - const isIncludeSvg = nodeRef.type.is('IncludeSvg') - if (shouldDecorate(state, nodeRef)) { - const argumentNodeName = isIncludeSvg - ? 'IncludeSvgArgument' - : 'IncludeGraphicsArgument' - const filePathArgument = nodeRef.node - .getChild(argumentNodeName) - ?.getChild('FilePathArgument') - ?.getChild('LiteralArgContent') - - if (filePathArgument) { - const filePath = state.doc.sliceString( - filePathArgument.from, - filePathArgument.to - ) - - // \includegraphics doesn't support SVG - if (!isIncludeSvg && filePath.toLowerCase().endsWith('.svg')) { - return false - } - - if ( - isIncludeSvg && - previewByPath(filePath)?.extension !== 'svg' - ) { - return false - } - - if (filePath) { - const environmentNode = ancestorNodeOfType( - state, - nodeRef.from, - 'FigureEnvironment' - ) - const centered = Boolean( - environmentNode && - centeringNodeForEnvironment(environmentNode) - ) - const figureData = environmentNode - ? parseFigureData(environmentNode, state) - : null - - const line = state.doc.lineAt(nodeRef.from) - - if (lineContainsOnlyNode(line, nodeRef)) { - const Widget = state.readOnly - ? GraphicsWidget - : EditableGraphicsWidget - decorations.push( - Decoration.replace({ - widget: new Widget( - filePath, - previewByPath, - centered, - figureData - ), - block: true, - }).range(line.from, line.to) - ) - } else { - const Widget = state.readOnly - ? InlineGraphicsWidget - : EditableInlineGraphicsWidget - decorations.push( - Decoration.replace({ - widget: new Widget( - filePath, - previewByPath, - centered, - figureData - ), - }).range(nodeRef.from, nodeRef.to) - ) - } - } + // \includegraphics doesn't support SVG + if (!isIncludeSvg && filePath.toLowerCase().endsWith('.svg')) { return false } - } - } else if (nodeRef.type.is('Maketitle')) { - if (shouldDecorate(state, nodeRef)) { - const line = state.doc.lineAt(nodeRef.from) - const from = extendBackwardsOverEmptyLines(state.doc, line) - const { to } = state.doc.lineAt(nodeRef.to) - if (shouldDecorate(state, { from, to })) { - decorations.push( - Decoration.replace({ - widget: new MakeTitleWidget(preamble), - block: true, - }).range(from, to) + if (isIncludeSvg && previewByPath(filePath)?.extension !== 'svg') { + return false + } + + if (filePath) { + const environmentNode = ancestorNodeOfType( + state, + nodeRef.from, + 'FigureEnvironment' ) + const centered = Boolean( + environmentNode && centeringNodeForEnvironment(environmentNode) + ) + const figureData = environmentNode + ? parseFigureData(environmentNode, state) + : null + + const line = state.doc.lineAt(nodeRef.from) + + if (lineContainsOnlyNode(line, nodeRef)) { + const Widget = state.readOnly + ? GraphicsWidget + : EditableGraphicsWidget + decorations.push( + Decoration.replace({ + widget: new Widget(filePath, centered, figureData), + block: true, + }).range(line.from, line.to) + ) + } else { + const Widget = state.readOnly + ? InlineGraphicsWidget + : EditableInlineGraphicsWidget + decorations.push( + Decoration.replace({ + widget: new Widget(filePath, centered, figureData), + }).range(nodeRef.from, nodeRef.to) + ) + } } return false } - } else if (nodeRef.type.is('Item')) { - // only decorate \item inside a list - if (currentListEnvironment) { - currentOrdinal++ - const line = state.doc.lineAt(nodeRef.from) - const onlySpaceBeforeNode = /^\s*$/.test( - state.sliceDoc(line.from, nodeRef.from) + } + } else if (nodeRef.type.is('Maketitle')) { + if (shouldDecorate(state, nodeRef)) { + const line = state.doc.lineAt(nodeRef.from) + const from = extendBackwardsOverEmptyLines(state.doc, line) + const { to } = state.doc.lineAt(nodeRef.to) + + if (shouldDecorate(state, { from, to })) { + decorations.push( + Decoration.replace({ + widget: new MakeTitleWidget(preamble), + block: true, + }).range(from, to) ) - const from = onlySpaceBeforeNode ? line.from : nodeRef.from + } - if (currentListEnvironment === 'description') { - const argumentNode = nodeRef.node.getChild(OptionalArgument) - const to = argumentNode ? argumentNode.from : nodeRef.to + return false + } + } else if (nodeRef.type.is('Item')) { + // only decorate \item inside a list + if (currentListEnvironment) { + currentOrdinal++ + const line = state.doc.lineAt(nodeRef.from) + const onlySpaceBeforeNode = /^\s*$/.test( + state.sliceDoc(line.from, nodeRef.from) + ) + const from = onlySpaceBeforeNode ? line.from : nodeRef.from - const onlySpaceAfterNode = - !argumentNode && - /^\s*$/.test(state.sliceDoc(nodeRef.to, line.to)) + if (currentListEnvironment === 'description') { + const argumentNode = nodeRef.node.getChild(OptionalArgument) + const to = argumentNode ? argumentNode.from : nodeRef.to - if (!onlySpaceAfterNode) { - // decorate the \item command and subsequent whitespace, if there is other content on the line - decorations.push( - Decoration.replace({ - widget: new DescriptionItemWidget(listDepth), - }).range(from, to) - ) - } + const onlySpaceAfterNode = + !argumentNode && /^\s*$/.test(state.sliceDoc(nodeRef.to, line.to)) - if (argumentNode) { - // decorate the optional argument - const decorateBrackets = shouldDecorate(state, argumentNode) - - decorations.push( - ...decorateArgumentBraces( - new BraceWidget(decorateBrackets ? '' : '['), - argumentNode, - argumentNode.from, - false, - new BraceWidget(decorateBrackets ? '' : ']'), - { open: OpenBracket, close: CloseBracket } - ) - ) - } - } else { + if (!onlySpaceAfterNode) { + // decorate the \item command and subsequent whitespace, if there is other content on the line decorations.push( Decoration.replace({ - widget: new ItemWidget( - currentListEnvironment || 'document', - currentOrdinal, - listDepth - ), - }).range(from, nodeRef.to) + widget: new DescriptionItemWidget(listDepth), + }).range(from, to) ) - return false } - } - } else if (nodeRef.type.is('NewTheoremCommand')) { - const result = parseTheoremArguments(state, nodeRef.node) - if (result) { - const { name, label } = result - theoremEnvironments.set(name, label) - } - } else if ( - nodeRef.type.is('TextColorCommand') || - nodeRef.type.is('ColorBoxCommand') - ) { - if (shouldDecorate(state, nodeRef)) { - const colorArgumentNode = nodeRef.node.getChild(ShortTextArgument) - const contentArgumentNode = nodeRef.node.getChild(TextArgument) - if (colorArgumentNode && contentArgumentNode) { - // command name and opening brace + + if (argumentNode) { + // decorate the optional argument + const decorateBrackets = shouldDecorate(state, argumentNode) + decorations.push( ...decorateArgumentBraces( - new BraceWidget(), - contentArgumentNode, - nodeRef.from + new BraceWidget(decorateBrackets ? '' : '['), + argumentNode, + argumentNode.from, + false, + new BraceWidget(decorateBrackets ? '' : ']'), + { open: OpenBracket, close: CloseBracket } ) ) } + } else { + decorations.push( + Decoration.replace({ + widget: new ItemWidget( + currentListEnvironment || 'document', + currentOrdinal, + listDepth + ), + }).range(from, nodeRef.to) + ) + return false } - } else if (nodeRef.type.is('$ToggleTextFormattingCommand')) { - // markup that can be toggled using toolbar buttons/keyboard shortcuts - const textArgumentNode = nodeRef.node.getChild('TextArgument') - const argumentText = textArgumentNode?.getChild('LongArg') - const shouldShowBraces = - !shouldDecorate(state, nodeRef) || - argumentText?.from === argumentText?.to + } + } else if (nodeRef.type.is('NewTheoremCommand')) { + const result = parseTheoremArguments(state, nodeRef.node) + if (result) { + const { name, label } = result + theoremEnvironments.set(name, label) + } + } else if ( + nodeRef.type.is('TextColorCommand') || + nodeRef.type.is('ColorBoxCommand') + ) { + if (shouldDecorate(state, nodeRef)) { + const colorArgumentNode = nodeRef.node.getChild(ShortTextArgument) + const contentArgumentNode = nodeRef.node.getChild(TextArgument) + if (colorArgumentNode && contentArgumentNode) { + // command name and opening brace + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + contentArgumentNode, + nodeRef.from + ) + ) + } + } + } else if (nodeRef.type.is('$ToggleTextFormattingCommand')) { + // markup that can be toggled using toolbar buttons/keyboard shortcuts + const textArgumentNode = nodeRef.node.getChild('TextArgument') + const argumentText = textArgumentNode?.getChild('LongArg') + const shouldShowBraces = + !shouldDecorate(state, nodeRef) || + argumentText?.from === argumentText?.to + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(shouldShowBraces ? '{' : ''), + textArgumentNode, + nodeRef.from, + true, + new BraceWidget(shouldShowBraces ? '}' : '') + ) + ) + } else if (nodeRef.type.is('$OtherTextFormattingCommand')) { + // markup that can't be toggled using toolbar buttons/keyboard shortcuts + const textArgumentNode = nodeRef.node.getChild('TextArgument') + if (shouldDecorate(state, nodeRef)) { decorations.push( ...decorateArgumentBraces( - new BraceWidget(shouldShowBraces ? '{' : ''), + new BraceWidget(), textArgumentNode, - nodeRef.from, - true, - new BraceWidget(shouldShowBraces ? '}' : '') + nodeRef.from ) ) - } else if (nodeRef.type.is('$OtherTextFormattingCommand')) { - // markup that can't be toggled using toolbar buttons/keyboard shortcuts - const textArgumentNode = nodeRef.node.getChild('TextArgument') - if (shouldDecorate(state, nodeRef)) { + } + } else if ( + nodeRef.type.is('FootnoteCommand') || + nodeRef.type.is('EndnoteCommand') + ) { + const textArgumentNode = nodeRef.node.getChild('TextArgument') + if (textArgumentNode) { + if (state.readOnly && selectionIntersects(state.selection, nodeRef)) { + // a special case for a read-only document: + // always display the content, styled differently from the main content. decorations.push( ...decorateArgumentBraces( new BraceWidget(), textArgumentNode, nodeRef.from - ) + ), + Decoration.mark({ + class: 'ol-cm-footnote ol-cm-footnote-view', + }).range(textArgumentNode.from, textArgumentNode.to) ) - } - } else if ( - nodeRef.type.is('FootnoteCommand') || - nodeRef.type.is('EndnoteCommand') - ) { - const textArgumentNode = nodeRef.node.getChild('TextArgument') - if (textArgumentNode) { - if ( - state.readOnly && - selectionIntersects(state.selection, nodeRef) - ) { - // a special case for a read-only document: - // always display the content, styled differently from the main content. + } else { + if (shouldDecorate(state, nodeRef)) { + // collapse the footnote when the selection is outside it decorations.push( - ...decorateArgumentBraces( - new BraceWidget(), - textArgumentNode, - nodeRef.from - ), - Decoration.mark({ - class: 'ol-cm-footnote ol-cm-footnote-view', - }).range(textArgumentNode.from, textArgumentNode.to) + Decoration.replace({ + widget: new FootnoteWidget( + nodeRef.type.is('FootnoteCommand') ? 'footnote' : 'endnote' + ), + }).range(nodeRef.from, nodeRef.to) ) - } else { + return false + } + } + } + } else if (nodeRef.type.is('UnknownCommand')) { + // a command that's not defined separately by the grammar + const commandNode = nodeRef.node + const commandNameNode = commandNode.getChild('$CtrlSeq') + + if (commandNameNode) { + const commandName = state.doc + .sliceString(commandNameNode.from, commandNameNode.to) + .trim() + + if (commandName.length > 0) { + const textArgumentNode = commandNode.getChild('TextArgument') + + if (commandName === '\\keywords') { + if (shouldDecorate(state, nodeRef)) { + // command name and opening brace + decorations.push( + ...decorateArgumentBraces( + new BraceWidget('keywords: '), + textArgumentNode, + nodeRef.from + ) + ) + return false + } + } else if (commandName === '\\LaTeX') { if (shouldDecorate(state, nodeRef)) { - // collapse the footnote when the selection is outside it decorations.push( Decoration.replace({ - widget: new FootnoteWidget( - nodeRef.type.is('FootnoteCommand') - ? 'footnote' - : 'endnote' - ), + widget: new LaTeXWidget(), }).range(nodeRef.from, nodeRef.to) ) return false } - } - } - } else if (nodeRef.type.is('UnknownCommand')) { - // a command that's not defined separately by the grammar - const commandNode = nodeRef.node - const commandNameNode = commandNode.getChild('$CtrlSeq') - - if (commandNameNode) { - const commandName = state.doc - .sliceString(commandNameNode.from, commandNameNode.to) - .trim() - - if (commandName.length > 0) { - const textArgumentNode = commandNode.getChild('TextArgument') - - if (commandName === '\\keywords') { - if (shouldDecorate(state, nodeRef)) { - // command name and opening brace - decorations.push( - ...decorateArgumentBraces( - new BraceWidget('keywords: '), - textArgumentNode, - nodeRef.from - ) + } else if (commandName === '\\TeX') { + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new TeXWidget(), + }).range(nodeRef.from, nodeRef.to) + ) + return false + } + } else if (commandName === '\\ce') { + // Chemical equation/formula, from the `mhchem` CTAN package. + // Handled by the MathJaX mhchem extension: + // https://docs.mathjax.org/en/latest/input/tex/extensions/mhchem.html + if (textArgumentNode && shouldDecorate(state, nodeRef)) { + const innerContent = state.doc + .sliceString( + textArgumentNode.from + 1, + textArgumentNode.to - 1 ) - return false - } - } else if (commandName === '\\LaTeX') { - if (shouldDecorate(state, nodeRef)) { + .trim() + + if (innerContent.length) { + const outerContent = state.doc.sliceString( + nodeRef.from, + nodeRef.to + ) + decorations.push( Decoration.replace({ - widget: new LaTeXWidget(), + widget: new MathWidget(outerContent, false), + }).range(nodeRef.from, nodeRef.to) + ) + } + + return false + } + } else if (hasCharacterSubstitution(commandName)) { + if (shouldDecorate(state, nodeRef)) { + const replacement = createCharacterCommand(commandName) + if (replacement) { + decorations.push( + Decoration.replace({ + widget: replacement, }).range(nodeRef.from, nodeRef.to) ) return false } - } else if (commandName === '\\TeX') { - if (shouldDecorate(state, nodeRef)) { + } + } else if (hasSpaceSubstitution(commandName)) { + if (shouldDecorate(state, nodeRef)) { + const replacement = createSpaceCommand(commandName) + if (replacement) { decorations.push( Decoration.replace({ - widget: new TeXWidget(), + widget: replacement, }).range(nodeRef.from, nodeRef.to) ) return false } - } else if (commandName === '\\ce') { - // Chemical equation/formula, from the `mhchem` CTAN package. - // Handled by the MathJaX mhchem extension: - // https://docs.mathjax.org/en/latest/input/tex/extensions/mhchem.html - if (textArgumentNode && shouldDecorate(state, nodeRef)) { - const innerContent = state.doc - .sliceString( - textArgumentNode.from + 1, - textArgumentNode.to - 1 - ) - .trim() - - if (innerContent.length) { - const outerContent = state.doc.sliceString( - nodeRef.from, - nodeRef.to - ) - - decorations.push( - Decoration.replace({ - widget: new MathWidget(outerContent, false), - }).range(nodeRef.from, nodeRef.to) - ) - } - - return false - } - } else if (hasCharacterSubstitution(commandName)) { - if (shouldDecorate(state, nodeRef)) { - const replacement = createCharacterCommand(commandName) - if (replacement) { - decorations.push( - Decoration.replace({ - widget: replacement, - }).range(nodeRef.from, nodeRef.to) - ) - return false - } - } - } else if (hasSpaceSubstitution(commandName)) { - if (shouldDecorate(state, nodeRef)) { - const replacement = createSpaceCommand(commandName) - if (replacement) { - decorations.push( - Decoration.replace({ - widget: replacement, - }).range(nodeRef.from, nodeRef.to) - ) - return false - } - } } } } } - }, - }) - - if (preamble.to > 0) { - // add environmentclass names to each line of the preamble - // note: this should be in markDecorations, - // but the preamble extents are calculated in this extension. - const endLine = state.doc.lineAt(preamble.to).number - for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) { - const line = state.doc.line(lineNumber) - const classes = ['ol-cm-preamble-line'] - if (lineNumber === 1) { - classes.push('ol-cm-environment-first-line') - } - if (lineNumber === endLine) { - classes.push('ol-cm-environment-last-line') - } - decorations.push( - Decoration.line({ - class: classes.join(' '), - }).range(line.from) - ) } + }, + }) - // hide the preamble. We use selectionIntersects directly, so that it also - // expands in readOnly mode. - const isExpanded = selectionIntersects(state.selection, preamble) - if (!isExpanded) { - decorations.push( - Decoration.replace({ - widget: new PreambleWidget(isExpanded), - block: true, - }).range(0, preamble.to) - ) - } else { - decorations.push( - Decoration.widget({ - widget: new PreambleWidget(isExpanded), - block: true, - side: -1, - }).range(0) - ) + if (preamble.to > 0) { + // add environmentclass names to each line of the preamble + // note: this should be in markDecorations, + // but the preamble extents are calculated in this extension. + const endLine = state.doc.lineAt(preamble.to).number + for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) { + const line = state.doc.line(lineNumber) + const classes = ['ol-cm-preamble-line'] + if (lineNumber === 1) { + classes.push('ol-cm-environment-first-line') } + if (lineNumber === endLine) { + classes.push('ol-cm-environment-last-line') + } + decorations.push( + Decoration.line({ + class: classes.join(' '), + }).range(line.from) + ) } - return { - decorations: Decoration.set(decorations, true), - preamble, + + // hide the preamble. We use selectionIntersects directly, so that it also + // expands in readOnly mode. + const isExpanded = selectionIntersects(state.selection, preamble) + if (!isExpanded) { + decorations.push( + Decoration.replace({ + widget: new PreambleWidget(isExpanded), + block: true, + }).range(0, preamble.to) + ) + } else { + decorations.push( + Decoration.widget({ + widget: new PreambleWidget(isExpanded), + block: true, + side: -1, + }).range(0) + ) } } + return { + decorations: Decoration.set(decorations, true), + preamble, + } +} - return [ - StateField.define<{ - mousedown: boolean - decorations: DecorationSet - preamble: Preamble - previousTree: Tree - }>({ - create(state) { - const previousTree = syntaxTree(state) - const { decorations, preamble } = createDecorations(state, previousTree) +/** + * A state field that decorates ranges of text (including multiple lines) with Widget or Line decorations. + * Atomic decorations replace a range of content with an uneditable widget. + * Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin. + */ +export const atomicDecorations: Extension = [ + StateField.define<{ + mousedown: boolean + decorations: DecorationSet + preamble: Preamble + previousTree: Tree + }>({ + create(state) { + const previousTree = syntaxTree(state) + const { decorations, preamble } = createDecorations(state, previousTree) - return { - mousedown: false, + return { + mousedown: false, + decorations, + preamble, + previousTree, + } + }, + update(value, tr) { + for (const effect of tr.effects) { + // store the "mousedown" value when it changes + if (effect.is(mouseDownEffect)) { + value = { + ...value, + mousedown: effect.value, + } + } + } + + const tree = syntaxTree(tr.state) + if ( + tree.type === value.previousTree.type && + tree.length < tr.state.doc.length + ) { + // still parsing + value = { + ...value, + decorations: value.decorations.map(tr.changes), + } + } else if ( + // only update the decorations when the mouse is not making a selection + !value.mousedown && + (tree !== value.previousTree || tr.selection || hasMouseDownEffect(tr)) + ) { + // tree changed, or selection changed, or mousedown ended + // TODO: update the existing decorations for the changed range(s)? + const { decorations, preamble } = createDecorations(tr.state, tree) + value = { + ...value, decorations, preamble, - previousTree, - } - }, - update(value, tr) { - for (const effect of tr.effects) { - // store the "mousedown" value when it changes - if (effect.is(mouseDownEffect)) { - value = { - ...value, - mousedown: effect.value, - } - } + previousTree: tree, } + } - const tree = syntaxTree(tr.state) - if ( - tree.type === value.previousTree.type && - tree.length < tr.state.doc.length - ) { - // still parsing - value = { - ...value, - decorations: value.decorations.map(tr.changes), - } - } else if ( - // only update the decorations when the mouse is not making a selection - !value.mousedown && - (tree !== value.previousTree || - tr.selection || - hasMouseDownEffect(tr)) - ) { - // tree changed, or selection changed, or mousedown ended - // TODO: update the existing decorations for the changed range(s)? - const { decorations, preamble } = createDecorations(tr.state, tree) - value = { - ...value, - decorations, - preamble, - previousTree: tree, - } - } - - return value - }, - provide(field) { - return [ - EditorView.decorations.from(field, field => field.decorations), - EditorView.atomicRanges.from(field, value => () => value.decorations), - ViewPlugin.define(view => { - return { - update(update) { - for (const tr of update.transactions) { - if (tr.annotation(pickedCompletion)?.label === '\\href{}{}') { - window.setTimeout(() => nextSnippetField(view)) - } + return value + }, + provide(field) { + return [ + EditorView.decorations.from(field, field => field.decorations), + EditorView.atomicRanges.from(field, value => () => value.decorations), + ViewPlugin.define(view => { + return { + update(update) { + for (const tr of update.transactions) { + if (tr.annotation(pickedCompletion)?.label === '\\href{}{}') { + window.setTimeout(() => nextSnippetField(view)) } - }, - } - }), - skipPreambleWithCursor(field), - selectDecoratedArgument(field), - ] - }, - }), - ] -} + } + }, + } + }), + skipPreambleWithCursor(field), + selectDecoratedArgument(field), + ] + }, + }), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts index 21e61b2b89..2f2a477e64 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts @@ -3,8 +3,8 @@ import { placeSelectionInsideBlock } from '../selection' import { isEqual } from 'lodash' import { FigureData } from '../../figure-modal' import { debugConsole } from '@/utils/debugging' -import { PreviewPath } from '../../../../../../../types/preview-path' import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api' +import { previewByPathFacet } from '../../file-preview' import { PdfDestroyLock } from '../utils/pdf-destroy-lock' // Module level to synchronize across all GraphicsWidgets @@ -25,7 +25,6 @@ export class GraphicsWidget extends WidgetType { constructor( public filePath: string, - public previewByPath: (path: string) => PreviewPath | null, public centered: boolean, public figureData: FigureData | null ) { @@ -107,7 +106,7 @@ export class GraphicsWidget extends WidgetType { renderGraphic(element: HTMLElement, view: EditorView) { element.textContent = '' // ensure the element is empty - const preview = this.previewByPath(this.filePath) + const preview = view.state.facet(previewByPathFacet)(this.filePath) element.dataset.filepath = this.filePath element.dataset.width = this.figureData?.width?.toString() 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 0a6c71c99b..388258fb87 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 @@ -21,14 +21,8 @@ import { pasteHtml } from './paste-html' import { commandTooltip } from '../command-tooltip' import { tableGeneratorTheme } from './table-generator' import { debugConsole } from '@/utils/debugging' -import { PreviewPath } from '../../../../../../types/preview-path' import { getFileExtension } from '../../utils/file' -type Options = { - visual: boolean - previewByPath: (path: string) => PreviewPath | null -} - // Module-provided visual editors registered via the // `sourceEditorVisualExtensions` hook. Module exposes `getExtensions(ext)`, // returning the visual-mode extensions for that file extension. A module match takes precedence @@ -55,17 +49,16 @@ const visualState = StateField.define({ }, }) -const configureVisualExtensions = (options: Options) => - options.visual ? sharedVisualExtensions() : [] +const configureVisualExtensions = (showVisual: boolean) => + showVisual ? sharedVisualExtensions() : [] -export const visual = (docName: string, options: Options): Extension => { - const extensions = - visualModuleExtensions(docName) ?? latexVisualExtensions(options) +export const visual = (docName: string, showVisual: boolean): Extension => { + const extensions = visualModuleExtensions(docName) ?? latexVisualExtensions() return [ - visualState.init(() => options.visual), - visualConf.of(configureVisualExtensions(options)), - visualOnly(options.visual, extensions), + visualState.init(() => showVisual), + visualConf.of(configureVisualExtensions(showVisual)), + visualOnly(showVisual, extensions), ] } @@ -73,11 +66,11 @@ export const isVisual = (view: EditorView) => { return view.state.field(visualState, false) || false } -export const setVisual = (options: Options): TransactionSpec => { +export const setVisual = (showVisual: boolean): TransactionSpec => { return { effects: [ - toggleVisualEffect.of(options.visual), - visualConf.reconfigure(configureVisualExtensions(options)), + toggleVisualEffect.of(showVisual), + visualConf.reconfigure(configureVisualExtensions(showVisual)), ], } } @@ -202,9 +195,9 @@ const sharedVisualExtensions = () => [ EditorView.contentAttributes.of({ 'aria-label': 'Visual Editor editing' }), ] -const latexVisualExtensions = (options: Options): Extension => [ +const latexVisualExtensions = (): Extension => [ listItemMarker, - atomicDecorations(options), + atomicDecorations, markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets visualKeymap, commandTooltip, diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 20ae044b8d..89d15b07d8 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -31,7 +31,8 @@ import { setKeybindings } from '../extensions/keybindings' import { Highlight } from '../../../../../types/highlight' import { EditorView } from '@codemirror/view' import { useErrorBoundary } from 'react-error-boundary' -import { setVisual } from '../extensions/visual/visual' +import { isVisual, setVisual } from '../extensions/visual/visual' +import { setFilePreview } from '../extensions/file-preview' import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import { useUserSettingsContext } from '@/shared/context/user-settings-context' import { setDocName } from '@/features/source-editor/extensions/doc-name' @@ -291,10 +292,9 @@ function useCodeMirrorScope(view: EditorView) { const showVisual = visual && !!openDocName && isCmVisualEditorAvailable(openDocName) - const visualRef = useRef({ - previewByPath, - visual: showVisual, - }) + const showVisualRef = useRef(showVisual) + + const previewByPathRef = useRef(previewByPath) // 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 @@ -308,21 +308,24 @@ function useCodeMirrorScope(view: EditorView) { const { showBoundary } = useErrorBoundary() - const handleException = useCallback((exception: any) => { - captureException(exception, { - tags: { - handler: 'cm6-exception', - // which editor mode is active ('visual' | 'code') - ol_editor_mode: visualRef.current.visual ? 'visual' : 'code', - // which editor keybindings are active ('default' | 'vim' | 'emacs') - ol_editor_keybindings: settingsRef.current.mode, - // whether Writefull is present ('extension' | 'integration' | 'none') - ol_extensions_writefull: window.writefull ? 'integration' : 'none', - // whether Grammarly is present - ol_extensions_grammarly: grammarlyExtensionPresent(), - }, - }) - }, []) + const handleException = useCallback( + (exception: any) => { + captureException(exception, { + tags: { + handler: 'cm6-exception', + // which editor mode is active ('visual' | 'code') + ol_editor_mode: isVisual(view) ? 'visual' : 'code', + // which editor keybindings are active ('default' | 'vim' | 'emacs') + ol_editor_keybindings: settingsRef.current.mode, + // whether Writefull is present ('extension' | 'integration' | 'none') + ol_extensions_writefull: window.writefull ? 'integration' : 'none', + // whether Grammarly is present + ol_extensions_grammarly: grammarlyExtensionPresent(), + }, + }) + }, + [view] + ) // create a new state when currentDocument changes @@ -350,7 +353,8 @@ function useCodeMirrorScope(view: EditorView) { settings: settingsRef.current, phrases: phrasesRef.current, spelling: spellingRef.current, - visual: visualRef.current, + showVisual: showVisualRef.current, + previewByPath: previewByPathRef.current, projectFeatures: projectFeaturesRef.current, initialSearchQuery: searchQueryRef.current, showBoundary, @@ -376,7 +380,7 @@ function useCodeMirrorScope(view: EditorView) { view.dispatch(spec) }) - if (!visualRef.current.visual) { + if (!showVisualRef.current) { window.setTimeout(() => { view.dispatch(restoreScrollPosition()) view.focus() @@ -405,9 +409,9 @@ function useCodeMirrorScope(view: EditorView) { }, [view, openDocName]) useEffect(() => { - visualRef.current.visual = showVisual + showVisualRef.current = showVisual window.setTimeout(() => { - view.dispatch(setVisual(visualRef.current)) + view.dispatch(setVisual(showVisual)) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head), }) @@ -417,9 +421,9 @@ function useCodeMirrorScope(view: EditorView) { }, [view, showVisual]) useEffect(() => { - visualRef.current.previewByPath = previewByPath + previewByPathRef.current = previewByPath window.setTimeout(() => { - view.dispatch(setVisual(visualRef.current)) + view.dispatch(setFilePreview(previewByPath)) }) }, [view, previewByPath]) diff --git a/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts b/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts index 953982c0ed..6bafe43770 100644 --- a/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts +++ b/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts @@ -61,7 +61,7 @@ describe('CodeMirror scroll position extension', function () { const view = new EditorView({ state: EditorState.create({ doc, - extensions: [scrollPosition({ currentDoc }, { visual: false })], + extensions: [scrollPosition({ currentDoc }, false)], }), }) @@ -97,7 +97,7 @@ describe('CodeMirror scroll position extension', function () { const view = new EditorView({ state: EditorState.create({ doc, - extensions: [scrollPosition({ currentDoc }, { visual: false })], + extensions: [scrollPosition({ currentDoc }, false)], }), }) view.dispatch(restoreScrollPosition())