1
0
Fork 0
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 ()

GitOrigin-RevId: c236caff7560d8d3e4f53667c7abe27b57f7711d
This commit is contained in:
Alf Eaton 2023-08-11 09:31:38 +01:00 committed by Copybot
parent 8f1de5fa09
commit 253f2c53d5
22 changed files with 1325 additions and 41 deletions

View file

@ -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": "",

View file

@ -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: [
{

View file

@ -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
}
})

View file

@ -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 }) => (

View file

@ -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,
},
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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',
}),
}
}

View file

@ -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)
}
}

View file

@ -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,
]

View file

@ -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')

View file

@ -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))
}
}
},
}
}),
]
},
}),

View file

@ -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,

View file

@ -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({

View file

@ -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
} |

View file

@ -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,

View file

@ -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() {

View file

@ -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": "Youve 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>",

View file

@ -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}')
})
})

View file

@ -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 () {

View file

@ -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)
})
})

View file

@ -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} ')