Browse Source

feat: markdown editor with monaco (wip)

NGPixel 2 years ago
parent
commit
9e875794cd
6 changed files with 835 additions and 107 deletions
  1. 6 0
      ux/package-lock.json
  2. 1 0
      ux/package.json
  3. 14 1
      ux/quasar.config.js
  4. 23 0
      ux/src/boot/monaco.js
  5. 170 106
      ux/src/components/EditorMarkdown.vue
  6. 621 0
      ux/src/helpers/monacoTypes.js

+ 6 - 0
ux/package-lock.json

@@ -68,6 +68,7 @@
         "markdown-it-sup": "1.0.0",
         "markdown-it-task-lists": "2.1.1",
         "mitt": "3.0.0",
+        "monaco-editor": "0.37.1",
         "pako": "2.1.0",
         "pinia": "2.0.33",
         "prosemirror-commands": "1.5.1",
@@ -5579,6 +5580,11 @@
       "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz",
       "integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ=="
     },
+    "node_modules/monaco-editor": {
+      "version": "0.37.1",
+      "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.37.1.tgz",
+      "integrity": "sha512-jLXEEYSbqMkT/FuJLBZAVWGuhIb4JNwHE9kPTorAVmsdZ4UzHAfgWxLsVtD7pLRFaOwYPhNG9nUCpmFL1t/dIg=="
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "dev": true,

+ 1 - 0
ux/package.json

@@ -73,6 +73,7 @@
     "markdown-it-sup": "1.0.0",
     "markdown-it-task-lists": "2.1.1",
     "mitt": "3.0.0",
+    "monaco-editor": "0.37.1",
     "pako": "2.1.0",
     "pinia": "2.0.33",
     "prosemirror-commands": "1.5.1",

+ 14 - 1
ux/quasar.config.js

@@ -39,7 +39,11 @@ module.exports = configure(function (/* ctx */) {
       'apollo',
       'components',
       'eventbus',
-      'i18n'
+      'i18n',
+      {
+        server: false,
+        path: 'monaco'
+      }
     ],
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
@@ -99,6 +103,15 @@ module.exports = configure(function (/* ctx */) {
           'prosemirror-model',
           'prosemirror-view'
         ]
+
+        viteConf.build.rollupOptions = {
+          external: ['monaco-editor'],
+          output: {
+            globals: {
+              'monaco-editor': 'monaco-editor'
+            }
+          }
+        }
       },
       // viteVuePluginOptions: {},
 

+ 23 - 0
ux/src/boot/monaco.js

@@ -0,0 +1,23 @@
+import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
+import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
+import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
+import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
+import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
+
+self.MonacoEnvironment = {
+  getWorker (_, label) {
+    if (label === 'json') {
+      return new JsonWorker()
+    }
+    if (label === 'css' || label === 'scss' || label === 'less') {
+      return new CssWorker()
+    }
+    if (label === 'html' || label === 'handlebars' || label === 'razor') {
+      return new HtmlWorker()
+    }
+    if (label === 'typescript' || label === 'javascript') {
+      return new TsWorker()
+    }
+    return new EditorWorker()
+  }
+}

+ 170 - 106
ux/src/components/EditorMarkdown.vue

@@ -201,10 +201,10 @@
           )
           q-tooltip(anchor='top middle' self='bottom middle') {{ t('editor.togglePreviewPane') }}
       //--------------------------------------------------------
-      //- CODEMIRROR
+      //- MONACO EDITOR
       //--------------------------------------------------------
       .editor-markdown-editor
-        textarea(ref='cmRef')
+        div(ref='monacoRef')
     transition(name='editor-markdown-preview')
       .editor-markdown-preview(v-if='state.previewShown')
         .editor-markdown-preview-toolbar
@@ -238,30 +238,14 @@ import { useMeta, useQuasar, setCssVar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { get, flatten, last, times, startsWith, debounce } from 'lodash-es'
 import { DateTime } from 'luxon'
+import * as monaco from 'monaco-editor'
+import { Position, Range } from 'monaco-editor'
+import { WorkspaceEdit } from '../helpers/monacoTypes'
 
 import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
-// Code Mirror
-import CodeMirror from 'codemirror'
-import 'codemirror/lib/codemirror.css'
-import '../css/codemirror.scss'
-
-// Language
-import 'codemirror/mode/markdown/markdown.js'
-
-// Addons
-import 'codemirror/addon/selection/active-line.js'
-import 'codemirror/addon/display/fullscreen.js'
-import 'codemirror/addon/display/fullscreen.css'
-import 'codemirror/addon/selection/mark-selection.js'
-import 'codemirror/addon/search/searchcursor.js'
-import 'codemirror/addon/hint/show-hint.js'
-import 'codemirror/addon/fold/foldcode.js'
-import 'codemirror/addon/fold/foldgutter.js'
-import 'codemirror/addon/fold/foldgutter.css'
-
 // Markdown Renderer
 import { MarkdownRenderer } from 'src/renderers/markdown'
 
@@ -281,8 +265,9 @@ const { t } = useI18n()
 
 // STATE
 
+let editor
 const cm = shallowRef(null)
-const cmRef = ref(null)
+const monacoRef = ref(null)
 
 const state = reactive({
   previewShown: true,
@@ -326,8 +311,8 @@ function insertTable () {
 }
 
 /**
- * Set current line as header
- */
+* Set current line as header
+*/
 function setHeaderLine (lvl) {
   const curLine = cm.value.doc.getCursor('head').line
   let lineContent = cm.value.doc.getLine(curLine)
@@ -340,8 +325,8 @@ function setHeaderLine (lvl) {
 }
 
 /**
- * Get the header lever of the current line
- */
+* Get the header lever of the current line
+*/
 function getHeaderLevel (cm) {
   const curLine = cm.doc.getCursor('head').line
   const lineContent = cm.doc.getLine(curLine)
@@ -354,16 +339,16 @@ function getHeaderLevel (cm) {
 }
 
 /**
- * Insert content at cursor
- */
+* Insert content at cursor
+*/
 function insertAtCursor ({ content }) {
   const cursor = cm.value.doc.getCursor('head')
   cm.value.doc.replaceRange(content, cursor)
 }
 
 /**
- * Insert content after current line
- */
+* Insert content after current line
+*/
 function insertAfter ({ content, newLine }) {
   const curLine = cm.value.doc.getCursor('to').line
   const lineLength = cm.value.doc.getLine(curLine).length
@@ -371,8 +356,8 @@ function insertAfter ({ content, newLine }) {
 }
 
 /**
- * Insert content before current line
- */
+* Insert content before current line
+*/
 function insertBeforeEachLine ({ content, after }) {
   let lines = []
   if (!cm.value.doc.somethingSelected()) {
@@ -399,27 +384,40 @@ function insertBeforeEachLine ({ content, after }) {
 }
 
 /**
- * Insert an Horizontal Bar
- */
+* Insert an Horizontal Bar
+*/
 function insertHorizontalBar () {
   insertAfter({ content: '---', newLine: true })
 }
 
 /**
- * Toggle Markup at selection
- */
-function toggleMarkup ({ start, end }) {
+* Toggle Markup at selection
+*/
+async function toggleMarkup ({ start, end }) {
   if (!end) { end = start }
-  if (!cm.value.doc.somethingSelected()) {
+  if (!editor.getSelection()) {
     return $q.notify({
       type: 'negative',
       message: t('editor.markup.noSelectionError')
     })
   }
-  cm.value.doc.replaceSelections(cm.value.doc.getSelections().map(s => start + s + end))
-}
 
-const onCmInput = debounce(processContent, 500)
+  const edits = []
+
+  for (const selection of editor.getSelections()) {
+    const selectedText = editor.getModel().getValueInRange(selection)
+    if (!selectedText) {
+      const word = editor.getModel().getWordAtPosition(selection.getPosition())
+    }
+    if (selectedText.startsWith(start) && selectedText.endsWith(end)) {
+      edits.push({ range: selection, text: selectedText.substring(start.length, selectedText.length - end.length) })
+    } else {
+      edits.push({ range: selection, text: `${start}${selectedText}${end}` })
+    }
+  }
+
+  editor.executeEdits('', edits)
+}
 
 function processContent (newContent) {
   pageStore.$patch({
@@ -435,75 +433,137 @@ onMounted(async () => {
     hideSideNav: true
   })
 
-  // -> Initialize CodeMirror
-  cm.value = CodeMirror.fromTextArea(cmRef.value, {
-    tabSize: 2,
-    mode: 'text/markdown',
-    theme: 'wikijs-dark',
-    lineNumbers: true,
-    lineWrapping: true,
-    line: true,
-    styleActiveLine: true,
-    highlightSelectionMatches: {
-      annotateScrollbar: true
-    },
-    viewportMargin: 50,
-    inputStyle: 'contenteditable',
-    allowDropFileTypes: ['image/jpg', 'image/png', 'image/svg', 'image/jpeg', 'image/gif'],
-    // direction: siteConfig.rtl ? 'rtl' : 'ltr',
-    foldGutter: true,
-    gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
+  // -> Define Monaco Theme
+  monaco.editor.defineTheme('wikijs', {
+    base: 'vs-dark',
+    inherit: true,
+    rules: [],
+    colors: {
+      'editor.background': '#070a0d',
+      'editor.lineHighlightBackground': '#0d1117',
+      'editorLineNumber.foreground': '#546e7a',
+      'editorGutter.background': '#1e232a'
+    }
+  })
+
+  // -> Initialize Monaco Editor
+  editor = monaco.editor.create(monacoRef.value, {
+    value: pageStore.content,
+    language: 'markdown',
+    theme: 'wikijs',
+    automaticLayout: true,
+    scrollBeyondLastLine: false,
+    fontSize: 16,
+    formatOnType: true,
+    lineNumbersMinChars: 3
+  })
+
+  window.edd = editor
+
+  // -> Define Formatting Actions
+  editor.addAction({
+    contextMenuGroupId: 'markdown.extension.editing',
+    contextMenuOrder: 0,
+    id: 'markdown.extension.editing.toggleBold',
+    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB],
+    label: 'Toggle bold',
+    precondition: '',
+    run (ed) {
+      toggleMarkup({ start: '**' })
+    }
   })
 
-  cm.value.setValue(pageStore.content)
-  cm.value.on('change', c => {
+  editor.addAction({
+    contextMenuGroupId: 'markdown.extension.editing',
+    contextMenuOrder: 0,
+    id: 'markdown.extension.editing.toggleItalic',
+    keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI],
+    label: 'Toggle italic',
+    precondition: '',
+    run (ed) {
+      toggleMarkup({ start: '*' })
+    }
+  })
+
+  editor.onDidChangeModelContent(debounce(ev => {
     editorStore.$patch({
       lastChangeTimestamp: DateTime.utc()
     })
     pageStore.$patch({
-      content: c.getValue()
+      content: editor.getValue()
     })
-    onCmInput(pageStore.content)
-  })
+    processContent(pageStore.content)
+  }, 500))
+
+  // -> Initialize CodeMirror
+  // cm.value = CodeMirror.fromTextArea(cmRef.value, {
+  //   tabSize: 2,
+  //   mode: 'text/markdown',
+  //   theme: 'wikijs-dark',
+  //   lineNumbers: true,
+  //   lineWrapping: true,
+  //   line: true,
+  //   styleActiveLine: true,
+  //   highlightSelectionMatches: {
+  //     annotateScrollbar: true
+  //   },
+  //   viewportMargin: 50,
+  //   inputStyle: 'contenteditable',
+  //   allowDropFileTypes: ['image/jpg', 'image/png', 'image/svg', 'image/jpeg', 'image/gif'],
+  //   // direction: siteConfig.rtl ? 'rtl' : 'ltr',
+  //   foldGutter: true,
+  //   gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
+  // })
 
-  cm.value.setSize(null, '100%')
+  // cm.value.setValue(pageStore.content)
+  // cm.value.on('change', c => {
+  //   editorStore.$patch({
+  //     lastChangeTimestamp: DateTime.utc()
+  //   })
+  //   pageStore.$patch({
+  //     content: c.getValue()
+  //   })
+  //   onCmInput(pageStore.content)
+  // })
+
+  // cm.value.setSize(null, '100%')
 
   // -> Set Keybindings
-  const keyBindings = {
-    'F11' (c) {
-      c.setOption('fullScreen', !c.getOption('fullScreen'))
-    },
-    'Esc' (c) {
-      if (c.getOption('fullScreen')) {
-        c.setOption('fullScreen', false)
-      }
-    },
-    [`${CtrlKey}-S`] (c) {
-      // save()
-      return false
-    },
-    [`${CtrlKey}-B`] (c) {
-      toggleMarkup({ start: '**' })
-      return false
-    },
-    [`${CtrlKey}-I`] (c) {
-      toggleMarkup({ start: '*' })
-      return false
-    },
-    [`${CtrlKey}-Alt-Right`] (c) {
-      let lvl = getHeaderLevel(c)
-      if (lvl >= 6) { lvl = 5 }
-      setHeaderLine(lvl + 1)
-      return false
-    },
-    [`${CtrlKey}-Alt-Left`] (c) {
-      let lvl = getHeaderLevel(c)
-      if (lvl <= 1) { lvl = 2 }
-      setHeaderLine(lvl - 1)
-      return false
-    }
-  }
-  cm.value.setOption('extraKeys', keyBindings)
+  // const keyBindings = {
+  //   'F11' (c) {
+  //     c.setOption('fullScreen', !c.getOption('fullScreen'))
+  //   },
+  //   'Esc' (c) {
+  //     if (c.getOption('fullScreen')) {
+  //       c.setOption('fullScreen', false)
+  //     }
+  //   },
+  //   [`${CtrlKey}-S`] (c) {
+  //     // save()
+  //     return false
+  //   },
+  //   [`${CtrlKey}-B`] (c) {
+  //     toggleMarkup({ start: '**' })
+  //     return false
+  //   },
+  //   [`${CtrlKey}-I`] (c) {
+  //     toggleMarkup({ start: '*' })
+  //     return false
+  //   },
+  //   [`${CtrlKey}-Alt-Right`] (c) {
+  //     let lvl = getHeaderLevel(c)
+  //     if (lvl >= 6) { lvl = 5 }
+  //     setHeaderLine(lvl + 1)
+  //     return false
+  //   },
+  //   [`${CtrlKey}-Alt-Left`] (c) {
+  //     let lvl = getHeaderLevel(c)
+  //     if (lvl <= 1) { lvl = 2 }
+  //     setHeaderLine(lvl - 1)
+  //     return false
+  //   }
+  // }
+  // cm.value.setOption('extraKeys', keyBindings)
   // this.cm.on('inputRead', this.autocomplete)
 
   // // Handle cursor movement
@@ -516,11 +576,11 @@ onMounted(async () => {
   // this.cm.on('paste', this.onCmPaste)
 
   // // Render initial preview
-  processContent(pageStore.content)
-  nextTick(() => {
-    cm.value.refresh()
-    cm.value.focus()
-  })
+  // processContent(pageStore.content)
+  // nextTick(() => {
+  //   cm.value.refresh()
+  //   cm.value.focus()
+  // })
 
   EVENT_BUS.on('insertAsset', insertAssetClb)
 
@@ -589,6 +649,10 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
     // @include until($tablet) {
     //   height: $editor-height-mobile;
     // }
+
+    > div {
+      height: 100%;
+    }
   }
   &-type {
     writing-mode: vertical-rl;
@@ -693,7 +757,7 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
   }
   &-toolbar {
     background-color: $primary;
-    border-left: 40px solid darken($primary, 5%);
+    border-left: 50px solid darken($primary, 5%);
     color: #FFF;
     height: 32px;
   }

+ 621 - 0
ux/src/helpers/monacoTypes.js

@@ -0,0 +1,621 @@
+// Adapted from https://github.com/trofimander/monaco-markdown/blob/master/src/ts/extHostTypes.ts
+// by https://github.com/trofimander
+// MIT Licensed
+
+// export function values<V = any>(set: Set<V>): V[];
+// export function values<K = any, V = any>(map: Map<K, V>): V[];
+export function values (forEachable) {
+  const result = []
+  forEachable.forEach(value => result.push(value))
+  return result
+}
+
+export class Position {
+  static Min (...positions) {
+    if (positions.length === 0) {
+      throw new TypeError()
+    }
+    let result = positions[0]
+    for (let i = 1; i < positions.length; i++) {
+      const p = positions[i]
+      if (p.isBefore(result)) {
+        result = p
+      }
+    }
+    return result
+  }
+
+  static Max (...positions) {
+    if (positions.length === 0) {
+      throw new TypeError()
+    }
+    let result = positions[0]
+    for (let i = 1; i < positions.length; i++) {
+      const p = positions[i]
+      if (p.isAfter(result)) {
+        result = p
+      }
+    }
+    return result
+  }
+
+  static isPosition (other) {
+    if (!other) {
+      return false
+    }
+    if (other instanceof Position) {
+      return true
+    }
+    const { line, character } = other
+    if (typeof line === 'number' && typeof character === 'number') {
+      return true
+    }
+    return false
+  }
+
+  get line () {
+    return this._line
+  }
+
+  get character () {
+    return this._character
+  }
+
+  constructor (line, character) {
+    if (line < 0) {
+      throw new Error('line must be non-negative')
+    }
+    if (character < 0) {
+      throw new Error('character must be non-negative')
+    }
+    this._line = line
+    this._character = character
+  }
+
+  isBefore (other) {
+    if (this._line < other._line) {
+      return true
+    }
+    if (other._line < this._line) {
+      return false
+    }
+    return this._character < other._character
+  }
+
+  isBeforeOrEqual (other) {
+    if (this._line < other._line) {
+      return true
+    }
+    if (other._line < this._line) {
+      return false
+    }
+    return this._character <= other._character
+  }
+
+  isAfter (other) {
+    return !this.isBeforeOrEqual(other)
+  }
+
+  isAfterOrEqual (other) {
+    return !this.isBefore(other)
+  }
+
+  isEqual (other) {
+    return this._line === other._line && this._character === other._character
+  }
+
+  compareTo (other) {
+    if (this._line < other._line) {
+      return -1
+    } else if (this._line > other.line) {
+      return 1
+    } else {
+      // equal line
+      if (this._character < other._character) {
+        return -1
+      } else if (this._character > other._character) {
+        return 1
+      } else {
+        // equal line and character
+        return 0
+      }
+    }
+  }
+
+  translate (lineDeltaOrChange, characterDelta = 0) {
+    if (lineDeltaOrChange === null || characterDelta === null) {
+      throw new Error()
+    }
+
+    let lineDelta
+    if (typeof lineDeltaOrChange === 'undefined') {
+      lineDelta = 0
+    } else if (typeof lineDeltaOrChange === 'number') {
+      lineDelta = lineDeltaOrChange
+    } else {
+      lineDelta = typeof lineDeltaOrChange.lineDelta === 'number' ? lineDeltaOrChange.lineDelta : 0
+      characterDelta = typeof lineDeltaOrChange.characterDelta === 'number' ? lineDeltaOrChange.characterDelta : 0
+    }
+
+    if (lineDelta === 0 && characterDelta === 0) {
+      return this
+    }
+    return new Position(this.line + lineDelta, this.character + characterDelta)
+  }
+
+  with (lineOrChange, character = this.character) {
+    if (lineOrChange === null || character === null) {
+      throw new Error()
+    }
+
+    let line
+    if (typeof lineOrChange === 'undefined') {
+      line = this.line
+    } else if (typeof lineOrChange === 'number') {
+      line = lineOrChange
+    } else {
+      line = typeof lineOrChange.line === 'number' ? lineOrChange.line : this.line
+      character = typeof lineOrChange.character === 'number' ? lineOrChange.character : this.character
+    }
+
+    if (line === this.line && character === this.character) {
+      return this
+    }
+    return new Position(line, character)
+  }
+
+  toJSON () {
+    return { line: this.line, character: this.character }
+  }
+}
+
+export class Range {
+  static isRange (thing) {
+    if (thing instanceof Range) {
+      return true
+    }
+    if (!thing) {
+      return false
+    }
+    return Position.isPosition(thing.start) && Position.isPosition(thing.end)
+  }
+
+  get start () {
+    return this._start
+  }
+
+  get end () {
+    return this._end
+  }
+
+  constructor (startLineOrStart, startColumnOrEnd, endLine, endColumn) {
+    let start
+    let end
+
+    if (typeof startLineOrStart === 'number' && typeof startColumnOrEnd === 'number' && typeof endLine === 'number' && typeof endColumn === 'number') {
+      start = new Position(startLineOrStart, startColumnOrEnd)
+      end = new Position(endLine, endColumn)
+    } else if (startLineOrStart instanceof Position && startColumnOrEnd instanceof Position) {
+      start = startLineOrStart
+      end = startColumnOrEnd
+    }
+
+    if (!start || !end) {
+      throw new Error('Invalid arguments')
+    }
+
+    if (start.isBefore(end)) {
+      this._start = start
+      this._end = end
+    } else {
+      this._start = end
+      this._end = start
+    }
+  }
+
+  contains (positionOrRange) {
+    if (positionOrRange instanceof Range) {
+      return this.contains(positionOrRange._start) &&
+      this.contains(positionOrRange._end)
+    } else if (positionOrRange instanceof Position) {
+      if (positionOrRange.isBefore(this._start)) {
+        return false
+      }
+      if (this._end.isBefore(positionOrRange)) {
+        return false
+      }
+      return true
+    }
+    return false
+  }
+
+  isEqual (other) {
+    return this._start.isEqual(other._start) && this._end.isEqual(other._end)
+  }
+
+  intersection (other) {
+    const start = Position.Max(other.start, this._start)
+    const end = Position.Min(other.end, this._end)
+    if (start.isAfter(end)) {
+      // this happens when there is no overlap:
+      // |-----|
+      //          |----|
+      return undefined
+    }
+    return new Range(start, end)
+  }
+
+  union (other) {
+    if (this.contains(other)) {
+      return this
+    } else if (other.contains(this)) {
+      return other
+    }
+    const start = Position.Min(other.start, this._start)
+    const end = Position.Max(other.end, this.end)
+    return new Range(start, end)
+  }
+
+  get isEmpty () {
+    return this._start.isEqual(this._end)
+  }
+
+  get isSingleLine () {
+    return this._start.line === this._end.line
+  }
+
+  with (startOrChange, end = this.end) {
+    if (startOrChange === null || end === null) {
+      throw new Error()
+    }
+
+    let start
+    if (!startOrChange) {
+      start = this.start
+    } else if (Position.isPosition(startOrChange)) {
+      start = startOrChange
+    } else {
+      start = startOrChange.start || this.start
+      end = startOrChange.end || this.end
+    }
+
+    if (start.isEqual(this._start) && end.isEqual(this.end)) {
+      return this
+    }
+    return new Range(start, end)
+  }
+
+  toJSON () {
+    return [this.start, this.end]
+  }
+}
+
+export class Selection extends Range {
+  static isSelection (thing) {
+    if (thing instanceof Selection) {
+      return true
+    }
+    if (!thing) {
+      return false
+    }
+    return Range.isRange(thing) &&
+      Position.isPosition(thing.anchor) &&
+      Position.isPosition(thing.active) &&
+      typeof thing.isReversed === 'boolean'
+  }
+
+  get anchor () {
+    return this._anchor
+  }
+
+  get active () {
+    return this._active
+  }
+
+  constructor (anchorLineOrAnchor, anchorColumnOrActive, activeLine, activeColumn) {
+    let anchor
+    let active
+
+    if (typeof anchorLineOrAnchor === 'number' && typeof anchorColumnOrActive === 'number' && typeof activeLine === 'number' && typeof activeColumn === 'number') {
+      anchor = new Position(anchorLineOrAnchor, anchorColumnOrActive)
+      active = new Position(activeLine, activeColumn)
+    } else if (anchorLineOrAnchor instanceof Position && anchorColumnOrActive instanceof Position) {
+      anchor = anchorLineOrAnchor
+      active = anchorColumnOrActive
+    }
+
+    if (!anchor || !active) {
+      throw new Error('Invalid arguments')
+    }
+
+    super(anchor, active)
+
+    this._anchor = anchor
+    this._active = active
+  }
+
+  get isReversed () {
+    return this._anchor === this._end
+  }
+
+  toJSON () {
+    return {
+      start: this.start,
+      end: this.end,
+      active: this.active,
+      anchor: this.anchor
+    }
+  }
+}
+
+export const EndOfLine = {
+  LF: 1,
+  CRLF: 2
+}
+
+export class TextEdit {
+  static isTextEdit (thing) {
+    if (thing instanceof TextEdit) {
+      return true
+    }
+    if (!thing) {
+      return false
+    }
+    return Range.isRange(thing) && typeof thing.newText === 'string'
+  }
+
+  static replace (range, newText) {
+    return new TextEdit(range, newText)
+  }
+
+  static insert (position, newText) {
+    return TextEdit.replace(new Range(position, position), newText)
+  }
+
+  static delete (range) {
+    return TextEdit.replace(range, '')
+  }
+
+  static setEndOfLine (eol) {
+    const ret = new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), '')
+    ret.newEol = eol
+    return ret
+  }
+
+  get range () {
+    return this._range
+  }
+
+  set range (value) {
+    if (value && !Range.isRange(value)) {
+      throw new Error('range')
+    }
+    this._range = value
+  }
+
+  get newText () {
+    return this._newText || ''
+  }
+
+  set newText (value) {
+    if (value && typeof value !== 'string') {
+      throw new Error('newText')
+    }
+    this._newText = value
+  }
+
+  get newEol () {
+    return this._newEol
+  }
+
+  set newEol (value) {
+    if (value && typeof value !== 'number') {
+      throw new Error('newEol')
+    }
+    this._newEol = value
+  }
+
+  constructor (range, newText) {
+    this.range = range
+    this._newText = newText
+  }
+
+  toJSON () {
+    return {
+      range: this.range,
+      newText: this.newText,
+      newEol: this._newEol
+    }
+  }
+}
+
+export class WorkspaceEdit {
+  constructor () {
+    this._edits = []
+  }
+
+  renameFile (from, to, options) {
+    this._edits.push({ _type: 1, from, to, options })
+  }
+
+  createFile (uri, options) {
+    this._edits.push({ _type: 1, from: undefined, to: uri, options })
+  }
+
+  deleteFile (uri, options) {
+    this._edits.push({ _type: 1, from: uri, to: undefined, options })
+  }
+
+  replace (uri, range, newText) {
+    this._edits.push({ _type: 2, uri, edit: new TextEdit(range, newText) })
+  }
+
+  insert (resource, position, newText) {
+    this.replace(resource, new Range(position, position), newText)
+  }
+
+  delete (resource, range) {
+    this.replace(resource, range, '')
+  }
+
+  has (uri) {
+    for (const edit of this._edits) {
+      if (edit._type === 2 && edit.uri.toString() === uri.toString()) {
+        return true
+      }
+    }
+    return false
+  }
+
+  set (uri, edits) {
+    if (!edits) {
+      // remove all text edits for `uri`
+      for (let i = 0; i < this._edits.length; i++) {
+        const element = this._edits[i]
+        if (element._type === 2 && element.uri.toString() === uri.toString()) {
+          this._edits[i] = undefined // will be coalesced down below
+        }
+      }
+      // this._edits = coalesce(this._edits); TODO
+    } else {
+      // append edit to the end
+      for (const edit of edits) {
+        if (edit) {
+          this._edits.push({ _type: 2, uri, edit })
+        }
+      }
+    }
+  }
+
+  get (uri) {
+    const res = []
+    for (const candidate of this._edits) {
+      if (candidate._type === 2 && candidate.uri.toString() === uri.toString()) {
+        res.push(candidate.edit)
+      }
+    }
+    return res
+  }
+
+  entries () {
+    const textEdits = new Map()
+    for (const candidate of this._edits) {
+      if (candidate._type === 2) {
+        let textEdit = textEdits.get(candidate.uri.toString())
+        if (!textEdit) {
+          textEdit = [candidate.uri, []]
+          textEdits.set(candidate.uri.toString(), textEdit)
+        }
+        textEdit[1].push(candidate.edit)
+      }
+    }
+    return values(textEdits)
+  }
+
+  _allEntries () {
+    const res = []
+    for (const edit of this._edits) {
+      if (edit._type === 1) {
+        res.push([edit.from, edit.to, edit.options])
+      } else {
+        res.push([edit.uri, [edit.edit]])
+      }
+    }
+    return res
+  }
+
+  get size () {
+    return this.entries().length
+  }
+
+  toJSON () {
+    return this.entries()
+  }
+}
+
+export const TextEditorRevealType = {
+  Default: 0,
+  InCenter: 1,
+  InCenterIfOutsideViewport: 2,
+  AtTop: 3
+}
+
+export const TextEditorSelectionChangeKind = {
+  Keyboard: 1,
+  Mouse: 2,
+  Command: 3
+}
+
+export class SnippetString {
+  static isSnippetString (thing) {
+    if (thing instanceof SnippetString) {
+      return true
+    }
+    if (!thing) {
+      return false
+    }
+    return typeof thing.value === 'string'
+  }
+
+  static _escape (value) {
+    return value.replace(/\$|}|\\/g, '\\$&')
+  }
+
+  constructor (value) {
+    this._tabstop = 1
+    this.value = value || ''
+  }
+
+  appendText (string) {
+    this.value += SnippetString._escape(string)
+    return this
+  }
+
+  appendTabstop (number = this._tabstop++) {
+    this.value += '$'
+    this.value += number
+    return this
+  }
+
+  appendPlaceholder (value, number = this._tabstop++) {
+    if (typeof value === 'function') {
+      const nested = new SnippetString()
+      nested._tabstop = this._tabstop
+      value(nested)
+      this._tabstop = nested._tabstop
+      value = nested.value
+    } else {
+      value = SnippetString._escape(value)
+    }
+
+    this.value += '${'
+    this.value += number
+    this.value += ':'
+    this.value += value
+    this.value += '}'
+
+    return this
+  }
+
+  appendVariable (name, defaultValue) {
+    if (typeof defaultValue === 'function') {
+      const nested = new SnippetString()
+      nested._tabstop = this._tabstop
+      defaultValue(nested)
+      this._tabstop = nested._tabstop
+      defaultValue = nested.value
+    } else if (typeof defaultValue === 'string') {
+      defaultValue = defaultValue.replace(/\$|}/g, '\\$&')
+    }
+
+    this.value += '${'
+    this.value += name
+    if (defaultValue) {
+      this.value += ':'
+      this.value += defaultValue
+    }
+    this.value += '}'
+
+    return this
+  }
+}