).detail)
+ },
+ { signal: controller.signal }
+ )
+
+ // focus the input element if there is content selected in the doc when the tooltip opens
+ if (!view.state.selection.main.empty) {
+ inputRef.current.focus()
+ }
+
+ inputRef.current?.addEventListener(
+ 'blur',
+ () => {
+ const currentUrl = readUrl(view.state)
+ if (currentUrl) {
+ setUrl(currentUrl)
+ }
+ },
+ { signal: controller.signal }
+ )
+
+ return () => controller.abort()
+ }
+ }, [view])
+
+ const handleSubmit = useCallback(
+ event => {
+ event.preventDefault()
+ view.dispatch(closeCommandTooltip())
+ view.focus()
+ },
+ [view]
+ )
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+const readUrl = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ return state.sliceDoc(argumentNode.from, argumentNode.to)
+ }
+}
+
+const replaceUrl = (state: EditorState, url: string) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ return {
+ changes: {
+ from: argumentNode.from,
+ to: argumentNode.to,
+ insert: url,
+ },
+ }
+ }
+}
+
+const removeLink = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const contentNode = commandNode
+ ?.getChild(ShortTextArgument)
+ ?.getChild(ShortArg)
+
+ if (commandNode && contentNode) {
+ const content = state.sliceDoc(contentNode.from, contentNode.to)
+ return {
+ changes: {
+ from: commandNode.from,
+ to: commandNode.to,
+ insert: content,
+ },
+ }
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/include-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/include-tooltip.tsx
new file mode 100644
index 0000000000..379f2cc100
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/command-tooltip/include-tooltip.tsx
@@ -0,0 +1,52 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useCodeMirrorStateContext } from '../codemirror-editor'
+import { Button } from 'react-bootstrap'
+import { resolveCommandNode } from '../../extensions/command-tooltip'
+import {
+ FilePathArgument,
+ LiteralArgContent,
+} from '../../lezer-latex/latex.terms.mjs'
+import Icon from '../../../../shared/components/icon'
+import { EditorState } from '@codemirror/state'
+
+export const IncludeTooltipContent: FC = () => {
+ const { t } = useTranslation()
+ const state = useCodeMirrorStateContext()
+
+ return (
+
+
+
+ )
+}
+
+const readFileName = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild('IncludeArgument')
+ ?.getChild(FilePathArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ return state.sliceDoc(argumentNode.from, argumentNode.to)
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/input-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/input-tooltip.tsx
new file mode 100644
index 0000000000..ad86cc7cb5
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/command-tooltip/input-tooltip.tsx
@@ -0,0 +1,52 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useCodeMirrorStateContext } from '../codemirror-editor'
+import { Button } from 'react-bootstrap'
+import { resolveCommandNode } from '../../extensions/command-tooltip'
+import {
+ FilePathArgument,
+ LiteralArgContent,
+} from '../../lezer-latex/latex.terms.mjs'
+import Icon from '../../../../shared/components/icon'
+import { EditorState } from '@codemirror/state'
+
+export const InputTooltipContent: FC = () => {
+ const { t } = useTranslation()
+ const state = useCodeMirrorStateContext()
+
+ return (
+
+
+
+ )
+}
+
+const readFileName = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild('InputArgument')
+ ?.getChild(FilePathArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ return state.sliceDoc(argumentNode.from, argumentNode.to)
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/ref-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/ref-tooltip.tsx
new file mode 100644
index 0000000000..e4c7dc91d3
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/command-tooltip/ref-tooltip.tsx
@@ -0,0 +1,102 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+ useCodeMirrorStateContext,
+ useCodeMirrorViewContext,
+} from '../codemirror-editor'
+import { Button } from 'react-bootstrap'
+import { resolveCommandNode } from '../../extensions/command-tooltip'
+import {
+ LabelArgument,
+ RefArgument,
+ ShortArg,
+ ShortTextArgument,
+} from '../../lezer-latex/latex.terms.mjs'
+import { SyntaxNode } from '@lezer/common'
+import { syntaxTree } from '@codemirror/language'
+import {
+ EditorSelection,
+ EditorState,
+ TransactionSpec,
+} from '@codemirror/state'
+import { EditorView } from '@codemirror/view'
+import Icon from '../../../../shared/components/icon'
+
+export const RefTooltipContent: FC = () => {
+ const { t } = useTranslation()
+ const view = useCodeMirrorViewContext()
+ const state = useCodeMirrorStateContext()
+
+ return (
+
+
+
+ )
+}
+
+const readTarget = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild(RefArgument)
+ ?.getChild(ShortTextArgument)
+ ?.getChild(ShortArg)
+
+ if (argumentNode) {
+ return state.sliceDoc(argumentNode.from, argumentNode.to)
+ }
+}
+
+const findTargetLabel = (state: EditorState, target: string) => {
+ let labelNode: SyntaxNode | undefined
+
+ syntaxTree(state).iterate({
+ enter(nodeRef) {
+ if (labelNode) {
+ return false
+ }
+
+ if (nodeRef.type.is(LabelArgument)) {
+ const argumentNode = nodeRef.node
+ .getChild('ShortTextArgument')
+ ?.getChild('ShortArg')
+
+ if (argumentNode) {
+ const label = state.sliceDoc(argumentNode.from, argumentNode.to)
+ if (label === target) {
+ labelNode = argumentNode
+ }
+ }
+ }
+ },
+ })
+
+ return labelNode
+}
+
+const selectNode = (node: SyntaxNode): TransactionSpec => {
+ const selection = EditorSelection.range(node.from, node.to)
+
+ return {
+ selection,
+ effects: EditorView.scrollIntoView(selection, {
+ y: 'center',
+ }),
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/url-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/url-tooltip.tsx
new file mode 100644
index 0000000000..c51b497de0
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/command-tooltip/url-tooltip.tsx
@@ -0,0 +1,46 @@
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useCodeMirrorStateContext } from '../codemirror-editor'
+import { Button } from 'react-bootstrap'
+import { resolveCommandNode } from '../../extensions/command-tooltip'
+import {
+ LiteralArgContent,
+ UrlArgument,
+} from '../../lezer-latex/latex.terms.mjs'
+import Icon from '../../../../shared/components/icon'
+import { EditorState } from '@codemirror/state'
+
+export const UrlTooltipContent: FC = () => {
+ const { t } = useTranslation()
+ const state = useCodeMirrorStateContext()
+
+ return (
+
+
+
+ )
+}
+
+const readUrl = (state: EditorState) => {
+ const commandNode = resolveCommandNode(state)
+ const argumentNode = commandNode
+ ?.getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ return state.sliceDoc(argumentNode.from, argumentNode.to)
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/extensions/command-tooltip.ts b/services/web/frontend/js/features/source-editor/extensions/command-tooltip.ts
new file mode 100644
index 0000000000..545187eb94
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/extensions/command-tooltip.ts
@@ -0,0 +1,363 @@
+import {
+ Decoration,
+ EditorView,
+ getTooltip,
+ keymap,
+ showTooltip,
+ Tooltip,
+ TooltipView,
+ ViewUpdate,
+} from '@codemirror/view'
+import {
+ EditorSelection,
+ EditorState,
+ Prec,
+ SelectionRange,
+ StateEffect,
+ StateField,
+} from '@codemirror/state'
+import { ancestorOfNodeWithType } from '../utils/tree-query'
+import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
+import {
+ FilePathArgument,
+ LiteralArgContent,
+ RefArgument,
+ ShortArg,
+ ShortTextArgument,
+ UrlArgument,
+} from '../lezer-latex/latex.terms.mjs'
+import {
+ hasNextSnippetField,
+ selectedCompletion,
+} from '@codemirror/autocomplete'
+import { SyntaxNode } from '@lezer/common'
+
+type ActiveTooltip = {
+ range: SelectionRange
+ tooltip: Tooltip
+ command: string
+} | null
+
+const createTooltipView = (
+ update?: (update: ViewUpdate) => void
+): TooltipView => {
+ const dom = document.createElement('div')
+ dom.role = 'menu'
+ dom.classList.add('ol-cm-command-tooltip')
+ return { dom, update }
+}
+
+const buildTooltip = (
+ command: string,
+ pos: number,
+ value: ActiveTooltip,
+ commandNode: SyntaxNode,
+ argumentNode?: SyntaxNode | null,
+ update?: (update: ViewUpdate) => void
+): ActiveTooltip => {
+ if (!argumentNode) {
+ return null
+ }
+
+ const { from, to } = commandNode
+
+ // if the node still matches the range (i.e. this is the same node),
+ // re-use the tooltip by supplying the same create function
+ if (value && from === value.range.from && to === value.range.to) {
+ return {
+ ...value,
+ tooltip: { ...value.tooltip, pos },
+ }
+ }
+
+ return {
+ command,
+ range: EditorSelection.range(from, to),
+ tooltip: {
+ create: () => createTooltipView(update), // ensure a new create function
+ arrow: true,
+ pos,
+ },
+ }
+}
+
+const createTooltipState = (
+ state: EditorState,
+ value: ActiveTooltip
+): ActiveTooltip => {
+ // NOTE: only handling the main selection
+ const { main } = state.selection
+ const pos = main.head
+ const node = syntaxTree(state).resolveInner(pos, 0)
+ const commandNode = ancestorOfNodeWithType(node, '$CommandTooltipCommand')
+ if (!commandNode) {
+ return null
+ }
+ // only show the tooltip when the selection is completely inside the node
+ if (main.from < commandNode.from || main.to > commandNode.to) {
+ return null
+ }
+ const commandName = commandNode.name
+
+ switch (commandName) {
+ // a hyperlink (\href)
+ case 'HrefCommand': {
+ const argumentNode = commandNode
+ .getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (
+ argumentNode &&
+ state.sliceDoc(argumentNode.from, argumentNode.to).includes('\n')
+ ) {
+ return null
+ }
+
+ const update = (update: ViewUpdate) => {
+ const tooltipState = commandTooltipState(update.state)
+ const input =
+ tooltipState && firstInteractiveElement(update.view, tooltipState)
+ if (input && document.activeElement !== input) {
+ const commandNode = resolveCommandNode(update.state)
+ const argumentNode = commandNode
+ ?.getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ const url = update.state.sliceDoc(
+ argumentNode.from,
+ argumentNode.to
+ )
+ if (url !== input.value) {
+ input.dispatchEvent(
+ new CustomEvent('value-update', {
+ detail: url,
+ })
+ )
+ }
+ }
+ }
+ }
+
+ return buildTooltip(
+ commandName,
+ pos,
+ value,
+ commandNode,
+ argumentNode,
+ update
+ )
+ }
+
+ // a URL (\url)
+ case 'UrlCommand': {
+ const argumentNode = commandNode
+ .getChild(UrlArgument)
+ ?.getChild(LiteralArgContent)
+
+ if (argumentNode) {
+ const content = state
+ .sliceDoc(argumentNode.from, argumentNode.to)
+ .trim()
+
+ if (
+ !content ||
+ content.includes('\n') ||
+ !/^https?:\/\/\w+/.test(content)
+ ) {
+ return null
+ }
+ }
+
+ return buildTooltip(commandName, pos, value, commandNode, argumentNode)
+ }
+
+ // a cross-reference (\ref)
+ case 'Ref': {
+ const argumentNode = commandNode
+ .getChild(RefArgument)
+ ?.getChild(ShortTextArgument)
+ ?.getChild(ShortArg)
+
+ return buildTooltip(commandName, pos, value, commandNode, argumentNode)
+ }
+
+ // an included file (\include)
+ case 'Include': {
+ const argumentNode = commandNode
+ .getChild('IncludeArgument')
+ ?.getChild(FilePathArgument)
+ ?.getChild(LiteralArgContent)
+
+ return buildTooltip(commandName, pos, value, commandNode, argumentNode)
+ }
+
+ // an input file (\input)
+ case 'Input': {
+ const argumentNode = commandNode
+ .getChild('InputArgument')
+ ?.getChild(FilePathArgument)
+ ?.getChild(LiteralArgContent)
+
+ return buildTooltip(commandName, pos, value, commandNode, argumentNode)
+ }
+ }
+
+ return null
+}
+
+const commandTooltipTheme = EditorView.baseTheme({
+ '&light .cm-tooltip.ol-cm-command-tooltip': {
+ border: '1px lightgray solid',
+ background: '#fefefe',
+ color: '#111',
+ boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
+ },
+ '&dark .cm-tooltip.ol-cm-command-tooltip': {
+ border: '1px #484747 solid',
+ background: '#25282c',
+ color: '#c1c1c1',
+ boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)',
+ },
+ '.ol-cm-command-tooltip-content': {
+ padding: '8px 0',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ '.btn-link.ol-cm-command-tooltip-link': {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '4px 12px',
+ textDecoration: 'none',
+ color: 'inherit',
+ },
+ '.ol-cm-command-tooltip-form': {
+ padding: '0 8px',
+ },
+})
+
+export const resolveCommandNode = (state: EditorState) => {
+ const tooltipState = commandTooltipState(state)
+ if (tooltipState) {
+ const pos = tooltipState.range.from
+ const tree = ensureSyntaxTree(state, pos)
+ if (tree) {
+ return tree.resolveInner(pos, 1).parent
+ }
+ }
+}
+
+const closeCommandTooltipEffect = StateEffect.define()
+
+export const closeCommandTooltip = () => {
+ return {
+ effects: closeCommandTooltipEffect.of(null),
+ }
+}
+
+export const commandTooltipStateField = StateField.define({
+ create(state) {
+ return createTooltipState(state, null)
+ },
+ update(value, tr) {
+ if (tr.effects.some(effect => effect.is(closeCommandTooltipEffect))) {
+ // close the tooltip if this effect is present
+ value = null
+ } else if (selectedCompletion(tr.state)) {
+ // don't show tooltip if autocomplete is open
+ value = null
+ } else {
+ if (value) {
+ // map the stored range through changes
+ value.range = value.range.map(tr.changes)
+ }
+ if (tr.docChanged || tr.selection) {
+ // create/update the tooltip
+ value = createTooltipState(tr.state, value)
+ }
+ }
+
+ return value
+ },
+ provide(field) {
+ return [
+ // show the tooltip when defined
+ showTooltip.from(field, field => (field ? field.tooltip : null)),
+
+ // set attributes on the node with the popover
+ EditorView.decorations.from(field, field => {
+ if (!field) {
+ return Decoration.none
+ }
+
+ return Decoration.set(
+ Decoration.mark({
+ attributes: {
+ 'aria-haspopup': 'menu',
+ },
+ }).range(field.range.from, field.range.to)
+ )
+ }),
+ ]
+ },
+})
+
+export const commandTooltipState = (
+ state: EditorState
+): ActiveTooltip | undefined => state.field(commandTooltipStateField, false)
+
+const firstInteractiveElement = (
+ view: EditorView,
+ tooltipState: NonNullable
+) =>
+ getTooltip(view, tooltipState.tooltip)?.dom.querySelector<
+ HTMLInputElement | HTMLButtonElement
+ >('input, button')
+
+const commandTooltipKeymap = Prec.highest(
+ keymap.of([
+ {
+ key: 'Tab',
+ // Tab to focus the first element in the tooltip, if open
+ run(view) {
+ const tooltipState = commandTooltipState(view.state)
+ if (tooltipState) {
+ const element = firstInteractiveElement(view, tooltipState)
+ if (element) {
+ // continue to the next snippet if there's already a URL filled in
+ if (
+ element.type === 'url' &&
+ element.value &&
+ hasNextSnippetField(view.state)
+ ) {
+ return false
+ }
+ element.focus()
+ return true
+ }
+ }
+
+ return false
+ },
+ },
+ {
+ key: 'Escape',
+ // Escape to close the tooltip, if open
+ run(view) {
+ const tooltipState = commandTooltipState(view.state)
+ if (tooltipState) {
+ view.dispatch(closeCommandTooltip())
+ }
+
+ return false
+ },
+ },
+ ])
+)
+
+export const commandTooltip = [
+ commandTooltipStateField,
+ commandTooltipKeymap,
+ commandTooltipTheme,
+]
diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
index 4185911d70..dcbacf4b34 100644
--- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
@@ -17,11 +17,15 @@ import {
import { snippet } from '@codemirror/autocomplete'
import { snippets } from './snippets'
import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors'
+import { isVisual } from '../visual/visual'
export const toggleBold = toggleRanges('\\textbf')
export const toggleItalic = toggleRanges('\\textit')
-export const wrapInHref = wrapRanges('\\href{}{', '}', false, range =>
- EditorSelection.cursor(range.from - 2)
+
+// TODO: apply as a snippet?
+// TODO: read URL from clipboard?
+export const wrapInHref = wrapRanges('\\href{}{', '}', false, (range, view) =>
+ isVisual(view) ? range : EditorSelection.cursor(range.from - 2)
)
export const toggleBulletList = toggleListForRanges('itemize')
export const toggleNumberedList = toggleListForRanges('enumerate')
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 d292775e09..5f7c704763 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
@@ -3,6 +3,7 @@ import {
Decoration,
DecorationSet,
EditorView,
+ ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { SyntaxNode, Tree } from '@lezer/common'
@@ -59,6 +60,7 @@ import { BeginTheoremWidget } from './visual-widgets/begin-theorem'
import { parseTheoremArguments } from '../../utils/tree-operations/theorems'
import { IndicatorWidget } from './visual-widgets/indicator'
import { TabularWidget } from './visual-widgets/tabular'
+import { nextSnippetField, pickedCompletion } from '@codemirror/autocomplete'
type Options = {
fileTreeManager: {
@@ -587,19 +589,24 @@ export const atomicDecorations = (options: Options) => {
return false // no markup in cite content
} else if (nodeRef.type.is('Ref')) {
// \ref command with a ref label argument
- if (shouldDecorate(state, nodeRef)) {
- const argumentNode = nodeRef.node
- .getChild('RefArgument')
- ?.getChild('ShortTextArgument')
- 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')) {
@@ -727,20 +734,57 @@ export const atomicDecorations = (options: Options) => {
return false // never decorate inside math
} else if (nodeRef.type.is('HrefCommand')) {
// a hyperlink with URL and content arguments
- if (shouldDecorate(state, nodeRef)) {
- const urlArgument = nodeRef.node.getChild('UrlArgument')
- const textArgument = nodeRef.node.getChild('ShortTextArgument')
+ const urlArgumentNode = nodeRef.node.getChild('UrlArgument')
+ const urlNode = urlArgumentNode?.getChild('LiteralArgContent')
+ const contentArgumentNode = nodeRef.node.getChild('ShortTextArgument')
+ const contentNode = contentArgumentNode?.getChild('ShortArg')
- if (urlArgument) {
+ 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(),
- textArgument,
- nodeRef.from
+ new BraceWidget(shouldShowBraces ? '{' : ''),
+ contentArgumentNode,
+ nodeRef.from,
+ true,
+ new BraceWidget(shouldShowBraces ? '}' : '')
)
)
}
}
+ } 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('Tilde')) {
// a tilde (non-breaking space)
if (shouldDecorate(state, nodeRef)) {
@@ -951,18 +995,6 @@ export const atomicDecorations = (options: Options) => {
)
)
}
- } else if (commandName === '\\url') {
- if (shouldDecorate(state, nodeRef)) {
- // command name and opening brace
- decorations.push(
- ...decorateArgumentBraces(
- new BraceWidget(),
- textArgumentNode,
- nodeRef.from
- )
- )
- return false
- }
} else if (
commandName === '\\footnote' ||
commandName === '\\endnote'
@@ -1136,6 +1168,17 @@ export const atomicDecorations = (options: Options) => {
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))
+ }
+ }
+ },
+ }
+ }),
]
},
}),
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 fa5d2c59be..7eb6316379 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
@@ -25,6 +25,7 @@ import { isSplitTestEnabled } from '../../../../utils/splitTestUtils'
import { toolbarPanel } from '../toolbar/toolbar-panel'
import { selectDecoratedArgument } from './select-decorated-argument'
import { pasteHtml } from './paste-html'
+import { commandTooltip } from '../command-tooltip'
type Options = {
visual: boolean
@@ -200,6 +201,7 @@ const extension = (options: Options) => [
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
skipPreambleWithCursor,
visualKeymap,
+ commandTooltip,
scrollJumpAdjuster,
isSplitTestEnabled('source-editor-toolbar') ? [] : toolbarPanel(),
selectDecoratedArgument,
diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts
index 1a68acc1e2..a134bdbe8e 100644
--- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts
+++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts
@@ -38,6 +38,7 @@ const Styles = {
}
const typeMap: Record = {
+ // commands that are section headings
PartCtrlSeq: ['$SectioningCommand'],
ChapterCtrlSeq: ['$SectioningCommand'],
SectionCtrlSeq: ['$SectioningCommand'],
@@ -45,6 +46,12 @@ const typeMap: Record = {
SubSubSectionCtrlSeq: ['$SectioningCommand'],
ParagraphCtrlSeq: ['$SectioningCommand'],
SubParagraphCtrlSeq: ['$SectioningCommand'],
+ // commands that have a "command tooltip"
+ HrefCommand: ['$CommandTooltipCommand'],
+ Include: ['$CommandTooltipCommand'],
+ Input: ['$CommandTooltipCommand'],
+ Ref: ['$CommandTooltipCommand'],
+ UrlCommand: ['$CommandTooltipCommand'],
}
export const LaTeXLanguage = LRLanguage.define({
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
index 40a3b21003..0b673327a1 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
@@ -58,6 +58,7 @@
DocumentClassCtrlSeq,
UsePackageCtrlSeq,
HrefCtrlSeq,
+ UrlCtrlSeq,
VerbCtrlSeq,
LstInlineCtrlSeq,
IncludeGraphicsCtrlSeq,
@@ -258,6 +259,9 @@ KnownCommand {
TheoremStyleCommand {
TheoremStyleCtrlSeq optionalWhitespace? ShortTextArgument
} |
+ UrlCommand {
+ UrlCtrlSeq optionalWhitespace? UrlArgument
+ } |
VerbCommand {
VerbCtrlSeq VerbContent
} |
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
index 56d6c66425..57b9165cc0 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
@@ -27,6 +27,7 @@ import {
DocumentClassCtrlSeq,
UsePackageCtrlSeq,
HrefCtrlSeq,
+ UrlCtrlSeq,
VerbCtrlSeq,
LstInlineCtrlSeq,
IncludeGraphicsCtrlSeq,
@@ -586,6 +587,7 @@ const otherKnowncommands = {
'\\documentclass': DocumentClassCtrlSeq,
'\\usepackage': UsePackageCtrlSeq,
'\\href': HrefCtrlSeq,
+ '\\url': UrlCtrlSeq,
'\\verb': VerbCtrlSeq,
'\\lstinline': LstInlineCtrlSeq,
'\\includegraphics': IncludeGraphicsCtrlSeq,
diff --git a/services/web/frontend/js/ide/editor/EditorManager.js b/services/web/frontend/js/ide/editor/EditorManager.js
index 4b3b35f6c1..61c47906c0 100644
--- a/services/web/frontend/js/ide/editor/EditorManager.js
+++ b/services/web/frontend/js/ide/editor/EditorManager.js
@@ -156,6 +156,18 @@ export default EditorManager = (function () {
const { doc, ...options } = event.detail
this.openDoc(doc, options)
})
+
+ window.addEventListener('editor:open-file', event => {
+ const { name, ...options } = event.detail
+ for (const extension of ['', '.tex']) {
+ const path = `${name}${extension}`
+ const doc = ide.fileTreeManager.findEntityByPath(path)
+ if (doc) {
+ this.openDoc(doc, options)
+ break
+ }
+ }
+ })
}
getEditorType() {
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 0bca93b6d8..518e009c15 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1145,7 +1145,10 @@
"only_group_admin_or_managers_can_delete_your_account": "Only your group admin or group managers will be able to delete your account.",
"open_a_file_on_the_left": "Open a file on the left",
"open_as_template": "Open as Template",
+ "open_file": "Edit file",
+ "open_link": "Go to page",
"open_project": "Open Project",
+ "open_target": "Go to target",
"opted_out_linking": "You’ve opted out from linking your __email__ __appName__ account to your institutional account.",
"optional": "Optional",
"or": "or",
@@ -1370,6 +1373,7 @@
"remove": "Remove",
"remove_collaborator": "Remove collaborator",
"remove_from_group": "Remove from group",
+ "remove_link": "Remove link",
"remove_manager": "Remove manager",
"remove_or_replace_figure": "Remove or replace figure",
"remove_secondary_email_addresses": "Remove any secondary email addresses associated with your account. <0>Remove them in account settings.0>",
diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx
new file mode 100644
index 0000000000..20a2c8f723
--- /dev/null
+++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx
@@ -0,0 +1,191 @@
+import { FC } from 'react'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
+import { mockScope } from '../helpers/mock-scope'
+
+const Container: FC = ({ children }) => (
+ {children}
+)
+
+const mountEditor = (content: string) => {
+ const scope = mockScope(content)
+ scope.editor.showVisual = true
+
+ cy.mount(
+
+
+
+
+
+ )
+
+ // wait for the content to be parsed and revealed
+ cy.get('.cm-content').should('have.css', 'opacity', '1')
+}
+
+describe(' command tooltip in Visual mode', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+ cy.interceptEvents()
+ cy.interceptSpelling()
+ })
+
+ it('shows a tooltip for \\href', function () {
+ const content = [
+ '\\documentclass{article}',
+ '\\usepackage{hyperref}',
+ '\\begin{document}',
+ '',
+ '\\end{document}',
+ ].join('\n')
+ mountEditor(content)
+
+ cy.window().then(win => {
+ cy.stub(win, 'open').as('window-open')
+ })
+
+ // enter the command
+ cy.get('.cm-line').eq(0).as('content-line')
+ cy.get('@content-line').type('\\href{{}}{{}foo')
+ cy.get('@content-line').should('have.text', '{foo}')
+
+ // enter the URL in the tooltip form
+ cy.findByLabelText('URL').type('https://example.com')
+
+ // open the link
+ cy.findByRole('button', { name: 'Go to page' }).click()
+ cy.get('@window-open').should(
+ 'have.been.calledOnceWithExactly',
+ 'https://example.com',
+ '_blank'
+ )
+
+ // remove the link
+ cy.findByRole('menu').should('have.length', 1)
+ cy.findByRole('button', { name: 'Remove link' }).click()
+ cy.findByRole('menu').should('have.length', 0)
+ cy.get('@content-line').should('have.text', 'foo')
+ })
+
+ it('can navigate the \\href tooltip using the keyboard', function () {
+ const content = [
+ '\\documentclass{article}',
+ '\\usepackage{hyperref}',
+ '\\begin{document}',
+ '',
+ '\\end{document}',
+ ].join('\n')
+ mountEditor(content)
+
+ // enter the command
+ cy.get('.cm-line').eq(0).as('content-line')
+ cy.get('@content-line').type('\\href{{}}{{}foo')
+
+ // into tooltip URL form input
+ cy.tab()
+ // down to first button
+ cy.tab()
+ // back into tooltip URL form input
+ cy.tab({ shift: true })
+ // back into document
+ cy.tab({ shift: true })
+
+ // close the tooltip
+ cy.get('@content-line').trigger('keydown', { key: 'Escape' })
+ cy.findByRole('menu').should('have.length', 0)
+ })
+
+ it('shows a tooltip for \\url', function () {
+ mountEditor('')
+
+ cy.window().then(win => {
+ cy.stub(win, 'open').as('window-open')
+ })
+
+ // enter the command and URL
+ cy.get('.cm-line').eq(0).as('content-line')
+ cy.get('@content-line').type('\\url{{}https://example.com')
+ cy.get('@content-line').should('have.text', '{https://example.com}')
+
+ // open the link
+ cy.findByRole('button', { name: 'Go to page' }).click()
+ cy.get('@window-open').should(
+ 'have.been.calledOnceWithExactly',
+ 'https://example.com',
+ '_blank'
+ )
+ })
+
+ it('shows a tooltip for \\include', function () {
+ mountEditor('')
+
+ // enter the command and file name
+ cy.get('.cm-line').eq(0).as('content-line')
+ cy.get('@content-line').type('\\include{{}foo')
+
+ // assert the focused command is undecorated
+ cy.get('@content-line').should('have.text', '\\include{foo}')
+
+ cy.window().then(win => {
+ cy.stub(win, 'dispatchEvent').as('dispatch-event')
+ })
+
+ // open the target
+ cy.findByRole('menu').should('have.length', 1)
+ cy.findByRole('button', { name: 'Edit file' }).click()
+ cy.get('@dispatch-event').should('have.been.calledOnce')
+
+ // assert the unfocused command is decorated
+ cy.get('@content-line').type('{downArrow}')
+ cy.findByRole('menu').should('have.length', 0)
+ cy.get('@content-line').should('have.text', '\\include{foo}')
+ })
+
+ it('shows a tooltip for \\input', function () {
+ mountEditor('')
+
+ // enter the command and file name
+ cy.get('.cm-line').eq(0).as('content-line')
+ cy.get('@content-line').type('\\input{{}foo')
+
+ // assert the focused command is undecorated
+ cy.get('@content-line').should('have.text', '\\input{foo}')
+
+ cy.window().then(win => {
+ cy.stub(win, 'dispatchEvent').as('dispatch-event')
+ })
+
+ // open the target
+ cy.findByRole('menu').should('have.length', 1)
+ cy.findByRole('button', { name: 'Edit file' }).click()
+ cy.get('@dispatch-event').should('have.been.calledOnce')
+
+ // assert the unfocused command is decorated
+ cy.get('@content-line').type('{downArrow}')
+ cy.findByRole('menu').should('have.length', 0)
+ cy.get('@content-line').should('have.text', '\\input{foo}')
+ })
+
+ it('shows a tooltip for \\ref', function () {
+ const content = ['\\section{Foo} \\label{sec:foo}', ''].join('\n')
+
+ mountEditor(content)
+
+ // assert the unfocused label is decorated
+ cy.get('.cm-line').eq(0).as('heading-line')
+ cy.get('@heading-line').should('have.text', 'Foo 🏷sec:foo')
+
+ // enter the command and cross-reference label
+ cy.get('.cm-line').eq(1).as('content-line')
+ cy.get('@content-line').type('\\ref{{}sec:foo')
+ cy.get('@content-line').should('have.text', '🏷{sec:foo}')
+
+ // open the target
+ cy.findByRole('menu').should('have.length', 1)
+ cy.findByRole('button', { name: 'Go to target' }).click()
+ cy.findByRole('menu').should('have.length', 0)
+
+ // assert the focused label is undecorated
+ cy.get('@heading-line').should('have.text', 'Foo \\label{sec:foo}')
+ })
+})
diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx
index 92829a49ef..fb96aed20f 100644
--- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx
+++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx
@@ -116,12 +116,8 @@ describe(' toolbar in Rich Text mode', function () {
selectAll()
clickToolbarButton('Insert Link')
- cy.get('.cm-content').should('have.text', '\\href{}{test}')
-
- cy.get('.cm-line').eq(0).type('http://example.com')
- cy.get('.cm-line')
- .eq(0)
- .should('have.text', '\\href{http://example.com}{test}')
+ cy.get('.cm-content').should('have.text', '{test}')
+ cy.findByLabelText('URL') // tooltip form
})
it('should insert a bullet list', function () {
diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx
new file mode 100644
index 0000000000..0405b4da75
--- /dev/null
+++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx
@@ -0,0 +1,121 @@
+import { mockScope } from '../helpers/mock-scope'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
+import { FC } from 'react'
+
+const Container: FC = ({ children }) => (
+ {children}
+)
+
+describe(' tooltips in Visual mode', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+ window.metaAttributesCache.set(
+ 'ol-mathJax3Path',
+ 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js'
+ )
+ cy.interceptEvents()
+ cy.interceptSpelling()
+
+ const scope = mockScope('\n\n\n')
+ scope.editor.showVisual = true
+
+ cy.mount(
+
+
+
+
+
+ )
+
+ // wait for the content to be parsed and revealed
+ cy.get('.cm-content').should('have.css', 'opacity', '1')
+
+ cy.get('.cm-line').eq(0).as('first-line')
+ })
+
+ it('displays a tooltip for \\href commands', function () {
+ cy.get('@first-line').type(
+ '\\href{{}https://example.com}{{}foo}{leftArrow}'
+ )
+ cy.get('.cm-content').should('have.text', '{foo}')
+ cy.get('.cm-tooltip').should('have.length', 1)
+ cy.get('.cm-tooltip').within(() => {
+ cy.findByLabelText('URL').should('have.value', 'https://example.com')
+ cy.findByLabelText('URL').type('/foo')
+ cy.findByLabelText('URL').should('have.value', 'https://example.com/foo')
+ cy.window().then(win => {
+ cy.stub(win, 'open').as('open-window')
+ })
+ cy.findByRole('button', { name: 'Go to page' }).click()
+ cy.get('@open-window').should(
+ 'have.been.calledOnceWithExactly',
+ 'https://example.com/foo',
+ '_blank'
+ )
+ cy.findByRole('button', { name: 'Remove link' }).click()
+ })
+ cy.get('.cm-content').should('have.text', 'foo')
+ cy.get('.cm-tooltip').should('have.length', 0)
+ })
+
+ it('displays a tooltip for \\url commands', function () {
+ cy.get('@first-line').type('\\url{{}https://example.com}{leftArrow}')
+ cy.get('.cm-content').should('have.text', '{https://example.com}')
+ cy.get('.cm-tooltip').should('have.length', 1)
+ cy.get('.cm-tooltip').within(() => {
+ cy.window().then(win => {
+ cy.stub(win, 'open').as('open-window')
+ })
+ cy.findByRole('button', { name: 'Go to page' }).click()
+ cy.get('@open-window').should(
+ 'have.been.calledOnceWithExactly',
+ 'https://example.com',
+ '_blank'
+ )
+ })
+ cy.get('@first-line').type('{rightArrow}{rightArrow}')
+ cy.get('.cm-content').should('have.text', 'https://example.com')
+ cy.get('.cm-tooltip').should('have.length', 0)
+ })
+
+ it('displays a tooltip for \\ref commands', function () {
+ cy.get('@first-line').type(
+ '\\label{{}fig:frog}{Enter}\\ref{{}fig:frog}{leftArrow}'
+ )
+ cy.get('.cm-content').should('have.text', '🏷fig:frog🏷{fig:frog}')
+ cy.get('.cm-tooltip').should('have.length', 1)
+ cy.get('.cm-tooltip').within(() => {
+ cy.findByRole('button', { name: 'Go to target' }).click()
+ })
+ cy.window().then(win => {
+ expect(win.getSelection()?.toString()).to.equal('fig:frog')
+ })
+ })
+
+ it('displays a tooltip for \\include commands', function () {
+ cy.get('@first-line').type('\\include{{}main}{leftArrow}')
+ cy.get('.cm-content').should('have.text', '\\include{main}')
+ cy.get('.cm-tooltip').should('have.length', 1)
+ cy.get('.cm-tooltip').within(() => {
+ cy.findByRole('button', { name: 'Edit file' }).click()
+ // TODO: assert event fired with "main.tex" as the name?
+ })
+ cy.get('@first-line').type('{rightArrow}{rightArrow}')
+ cy.get('.cm-content').should('have.text', '🔗main')
+ cy.get('.cm-tooltip').should('have.length', 0)
+ })
+
+ it('displays a tooltip for \\input commands', function () {
+ cy.get('@first-line').type('\\input{{}main}{leftArrow}')
+ cy.get('.cm-content').should('have.text', '\\input{main}')
+ cy.get('.cm-tooltip').should('have.length', 1)
+ cy.get('.cm-tooltip').within(() => {
+ cy.findByRole('button', { name: 'Edit file' }).click()
+ // TODO: assert event fired with "main.tex" as the name?
+ })
+ cy.get('@first-line').type('{rightArrow}{rightArrow}')
+ cy.get('.cm-content').should('have.text', '🔗main')
+ cy.get('.cm-tooltip').should('have.length', 0)
+ })
+})
diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx
index 74a293e4e5..de71d0518a 100644
--- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx
+++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx
@@ -181,7 +181,6 @@ describe(' in Visual mode', function () {
})
forEach([
- ['ref', '🏷'],
['label', '🏷'],
['cite', '📚'],
['include', '🔗'],
@@ -196,6 +195,17 @@ describe(' in Visual mode', function () {
cy.get('@first-line').should('have.text', `${icon}key `)
})
+ forEach([['ref', '🏷']]).it('handles \\%s commands', function (command, icon) {
+ cy.get('@first-line').type(`\\${command}{} `)
+ cy.get('@first-line').should('have.text', `${icon} `)
+ cy.get('@first-line').type('{Backspace}{leftArrow}key')
+ cy.get('@first-line').should('have.text', `${icon}{key}`)
+ cy.get('@first-line').type('{rightArrow}')
+ cy.get('@first-line').should('have.text', `${icon}{key}`)
+ cy.get('@first-line').type(' ')
+ cy.get('@first-line').should('have.text', `${icon}key `)
+ })
+
it('handles \\href command', function () {
cy.get('@first-line').type('\\href{{}https://overleaf.com} ')
cy.get('@first-line').should('have.text', '\\href{https://overleaf.com} ')