Compare commits
24 Commits
v13.0.0-10
...
v13.0.0-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
197932752e | ||
|
|
f0b2bdaf34 | ||
|
|
b96362c0f1 | ||
|
|
67f241cd7a | ||
|
|
c8af0bebf7 | ||
|
|
4f35e799a6 | ||
|
|
eb2a52dd26 | ||
|
|
189b1068ae | ||
|
|
7a3b60a5d7 | ||
|
|
99f06fc093 | ||
|
|
22917bca19 | ||
|
|
7f0e25dcba | ||
|
|
d90c9b1cb2 | ||
|
|
c426055f17 | ||
|
|
18c9010b63 | ||
|
|
c3edac62ef | ||
|
|
755de18fd5 | ||
|
|
641dc25076 | ||
|
|
1d58ea785f | ||
|
|
f53dff5043 | ||
|
|
74d1a31f49 | ||
|
|
d1063ab70b | ||
|
|
f4c919d9ec | ||
|
|
aeb23dbaa9 |
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: dmonad
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
22
README.md
22
README.md
@@ -17,10 +17,15 @@ suited for even large documents.
|
||||
* Discuss: [https://discuss.yjs.dev](https://discuss.yjs.dev)
|
||||
* Benchmarks:
|
||||
[https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
|
||||
* Podcast [**"Yjs Deep Dive into real time collaborative editing solutions":**](https://www.tag1consulting.com/blog/deep-dive-real-time-collaborative-editing-solutions-tagteamtalk-001-0)
|
||||
* Podcast [**"Google Docs-style editing in Gutenberg with the YJS framework":**](https://publishpress.com/blog/yjs/)
|
||||
|
||||
:warning: This is the documentation for v13 (still in alpha). For the stable v12
|
||||
release checkout the [v12 docs](./README.v12.md) :warning:
|
||||
|
||||
:construction_worker_woman: If you are looking for professional support to build
|
||||
collaborative or distributed applications ping us at <yjs@tag1consulting.com>.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
* [Overview](#Overview)
|
||||
@@ -70,18 +75,19 @@ manage all that for you and are the perfect starting point for your
|
||||
collaborative app.
|
||||
|
||||
<dl>
|
||||
<dt><a href="http://github.com/yjs/y-webrtc">y-webrtc</a></dt>
|
||||
<dd>
|
||||
Propagates document updates peer-to-peer using WebRTC. The peers exchange
|
||||
signaling data over signaling servers. Publically available signaling servers
|
||||
are available. Communication over the signaling servers can be encrypted by
|
||||
providing a shared secret, keeping the connection information and the shared
|
||||
document private.
|
||||
</dd>
|
||||
<dt><a href="http://github.com/yjs/y-websocket">y-websocket</a></dt>
|
||||
<dd>
|
||||
A module that contains a simple websocket backend and a websocket client that
|
||||
connects to that backend. The backend can be extended to persist updates in a
|
||||
leveldb database.
|
||||
</dd>
|
||||
<dt><a href="http://github.com/yjs/y-mesh">y-mesh</a></dt>
|
||||
<dd>
|
||||
[WIP] Creates a connected graph of webrtc connections with a high
|
||||
<a href="https://en.wikipedia.org/wiki/Strength_of_a_graph">strength</a>. It
|
||||
requires a signalling server that connects a client to the first peer. But after
|
||||
that the network manages itself. It is well suited for large and small networks.
|
||||
</dd>
|
||||
<dt><a href="http://github.com/yjs/y-dat">y-dat</a></dt>
|
||||
<dd>
|
||||
@@ -97,7 +103,7 @@ hypercores and y-dat listens to changes and applies them to the Yjs document.
|
||||
Install Yjs and a provider with your favorite package manager:
|
||||
|
||||
```sh
|
||||
npm i yjs@13.0.0-97 y-websocket@1.0.0-6
|
||||
npm i yjs@13.0.0-103 y-websocket@1.0.0-6
|
||||
```
|
||||
|
||||
Start the y-websocket server:
|
||||
|
||||
2536
package-lock.json
generated
2536
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-100",
|
||||
"version": "13.0.0-105",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.js",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"main": "./dist/yjs.cjs",
|
||||
"module": "./src/index.js",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
|
||||
"test": "npm run dist && node ./dist/tests.cjs --repitition-time 50",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repitition-time 10000",
|
||||
"dist": "rm -rf dist && rollup -c",
|
||||
"watch": "rollup -wc",
|
||||
"lint": "markdownlint README.md && standard && tsc",
|
||||
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
|
||||
"serve-docs": "npm run docs && serve ./docs/",
|
||||
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
|
||||
"serve-docs": "npm run docs && http-server ./docs/",
|
||||
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repitition-time 1000",
|
||||
"postversion": "git push && git push --tags",
|
||||
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
|
||||
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
@@ -51,15 +51,17 @@
|
||||
},
|
||||
"homepage": "https://yjs.dev",
|
||||
"dependencies": {
|
||||
"lib0": "^0.1.1"
|
||||
"lib0": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^11.0.1",
|
||||
"@rollup/plugin-node-resolve": "^7.0.0",
|
||||
"concurrently": "^3.6.1",
|
||||
"http-server": "^0.12.1",
|
||||
"jsdoc": "^3.6.3",
|
||||
"live-server": "^1.2.1",
|
||||
"rollup": "^1.20.3",
|
||||
"markdownlint-cli": "^0.19.0",
|
||||
"rollup": "^1.29.0",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-node-resolve": "^4.2.4",
|
||||
"standard": "^11.0.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.6.2",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import nodeResolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
|
||||
const localImports = process.env.LOCALIMPORTS
|
||||
|
||||
@@ -37,23 +38,18 @@ const debugResolve = {
|
||||
|
||||
export default [{
|
||||
input: './src/index.js',
|
||||
output: [{
|
||||
output: {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.js',
|
||||
file: 'dist/yjs.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
paths: path => {
|
||||
if (/^lib0\//.test(path)) {
|
||||
return `lib0/dist/${path.slice(5)}`
|
||||
return `lib0/dist/${path.slice(5, -3)}.cjs`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}, {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.mjs',
|
||||
format: 'es',
|
||||
sourcemap: true
|
||||
}],
|
||||
},
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
@@ -68,6 +64,24 @@ export default [{
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
})
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'main']
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
external: ['isomorphic.js']
|
||||
}]
|
||||
|
||||
@@ -35,6 +35,8 @@ import * as set from 'lib0/set.js'
|
||||
import * as binary from 'lib0/binary.js'
|
||||
|
||||
/**
|
||||
* @todo This should return several items
|
||||
*
|
||||
* @param {StructStore} store
|
||||
* @param {ID} id
|
||||
* @return {{item:Item, diff:number}}
|
||||
@@ -53,7 +55,7 @@ export const followRedone = (store, id) => {
|
||||
item = getItem(store, nextID)
|
||||
diff = nextID.clock - item.id.clock
|
||||
nextID = item.redone
|
||||
} while (nextID !== null)
|
||||
} while (nextID !== null && item instanceof Item)
|
||||
return {
|
||||
item, diff
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import * as encoding from 'lib0/encoding.js' // eslint-disable-line
|
||||
* @param {EventType} event
|
||||
*/
|
||||
export const callTypeObservers = (type, transaction, event) => {
|
||||
callEventHandlerListeners(type._eH, event, transaction)
|
||||
const changedType = type
|
||||
const changedParentTypes = transaction.changedParentTypes
|
||||
while (true) {
|
||||
// @ts-ignore
|
||||
@@ -40,6 +40,7 @@ export const callTypeObservers = (type, transaction, event) => {
|
||||
}
|
||||
type = type._item.parent
|
||||
}
|
||||
callEventHandlerListeners(changedType._eH, event, transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ export class YXmlText extends YText {
|
||||
const node = nestedNodes[i]
|
||||
str += `<${node.nodeName}`
|
||||
for (let j = 0; j < node.attrs.length; j++) {
|
||||
const attr = node.attrs[i]
|
||||
const attr = node.attrs[j]
|
||||
str += ` ${attr.key}="${attr.value}"`
|
||||
}
|
||||
str += '>'
|
||||
|
||||
@@ -23,11 +23,12 @@ import * as map from 'lib0/map.js'
|
||||
*/
|
||||
export class Doc extends Observable {
|
||||
/**
|
||||
* @param {Object|undefined} conf configuration
|
||||
* @param {Object} conf configuration
|
||||
* @param {boolean} [conf.gc] Disable garbage collection (default: gc=true)
|
||||
*/
|
||||
constructor (conf = {}) {
|
||||
constructor ({ gc = true } = {}) {
|
||||
super()
|
||||
this.gc = conf.gc || true
|
||||
this.gc = gc
|
||||
this.clientID = random.uint32()
|
||||
/**
|
||||
* @type {Map<string, AbstractType<YEvent>>}
|
||||
|
||||
@@ -83,33 +83,37 @@ export class PermanentUserData {
|
||||
}
|
||||
user.get('ids').push([clientid])
|
||||
users.observe(event => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
// user was overwritten, port all data over to the next user object
|
||||
// @todo Experiment with Y.Sets here
|
||||
user = userOverwrite
|
||||
// @todo iterate over old type
|
||||
this.clients.forEach((_userDescription, clientid) => {
|
||||
if (userDescription === _userDescription) {
|
||||
user.get('ids').push([clientid])
|
||||
setTimeout(() => {
|
||||
const userOverwrite = users.get(userDescription)
|
||||
if (userOverwrite !== user) {
|
||||
// user was overwritten, port all data over to the next user object
|
||||
// @todo Experiment with Y.Sets here
|
||||
user = userOverwrite
|
||||
// @todo iterate over old type
|
||||
this.clients.forEach((_userDescription, clientid) => {
|
||||
if (userDescription === _userDescription) {
|
||||
user.get('ids').push([clientid])
|
||||
}
|
||||
})
|
||||
const encoder = encoding.createEncoder()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
})
|
||||
const encoder = encoding.createEncoder()
|
||||
const ds = this.dss.get(userDescription)
|
||||
if (ds) {
|
||||
writeDeleteSet(encoder, ds)
|
||||
user.get('ds').push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
|
||||
const yds = user.get('ds')
|
||||
const ds = transaction.deleteSet
|
||||
if (transaction.local && ds.clients.size > 0) {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
setTimeout(() => {
|
||||
const yds = user.get('ds')
|
||||
const ds = transaction.deleteSet
|
||||
if (transaction.local && ds.clients.size > 0) {
|
||||
const encoder = encoding.createEncoder()
|
||||
writeDeleteSet(encoder, ds)
|
||||
yds.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as encoding from 'lib0/encoding.js'
|
||||
import * as map from 'lib0/map.js'
|
||||
import * as math from 'lib0/math.js'
|
||||
import * as set from 'lib0/set.js'
|
||||
import { callAll } from 'lib0/function.js'
|
||||
|
||||
/**
|
||||
* A transaction is created for every change on the Yjs model. It is possible
|
||||
@@ -72,7 +73,7 @@ export class Transaction {
|
||||
/**
|
||||
* All types that were directly modified (property added or child
|
||||
* inserted/deleted). New types are not included in this Set.
|
||||
* Maps from type to parentSubs (`item._parentSub = null` for YArray)
|
||||
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||
* @type {Map<AbstractType<YEvent>,Set<String|null>>}
|
||||
*/
|
||||
this.changed = new Map()
|
||||
@@ -144,6 +145,164 @@ export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Transaction>} transactionCleanups
|
||||
* @param {number} i
|
||||
*/
|
||||
const cleanupTransactions = (transactionCleanups, i) => {
|
||||
if (i < transactionCleanups.length) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const doc = transaction.doc
|
||||
const store = doc.store
|
||||
const ds = transaction.deleteSet
|
||||
try {
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
/**
|
||||
* An array of event callbacks.
|
||||
*
|
||||
* Each callback is called even if the other ones throw errors.
|
||||
*
|
||||
* @type {Array<function():void>}
|
||||
*/
|
||||
const fs = []
|
||||
// observe events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) =>
|
||||
fs.push(() => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => {
|
||||
// deep observe events
|
||||
transaction.changedParentTypes.forEach((events, type) =>
|
||||
fs.push(() => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// We don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
}
|
||||
})
|
||||
)
|
||||
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||
})
|
||||
callAll(fs, [])
|
||||
} finally {
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
for (const [client, clock] of transaction.afterState) {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (const mid of transaction._mergeStructs) {
|
||||
const client = mid.client
|
||||
const clock = mid.clock
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const updateMessage = computeUpdateMessageFromTransaction(transaction)
|
||||
if (updateMessage !== null) {
|
||||
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
if (transactionCleanups.length <= i + 1) {
|
||||
doc._transactionCleanups = []
|
||||
} else {
|
||||
cleanupTransactions(transactionCleanups, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the functionality of `y.transact(()=>{..})`
|
||||
*
|
||||
@@ -169,134 +328,13 @@ export const transact = (doc, f, origin = null, local = true) => {
|
||||
if (initialCall && transactionCleanups[0] === doc._transaction) {
|
||||
// The first transaction ended, now process observer calls.
|
||||
// Observer call may create new transactions for which we need to call the observers and do cleanup.
|
||||
// We don't want to nest these calls, so we execute these calls one after another
|
||||
for (let i = 0; i < transactionCleanups.length; i++) {
|
||||
const transaction = transactionCleanups[i]
|
||||
const store = transaction.doc.store
|
||||
const ds = transaction.deleteSet
|
||||
sortAndMergeDeleteSet(ds)
|
||||
transaction.afterState = getStateVector(transaction.doc.store)
|
||||
doc._transaction = null
|
||||
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||
// emit change events on changed types
|
||||
transaction.changed.forEach((subs, itemtype) => {
|
||||
if (itemtype._item === null || !itemtype._item.deleted) {
|
||||
itemtype._callObserver(transaction, subs)
|
||||
}
|
||||
})
|
||||
transaction.changedParentTypes.forEach((events, type) => {
|
||||
// We need to think about the possibility that the user transforms the
|
||||
// Y.Doc in the event.
|
||||
if (type._item === null || !type._item.deleted) {
|
||||
events = events
|
||||
.filter(event =>
|
||||
event.target._item === null || !event.target._item.deleted
|
||||
)
|
||||
events
|
||||
.forEach(event => {
|
||||
event.currentTarget = type
|
||||
})
|
||||
// We don't need to check for events.length
|
||||
// because we know it has at least one element
|
||||
callEventHandlerListeners(type._dEH, events, transaction)
|
||||
}
|
||||
})
|
||||
doc.emit('afterTransaction', [transaction, doc])
|
||||
/**
|
||||
* @param {Array<AbstractStruct>} structs
|
||||
* @param {number} pos
|
||||
*/
|
||||
const tryToMergeWithLeft = (structs, pos) => {
|
||||
const left = structs[pos - 1]
|
||||
const right = structs[pos]
|
||||
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||
if (left.mergeWith(right)) {
|
||||
structs.splice(pos, 1)
|
||||
if (right instanceof Item && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) {
|
||||
right.parent._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replace deleted items with ItemDeleted / GC.
|
||||
// This is where content is actually remove from the Yjs Doc.
|
||||
if (doc.gc) {
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||
for (
|
||||
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||
struct = structs[++si]
|
||||
) {
|
||||
const struct = structs[si]
|
||||
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||
break
|
||||
}
|
||||
if (struct instanceof Item && struct.deleted && !struct.keep) {
|
||||
struct.gc(store, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge deleted / gc'd items
|
||||
// merge from right to left for better efficiecy and so we don't miss any merge targets
|
||||
for (const [client, deleteItems] of ds.clients) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||
const deleteItem = deleteItems[di]
|
||||
// start with merging the item next to the last deleted item
|
||||
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||
for (
|
||||
let si = mostRightIndexToCheck, struct = structs[si];
|
||||
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||
struct = structs[--si]
|
||||
) {
|
||||
tryToMergeWithLeft(structs, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on all affected store.clients props, try to merge
|
||||
for (const [client, clock] of transaction.afterState) {
|
||||
const beforeClock = transaction.beforeState.get(client) || 0
|
||||
if (beforeClock !== clock) {
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
// we iterate from right to left so we can safely remove entries
|
||||
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
|
||||
for (let i = structs.length - 1; i >= firstChangePos; i--) {
|
||||
tryToMergeWithLeft(structs, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to merge mergeStructs
|
||||
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||
// but at the moment DS does not handle duplicates
|
||||
for (const mid of transaction._mergeStructs) {
|
||||
const client = mid.client
|
||||
const clock = mid.clock
|
||||
const structs = /** @type {Array<AbstractStruct>} */ (store.clients.get(client))
|
||||
const replacedStructPos = findIndexSS(structs, clock)
|
||||
if (replacedStructPos + 1 < structs.length) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos + 1)
|
||||
}
|
||||
if (replacedStructPos > 0) {
|
||||
tryToMergeWithLeft(structs, replacedStructPos)
|
||||
}
|
||||
}
|
||||
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||
if (doc._observers.has('update')) {
|
||||
const updateMessage = computeUpdateMessageFromTransaction(transaction)
|
||||
if (updateMessage !== null) {
|
||||
doc.emit('update', [encoding.toUint8Array(updateMessage), transaction.origin, doc])
|
||||
}
|
||||
}
|
||||
}
|
||||
doc._transactionCleanups = []
|
||||
// We don't want to nest these calls, so we execute these calls one after
|
||||
// another.
|
||||
// Also we need to ensure that all cleanups are called, even if the
|
||||
// observes throw errors.
|
||||
// This file is full of hacky try {} finally {} blocks to ensure that an
|
||||
// event can throw errors and also that the cleanup is called.
|
||||
cleanupTransactions(transactionCleanups, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,15 +75,15 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
itemsToRedo.add(struct)
|
||||
}
|
||||
})
|
||||
itemsToRedo.forEach(item => {
|
||||
performedChange = redoItem(transaction, item, itemsToRedo) !== null || performedChange
|
||||
itemsToRedo.forEach(struct => {
|
||||
performedChange = redoItem(transaction, struct, itemsToRedo) !== null || performedChange
|
||||
})
|
||||
/**
|
||||
* @type {Array<Item>}
|
||||
*/
|
||||
const itemsToDelete = []
|
||||
iterateStructs(transaction, structs, stackStartClock, stackItem.len, struct => {
|
||||
if (struct instanceof Item && !struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
if (struct instanceof Item) {
|
||||
if (struct.redone !== null) {
|
||||
let { item, diff } = followRedone(store, struct.id)
|
||||
if (diff > 0) {
|
||||
@@ -94,7 +94,9 @@ const popStackItem = (undoManager, stack, eventType) => {
|
||||
}
|
||||
struct = item
|
||||
}
|
||||
itemsToDelete.push(struct)
|
||||
if (!struct.deleted && scope.some(type => isParentOf(type, /** @type {Item} */ (struct)))) {
|
||||
itemsToDelete.push(struct)
|
||||
}
|
||||
}
|
||||
})
|
||||
// We want to delete in reverse order so that children are deleted before
|
||||
|
||||
@@ -13,10 +13,23 @@ import * as t from 'lib0/testing.js'
|
||||
export const testUndoText = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||
const undoManager = new UndoManager(text0)
|
||||
|
||||
// items that are added & deleted in the same transaction won't be undo
|
||||
text0.insert(0, 'test')
|
||||
text0.delete(0, 4)
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
// follow redone items
|
||||
text0.insert(0, 'a')
|
||||
undoManager.stopCapturing()
|
||||
text0.delete(0, 1)
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'a')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
text0.insert(0, 'abc')
|
||||
text1.insert(0, 'xyz')
|
||||
testConnector.syncAll()
|
||||
|
||||
@@ -207,7 +207,7 @@ export const testChangeEvent = tc => {
|
||||
const newArr = new Y.Array()
|
||||
array0.insert(0, [newArr, 4, 'dtrn'])
|
||||
t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0)
|
||||
t.compare(changes.delta, [{insert: [newArr, 4, 'dtrn']}])
|
||||
t.compare(changes.delta, [{ insert: [newArr, 4, 'dtrn'] }])
|
||||
changes = null
|
||||
array0.delete(0, 2)
|
||||
t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2)
|
||||
|
||||
@@ -340,6 +340,56 @@ export const testChangeEvent = tc => {
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = tc => {
|
||||
const doc = new Y.Doc()
|
||||
const map = doc.getMap('map')
|
||||
|
||||
let updateCalled = false
|
||||
let throwingObserverCalled = false
|
||||
let throwingDeepObserverCalled = false
|
||||
doc.on('update', () => {
|
||||
updateCalled = true
|
||||
})
|
||||
|
||||
const throwingObserver = () => {
|
||||
throwingObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
const throwingDeepObserver = () => {
|
||||
throwingDeepObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
map.observe(throwingObserver)
|
||||
map.observeDeep(throwingDeepObserver)
|
||||
|
||||
t.fails(() => {
|
||||
map.set('y', '2')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
// check if it works again
|
||||
updateCalled = false
|
||||
throwingObserverCalled = false
|
||||
throwingDeepObserverCalled = false
|
||||
t.fails(() => {
|
||||
map.set('z', '3')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
t.assert(map.get('z') === '3')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
|
||||
@@ -81,10 +81,10 @@ export const testBasicFormat = tc => {
|
||||
export const testGetDeltaWithEmbeds = tc => {
|
||||
const { text0 } = init(tc, { users: 1 })
|
||||
text0.applyDelta([{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
t.compare(text0.toDelta(), [{
|
||||
insert: {linebreak: 's'}
|
||||
insert: { linebreak: 's' }
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export const testSnapshot = tc => {
|
||||
delete v.attributes.ychange.user
|
||||
}
|
||||
})
|
||||
t.compare(state2Diff, [{insert: 'a'}, {insert: 'x', attributes: {ychange: { type: 'added' }}}, {insert: 'b', attributes: {ychange: { type: 'removed' }}}, { insert: 'cd' }])
|
||||
t.compare(state2Diff, [{ insert: 'a' }, { insert: 'x', attributes: { ychange: { type: 'added' } } }, { insert: 'b', attributes: { ychange: { type: 'removed' } } }, { insert: 'cd' }])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
"paths": {
|
||||
"yjs": ["./src/index.js"]
|
||||
"yjs": ["./src/index.js"],
|
||||
"lib0/*": ["node_modules/lib0/*"],
|
||||
"lib0/set.js": ["node_modules/lib0/set.js"],
|
||||
"lib0/function.js": ["node_modules/lib0/function.js"]
|
||||
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
|
||||
Reference in New Issue
Block a user