Browse Source

[visual] Show tooltip with target for linkable nodes (#13636)

GitOrigin-RevId: c236caff7560d8d3e4f53667c7abe27b57f7711d
Alf Eaton 3 years ago
parent
commit
253f2c53d5
22 changed files with 1325 additions and 41 deletions
  1. 4 0
      services/web/frontend/extracted-translations.json
  2. 2 2
      services/web/frontend/js/features/source-editor/commands/ranges.ts
  3. 92 0
      services/web/frontend/js/features/source-editor/components/codemirror-command-tooltip.tsx
  4. 2 0
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  5. 175 0
      services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx
  6. 52 0
      services/web/frontend/js/features/source-editor/components/command-tooltip/include-tooltip.tsx
  7. 52 0
      services/web/frontend/js/features/source-editor/components/command-tooltip/input-tooltip.tsx
  8. 102 0
      services/web/frontend/js/features/source-editor/components/command-tooltip/ref-tooltip.tsx
  9. 46 0
      services/web/frontend/js/features/source-editor/components/command-tooltip/url-tooltip.tsx
  10. 363 0
      services/web/frontend/js/features/source-editor/extensions/command-tooltip.ts
  11. 6 2
      services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
  12. 73 30
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  13. 2 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual.ts
  14. 7 0
      services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts
  15. 4 0
      services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
  16. 2 0
      services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
  17. 12 0
      services/web/frontend/js/ide/editor/EditorManager.js
  18. 4 0
      services/web/locales/en.json
  19. 191 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx
  20. 2 6
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx
  21. 121 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx
  22. 11 1
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -705,7 +705,10 @@
   "on": "",
   "on_free_plan_upgrade_to_access_features": "",
   "only_group_admin_or_managers_can_delete_your_account": "",
+  "open_file": "",
+  "open_link": "",
   "open_project": "",
+  "open_target": "",
   "optional": "",
   "or": "",
   "organize_projects": "",
@@ -844,6 +847,7 @@
   "remove": "",
   "remove_collaborator": "",
   "remove_from_group": "",
+  "remove_link": "",
   "remove_manager": "",
   "remove_or_replace_figure": "",
   "remove_secondary_email_addresses": "",

+ 2 - 2
services/web/frontend/js/features/source-editor/commands/ranges.ts

@@ -23,7 +23,7 @@ export const wrapRanges =
     prefix: string,
     suffix: string,
     wrapWholeLine = false,
-    selection?: (range: SelectionRange) => SelectionRange
+    selection?: (range: SelectionRange, view: EditorView) => SelectionRange
   ) =>
   (view: EditorView): boolean => {
     if (view.state.readOnly) {
@@ -63,7 +63,7 @@ export const wrapRanges =
         )
 
         return {
-          range: selection ? selection(changedRange) : changedRange,
+          range: selection ? selection(changedRange, view) : changedRange,
           // create a single change, including the content
           changes: [
             {

+ 92 - 0
services/web/frontend/js/features/source-editor/components/codemirror-command-tooltip.tsx

@@ -0,0 +1,92 @@
+import { memo, useEffect } from 'react'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from './codemirror-editor'
+import {
+  closeCommandTooltip,
+  commandTooltipState,
+} from '../extensions/command-tooltip'
+import ReactDOM from 'react-dom'
+import { HrefTooltipContent } from './command-tooltip/href-tooltip'
+import { UrlTooltipContent } from './command-tooltip/url-tooltip'
+import { RefTooltipContent } from './command-tooltip/ref-tooltip'
+import { IncludeTooltipContent } from './command-tooltip/include-tooltip'
+import { InputTooltipContent } from './command-tooltip/input-tooltip'
+import { getTooltip } from '@codemirror/view'
+
+export const CodeMirrorCommandTooltip = memo(function CodeMirrorLinkTooltip() {
+  const view = useCodeMirrorViewContext()
+  const state = useCodeMirrorStateContext()
+
+  const tooltipState = commandTooltipState(state)
+  const tooltipView = tooltipState && getTooltip(view, tooltipState.tooltip)
+
+  useEffect(() => {
+    if (!tooltipView) {
+      return
+    }
+
+    const controller = new AbortController()
+
+    tooltipView.dom.addEventListener(
+      'keydown',
+      (event: KeyboardEvent) => {
+        switch (event.key) {
+          case 'Escape':
+            // Escape to close the tooltip
+            event.preventDefault()
+            view.dispatch(closeCommandTooltip())
+            break
+
+          case 'Tab':
+            // Shift+Tab from the first element to return focus to the editor
+            if (
+              event.shiftKey &&
+              document.activeElement ===
+                tooltipView?.dom.querySelector('input,button')
+            ) {
+              event.preventDefault()
+              view.focus()
+            }
+
+            break
+
+          default:
+            break
+        }
+      },
+      { signal: controller.signal }
+    )
+
+    return () => controller.abort()
+  }, [tooltipView, view])
+
+  if (!tooltipView) {
+    return null
+  }
+
+  return ReactDOM.createPortal(
+    <CodeMirrorCommandTooltipContent command={tooltipState.command} />,
+    tooltipView.dom
+  )
+})
+
+const CodeMirrorCommandTooltipContent = memo<{
+  command: string
+}>(function CodeMirrorCommandTooltipContent({ command }) {
+  switch (command) {
+    case 'HrefCommand':
+      return <HrefTooltipContent />
+    case 'UrlCommand':
+      return <UrlTooltipContent />
+    case 'Ref':
+      return <RefTooltipContent />
+    case 'Include':
+      return <IncludeTooltipContent />
+    case 'Input':
+      return <InputTooltipContent />
+    default:
+      return null
+  }
+})

+ 2 - 0
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -13,6 +13,7 @@ import CodeMirrorView from './codemirror-view'
 import CodeMirrorSearch from './codemirror-search'
 import { CodeMirrorToolbar } from './codemirror-toolbar'
 import { CodemirrorOutline } from './codemirror-outline'
+import { CodeMirrorCommandTooltip } from './codemirror-command-tooltip'
 import { dispatchTimer } from '../../../infrastructure/cm6-performance'
 
 import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
@@ -60,6 +61,7 @@ function CodeMirrorEditor() {
         <FigureModal />
         <CodeMirrorSearch />
         <CodeMirrorToolbar />
+        <CodeMirrorCommandTooltip />
         {isReviewPanelReact && <ReviewPanel />}
         {sourceEditorComponents.map(
           ({ import: { default: Component }, path }) => (

+ 175 - 0
services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx

@@ -0,0 +1,175 @@
+import { FC, useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from '../codemirror-editor'
+import {
+  closeCommandTooltip,
+  resolveCommandNode,
+} from '../../extensions/command-tooltip'
+import {
+  LiteralArgContent,
+  ShortArg,
+  ShortTextArgument,
+  UrlArgument,
+} from '../../lezer-latex/latex.terms.mjs'
+import { Button, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
+import Icon from '../../../../shared/components/icon'
+import { EditorState } from '@codemirror/state'
+
+export const HrefTooltipContent: FC = () => {
+  const state = useCodeMirrorStateContext()
+  const view = useCodeMirrorViewContext()
+  const [url, setUrl] = useState<string>(() => readUrl(state) ?? '')
+  const { t } = useTranslation()
+
+  const inputRef = useRef<HTMLInputElement | null>(null)
+
+  // Update the URL if the argument value changes while not editing
+  // TODO: on input blur, update the input value with this URL or read from the syntax tree?
+  useEffect(() => {
+    if (inputRef.current) {
+      const controller = new AbortController()
+
+      // update the input URL when it changes in the doc
+      inputRef.current.addEventListener(
+        'value-update',
+        event => {
+          setUrl((event as CustomEvent<string>).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 (
+    <div className="ol-cm-command-tooltip-content">
+      <form className="ol-cm-command-tooltip-form" onSubmit={handleSubmit}>
+        <FormGroup controlId="link-tooltip-url-input">
+          <ControlLabel>URL</ControlLabel>
+          <FormControl
+            type="url"
+            bsSize="sm"
+            size={50}
+            placeholder="https://…"
+            value={url}
+            inputRef={element => {
+              inputRef.current = element
+            }}
+            autoComplete="off"
+            onChange={event => {
+              const url = (event.target as HTMLInputElement).value
+              setUrl(url)
+              const spec = replaceUrl(state, url)
+              if (spec) {
+                view.dispatch(spec)
+              }
+            }}
+          />
+        </FormGroup>
+      </form>
+
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          window.open(url, '_blank')
+        }}
+      >
+        <Icon type="external-link" fw />
+        {t('open_link')}
+      </Button>
+
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          const spec = removeLink(state)
+          if (spec) {
+            view.dispatch(spec)
+            view.focus()
+          }
+        }}
+      >
+        <Icon type="chain-broken" fw />
+        {t('remove_link')}
+      </Button>
+    </div>
+  )
+}
+
+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,
+      },
+    }
+  }
+}

+ 52 - 0
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 (
+    <div className="ol-cm-command-tooltip-content">
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          const name = readFileName(state)
+          if (name) {
+            window.dispatchEvent(
+              new CustomEvent('editor:open-file', {
+                detail: { name },
+              })
+            )
+            // TODO: handle file not found
+          }
+        }}
+      >
+        <Icon type="edit" fw />
+        {t('open_file')}
+      </Button>
+    </div>
+  )
+}
+
+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)
+  }
+}

+ 52 - 0
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 (
+    <div className="ol-cm-command-tooltip-content">
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          const name = readFileName(state)
+          if (name) {
+            window.dispatchEvent(
+              new CustomEvent('editor:open-file', {
+                detail: { name },
+              })
+            )
+            // TODO: handle file not found
+          }
+        }}
+      >
+        <Icon type="edit" fw />
+        {t('open_file')}
+      </Button>
+    </div>
+  )
+}
+
+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)
+  }
+}

+ 102 - 0
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 (
+    <div className="ol-cm-command-tooltip-content">
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          const target = readTarget(state)
+          if (target) {
+            const labelNode = findTargetLabel(state, target)
+            // TODO: handle label not found
+            if (labelNode) {
+              view.dispatch(selectNode(labelNode))
+            }
+          }
+        }}
+      >
+        <Icon type="link" fw />
+        {t('open_target')}
+      </Button>
+    </div>
+  )
+}
+
+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',
+    }),
+  }
+}

+ 46 - 0
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 (
+    <div className="ol-cm-command-tooltip-content">
+      <Button
+        type="button"
+        bsStyle="link"
+        className="ol-cm-command-tooltip-link"
+        onClick={() => {
+          const url = readUrl(state)
+          if (url) {
+            window.open(url, '_blank')
+          }
+        }}
+      >
+        <Icon type="external-link" fw />
+        {t('open_link')}
+      </Button>
+    </div>
+  )
+}
+
+const readUrl = (state: EditorState) => {
+  const commandNode = resolveCommandNode(state)
+  const argumentNode = commandNode
+    ?.getChild(UrlArgument)
+    ?.getChild(LiteralArgContent)
+
+  if (argumentNode) {
+    return state.sliceDoc(argumentNode.from, argumentNode.to)
+  }
+}

+ 363 - 0
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<ActiveTooltip>({
+  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<ActiveTooltip>
+) =>
+  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,
+]

+ 6 - 2
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')

+ 73 - 30
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 (
+            urlArgumentNode &&
+            urlNode &&
+            contentArgumentNode &&
+            contentNode
+          ) {
+            const shouldShowBraces =
+              !shouldDecorate(state, nodeRef) ||
+              contentNode.from === contentNode.to
 
-            if (urlArgument) {
+            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))
+                  }
+                }
+              },
+            }
+          }),
         ]
       },
     }),

+ 2 - 0
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,

+ 7 - 0
services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts

@@ -38,6 +38,7 @@ const Styles = {
 }
 
 const typeMap: Record<string, string[]> = {
+  // commands that are section headings
   PartCtrlSeq: ['$SectioningCommand'],
   ChapterCtrlSeq: ['$SectioningCommand'],
   SectionCtrlSeq: ['$SectioningCommand'],
@@ -45,6 +46,12 @@ const typeMap: Record<string, string[]> = {
   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({

+ 4 - 0
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
     } |

+ 2 - 0
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,

+ 12 - 0
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() {

+ 4 - 0
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 <b>__email__</b> <b>__appName__</b> 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>",

+ 191 - 0
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 }) => (
+  <div style={{ width: 785, height: 785 }}>{children}</div>
+)
+
+const mountEditor = (content: string) => {
+  const scope = mockScope(content)
+  scope.editor.showVisual = true
+
+  cy.mount(
+    <Container>
+      <EditorProviders scope={scope}>
+        <CodemirrorEditor />
+      </EditorProviders>
+    </Container>
+  )
+
+  // wait for the content to be parsed and revealed
+  cy.get('.cm-content').should('have.css', 'opacity', '1')
+}
+
+describe('<CodeMirrorEditor/> 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}')
+  })
+})

+ 2 - 6
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx

@@ -116,12 +116,8 @@ describe('<CodeMirrorEditor/> 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 () {

+ 121 - 0
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 }) => (
+  <div style={{ width: 785, height: 785 }}>{children}</div>
+)
+
+describe('<CodeMirrorEditor/> 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(
+      <Container>
+        <EditorProviders scope={scope}>
+          <CodemirrorEditor />
+        </EditorProviders>
+      </Container>
+    )
+
+    // 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)
+  })
+})

+ 11 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

@@ -181,7 +181,6 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
   })
 
   forEach([
-    ['ref', '🏷'],
     ['label', '🏷'],
     ['cite', '📚'],
     ['include', '🔗'],
@@ -196,6 +195,17 @@ describe('<CodeMirrorEditor/> 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} ')