Remove Range on user edit

Hello. I’m trying to create piece of functionality that parses comments and does some actions on the back of it. However I struggle to understand the best way to intercept when a comment gets partially deleted (e.g. by removing closing */) to delete it completely.

Information about comments of interest is stored in the StateField. For visualisation the /* and */ of comments are hidden with Decoration.replace, and comment itself is decorated with Decoration.mark.

I struggle to initiate view.dispatch from within StateField (if the range becomes “broken”). If try to use ViewPlugin have an error Calls to EditorView.update are not allowed while an update is in progress.

What is the most optimal way to achieve the behaviour?

Example of code:

import * as cmView from '@codemirror/view'
import * as cmRangeSet from '@codemirror/rangeset'
import * as cmState from "@codemirror/state"
const TAG_PREFIX = '/* A:'
const TAG_POSTFIX = ' */'

const themeBlock = cmView.EditorView.baseTheme({
  '.cm-decorated-line': {
    borderStyle: 'solid',
    borderWidth: '1px',
    borderRadius: '3px',
    borderColor: 'rgb(150, 150, 150)',
    background: 'rgb(220, 219, 169)',
    padding: '3px'
  },
})
const markBlock = cmView.Decoration.mark({ class: 'cm-decorated-line' })
const hideBlock = cmView.Decoration.replace({ inclusive: false })

const effectStart = cmState.StateEffect.define()
const fieldBlocks = cmState.StateField.define({
  create: () => cmRangeSet.RangeSet.empty,
  update: (value, tr) => {
    value = value.map(tr.changes)

    // THE BELOW DOES NOT WORK
    // let toRemove = []
    // value = value.update({
    //   filter: (from, to, value) => {
    //     const text = tr.state.doc.sliceString(from, to)
    //     const keep = text.startsWith(TAG_PREFIX) && text.endsWith(TAG_POSTFIX)
    //     if (!keep) toRemove.push({ from, to })
    //     return keep
    //   }
    // })
    // if (toRemove.length) {
    //   console.log(toRemove)
    //   tr.state.update({ changes: toRemove })
    // }

    // add new block
    let newBlock = tr.effects.find(e => e.is(effectStart))
    if (newBlock) {
      value = value.update({
        add: [new cmRangeSet.Range(
          newBlock.value.from,
          newBlock.value.to,
          { type: null, params: [] }
        )]
      })
    }
    return value
  },

  provide: (field) => cmView.EditorView.decorations.from(field, (value) => {
    const prefix = TAG_PREFIX.length, postfix = TAG_POSTFIX.length

    let decorations = []
    for (let iter = value.iter(); iter.value !== null; iter.next()) {
      decorations.push(new cmRangeSet.Range(iter.from, iter.from + prefix, hideBlock))
      decorations.push(new cmRangeSet.Range(iter.from + prefix, iter.to - postfix, markBlock))
      decorations.push(new cmRangeSet.Range(iter.to - postfix, iter.to, hideBlock))
    }
    return cmRangeSet.RangeSet.of(decorations, true)
  })

})

const assistPlugin = cmView.ViewPlugin.fromClass(class {
  constructor(view) {
    console.log('plugin > constructor')
  }

  update(view) {
    console.log('plugin > update')
    if (view.docChanged) {
      const ranges = view.state.field(fieldBlocks)
      let changes = []
      for (let iter = ranges.iter(); iter.value !== null; iter.next()) {
        const text = view.state.doc.sliceString(iter.from, iter.to)
        if (text.startsWith(TAG_PREFIX) && text.endsWith(TAG_POSTFIX))
          changes.push({ from: iter.from, to: iter.to })
      }
      // THE BELOW FAILS
      view.view.dispatch({changes: changes})
    }
  }
})

function commandStartAssist(view) {
  let range = view.state.selection.main
  if (view.state.readOnly || !range.empty) return false
  // TODO: prevent adding inside existing range
  const tag = TAG_PREFIX + '  ' + TAG_POSTFIX
  view.dispatch({
    changes: {
      from: range.head,
      insert: tag
    },
    effects: effectStart.of({
      from: range.head,
      to: range.head + tag.length
    }),
    selection: cmState.EditorSelection.cursor(range.head + TAG_PREFIX.length + 1)
  })
  return true
}

const keymap = cmView.keymap.of([{
  key: 'Ctrl-a',
  preventDefault: true,
  run: commandStartAssist
}])

export const assist = [fieldBlocks, themeBlock, assistPlugin, keymap]

That’s definitely something you shouldn’t do—you can’t start moving on to the next state before the current state has come into existence. Maybe transactionFilter or changeFilter do what you need here?