Skip to content

Commit

Permalink
add y-undomanager
Browse files Browse the repository at this point in the history
  • Loading branch information
dmonad committed May 25, 2021
1 parent a4de4e4 commit 40c1c2a
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 14 deletions.
6 changes: 5 additions & 1 deletion demo/codemirror.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import * as Y from 'yjs'
// @ts-ignore
import { yCollab } from 'y-codemirror.next'
import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next'
import { WebrtcProvider } from 'y-webrtc'

import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
import { keymap } from '@codemirror/view'
import { javascript } from '@codemirror/lang-javascript'
// import { oneDark } from '@codemirror/next/theme-one-dark'

Expand Down Expand Up @@ -38,6 +39,9 @@ provider.awareness.setLocalStateField('user', {
const state = EditorState.create({
doc: ytext.toString(),
extensions: [
keymap.of([
...yUndoManagerKeymap
]),
basicSetup,
javascript(),
yCollab(ytext, provider.awareness)
Expand Down
4 changes: 2 additions & 2 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ const minificationPlugins = process.env.PRODUCTION != null
mangle: {
toplevel: true
}
})]
: []
})
] : []

export default [{
input: './src/y-codemirror.js',
Expand Down
28 changes: 22 additions & 6 deletions src/y-codemirror.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@

import * as Y from 'yjs' // eslint-disable-line
import { EditorView } from '@codemirror/view'
import { Extension } from '@codemirror/state' // eslint-disable-line

import { ySync, ySyncFacet, YSyncConfig } from './y-sync.js'
import { yRemoteSelections, yRemoteSelectionsTheme } from './y-remote-selections.js'
// import { yUndoManager } from './y-undomanager.js'
import { yUndoManager, yUndoManagerFacet, YUndoManagerConfig, undo, redo, yUndoManagerKeymap } from './y-undomanager.js'

export { yRemoteSelections, yRemoteSelectionsTheme, ySync, ySyncFacet, YSyncConfig }
export { yRemoteSelections, yRemoteSelectionsTheme, ySync, ySyncFacet, YSyncConfig, yUndoManagerKeymap }

/**
* @param {Y.Text} ytext
* @param {any} awareness
* @return {Extension}
*/
export const yCollab = (ytext, awareness) => {
export const yCollab = (ytext, awareness, { undoManager = new Y.UndoManager(ytext) } = {}) => {
const syncConf = ySyncFacet.of(new YSyncConfig(ytext, awareness))
// By default, only track changes that are produced by the sync plugin (local edits)
undoManager.trackedOrigins.add(YSyncConfig)
const undoManagerConf = yUndoManagerFacet.of(new YUndoManagerConfig(undoManager))
const plugins = [
ySyncFacet.of(new YSyncConfig(ytext, awareness)),
ySync
syncConf,
undoManagerConf,
// yUndoManager must be included before the sync plugin
yUndoManager,
ySync,
EditorView.domEventHandlers({
beforeinput (e, view) {
if (e.inputType === 'historyUndo') return undo(view)
if (e.inputType === 'historyRedo') return redo(view)
return false
}
})
]
if (awareness) {
plugins.push(
yRemoteSelectionsTheme,
yRemoteSelections
// yUndoManager
)
}
return plugins
Expand Down
29 changes: 29 additions & 0 deletions src/y-range.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as Y from 'yjs'

export class YRange {
/**
* @param {Y.RelativePosition} yanchor
* @param {Y.RelativePosition} yhead
*/
constructor (yanchor, yhead) {
this.yanchor = yanchor
this.yhead = yhead
}

/**
* @returns {any}
*/
toJSON () {
return {
yanchor: Y.relativePositionToJSON(this.yanchor),
yhead: Y.relativePositionToJSON(this.yhead)
}
}

/**
* @param {any} json
*/
static fromJSON (json) {
return new YRange(Y.createRelativePositionFromJSON(json.yanchor), Y.createRelativePositionFromJSON(json.yhead))
}
}
12 changes: 7 additions & 5 deletions src/y-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as Y from 'yjs'
import { Facet, Annotation, AnnotationType, SelectionRange, EditorSelection } from '@codemirror/state' // eslint-disable-line
import { ViewPlugin, ViewUpdate, EditorView } from '@codemirror/view' // eslint-disable-line
import { YRange } from './y-range'

export class YSyncConfig {
constructor (ytext, awareness) {
Expand Down Expand Up @@ -34,18 +35,19 @@ export class YSyncConfig {

/**
* @param {SelectionRange} range
* @return {YRange}
*/
toYSelectionRange (range) {
toYRange (range) {
const assoc = range.assoc
const yanchor = this.toYPos(range.anchor, assoc)
const yhead = this.toYPos(range.head, assoc)
return { yanchor, yhead }
return new YRange(yanchor, yhead)
}

/**
* @param {any} yrange
* @param {YRange} yrange
*/
fromYSelectionRange (yrange) {
fromYRange (yrange) {
const anchor = this.fromYPos(yrange.yanchor)
const head = this.fromYPos(yrange.yhead)
if (anchor.pos === head.pos) {
Expand Down Expand Up @@ -104,7 +106,7 @@ class YSyncPluginValue {
* @param {ViewUpdate} update
*/
update (update) {
if (update.transactions.length > 0 && update.transactions[0].annotation(ySyncAnnotation) === this.conf) {
if (!update.docChanged || (update.transactions.length > 0 && update.transactions[0].annotation(ySyncAnnotation) === this.conf)) {
return
}
const ytext = this.conf.ytext
Expand Down
153 changes: 153 additions & 0 deletions src/y-undomanager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import * as Y from 'yjs' // eslint-disable-line
import {
EditorState, StateCommand, Facet, Annotation, AnnotationType // eslint-disable-line
} from '@codemirror/state'

import { ViewPlugin, ViewUpdate, KeyBinding, EditorView } from '@codemirror/view' // eslint-disable-line
import { ySyncFacet } from './y-sync.js'
import { YRange } from './y-range.js' // eslint-disable-line
import { createMutex } from 'lib0/mutex'

export class YUndoManagerConfig {
/**
* @param {Y.UndoManager} undoManager
*/
constructor (undoManager) {
this.undoManager = undoManager
}

/**
* @param {any} origin
*/
addTrackedOrigin (origin) {
this.undoManager.trackedOrigins.add(origin)
}

/**
* @param {any} origin
*/
removeTrackedOrigin (origin) {
this.undoManager.trackedOrigins.delete(origin)
}

/**
* @return {boolean} Whether a change was undone.
*/
undo () {
return this.undoManager.undo() != null
}

/**
* @return {boolean} Whether a change was redone.
*/
redo () {
return this.undoManager.redo() != null
}
}

/**
* @type {Facet<YUndoManagerConfig, YUndoManagerConfig>}
*/
export const yUndoManagerFacet = Facet.define({
combine (inputs) {
return inputs[inputs.length - 1]
}
})

/**
* @type {AnnotationType<YUndoManagerConfig>}
*/
export const yUndoManagerAnnotation = Annotation.define()

/**
* @extends {PluginValue}
*/
class YUndoManagerPluginValue {
/**
* @param {EditorView} view
*/
constructor (view) {
this.view = view
this.conf = view.state.facet(yUndoManagerFacet)
this.syncConf = view.state.facet(ySyncFacet)
/**
* @type {null | YRange}
*/
this._beforeChangeSelection = null
this._mux = createMutex()

this._onStackItemAdded = ({ stackItem, changedParentTypes }) => {
// only store metadata if this type was affected
if (changedParentTypes.has(this.syncConf.ytext) && this._beforeChangeSelection) {
stackItem.meta.set(this, this._beforeChangeSelection)
}
}
this._onStackItemPopped = ({ stackItem }) => {
const sel = stackItem.meta.get(this)
if (sel) {
const selection = this.syncConf.fromYRange(sel)
view.dispatch(view.state.update({ selection }))
this._beforeChange()
}
}
/**
* Do this without mutex, simply use the sync annotation
*/
this._beforeChange = () => {
// update the the beforeChangeSelection that is stored befor each change to the editor (except when applying remote changes)
this._mux(() => {
// store the selection before the change is applied so we can restore it with the undo manager.
this._beforeChangeSelection = this.syncConf.toYRange(this.view.state.selection.main)
})
}
this.conf.undoManager.on('stack-item-added', this._onStackItemAdded)
this.conf.undoManager.on('stack-item-popped', this._onStackItemPopped)
}

/**
* @param {ViewUpdate} update
*/
update (update) {
// This only works when YUndoManagerPlugin is included before the sync plugin
this._beforeChange()
}

destroy () {
this.conf.undoManager.off('stack-item-added', this._onStackItemAdded)
this.conf.undoManager.off('stack-item-popped', this._onStackItemPopped)
}
}
export const yUndoManager = ViewPlugin.fromClass(YUndoManagerPluginValue)

/**
* @type {StateCommand}
*/
export const undo = ({ state, dispatch }) =>
state.facet(yUndoManagerFacet).undo() || true

/**
* @type {StateCommand}
*/
export const redo = ({ state, dispatch }) =>
state.facet(yUndoManagerFacet).redo() || true

/**
* @param {EditorState} state
* @return {number}
*/
export const undoDepth = state => state.facet(yUndoManagerFacet).undoManager.undoStack.length

/**
* @param {EditorState} state
* @return {number}
*/
export const redoDepth = state => state.facet(yUndoManagerFacet).undoManager.redoStack.length

/**
* Default key bindigs for the undo manager.
* @type {Array<KeyBinding>}
*/
export const yUndoManagerKeymap = [
{ key: 'Mod-z', run: undo, preventDefault: true },
{ key: 'Mod-y', mac: 'Mod-Shift-z', run: redo, preventDefault: true }
]

0 comments on commit 40c1c2a

Please sign in to comment.