mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-14 10:14:32 +00:00
[visual] Show tooltip with target for linkable nodes (#13636)
GitOrigin-RevId: c236caff7560d8d3e4f53667c7abe27b57f7711d
This commit is contained in:
parent
8f1de5fa09
commit
253f2c53d5
22 changed files with 1325 additions and 41 deletions
services/web
frontend
extracted-translations.json
js
features/source-editor
commands
components
extensions
languages/latex
lezer-latex
ide/editor
locales
test/frontend/features/source-editor/components
|
@ -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": "",
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
|
@ -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 }) => (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]
|
|
@ -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')
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
} |
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>",
|
||||
|
|
|
@ -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}')
|
||||
})
|
||||
})
|
|
@ -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 () {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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} ')
|
||||
|
|
Loading…
Add table
Reference in a new issue