mirror of
https://github.com/overleaf/overleaf.git
synced 2024-10-24 21:12:38 -04:00
364 lines
8.9 KiB
TypeScript
364 lines
8.9 KiB
TypeScript
|
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,
|
||
|
]
|