Add CodeMirror dev tools extension (#12142)

GitOrigin-RevId: 148a0fba5faf6dc8f638fcb4666e2fda6c5c6c40
This commit is contained in:
Alf Eaton 2023-04-14 09:54:09 +01:00 committed by Copybot
parent 161decd67d
commit ab10fd99f5
4 changed files with 311 additions and 180 deletions

View file

@ -48,6 +48,7 @@ import { foldingKeymap } from './folding-keymap'
import { inlineBackground } from './inline-background'
import { fontLoad } from './font-load'
import { indentationMarkers } from './indentation-markers'
import { codemirrorDevTools } from '../languages/latex/codemirror-dev-tools'
const ignoredDefaultKeybindings = new Set([
// NOTE: disable "Mod-Enter" as it's used for "Compile"
@ -133,6 +134,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
scrollOneLine(),
fontLoad(),
inlineBackground(options.visual.visual),
codemirrorDevTools(),
exceptionLogger(),
moduleExtensions.map(extension => extension()),
thirdPartyExtensions(),

View file

@ -0,0 +1,309 @@
import {
Annotation,
Compartment,
EditorSelection,
EditorState,
StateEffect,
StateField,
Transaction,
} from '@codemirror/state'
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
} from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { toggleVisualEffect } from '../../extensions/visual/visual'
import { hasLanguageLoadedEffect } from '../../extensions/language'
// to enable: window.localStorage.setItem('cm6-dev-tools', 'on')
// to disable: window.localStorage.removeItem('cm6-dev-tools')
const enabled = window.localStorage.getItem('cm6-dev-tools') === 'on'
const devToolsConf = new Compartment()
export const codemirrorDevTools = () => {
return enabled ? [devToolsButton, devToolsConf.of(createExtension())] : []
}
const devToolsButton = ViewPlugin.define(view => {
const getContainer = () =>
document.querySelector('.formatting-buttons-wrapper')
const removeButton = () => {
getContainer()?.querySelector('#cm6-dev-tools-button')?.remove()
}
const addButton = () => {
const button = document.createElement('button')
button.classList.add('btn', 'formatting-btn', 'formatting-btn--icon')
button.id = 'cm6-dev-tools-button'
button.textContent = '🦧'
button.addEventListener('click', event => {
event.preventDefault()
view.dispatch(toggleDevTools())
})
getContainer()?.prepend(button)
}
removeButton()
addButton()
return {
update(update) {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect.is(toggleVisualEffect)) {
window.setTimeout(() => {
removeButton()
addButton()
})
}
}
}
},
destroy() {
removeButton()
},
}
})
const isActive = () =>
window.localStorage.getItem('cm6-dev-tools-active') === 'on'
const toggleDevTools = () => {
window.localStorage.setItem('cm6-dev-tools-active', isActive() ? 'off' : 'on')
return {
effects: devToolsConf.reconfigure(createExtension()),
}
}
const createExtension = () =>
isActive() ? [devToolsView, highlightSelectedNode, devToolsTheme] : []
const devToolsTheme = EditorView.baseTheme({
'.ol-cm-dev-tools-container': {
padding: '8px 8px 0',
backgroundColor: '#222',
color: '#eee',
fontSize: '13px',
flexShrink: '0',
fontFamily: '"SF Mono", monospace',
height: '100%',
overflow: 'auto',
position: 'sticky',
top: 0,
},
'.ol-cm-dev-tools-item': {
cursor: 'pointer',
borderTop: '2px solid transparent',
borderBottom: '2px solid transparent',
scrollMargin: '2em',
},
'.ol-cm-selected-node-highlight': {
backgroundColor: 'yellow',
},
'.ol-cm-dev-tools-covered-item': {
backgroundColor: 'rgba(255, 255, 0, 0.2)',
},
'.ol-cm-dev-tools-selected-item': {
backgroundColor: 'rgba(255, 255, 0, 0.5)',
color: '#000',
},
'.ol-cm-dev-tools-cursor-before': {
borderTopColor: 'rgba(255, 255, 0, 1)',
'& + .ol-cm-dev-tools-cursor-before': {
borderTopColor: 'transparent',
},
},
'.ol-cm-dev-tools-positions': {
position: 'sticky',
bottom: '0',
backgroundColor: 'inherit',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
},
'.ol-cm-dev-tools-position': {
padding: '4px 0',
},
})
const fromDevTools = Annotation.define()
const transactionIsFromDevTools = (tr: Transaction) =>
tr.annotation(fromDevTools)
const devToolsView = ViewPlugin.define(view => {
const scroller = document.querySelector<HTMLDivElement>('.cm-scroller')
if (!scroller) {
return {}
}
const container = document.createElement('div')
container.classList.add('ol-cm-dev-tools-container')
scroller.append(container)
const highlightNodeRange = (from: number, to: number) => {
view.dispatch({
effects: [selectedNodeEffect.of({ from, to })],
})
}
const selectNodeRange = (from: number, to: number) => {
view.dispatch({
annotations: [fromDevTools.of(true)],
selection: EditorSelection.single(from, to),
effects: EditorView.scrollIntoView(from, { y: 'center' }),
})
view.focus()
}
buildPanel(view.state, container, highlightNodeRange, selectNodeRange, true)
return {
update(update) {
if (
update.docChanged ||
update.selectionSet ||
hasLanguageLoadedEffect(update)
) {
const scroll = !update.transactions.some(transactionIsFromDevTools)
buildPanel(
update.state,
container,
highlightNodeRange,
selectNodeRange,
scroll
)
}
},
destroy() {
container.remove()
},
}
})
const buildPanel = (
state: EditorState,
container: HTMLDivElement,
highlightNodeRange: (from: number, to: number) => void,
selectNodeRange: (from: number, to: number) => void,
scroll: boolean
) => {
container.textContent = '' // clear
const tree = syntaxTree(state)
const { selection } = state
let itemToCenter: HTMLDivElement
let depth = 0
tree.iterate({
enter(nodeRef) {
const { from, to, name } = nodeRef
const element = document.createElement('div')
element.classList.add('ol-cm-dev-tools-item')
element.style.paddingLeft = `${depth * 16}px`
element.textContent = name
element.addEventListener('mouseover', () => {
highlightNodeRange(from, to)
})
element.addEventListener('click', () => {
selectNodeRange(from, to)
})
container.append(element)
for (const range of selection.ranges) {
// completely covered by selection
if (range.from <= from && range.to >= to) {
element.classList.add('ol-cm-dev-tools-selected-item')
itemToCenter = element
} else if (
(range.from > from && range.from < to) ||
(range.to > from && range.to < to)
) {
element.classList.add('ol-cm-dev-tools-covered-item')
itemToCenter = element
}
if (range.head === from) {
element.classList.add('ol-cm-dev-tools-cursor-before')
itemToCenter = element
}
}
depth++
},
leave(node) {
depth--
},
})
const positions = document.createElement('div')
positions.classList.add('ol-cm-dev-tools-positions')
container.append(positions)
for (const range of state.selection.ranges) {
const line = state.doc.lineAt(range.head)
const column = range.head - line.from + 1
const position = document.createElement('div')
position.classList.add('ol-cm-dev-tools-position')
position.textContent = `line ${line.number}, col ${column}, pos ${range.head}`
positions.append(position)
}
if (scroll && itemToCenter!) {
window.setTimeout(() => {
itemToCenter.scrollIntoView({
block: 'center',
inline: 'center',
})
})
}
}
const selectedNodeEffect = StateEffect.define<{
from: number
to: number
} | null>()
const highlightSelectedNode = StateField.define<DecorationSet>({
create() {
return Decoration.none
},
update(value, tr) {
if (tr.selection) {
value = Decoration.none
}
for (const effect of tr.effects) {
if (effect.is(selectedNodeEffect)) {
if (effect.value) {
const { from, to } = effect.value
// TODO: widget decoration if no range to decorate?
if (to > from) {
value = Decoration.set([
Decoration.mark({
class: 'ol-cm-selected-node-highlight',
}).range(from, to),
])
}
} else {
value = Decoration.none
}
}
}
return value
},
provide(f) {
return EditorView.decorations.from(f)
},
})

View file

@ -1,178 +0,0 @@
import { Decoration, EditorView, Panel, showPanel } from '@codemirror/view'
import { languageLoadedEffect } from '../../extensions/language'
import { Compartment, EditorState } from '@codemirror/state'
import { getAncestorStack } from '../../utils/tree-query'
import { resolveNodeAtPos } from '../../utils/tree-operations/common'
const decorationsConf = new Compartment()
export const debugPanel = () => {
const enableDebugPanel = new URLSearchParams(window.location.search).has(
'cm_debug_panel'
)
if (!enableDebugPanel) {
return []
}
return [
showPanel.of(createInfoPanel),
decorationsConf.of(EditorView.decorations.of(Decoration.none)),
// clear the highlight when the selection changes
EditorView.updateListener.of(update => {
if (update.selectionSet) {
update.view.dispatch({
effects: decorationsConf.reconfigure(
EditorView.decorations.of(Decoration.none)
),
})
}
}),
EditorView.baseTheme({
'.ol-cm-debug-panel': {
paddingBottom: '24px',
},
'.ol-cm-debug-panel-type': {
backgroundColor: '#138a07',
color: '#fff',
padding: '0px 4px',
marginLeft: '4px',
borderRadius: '4px',
},
'.ol-cm-debug-panel-item': {
border: 'none',
backgroundColor: '#fff',
color: '#000',
outline: '1px solid transparent',
marginBottom: '2px',
display: 'inline-flex',
alignItems: 'center',
'&:hover': {
outlineColor: '#000',
},
},
'.ol-cm-debug-panel-position': {
position: 'absolute',
bottom: '0',
right: '0',
padding: '5px',
},
'.ol-cm-debug-panel-node-highlight': {
backgroundColor: '#ffff0077',
},
}),
]
}
const placeholder = () => document.createElement('div')
const createInfoPanel = (view: EditorView): Panel => {
const dom = document.createElement('div')
dom.className = 'ol-cm-debug-panel'
dom.append(buildPanelContent(view, view.state))
return {
dom,
update(update) {
if (update.selectionSet) {
// update when the selection changes
dom.firstChild!.replaceWith(
buildPanelContent(update.view, update.state)
)
} else {
// update when the language is loaded
for (const tr of update.transactions) {
if (tr.effects.some(effect => effect.is(languageLoadedEffect))) {
dom.firstChild!.replaceWith(
buildPanelContent(update.view, update.state)
)
}
}
}
},
}
}
const buildPanelContent = (
view: EditorView,
state: EditorState
): HTMLDivElement => {
const pos = state.selection.main.anchor
const ancestors = getAncestorStack(state, pos)
if (!ancestors) {
return placeholder()
}
if (ancestors.length > 0) {
const node = ancestors[ancestors.length - 1]
const nodeBefore = resolveNodeAtPos(state, pos, -1)
const nodeAfter = resolveNodeAtPos(state, pos, 1)
const parts = []
if (nodeBefore) {
parts.push(`[${nodeBefore.name}]`)
}
parts.push(node.label)
if (nodeAfter) {
parts.push(`[${nodeAfter.name}]`)
}
node.label = parts.join(' ')
}
const panelContent = document.createElement('div')
panelContent.style.padding = '5px 10px'
const line = state.doc.lineAt(pos)
const column = pos - line.from + 1
const positionContainer = document.createElement('div')
positionContainer.className = 'ol-cm-debug-panel-position'
positionContainer.textContent = `line ${line.number}, col ${column}, pos ${pos}`
panelContent.appendChild(positionContainer)
const stackContainer = document.createElement('div')
for (const [index, item] of ancestors.entries()) {
if (index > 0) {
stackContainer.append(' > ')
}
const element = document.createElement('button')
element.className = 'ol-cm-debug-panel-item'
const label = document.createElement('span')
label.className = 'ol-cm-debug-panel-label'
label.textContent = item.label
element.append(label)
if (item.type) {
const type = document.createElement('span')
type.className = 'ol-cm-debug-panel-type'
type.textContent = item.type
element.append(type)
}
element.addEventListener('click', () => {
view.dispatch({
effects: [
decorationsConf.reconfigure(
EditorView.decorations.of(
Decoration.set(
Decoration.mark({
class: 'ol-cm-debug-panel-node-highlight',
}).range(item.from, item.to)
)
)
),
EditorView.scrollIntoView(item.from, { y: 'center' }),
],
})
})
stackContainer.append(element)
}
panelContent.appendChild(stackContainer)
return panelContent
}

View file

@ -3,7 +3,6 @@ import { shortcuts } from './shortcuts'
import { linting } from './linting'
import { LanguageSupport, indentUnit } from '@codemirror/language'
import { CompletionSource } from '@codemirror/autocomplete'
import { debugPanel } from './debug-panel'
import { openAutocomplete } from './open-autocomplete'
import { metadata } from './metadata'
import {
@ -35,7 +34,6 @@ export const latex = () => {
latexIndentService(),
linting(),
metadata(),
debugPanel(),
openAutocomplete(),
...completionSources.map(completionSource =>
LaTeXLanguage.data.of({