mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Add CodeMirror dev tools extension (#12142)
GitOrigin-RevId: 148a0fba5faf6dc8f638fcb4666e2fda6c5c6c40
This commit is contained in:
parent
161decd67d
commit
ab10fd99f5
4 changed files with 311 additions and 180 deletions
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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({
|
||||
|
|
Loading…
Reference in a new issue