From 94bada2a3265dc4c68889952778f71e1af5359ec Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 11 Aug 2023 09:31:38 +0100 Subject: [PATCH] [visual] Show tooltip with target for linkable nodes (#13636) GitOrigin-RevId: c236caff7560d8d3e4f53667c7abe27b57f7711d --- .../web/frontend/extracted-translations.json | 4 + .../features/source-editor/commands/ranges.ts | 4 +- .../components/codemirror-command-tooltip.tsx | 92 +++++ .../components/codemirror-editor.tsx | 2 + .../command-tooltip/href-tooltip.tsx | 175 +++++++++ .../command-tooltip/include-tooltip.tsx | 52 +++ .../command-tooltip/input-tooltip.tsx | 52 +++ .../command-tooltip/ref-tooltip.tsx | 102 +++++ .../command-tooltip/url-tooltip.tsx | 46 +++ .../extensions/command-tooltip.ts | 363 ++++++++++++++++++ .../extensions/toolbar/commands.ts | 8 +- .../extensions/visual/atomic-decorations.ts | 103 +++-- .../source-editor/extensions/visual/visual.ts | 2 + .../languages/latex/latex-language.ts | 7 + .../source-editor/lezer-latex/latex.grammar | 4 + .../source-editor/lezer-latex/tokens.mjs | 2 + .../frontend/js/ide/editor/EditorManager.js | 12 + services/web/locales/en.json | 4 + ...ror-editor-visual-command-tooltip.spec.tsx | 191 +++++++++ .../codemirror-editor-visual-toolbar.spec.tsx | 8 +- ...codemirror-editor-visual-tooltips.spec.tsx | 121 ++++++ .../codemirror-editor-visual.spec.tsx | 12 +- 22 files changed, 1325 insertions(+), 41 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/components/codemirror-command-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/command-tooltip/include-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/command-tooltip/input-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/command-tooltip/ref-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/command-tooltip/url-tooltip.tsx create mode 100644 services/web/frontend/js/features/source-editor/extensions/command-tooltip.ts create mode 100644 services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx create mode 100644 services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ba664f8ce0..6f3cf9c574 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/source-editor/commands/ranges.ts b/services/web/frontend/js/features/source-editor/commands/ranges.ts index 09d3b1ad2f..808602ddb9 100644 --- a/services/web/frontend/js/features/source-editor/commands/ranges.ts +++ b/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: [ { diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-command-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-command-tooltip.tsx new file mode 100644 index 0000000000..3bc56f2a8f --- /dev/null +++ b/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( + , + tooltipView.dom + ) +}) + +const CodeMirrorCommandTooltipContent = memo<{ + command: string +}>(function CodeMirrorCommandTooltipContent({ command }) { + switch (command) { + case 'HrefCommand': + return + case 'UrlCommand': + return + case 'Ref': + return + case 'Include': + return + case 'Input': + return + default: + return null + } +}) diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index d7d4f8abef..7f8680faed 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/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() { + {isReviewPanelReact && } {sourceEditorComponents.map( ({ import: { default: Component }, path }) => ( diff --git a/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/command-tooltip/href-tooltip.tsx new file mode 100644 index 0000000000..8c394611ca --- /dev/null +++ b/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(() => readUrl(state) ?? '') + const { t } = useTranslation() + + const inputRef = useRef(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).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 ( +
+
+ + URL + { + 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) + } + }} + /> + +
+ + + + +
+ ) +} + +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.", 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} ')