Compare commits

...

83 Commits

Author SHA1 Message Date
Kevin Jahns
2e9a648d08 13.6.1 2023-05-04 11:29:08 +02:00
Kevin Jahns
83712cb1a6 update typings of getAttributes 2023-05-04 11:26:11 +02:00
Kevin Jahns
30b56d5ae9 Enable typings for inserting custom attrs in YXmlElement - fixes #531 2023-05-04 10:07:05 +02:00
Kevin Jahns
adaa95ebb8 add example to createDocFromSnapshot - #159 2023-04-27 18:08:28 +02:00
Kevin Jahns
1f2f08ef7e 13.6.0 2023-04-22 18:41:44 +02:00
Kevin Jahns
39167e6e2a Implement function that obfuscates a ydoc and scrambles its content 2023-04-22 18:39:29 +02:00
Kevin Jahns
5a8519d2c2 13.5.53 2023-04-18 20:09:59 +02:00
Kevin Jahns
d039d48b3f ytext: diff should never create useless delta op 2023-04-18 20:07:17 +02:00
Kevin Jahns
710ac31af3 13.5.52 2023-04-03 14:12:34 +02:00
Kevin Jahns
49f435284f lint 2023-04-03 14:10:26 +02:00
Kevin Jahns
ba96f2fe74 implement fix for #500. extends #515 2023-04-03 14:02:37 +02:00
Dominik Henneke
99bab4a1d8 Fix lint errors 2023-04-03 14:02:37 +02:00
Dominik Henneke
1674d3986d Restore deleted entries in a map 2023-04-03 14:02:37 +02:00
Kevin Jahns
dc3e99e6a1 Merge pull request #518 from WofWca/jsdoc-yarray
docs: fix JSDoc typo
2023-04-02 11:47:46 +02:00
WofWca
fb6664a2bc docs: fix JSDoc typo 2023-04-01 23:12:49 +08:00
Kevin Jahns
0d7e865531 13.5.51 2023-03-22 11:05:23 +01:00
Kevin Jahns
e73eb0bf92 use lib0 conditional exports in cjs file 2023-03-22 11:02:55 +01:00
Kevin Jahns
d815855450 specify engine 2023-03-21 11:27:37 +01:00
Kevin Jahns
61ba6cdde1 bump ci to use current nodejs versions 2023-03-21 11:22:59 +01:00
Kevin Jahns
cb70d7bad3 fix typings and lib0 resolution 2023-03-21 11:14:37 +01:00
Kevin Jahns
2001bec8eb modernize tsconfig 2023-03-11 12:20:52 +01:00
Kevin Jahns
2e2710ded9 13.5.50 2023-03-11 09:15:11 +01:00
Kevin Jahns
227018f5c7 toDelta doesnt create transaction - fixes #506 2023-03-11 09:13:27 +01:00
Kevin Jahns
da8bacfc78 add tests for complex Y.Text deltas 2023-03-10 12:53:48 +01:00
Kevin Jahns
92bad63145 add docs: tr.changes should only be computed during the event 2023-03-09 18:44:43 +01:00
Kevin Jahns
52ff230dd1 13.5.49 2023-03-09 13:59:08 +01:00
Kevin Jahns
fe48efe64f fix generating too many cleanup transactions. closes #506 2023-03-09 13:45:13 +01:00
Kevin Jahns
7e40fc442d 13.5.48 2023-03-02 19:50:34 +01:00
Kevin Jahns
035e350062 optimize formatting cleanup 2023-03-02 19:48:00 +01:00
Kevin Jahns
bf338d8040 fix attribute update issue - fixes #503 2023-03-02 19:08:01 +01:00
Kevin Jahns
658c520b93 13.5.47 2023-02-21 14:37:24 +01:00
Kevin Jahns
2576d4efca increasing sort of ds encoding 2023-02-21 14:35:28 +01:00
Kevin Jahns
58b754950e Merge pull request #439 from Synthesia-Technologies/feat/deterministic-update-encoding
Make encodeStateAsUpdate deterministic
2023-02-21 10:59:31 +01:00
Kevin Jahns
ea7ad07f34 13.5.46 2023-02-14 16:21:01 +01:00
Kevin Jahns
1c999b250e fix #474 - formatting bug 2023-02-14 16:19:22 +01:00
Kevin Jahns
e9189365ee add debugging case for #474 - unfininished 2023-02-13 14:27:57 +01:00
Kevin Jahns
e0a2f11db3 13.5.45 2023-01-31 12:57:56 +01:00
Kevin Jahns
7445a9ce5f add whenSynced and isSynced property with refined logic 2023-01-31 12:56:07 +01:00
Kevin Jahns
7f6c12a541 bump typescript and fix type issues 2023-01-31 12:16:03 +01:00
Kevin Jahns
370d0c138d Merge pull request #496 from neftaly/array.from
Add test for Y.Array.from
2023-01-25 13:12:52 +01:00
Neftaly Hernandez
d29de75f85 Add test for Y.Array.from 2023-01-23 06:41:57 +00:00
Kevin Jahns
f215866429 remove poxi on request.. 2023-01-19 15:24:53 +01:00
Kevin Jahns
093b41ccc4 Merge pull request #495 from laem/patch-1
New user
2023-01-19 15:20:41 +01:00
Mael
ab60cd1ff8 New user 2023-01-19 15:06:20 +01:00
Kevin Jahns
1130abe05b add POXi as a user 2023-01-18 12:20:52 +01:00
Kevin Jahns
31b4ab8d0c 13.5.44 2023-01-01 18:25:03 +01:00
Kevin Jahns
ab978b2003 add exports.module field - #438 2023-01-01 18:23:21 +01:00
Kevin Jahns
afc6728c9e 13.5.43 2022-11-30 12:28:28 +01:00
Kevin Jahns
0ef5bd42fe lint readme 2022-11-30 12:26:55 +01:00
Kevin Jahns
3ece681758 allow transactions within event handlers having different origins 2022-11-30 12:09:19 +01:00
Kevin Jahns
cac9407185 remove snapshot param in yxml.getAttributes 2022-11-30 11:34:34 +01:00
Kevin Jahns
7ea8ffebae Merge pull request #482 from joakim/patch-2
Add missing word
2022-11-08 16:30:12 +01:00
Joakim
d7751c16fd Add missing word 2022-11-05 21:15:33 +01:00
Kevin Jahns
a64c51ec06 Merge pull request #478 from ViktorQvarfordt/patch-1
Add Sana to -Who Is Using Yjs-
2022-10-24 13:10:31 +02:00
Viktor Qvarfordt
7405057037 Add Sana to -Who Is Using Yjs- 2022-10-23 21:34:43 +02:00
Kevin Jahns
6208b82872 13.5.42 2022-10-18 16:52:38 +02:00
Kevin Jahns
12a9134b09 lint 2022-10-18 16:51:07 +02:00
Kevin Jahns
7395229086 Port test from @PatrickShaw #432. Allow infinite captureTimeout in UndoManager #431. Closes #432 2022-10-18 16:45:30 +02:00
Kevin Jahns
8fb73edd97 Merge pull request #453 from Cargo/main
Allow updating captureTimeout on UndoManager instances
2022-10-03 11:58:03 +02:00
Kevin Jahns
f1ad5686c1 Add Hyperquery to -Who Is Using Yjs- 2022-10-02 17:10:14 +02:00
Kevin Jahns
ed9236bdc7 Merge pull request #464 from MaxNoetzold/main
Add y-mongodb-provider to provider list in README
2022-09-26 22:08:43 +02:00
Kevin Jahns
5405fd2d7c Merge pull request #465 from neo/patch-1
Remove unused return in `forEach` of `YMap`
2022-09-20 13:07:23 +02:00
Wenchen Li
12667f6b66 Remove unused return in forEach of YMap
If the idea is to keep the API as close to the JS Map as possible, maybe we should consider not returning here.

Ref: https://github.com/microsoft/TypeScript/blob/v4.8.3/lib/lib.es2015.collection.d.ts#L28-L31
2022-09-19 18:19:41 -04:00
MaxNoetzold
3d7ef7e28b add y-mongodb-provider to provider list in README 2022-09-18 14:17:52 +02:00
Kevin Jahns
56267e0a7d Merge pull request #455 from scenaristeur/patch-1
Update INTERNALS.md
2022-09-16 23:26:04 +02:00
Kevin Jahns
da71f6fa45 Merge pull request #463 from doodlewind/patch-1
add AFFiNE to sponsor list
2022-09-14 17:43:04 +02:00
Kevin Jahns
588788fbef fix snapshot diff calculation naming bug 2022-09-14 00:37:06 +02:00
Yifeng Wang
fb9df6efe2 add AFFiNE to sponsor list 2022-09-13 21:31:10 +08:00
Kevin Jahns
a69ecb0287 Merge pull request #460 from regischen/fix-typo
fix typo
2022-08-25 12:03:42 +02:00
regischen
923fc6e06e fix typo 2022-08-25 17:35:24 +08:00
David
0fdfd93e4b Update INTERNALS.md
Typo
2022-08-14 10:10:14 +02:00
Aart Rost
e0e5f8d2ea Allow updating captureTimeout on UndoManager instances
Used to pause the undoManager by toggling the timeout with `yUndoManager.captureTimeout = Number.MAX_VALUE`
2022-08-10 14:07:40 -07:00
Kevin Jahns
daf034cf75 Merge pull request #452 from aryzing/patch-1
Update README.md
2022-08-08 10:57:13 +02:00
Eduard Bardají Puig
2157ebb4d0 Update README.md
Add missing `.getText()` method to docs.
2022-08-07 20:52:16 +02:00
Kevin Jahns
97ef4ae1e0 13.5.41 2022-07-28 14:14:27 +02:00
Kevin Jahns
df2d59e2fb UndoManager: fix special deletion case. closes #447 closes #443 2022-07-28 14:12:21 +02:00
Kevin Jahns
7a61c90261 13.5.40 2022-07-22 14:24:44 +02:00
Kevin Jahns
6fa8778fc7 add doc parameter to UndoManager 2022-07-22 14:22:46 +02:00
Adam Chelminski
6b7b3136e0 delete set encoding should be in descending order 2022-06-23 16:01:29 +02:00
Adam Chelminski
da052bdb0a Make encodeStateAsUpdate deterministic 2022-06-23 15:50:35 +02:00
Kevin Jahns
1bc9308566 improve already-imported message further 2022-06-16 12:57:04 +02:00
Kevin Jahns
a5e0448a92 Merge pull request #434 from PatrickShaw/globalThis-add
Prefer globalThis over window and global
2022-06-15 12:04:09 +02:00
Patrick Shaw
8937494bdd Added globalThis 2022-06-01 13:36:32 +10:00
35 changed files with 3238 additions and 3740 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [10.x, 12.x, 14.x] node-version: [16.x, 18.x]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -25,5 +25,7 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run lint
- run: npm test - run: npm run test-extensive
env:
CI: true

View File

@@ -1,31 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x, 13.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm run test-extensive
env:
CI: true

View File

@@ -66,7 +66,7 @@ fields, respectively. These are used when peers concurrently insert at the same
location in a document. Though quite rare in practice, Yjs needs to make sure location in a document. Though quite rare in practice, Yjs needs to make sure
the list items always resolve to the same order on all peers. The actual logic the list items always resolve to the same order on all peers. The actual logic
is relatively simple - its only a couple dozen lines of code and it lives in is relatively simple - its only a couple dozen lines of code and it lives in
the `Item#integrate()` method. The YATA paper has much more detail on the this the `Item#integrate()` method. The YATA paper has much more detail on this
algorithm. algorithm.
### Item Storage ### Item Storage

View File

@@ -34,7 +34,11 @@ on Yjs. [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%2
## Who is using Yjs ## Who is using Yjs
* [AFFiNE](https://affine.pro/) A local-first, privacy-first, open source
knowledge base. 🏅
* [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star2: * [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star2:
* [Sana](https://sanalabs.com/) A learning platform with collaborative text
editing powered by Yjs.
* [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and * [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and
community. :star: community. :star:
* [Room.sh](https://room.sh/) A meeting application with integrated * [Room.sh](https://room.sh/) A meeting application with integrated
@@ -52,6 +56,10 @@ on Yjs. [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%2
* [Slidebeamer](https://slidebeamer.com/) Presentation app. * [Slidebeamer](https://slidebeamer.com/) Presentation app.
* [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys. * [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys.
* [Skiff](https://skiff.org/) Private, decentralized workspace. * [Skiff](https://skiff.org/) Private, decentralized workspace.
* [Hyperquery](https://hyperquery.ai/) A collaborative data workspace for
sharing analyses, documentation, spreadsheets, and dashboards.
* [Nosgestesclimat](https://nosgestesclimat.fr/groupe) The french carbon
footprint calculator has a group P2P mode based on yjs
## Table of Contents ## Table of Contents
@@ -136,6 +144,11 @@ Use Matrix as transport and storage of Yjs updates, so you can focus building
your client app and Matrix can provide powerful features like Authentication, your client app and Matrix can provide powerful features like Authentication,
Authorization, Federation, hosting (self-hosting or SaaS) and even End-to-End Authorization, Federation, hosting (self-hosting or SaaS) and even End-to-End
Encryption (E2EE). Encryption (E2EE).
</dd>
<dt><a href="https://github.com/MaxNoetzold/y-mongodb-provider">y-mongodb-provider</a></dt>
<dd>
Adds persistent storage to a server with MongoDB. Can be used with the
y-websocket provider.
</dd> </dd>
</dl> </dl>
@@ -629,6 +642,8 @@ type. Doesn't log types that have not been defined (using
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd> <dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
<b><code>getMap(string):Y.Map</code></b> <b><code>getMap(string):Y.Map</code></b>
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd> <dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
<b><code>getText(string):Y.Text</code></b>
<dd>Define a shared Y.Text type. Is equivalent to <code>y.get(string, Y.Text)</code>.</dd>
<b><code>getXmlFragment(string):Y.XmlFragment</code></b> <b><code>getXmlFragment(string):Y.XmlFragment</code></b>
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd> <dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
<b><code>on(string, function)</code></b> <b><code>on(string, function)</code></b>
@@ -738,6 +753,30 @@ currentState1 = Y.mergeUpdates([currentState1, diff2])
currentState1 = Y.mergeUpdates([currentState1, diff1]) currentState1 = Y.mergeUpdates([currentState1, diff1])
``` ```
#### Obfuscating Updates
If one of your users runs into a weird bug (e.g. the rich-text editor throws
error messages), then you don't have to request the full document from your
user. Instead, they can obfuscate the document (i.e. replace the content with
meaningless generated content) before sending it to you. Note that someone might
still deduce the type of content by looking at the general structure of the
document. But this is much better than requesting the original document.
Obfuscated updates contain all the CRDT-related data that is required for
merging. So it is safe to merge obfuscated updates.
```javascript
const ydoc = new Y.Doc()
// perform some changes..
ydoc.getText().insert(0, 'hello world')
const update = Y.encodeStateAsUpdate(ydoc)
// the below update contains scrambled data
const obfuscatedUpdate = Y.obfuscateUpdate(update)
const ydoc2 = new Y.Doc()
Y.applyUpdate(ydoc2, obfuscatedUpdate)
ydoc2.getText().toString() // => "00000000000"
```
#### Using V2 update format #### Using V2 update format
Yjs implements two update formats. By default you are using the V1 update format. Yjs implements two update formats. By default you are using the V1 update format.
@@ -1048,16 +1087,17 @@ More information about the specific implementation is available in
[INTERNALS.md](./INTERNALS.md) and in [INTERNALS.md](./INTERNALS.md) and in
[this walkthrough of the Yjs codebase](https://youtu.be/0l5XgnQ6rB4). [this walkthrough of the Yjs codebase](https://youtu.be/0l5XgnQ6rB4).
CRDTs that suitable for shared text editing suffer from the fact that they only grow CRDTs that are suitable for shared text editing suffer from the fact that they
in size. There are CRDTs that do not grow in size, but they do not have the only grow in size. There are CRDTs that do not grow in size, but they do not
characteristics that are benificial for shared text editing (like intention have the characteristics that are benificial for shared text editing (like
preservation). Yjs implements many improvements to the original algorithm that intention preservation). Yjs implements many improvements to the original
diminish the trade-off that the document only grows in size. We can't garbage algorithm that diminish the trade-off that the document only grows in size. We
collect deleted structs (tombstones) while ensuring a unique order of the can't garbage collect deleted structs (tombstones) while ensuring a unique
structs. But we can 1. merge preceeding structs into a single struct to reduce order of the structs. But we can 1. merge preceeding structs into a single
the amount of meta information, 2. we can delete content from the struct if it struct to reduce the amount of meta information, 2. we can delete content from
is deleted, and 3. we can garbage collect tombstones if we don't care about the the struct if it is deleted, and 3. we can garbage collect tombstones if we
order of the structs anymore (e.g. if the parent was deleted). don't care about the order of the structs anymore (e.g. if the parent was
deleted).
**Examples:** **Examples:**

4052
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.39", "version": "13.6.1",
"description": "Shared Editing Library", "description": "Shared Editing Library",
"main": "./dist/yjs.cjs", "main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs", "module": "./dist/yjs.mjs",
@@ -27,6 +27,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/src/index.d.ts", "types": "./dist/src/index.d.ts",
"module": "./dist/yjs.mjs",
"import": "./dist/yjs.mjs", "import": "./dist/yjs.mjs",
"require": "./dist/yjs.cjs" "require": "./dist/yjs.cjs"
}, },
@@ -74,19 +75,24 @@
}, },
"homepage": "https://docs.yjs.dev", "homepage": "https://docs.yjs.dev",
"dependencies": { "dependencies": {
"lib0": "^0.2.49" "lib0": "^0.2.74"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0", "@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^11.2.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@types/node": "^18.15.5",
"concurrently": "^3.6.1", "concurrently": "^3.6.1",
"http-server": "^0.12.3", "http-server": "^0.12.3",
"jsdoc": "^3.6.7", "jsdoc": "^3.6.7",
"markdownlint-cli": "^0.23.2", "markdownlint-cli": "^0.23.2",
"rollup": "^2.60.0", "rollup": "^3.20.0",
"standard": "^16.0.4", "standard": "^16.0.4",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^4.4.4", "typescript": "^4.9.5",
"y-protocols": "^1.0.5" "y-protocols": "^1.0.5"
},
"engines": {
"npm": ">=8.0.0",
"node": ">=16.0.0"
} }
} }

View File

@@ -42,13 +42,7 @@ export default [{
name: 'Y', name: 'Y',
file: 'dist/yjs.cjs', file: 'dist/yjs.cjs',
format: 'cjs', format: 'cjs',
sourcemap: true, sourcemap: true
paths: path => {
if (/^lib0\//.test(path)) {
return `lib0/dist/${path.slice(5)}.cjs`
}
return path
}
}, },
external: id => /^lib0\//.test(id) external: id => /^lib0\//.test(id)
}, { }, {
@@ -88,7 +82,7 @@ export default [{
plugins: [ plugins: [
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
mainFields: ['module', 'browser', 'main'] mainFields: ['browser', 'module', 'main']
}), }),
commonjs() commonjs()
] ]
@@ -103,9 +97,10 @@ export default [{
plugins: [ plugins: [
debugResolve, debugResolve,
nodeResolve({ nodeResolve({
mainFields: ['module', 'main'] mainFields: ['node', 'module', 'main'],
exportConditions: ['node', 'module', 'import', 'default']
}), }),
commonjs() commonjs()
], ],
external: ['isomorphic.js'] external: id => /^lib0\//.test(id)
}] }]

View File

@@ -90,13 +90,17 @@ export {
diffUpdateV2, diffUpdateV2,
convertUpdateFormatV1ToV2, convertUpdateFormatV1ToV2,
convertUpdateFormatV2ToV1, convertUpdateFormatV2ToV1,
obfuscateUpdate,
obfuscateUpdateV2,
UpdateEncoderV1 UpdateEncoderV1
} from './internals.js' } from './internals.js'
const glo = /** @type {any} */ (typeof window !== 'undefined' const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
? window ? globalThis
// @ts-ignore : typeof window !== 'undefined'
: typeof global !== 'undefined' ? global : {}) ? window
// @ts-ignore
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS$ __' const importIdentifier = '__ $YJS$ __'
@@ -112,7 +116,9 @@ if (glo[importIdentifier] === true) {
* This often leads to issues that are hard to debug. We often need to perform constructor checks, * This often leads to issues that are hard to debug. We often need to perform constructor checks,
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to * e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
* do the constructor checks anymore - which might break the CRDT algorithm. * do the constructor checks anymore - which might break the CRDT algorithm.
*
* https://github.com/yjs/yjs/issues/438
*/ */
console.error('Yjs was already imported. This breaks constructor checks and will lead to isssues!') console.error('Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438')
} }
glo[importIdentifier] = true glo[importIdentifier] = true

View File

@@ -23,11 +23,12 @@ import {
readContentType, readContentType,
addChangedTypeToTransaction, addChangedTypeToTransaction,
isDeleted, isDeleted,
DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as error from 'lib0/error' import * as error from 'lib0/error'
import * as binary from 'lib0/binary' import * as binary from 'lib0/binary'
import * as array from 'lib0/array'
/** /**
* @todo This should return several items * @todo This should return several items
@@ -120,6 +121,12 @@ export const splitItem = (transaction, leftItem, diff) => {
return rightItem return rightItem
} }
/**
* @param {Array<StackItem>} stack
* @param {ID} id
*/
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => isDeleted(s.deletions, id))
/** /**
* Redoes the effect of this operation. * Redoes the effect of this operation.
* *
@@ -128,12 +135,13 @@ export const splitItem = (transaction, leftItem, diff) => {
* @param {Set<Item>} redoitems * @param {Set<Item>} redoitems
* @param {DeleteSet} itemsToDelete * @param {DeleteSet} itemsToDelete
* @param {boolean} ignoreRemoteMapChanges * @param {boolean} ignoreRemoteMapChanges
* @param {import('../utils/UndoManager.js').UndoManager} um
* *
* @return {Item|null} * @return {Item|null}
* *
* @private * @private
*/ */
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges) => { export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) => {
const doc = transaction.doc const doc = transaction.doc
const store = doc.store const store = doc.store
const ownClientID = doc.clientID const ownClientID = doc.clientID
@@ -153,7 +161,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
// 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, ignoreRemoteMapChanges) === null)) { if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) === null)) {
return null return null
} }
while (parentItem.redone !== null) { while (parentItem.redone !== null) {
@@ -203,18 +211,10 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
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.
while (left !== null && left.right !== null && isDeleted(itemsToDelete, left.right.id)) { while (left !== null && left.right !== null && (left.right.redone || isDeleted(itemsToDelete, left.right.id) || isDeletedByUndoStack(um.undoStack, left.right.id) || isDeletedByUndoStack(um.redoStack, left.right.id))) {
left = left.right left = left.right
} // follow redone
// follow redone while (left.redone) left = getItemCleanStart(transaction, left.redone)
// trace redone until parent matches
while (left !== null && left.redone !== null) {
left = getItemCleanStart(transaction, left.redone)
}
// check wether we were allowed to follow right (indicating that originally this op was replaced by another item)
if (left === null || /** @type {AbstractType<any>} */ (left.parent)._item !== parentItem) {
// invalid parent; should never happen
return null
} }
if (left && left.right !== null) { if (left && left.right !== null) {
// It is not possible to redo this item because it conflicts with a // It is not possible to redo this item because it conflicts with a
@@ -761,48 +761,48 @@ export class AbstractContent {
} }
/** /**
* @param {number} offset * @param {number} _offset
* @return {AbstractContent} * @return {AbstractContent}
*/ */
splice (offset) { splice (_offset) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {AbstractContent} right * @param {AbstractContent} _right
* @return {boolean} * @return {boolean}
*/ */
mergeWith (right) { mergeWith (_right) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} _transaction
* @param {Item} item * @param {Item} _item
*/ */
integrate (transaction, item) { integrate (_transaction, _item) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {Transaction} transaction * @param {Transaction} _transaction
*/ */
delete (transaction) { delete (_transaction) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {StructStore} store * @param {StructStore} _store
*/ */
gc (store) { gc (_store) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }
/** /**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder * @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder
* @param {number} offset * @param {number} _offset
*/ */
write (encoder, offset) { write (_encoder, _offset) {
throw error.methodUnimplemented() throw error.methodUnimplemented()
} }

View File

@@ -324,9 +324,9 @@ export class AbstractType {
} }
/** /**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder * @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder
*/ */
_write (encoder) { } _write (_encoder) { }
/** /**
* The first non-deleted item * The first non-deleted item
@@ -344,9 +344,9 @@ export class AbstractType {
* Must be implemented by each type. * Must be implemented by each type.
* *
* @param {Transaction} transaction * @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified. * @param {Set<null|string>} _parentSubs Keys changed on this type. `null` if list was modified.
*/ */
_callObserver (transaction, parentSubs) { _callObserver (transaction, _parentSubs) {
if (!transaction.local && this._searchMarker) { if (!transaction.local && this._searchMarker) {
this._searchMarker.length = 0 this._searchMarker.length = 0
} }

View File

@@ -58,11 +58,14 @@ export class YArray extends AbstractType {
/** /**
* Construct a new YArray containing the specified items. * Construct a new YArray containing the specified items.
* @template T * @template {Object<string,any>|Array<any>|number|null|string|Uint8Array} T
* @param {Array<T>} items * @param {Array<T>} items
* @return {YArray<T>} * @return {YArray<T>}
*/ */
static from (items) { static from (items) {
/**
* @type {YArray<T>}
*/
const a = new YArray() const a = new YArray()
a.push(items) a.push(items)
return a return a
@@ -84,6 +87,9 @@ export class YArray extends AbstractType {
this._prelimContent = null this._prelimContent = null
} }
/**
* @return {YArray<T>}
*/
_copy () { _copy () {
return new YArray() return new YArray()
} }
@@ -92,9 +98,12 @@ export class YArray extends AbstractType {
* @return {YArray<T>} * @return {YArray<T>}
*/ */
clone () { clone () {
/**
* @type {YArray<T>}
*/
const arr = new YArray() const arr = new YArray()
arr.insert(0, this.toArray().map(el => arr.insert(0, this.toArray().map(el =>
el instanceof AbstractType ? el.clone() : el el instanceof AbstractType ? /** @type {typeof el} */ (el.clone()) : el
)) ))
return arr return arr
} }
@@ -133,7 +142,7 @@ export class YArray extends AbstractType {
insert (index, content) { insert (index, content) {
if (this.doc !== null) { if (this.doc !== null) {
transact(this.doc, transaction => { transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content) typeListInsertGenerics(transaction, this, index, /** @type {any} */ (content))
}) })
} else { } else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content) /** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
@@ -150,7 +159,7 @@ export class YArray extends AbstractType {
push (content) { push (content) {
if (this.doc !== null) { if (this.doc !== null) {
transact(this.doc, transaction => { transact(this.doc, transaction => {
typeListPushGenerics(transaction, this, content) typeListPushGenerics(transaction, this, /** @type {any} */ (content))
}) })
} else { } else {
/** @type {Array<any>} */ (this._prelimContent).push(...content) /** @type {Array<any>} */ (this._prelimContent).push(...content)
@@ -235,7 +244,7 @@ export class YArray extends AbstractType {
} }
/** /**
* Executes a provided function on once on overy element of this YArray. * Executes a provided function once on overy element of this YArray.
* *
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray. * @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/ */
@@ -259,9 +268,9 @@ export class YArray extends AbstractType {
} }
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* *
* @private * @private
* @function * @function
*/ */
export const readYArray = decoder => new YArray() export const readYArray = _decoder => new YArray()

View File

@@ -81,6 +81,9 @@ export class YMap extends AbstractType {
this._prelimContent = null this._prelimContent = null
} }
/**
* @return {YMap<MapType>}
*/
_copy () { _copy () {
return new YMap() return new YMap()
} }
@@ -89,9 +92,12 @@ export class YMap extends AbstractType {
* @return {YMap<MapType>} * @return {YMap<MapType>}
*/ */
clone () { clone () {
/**
* @type {YMap<MapType>}
*/
const map = new YMap() const map = new YMap()
this.forEach((value, key) => { this.forEach((value, key) => {
map.set(key, value instanceof AbstractType ? value.clone() : value) map.set(key, value instanceof AbstractType ? /** @type {typeof value} */ (value.clone()) : value)
}) })
return map return map
} }
@@ -167,16 +173,11 @@ export class YMap extends AbstractType {
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray. * @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
*/ */
forEach (f) { forEach (f) {
/**
* @type {Object<string,MapType>}
*/
const map = {}
this._map.forEach((item, key) => { this._map.forEach((item, key) => {
if (!item.deleted) { if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this) f(item.content.getContent()[item.length - 1], key, this)
} }
}) })
return map
} }
/** /**
@@ -205,14 +206,16 @@ export class YMap extends AbstractType {
/** /**
* Adds or updates an element with a specified key and value. * Adds or updates an element with a specified key and value.
* @template {MapType} VAL
* *
* @param {string} key The key of the element to add to this YMap * @param {string} key The key of the element to add to this YMap
* @param {MapType} value The value of the element to add * @param {VAL} value The value of the element to add
* @return {VAL}
*/ */
set (key, value) { set (key, value) {
if (this.doc !== null) { if (this.doc !== null) {
transact(this.doc, transaction => { transact(this.doc, transaction => {
typeMapSet(transaction, this, key, value) typeMapSet(transaction, this, key, /** @type {any} */ (value))
}) })
} else { } else {
/** @type {Map<string, any>} */ (this._prelimContent).set(key, value) /** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
@@ -246,7 +249,7 @@ export class YMap extends AbstractType {
clear () { clear () {
if (this.doc !== null) { if (this.doc !== null) {
transact(this.doc, transaction => { transact(this.doc, transaction => {
this.forEach(function (value, key, map) { this.forEach(function (_value, key, map) {
typeMapDelete(transaction, map, key) typeMapDelete(transaction, map, key)
}) })
}) })
@@ -264,9 +267,9 @@ export class YMap extends AbstractType {
} }
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* *
* @private * @private
* @function * @function
*/ */
export const readYMap = decoder => new YMap() export const readYMap = _decoder => new YMap()

View File

@@ -251,7 +251,7 @@ const insertAttributes = (transaction, parent, currPos, attributes) => {
* @function * @function
**/ **/
const insertText = (transaction, parent, currPos, text, attributes) => { const insertText = (transaction, parent, currPos, text, attributes) => {
currPos.currentAttributes.forEach((val, key) => { currPos.currentAttributes.forEach((_val, key) => {
if (attributes[key] === undefined) { if (attributes[key] === undefined) {
attributes[key] = null attributes[key] = null
} }
@@ -363,33 +363,48 @@ const formatText = (transaction, parent, currPos, length, attributes) => {
* @function * @function
*/ */
const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => { const cleanupFormattingGap = (transaction, start, curr, startAttributes, currAttributes) => {
let end = curr /**
const endAttributes = map.copy(currAttributes) * @type {Item|null}
*/
let end = start
/**
* @type {Map<string,ContentFormat>}
*/
const endFormats = map.create()
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)) const cf = /** @type {ContentFormat} */ (end.content)
endFormats.set(cf.key, cf)
} }
end = end.right end = end.right
} }
let cleanups = 0 let cleanups = 0
let reachedEndOfCurr = false let reachedCurr = false
while (start !== end) { while (start !== end) {
if (curr === start) { if (curr === start) {
reachedEndOfCurr = true reachedCurr = true
} }
if (!start.deleted) { if (!start.deleted) {
const content = start.content const content = start.content
switch (content.constructor) { switch (content.constructor) {
case ContentFormat: { case ContentFormat: {
const { key, value } = /** @type {ContentFormat} */ (content) const { key, value } = /** @type {ContentFormat} */ (content)
if ((endAttributes.get(key) || null) !== value || (startAttributes.get(key) || null) === value) { const startAttrValue = startAttributes.get(key) || null
if (endFormats.get(key) !== content || startAttrValue === value) {
// 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) { if (!reachedCurr && (currAttributes.get(key) || null) === value && startAttrValue !== value) {
currAttributes.delete(key) if (startAttrValue === null) {
currAttributes.delete(key)
} else {
currAttributes.set(key, startAttrValue)
}
} }
} }
if (!reachedCurr && !start.deleted) {
updateCurrentAttributes(currAttributes, /** @type {ContentFormat} */ (content))
}
break break
} }
} }
@@ -616,36 +631,39 @@ export class YTextEvent extends YEvent {
/** /**
* @type {any} * @type {any}
*/ */
let op let op = null
switch (action) { switch (action) {
case 'delete': case 'delete':
op = { delete: deleteLen } if (deleteLen > 0) {
op = { delete: deleteLen }
}
deleteLen = 0 deleteLen = 0
break break
case 'insert': case 'insert':
op = { insert } if (typeof insert === 'object' || insert.length > 0) {
if (currentAttributes.size > 0) { op = { insert }
op.attributes = {} if (currentAttributes.size > 0) {
currentAttributes.forEach((value, key) => { op.attributes = {}
if (value !== null) { currentAttributes.forEach((value, key) => {
op.attributes[key] = value if (value !== null) {
} op.attributes[key] = value
}) }
})
}
} }
insert = '' insert = ''
break break
case 'retain': case 'retain':
op = { retain } if (retain > 0) {
if (Object.keys(attributes).length > 0) { op = { retain }
op.attributes = {} if (!object.isEmpty(attributes)) {
for (const key in attributes) { op.attributes = object.assign({}, attributes)
op.attributes[key] = attributes[key]
} }
} }
retain = 0 retain = 0
break break
} }
delta.push(op) if (op) delta.push(op)
action = null action = null
} }
} }
@@ -927,7 +945,7 @@ export class YText extends AbstractType {
* Apply a {@link Delta} on this shared YText type. * Apply a {@link Delta} on this shared YText type.
* *
* @param {any} delta The changes to apply on this element. * @param {any} delta The changes to apply on this element.
* @param {object} [opts] * @param {object} opts
* @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true. * @param {boolean} [opts.sanitize] Sanitize input delta. Removes ending newlines if set to true.
* *
* *
@@ -1003,27 +1021,19 @@ export class YText extends AbstractType {
str = '' str = ''
} }
} }
// snapshots are merged again after the transaction, so we need to keep the const computeDelta = () => {
// transalive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
while (n !== null) { while (n !== null) {
if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) { if (isVisible(n, snapshot) || (prevSnapshot !== undefined && isVisible(n, prevSnapshot))) {
switch (n.content.constructor) { switch (n.content.constructor) {
case ContentString: { case ContentString: {
const cur = currentAttributes.get('ychange') const cur = currentAttributes.get('ychange')
if (snapshot !== undefined && !isVisible(n, snapshot)) { if (snapshot !== undefined && !isVisible(n, snapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'removed') { if (cur === undefined || cur.user !== n.id.client || cur.type !== 'removed') {
packStr() packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' }) currentAttributes.set('ychange', computeYChange ? computeYChange('removed', n.id) : { type: 'removed' })
} }
} else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) { } else if (prevSnapshot !== undefined && !isVisible(n, prevSnapshot)) {
if (cur === undefined || cur.user !== n.id.client || cur.state !== 'added') { if (cur === undefined || cur.user !== n.id.client || cur.type !== 'added') {
packStr() packStr()
currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' }) currentAttributes.set('ychange', computeYChange ? computeYChange('added', n.id) : { type: 'added' })
} }
@@ -1064,7 +1074,22 @@ export class YText extends AbstractType {
n = n.right n = n.right
} }
packStr() packStr()
}, splitSnapshotAffectedStructs) }
if (snapshot || prevSnapshot) {
// snapshots are merged again after the transaction, so we need to keep the
// transaction alive until we are done
transact(doc, transaction => {
if (snapshot) {
splitSnapshotAffectedStructs(transaction, snapshot)
}
if (prevSnapshot) {
splitSnapshotAffectedStructs(transaction, prevSnapshot)
}
computeDelta()
}, 'cleanup')
} else {
computeDelta()
}
return ops return ops
} }
@@ -1229,12 +1254,11 @@ export class YText extends AbstractType {
* *
* @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks. * @note Xml-Text nodes don't have attributes. You can use this feature to assign properties to complete text-blocks.
* *
* @param {Snapshot} [snapshot]
* @return {Object<string, any>} A JSON Object that describes the attributes. * @return {Object<string, any>} A JSON Object that describes the attributes.
* *
* @public * @public
*/ */
getAttributes (snapshot) { getAttributes () {
return typeMapGetAll(this) return typeMapGetAll(this)
} }
@@ -1247,10 +1271,10 @@ export class YText extends AbstractType {
} }
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* @return {YText} * @return {YText}
* *
* @private * @private
* @function * @function
*/ */
export const readYText = decoder => new YText() export const readYText = _decoder => new YText()

View File

@@ -1,3 +1,4 @@
import * as object from 'lib0/object'
import { import {
YXmlFragment, YXmlFragment,
@@ -9,15 +10,21 @@ import {
typeMapGetAll, typeMapGetAll,
typeListForEach, typeListForEach,
YXmlElementRefID, YXmlElementRefID,
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Snapshot, Doc, Item // eslint-disable-line YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item // eslint-disable-line
} from '../internals.js' } from '../internals.js'
/**
* @typedef {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} ValueTypes
*/
/** /**
* An YXmlElement imitates the behavior of a * An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}. * {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
* *
* * An YXmlElement has attributes (key value pairs) * * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement * * An YXmlElement has childElements that must inherit from YXmlElement
*
* @template {{ [key: string]: ValueTypes }} [KV={ [key: string]: string }]
*/ */
export class YXmlElement extends YXmlFragment { export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') { constructor (nodeName = 'UNDEFINED') {
@@ -73,14 +80,19 @@ export class YXmlElement extends YXmlFragment {
} }
/** /**
* @return {YXmlElement} * @return {YXmlElement<KV>}
*/ */
clone () { clone () {
/**
* @type {YXmlElement<KV>}
*/
const el = new YXmlElement(this.nodeName) const el = new YXmlElement(this.nodeName)
const attrs = this.getAttributes() const attrs = this.getAttributes()
for (const key in attrs) { object.forEach(attrs, (value, key) => {
el.setAttribute(key, attrs[key]) if (typeof value === 'string') {
} el.setAttribute(key, value)
}
})
// @ts-ignore // @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item)) el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el return el
@@ -116,7 +128,7 @@ export class YXmlElement extends YXmlFragment {
/** /**
* Removes an attribute from this YXmlElement. * Removes an attribute from this YXmlElement.
* *
* @param {String} attributeName The attribute name that is to be removed. * @param {string} attributeName The attribute name that is to be removed.
* *
* @public * @public
*/ */
@@ -133,8 +145,10 @@ export class YXmlElement extends YXmlFragment {
/** /**
* Sets or updates an attribute. * Sets or updates an attribute.
* *
* @param {String} attributeName The attribute name that is to be set. * @template {keyof KV & string} KEY
* @param {String} attributeValue The attribute value that is to be set. *
* @param {KEY} attributeName The attribute name that is to be set.
* @param {KV[KEY]} attributeValue The attribute value that is to be set.
* *
* @public * @public
*/ */
@@ -151,9 +165,11 @@ export class YXmlElement extends YXmlFragment {
/** /**
* Returns an attribute value that belongs to the attribute name. * Returns an attribute value that belongs to the attribute name.
* *
* @param {String} attributeName The attribute name that identifies the * @template {keyof KV & string} KEY
*
* @param {KEY} attributeName The attribute name that identifies the
* queried value. * queried value.
* @return {String} The queried attribute value. * @return {KV[KEY]|undefined} The queried attribute value.
* *
* @public * @public
*/ */
@@ -164,7 +180,7 @@ export class YXmlElement extends YXmlFragment {
/** /**
* Returns whether an attribute exists * Returns whether an attribute exists
* *
* @param {String} attributeName The attribute name to check for existence. * @param {string} attributeName The attribute name to check for existence.
* @return {boolean} whether the attribute exists. * @return {boolean} whether the attribute exists.
* *
* @public * @public
@@ -176,13 +192,12 @@ export class YXmlElement extends YXmlFragment {
/** /**
* Returns all attribute name/value pairs in a JSON Object. * Returns all attribute name/value pairs in a JSON Object.
* *
* @param {Snapshot} [snapshot] * @return {{ [Key in Extract<keyof KV,string>]?: KV[Key]}} A JSON Object that describes the attributes.
* @return {Object<string, any>} A JSON Object that describes the attributes.
* *
* @public * @public
*/ */
getAttributes (snapshot) { getAttributes () {
return typeMapGetAll(this) return /** @type {any} */ (typeMapGetAll(this))
} }
/** /**
@@ -204,7 +219,10 @@ export class YXmlElement extends YXmlFragment {
const dom = _document.createElement(this.nodeName) const dom = _document.createElement(this.nodeName)
const attrs = this.getAttributes() const attrs = this.getAttributes()
for (const key in attrs) { for (const key in attrs) {
dom.setAttribute(key, attrs[key]) const value = attrs[key]
if (typeof value === 'string') {
dom.setAttribute(key, value)
}
} }
typeListForEach(this, yxml => { typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding)) dom.appendChild(yxml.toDOM(_document, hooks, binding))

View File

@@ -17,10 +17,11 @@ import {
transact, transact,
typeListGet, typeListGet,
typeListSlice, typeListSlice,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook, Snapshot // eslint-disable-line UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook // eslint-disable-line
} from '../internals.js' } from '../internals.js'
import * as error from 'lib0/error' import * as error from 'lib0/error'
import * as array from 'lib0/array'
/** /**
* Define the elements to which a set of CSS queries apply. * Define the elements to which a set of CSS queries apply.
@@ -237,7 +238,7 @@ export class YXmlFragment extends AbstractType {
querySelectorAll (query) { querySelectorAll (query) {
query = query.toUpperCase() query = query.toUpperCase()
// @ts-ignore // @ts-ignore
return Array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query)) return array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
} }
/** /**
@@ -407,7 +408,7 @@ export class YXmlFragment extends AbstractType {
/** /**
* Executes a provided function on once on overy child element. * Executes a provided function on once on overy child element.
* *
* @param {function(YXmlElement|YXmlText,number, typeof this):void} f A function to execute on every element of this YArray. * @param {function(YXmlElement|YXmlText,number, typeof self):void} f A function to execute on every element of this YArray.
*/ */
forEach (f) { forEach (f) {
typeListForEach(this, f) typeListForEach(this, f)
@@ -427,10 +428,10 @@ export class YXmlFragment extends AbstractType {
} }
/** /**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder * @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* @return {YXmlFragment} * @return {YXmlFragment}
* *
* @private * @private
* @function * @function
*/ */
export const readYXmlFragment = decoder => new YXmlFragment() export const readYXmlFragment = _decoder => new YXmlFragment()

View File

@@ -171,7 +171,7 @@ export const mergeDeleteSets = dss => {
* @function * @function
*/ */
export const addToDeleteSet = (ds, client, clock, length) => { export const addToDeleteSet = (ds, client, clock, length) => {
map.setIfUndefined(ds.clients, client, () => []).push(new DeleteItem(clock, length)) map.setIfUndefined(ds.clients, client, () => /** @type {Array<DeleteItem>} */ ([])).push(new DeleteItem(clock, length))
} }
export const createDeleteSet = () => new DeleteSet() export const createDeleteSet = () => new DeleteSet()
@@ -219,17 +219,21 @@ export const createDeleteSetFromStructStore = ss => {
*/ */
export const writeDeleteSet = (encoder, ds) => { export const writeDeleteSet = (encoder, ds) => {
encoding.writeVarUint(encoder.restEncoder, ds.clients.size) encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
ds.clients.forEach((dsitems, client) => {
encoder.resetDsCurVal() // Ensure that the delete set is written in a deterministic order
encoding.writeVarUint(encoder.restEncoder, client) array.from(ds.clients.entries())
const len = dsitems.length .sort((a, b) => b[0] - a[0])
encoding.writeVarUint(encoder.restEncoder, len) .forEach(([client, dsitems]) => {
for (let i = 0; i < len; i++) { encoder.resetDsCurVal()
const item = dsitems[i] encoding.writeVarUint(encoder.restEncoder, client)
encoder.writeDsClock(item.clock) const len = dsitems.length
encoder.writeDsLen(item.len) encoding.writeVarUint(encoder.restEncoder, len)
} for (let i = 0; i < len; i++) {
}) const item = dsitems[i]
encoder.writeDsClock(item.clock)
encoder.writeDsLen(item.len)
}
})
} }
/** /**
@@ -247,7 +251,7 @@ export const readDeleteSet = decoder => {
const client = decoding.readVarUint(decoder.restDecoder) const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder) const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
if (numberOfDeletes > 0) { if (numberOfDeletes > 0) {
const dsField = map.setIfUndefined(ds.clients, client, () => []) const dsField = map.setIfUndefined(ds.clients, client, () => /** @type {Array<DeleteItem>} */ ([]))
for (let i = 0; i < numberOfDeletes; i++) { for (let i = 0; i < numberOfDeletes; i++) {
dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen())) dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen()))
} }

View File

@@ -38,7 +38,7 @@ export const generateNewClientId = random.uint32
*/ */
export class Doc extends Observable { export class Doc extends Observable {
/** /**
* @param {DocOpts} [opts] configuration * @param {DocOpts} opts configuration
*/ */
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) { constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) {
super() super()
@@ -72,13 +72,57 @@ export class Doc extends Observable {
this.shouldLoad = shouldLoad this.shouldLoad = shouldLoad
this.autoLoad = autoLoad this.autoLoad = autoLoad
this.meta = meta this.meta = meta
/**
* This is set to true when the persistence provider loaded the document from the database or when the `sync` event fires.
* Note that not all providers implement this feature. Provider authors are encouraged to fire the `load` event when the doc content is loaded from the database.
*
* @type {boolean}
*/
this.isLoaded = false this.isLoaded = false
/**
* This is set to true when the connection provider has successfully synced with a backend.
* Note that when using peer-to-peer providers this event may not provide very useful.
* Also note that not all providers implement this feature. Provider authors are encouraged to fire
* the `sync` event when the doc has been synced (with `true` as a parameter) or if connection is
* lost (with false as a parameter).
*/
this.isSynced = false
/**
* Promise that resolves once the document has been loaded from a presistence provider.
*/
this.whenLoaded = promise.create(resolve => { this.whenLoaded = promise.create(resolve => {
this.on('load', () => { this.on('load', () => {
this.isLoaded = true this.isLoaded = true
resolve(this) resolve(this)
}) })
}) })
const provideSyncedPromise = () => promise.create(resolve => {
/**
* @param {boolean} isSynced
*/
const eventHandler = (isSynced) => {
if (isSynced === undefined || isSynced === true) {
this.off('sync', eventHandler)
resolve()
}
}
this.on('sync', eventHandler)
})
this.on('sync', isSynced => {
if (isSynced === false && this.isSynced) {
this.whenSynced = provideSyncedPromise()
}
this.isSynced = isSynced === undefined || isSynced === true
if (!this.isLoaded) {
this.emit('load', [])
}
})
/**
* Promise that resolves once the document has been synced with a backend.
* This promise is recreated when the connection is lost.
* Note the documentation about the `isSynced` property.
*/
this.whenSynced = provideSyncedPromise()
} }
/** /**
@@ -103,7 +147,7 @@ export class Doc extends Observable {
} }
getSubdocGuids () { getSubdocGuids () {
return new Set(Array.from(this.subdocs).map(doc => doc.guid)) return new Set(array.from(this.subdocs).map(doc => doc.guid))
} }
/** /**
@@ -112,13 +156,15 @@ export class Doc extends Observable {
* that happened inside of the transaction are sent as one message to the * that happened inside of the transaction are sent as one message to the
* other peers. * other peers.
* *
* @param {function(Transaction):void} f The function that should be executed as a transaction * @template T
* @param {function(Transaction):T} f The function that should be executed as a transaction
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin * @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
* @return T
* *
* @public * @public
*/ */
transact (f, origin = null) { transact (f, origin = null) {
transact(this, f, origin) return transact(this, f, origin)
} }
/** /**

View File

@@ -71,7 +71,7 @@ export class PermanentUserData {
* @param {Doc} doc * @param {Doc} doc
* @param {number} clientid * @param {number} clientid
* @param {string} userDescription * @param {string} userDescription
* @param {Object} [conf] * @param {Object} conf
* @param {function(Transaction, DeleteSet):boolean} [conf.filter] * @param {function(Transaction, DeleteSet):boolean} [conf.filter]
*/ */
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) { setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
@@ -84,7 +84,7 @@ export class PermanentUserData {
users.set(userDescription, user) users.set(userDescription, user)
} }
user.get('ids').push([clientid]) user.get('ids').push([clientid])
users.observe(event => { users.observe(_event => {
setTimeout(() => { setTimeout(() => {
const userOverwrite = users.get(userDescription) const userOverwrite = users.get(userDescription)
if (userOverwrite !== user) { if (userOverwrite !== user) {

View File

@@ -153,6 +153,14 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
} }
/** /**
* @example
* const ydoc = new Y.Doc({ gc: false })
* ydoc.getText().insert(0, 'world!')
* const snapshot = Y.snapshot(ydoc)
* ydoc.getText().insert(0, 'hello ')
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
* assert(restored.getText().toString() === 'world!')
*
* @param {Doc} originDoc * @param {Doc} originDoc
* @param {Snapshot} snapshot * @param {Snapshot} snapshot
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc * @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
@@ -161,7 +169,7 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => { export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
if (originDoc.gc) { if (originDoc.gc) {
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted // we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
throw new Error('originDoc must not be garbage collected') throw new Error('Garbage-collection must be disabled in `originDoc`!')
} }
const { sv, ds } = snapshot const { sv, ds } = snapshot

View File

@@ -251,7 +251,6 @@ const cleanupTransactions = (transactionCleanups, i) => {
try { try {
sortAndMergeDeleteSet(ds) sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store) transaction.afterState = getStateVector(transaction.doc.store)
doc._transaction = null
doc.emit('beforeObserverCalls', [transaction, doc]) doc.emit('beforeObserverCalls', [transaction, doc])
/** /**
* An array of event callbacks. * An array of event callbacks.
@@ -377,15 +376,21 @@ const cleanupTransactions = (transactionCleanups, i) => {
/** /**
* Implements the functionality of `y.transact(()=>{..})` * Implements the functionality of `y.transact(()=>{..})`
* *
* @template T
* @param {Doc} doc * @param {Doc} doc
* @param {function(Transaction):void} f * @param {function(Transaction):T} f
* @param {any} [origin=true] * @param {any} [origin=true]
* @return {T}
* *
* @function * @function
*/ */
export const transact = (doc, f, origin = null, local = true) => { export const transact = (doc, f, origin = null, local = true) => {
const transactionCleanups = doc._transactionCleanups const transactionCleanups = doc._transactionCleanups
let initialCall = false let initialCall = false
/**
* @type {any}
*/
let result = null
if (doc._transaction === null) { if (doc._transaction === null) {
initialCall = true initialCall = true
doc._transaction = new Transaction(doc, origin, local) doc._transaction = new Transaction(doc, origin, local)
@@ -396,18 +401,23 @@ export const transact = (doc, f, origin = null, local = true) => {
doc.emit('beforeTransaction', [doc._transaction, doc]) doc.emit('beforeTransaction', [doc._transaction, doc])
} }
try { try {
f(doc._transaction) result = f(doc._transaction)
} finally { } finally {
if (initialCall && transactionCleanups[0] === doc._transaction) { if (initialCall) {
// The first transaction ended, now process observer calls. const finishCleanup = doc._transaction === transactionCleanups[0]
// Observer call may create new transactions for which we need to call the observers and do cleanup. doc._transaction = null
// We don't want to nest these calls, so we execute these calls one after if (finishCleanup) {
// another. // The first transaction ended, now process observer calls.
// Also we need to ensure that all cleanups are called, even if the // Observer call may create new transactions for which we need to call the observers and do cleanup.
// observes throw errors. // We don't want to nest these calls, so we execute these calls one after
// This file is full of hacky try {} finally {} blocks to ensure that an // another.
// event can throw errors and also that the cleanup is called. // Also we need to ensure that all cleanups are called, even if the
cleanupTransactions(transactionCleanups, 0) // 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)
}
} }
} }
return result
} }

View File

@@ -10,14 +10,14 @@ import {
getItemCleanStart, getItemCleanStart,
isDeleted, isDeleted,
addToDeleteSet, addToDeleteSet,
Transaction, Doc, Item, GC, DeleteSet, AbstractType, YEvent // eslint-disable-line Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} 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 * as array from 'lib0/array'
import { Observable } from 'lib0/observable' import { Observable } from 'lib0/observable'
class StackItem { export class StackItem {
/** /**
* @param {DeleteSet} deletions * @param {DeleteSet} deletions
* @param {DeleteSet} insertions * @param {DeleteSet} insertions
@@ -101,7 +101,7 @@ const popStackItem = (undoManager, stack, eventType) => {
} }
}) })
itemsToRedo.forEach(struct => { itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges) !== null || performedChange performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges, undoManager) !== 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.
@@ -134,11 +134,12 @@ const popStackItem = (undoManager, stack, eventType) => {
* @property {number} [UndoManagerOptions.captureTimeout=500] * @property {number} [UndoManagerOptions.captureTimeout=500]
* @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false. * @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false.
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes * @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
* it is necessary to filter whan an Undo/Redo operation can delete. If this * it is necessary to filter what an Undo/Redo operation can delete. If this
* 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..). * @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..).
* @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty.
*/ */
/** /**
@@ -157,10 +158,11 @@ export class UndoManager extends Observable {
*/ */
constructor (typeScope, { constructor (typeScope, {
captureTimeout = 500, captureTimeout = 500,
captureTransaction = tr => true, captureTransaction = _tr => true,
deleteFilter = () => true, deleteFilter = () => true,
trackedOrigins = new Set([null]), trackedOrigins = new Set([null]),
ignoreRemoteMapChanges = false ignoreRemoteMapChanges = false,
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope.doc)
} = {}) { } = {}) {
super() super()
/** /**
@@ -187,9 +189,10 @@ export class UndoManager extends Observable {
*/ */
this.undoing = false this.undoing = false
this.redoing = false this.redoing = false
this.doc = /** @type {Doc} */ (this.scope[0].doc) this.doc = doc
this.lastChange = 0 this.lastChange = 0
this.ignoreRemoteMapChanges = ignoreRemoteMapChanges this.ignoreRemoteMapChanges = ignoreRemoteMapChanges
this.captureTimeout = captureTimeout
/** /**
* @param {Transaction} transaction * @param {Transaction} transaction
*/ */
@@ -221,7 +224,7 @@ export class UndoManager extends Observable {
}) })
const now = time.getUnixTime() const now = time.getUnixTime()
let didAdd = false let didAdd = false
if (now - this.lastChange < captureTimeout && stack.length > 0 && !undoing && !redoing) { if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op // append change to last stack op
const lastOp = stack[stack.length - 1] const lastOp = stack[stack.length - 1]
lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet]) lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet])

View File

@@ -130,6 +130,11 @@ export class YEvent {
} }
/** /**
* This is a computed property. Note that this can only be safely computed during the
* event call. Computing this property after other changes happened might result in
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
*
* @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>} * @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
*/ */
get delta () { get delta () {
@@ -149,6 +154,11 @@ export class YEvent {
} }
/** /**
* This is a computed property. Note that this can only be safely computed during the
* event call. Computing this property after other changes happened might result in
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
*
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}} * @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
*/ */
get changes () { get changes () {

View File

@@ -45,6 +45,7 @@ import * as decoding from 'lib0/decoding'
import * as binary from 'lib0/binary' import * as binary from 'lib0/binary'
import * as map from 'lib0/map' import * as map from 'lib0/map'
import * as math from 'lib0/math' import * as math from 'lib0/math'
import * as array from 'lib0/array'
/** /**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
@@ -96,7 +97,7 @@ export const writeClientsStructs = (encoder, store, _sm) => {
encoding.writeVarUint(encoder.restEncoder, sm.size) encoding.writeVarUint(encoder.restEncoder, sm.size)
// Write items with higher client ids first // Write items with higher client ids first
// This heavily improves the conflict algorithm. // This heavily improves the conflict algorithm.
Array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => { array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
// @ts-ignore // @ts-ignore
writeStructs(encoder, store.clients.get(client), client, clock) writeStructs(encoder, store.clients.get(client), client, clock)
}) })
@@ -231,7 +232,7 @@ const integrateStructs = (transaction, store, clientsStructRefs) => {
*/ */
const stack = [] const stack = []
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user. // sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
let clientsStructRefsIds = Array.from(clientsStructRefs.keys()).sort((a, b) => a - b) let clientsStructRefsIds = array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
if (clientsStructRefsIds.length === 0) { if (clientsStructRefsIds.length === 0) {
return null return null
} }
@@ -601,7 +602,7 @@ export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1
*/ */
export const writeStateVector = (encoder, sv) => { export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder.restEncoder, sv.size) encoding.writeVarUint(encoder.restEncoder, sv.size)
Array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => { array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
encoding.writeVarUint(encoder.restEncoder, clock) encoding.writeVarUint(encoder.restEncoder, clock)
}) })

View File

@@ -2,19 +2,40 @@
import * as binary from 'lib0/binary' import * as binary from 'lib0/binary'
import * as decoding from 'lib0/decoding' import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding' import * as encoding from 'lib0/encoding'
import * as error from 'lib0/error'
import * as f from 'lib0/function'
import * as logging from 'lib0/logging' import * as logging from 'lib0/logging'
import * as map from 'lib0/map'
import * as math from 'lib0/math' import * as math from 'lib0/math'
import * as string from 'lib0/string'
import { import {
ContentAny,
ContentBinary,
ContentDeleted,
ContentDoc,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentString,
ContentType,
createID, createID,
readItemContent, decodeStateVector,
readDeleteSet,
writeDeleteSet,
Skip,
mergeDeleteSets,
DSEncoderV1, DSEncoderV1,
DSEncoderV2, DSEncoderV2,
decodeStateVector, GC,
Item, GC, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line Item,
mergeDeleteSets,
readDeleteSet,
readItemContent,
Skip,
UpdateDecoderV1,
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
writeDeleteSet,
YXmlElement,
YXmlHook
} from '../internals.js' } from '../internals.js'
/** /**
@@ -552,17 +573,17 @@ const finishLazyStructWriting = (lazyWriter) => {
/** /**
* @param {Uint8Array} update * @param {Uint8Array} update
* @param {function(Item|GC|Skip):Item|GC|Skip} blockTransformer
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder * @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder * @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
*/ */
export const convertUpdateFormat = (update, YDecoder, YEncoder) => { export const convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => {
const updateDecoder = new YDecoder(decoding.createDecoder(update)) const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false) const lazyDecoder = new LazyStructReader(updateDecoder, false)
const updateEncoder = new YEncoder() const updateEncoder = new YEncoder()
const lazyWriter = new LazyStructWriter(updateEncoder) const lazyWriter = new LazyStructWriter(updateEncoder)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) { for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
writeStructToLazyStructWriter(lazyWriter, curr, 0) writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0)
} }
finishLazyStructWriting(lazyWriter) finishLazyStructWriting(lazyWriter)
const ds = readDeleteSet(updateDecoder) const ds = readDeleteSet(updateDecoder)
@@ -571,11 +592,132 @@ export const convertUpdateFormat = (update, YDecoder, YEncoder) => {
} }
/** /**
* @param {Uint8Array} update * @typedef {Object} ObfuscatorOptions
* @property {boolean} [ObfuscatorOptions.formatting=true]
* @property {boolean} [ObfuscatorOptions.subdocs=true]
* @property {boolean} [ObfuscatorOptions.yxml=true] Whether to obfuscate nodeName / hookName
*/ */
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {ObfuscatorOptions} obfuscator
*/
const createObfuscator = ({ formatting = true, subdocs = true, yxml = true } = {}) => {
let i = 0
const mapKeyCache = map.create()
const nodeNameCache = map.create()
const formattingKeyCache = map.create()
const formattingValueCache = map.create()
formattingValueCache.set(null, null) // end of a formatting range should always be the end of a formatting range
/**
* @param {Item|GC|Skip} block
* @return {Item|GC|Skip}
*/
return block => {
switch (block.constructor) {
case GC:
case Skip:
return block
case Item: {
const item = /** @type {Item} */ (block)
const content = item.content
switch (content.constructor) {
case ContentDeleted:
break
case ContentType: {
if (yxml) {
const type = /** @type {ContentType} */ (content).type
if (type instanceof YXmlElement) {
type.nodeName = map.setIfUndefined(nodeNameCache, type.nodeName, () => 'node-' + i)
}
if (type instanceof YXmlHook) {
type.hookName = map.setIfUndefined(nodeNameCache, type.hookName, () => 'hook-' + i)
}
}
break
}
case ContentAny: {
const c = /** @type {ContentAny} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentBinary: {
const c = /** @type {ContentBinary} */ (content)
c.content = new Uint8Array([i])
break
}
case ContentDoc: {
const c = /** @type {ContentDoc} */ (content)
if (subdocs) {
c.opts = {}
c.doc.guid = i + ''
}
break
}
case ContentEmbed: {
const c = /** @type {ContentEmbed} */ (content)
c.embed = {}
break
}
case ContentFormat: {
const c = /** @type {ContentFormat} */ (content)
if (formatting) {
c.key = map.setIfUndefined(formattingKeyCache, c.key, () => i + '')
c.value = map.setIfUndefined(formattingValueCache, c.value, () => ({ i }))
}
break
}
case ContentJSON: {
const c = /** @type {ContentJSON} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentString: {
const c = /** @type {ContentString} */ (content)
c.str = string.repeat((i % 10) + '', c.str.length)
break
}
default:
// unknown content type
error.unexpectedCase()
}
if (item.parentSub) {
item.parentSub = map.setIfUndefined(mapKeyCache, item.parentSub, () => i + '')
}
i++
return block
}
default:
// unknown block-type
error.unexpectedCase()
}
}
}
/**
* This function obfuscates the content of a Yjs update. This is useful to share
* buggy Yjs documents while significantly limiting the possibility that a
* developer can on the user. Note that it might still be possible to deduce
* some information by analyzing the "structure" of the document or by analyzing
* the typing behavior using the CRDT-related metadata that is still kept fully
* intact.
*
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdate = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdateV2 = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV2, UpdateEncoderV2)
/** /**
* @param {Uint8Array} update * @param {Uint8Array} update
*/ */
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, UpdateDecoderV2, UpdateEncoderV1) export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, f.id, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, f.id, UpdateDecoderV2, UpdateEncoderV1)

View File

@@ -2,12 +2,55 @@
import * as Y from '../src/index.js' import * as Y from '../src/index.js'
import * as t from 'lib0/testing' import * as t from 'lib0/testing'
/**
* @param {t.TestCase} _tc
*/
export const testAfterTransactionRecursion = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment('')
ydoc.on('afterTransaction', tr => {
if (tr.origin === 'test') {
yxml.toJSON()
}
})
ydoc.transact(_tr => {
for (let i = 0; i < 15000; i++) {
yxml.push([new Y.XmlText('a')])
}
}, 'test')
}
/**
* @param {t.TestCase} _tc
*/
export const testOriginInTransaction = _tc => {
const doc = new Y.Doc()
const ytext = doc.getText()
/**
* @type {Array<string>}
*/
const origins = []
doc.on('afterTransaction', (tr) => {
origins.push(tr.origin)
if (origins.length <= 1) {
ytext.toDelta(Y.snapshot(doc)) // adding a snapshot forces toDelta to create a cleanup transaction
doc.transact(() => {
ytext.insert(0, 'a')
}, 'nested')
}
})
doc.transact(() => {
ytext.insert(0, '0')
}, 'first')
t.compareArrays(origins, ['first', 'cleanup', 'nested'])
}
/** /**
* Client id should be changed when an instance receives updates from another client using the same client id. * Client id should be changed when an instance receives updates from another client using the same client id.
* *
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testClientIdDuplicateChange = tc => { export const testClientIdDuplicateChange = _tc => {
const doc1 = new Y.Doc() const doc1 = new Y.Doc()
doc1.clientID = 0 doc1.clientID = 0
const doc2 = new Y.Doc() const doc2 = new Y.Doc()
@@ -19,9 +62,9 @@ export const testClientIdDuplicateChange = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testGetTypeEmptyId = tc => { export const testGetTypeEmptyId = _tc => {
const doc1 = new Y.Doc() const doc1 = new Y.Doc()
doc1.getText('').insert(0, 'h') doc1.getText('').insert(0, 'h')
doc1.getText().insert(1, 'i') doc1.getText().insert(1, 'i')
@@ -32,9 +75,9 @@ export const testGetTypeEmptyId = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testToJSON = tc => { export const testToJSON = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object') t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
@@ -59,9 +102,9 @@ export const testToJSON = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testSubdoc = tc => { export const testSubdoc = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
doc.load() // doesn't do anything doc.load() // doesn't do anything
{ {
@@ -126,9 +169,9 @@ export const testSubdoc = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testSubdocLoadEdgeCases = tc => { export const testSubdocLoadEdgeCases = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yarray = ydoc.getArray() const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc() const subdoc1 = new Y.Doc()
@@ -173,9 +216,9 @@ export const testSubdocLoadEdgeCases = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testSubdocLoadEdgeCasesAutoload = tc => { export const testSubdocLoadEdgeCasesAutoload = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yarray = ydoc.getArray() const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc({ autoLoad: true }) const subdoc1 = new Y.Doc({ autoLoad: true })
@@ -215,9 +258,9 @@ export const testSubdocLoadEdgeCasesAutoload = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testSubdocsUndo = tc => { export const testSubdocsUndo = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const elems = ydoc.getXmlFragment() const elems = ydoc.getXmlFragment()
const undoManager = new Y.UndoManager(elems) const undoManager = new Y.UndoManager(elems)
@@ -230,9 +273,9 @@ export const testSubdocsUndo = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testLoadDocs = async tc => { export const testLoadDocsEvent = async _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false) t.assert(ydoc.isLoaded === false)
let loadedEvent = false let loadedEvent = false
@@ -244,3 +287,44 @@ export const testLoadDocs = async tc => {
t.assert(loadedEvent) t.assert(loadedEvent)
t.assert(ydoc.isLoaded) t.assert(ydoc.isLoaded)
} }
/**
* @param {t.TestCase} _tc
*/
export const testSyncDocsEvent = async _tc => {
const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false)
t.assert(ydoc.isSynced === false)
let loadedEvent = false
ydoc.once('load', () => {
loadedEvent = true
})
let syncedEvent = false
ydoc.once('sync', /** @param {any} isSynced */ (isSynced) => {
syncedEvent = true
t.assert(isSynced)
})
ydoc.emit('sync', [true, ydoc])
await ydoc.whenLoaded
const oldWhenSynced = ydoc.whenSynced
await ydoc.whenSynced
t.assert(loadedEvent)
t.assert(syncedEvent)
t.assert(ydoc.isLoaded)
t.assert(ydoc.isSynced)
let loadedEvent2 = false
ydoc.on('load', () => {
loadedEvent2 = true
})
let syncedEvent2 = false
ydoc.on('sync', (isSynced) => {
syncedEvent2 = true
t.assert(isSynced === false)
})
ydoc.emit('sync', [false, ydoc])
t.assert(!loadedEvent2)
t.assert(syncedEvent2)
t.assert(ydoc.isLoaded)
t.assert(!ydoc.isSynced)
t.assert(ydoc.whenSynced !== oldWhenSynced)
}

View File

@@ -1,3 +1,4 @@
/* eslint-env node */
import * as map from './y-map.tests.js' import * as map from './y-map.tests.js'
import * as array from './y-array.tests.js' import * as array from './y-array.tests.js'

View File

@@ -2,6 +2,18 @@ import * as Y from '../src/index.js'
import * as t from 'lib0/testing' import * as t from 'lib0/testing'
import { init } from './testHelper.js' import { init } from './testHelper.js'
/**
* @param {t.TestCase} tc
*/
export const testBasic = tc => {
const ydoc = new Y.Doc({ gc: false })
ydoc.getText().insert(0, 'world!')
const snapshot = Y.snapshot(ydoc)
ydoc.getText().insert(0, 'hello ')
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
t.assert(restored.getText().toString() === 'world!')
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */

View File

@@ -134,7 +134,7 @@ export class TestYInstance extends Y.Doc {
* @param {TestYInstance} remoteClient * @param {TestYInstance} remoteClient
*/ */
_receive (message, remoteClient) { _receive (message, remoteClient) {
map.setIfUndefined(this.receiving, remoteClient, () => []).push(message) map.setIfUndefined(this.receiving, remoteClient, () => /** @type {Array<Uint8Array>} */ ([])).push(message)
} }
} }
@@ -347,7 +347,7 @@ export const compare = users => {
t.compare(userMapValues[i], userMapValues[i + 1]) t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1]) t.compare(userXmlValues[i], userXmlValues[i + 1])
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length) t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1], '', (constructor, a, b) => { t.compare(userTextValues[i], userTextValues[i + 1], '', (_constructor, a, b) => {
if (a instanceof Y.AbstractType) { if (a instanceof Y.AbstractType) {
t.compare(a.toJSON(), b.toJSON()) t.compare(a.toJSON(), b.toJSON())
} else if (a !== b) { } else if (a !== b) {
@@ -370,8 +370,8 @@ export const compare = users => {
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id)) export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
/** /**
* @param {import('../src/internals').StructStore} ss1 * @param {import('../src/internals.js').StructStore} ss1
* @param {import('../src/internals').StructStore} ss2 * @param {import('../src/internals.js').StructStore} ss2
*/ */
export const compareStructStores = (ss1, ss2) => { export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size) t.assert(ss1.clients.size === ss2.clients.size)
@@ -413,13 +413,13 @@ export const compareStructStores = (ss1, ss2) => {
} }
/** /**
* @param {import('../src/internals').DeleteSet} ds1 * @param {import('../src/internals.js').DeleteSet} ds1
* @param {import('../src/internals').DeleteSet} ds2 * @param {import('../src/internals.js').DeleteSet} ds2
*/ */
export const compareDS = (ds1, ds2) => { export const compareDS = (ds1, ds2) => {
t.assert(ds1.clients.size === ds2.clients.size) t.assert(ds1.clients.size === ds2.clients.size)
ds1.clients.forEach((deleteItems1, client) => { ds1.clients.forEach((deleteItems1, client) => {
const deleteItems2 = /** @type {Array<import('../src/internals').DeleteItem>} */ (ds2.clients.get(client)) const deleteItems2 = /** @type {Array<import('../src/internals.js').DeleteItem>} */ (ds2.clients.get(client))
t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length) t.assert(deleteItems2 !== undefined && deleteItems1.length === deleteItems2.length)
for (let i = 0; i < deleteItems1.length; i++) { for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i] const di1 = deleteItems1[i]

View File

@@ -1,8 +1,21 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line import { init } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js' import * as Y from '../src/index.js'
import * as t from 'lib0/testing' import * as t from 'lib0/testing'
/**
* @param {t.TestCase} tc
*/
export const testInfiniteCaptureTimeout = tc => {
const { array0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(array0, { captureTimeout: Number.MAX_VALUE })
array0.push([1, 2, 3])
undoManager.stopCapturing()
array0.push([4, 5, 6])
undoManager.undo()
t.compare(array0.toArray(), [1, 2, 3])
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -51,9 +64,23 @@ export const testUndoText = tc => {
/** /**
* Test case to fix #241 * Test case to fix #241
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testDoubleUndo = tc => { export const testEmptyTypeScope = _tc => {
const ydoc = new Y.Doc()
const um = new Y.UndoManager([], { doc: ydoc })
const yarray = ydoc.getArray()
um.addToScope(yarray)
yarray.insert(0, [1])
um.undo()
t.assert(yarray.length === 0)
}
/**
* Test case to fix #241
* @param {t.TestCase} _tc
*/
export const testDoubleUndo = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const text = doc.getText() const text = doc.getText()
text.insert(0, '1221') text.insert(0, '1221')
@@ -289,9 +316,9 @@ export const testUndoDeleteFilter = tc => {
/** /**
* This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6 * This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testUndoUntilChangePerformed = tc => { export const testUndoUntilChangePerformed = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const doc2 = new Y.Doc() const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update)) doc.on('update', update => Y.applyUpdate(doc2, update))
@@ -320,9 +347,9 @@ export const testUndoUntilChangePerformed = tc => {
/** /**
* This issue has been reported in https://github.com/yjs/yjs/issues/317 * This issue has been reported in https://github.com/yjs/yjs/issues/317
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testUndoNestedUndoIssue = tc => { export const testUndoNestedUndoIssue = _tc => {
const doc = new Y.Doc({ gc: false }) const doc = new Y.Doc({ gc: false })
const design = doc.getMap() const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 }) const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
@@ -376,9 +403,9 @@ export const testUndoNestedUndoIssue = tc => {
/** /**
* This issue has been reported in https://github.com/yjs/yjs/issues/355 * This issue has been reported in https://github.com/yjs/yjs/issues/355
* *
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testConsecutiveRedoBug = tc => { export const testConsecutiveRedoBug = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const yRoot = doc.getMap() const yRoot = doc.getMap()
const undoMgr = new Y.UndoManager(yRoot) const undoMgr = new Y.UndoManager(yRoot)
@@ -427,9 +454,9 @@ export const testConsecutiveRedoBug = tc => {
/** /**
* This issue has been reported in https://github.com/yjs/yjs/issues/304 * This issue has been reported in https://github.com/yjs/yjs/issues/304
* *
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testUndoXmlBug = tc => { export const testUndoXmlBug = _tc => {
const origin = 'origin' const origin = 'origin'
const doc = new Y.Doc() const doc = new Y.Doc()
const fragment = doc.getXmlFragment('t') const fragment = doc.getXmlFragment('t')
@@ -472,9 +499,9 @@ export const testUndoXmlBug = tc => {
/** /**
* This issue has been reported in https://github.com/yjs/yjs/issues/343 * This issue has been reported in https://github.com/yjs/yjs/issues/343
* *
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testUndoBlockBug = tc => { export const testUndoBlockBug = _tc => {
const doc = new Y.Doc({ gc: false }) const doc = new Y.Doc({ gc: false })
const design = doc.getMap() const design = doc.getMap()
@@ -532,9 +559,9 @@ export const testUndoBlockBug = tc => {
* Undo text formatting delete should not corrupt peer state. * Undo text formatting delete should not corrupt peer state.
* *
* @see https://github.com/yjs/yjs/issues/392 * @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testUndoDeleteTextFormat = tc => { export const testUndoDeleteTextFormat = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const text = doc.getText() const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.') text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
@@ -570,9 +597,9 @@ export const testUndoDeleteTextFormat = tc => {
* Undo text formatting delete should not corrupt peer state. * Undo text formatting delete should not corrupt peer state.
* *
* @see https://github.com/yjs/yjs/issues/392 * @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testBehaviorOfIgnoreremotemapchangesProperty = tc => { export const testBehaviorOfIgnoreremotemapchangesProperty = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const doc2 = new Y.Doc() const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update, doc)) doc.on('update', update => Y.applyUpdate(doc2, update, doc))
@@ -588,3 +615,63 @@ export const testBehaviorOfIgnoreremotemapchangesProperty = tc => {
t.assert(map1.get('x') === 2) t.assert(map1.get('x') === 2)
t.assert(map2.get('x') === 2) t.assert(map2.get('x') === 2)
} }
/**
* Special deletion case.
*
* @see https://github.com/yjs/yjs/issues/447
* @param {t.TestCase} _tc
*/
export const testSpecialDeletionCase = _tc => {
const origin = 'undoable'
const doc = new Y.Doc()
const fragment = doc.getXmlFragment()
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
doc.transact(() => {
const e = new Y.XmlElement('test')
e.setAttribute('a', '1')
e.setAttribute('b', '2')
fragment.insert(0, [e])
})
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
doc.transact(() => {
// change attribute "b" and delete test-node
const e = fragment.get(0)
e.setAttribute('b', '3')
fragment.delete(0)
}, origin)
t.compareStrings(fragment.toString(), '')
undoManager.undo()
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
}
/**
* Deleted entries in a map should be restored on undo.
*
* @see https://github.com/yjs/yjs/issues/500
* @param {t.TestCase} tc
*/
export const testUndoDeleteInMap = (tc) => {
const { map0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(map0, { captureTimeout: 0 })
map0.set('a', 'a')
map0.delete('a')
map0.set('a', 'b')
map0.delete('a')
map0.set('a', 'c')
map0.delete('a')
map0.set('a', 'd')
t.compare(map0.toJSON(), { a: 'd' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'c' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'b' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'a' })
}

View File

@@ -4,6 +4,7 @@ import * as Y from '../src/index.js'
import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js' import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js'
import * as encoding from 'lib0/encoding' import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding' import * as decoding from 'lib0/decoding'
import * as object from 'lib0/object'
/** /**
* @typedef {Object} Enc * @typedef {Object} Enc
@@ -138,7 +139,6 @@ export const testKeyEncoding = tc => {
*/ */
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => { const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
const cases = [] const cases = []
// Case 1: Simple case, simply merge everything // Case 1: Simple case, simply merge everything
cases.push(enc.mergeUpdates(updates)) cases.push(enc.mergeUpdates(updates))
@@ -304,3 +304,54 @@ export const testMergePendingUpdates = tc => {
const yText5 = yDoc5.getText('textBlock') const yText5 = yDoc5.getText('textBlock')
t.compareStrings(yText5.toString(), 'nenor') t.compareStrings(yText5.toString(), 'nenor')
} }
/**
* @param {t.TestCase} tc
*/
export const testObfuscateUpdates = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText('text')
const ymap = ydoc.getMap('map')
const yarray = ydoc.getArray('array')
// test ytext
ytext.applyDelta([{ insert: 'text', attributes: { bold: true } }, { insert: { href: 'supersecreturl' } }])
// test ymap
ymap.set('key', 'secret1')
ymap.set('key', 'secret2')
// test yarray with subtype & subdoc
const subtype = new Y.XmlElement('secretnodename')
const subdoc = new Y.Doc({ guid: 'secret' })
subtype.setAttribute('attr', 'val')
yarray.insert(0, ['teststring', 42, subtype, subdoc])
// obfuscate the content and put it into a new document
const obfuscatedUpdate = Y.obfuscateUpdate(Y.encodeStateAsUpdate(ydoc))
const odoc = new Y.Doc()
Y.applyUpdate(odoc, obfuscatedUpdate)
const otext = odoc.getText('text')
const omap = odoc.getMap('map')
const oarray = odoc.getArray('array')
// test ytext
const delta = otext.toDelta()
t.assert(delta.length === 2)
t.assert(delta[0].insert !== 'text' && delta[0].insert.length === 4)
t.assert(object.length(delta[0].attributes) === 1)
t.assert(!object.hasProperty(delta[0].attributes, 'bold'))
t.assert(object.length(delta[1]) === 1)
t.assert(object.hasProperty(delta[1], 'insert'))
// test ymap
t.assert(omap.size === 1)
t.assert(!omap.has('key'))
// test yarray with subtype & subdoc
const result = oarray.toArray()
t.assert(result.length === 4)
t.assert(result[0] !== 'teststring')
t.assert(result[1] !== 42)
const osubtype = /** @type {Y.XmlElement} */ (result[2])
const osubdoc = result[3]
// test subtype
t.assert(osubtype.nodeName !== subtype.nodeName)
t.assert(object.length(osubtype.getAttributes()) === 1)
t.assert(osubtype.getAttribute('attr') === undefined)
// test subdoc
t.assert(osubdoc.guid !== subdoc.guid)
}

View File

@@ -32,6 +32,17 @@ export const testSlice = tc => {
t.compareArrays(arr.slice(0, 2), [0, 1]) t.compareArrays(arr.slice(0, 2), [0, 1])
} }
/**
* @param {t.TestCase} tc
*/
export const testArrayFrom = tc => {
const doc1 = new Y.Doc()
const db1 = doc1.getMap('root')
const nestedArray1 = Y.Array.from([0, 1, 2])
db1.set('array', nestedArray1)
t.compare(nestedArray1.toArray(), [0, 1, 2])
}
/** /**
* Debugging yjs#297 - a critical bug connected to the search-marker approach * Debugging yjs#297 - a critical bug connected to the search-marker approach
* *

View File

@@ -455,9 +455,9 @@ export const testChangeEvent = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testYmapEventExceptionsShouldCompleteTransaction = tc => { export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
const doc = new Y.Doc() const doc = new Y.Doc()
const map = doc.getMap('map') const map = doc.getMap('map')

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,33 @@ import * as Y from '../src/index.js'
import * as t from 'lib0/testing' import * as t from 'lib0/testing'
export const testCustomTypings = () => {
const ydoc = new Y.Doc()
const ymap = ydoc.getMap()
/**
* @type {Y.XmlElement<{ num: number, str: string, [k:string]: object|number|string }>}
*/
const yxml = ymap.set('yxml', new Y.XmlElement('test'))
/**
* @type {number|undefined}
*/
const num = yxml.getAttribute('num')
/**
* @type {string|undefined}
*/
const str = yxml.getAttribute('str')
/**
* @type {object|number|string|undefined}
*/
const dtrn = yxml.getAttribute('dtrn')
const attrs = yxml.getAttributes()
/**
* @type {object|number|string|undefined}
*/
const any = attrs.shouldBeAny
console.log({ num, str, dtrn, attrs, any })
}
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} tc
*/ */
@@ -92,9 +119,9 @@ export const testTreewalker = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testYtextAttributes = tc => { export const testYtextAttributes = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText)) const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
ytext.observe(event => { ytext.observe(event => {
@@ -106,9 +133,9 @@ export const testYtextAttributes = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testSiblings = tc => { export const testSiblings = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment() const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText() const first = new Y.XmlText()
@@ -122,9 +149,9 @@ export const testSiblings = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testInsertafter = tc => { export const testInsertafter = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment() const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText() const first = new Y.XmlText()
@@ -152,9 +179,9 @@ export const testInsertafter = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testClone = tc => { export const testClone = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment() const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText('text') const first = new Y.XmlText('text')
@@ -170,9 +197,9 @@ export const testClone = tc => {
} }
/** /**
* @param {t.TestCase} tc * @param {t.TestCase} _tc
*/ */
export const testFormattingBug = tc => { export const testFormattingBug = _tc => {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
const yxml = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText)) const yxml = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
const delta = [ const delta = [

View File

@@ -1,64 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
/* Basic Options */ "target": "ES2021",
"target": "es2018", "lib": ["ES2021", "dom"],
"lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */ "module": "node16",
"allowJs": true, /* Allow javascript files to be compiled. */ "allowJs": true,
"checkJs": true, /* Report errors in .js files. */ "checkJs": true,
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "declaration": true,
"declaration": true, /* Generates corresponding '.d.ts' file. */ "declarationMap": true,
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ "outDir": "./dist",
// "sourceMap": true, /* Generates corresponding '.map' file. */ "baseUrl": "./",
// "outFile": "./dist/yjs.js", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
// "strictNullChecks": true, /* Enable strict null checks. */ "strict": true,
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ "noImplicitAny": true,
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ "moduleResolution": "nodenext",
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"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": { "paths": {
"yjs": ["./src/index.js"] "yjs": ["./src/index.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. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
// "maxNodeModuleJsDepth": 0,
// "types": ["./src/utils/typedefs.js"]
}, },
"include": ["./src/**/*.js", "./tests/**/*.js"] "include": ["./src/**/*.js", "./tests/**/*.js"]
} }