Просмотр исходного кода

Add CodeMirror dev tools extension (#12142)

GitOrigin-RevId: 148a0fba5faf6dc8f638fcb4666e2fda6c5c6c40
Alf Eaton 3 лет назад
Родитель
Сommit
ab10fd99f5

+ 2 - 0
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<string, any>): Extension[] => [
   scrollOneLine(),
   fontLoad(),
   inlineBackground(options.visual.visual),
+  codemirrorDevTools(),
   exceptionLogger(),
   moduleExtensions.map(extension => extension()),
   thirdPartyExtensions(),

+ 309 - 0
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<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)
+  },
+})

+ 0 - 178
services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts

@@ -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
-}

+ 0 - 2
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({