Highlight only when the cursor is in a token?

In the XML and HTML languages, if your cursor is in an element’s tag name, that tag name and its closing tag are highlighted. Otherwise, some other highlight is applied. I believe this is automatically added by the openedBy and closedBy attributes, so it can’t be used by tokens that are “self-closing”.

How can I apply a highlight/css class to a token only when the cursor is in the token?

(To be clear, I already know how to associate a token with a “tag”/style. I’m unsure how to apply that style conditioned with the cursor.)

You’d need to have an extension check the cursor and syntax tree on updates, and only add a decoration when the cursor is in the kind of node you’re interested in.

1 Like

Pretty straightforward, thanks! Here’s some demo code for anyone else. It highlights the quotes if the cursor is in a “quoted string” or the nearest parens if the cursor is in a (group). Could use some refactoring/deduping but whatever.

import { Decoration, type DecorationSet, EditorView } from '@codemirror/view'
import { type Range, StateField, type EditorState } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { queryTerms } from 'shared-dom'

const quoteDecorator = Decoration.mark({ class: 'query-quote' })
const parenDecorator = Decoration.mark({ class: 'query-paren' })
const activeDecorator = Decoration.mark({ class: 'query-active' })

function getDecorations(state: EditorState): DecorationSet {
	const decorations: Array<Range<Decoration>> = []
	let activeParenSet = false
	syntaxTree(state).iterate({
		enter: (node) => {
			if (node.type.is(queryTerms.QuotedString)) {
				if (
					state.selection.main.head > node.from &&
					state.selection.main.head < node.to
				) {
					decorations.push(activeDecorator.range(node.from, node.from + 1))
					decorations.push(activeDecorator.range(node.to - 1, node.to))
				} else {
					decorations.push(quoteDecorator.range(node.from, node.from + 1))
					decorations.push(quoteDecorator.range(node.to - 1, node.to))
				}
			}
		},
		leave: (node) => {
			// this is on the `leave` callback because we want to set `activeParenSet=true` on the most nested parens
			if (node.type.is(queryTerms.Group)) {
				if (
					state.selection.main.head > node.from &&
					state.selection.main.head < node.to &&
					!activeParenSet
				) {
					decorations.push(activeDecorator.range(node.from, node.from + 1))
					decorations.push(activeDecorator.range(node.to - 1, node.to))
					activeParenSet = true
				} else {
					decorations.push(parenDecorator.range(node.from, node.from + 1))
					decorations.push(parenDecorator.range(node.to - 1, node.to))
				}
			}
		},
	})
	return Decoration.set(decorations, true)
}

export const queryDecorations = StateField.define<DecorationSet>({
	create: getDecorations,
	update(decorations, tr) {
		if (!tr.docChanged && tr.selection == null) return decorations
		return getDecorations(tr.state)
	},
	provide: (f) => EditorView.decorations.from(f),
})

const baseTheme = EditorView.baseTheme({
	'.query-paren': {
		color: 'darkorange',
		fontWeight: 'bold',
	},
	'.query-quote': {
		color: 'darkorange',
		fontWeight: 'bold',
	},
	'.query-active': {
		color: 'red',
		fontWeight: 'bold',
	},
})