diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 5ee8359dba..6d7462644e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -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): Extension[] => [ scrollOneLine(), fontLoad(), inlineBackground(options.visual.visual), + codemirrorDevTools(), exceptionLogger(), moduleExtensions.map(extension => extension()), thirdPartyExtensions(), diff --git a/services/web/frontend/js/features/source-editor/languages/latex/codemirror-dev-tools.ts b/services/web/frontend/js/features/source-editor/languages/latex/codemirror-dev-tools.ts new file mode 100644 index 0000000000..dbe623867c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/codemirror-dev-tools.ts @@ -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('.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({ + 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) + }, +}) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts b/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts deleted file mode 100644 index 2700e961ee..0000000000 --- a/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts +++ /dev/null @@ -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 -} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/index.ts b/services/web/frontend/js/features/source-editor/languages/latex/index.ts index 429755231e..6a8c38d48d 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/index.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/index.ts @@ -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({