Compare commits

...

46 Commits

Author SHA1 Message Date
Kevin Jahns
7b680f1bda 13.5.33 2022-03-30 14:55:10 +02:00
Kevin Jahns
806bf3f6dd lint 2022-03-30 14:53:14 +02:00
Kevin Jahns
42fe19daf1 require updated lib0 dependency 2022-03-30 14:51:21 +02:00
Kevin Jahns
7d3de7fa07 add blocksurvey.io as a user 2022-03-28 10:50:48 +02:00
Kevin Jahns
63c1cb4eb9 bump dependencies 2022-03-28 10:38:54 +02:00
Kevin Jahns
be1449a7af 13.5.32 2022-03-26 10:31:13 +01:00
Kevin Jahns
a22b3cdbc1 add option to UndoManager to ignore remote map changes. implements #390 2022-03-26 10:29:19 +01:00
Kevin Jahns
e9a0dc4ed2 add destroy logic 2022-03-25 11:08:30 +01:00
Kevin Jahns
b0b276d964 13.5.31 2022-03-25 11:01:57 +01:00
Kevin Jahns
d3e117702c add method to add & remove tracked origins 2022-03-25 11:00:07 +01:00
Kevin Jahns
ff5067e149 13.5.30 2022-03-22 09:47:10 +01:00
Kevin Jahns
f80e39a477 Merge pull request #408 from rileyjshaw/upgrade-lib0
Upgrade lib0 from v0.2.43 to v0.2.47
2022-03-22 09:43:41 +01:00
Riley Shaw
f70198333a Upgrade lib0 from v0.2.43 to v0.2.47
After upgrading to the latest version (13.5.29), I started to see the
following error:

```
ERROR in ./node_modules/yjs/dist/yjs.mjs 4048:13-26
export 'isArray' (imported as 'array') was not found in 'lib0/array' (possible exports: appendTo, copy, create, equalFlat, every, flatten, from, last, some)
```

It looks like `array.isArray` was added to `lib0` in [v0.2.47](https://github.com/dmonad/lib0/releases/tag/v0.2.47).
Updating the version in `package.json` fixed the error in my project.
2022-03-21 19:16:23 -04:00
Kevin Jahns
3c31b22a92 13.5.29 2022-03-21 00:00:00 +01:00
Kevin Jahns
6b8cef29e2 address #398 2022-03-20 23:58:14 +01:00
Kevin Jahns
4a06492fb1 add stack-item-updated event to Y.UndoManager. implements #407 2022-03-20 22:49:23 +01:00
Kevin Jahns
46fbce0de8 more utility around Y.UndoManager 2022-03-20 22:41:33 +01:00
Kevin Jahns
239703fe5c Merge pull request #401 from dylans/patch-1
Add Living Spec to readme
2022-03-19 13:22:10 +01:00
Kevin Jahns
5e907e3281 Merge pull request #404 from alderzhang/alder/issue-403
fix decodeUpdateV2 bug
2022-03-15 13:53:12 +01:00
alderzhang
6aea35246b fix decodeUpdateV2 bug 2022-03-15 15:22:51 +08:00
Dylan Schiemann
5058189a46 Add Living Spec to readme
We've sponsored via GitHub and OpenCollective for a while and have made a few contributions. We'd like to be added to the list of users if appropriate. Thanks.
2022-03-13 04:23:17 -07:00
Kevin Jahns
4db3439bb1 Merge pull request #397 from sep2/patch-1
add immer-yjs to Bindings
2022-03-09 22:06:56 +01:00
Felix
aa5463b06d add immer-yjs to Bindings 2022-03-07 11:45:50 +08:00
Kevin Jahns
afe8e52840 13.5.28 2022-03-02 13:27:33 +01:00
Kevin Jahns
d0f9c4a27f lint 2022-03-02 13:25:56 +01:00
Kevin Jahns
a5ffdce342 bump dependencies 2022-03-02 13:23:18 +01:00
Kevin Jahns
67d27dfca2 update readme 2022-03-02 13:19:00 +01:00
Kevin Jahns
9f1548204a Merge pull request #376 from fson/yevent-target-type
Add more accurate typing for YEvent.target
2022-03-02 13:18:00 +01:00
Kevin Jahns
46e108f345 Merge pull request #388 from sanalabs/uk/decode-methods
Add decode methods
2022-03-01 15:16:59 +01:00
Kevin Jahns
bda622f523 Merge pull request #391 from Flamenco/patch-1
Fix typo
2022-03-01 15:15:58 +01:00
Kevin Jahns
fef9e39d91 Merge pull request #393 from dkuhnert/issue-392
cleanup redundant text attributes when delete attributes
2022-02-24 22:07:49 +01:00
Kevin Jahns
5751a12c11 add PRSM to projects 2022-02-24 14:45:17 +01:00
dkuhnert
fddb620d41 cleanup redundant text attributes when delete attributes
fixes #392
2022-02-23 18:20:26 +01:00
dkuhnert
abf3fab1b6 cleanup redundant text attributes when delete attributes
fixes #392
2022-02-23 14:53:31 +01:00
Flamenco
69e2375dc5 Fix typo 2022-02-18 08:15:45 -05:00
Kevin Jahns
058a50285c add dynaboard to the list of projects 2022-02-16 21:15:19 +01:00
Ulf Karlsson
8678ef62d6 Remove curr.info 2022-02-07 20:00:10 +01:00
Ulf Karlsson
db53b6c720 Add decode methods 2022-02-07 17:15:06 +01:00
Kevin Jahns
3f34777201 13.5.27 2022-02-04 12:42:59 +01:00
Kevin Jahns
24eddb2d75 fix concurrent formatting / cleanup bug 2022-02-04 12:41:13 +01:00
Kevin Jahns
8ce107bd17 Merge branch 'ja2nicholl-update-attributes-on-delete' 2022-02-04 11:27:07 +01:00
Kevin Jahns
2d1e3fde43 fixed edge formatting case 2022-02-04 11:26:32 +01:00
Kevin Jahns
04009f0d42 Merge branch 'update-attributes-on-delete' of git://github.com/ja2nicholl/yjs into ja2nicholl-update-attributes-on-delete 2022-02-04 10:39:41 +01:00
Jeremy Nicholl
84e95f11cb Fix formatting 2022-02-03 15:19:57 -05:00
Jeremy Nicholl
164b38f0cd Avoid copying attribute map when deleting
Calling cleanupFormattingGap should not make a copy of the
attributes because it needs to be able to update them.
2022-01-31 14:49:16 -05:00
Ville Immonen
81a36a2762 add more accurate typing for YEvent.target 2022-01-15 14:22:17 +02:00
23 changed files with 4703 additions and 519 deletions

View File

@@ -152,7 +152,7 @@ concepts that can be used to create a custom network protocol:
an incremental document updates that allows clients to sync with each other. an incremental document updates that allows clients to sync with each other.
The update object is an Uint8Array that efficiently encodes `Item` objects and The update object is an Uint8Array that efficiently encodes `Item` objects and
the delete set. the delete set.
* `state vector`: A state vector defines the know state of each user (a set of * `state vector`: A state vector defines the known state of each user (a set of
tuples `(client, clock)`). This object is also efficiently encoded as a tuples `(client, clock)`). This object is also efficiently encoded as a
Uint8Array. Uint8Array.

View File

@@ -25,44 +25,30 @@ build collaborative or distributed applications ping us at
<yjs@tag1consulting.com>. Otherwise you can find help on our <yjs@tag1consulting.com>. Otherwise you can find help on our
[discussion board](https://discuss.yjs.dev). [discussion board](https://discuss.yjs.dev).
## Sponsors ## Sponsorship
I'm currently looking for sponsors that allow me to be less dependent on Please contribute to the project financially if your company relies on Yjs.
contracting work. These awesome backers already fund further development of Support the project and receive more attention on your tickets.
Yjs: [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad)
[![davidhq](https://github.com/davidhq.png?size=60)](https://github.com/davidhq)
[![Ifiok Jr.](https://github.com/ifiokjr.png?size=60)](https://github.com/ifiokjr)
[![Burke Libbey](https://github.com/burke.png?size=60)](https://github.com/burke)
[![Beni Cherniavsky-Paskin](https://github.com/cben.png?size=60)](https://github.com/cben)
[![Tom Moor](https://github.com/tommoor.png?size=60)](https://github.com/tommoor)
[![Michael Meyers](https://github.com/michaelemeyers.png?size=60)](https://github.com/michaelemeyers)
[![Cristiano Benjamin](https://github.com/csbenjamin.png?size=60)](https://github.com/csbenjamin)
[![Braden](https://github.com/AdventureBeard.png?size=60)](https://github.com/AdventureBeard)
[![nimbuswebinc](https://nimbusweb.me/new-style-img/note-icon.svg)](https://github.com/nimbuswebinc)
[![JourneyApps](https://github.com/journeyapps.png?size=60)](https://github.com/journeyapps)
[![Adam Brunnmeier](https://github.com/adabru.png?size=60)](https://github.com/adabru)
[![Nathanael Anderson](https://github.com/NathanaelA.png?size=60)](https://github.com/NathanaelA)
[<img src="https://room.sh/img/icons/android-chrome-192x192.png" height="60px" />](https://room.sh/)
Sponsorship also comes with special perks! [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad)
## Who is using Yjs ## Who is using Yjs
* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted * [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star2:
collaborative notes app.
* [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and * [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and
community. :star2: community. :star2:
* [Input](https://input.com/) A collaborative note taking app. :star2:
* [Room.sh](https://room.sh/) A meeting application with integrated * [Room.sh](https://room.sh/) A meeting application with integrated
collaborative drawing, editing, and coding tools. :star: collaborative drawing, editing, and coding tools. :star:
* [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by * [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by
Nimbus Web. Nimbus Web. :star:
* [JoeDocs](https://joedocs.com/) An open collaborative wiki.
* [Pluxbox RadioManager](https://getradiomanager.com/) A web-based app to * [Pluxbox RadioManager](https://getradiomanager.com/) A web-based app to
collaboratively organize radio broadcasts. collaboratively organize radio broadcasts. :star:
* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted
collaborative notes app.
* [PRSM](https://prsm.uk/) Collaborative mind-mapping and system visualisation. *[(source)](https://github.com/micrology/prsm)*
* [Alldone](https://alldone.app/) A next-gen project management and * [Alldone](https://alldone.app/) A next-gen project management and
collaboration platform. collaboration platform.
* [Living Spec](https://livingspec.com/) A modern way for product teams to collaborate.
* [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys.
## Table of Contents ## Table of Contents
@@ -95,6 +81,7 @@ are implemented in separate modules.
| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) | | [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) |
| [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) | | [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) |
| [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) | | [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) |
| [immer](https://github.com/immerjs/immer) | | [immer-yjs](https://github.com/sep2/immer-yjs) | [demo](https://codesandbox.io/s/immer-yjs-demo-6e0znb) |
| React / Vue / Svelte / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) | | React / Vue / Svelte / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) |
### Providers ### Providers
@@ -885,6 +872,16 @@ undo- or the redo-stack.
</dd> </dd>
<b> <b>
<code> <code>
on('stack-item-updated', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' })
</code>
</b>
<dd>
Register an event that is called when an existing <code>StackItem</code> is updated.
This happens when two changes happen within a "captureInterval".
</dd>
<b>
<code>
on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo' on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
| 'redo' }) | 'redo' })
</code> </code>
@@ -893,6 +890,14 @@ on('stack-item-popped', { stackItem: { meta: Map&lt;any,any&gt; }, type: 'undo'
Register an event that is called when a <code>StackItem</code> is popped from Register an event that is called when a <code>StackItem</code> is popped from
the undo- or the redo-stack. the undo- or the redo-stack.
</dd> </dd>
<b>
<code>
on('stack-cleared', { undoStackCleared: boolean, redoStackCleared: boolean })
</code>
</b>
<dd>
Register an event that is called when the undo- and/or the redo-stack is cleared.
</dd>
</dl> </dl>
#### Example: Stop Capturing #### Example: Stop Capturing

4804
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "yjs", "name": "yjs",
"version": "13.5.26", "version": "13.5.33",
"description": "Shared Editing Library", "description": "Shared Editing Library",
"main": "./dist/yjs.cjs", "main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs", "module": "./dist/yjs.mjs",
@@ -73,7 +73,7 @@
}, },
"homepage": "https://docs.yjs.dev", "homepage": "https://docs.yjs.dev",
"dependencies": { "dependencies": {
"lib0": "^0.2.43" "lib0": "^0.2.48"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0", "@rollup/plugin-commonjs": "^17.0.0",

View File

@@ -67,6 +67,8 @@ export {
decodeStateVector, decodeStateVector,
logUpdate, logUpdate,
logUpdateV2, logUpdateV2,
decodeUpdate,
decodeUpdateV2,
relativePositionToJSON, relativePositionToJSON,
isDeleted, isDeleted,
isParentOf, isParentOf,

View File

@@ -100,4 +100,4 @@ export class ContentFormat {
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentFormat} * @return {ContentFormat}
*/ */
export const readContentFormat = decoder => new ContentFormat(decoder.readString(), decoder.readJSON()) export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON())

View File

@@ -39,7 +39,7 @@ export const YXmlTextRefID = 6
*/ */
export class ContentType { export class ContentType {
/** /**
* @param {AbstractType<YEvent>} type * @param {AbstractType<any>} type
*/ */
constructor (type) { constructor (type) {
/** /**

View File

@@ -127,12 +127,13 @@ export const splitItem = (transaction, leftItem, diff) => {
* @param {Item} item * @param {Item} item
* @param {Set<Item>} redoitems * @param {Set<Item>} redoitems
* @param {DeleteSet} itemsToDelete * @param {DeleteSet} itemsToDelete
* @param {boolean} ignoreRemoteMapChanges
* *
* @return {Item|null} * @return {Item|null}
* *
* @private * @private
*/ */
export const redoItem = (transaction, item, redoitems, itemsToDelete) => { export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges) => {
const doc = transaction.doc const doc = transaction.doc
const store = doc.store const store = doc.store
const ownClientID = doc.clientID const ownClientID = doc.clientID
@@ -152,7 +153,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
// make sure that parent is redone // make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true) { if (parentItem !== null && parentItem.deleted === true) {
// try to undo parent if it will be undone anyway // try to undo parent if it will be undone anyway
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete) === null)) { if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges) === null)) {
return null return null
} }
while (parentItem.redone !== null) { while (parentItem.redone !== null) {
@@ -198,7 +199,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete) => {
} }
} else { } else {
right = null right = null
if (item.right) { if (item.right && !ignoreRemoteMapChanges) {
left = item left = item
// Iterate right while right is in itemsToDelete // Iterate right while right is in itemsToDelete
// If it is intended to delete right while item is redone, we can expect that item should replace right. // If it is intended to delete right while item is redone, we can expect that item should replace right.

View File

@@ -278,7 +278,7 @@ export class AbstractType {
this._eH = createEventHandler() this._eH = createEventHandler()
/** /**
* Deep event handlers * Deep event handlers
* @type {EventHandler<Array<YEvent>,Transaction>} * @type {EventHandler<Array<YEvent<any>>,Transaction>}
*/ */
this._dEH = createEventHandler() this._dEH = createEventHandler()
/** /**
@@ -364,7 +364,7 @@ export class AbstractType {
/** /**
* Observe all events that are created by this type and its children. * Observe all events that are created by this type and its children.
* *
* @param {function(Array<YEvent>,Transaction):void} f Observer function * @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
*/ */
observeDeep (f) { observeDeep (f) {
addEventHandlerListener(this._dEH, f) addEventHandlerListener(this._dEH, f)
@@ -382,7 +382,7 @@ export class AbstractType {
/** /**
* Unregister an observer function. * Unregister an observer function.
* *
* @param {function(Array<YEvent>,Transaction):void} f Observer function * @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
*/ */
unobserveDeep (f) { unobserveDeep (f) {
removeEventHandlerListener(this._dEH, f) removeEventHandlerListener(this._dEH, f)

View File

@@ -23,6 +23,7 @@ import { typeListSlice } from './AbstractType.js'
/** /**
* Event that describes the changes on a YArray * Event that describes the changes on a YArray
* @template T * @template T
* @extends YEvent<YArray<T>>
*/ */
export class YArrayEvent extends YEvent { export class YArrayEvent extends YEvent {
/** /**

View File

@@ -21,6 +21,7 @@ import * as iterator from 'lib0/iterator'
/** /**
* @template T * @template T
* @extends YEvent<YMap<T>>
* Event that describes the changes on a YMap. * Event that describes the changes on a YMap.
*/ */
export class YMapEvent extends YEvent { export class YMapEvent extends YEvent {

View File

@@ -291,7 +291,8 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes) const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes)
// iterate until first non-format or null is found // iterate until first non-format or null is found
// delete all formats with attributes[format.key] != null // delete all formats with attributes[format.key] != null
while (length > 0 && currPos.right !== null) { // also check the attributes after the first non-format as we do not want to insert redundant negated attributes there
while (currPos.right !== null && (length > 0 || currPos.right.content.constructor === ContentFormat)) {
if (!currPos.right.deleted) { if (!currPos.right.deleted) {
switch (currPos.right.content.constructor) { switch (currPos.right.content.constructor) {
case ContentFormat: { case ContentFormat: {
@@ -338,14 +339,16 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
* *
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {Item} start * @param {Item} start
* @param {Item|null} end exclusive end, automatically iterates to the next Content Item * @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startAttributes * @param {Map<string,any>} startAttributes
* @param {Map<string,any>} endAttributes This attribute is modified! * @param {Map<string,any>} currAttributes
* @return {number} The amount of formatting Items deleted. * @return {number} The amount of formatting Items deleted.
* *
* @function * @function
*/ */
const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttributes) => { const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => {
let end = curr
const endAttributes = map.copy(currAttributes)
while (end && (!end.countable || end.deleted)) { while (end && (!end.countable || end.deleted)) {
if (!end.deleted && end.content.constructor === ContentFormat) { if (!end.deleted && end.content.constructor === ContentFormat) {
updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content)) updateCurrentAttributes(endAttributes, /** @type {ContentFormat} */ (end.content))
@@ -353,7 +356,11 @@ const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttri
end = end.right end = end.right
} }
let cleanups = 0 let cleanups = 0
let reachedEndOfCurr = false
while (start !== end) { while (start !== end) {
if (curr === start) {
reachedEndOfCurr = true
}
if (!start.deleted) { if (!start.deleted) {
const content = start.content const content = start.content
switch (content.constructor) { switch (content.constructor) {
@@ -363,6 +370,9 @@ const cleanupFormattingGap = (transaction, start, end, startAttributes, endAttri
// Either this format is overwritten or it is not necessary because the attribute already existed. // Either this format is overwritten or it is not necessary because the attribute already existed.
start.delete(transaction) start.delete(transaction)
cleanups++ cleanups++
if (!reachedEndOfCurr && (currAttributes.get(key) || null) === value && (startAttributes.get(key) || null) !== value) {
currAttributes.delete(key)
}
} }
break break
} }
@@ -465,7 +475,7 @@ const deleteText = (transaction, currPos, length) => {
currPos.forward() currPos.forward()
} }
if (start) { if (start) {
cleanupFormattingGap(transaction, start, currPos.right, startAttrs, map.copy(currPos.currentAttributes)) cleanupFormattingGap(transaction, start, currPos.right, startAttrs, currPos.currentAttributes)
} }
const parent = /** @type {AbstractType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent) const parent = /** @type {AbstractType<any>} */ (/** @type {Item} */ (currPos.left || currPos.right).parent)
if (parent._searchMarker) { if (parent._searchMarker) {
@@ -502,6 +512,7 @@ const deleteText = (transaction, currPos, length) => {
*/ */
/** /**
* @extends YEvent<YText>
* Event that describes the changes on a YText type. * Event that describes the changes on a YText type.
*/ */
export class YTextEvent extends YEvent { export class YTextEvent extends YEvent {
@@ -684,7 +695,7 @@ export class YTextEvent extends YEvent {
} else { } else {
attributes[key] = value attributes[key] = value
} }
} else { } else if (value !== null) {
item.delete(transaction) item.delete(transaction)
} }
} }
@@ -710,7 +721,7 @@ export class YTextEvent extends YEvent {
} else { } else {
attributes[key] = value attributes[key] = value
} }
} else { } else if (attr !== null) { // this will be cleaned up automatically by the contextless cleanup function
item.delete(transaction) item.delete(transaction)
} }
} }

View File

@@ -5,6 +5,7 @@ import {
} from '../internals.js' } from '../internals.js'
/** /**
* @extends YEvent<YXmlElement|YXmlText|YXmlFragment>
* An Event that describes changes on a YXml Element or Yxml Fragment * An Event that describes changes on a YXml Element or Yxml Fragment
*/ */
export class YXmlEvent extends YEvent { export class YXmlEvent extends YEvent {

View File

@@ -48,7 +48,7 @@ export class Doc extends Observable {
this.guid = guid this.guid = guid
this.collectionid = collectionid this.collectionid = collectionid
/** /**
* @type {Map<string, AbstractType<YEvent>>} * @type {Map<string, AbstractType<YEvent<any>>>}
*/ */
this.share = new Map() this.share = new Map()
this.store = new StructStore() this.store = new StructStore()

View File

@@ -75,13 +75,13 @@ export class Transaction {
* All types that were directly modified (property added or child * All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set. * 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>>} * @type {Map<AbstractType<YEvent<any>>,Set<String|null>>}
*/ */
this.changed = new Map() this.changed = new Map()
/** /**
* Stores the events for the types that observe also child elements. * Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`. * It is mainly used by `observeDeep`.
* @type {Map<AbstractType<YEvent>,Array<YEvent>>} * @type {Map<AbstractType<YEvent<any>>,Array<YEvent<any>>>}
*/ */
this.changedParentTypes = new Map() this.changedParentTypes = new Map()
/** /**
@@ -148,7 +148,7 @@ export const nextID = transaction => {
* did not change, it was just added and we should not fire events for `type`. * did not change, it was just added and we should not fire events for `type`.
* *
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {AbstractType<YEvent>} type * @param {AbstractType<YEvent<any>>} type
* @param {string|null} parentSub * @param {string|null} parentSub
*/ */
export const addChangedTypeToTransaction = (transaction, type, parentSub) => { export const addChangedTypeToTransaction = (transaction, type, parentSub) => {

View File

@@ -14,6 +14,7 @@ import {
} from '../internals.js' } from '../internals.js'
import * as time from 'lib0/time' import * as time from 'lib0/time'
import * as array from 'lib0/array'
import { Observable } from 'lib0/observable' import { Observable } from 'lib0/observable'
class StackItem { class StackItem {
@@ -30,6 +31,18 @@ class StackItem {
this.meta = new Map() this.meta = new Map()
} }
} }
/**
* @param {Transaction} tr
* @param {UndoManager} um
* @param {StackItem} stackItem
*/
const clearUndoManagerStackItem = (tr, um, stackItem) => {
iterateDeletedStructs(tr, stackItem.deletions, item => {
if (item instanceof Item && um.scope.some(type => isParentOf(type, item))) {
keepItem(item, false)
}
})
}
/** /**
* @param {UndoManager} undoManager * @param {UndoManager} undoManager
@@ -88,7 +101,7 @@ const popStackItem = (undoManager, stack, eventType) => {
} }
}) })
itemsToRedo.forEach(struct => { itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions) !== null || performedChange performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges) !== null || performedChange
}) })
// We want to delete in reverse order so that children are deleted before // We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered. // parents, so we have more information available when items are filtered.
@@ -124,6 +137,7 @@ const popStackItem = (undoManager, stack, eventType) => {
* filter returns false, the type/item won't be deleted even it is in the * filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope. * undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])] * @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
* @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..).
*/ */
/** /**
@@ -133,16 +147,20 @@ const popStackItem = (undoManager, stack, eventType) => {
* Fires 'stack-item-popped' event when a stack item was popped from either the * Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`. * undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
* *
* @extends {Observable<'stack-item-added'|'stack-item-popped'>} * @extends {Observable<'stack-item-added'|'stack-item-popped'|'stack-cleared'|'stack-item-updated'>}
*/ */
export class UndoManager extends Observable { export class UndoManager extends Observable {
/** /**
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types * @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {UndoManagerOptions} options * @param {UndoManagerOptions} options
*/ */
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) { constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]), ignoreRemoteMapChanges = false } = {}) {
super() super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope] /**
* @type {Array<AbstractType<any>>}
*/
this.scope = []
this.addToScope(typeScope)
this.deleteFilter = deleteFilter this.deleteFilter = deleteFilter
trackedOrigins.add(this) trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins this.trackedOrigins = trackedOrigins
@@ -163,7 +181,11 @@ export class UndoManager extends Observable {
this.redoing = false this.redoing = false
this.doc = /** @type {Doc} */ (this.scope[0].doc) this.doc = /** @type {Doc} */ (this.scope[0].doc)
this.lastChange = 0 this.lastChange = 0
this.doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => { this.ignoreRemoteMapChanges = ignoreRemoteMapChanges
/**
* @param {Transaction} transaction
*/
this.afterTransactionHandler = transaction => {
// Only track certain transactions // Only track certain transactions
if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) { if (!this.scope.some(type => transaction.changedParentTypes.has(type)) || (!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))) {
return return
@@ -175,7 +197,7 @@ export class UndoManager extends Observable {
this.stopCapturing() // next undo should not be appended to last stack item this.stopCapturing() // next undo should not be appended to last stack item
} else if (!redoing) { } else if (!redoing) {
// neither undoing nor redoing: delete redoStack // neither undoing nor redoing: delete redoStack
this.redoStack = [] this.clear(false, true)
} }
const insertions = new DeleteSet() const insertions = new DeleteSet()
transaction.afterState.forEach((endClock, client) => { transaction.afterState.forEach((endClock, client) => {
@@ -206,29 +228,59 @@ export class UndoManager extends Observable {
keepItem(item, true) keepItem(item, true)
} }
}) })
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
if (didAdd) { if (didAdd) {
this.emit('stack-item-added', [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]) this.emit('stack-item-added', changeEvent)
} else {
this.emit('stack-item-updated', changeEvent)
}
}
this.doc.on('afterTransaction', this.afterTransactionHandler)
this.doc.on('destroy', () => {
this.destroy()
})
}
/**
* @param {Array<AbstractType<any>> | AbstractType<any>} ytypes
*/
addToScope (ytypes) {
ytypes = array.isArray(ytypes) ? ytypes : [ytypes]
ytypes.forEach(ytype => {
if (this.scope.every(yt => yt !== ytype)) {
this.scope.push(ytype)
} }
}) })
} }
clear () { /**
this.doc.transact(transaction => { * @param {any} origin
/** */
* @param {StackItem} stackItem addTrackedOrigin (origin) {
*/ this.trackedOrigins.add(origin)
const clearItem = stackItem => { }
iterateDeletedStructs(transaction, stackItem.deletions, item => {
if (item instanceof Item && this.scope.some(type => isParentOf(type, item))) { /**
keepItem(item, false) * @param {any} origin
} */
}) removeTrackedOrigin (origin) {
} this.trackedOrigins.delete(origin)
this.undoStack.forEach(clearItem) }
this.redoStack.forEach(clearItem)
}) clear (clearUndoStack = true, clearRedoStack = true) {
this.undoStack = [] if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
this.redoStack = [] this.doc.transact(tr => {
if (clearUndoStack) {
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.undoStack = []
}
if (clearRedoStack) {
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.redoStack = []
}
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
})
}
} }
/** /**
@@ -304,4 +356,10 @@ export class UndoManager extends Observable {
canRedo () { canRedo () {
return this.redoStack.length > 0 return this.redoStack.length > 0
} }
destroy () {
this.trackedOrigins.delete(this)
this.doc.off('afterTransaction', this.afterTransactionHandler)
super.destroy()
}
} }

View File

@@ -298,10 +298,24 @@ export class UpdateEncoderV2 extends DSEncoderV2 {
writeKey (key) { writeKey (key) {
const clock = this.keyMap.get(key) const clock = this.keyMap.get(key)
if (clock === undefined) { if (clock === undefined) {
/**
* @todo uncomment to introduce this feature finally
*
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
* Furthermore, I forgot to set the keyclock. So everything was working fine.
*
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
*
* I don't know yet how to reintroduce this feature..
*
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
*
*/
// this.keyMap.set(key, this.keyClock)
this.keyClockEncoder.write(this.keyClock++) this.keyClockEncoder.write(this.keyClock++)
this.stringEncoder.write(key) this.stringEncoder.write(key)
} else { } else {
this.keyClockEncoder.write(this.keyClock++) this.keyClockEncoder.write(clock)
} }
} }
} }

View File

@@ -8,17 +8,18 @@ import * as set from 'lib0/set'
import * as array from 'lib0/array' import * as array from 'lib0/array'
/** /**
* @template {AbstractType<any>} T
* YEvent describes the changes on a YType. * YEvent describes the changes on a YType.
*/ */
export class YEvent { export class YEvent {
/** /**
* @param {AbstractType<any>} target The changed type. * @param {T} target The changed type.
* @param {Transaction} transaction * @param {Transaction} transaction
*/ */
constructor (target, transaction) { constructor (target, transaction) {
/** /**
* The type on which this event was created on. * The type on which this event was created on.
* @type {AbstractType<any>} * @type {T}
*/ */
this.target = target this.target = target
/** /**

View File

@@ -112,6 +112,30 @@ export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
logging.print('DeleteSet: ', ds) logging.print('DeleteSet: ', ds)
} }
/**
* @param {Uint8Array} update
*
*/
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*
*/
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
}
return {
structs,
ds: readDeleteSet(updateDecoder)
}
}
export class LazyStructWriter { export class LazyStructWriter {
/** /**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder

View File

@@ -72,9 +72,9 @@ export const testPermanentUserData = async tc => {
export const testDiffStateVectorOfUpdateIsEmpty = tc => { export const testDiffStateVectorOfUpdateIsEmpty = tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
/** /**
* @type {null | Uint8Array} * @type {any}
*/ */
let sv = /* any */ (null) let sv = null
ydoc.getText().insert(0, 'a') ydoc.getText().insert(0, 'a')
ydoc.on('update', update => { ydoc.on('update', update => {
sv = Y.encodeStateVectorFromUpdate(update) sv = Y.encodeStateVectorFromUpdate(update)

View File

@@ -527,3 +527,64 @@ export const testUndoBlockBug = tc => {
undoManager.redo() // {"text":{}} undoManager.redo() // {"text":{}}
t.compare(design.toJSON(), { text: { blocks: { text: '4' } } }) t.compare(design.toJSON(), { text: { blocks: { text: '4' } } })
} }
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc
*/
export const testUndoDeleteTextFormat = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.getText()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const undoManager = new Y.UndoManager(text)
text.format(13, 7, { bold: true })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(16, 4, { bold: null })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
undoManager.undo()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const expect = [
{ insert: 'Attack ships ' },
{
insert: 'on fire',
attributes: { bold: true }
},
{ insert: ' off the shoulder of Orion.' }
]
t.compare(text.toDelta(), expect)
t.compare(text2.toDelta(), expect)
}
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc
*/
export const testBehaviorOfIgnoreremotemapchangesProperty = tc => {
const doc = new Y.Doc()
const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
const map1 = doc.getMap()
const map2 = doc2.getMap()
const um1 = new Y.UndoManager(map1, { ignoreRemoteMapChanges: true })
map1.set('x', 1)
map2.set('x', 2)
map1.set('x', 3)
map2.set('x', 4)
um1.undo()
t.assert(map1.get('x') === 2)
t.assert(map2.get('x') === 2)
}

View File

@@ -112,6 +112,24 @@ export const testMergeUpdates = tc => {
}) })
} }
/**
* @param {t.TestCase} tc
*/
export const testKeyEncoding = tc => {
const { users, text0, text1 } = init(tc, { users: 2 })
text0.insert(0, 'a', { italic: true })
text0.insert(0, 'b')
text0.insert(0, 'c', { italic: true })
const update = Y.encodeStateAsUpdateV2(users[0])
Y.applyUpdateV2(users[1], update)
t.compare(text1.toDelta(), [{ insert: 'c', attributes: { italic: true } }, { insert: 'b' }, { insert: 'a', attributes: { italic: true } }])
compare(users)
}
/** /**
* @param {Y.Doc} ydoc * @param {Y.Doc} ydoc
* @param {Array<Uint8Array>} updates - expecting at least 4 updates * @param {Array<Uint8Array>} updates - expecting at least 4 updates

View File

@@ -6,6 +6,10 @@ import * as math from 'lib0/math'
const { init, compare } = Y const { init, compare } = Y
/** /**
* In this test we are mainly interested in the cleanup behavior and whether the resulting delta makes sense.
* It is fine if the resulting delta is not minimal. But applying the delta to a rich-text editor should result in a
* synced document.
*
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
export const testDeltaAfterConcurrentFormatting = tc => { export const testDeltaAfterConcurrentFormatting = tc => {
@@ -14,12 +18,17 @@ export const testDeltaAfterConcurrentFormatting = tc => {
testConnector.flushAllMessages() testConnector.flushAllMessages()
text0.format(0, 3, { bold: true }) text0.format(0, 3, { bold: true })
text1.format(2, 2, { bold: true }) text1.format(2, 2, { bold: true })
let delta = null /**
* @type {any}
*/
const deltas = []
text1.observe(event => { text1.observe(event => {
delta = event.delta if (event.delta.length > 0) {
deltas.push(event.delta)
}
}) })
testConnector.flushAllMessages() testConnector.flushAllMessages()
t.compare(delta, []) t.compare(deltas, [[{ retain: 3, attributes: { bold: true } }, { retain: 2, attributes: { bold: null } }]])
} }
/** /**
@@ -138,6 +147,28 @@ export const testNotMergeEmptyLinesFormat = tc => {
]) ])
} }
/**
* @param {t.TestCase} tc
*/
export const testPreserveAttributesThroughDelete = tc => {
const ydoc = new Y.Doc()
const testText = ydoc.getText('test')
testText.applyDelta([
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } },
{ insert: '\n' }
])
testText.applyDelta([
{ retain: 4 },
{ delete: 1 },
{ retain: 1, attributes: { title: true } }
])
t.compare(testText.toDelta(), [
{ insert: 'Text' },
{ insert: '\n', attributes: { title: true } }
])
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -576,6 +607,35 @@ export const testFormattingBug = async tc => {
console.log(text1.toDelta()) console.log(text1.toDelta())
} }
/**
* Delete formatting should not leave redundant formatting items.
*
* @param {t.TestCase} tc
*/
export const testDeleteFormatting = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.getText()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(13, 7, { bold: true })
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(16, 4, { bold: null })
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const expected = [
{ insert: 'Attack ships ' },
{ insert: 'on ', attributes: { bold: true } },
{ insert: 'fire off the shoulder of Orion.' }
]
t.compare(text.toDelta(), expected)
t.compare(text2.toDelta(), expected)
}
// RANDOM TESTS // RANDOM TESTS
let charCounter = 0 let charCounter = 0