import { Decoration, EditorView, getTooltip, keymap, showTooltip, Tooltip, TooltipView, ViewUpdate, } from '@codemirror/view' import { EditorSelection, EditorState, Prec, SelectionRange, StateEffect, StateField, } from '@codemirror/state' import { ancestorOfNodeWithType } from '../utils/tree-query' import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import { FilePathArgument, LiteralArgContent, RefArgument, ShortArg, ShortTextArgument, UrlArgument, } from '../lezer-latex/latex.terms.mjs' import { hasNextSnippetField, selectedCompletion, } from '@codemirror/autocomplete' import { SyntaxNode } from '@lezer/common' type ActiveTooltip = { range: SelectionRange tooltip: Tooltip command: string } | null const createTooltipView = ( update?: (update: ViewUpdate) => void ): TooltipView => { const dom = document.createElement('div') dom.role = 'menu' dom.classList.add('ol-cm-command-tooltip') return { dom, update } } const buildTooltip = ( command: string, pos: number, value: ActiveTooltip, commandNode: SyntaxNode, argumentNode?: SyntaxNode | null, update?: (update: ViewUpdate) => void ): ActiveTooltip => { if (!argumentNode) { return null } const { from, to } = commandNode // if the node still matches the range (i.e. this is the same node), // re-use the tooltip by supplying the same create function if (value && from === value.range.from && to === value.range.to) { return { ...value, tooltip: { ...value.tooltip, pos }, } } return { command, range: EditorSelection.range(from, to), tooltip: { create: () => createTooltipView(update), // ensure a new create function arrow: true, pos, }, } } const createTooltipState = ( state: EditorState, value: ActiveTooltip ): ActiveTooltip => { // NOTE: only handling the main selection const { main } = state.selection const pos = main.head const node = syntaxTree(state).resolveInner(pos, 0) const commandNode = ancestorOfNodeWithType(node, '$CommandTooltipCommand') if (!commandNode) { return null } // only show the tooltip when the selection is completely inside the node if (main.from < commandNode.from || main.to > commandNode.to) { return null } const commandName = commandNode.name switch (commandName) { // a hyperlink (\href) case 'HrefCommand': { const argumentNode = commandNode .getChild(UrlArgument) ?.getChild(LiteralArgContent) if ( argumentNode && state.sliceDoc(argumentNode.from, argumentNode.to).includes('\n') ) { return null } const update = (update: ViewUpdate) => { const tooltipState = commandTooltipState(update.state) const input = tooltipState && firstInteractiveElement(update.view, tooltipState) if (input && document.activeElement !== input) { const commandNode = resolveCommandNode(update.state) const argumentNode = commandNode ?.getChild(UrlArgument) ?.getChild(LiteralArgContent) if (argumentNode) { const url = update.state.sliceDoc( argumentNode.from, argumentNode.to ) if (url !== input.value) { input.dispatchEvent( new CustomEvent('value-update', { detail: url, }) ) } } } } return buildTooltip( commandName, pos, value, commandNode, argumentNode, update ) } // a URL (\url) case 'UrlCommand': { const argumentNode = commandNode .getChild(UrlArgument) ?.getChild(LiteralArgContent) if (argumentNode) { const content = state .sliceDoc(argumentNode.from, argumentNode.to) .trim() if ( !content || content.includes('\n') || !/^https?:\/\/\w+/.test(content) ) { return null } } return buildTooltip(commandName, pos, value, commandNode, argumentNode) } // a cross-reference (\ref) case 'Ref': { const argumentNode = commandNode .getChild(RefArgument) ?.getChild(ShortTextArgument) ?.getChild(ShortArg) return buildTooltip(commandName, pos, value, commandNode, argumentNode) } // an included file (\include) case 'Include': { const argumentNode = commandNode .getChild('IncludeArgument') ?.getChild(FilePathArgument) ?.getChild(LiteralArgContent) return buildTooltip(commandName, pos, value, commandNode, argumentNode) } // an input file (\input) case 'Input': { const argumentNode = commandNode .getChild('InputArgument') ?.getChild(FilePathArgument) ?.getChild(LiteralArgContent) return buildTooltip(commandName, pos, value, commandNode, argumentNode) } } return null } const commandTooltipTheme = EditorView.baseTheme({ '&light .cm-tooltip.ol-cm-command-tooltip': { border: '1px lightgray solid', background: '#fefefe', color: '#111', boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)', }, '&dark .cm-tooltip.ol-cm-command-tooltip': { border: '1px #484747 solid', background: '#25282c', color: '#c1c1c1', boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)', }, '.ol-cm-command-tooltip-content': { padding: '8px 0', display: 'flex', flexDirection: 'column', }, '.btn-link.ol-cm-command-tooltip-link': { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 12px', textDecoration: 'none', color: 'inherit', }, '.ol-cm-command-tooltip-form': { padding: '0 8px', }, }) export const resolveCommandNode = (state: EditorState) => { const tooltipState = commandTooltipState(state) if (tooltipState) { const pos = tooltipState.range.from const tree = ensureSyntaxTree(state, pos) if (tree) { return tree.resolveInner(pos, 1).parent } } } const closeCommandTooltipEffect = StateEffect.define() export const closeCommandTooltip = () => { return { effects: closeCommandTooltipEffect.of(null), } } export const commandTooltipStateField = StateField.define({ create(state) { return createTooltipState(state, null) }, update(value, tr) { if (tr.effects.some(effect => effect.is(closeCommandTooltipEffect))) { // close the tooltip if this effect is present value = null } else if (selectedCompletion(tr.state)) { // don't show tooltip if autocomplete is open value = null } else { if (value) { // map the stored range through changes value.range = value.range.map(tr.changes) } if (tr.docChanged || tr.selection) { // create/update the tooltip value = createTooltipState(tr.state, value) } } return value }, provide(field) { return [ // show the tooltip when defined showTooltip.from(field, field => (field ? field.tooltip : null)), // set attributes on the node with the popover EditorView.decorations.from(field, field => { if (!field) { return Decoration.none } return Decoration.set( Decoration.mark({ attributes: { 'aria-haspopup': 'menu', }, }).range(field.range.from, field.range.to) ) }), ] }, }) export const commandTooltipState = ( state: EditorState ): ActiveTooltip | undefined => state.field(commandTooltipStateField, false) const firstInteractiveElement = ( view: EditorView, tooltipState: NonNullable ) => getTooltip(view, tooltipState.tooltip)?.dom.querySelector< HTMLInputElement | HTMLButtonElement >('input, button') const commandTooltipKeymap = Prec.highest( keymap.of([ { key: 'Tab', // Tab to focus the first element in the tooltip, if open run(view) { const tooltipState = commandTooltipState(view.state) if (tooltipState) { const element = firstInteractiveElement(view, tooltipState) if (element) { // continue to the next snippet if there's already a URL filled in if ( element.type === 'url' && element.value && hasNextSnippetField(view.state) ) { return false } element.focus() return true } } return false }, }, { key: 'Escape', // Escape to close the tooltip, if open run(view) { const tooltipState = commandTooltipState(view.state) if (tooltipState) { view.dispatch(closeCommandTooltip()) } return false }, }, ]) ) export const commandTooltip = [ commandTooltipStateField, commandTooltipKeymap, commandTooltipTheme, ]