@bangle.dev/collab-client
Advanced tools
Comparing version 0.30.0-alpha.4 to 0.30.0-alpha.5
@@ -19,3 +19,4 @@ /** | ||
import { plugins, pullChanges } from '../src/collab-extension'; | ||
import { plugins } from '../src/collab-extension'; | ||
import { onUpstreamChanges } from '../src/commands'; | ||
@@ -56,4 +57,4 @@ waitForExpect.defaults.timeout = 500; | ||
docChangeEmitter.on('doc_changed', () => { | ||
pullChanges()(editor.view.state, editor.view.dispatch); | ||
docChangeEmitter.on('doc_changed', ({ version }: { version: number }) => { | ||
onUpstreamChanges(version)(editor.view.state, editor.view.dispatch); | ||
}); | ||
@@ -68,5 +69,2 @@ | ||
typeText, | ||
pullFromServer() { | ||
docChangeEmitter.emit('doc_changed', {}); | ||
}, | ||
}; | ||
@@ -109,6 +107,9 @@ }; | ||
}, | ||
applyCollabState: () => { | ||
applyCollabState: (docName, newCollabState) => { | ||
queueMicrotask(() => { | ||
if (emitDocChangeEvent) { | ||
docChangeEmitter.emit('doc_changed', {}); | ||
docChangeEmitter.emit('doc_changed', { | ||
docName, | ||
version: newCollabState.version, | ||
}); | ||
} | ||
@@ -384,3 +385,2 @@ }); | ||
console.log('TYPING HELLO'); | ||
client2.typeText('hello', 6); | ||
@@ -453,2 +453,3 @@ await sleep(); | ||
await sleep(500); | ||
await waitForExpect(async () => { | ||
@@ -655,27 +656,31 @@ expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
const fakeClientId = 'fakeClientId'; | ||
await manager.handleRequest({ | ||
type: CollabRequestType.PushEvents, | ||
payload: { | ||
clientID: fakeClientId, | ||
version: manager.getCollabState(docName)?.version!, | ||
managerId: manager.managerId, | ||
steps: [ | ||
{ | ||
stepType: 'replace', | ||
from: 1, | ||
to: 1, | ||
slice: { | ||
content: [ | ||
{ | ||
type: 'text', | ||
text: 'very ', | ||
expect( | ||
( | ||
await manager.handleRequest({ | ||
type: CollabRequestType.PushEvents, | ||
payload: { | ||
clientID: fakeClientId, | ||
version: manager.getCollabState(docName)?.version!, | ||
managerId: manager.managerId, | ||
steps: [ | ||
{ | ||
stepType: 'replace', | ||
from: 1, | ||
to: 1, | ||
slice: { | ||
content: [ | ||
{ | ||
type: 'text', | ||
text: 'very ', | ||
}, | ||
], | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
docName, | ||
userId: 'test-' + fakeClientId, | ||
}, | ||
], | ||
docName, | ||
userId: 'test-' + fakeClientId, | ||
}, | ||
}); | ||
}) | ||
).ok, | ||
).toBe(true); | ||
@@ -690,3 +695,2 @@ await waitForExpect(async () => { | ||
server.allowDocChangeEvent(); | ||
client1.typeText('wow '); | ||
@@ -693,0 +697,0 @@ expect(client1.debugString()).toEqual(`doc(paragraph("wow hello world!"))`); |
@@ -1,16 +0,78 @@ | ||
import { CollabManager } from '@bangle.dev/collab-server'; | ||
import { EditorState, Plugin, Command } from '@bangle.dev/pm'; | ||
import { CollabFail, CollabManager } from '@bangle.dev/collab-server'; | ||
import { Node, TextSelection, Command } from '@bangle.dev/pm'; | ||
import * as prosemirror_state from 'prosemirror-state'; | ||
interface CollabSettings { | ||
serverVersion: undefined | number; | ||
} | ||
declare type ValidStates = FatalErrorState | InitDocState | InitErrorState | InitState | PullState | PushPullErrorState | PushState | ReadyState; | ||
declare enum CollabStateName { | ||
FatalError = "FATAL_ERROR_STATE", | ||
Init = "INIT_STATE", | ||
InitDoc = "INIT_DOC_STATE", | ||
InitError = "INIT_ERROR_STATE", | ||
Pull = "PULL_STATE", | ||
Push = "PUSH_STATE", | ||
PushPullError = "PUSH_PULL_ERROR_STATE", | ||
Ready = "READY_STATE" | ||
} | ||
interface FatalErrorState { | ||
name: CollabStateName.FatalError; | ||
state: { | ||
message: string; | ||
}; | ||
} | ||
interface InitState { | ||
name: CollabStateName.Init; | ||
} | ||
interface InitDocState { | ||
name: CollabStateName.InitDoc; | ||
state: { | ||
initialDoc: Node; | ||
initialVersion: number; | ||
initialSelection?: TextSelection; | ||
managerId: string; | ||
}; | ||
} | ||
interface InitErrorState { | ||
name: CollabStateName.InitError; | ||
state: { | ||
failure: CollabFail; | ||
}; | ||
} | ||
interface ReadyState { | ||
name: CollabStateName.Ready; | ||
state: InitDocState['state']; | ||
} | ||
interface PushState { | ||
name: CollabStateName.Push; | ||
state: InitDocState['state']; | ||
} | ||
interface PullState { | ||
name: CollabStateName.Pull; | ||
state: InitDocState['state']; | ||
} | ||
interface PushPullErrorState { | ||
name: CollabStateName.PushPullError; | ||
state: { | ||
failure: CollabFail; | ||
initDocState: InitDocState['state']; | ||
}; | ||
} | ||
interface CollabPluginContext { | ||
readonly restartCount: number; | ||
readonly debugInfo: string | undefined; | ||
} | ||
interface CollabPluginState { | ||
context: CollabPluginContext; | ||
collabState: ValidStates; | ||
} | ||
declare function onUpstreamChanges(version: number): Command; | ||
declare const plugins: typeof pluginsFactory; | ||
declare const commands: { | ||
pullChanges: typeof pullChanges; | ||
onUpstreamChanges: typeof onUpstreamChanges; | ||
}; | ||
declare const RECOVERY_BACK_OFF = 50; | ||
interface CollabSettings { | ||
docName: string; | ||
clientID: string; | ||
userId: string; | ||
ready: boolean; | ||
} | ||
declare const getCollabSettings: (state: EditorState) => CollabSettings; | ||
declare function pluginsFactory({ clientID, docName, sendManagerRequest, retryWaitTime, }: { | ||
@@ -21,9 +83,3 @@ clientID: string; | ||
retryWaitTime?: number; | ||
}): () => (Plugin<any> | Plugin<null> | Plugin<{ | ||
docName: string; | ||
clientID: string; | ||
userId: string; | ||
ready: boolean; | ||
}>)[]; | ||
declare function pullChanges(): Command; | ||
}): (prosemirror_state.Plugin<any> | (prosemirror_state.Plugin<CollabPluginState> | prosemirror_state.Plugin<CollabSettings>)[])[]; | ||
@@ -33,4 +89,2 @@ declare const collabExtension_d_plugins: typeof plugins; | ||
declare const collabExtension_d_RECOVERY_BACK_OFF: typeof RECOVERY_BACK_OFF; | ||
declare const collabExtension_d_getCollabSettings: typeof getCollabSettings; | ||
declare const collabExtension_d_pullChanges: typeof pullChanges; | ||
declare namespace collabExtension_d { | ||
@@ -41,4 +95,2 @@ export { | ||
collabExtension_d_RECOVERY_BACK_OFF as RECOVERY_BACK_OFF, | ||
collabExtension_d_getCollabSettings as getCollabSettings, | ||
collabExtension_d_pullChanges as pullChanges, | ||
}; | ||
@@ -45,0 +97,0 @@ } |
import { getVersion, receiveTransaction, sendableSteps, collab } from 'prosemirror-collab'; | ||
import { PluginKey, Step, TextSelection, Selection, Node, Plugin } from '@bangle.dev/pm'; | ||
import { isTestEnv, abortableSetTimeout, uuid } from '@bangle.dev/utils'; | ||
import { CollabRequestType, CollabFail } from '@bangle.dev/collab-server'; | ||
import { PluginKey, Step, TextSelection, Selection, Node, Plugin } from '@bangle.dev/pm'; | ||
const collabClientKey = new PluginKey('bangle.dev/collab-client'); | ||
const collabSettingsKey = new PluginKey('bangle/collabSettingsKey'); | ||
var EventType; | ||
@@ -17,38 +19,81 @@ (function (EventType) { | ||
})(EventType || (EventType = {})); | ||
var StateName; | ||
(function (StateName) { | ||
StateName["FatalError"] = "FATAL_ERROR_STATE"; | ||
StateName["Init"] = "INIT_STATE"; | ||
StateName["InitDoc"] = "INIT_DOC_STATE"; | ||
StateName["InitError"] = "INIT_ERROR_STATE"; | ||
StateName["Pull"] = "PULL_STATE"; | ||
StateName["Push"] = "PUSH_STATE"; | ||
StateName["PushPullError"] = "PUSH_PULL_ERROR_STATE"; | ||
StateName["Ready"] = "READY_STATE"; | ||
})(StateName || (StateName = {})); | ||
var CollabStateName; | ||
(function (CollabStateName) { | ||
CollabStateName["FatalError"] = "FATAL_ERROR_STATE"; | ||
CollabStateName["Init"] = "INIT_STATE"; | ||
CollabStateName["InitDoc"] = "INIT_DOC_STATE"; | ||
CollabStateName["InitError"] = "INIT_ERROR_STATE"; | ||
CollabStateName["Pull"] = "PULL_STATE"; | ||
CollabStateName["Push"] = "PUSH_STATE"; | ||
CollabStateName["PushPullError"] = "PUSH_PULL_ERROR_STATE"; | ||
CollabStateName["Ready"] = "READY_STATE"; | ||
})(CollabStateName || (CollabStateName = {})); | ||
const collabSettingsKey = new PluginKey('bangle/collabSettingsKey'); | ||
const collabPluginKey = new PluginKey('bangle/collabPluginKey'); | ||
function replaceDocument(state, serializedDoc, version) { | ||
const { schema, tr } = state; | ||
const content = | ||
// TODO remove serializedDoc | ||
serializedDoc instanceof Node | ||
? serializedDoc.content | ||
: (serializedDoc.content || []).map((child) => schema.nodeFromJSON(child)); | ||
const hasContent = Array.isArray(content) | ||
? content.length > 0 | ||
: Boolean(content); | ||
if (!hasContent) { | ||
return tr; | ||
} | ||
tr.setMeta('addToHistory', false); | ||
tr.replaceWith(0, state.doc.nodeSize - 2, content); | ||
tr.setSelection(Selection.atStart(tr.doc)); | ||
if (typeof version !== undefined) { | ||
const collabState = { version, unconfirmed: [] }; | ||
tr.setMeta('collab$', collabState); | ||
} | ||
return tr; | ||
// In the following states, the user is not allowed to edit the document. | ||
const NoEditStates = [ | ||
CollabStateName.Init, | ||
CollabStateName.InitDoc, | ||
CollabStateName.FatalError, | ||
]; | ||
function dispatchCollabPluginEvent(data) { | ||
return (state, dispatch) => { | ||
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta(collabClientKey, data)); | ||
return true; | ||
}; | ||
} | ||
function onUpstreamChanges(version) { | ||
return (state, dispatch) => { | ||
const pluginState = collabSettingsKey.getState(state); | ||
if (!pluginState) { | ||
return false; | ||
} | ||
if (pluginState.serverVersion !== version) { | ||
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta(collabSettingsKey, { serverVersion: version })); | ||
} | ||
return false; | ||
}; | ||
} | ||
function onLocalChanges() { | ||
return (state, dispatch) => { | ||
const pluginState = collabClientKey.getState(state); | ||
if (!pluginState) { | ||
return false; | ||
} | ||
if (isCollabStateReady()(state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
debugInfo: 'onLocalChanges', | ||
}, | ||
collabEvent: { | ||
type: EventType.Push, | ||
}, | ||
})(state, dispatch); | ||
return true; | ||
} | ||
return false; | ||
}; | ||
} | ||
function isOutdatedVersion() { | ||
return (state) => { | ||
var _a; | ||
const serverVersion = (_a = collabSettingsKey.getState(state)) === null || _a === void 0 ? void 0 : _a.serverVersion; | ||
return (typeof serverVersion === 'number' && getVersion(state) < serverVersion); | ||
}; | ||
} | ||
function isCollabStateReady() { | ||
return (state) => { | ||
var _a; | ||
return (((_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.collabState.name) === | ||
CollabStateName.Ready); | ||
}; | ||
} | ||
// In these states the document is frozen and no edits and _almost_ no transactions are allowed | ||
function isNoEditState() { | ||
return (state) => { | ||
var _a; | ||
const stateName = (_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.collabState.name; | ||
return stateName ? NoEditStates.includes(stateName) : false; | ||
}; | ||
} | ||
function applySteps(view, payload, logger) { | ||
@@ -68,3 +113,3 @@ if (view.isDestroyed) { | ||
.setMeta('addToHistory', false) | ||
.setMeta('bangle/isRemote', true); | ||
.setMeta('bangle.dev/isRemote', true); | ||
const newState = view.state.apply(tr); | ||
@@ -75,8 +120,2 @@ view.updateState(newState); | ||
} | ||
// Prevent any changes in the document from happening | ||
function freezeDoc(view) { | ||
view.state.apply(view.state.tr | ||
.setMeta('bangle/isRemote', true) | ||
.setMeta('bangle/allowUpdatingEditorState', false)); | ||
} | ||
function applyDoc(view, doc, version, oldSelection) { | ||
@@ -100,8 +139,27 @@ if (view.isDestroyed) { | ||
} | ||
const newState = view.state.apply(tr | ||
.setMeta('bangle/isRemote', true) | ||
.setMeta('bangle/allowUpdatingEditorState', true)); | ||
const newState = view.state.apply(tr.setMeta('bangle.dev/isRemote', true)); | ||
view.updateState(newState); | ||
view.dispatch(view.state.tr.setMeta(collabSettingsKey, { ready: true })); | ||
} | ||
function replaceDocument(state, serializedDoc, version) { | ||
const { schema, tr } = state; | ||
const content = | ||
// TODO remove serializedDoc | ||
serializedDoc instanceof Node | ||
? serializedDoc.content | ||
: (serializedDoc.content || []).map((child) => schema.nodeFromJSON(child)); | ||
const hasContent = Array.isArray(content) | ||
? content.length > 0 | ||
: Boolean(content); | ||
if (!hasContent) { | ||
return tr; | ||
} | ||
tr.setMeta('addToHistory', false); | ||
tr.replaceWith(0, state.doc.nodeSize - 2, content); | ||
tr.setSelection(Selection.atStart(tr.doc)); | ||
if (typeof version !== undefined) { | ||
const collabState = { version, unconfirmed: [] }; | ||
tr.setMeta('collab$', collabState); | ||
} | ||
return tr; | ||
} | ||
@@ -112,56 +170,137 @@ const LOG = true; | ||
: () => { }; | ||
const MAX_RESTARTS = 100; | ||
function collabClient(param) { | ||
const logger = (...args) => log(`${param.clientID}:version=${getVersion(context.view.state)}:`, ...args); | ||
const context = { | ||
...param, | ||
pendingPush: false, | ||
pendingUpstreamChange: false, | ||
restartCount: 0, | ||
function collabClientPlugin(clientInfo) { | ||
const logger = (state) => (...args) => { | ||
var _a, _b; | ||
return log(`${clientInfo.clientID}:version=${getVersion(state)}:${(_b = (_a = collabClientKey.getState(state)) === null || _a === void 0 ? void 0 : _a.context.debugInfo) !== null && _b !== void 0 ? _b : ''}`, ...args); | ||
}; | ||
let gState = { name: StateName.Init }; | ||
let controller = new AbortController(); | ||
const dispatchEvent = (event, debugString) => { | ||
if (event.type === EventType.Restart) { | ||
context.restartCount++; | ||
} | ||
if (context.restartCount > MAX_RESTARTS) { | ||
freezeDoc(context.view); | ||
throw new Error('Too many restarts'); | ||
} | ||
const state = applyState(gState, event, logger); | ||
// only update state if it changed, we donot support self transitions | ||
if (gState.name !== state.name) { | ||
controller.abort(); | ||
controller = new AbortController(); | ||
logger(debugString ? `from=${debugString}:` : '', `event ${event.type} changed state from ${gState.name} to ${state.name}`); | ||
gState = state; | ||
runActions(context, state, controller.signal, dispatchEvent, logger); | ||
} | ||
else { | ||
logger(debugString, `event ${event.type} ignored`); | ||
} | ||
}; | ||
runActions(context, gState, controller.signal, dispatchEvent, logger); | ||
return { | ||
onUpstreamChange() { | ||
if (gState.name === StateName.Ready) { | ||
dispatchEvent({ type: EventType.Pull }, 'onUpstreamChange'); | ||
} | ||
else { | ||
context.pendingUpstreamChange = true; | ||
} | ||
}, | ||
onLocalEdits() { | ||
if (gState.name === StateName.Ready) { | ||
dispatchEvent({ type: EventType.Push }, 'onLocalEdits'); | ||
} | ||
else { | ||
context.pendingPush = true; | ||
} | ||
}, | ||
async destroy() { | ||
controller.abort(); | ||
}, | ||
}; | ||
return [ | ||
new Plugin({ | ||
key: collabClientKey, | ||
filterTransaction(tr, state) { | ||
// Do not block collab plugins' transactions | ||
if (tr.getMeta(collabClientKey) || | ||
tr.getMeta(collabSettingsKey) || | ||
tr.getMeta('bangle.dev/isRemote')) { | ||
return true; | ||
} | ||
// prevent any other tr until state is in one of the no-edit state | ||
if (tr.docChanged && isNoEditState()(state)) { | ||
logger(state)('skipping transaction'); | ||
return false; | ||
} | ||
return true; | ||
}, | ||
state: { | ||
init() { | ||
return { | ||
context: { | ||
restartCount: 0, | ||
debugInfo: undefined, | ||
}, | ||
collabState: { name: CollabStateName.Init }, | ||
}; | ||
}, | ||
apply(tr, value, oldState, newState) { | ||
const meta = tr.getMeta(collabClientKey); | ||
if (meta === undefined) { | ||
return value; | ||
} | ||
let result = { | ||
...value, | ||
context: { | ||
...value.context, | ||
// unset debugInfo everytime | ||
debugInfo: undefined, | ||
}, | ||
}; | ||
if (meta.collabEvent) { | ||
const newPluginState = applyState(value.collabState, meta.collabEvent, logger(newState)); | ||
if (newPluginState.name !== value.collabState.name) { | ||
result.collabState = newPluginState; | ||
} | ||
else { | ||
logger(newState)('applyState', `debugInfo=${result.context.debugInfo}`, `event ${meta.collabEvent.type} ignored`); | ||
} | ||
} | ||
if (meta.context) { | ||
result.context = { ...result.context, ...meta.context }; | ||
} | ||
logger(newState)('apply state, newStateName=', result.collabState.name, `debugInfo=${result.context.debugInfo}`, 'oldStateName=', value.collabState.name); | ||
return result; | ||
}, | ||
}, | ||
view(view) { | ||
let controller = new AbortController(); | ||
const pluginState = collabClientKey.getState(view.state); | ||
if (pluginState) { | ||
runActions(clientInfo, pluginState.collabState, pluginState.context, view, controller.signal, logger(view.state)); | ||
} | ||
return { | ||
destroy() { | ||
controller.abort(); | ||
}, | ||
update(view, prevState) { | ||
var _a; | ||
const pluginState = collabClientKey.getState(view.state); | ||
// ignore running actions if collab state didn't change | ||
if ((pluginState === null || pluginState === void 0 ? void 0 : pluginState.collabState) === | ||
((_a = collabClientKey.getState(prevState)) === null || _a === void 0 ? void 0 : _a.collabState)) { | ||
return; | ||
} | ||
if (pluginState) { | ||
controller.abort(); | ||
controller = new AbortController(); | ||
runActions(clientInfo, pluginState.collabState, pluginState.context, view, controller.signal, logger(view.state)); | ||
} | ||
}, | ||
}; | ||
}, | ||
}), | ||
new Plugin({ | ||
key: collabSettingsKey, | ||
state: { | ||
init: (_, _state) => { | ||
return { | ||
serverVersion: undefined, | ||
}; | ||
}, | ||
apply: (tr, value, oldState, newState) => { | ||
const meta = tr.getMeta(collabSettingsKey); | ||
if (meta) { | ||
logger(newState)('collabSettingsKey received tr', meta); | ||
return { | ||
...value, | ||
...meta, | ||
}; | ||
} | ||
return value; | ||
}, | ||
}, | ||
view(view) { | ||
// // If there are sendable steps to send to server | ||
if (sendableSteps(view.state)) { | ||
onLocalChanges()(view.state, view.dispatch); | ||
} | ||
return { | ||
update(view) { | ||
if (isOutdatedVersion()(view.state) && | ||
isCollabStateReady()(view.state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
debugInfo: 'collabSettingsKey(outdated-local-version)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
// // If there are sendable steps to send to server | ||
if (sendableSteps(view.state)) { | ||
onLocalChanges()(view.state, view.dispatch); | ||
} | ||
}, | ||
}; | ||
}, | ||
}), | ||
]; | ||
} | ||
@@ -176,9 +315,9 @@ // Must be kept pure -- no side effects | ||
// FatalError is terminal and should not allow at state transitions | ||
case StateName.FatalError: { | ||
case CollabStateName.FatalError: { | ||
logIgnoreEvent(state, event); | ||
return state; | ||
} | ||
case StateName.InitDoc: { | ||
case CollabStateName.InitDoc: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} | ||
@@ -188,9 +327,9 @@ logIgnoreEvent(state, event); | ||
} | ||
case StateName.InitError: { | ||
case CollabStateName.InitError: { | ||
if (event.type === EventType.Restart) { | ||
return { name: StateName.Init }; | ||
return { name: CollabStateName.Init }; | ||
} | ||
else if (event.type === EventType.FatalError) { | ||
return { | ||
name: StateName.FatalError, | ||
name: CollabStateName.FatalError, | ||
state: { message: event.payload.message }, | ||
@@ -202,7 +341,7 @@ }; | ||
} | ||
case StateName.Init: { | ||
case CollabStateName.Init: { | ||
if (event.type === EventType.InitDoc) { | ||
const { payload } = event; | ||
return { | ||
name: StateName.InitDoc, | ||
name: CollabStateName.InitDoc, | ||
state: { | ||
@@ -218,3 +357,3 @@ initialDoc: payload.doc, | ||
return { | ||
name: StateName.InitError, | ||
name: CollabStateName.InitError, | ||
state: { failure: event.payload.failure }, | ||
@@ -226,9 +365,9 @@ }; | ||
} | ||
case StateName.Pull: { | ||
case CollabStateName.Pull: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} | ||
else if (event.type === EventType.PushPullError) { | ||
return { | ||
name: StateName.PushPullError, | ||
name: CollabStateName.PushPullError, | ||
state: { | ||
@@ -243,12 +382,12 @@ failure: event.payload.failure, | ||
} | ||
case StateName.PushPullError: { | ||
case CollabStateName.PushPullError: { | ||
if (event.type === EventType.Restart) { | ||
return { name: StateName.Init }; | ||
return { name: CollabStateName.Init }; | ||
} | ||
else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state.initDocState }; | ||
return { name: CollabStateName.Pull, state: state.state.initDocState }; | ||
} | ||
else if (event.type === EventType.FatalError) { | ||
return { | ||
name: StateName.FatalError, | ||
name: CollabStateName.FatalError, | ||
state: { message: event.payload.message }, | ||
@@ -260,12 +399,12 @@ }; | ||
} | ||
case StateName.Push: { | ||
case CollabStateName.Push: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} | ||
else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state }; | ||
return { name: CollabStateName.Pull, state: state.state }; | ||
} | ||
else if (event.type === EventType.PushPullError) { | ||
return { | ||
name: StateName.PushPullError, | ||
name: CollabStateName.PushPullError, | ||
state: { | ||
@@ -280,8 +419,8 @@ failure: event.payload.failure, | ||
} | ||
case StateName.Ready: { | ||
case CollabStateName.Ready: { | ||
if (event.type === EventType.Push) { | ||
return { name: StateName.Push, state: state.state }; | ||
return { name: CollabStateName.Push, state: state.state }; | ||
} | ||
else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state }; | ||
return { name: CollabStateName.Pull, state: state.state }; | ||
} | ||
@@ -297,52 +436,76 @@ logIgnoreEvent(state, event); | ||
// code that needs to run when state changes | ||
async function runActions(context, state, signal, dispatch, logger) { | ||
async function runActions(clientInfo, collabState, context, view, signal, logger) { | ||
if (signal.aborted) { | ||
return; | ||
} | ||
const stateName = state.name; | ||
const { view } = context; | ||
logger(`running action for ${stateName}`); | ||
const stateName = collabState.name; | ||
logger(`running action for ${stateName}, context=`, context); | ||
const debugString = `runActions:${stateName}`; | ||
const base = { | ||
clientInfo, | ||
context: context, | ||
logger, | ||
signal, | ||
view, | ||
}; | ||
switch (stateName) { | ||
case StateName.FatalError: { | ||
logger(`Freezing document(${context.docName}) to prevent further edits due to FatalError`); | ||
freezeDoc(view); | ||
case CollabStateName.FatalError: { | ||
logger(`Freezing document(${clientInfo.docName}) to prevent further edits due to FatalError`); | ||
return; | ||
} | ||
case StateName.InitDoc: { | ||
const { initialDoc, initialVersion, initialSelection } = state.state; | ||
case CollabStateName.InitDoc: { | ||
const { initialDoc, initialVersion, initialSelection } = collabState.state; | ||
if (!signal.aborted) { | ||
applyDoc(view, initialDoc, initialVersion, initialSelection); | ||
dispatch({ | ||
type: EventType.Ready, | ||
}, debugString); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
context: { | ||
debugInfo: debugString, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
return; | ||
} | ||
case StateName.InitError: { | ||
return handleErrorStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.InitError: { | ||
return handleErrorStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Init: { | ||
return initStateAction(context, dispatch, logger, signal); | ||
case CollabStateName.Init: { | ||
return initStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Pull: { | ||
return pullStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.Pull: { | ||
return pullStateAction({ ...base, collabState }); | ||
} | ||
case StateName.PushPullError: { | ||
return handleErrorStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.PushPullError: { | ||
return handleErrorStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Push: { | ||
return pushStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.Push: { | ||
return pushStateAction({ ...base, collabState }); | ||
} | ||
// Ready state is a special state where pending changes are dispatched | ||
// or if no pending changes, it waits for new changes. | ||
case StateName.Ready: { | ||
case CollabStateName.Ready: { | ||
if (!signal.aborted) { | ||
if (context.pendingUpstreamChange) { | ||
context.pendingUpstreamChange = false; | ||
dispatch({ type: EventType.Pull }, debugString); | ||
if (isOutdatedVersion()(view.state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
...context, | ||
debugInfo: debugString + '(outdated-local-version)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
else if (context.pendingPush) { | ||
context.pendingPush = false; | ||
dispatch({ type: EventType.Push }, debugString); | ||
else if (sendableSteps(view.state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
...context, | ||
debugInfo: debugString + '(sendable-steps)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Push, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -357,4 +520,4 @@ } | ||
} | ||
async function initStateAction(context, dispatch, logger, signal, state) { | ||
const { docName, schema, userId, sendManagerRequest } = context; | ||
const initStateAction = async ({ clientInfo, context, logger, signal, view, }) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const debugSource = `initStateAction:`; | ||
@@ -372,23 +535,29 @@ const result = await sendManagerRequest({ | ||
if (!result.ok) { | ||
dispatch({ | ||
type: EventType.InitError, | ||
payload: { failure: result.body }, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.InitError, | ||
payload: { failure: result.body }, | ||
}, | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
return; | ||
} | ||
const { doc, managerId, version } = result.body; | ||
dispatch({ | ||
type: EventType.InitDoc, | ||
payload: { | ||
doc: schema.nodeFromJSON(doc), | ||
version, | ||
managerId, | ||
selection: undefined, | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.InitDoc, | ||
payload: { | ||
doc: view.state.schema.nodeFromJSON(doc), | ||
version, | ||
managerId, | ||
selection: undefined, | ||
}, | ||
}, | ||
}, debugSource); | ||
})(view.state, view.dispatch); | ||
return; | ||
} | ||
async function pullStateAction(context, dispatch, logger, signal, state) { | ||
const { docName, userId, view, sendManagerRequest } = context; | ||
const { managerId } = state.state; | ||
}; | ||
const pullStateAction = async ({ clientInfo, logger, signal, collabState, view, }) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const { managerId } = collabState.state; | ||
const response = await sendManagerRequest({ | ||
@@ -409,20 +578,25 @@ type: CollabRequestType.PullEvents, | ||
applySteps(view, response.body, logger); | ||
dispatch({ | ||
type: EventType.Ready, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
else { | ||
dispatch({ | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
return; | ||
} | ||
function handleErrorStateAction(context, dispatch, logger, signal, state) { | ||
const failure = state.state.failure; | ||
}; | ||
const handleErrorStateAction = async ({ clientInfo, view, logger, signal, collabState }) => { | ||
const failure = collabState.state.failure; | ||
logger('Recovering failure', failure); | ||
const debugSource = `pushPullErrorStateAction:${failure}:`; | ||
switch (failure) { | ||
// 400, 410 | ||
case CollabFail.InvalidVersion: | ||
@@ -432,7 +606,10 @@ case CollabFail.IncorrectManager: { | ||
if (!signal.aborted) { | ||
dispatch({ | ||
type: EventType.Restart, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.Restart, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
}, signal, context.retryWaitTime); | ||
}, signal, clientInfo.retryWaitTime); | ||
return; | ||
@@ -443,8 +620,11 @@ } | ||
if (!signal.aborted) { | ||
dispatch({ | ||
type: EventType.FatalError, | ||
payload: { | ||
message: 'History/Server not available', | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.FatalError, | ||
payload: { | ||
message: 'History/Server not available', | ||
}, | ||
}, | ||
}, debugSource); | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -456,8 +636,11 @@ return; | ||
if (!signal.aborted) { | ||
dispatch({ | ||
type: EventType.FatalError, | ||
payload: { | ||
message: 'Document not found', | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.FatalError, | ||
payload: { | ||
message: 'Document not found', | ||
}, | ||
}, | ||
}, debugSource); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -468,5 +651,8 @@ return; | ||
case CollabFail.OutdatedVersion: { | ||
dispatch({ | ||
type: EventType.Pull, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
return; | ||
@@ -478,7 +664,10 @@ } | ||
if (!signal.aborted) { | ||
dispatch({ | ||
type: EventType.Pull, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
}, signal, context.retryWaitTime); | ||
}, signal, clientInfo.retryWaitTime); | ||
return; | ||
@@ -490,14 +679,17 @@ } | ||
} | ||
} | ||
async function pushStateAction(context, dispatch, logger, signal, state) { | ||
const { docName, userId, view, sendManagerRequest } = context; | ||
}; | ||
const pushStateAction = async ({ clientInfo, signal, collabState, view, }) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const debugSource = `pushStateAction:`; | ||
const steps = sendableSteps(view.state); | ||
if (!steps) { | ||
dispatch({ | ||
type: EventType.Ready, | ||
}, debugSource + '(no steps):'); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
context: { debugInfo: debugSource + '(no steps):' }, | ||
})(view.state, view.dispatch); | ||
return; | ||
} | ||
const { managerId } = state.state; | ||
const { managerId } = collabState.state; | ||
const response = await sendManagerRequest({ | ||
@@ -521,125 +713,39 @@ type: CollabRequestType.PushEvents, | ||
// get any new steps from other clients | ||
dispatch({ | ||
type: EventType.Pull, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
else { | ||
dispatch({ | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, debugSource); | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
return; | ||
} | ||
}; | ||
const plugins = pluginsFactory; | ||
const commands = { | ||
pullChanges, | ||
}; | ||
const commands = { onUpstreamChanges }; | ||
const RECOVERY_BACK_OFF = 50; | ||
const getCollabSettings = (state) => { | ||
return collabSettingsKey.getState(state); | ||
}; | ||
function pluginsFactory({ clientID = 'client-' + uuid(), docName, sendManagerRequest, retryWaitTime = 100, }) { | ||
return () => { | ||
return [ | ||
collab({ | ||
clientID, | ||
}), | ||
new Plugin({ | ||
key: collabSettingsKey, | ||
state: { | ||
init: (_, _state) => { | ||
return { | ||
docName: docName, | ||
clientID: clientID, | ||
userId: 'user-' + clientID, | ||
ready: false, | ||
}; | ||
}, | ||
apply: (tr, value) => { | ||
if (tr.getMeta(collabSettingsKey)) { | ||
return { | ||
...value, | ||
...tr.getMeta(collabSettingsKey), | ||
}; | ||
} | ||
return value; | ||
}, | ||
}, | ||
filterTransaction(tr, state) { | ||
// Don't allow transactions that modifies the document before | ||
// collab is ready. | ||
if (tr.docChanged) { | ||
// Let collab client's setup tr's go through | ||
if (tr.getMeta('bangle/allowUpdatingEditorState') === true) { | ||
return true; | ||
} | ||
// prevent any other tr until state is ready | ||
if (!collabSettingsKey.getState(state).ready) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
}, | ||
}), | ||
collabMachinePlugin({ | ||
sendManagerRequest, | ||
retryWaitTime, | ||
}), | ||
]; | ||
}; | ||
const userId = 'user-' + clientID; | ||
return [ | ||
collab({ | ||
clientID, | ||
}), | ||
collabClientPlugin({ | ||
clientID, | ||
docName, | ||
retryWaitTime, | ||
sendManagerRequest, | ||
userId, | ||
}), | ||
]; | ||
} | ||
function collabMachinePlugin({ sendManagerRequest, retryWaitTime, }) { | ||
let instance; | ||
return new Plugin({ | ||
key: collabPluginKey, | ||
state: { | ||
init() { | ||
return null; | ||
}, | ||
apply(tr, pluginState, _prevState, newState) { | ||
if (!tr.getMeta('bangle/isRemote') && | ||
collabSettingsKey.getState(newState).ready) { | ||
setTimeout(() => { | ||
instance === null || instance === void 0 ? void 0 : instance.onLocalEdits(); | ||
}, 0); | ||
} | ||
if (tr.getMeta('bangle/collab-pull-changes')) { | ||
setTimeout(() => { | ||
instance === null || instance === void 0 ? void 0 : instance.onUpstreamChange(); | ||
}, 0); | ||
} | ||
return pluginState; | ||
}, | ||
}, | ||
view(view) { | ||
if (!instance) { | ||
const collabSettings = getCollabSettings(view.state); | ||
instance = collabClient({ | ||
docName: collabSettings.docName, | ||
clientID: collabSettings.clientID, | ||
userId: collabSettings.userId, | ||
schema: view.state.schema, | ||
sendManagerRequest, | ||
retryWaitTime, | ||
view, | ||
}); | ||
} | ||
return { | ||
update() { }, | ||
destroy() { | ||
instance === null || instance === void 0 ? void 0 : instance.destroy(); | ||
instance = undefined; | ||
}, | ||
}; | ||
}, | ||
}); | ||
} | ||
function pullChanges() { | ||
return (state, dispatch) => { | ||
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta('bangle/collab-pull-changes', true)); | ||
return true; | ||
}; | ||
} | ||
@@ -650,7 +756,5 @@ var collabExtension = /*#__PURE__*/Object.freeze({ | ||
commands: commands, | ||
RECOVERY_BACK_OFF: RECOVERY_BACK_OFF, | ||
getCollabSettings: getCollabSettings, | ||
pullChanges: pullChanges | ||
RECOVERY_BACK_OFF: RECOVERY_BACK_OFF | ||
}); | ||
export { collabExtension as collabClient }; |
{ | ||
"name": "@bangle.dev/collab-client", | ||
"version": "0.30.0-alpha.4", | ||
"version": "0.30.0-alpha.5", | ||
"homepage": "https://bangle.dev", | ||
@@ -43,8 +43,8 @@ "authors": [ | ||
"devDependencies": { | ||
"@bangle.dev/all-base-components": "0.30.0-alpha.4", | ||
"@bangle.dev/collab-server": "0.30.0-alpha.4", | ||
"@bangle.dev/core": "0.30.0-alpha.4", | ||
"@bangle.dev/disk": "0.30.0-alpha.4", | ||
"@bangle.dev/pm": "0.30.0-alpha.4", | ||
"@bangle.dev/test-helpers": "0.30.0-alpha.4", | ||
"@bangle.dev/all-base-components": "0.30.0-alpha.5", | ||
"@bangle.dev/collab-server": "0.30.0-alpha.5", | ||
"@bangle.dev/core": "0.30.0-alpha.5", | ||
"@bangle.dev/disk": "0.30.0-alpha.5", | ||
"@bangle.dev/pm": "0.30.0-alpha.5", | ||
"@bangle.dev/test-helpers": "0.30.0-alpha.5", | ||
"@types/node": "^17.0.43", | ||
@@ -56,3 +56,3 @@ "localforage": "^1.9.0", | ||
"dependencies": { | ||
"@bangle.dev/utils": "0.30.0-alpha.4", | ||
"@bangle.dev/utils": "0.30.0-alpha.5", | ||
"@types/jest": "^27.5.2", | ||
@@ -59,0 +59,0 @@ "prosemirror-collab": "^1.3.0", |
import { getVersion, sendableSteps } from 'prosemirror-collab'; | ||
import { CollabFail, CollabRequestType } from '@bangle.dev/collab-server'; | ||
import { | ||
CollabFail, | ||
CollabManager, | ||
CollabRequestType, | ||
} from '@bangle.dev/collab-server'; | ||
import { EditorState, EditorView, Plugin } from '@bangle.dev/pm'; | ||
import { abortableSetTimeout, isTestEnv } from '@bangle.dev/utils'; | ||
import { | ||
Context, | ||
dispatchCollabPluginEvent, | ||
isCollabStateReady, | ||
isNoEditState, | ||
isOutdatedVersion, | ||
onLocalChanges, | ||
} from './commands'; | ||
import { | ||
collabClientKey, | ||
CollabPluginContext, | ||
CollabPluginState, | ||
CollabSettings, | ||
collabSettingsKey, | ||
CollabStateName, | ||
EventType, | ||
@@ -14,7 +31,7 @@ InitErrorState, | ||
PushState, | ||
StateName, | ||
TrMeta, | ||
ValidEvents, | ||
ValidStates, | ||
ValidStates as ValidCollabStates, | ||
} from './common'; | ||
import { applyDoc, applySteps, freezeDoc } from './helpers'; | ||
import { applyDoc, applySteps } from './helpers'; | ||
@@ -26,76 +43,195 @@ const LOG = true; | ||
const MAX_RESTARTS = 100; | ||
interface ClientInfo { | ||
readonly clientID: string; | ||
readonly docName: string; | ||
readonly retryWaitTime: number; | ||
readonly sendManagerRequest: CollabManager['handleRequest']; | ||
readonly userId: string; | ||
} | ||
export function collabClient( | ||
param: Omit< | ||
Context, | ||
'pendingPush' | 'pendingUpstreamChange' | 'restartCount' | ||
>, | ||
) { | ||
const logger = (...args: any[]) => | ||
log( | ||
`${param.clientID}:version=${getVersion(context.view.state)}:`, | ||
...args, | ||
); | ||
export function collabClientPlugin(clientInfo: ClientInfo) { | ||
const logger = | ||
(state: EditorState) => | ||
(...args: any[]) => | ||
log( | ||
`${clientInfo.clientID}:version=${getVersion(state)}:${ | ||
collabClientKey.getState(state)?.context.debugInfo ?? '' | ||
}`, | ||
...args, | ||
); | ||
const context: Context = { | ||
...param, | ||
pendingPush: false, | ||
pendingUpstreamChange: false, | ||
restartCount: 0, | ||
}; | ||
return [ | ||
new Plugin({ | ||
key: collabClientKey, | ||
filterTransaction(tr, state) { | ||
// Do not block collab plugins' transactions | ||
if ( | ||
tr.getMeta(collabClientKey) || | ||
tr.getMeta(collabSettingsKey) || | ||
tr.getMeta('bangle.dev/isRemote') | ||
) { | ||
return true; | ||
} | ||
let gState: ValidStates = { name: StateName.Init }; | ||
let controller = new AbortController(); | ||
// prevent any other tr until state is in one of the no-edit state | ||
if (tr.docChanged && isNoEditState()(state)) { | ||
logger(state)('skipping transaction'); | ||
return false; | ||
} | ||
const dispatchEvent = (event: ValidEvents, debugString?: string): void => { | ||
if (event.type === EventType.Restart) { | ||
context.restartCount++; | ||
} | ||
return true; | ||
}, | ||
state: { | ||
init(): CollabPluginState { | ||
return { | ||
context: { | ||
restartCount: 0, | ||
debugInfo: undefined, | ||
}, | ||
collabState: { name: CollabStateName.Init }, | ||
}; | ||
}, | ||
apply(tr, value, oldState, newState) { | ||
const meta: TrMeta | undefined = tr.getMeta(collabClientKey); | ||
if (context.restartCount > MAX_RESTARTS) { | ||
freezeDoc(context.view); | ||
throw new Error('Too many restarts'); | ||
} | ||
if (meta === undefined) { | ||
return value; | ||
} | ||
let result: CollabPluginState = { | ||
...value, | ||
context: { | ||
...value.context, | ||
// unset debugInfo everytime | ||
debugInfo: undefined, | ||
}, | ||
}; | ||
const state = applyState(gState, event, logger); | ||
if (meta.collabEvent) { | ||
const newPluginState = applyState( | ||
value.collabState, | ||
meta.collabEvent, | ||
logger(newState), | ||
); | ||
if (newPluginState.name !== value.collabState.name) { | ||
result.collabState = newPluginState; | ||
} else { | ||
logger(newState)( | ||
'applyState', | ||
`debugInfo=${result.context.debugInfo}`, | ||
`event ${meta.collabEvent.type} ignored`, | ||
); | ||
} | ||
} | ||
// only update state if it changed, we donot support self transitions | ||
if (gState.name !== state.name) { | ||
controller.abort(); | ||
controller = new AbortController(); | ||
logger( | ||
debugString ? `from=${debugString}:` : '', | ||
`event ${event.type} changed state from ${gState.name} to ${state.name}`, | ||
); | ||
gState = state; | ||
runActions(context, state, controller.signal, dispatchEvent, logger); | ||
} else { | ||
logger(debugString, `event ${event.type} ignored`); | ||
} | ||
}; | ||
if (meta.context) { | ||
result.context = { ...result.context, ...meta.context }; | ||
} | ||
runActions(context, gState, controller.signal, dispatchEvent, logger); | ||
logger(newState)( | ||
'apply state, newStateName=', | ||
result.collabState.name, | ||
`debugInfo=${result.context.debugInfo}`, | ||
'oldStateName=', | ||
value.collabState.name, | ||
); | ||
return { | ||
onUpstreamChange() { | ||
if (gState.name === StateName.Ready) { | ||
dispatchEvent({ type: EventType.Pull }, 'onUpstreamChange'); | ||
} else { | ||
context.pendingUpstreamChange = true; | ||
} | ||
}, | ||
return result; | ||
}, | ||
}, | ||
onLocalEdits() { | ||
if (gState.name === StateName.Ready) { | ||
dispatchEvent({ type: EventType.Push }, 'onLocalEdits'); | ||
} else { | ||
context.pendingPush = true; | ||
} | ||
}, | ||
view(view) { | ||
let controller = new AbortController(); | ||
const pluginState = collabClientKey.getState(view.state); | ||
if (pluginState) { | ||
runActions( | ||
clientInfo, | ||
pluginState.collabState, | ||
pluginState.context, | ||
view, | ||
controller.signal, | ||
logger(view.state), | ||
); | ||
} | ||
async destroy() { | ||
controller.abort(); | ||
}, | ||
}; | ||
return { | ||
destroy() { | ||
controller.abort(); | ||
}, | ||
update(view, prevState) { | ||
const pluginState = collabClientKey.getState(view.state); | ||
// ignore running actions if collab state didn't change | ||
if ( | ||
pluginState?.collabState === | ||
collabClientKey.getState(prevState)?.collabState | ||
) { | ||
return; | ||
} | ||
if (pluginState) { | ||
controller.abort(); | ||
controller = new AbortController(); | ||
runActions( | ||
clientInfo, | ||
pluginState.collabState, | ||
pluginState.context, | ||
view, | ||
controller.signal, | ||
logger(view.state), | ||
); | ||
} | ||
}, | ||
}; | ||
}, | ||
}), | ||
new Plugin({ | ||
key: collabSettingsKey, | ||
state: { | ||
init: (_, _state): CollabSettings => { | ||
return { | ||
serverVersion: undefined, | ||
}; | ||
}, | ||
apply: (tr, value, oldState, newState) => { | ||
const meta = tr.getMeta(collabSettingsKey); | ||
if (meta) { | ||
logger(newState)('collabSettingsKey received tr', meta); | ||
return { | ||
...value, | ||
...meta, | ||
}; | ||
} | ||
return value; | ||
}, | ||
}, | ||
view(view) { | ||
// // If there are sendable steps to send to server | ||
if (sendableSteps(view.state)) { | ||
onLocalChanges()(view.state, view.dispatch); | ||
} | ||
return { | ||
update(view) { | ||
if ( | ||
isOutdatedVersion()(view.state) && | ||
isCollabStateReady()(view.state) | ||
) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
debugInfo: 'collabSettingsKey(outdated-local-version)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
// // If there are sendable steps to send to server | ||
if (sendableSteps(view.state)) { | ||
onLocalChanges()(view.state, view.dispatch); | ||
} | ||
}, | ||
}; | ||
}, | ||
}), | ||
]; | ||
} | ||
@@ -105,7 +241,7 @@ | ||
export function applyState( | ||
state: ValidStates, | ||
state: ValidCollabStates, | ||
event: ValidEvents, | ||
logger: (...args: any[]) => void, | ||
): ValidStates { | ||
const logIgnoreEvent = (state: ValidStates, event: ValidEvents) => { | ||
): ValidCollabStates { | ||
const logIgnoreEvent = (state: ValidCollabStates, event: ValidEvents) => { | ||
logger( | ||
@@ -121,3 +257,3 @@ 'applyState:', | ||
// FatalError is terminal and should not allow at state transitions | ||
case StateName.FatalError: { | ||
case CollabStateName.FatalError: { | ||
logIgnoreEvent(state, event); | ||
@@ -127,5 +263,5 @@ return state; | ||
case StateName.InitDoc: { | ||
case CollabStateName.InitDoc: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} | ||
@@ -137,8 +273,8 @@ | ||
case StateName.InitError: { | ||
case CollabStateName.InitError: { | ||
if (event.type === EventType.Restart) { | ||
return { name: StateName.Init }; | ||
return { name: CollabStateName.Init }; | ||
} else if (event.type === EventType.FatalError) { | ||
return { | ||
name: StateName.FatalError, | ||
name: CollabStateName.FatalError, | ||
state: { message: event.payload.message }, | ||
@@ -152,7 +288,7 @@ }; | ||
case StateName.Init: { | ||
case CollabStateName.Init: { | ||
if (event.type === EventType.InitDoc) { | ||
const { payload } = event; | ||
return { | ||
name: StateName.InitDoc, | ||
name: CollabStateName.InitDoc, | ||
state: { | ||
@@ -167,3 +303,3 @@ initialDoc: payload.doc, | ||
return { | ||
name: StateName.InitError, | ||
name: CollabStateName.InitError, | ||
state: { failure: event.payload.failure }, | ||
@@ -177,8 +313,8 @@ }; | ||
case StateName.Pull: { | ||
case CollabStateName.Pull: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} else if (event.type === EventType.PushPullError) { | ||
return { | ||
name: StateName.PushPullError, | ||
name: CollabStateName.PushPullError, | ||
state: { | ||
@@ -195,10 +331,10 @@ failure: event.payload.failure, | ||
case StateName.PushPullError: { | ||
case CollabStateName.PushPullError: { | ||
if (event.type === EventType.Restart) { | ||
return { name: StateName.Init }; | ||
return { name: CollabStateName.Init }; | ||
} else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state.initDocState }; | ||
return { name: CollabStateName.Pull, state: state.state.initDocState }; | ||
} else if (event.type === EventType.FatalError) { | ||
return { | ||
name: StateName.FatalError, | ||
name: CollabStateName.FatalError, | ||
state: { message: event.payload.message }, | ||
@@ -212,10 +348,10 @@ }; | ||
case StateName.Push: { | ||
case CollabStateName.Push: { | ||
if (event.type === EventType.Ready) { | ||
return { name: StateName.Ready, state: state.state }; | ||
return { name: CollabStateName.Ready, state: state.state }; | ||
} else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state }; | ||
return { name: CollabStateName.Pull, state: state.state }; | ||
} else if (event.type === EventType.PushPullError) { | ||
return { | ||
name: StateName.PushPullError, | ||
name: CollabStateName.PushPullError, | ||
state: { | ||
@@ -232,7 +368,7 @@ failure: event.payload.failure, | ||
case StateName.Ready: { | ||
case CollabStateName.Ready: { | ||
if (event.type === EventType.Push) { | ||
return { name: StateName.Push, state: state.state }; | ||
return { name: CollabStateName.Push, state: state.state }; | ||
} else if (event.type === EventType.Pull) { | ||
return { name: StateName.Pull, state: state.state }; | ||
return { name: CollabStateName.Pull, state: state.state }; | ||
} | ||
@@ -253,6 +389,7 @@ | ||
export async function runActions( | ||
context: Context, | ||
state: ValidStates, | ||
clientInfo: ClientInfo, | ||
collabState: ValidCollabStates, | ||
context: CollabPluginContext, | ||
view: EditorView, | ||
signal: AbortSignal, | ||
dispatch: (event: ValidEvents, debugString?: string) => void, | ||
logger: (...args: any[]) => void, | ||
@@ -264,28 +401,36 @@ ): Promise<void> { | ||
const stateName = state.name; | ||
const { view } = context; | ||
const stateName = collabState.name; | ||
logger(`running action for ${stateName}`); | ||
logger(`running action for ${stateName}, context=`, context); | ||
const debugString = `runActions:${stateName}`; | ||
const base = { | ||
clientInfo, | ||
context: context, | ||
logger, | ||
signal, | ||
view, | ||
}; | ||
switch (stateName) { | ||
case StateName.FatalError: { | ||
case CollabStateName.FatalError: { | ||
logger( | ||
`Freezing document(${context.docName}) to prevent further edits due to FatalError`, | ||
`Freezing document(${clientInfo.docName}) to prevent further edits due to FatalError`, | ||
); | ||
freezeDoc(view); | ||
return; | ||
} | ||
case StateName.InitDoc: { | ||
const { initialDoc, initialVersion, initialSelection } = state.state; | ||
case CollabStateName.InitDoc: { | ||
const { initialDoc, initialVersion, initialSelection } = | ||
collabState.state; | ||
if (!signal.aborted) { | ||
applyDoc(view, initialDoc, initialVersion, initialSelection); | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
debugString, | ||
); | ||
context: { | ||
debugInfo: debugString, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -295,20 +440,20 @@ return; | ||
case StateName.InitError: { | ||
return handleErrorStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.InitError: { | ||
return handleErrorStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Init: { | ||
return initStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.Init: { | ||
return initStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Pull: { | ||
return pullStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.Pull: { | ||
return pullStateAction({ ...base, collabState }); | ||
} | ||
case StateName.PushPullError: { | ||
return handleErrorStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.PushPullError: { | ||
return handleErrorStateAction({ ...base, collabState }); | ||
} | ||
case StateName.Push: { | ||
return pushStateAction(context, dispatch, logger, signal, state); | ||
case CollabStateName.Push: { | ||
return pushStateAction({ ...base, collabState }); | ||
} | ||
@@ -318,10 +463,24 @@ | ||
// or if no pending changes, it waits for new changes. | ||
case StateName.Ready: { | ||
case CollabStateName.Ready: { | ||
if (!signal.aborted) { | ||
if (context.pendingUpstreamChange) { | ||
context.pendingUpstreamChange = false; | ||
dispatch({ type: EventType.Pull }, debugString); | ||
} else if (context.pendingPush) { | ||
context.pendingPush = false; | ||
dispatch({ type: EventType.Push }, debugString); | ||
if (isOutdatedVersion()(view.state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
...context, | ||
debugInfo: debugString + '(outdated-local-version)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
})(view.state, view.dispatch); | ||
} else if (sendableSteps(view.state)) { | ||
dispatchCollabPluginEvent({ | ||
context: { | ||
...context, | ||
debugInfo: debugString + '(sendable-steps)', | ||
}, | ||
collabEvent: { | ||
type: EventType.Push, | ||
}, | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -339,10 +498,19 @@ } | ||
async function initStateAction( | ||
context: Context, | ||
dispatch: (event: ValidEvents, debugString?: string) => void, | ||
logger: (...args: any[]) => void, | ||
signal: AbortSignal, | ||
state: InitState, | ||
) { | ||
const { docName, schema, userId, sendManagerRequest } = context; | ||
type CollabAction<T extends ValidCollabStates> = (param: { | ||
clientInfo: ClientInfo; | ||
context: CollabPluginContext; | ||
logger: (...args: any[]) => void; | ||
signal: AbortSignal; | ||
collabState: T; | ||
view: EditorView; | ||
}) => Promise<void>; | ||
const initStateAction: CollabAction<InitState> = async ({ | ||
clientInfo, | ||
context, | ||
logger, | ||
signal, | ||
view, | ||
}) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const debugSource = `initStateAction:`; | ||
@@ -363,9 +531,9 @@ | ||
if (!result.ok) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.InitError, | ||
payload: { failure: result.body }, | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
return; | ||
@@ -375,7 +543,8 @@ } | ||
const { doc, managerId, version } = result.body; | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.InitDoc, | ||
payload: { | ||
doc: schema.nodeFromJSON(doc), | ||
doc: view.state.schema.nodeFromJSON(doc), | ||
version, | ||
@@ -386,16 +555,16 @@ managerId, | ||
}, | ||
debugSource, | ||
); | ||
})(view.state, view.dispatch); | ||
return; | ||
} | ||
}; | ||
async function pullStateAction( | ||
context: Context, | ||
dispatch: (event: ValidEvents, debugString?: string) => void, | ||
logger: (...args: any[]) => void, | ||
signal: AbortSignal, | ||
state: PullState, | ||
): Promise<void> { | ||
const { docName, userId, view, sendManagerRequest } = context; | ||
const { managerId } = state.state; | ||
const pullStateAction: CollabAction<PullState> = async ({ | ||
clientInfo, | ||
logger, | ||
signal, | ||
collabState, | ||
view, | ||
}) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const { managerId } = collabState.state; | ||
const response = await sendManagerRequest({ | ||
@@ -417,28 +586,24 @@ type: CollabRequestType.PullEvents, | ||
applySteps(view, response.body, logger); | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
debugSource, | ||
); | ||
})(view.state, view.dispatch); | ||
} else { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, | ||
debugSource, | ||
); | ||
})(view.state, view.dispatch); | ||
} | ||
return; | ||
} | ||
}; | ||
function handleErrorStateAction( | ||
context: Context, | ||
dispatch: (event: ValidEvents, debugString?: string) => void, | ||
logger: (...args: any[]) => void, | ||
signal: AbortSignal, | ||
state: PushPullErrorState | InitErrorState, | ||
): void { | ||
const failure = state.state.failure; | ||
const handleErrorStateAction: CollabAction< | ||
PushPullErrorState | InitErrorState | ||
> = async ({ clientInfo, view, logger, signal, collabState }) => { | ||
const failure = collabState.state.failure; | ||
logger('Recovering failure', failure); | ||
@@ -449,3 +614,2 @@ | ||
switch (failure) { | ||
// 400, 410 | ||
case CollabFail.InvalidVersion: | ||
@@ -456,12 +620,12 @@ case CollabFail.IncorrectManager: { | ||
if (!signal.aborted) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.Restart, | ||
}, | ||
debugSource, | ||
); | ||
})(view.state, view.dispatch); | ||
} | ||
}, | ||
signal, | ||
context.retryWaitTime, | ||
clientInfo.retryWaitTime, | ||
); | ||
@@ -473,4 +637,5 @@ return; | ||
if (!signal.aborted) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
context: { debugInfo: debugSource }, | ||
collabEvent: { | ||
type: EventType.FatalError, | ||
@@ -481,4 +646,3 @@ payload: { | ||
}, | ||
debugSource, | ||
); | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -490,4 +654,4 @@ return; | ||
if (!signal.aborted) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.FatalError, | ||
@@ -498,4 +662,4 @@ payload: { | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
@@ -506,8 +670,8 @@ return; | ||
case CollabFail.OutdatedVersion: { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
return; | ||
@@ -521,12 +685,12 @@ } | ||
if (!signal.aborted) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
}, | ||
signal, | ||
context.retryWaitTime, | ||
clientInfo.retryWaitTime, | ||
); | ||
@@ -542,12 +706,11 @@ | ||
} | ||
} | ||
}; | ||
async function pushStateAction( | ||
context: Context, | ||
dispatch: (event: ValidEvents, debugString?: string) => void, | ||
logger: (...args: any[]) => void, | ||
signal: AbortSignal, | ||
state: PushState, | ||
) { | ||
const { docName, userId, view, sendManagerRequest } = context; | ||
const pushStateAction: CollabAction<PushState> = async ({ | ||
clientInfo, | ||
signal, | ||
collabState, | ||
view, | ||
}) => { | ||
const { docName, userId, sendManagerRequest } = clientInfo; | ||
const debugSource = `pushStateAction:`; | ||
@@ -558,12 +721,12 @@ | ||
if (!steps) { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Ready, | ||
}, | ||
debugSource + '(no steps):', | ||
); | ||
context: { debugInfo: debugSource + '(no steps):' }, | ||
})(view.state, view.dispatch); | ||
return; | ||
} | ||
const { managerId } = state.state; | ||
const { managerId } = collabState.state; | ||
@@ -590,18 +753,18 @@ const response = await sendManagerRequest({ | ||
// get any new steps from other clients | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.Pull, | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} else { | ||
dispatch( | ||
{ | ||
dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.PushPullError, | ||
payload: { failure: response.body }, | ||
}, | ||
debugSource, | ||
); | ||
context: { debugInfo: debugSource }, | ||
})(view.state, view.dispatch); | ||
} | ||
return; | ||
} | ||
}; |
import { collab } from 'prosemirror-collab'; | ||
import { CollabManager } from '@bangle.dev/collab-server'; | ||
import { Command, EditorState, Plugin } from '@bangle.dev/pm'; | ||
import { uuid } from '@bangle.dev/utils'; | ||
import { collabClient } from './collab-client'; | ||
import { collabPluginKey, collabSettingsKey } from './helpers'; | ||
import { collabClientPlugin } from './collab-client'; | ||
import { onUpstreamChanges } from './commands'; | ||
@@ -19,18 +18,6 @@ const LOG = false; | ||
export const plugins = pluginsFactory; | ||
export const commands = { | ||
pullChanges, | ||
}; | ||
export const commands = { onUpstreamChanges }; | ||
export const RECOVERY_BACK_OFF = 50; | ||
interface CollabSettings { | ||
docName: string; | ||
clientID: string; | ||
userId: string; | ||
ready: boolean; | ||
} | ||
export const getCollabSettings = (state: EditorState): CollabSettings => { | ||
return collabSettingsKey.getState(state); | ||
}; | ||
function pluginsFactory({ | ||
@@ -47,119 +34,15 @@ clientID = 'client-' + uuid(), | ||
}) { | ||
return () => { | ||
return [ | ||
collab({ | ||
clientID, | ||
}), | ||
new Plugin({ | ||
key: collabSettingsKey, | ||
state: { | ||
init: (_, _state) => { | ||
return { | ||
docName: docName, | ||
clientID: clientID, | ||
userId: 'user-' + clientID, | ||
ready: false, | ||
}; | ||
}, | ||
apply: (tr, value) => { | ||
if (tr.getMeta(collabSettingsKey)) { | ||
return { | ||
...value, | ||
...tr.getMeta(collabSettingsKey), | ||
}; | ||
} | ||
return value; | ||
}, | ||
}, | ||
filterTransaction(tr, state) { | ||
// Don't allow transactions that modifies the document before | ||
// collab is ready. | ||
if (tr.docChanged) { | ||
// Let collab client's setup tr's go through | ||
if (tr.getMeta('bangle/allowUpdatingEditorState') === true) { | ||
return true; | ||
} | ||
// prevent any other tr until state is ready | ||
if (!collabSettingsKey.getState(state).ready) { | ||
log('skipping transaction'); | ||
return false; | ||
} | ||
} | ||
return true; | ||
}, | ||
}), | ||
collabMachinePlugin({ | ||
sendManagerRequest, | ||
retryWaitTime, | ||
}), | ||
]; | ||
}; | ||
const userId = 'user-' + clientID; | ||
return [ | ||
collab({ | ||
clientID, | ||
}), | ||
collabClientPlugin({ | ||
clientID, | ||
docName, | ||
retryWaitTime, | ||
sendManagerRequest, | ||
userId, | ||
}), | ||
]; | ||
} | ||
function collabMachinePlugin({ | ||
sendManagerRequest, | ||
retryWaitTime, | ||
}: { | ||
sendManagerRequest: CollabManager['handleRequest']; | ||
retryWaitTime: number; | ||
}) { | ||
let instance: ReturnType<typeof collabClient> | undefined; | ||
return new Plugin({ | ||
key: collabPluginKey, | ||
state: { | ||
init() { | ||
return null; | ||
}, | ||
apply(tr, pluginState, _prevState, newState) { | ||
if ( | ||
!tr.getMeta('bangle/isRemote') && | ||
collabSettingsKey.getState(newState).ready | ||
) { | ||
setTimeout(() => { | ||
instance?.onLocalEdits(); | ||
}, 0); | ||
} | ||
if (tr.getMeta('bangle/collab-pull-changes')) { | ||
setTimeout(() => { | ||
instance?.onUpstreamChange(); | ||
}, 0); | ||
} | ||
return pluginState; | ||
}, | ||
}, | ||
view(view) { | ||
if (!instance) { | ||
const collabSettings = getCollabSettings(view.state); | ||
instance = collabClient({ | ||
docName: collabSettings.docName, | ||
clientID: collabSettings.clientID, | ||
userId: collabSettings.userId, | ||
schema: view.state.schema, | ||
sendManagerRequest, | ||
retryWaitTime, | ||
view, | ||
}); | ||
} | ||
return { | ||
update() {}, | ||
destroy() { | ||
instance?.destroy(); | ||
instance = undefined; | ||
}, | ||
}; | ||
}, | ||
}); | ||
} | ||
export function pullChanges(): Command { | ||
return (state, dispatch) => { | ||
dispatch?.(state.tr.setMeta('bangle/collab-pull-changes', true)); | ||
return true; | ||
}; | ||
} |
@@ -1,4 +0,14 @@ | ||
import { CollabFail, CollabManager } from '@bangle.dev/collab-server'; | ||
import { EditorView, Node, Schema, TextSelection } from '@bangle.dev/pm'; | ||
import { CollabFail } from '@bangle.dev/collab-server'; | ||
import { Node, PluginKey, TextSelection } from '@bangle.dev/pm'; | ||
export const collabClientKey = new PluginKey<CollabPluginState>( | ||
'bangle.dev/collab-client', | ||
); | ||
export interface CollabSettings { | ||
serverVersion: undefined | number; | ||
} | ||
export const collabSettingsKey = new PluginKey<CollabSettings>( | ||
'bangle/collabSettingsKey', | ||
); | ||
// Events | ||
@@ -66,2 +76,3 @@ export type ValidEvents = | ||
} | ||
export interface RestartEvent { | ||
@@ -82,3 +93,3 @@ type: EventType.Restart; | ||
export enum StateName { | ||
export enum CollabStateName { | ||
FatalError = 'FATAL_ERROR_STATE', | ||
@@ -95,3 +106,3 @@ Init = 'INIT_STATE', | ||
export interface FatalErrorState { | ||
name: StateName.FatalError; | ||
name: CollabStateName.FatalError; | ||
state: { | ||
@@ -103,7 +114,7 @@ message: string; | ||
export interface InitState { | ||
name: StateName.Init; | ||
name: CollabStateName.Init; | ||
} | ||
export interface InitDocState { | ||
name: StateName.InitDoc; | ||
name: CollabStateName.InitDoc; | ||
state: { | ||
@@ -118,3 +129,3 @@ initialDoc: Node; | ||
export interface InitErrorState { | ||
name: StateName.InitError; | ||
name: CollabStateName.InitError; | ||
state: { | ||
@@ -126,3 +137,3 @@ failure: CollabFail; | ||
export interface ReadyState { | ||
name: StateName.Ready; | ||
name: CollabStateName.Ready; | ||
state: InitDocState['state']; | ||
@@ -132,3 +143,3 @@ } | ||
export interface PushState { | ||
name: StateName.Push; | ||
name: CollabStateName.Push; | ||
state: InitDocState['state']; | ||
@@ -138,3 +149,3 @@ } | ||
export interface PullState { | ||
name: StateName.Pull; | ||
name: CollabStateName.Pull; | ||
state: InitDocState['state']; | ||
@@ -144,3 +155,3 @@ } | ||
export interface PushPullErrorState { | ||
name: StateName.PushPullError; | ||
name: CollabStateName.PushPullError; | ||
state: { | ||
@@ -152,13 +163,14 @@ failure: CollabFail; | ||
export interface Context { | ||
readonly clientID: string; | ||
readonly docName: string; | ||
readonly retryWaitTime: number; | ||
readonly schema: Schema; | ||
readonly sendManagerRequest: CollabManager['handleRequest']; | ||
readonly userId: string; | ||
readonly view: EditorView; | ||
pendingUpstreamChange: boolean; | ||
pendingPush: boolean; | ||
restartCount: number; | ||
export interface CollabPluginContext { | ||
readonly restartCount: number; | ||
readonly debugInfo: string | undefined; | ||
} | ||
export interface CollabPluginState { | ||
context: CollabPluginContext; | ||
collabState: ValidStates; | ||
} | ||
export interface TrMeta { | ||
context?: Partial<CollabPluginContext>; | ||
collabEvent?: ValidEvents; | ||
} |
@@ -8,3 +8,2 @@ import { getVersion, receiveTransaction } from 'prosemirror-collab'; | ||
Node, | ||
PluginKey, | ||
Selection, | ||
@@ -15,39 +14,2 @@ Step, | ||
export const collabSettingsKey = new PluginKey('bangle/collabSettingsKey'); | ||
export const collabPluginKey = new PluginKey('bangle/collabPluginKey'); | ||
export function replaceDocument( | ||
state: EditorState, | ||
serializedDoc: any, | ||
version?: number, | ||
) { | ||
const { schema, tr } = state; | ||
const content: Node[] = | ||
// TODO remove serializedDoc | ||
serializedDoc instanceof Node | ||
? serializedDoc.content | ||
: (serializedDoc.content || []).map((child: any) => | ||
schema.nodeFromJSON(child), | ||
); | ||
const hasContent = Array.isArray(content) | ||
? content.length > 0 | ||
: Boolean(content); | ||
if (!hasContent) { | ||
return tr; | ||
} | ||
tr.setMeta('addToHistory', false); | ||
tr.replaceWith(0, state.doc.nodeSize - 2, content); | ||
tr.setSelection(Selection.atStart(tr.doc)); | ||
if (typeof version !== undefined) { | ||
const collabState = { version, unconfirmed: [] }; | ||
tr.setMeta('collab$', collabState); | ||
} | ||
return tr; | ||
} | ||
export function applySteps( | ||
@@ -75,3 +37,3 @@ view: EditorView, | ||
.setMeta('addToHistory', false) | ||
.setMeta('bangle/isRemote', true); | ||
.setMeta('bangle.dev/isRemote', true); | ||
const newState = view.state.apply(tr); | ||
@@ -84,11 +46,2 @@ view.updateState(newState); | ||
// Prevent any changes in the document from happening | ||
export function freezeDoc(view: EditorView) { | ||
view.state.apply( | ||
view.state.tr | ||
.setMeta('bangle/isRemote', true) | ||
.setMeta('bangle/allowUpdatingEditorState', false), | ||
); | ||
} | ||
export function applyDoc( | ||
@@ -119,10 +72,39 @@ view: EditorView, | ||
const newState = view.state.apply( | ||
tr | ||
.setMeta('bangle/isRemote', true) | ||
.setMeta('bangle/allowUpdatingEditorState', true), | ||
); | ||
const newState = view.state.apply(tr.setMeta('bangle.dev/isRemote', true)); | ||
view.updateState(newState); | ||
view.dispatch(view.state.tr.setMeta(collabSettingsKey, { ready: true })); | ||
} | ||
function replaceDocument( | ||
state: EditorState, | ||
serializedDoc: any, | ||
version?: number, | ||
) { | ||
const { schema, tr } = state; | ||
const content: Node[] = | ||
// TODO remove serializedDoc | ||
serializedDoc instanceof Node | ||
? serializedDoc.content | ||
: (serializedDoc.content || []).map((child: any) => | ||
schema.nodeFromJSON(child), | ||
); | ||
const hasContent = Array.isArray(content) | ||
? content.length > 0 | ||
: Boolean(content); | ||
if (!hasContent) { | ||
return tr; | ||
} | ||
tr.setMeta('addToHistory', false); | ||
tr.replaceWith(0, state.doc.nodeSize - 2, content); | ||
tr.setSelection(Selection.atStart(tr.doc)); | ||
if (typeof version !== undefined) { | ||
const collabState = { version, unconfirmed: [] }; | ||
tr.setMeta('collab$', collabState); | ||
} | ||
return tr; | ||
} |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
106743
12
3173
+ Added@bangle.dev/utils@0.30.0-alpha.5(transitive)
- Removed@bangle.dev/utils@0.30.0-alpha.4(transitive)