snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
This commit is contained in:
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github: dmonad
|
||||||
|
open_collective: y-collective
|
||||||
90
.github/INCIDENT_RESPONSE.md
vendored
Normal file
90
.github/INCIDENT_RESPONSE.md
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Incident Response Plan
|
||||||
|
|
||||||
|
How Yjs maintainers handle security incidents. This plan is designed for small teams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Receive a Private Disclosure
|
||||||
|
|
||||||
|
All security issues should be reported privately via the method described in [`SECURITY.md`](../SECURITY.md) (GitHub Security Advisory). Lower-severity issues can be filed as regular issues.
|
||||||
|
|
||||||
|
**Target:** Acknowledge receipt within 3 business days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Triage
|
||||||
|
|
||||||
|
Assess severity:
|
||||||
|
|
||||||
|
| Severity | Example | Target |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| **Critical** | Arbitrary code execution, compromised release | Acknowledge < 24h, fix < 7d |
|
||||||
|
| **High** | Data leakage, denial of service on shared docs | Acknowledge < 3d, fix < 14d |
|
||||||
|
| **Medium** | Edge-case corruption under unusual conditions | Fix in next release |
|
||||||
|
| **Low** | Informational, no direct user risk | Fix as scheduled |
|
||||||
|
|
||||||
|
These are good-faith targets for a volunteer-maintained project, not SLAs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Contain & Fix
|
||||||
|
|
||||||
|
1. **Open a private GitHub Security Advisory** for the issue if one doesn't already exist.
|
||||||
|
2. **Develop the fix** in the advisory's private fork to avoid leaking details.
|
||||||
|
3. **Assign a CVE** through GitHub's advisory flow before publishing.
|
||||||
|
4. **Yank affected releases from npm** (`npm deprecate` with a message pointing to the advisory) if the severity is Critical or High.
|
||||||
|
5. **Publish the patched release** to npm.
|
||||||
|
6. **Update the version support matrix** in `SECURITY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Disclose
|
||||||
|
|
||||||
|
After the fix is published:
|
||||||
|
|
||||||
|
1. **Publish the GitHub Security Advisory** with:
|
||||||
|
- What happened
|
||||||
|
- Which versions are affected
|
||||||
|
- How users can update or mitigate
|
||||||
|
- The assigned CVE
|
||||||
|
2. **Notify sponsors** via the appropriate channel (email, GitHub Sponsors update, or OpenCollective post).
|
||||||
|
3. **Post to GitHub Discussions** for broader community awareness, linking to the advisory.
|
||||||
|
|
||||||
|
Responsible disclosure principles:
|
||||||
|
- Never publish details before a fix is available.
|
||||||
|
- Never name reporters without their consent.
|
||||||
|
- Coordinate embargo periods with downstream consumers (e.g., major framework integrations) when needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Post-Incident
|
||||||
|
|
||||||
|
Within one week of resolution:
|
||||||
|
|
||||||
|
- Note what happened, what worked, and what to improve (can be a short section in the advisory itself or a comment on the discussion post).
|
||||||
|
- Update this plan if the process broke down anywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Checklist (quick reference)
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Private report received & acknowledged
|
||||||
|
[ ] Severity assessed
|
||||||
|
[ ] Private advisory + fork created on GitHub
|
||||||
|
[ ] Fix developed & tested
|
||||||
|
[ ] CVE assigned
|
||||||
|
[ ] Affected npm versions deprecated (if Critical/High)
|
||||||
|
[ ] Patched release published
|
||||||
|
[ ] SECURITY.md version matrix updated
|
||||||
|
[ ] Advisory published
|
||||||
|
[ ] Sponsors notified
|
||||||
|
[ ] Community notified (Discussions)
|
||||||
|
[ ] Post-incident notes written
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan Maintenance
|
||||||
|
|
||||||
|
Review this plan once a year or after any incident. Changes are tracked in git history.
|
||||||
34
.github/workflows/node.js.yml
vendored
Normal file
34
.github/workflows/node.js.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [26.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
70
.github/workflows/publish.yml
vendored
Normal file
70
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Publish to npm
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Node.js CI"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
should_publish: ${{ steps.version-check.outputs.should_publish }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 26.x
|
||||||
|
- name: Check if version is already published
|
||||||
|
id: version-check
|
||||||
|
run: |
|
||||||
|
NAME=$(node -p "require('./package.json').name")
|
||||||
|
LOCAL=$(node -p "require('./package.json').version")
|
||||||
|
if npm view "${NAME}@${LOCAL}" version 2>/dev/null; then
|
||||||
|
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Version $LOCAL already published, skipping."
|
||||||
|
else
|
||||||
|
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "New version detected: $LOCAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: check
|
||||||
|
if: needs.check.outputs.should_publish == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 26.x
|
||||||
|
- run: npm ci
|
||||||
|
- name: Build
|
||||||
|
run: npm run dist
|
||||||
|
- name: Determine npm tag
|
||||||
|
id: npm-tag
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
echo "tag=beta" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Publish
|
||||||
|
run: npm publish --provenance --access public --tag ${{ steps.npm-tag.outputs.tag }}
|
||||||
|
- name: Create GitHub Release
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
PRERELEASE=""
|
||||||
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
PRERELEASE="--prerelease"
|
||||||
|
fi
|
||||||
|
gh release create "v${VERSION}" --generate-notes $PRERELEASE
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
78
.github/workflows/scorecard.yml
vendored
Normal file
78
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||||
|
# by a third-party and are governed by separate terms of service, privacy
|
||||||
|
# policy, and support documentation.
|
||||||
|
|
||||||
|
name: Scorecard supply-chain security
|
||||||
|
on:
|
||||||
|
# For Branch-Protection check. Only the default branch is supported. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||||
|
branch_protection_rule:
|
||||||
|
# To guarantee Maintained check is occasionally updated. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||||
|
schedule:
|
||||||
|
- cron: '28 5 * * 0'
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
# Declare default permissions as read only.
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||||
|
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
# Needed to upload the results to code-scanning dashboard.
|
||||||
|
security-events: write
|
||||||
|
# Needed to publish results and get a badge (see publish_results below).
|
||||||
|
id-token: write
|
||||||
|
# Uncomment the permissions below if installing in a private repository.
|
||||||
|
# contents: read
|
||||||
|
# actions: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: "Run analysis"
|
||||||
|
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||||
|
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||||
|
# - you are installing Scorecard on a *private* repository
|
||||||
|
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||||
|
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||||
|
|
||||||
|
# Public repositories:
|
||||||
|
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||||
|
# - Allows the repository to include the Scorecard badge.
|
||||||
|
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||||
|
# For private repositories:
|
||||||
|
# - `publish_results` will always be set to `false`, regardless
|
||||||
|
# of the value entered here.
|
||||||
|
publish_results: true
|
||||||
|
|
||||||
|
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||||
|
# file_mode: git
|
||||||
|
|
||||||
|
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||||
|
# format to the repository Actions tab.
|
||||||
|
- name: "Upload artifact"
|
||||||
|
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
|
- name: "Upload to code-scanning"
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vscode
|
||||||
|
docs
|
||||||
|
next.md
|
||||||
52
.jsdoc.json
Normal file
52
.jsdoc.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"sourceType": "module",
|
||||||
|
"tags": {
|
||||||
|
"allowUnknownTags": true,
|
||||||
|
"dictionaries": ["jsdoc"]
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"include": ["./src"],
|
||||||
|
"includePattern": ".js$"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"plugins/markdown"
|
||||||
|
],
|
||||||
|
"templates": {
|
||||||
|
"referenceTitle": "Yjs",
|
||||||
|
"disableSort": false,
|
||||||
|
"useCollapsibles": true,
|
||||||
|
"collapse": true,
|
||||||
|
"resources": {
|
||||||
|
"yjs.dev": "Website",
|
||||||
|
"docs.yjs.dev": "Docs",
|
||||||
|
"discuss.yjs.dev": "Forum",
|
||||||
|
"https://gitter.im/Yjs/community": "Chat"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"url": "https://yjs.dev/images/logo/yjs-512x512.png",
|
||||||
|
"width": "162px",
|
||||||
|
"height": "162px",
|
||||||
|
"link": "/"
|
||||||
|
},
|
||||||
|
"tabNames": {
|
||||||
|
"api": "API",
|
||||||
|
"tutorials": "Examples"
|
||||||
|
},
|
||||||
|
"footerText": "Shared Editing",
|
||||||
|
"css": [
|
||||||
|
"./style.css"
|
||||||
|
],
|
||||||
|
"default": {
|
||||||
|
"staticFiles": {
|
||||||
|
"include": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opts": {
|
||||||
|
"destination": "./docs/",
|
||||||
|
"encoding": "utf8",
|
||||||
|
"private": false,
|
||||||
|
"recurse": true,
|
||||||
|
"template": "./node_modules/tui-jsdoc-template"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.markdownlint.json
Normal file
4
.markdownlint.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"default": true,
|
||||||
|
"no-inline-html": false
|
||||||
|
}
|
||||||
1
.well-known/funding-manifest-urls
Normal file
1
.well-known/funding-manifest-urls
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://yjs.dev
|
||||||
5
CODE_OF_CONDUCT.md
Normal file
5
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
By participating, you agree to uphold this code. Report unacceptable behavior to [kevin.jahns@protonmail.com](mailto:kevin.jahns@protonmail.com).
|
||||||
9
CONTRIBUTING.md
Normal file
9
CONTRIBUTING.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
1. **Open an issue first.** Discuss your idea or bug report before writing code.
|
||||||
|
2. **Submit a PR** that references the issue.
|
||||||
|
3. **Include tests.** Every PR must include tests covering the change. Test-only PRs are welcome too.
|
||||||
|
|
||||||
|
Yjs is free to use, but not free to maintain. Companies that rely on Yjs are expected to support the project — through [sponsorship](https://github.com/sponsors/dmonad), contributions, or both — if they want issues, bug reports, and PRs addressed.
|
||||||
|
|
||||||
|
Every contribution becomes maintenance work for the project maintainers. Priority is given to features that serve the project's financial supporters. Other contributions may not be accepted solely for this reason.
|
||||||
179
INTERNALS.md
Normal file
179
INTERNALS.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Yjs Internals
|
||||||
|
|
||||||
|
This document roughly explains how Yjs works internally. There is a complete
|
||||||
|
walkthrough of the Yjs codebase available as a recording:
|
||||||
|
https://youtu.be/0l5XgnQ6rB4
|
||||||
|
|
||||||
|
The Yjs CRDT algorithm is described in the [YATA
|
||||||
|
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types)
|
||||||
|
from 2016. For an algorithmic view of how it works, the paper is a reasonable
|
||||||
|
place to start. There are a handful of small improvements implemented in Yjs
|
||||||
|
which aren't described in the paper. The most notable is that items have an
|
||||||
|
`originRight` as well as an `origin` property, which improves performance when
|
||||||
|
many concurrent inserts happen after the same character.
|
||||||
|
|
||||||
|
At its heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
|
||||||
|
reuse the CRDT resolution algorithm:
|
||||||
|
|
||||||
|
- Arrays are easy - they're lists of arbitrary items.
|
||||||
|
- Text is a list of characters, optionally punctuated by formatting markers and
|
||||||
|
embeds for rich text support. Several characters can be wrapped in a single
|
||||||
|
linked list `Item` (this is also known as the compound representation of
|
||||||
|
CRDTs). More information about this in [this blog
|
||||||
|
article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/).
|
||||||
|
- Maps are lists of entries. The last inserted entry for each key is used, and
|
||||||
|
all other duplicates for each key are flagged as deleted.
|
||||||
|
|
||||||
|
Each client is assigned a unique *clientID* property on first insert. This is a
|
||||||
|
random 53-bit integer (53 bits because that fits in the javascript safe integer
|
||||||
|
range \[JavaScript uses IEEE 754 floats\]).
|
||||||
|
|
||||||
|
## List items
|
||||||
|
|
||||||
|
Each item in a Yjs list is made up of two objects:
|
||||||
|
|
||||||
|
- An `Item` (*src/structs/Item.js*). This is used to relate the item to other
|
||||||
|
adjacent items.
|
||||||
|
- An object in the `AbstractType` hierarchy (subclasses of
|
||||||
|
*src/types/AbstractType.js* - eg `YText`). This stores the actual content in
|
||||||
|
the Yjs document.
|
||||||
|
|
||||||
|
The item and type object pair have a 1-1 mapping. The item's `content` field
|
||||||
|
references the AbstractType object and the AbstractType object's `_item` field
|
||||||
|
references the item.
|
||||||
|
|
||||||
|
Everything inserted in a Yjs document is given a unique ID, formed from a
|
||||||
|
*ID(clientID, clock)* pair (also known as a [Lamport
|
||||||
|
Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp)). The clock counts
|
||||||
|
up from 0 with the first inserted character or item a client makes. This is
|
||||||
|
similar to automerge's operation IDs, but note that the clock is only
|
||||||
|
incremented by inserts. Deletes are handled in a very different way (see
|
||||||
|
below).
|
||||||
|
|
||||||
|
If a run of characters is inserted into a document (eg `"abc"`), the clock will
|
||||||
|
be incremented for each character (eg 3 times here). But Yjs will only add a
|
||||||
|
single `Item` into the list. This has no effect on the core CRDT algorithm, but
|
||||||
|
the optimization dramatically decreases the number of javascript objects
|
||||||
|
created during normal text editing. This optimization only applies if the
|
||||||
|
characters share the same clientID, they're inserted in order, and all
|
||||||
|
characters have either been deleted or all characters are not deleted. The item
|
||||||
|
will be split if the run is interrupted for any reason (eg a character in the
|
||||||
|
middle of the run is deleted).
|
||||||
|
|
||||||
|
When an item is created, it stores a reference to the IDs of the preceding and
|
||||||
|
succeeding item. These are stored in the item's `origin` and `originRight`
|
||||||
|
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
|
||||||
|
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
|
||||||
|
the `Item#integrate()` method. The YATA paper has much more detail on this
|
||||||
|
algorithm.
|
||||||
|
|
||||||
|
### Item Storage
|
||||||
|
|
||||||
|
The items themselves are stored in two data structures and a cache:
|
||||||
|
|
||||||
|
- The items are stored in a tree of doubly-linked lists in *document order*.
|
||||||
|
Each item has `left` and `right` properties linking to its siblings in the
|
||||||
|
document. Items also have a `parent` property to reference their parent in the
|
||||||
|
document tree (null at the root). (And you can access an item's children, if
|
||||||
|
any, through `item.content`).
|
||||||
|
- All items are referenced in *insertion order* inside the struct store
|
||||||
|
(*src/utils/StructStore.js*). This references the list of items inserted by
|
||||||
|
for each client, in chronological order. This is used to find an item in the
|
||||||
|
tree with a given ID (using a binary search). It is also used to efficiently
|
||||||
|
gather the operations a peer is missing during sync (more on this below).
|
||||||
|
|
||||||
|
When a local insert happens, Yjs needs to map the insert position in the
|
||||||
|
document (eg position 1000) to an ID. With just the linked list, this would
|
||||||
|
require a slow O(n) linear scan of the list. But when editing a document, most
|
||||||
|
inserts are either at the same position as the last insert, or nearby. To
|
||||||
|
improve performance, Yjs stores a cache of the 80 most recently looked up
|
||||||
|
insert positions in the document. This is consulted and updated when a position
|
||||||
|
is looked up to improve performance in the average case. The cache is updated
|
||||||
|
using a heuristic that is still changing (currently, it is updated when a new
|
||||||
|
position significantly diverges from existing markers in the cache). Internally
|
||||||
|
this is referred to as the skip list / fast search marker.
|
||||||
|
|
||||||
|
### Deletions
|
||||||
|
|
||||||
|
Deletions in Yjs are treated very differently from insertions. Insertions are
|
||||||
|
implemented as a sequential operation based CRDT, but deletions are treated as
|
||||||
|
a simpler state based CRDT.
|
||||||
|
|
||||||
|
When an item has been deleted by any peer, at any point in history, it is
|
||||||
|
flagged as deleted on the item. (Internally Yjs uses the `info` bitfield.) Yjs
|
||||||
|
does not record metadata about a deletion:
|
||||||
|
|
||||||
|
- No data is kept on *when* an item was deleted, or which user deleted it.
|
||||||
|
- The struct store does not contain deletion records
|
||||||
|
- The clientID's clock is not incremented
|
||||||
|
|
||||||
|
If garbage collection is enabled in Yjs, when an object is deleted its content
|
||||||
|
is discarded. If a deleted object contains children (eg a field is deleted in
|
||||||
|
an object), the content is replaced with a `GC` object (*src/structs/GC.js*).
|
||||||
|
This is a very lightweight structure - it only stores the length of the removed
|
||||||
|
content.
|
||||||
|
|
||||||
|
Yjs has some special logic to share which content in a document has been
|
||||||
|
deleted:
|
||||||
|
|
||||||
|
- When a delete happens, as well as marking the item, the deleted IDs are
|
||||||
|
listed locally within the transaction. (See below for more information about
|
||||||
|
transactions.) When a transaction has been committed locally, the set of
|
||||||
|
deleted items is appended to a transaction's update message.
|
||||||
|
- A snapshot (a marked point in time in the Yjs history) is specified using
|
||||||
|
both the set of (clientID, clock) pairs *and* the set of all deleted item
|
||||||
|
IDs. The deleted set is O(n), but because deletions usually happen in runs,
|
||||||
|
this data set is usually tiny in practice. (The real world editing trace from
|
||||||
|
the B4 benchmark document contains 182k inserts and 77k deleted characters. The
|
||||||
|
deleted set size in a snapshot is only 4.5Kb).
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
|
||||||
|
All updates in Yjs happen within a *transaction*. (Defined in
|
||||||
|
*src/utils/Transaction.js*.)
|
||||||
|
|
||||||
|
The transaction collects a set of updates to the Yjs document to be applied on
|
||||||
|
remote peers atomically. Once a transaction has been committed locally, it
|
||||||
|
generates a compressed *update message* which is broadcast to synchronized
|
||||||
|
remote peers to notify them of the local change. The update message contains:
|
||||||
|
|
||||||
|
- The set of newly inserted items
|
||||||
|
- The set of items deleted within the transaction.
|
||||||
|
|
||||||
|
## Network protocol
|
||||||
|
|
||||||
|
The network protocol is not really a part of Yjs. There are a few relevant
|
||||||
|
concepts that can be used to create a custom network protocol:
|
||||||
|
|
||||||
|
* `update`: The Yjs document can be encoded to an *update* object that can be
|
||||||
|
parsed to reconstruct the document. Also every change on the document fires
|
||||||
|
an incremental document update that allows clients to sync with each other.
|
||||||
|
The update object is a Uint8Array that efficiently encodes `Item` objects and
|
||||||
|
the delete set.
|
||||||
|
* `state vector`: A state vector defines the known state of each user (a set of
|
||||||
|
tuples `(client, clock)`). This object is also efficiently encoded as a
|
||||||
|
Uint8Array.
|
||||||
|
|
||||||
|
The client can ask a remote client for missing document updates by sending
|
||||||
|
their state vector (often referred to as *sync step 1*). The remote peer can
|
||||||
|
compute the missing `Item` objects using the `clocks` of the respective clients
|
||||||
|
and compute a minimal update message that reflects all missing updates (sync
|
||||||
|
step 2).
|
||||||
|
|
||||||
|
An implementation of the syncing process is in
|
||||||
|
[y-protocols](https://github.com/yjs/y-protocols).
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
A snapshot can be used to restore an old document state. It is a `state vector`
|
||||||
|
\+ `delete set`. A client can restore an old document state by iterating through
|
||||||
|
the sequence CRDT and ignoring all Items that have an `id.clock >
|
||||||
|
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
|
||||||
|
use the delete set to find out if an item was deleted or not.
|
||||||
|
|
||||||
|
It is not recommended to restore an old document state using snapshots,
|
||||||
|
although that would certainly be possible. Instead, the old state should be
|
||||||
|
computed by iterating through the newest state and using the additional
|
||||||
|
information from the state vector.
|
||||||
23
LICENSE
Normal file
23
LICENSE
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2023
|
||||||
|
- Kevin Jahns <kevin.jahns@protonmail.com>.
|
||||||
|
- Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
12
SECURITY.md
Normal file
12
SECURITY.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 13.6.x | :white_check_mark: |
|
||||||
|
| < 13.6.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If the vulnerability could be used to exploit user data or leak other sensitive information, please report it privately via [GitHub's security advisory reporting](https://github.com/yjs/yjs/security/advisories/new). For lower-severity issues, feel free to open a regular issue.
|
||||||
131
THREAT_MODEL.md
Normal file
131
THREAT_MODEL.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Threat Model - Yjs
|
||||||
|
|
||||||
|
This document outlines the threat model for the Yjs project, a CRDT framework for shared editing. It identifies assets, threat actors, attack surfaces, and mitigations.
|
||||||
|
|
||||||
|
## 1. Assets
|
||||||
|
|
||||||
|
What we are protecting:
|
||||||
|
|
||||||
|
| Asset | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| Document data | Shared documents edited by end users via Yjs-powered applications |
|
||||||
|
| Library integrity | The correctness and safety of the Yjs source code and published npm packages |
|
||||||
|
| CI/CD pipeline | GitHub Actions workflows, publishing credentials, and automation |
|
||||||
|
| Maintainer accounts | GitHub and npm accounts of project maintainers |
|
||||||
|
| User trust | Reputation and reliability of the project as a dependency |
|
||||||
|
|
||||||
|
## 2. Threat Actors
|
||||||
|
|
||||||
|
| Actor | Motivation | Capability |
|
||||||
|
|-------|-----------|------------|
|
||||||
|
| Malicious contributor | Inject backdoor or vulnerable code via pull requests | Low-Medium: constrained by code review |
|
||||||
|
| Supply chain attacker | Compromise a dependency to reach Yjs consumers downstream | Medium: targets transitive dependencies |
|
||||||
|
| Malicious peer (network) | Exploit the CRDT sync protocol to corrupt documents or crash peers | Medium: can craft arbitrary binary messages |
|
||||||
|
| Compromised maintainer account | Publish a malicious package version to npm | High: full publish access |
|
||||||
|
| Automated bots | Spam, typosquatting, or social engineering via issues/PRs | Low |
|
||||||
|
|
||||||
|
## 3. Attack Surfaces
|
||||||
|
|
||||||
|
### 3.1 Document Corruption by Write-Peers
|
||||||
|
|
||||||
|
Any peer with write access has the theoretical ability to corrupt a Yjs document. The CRDT protocol is designed for collaboration between trusted peers — it guarantees conflict-free merging, but it does not protect against a malicious peer intentionally writing destructive operations. Once a malicious update is applied to a document, it becomes part of the document's permanent history.
|
||||||
|
|
||||||
|
**It is therefore critical that untrusted peers are never allowed to write updates to the original Yjs document.**
|
||||||
|
|
||||||
|
**Server-side filtering of updates is a common antipattern.** Some applications attempt to validate or filter incoming updates on the server before applying them to the shared document. This approach is fundamentally flawed with untrusted peers — Yjs updates are binary-encoded CRDT operations, and selectively filtering them can easily be exploited and is not a reliable security boundary.
|
||||||
|
|
||||||
|
**Suggestion-only (untrusted) users should work on a separate Yjs document fork**, not on the original document. Their changes can be reviewed and selectively merged by a trusted peer. Only trusted, authenticated peers should be allowed to write to the authoritative document.
|
||||||
|
|
||||||
|
Additionally, a malicious peer could send crafted binary messages to:
|
||||||
|
|
||||||
|
- **Cause denial of service** via extremely large updates, deeply nested structures, or messages that trigger excessive computation.
|
||||||
|
- **Trigger buffer overflows or out-of-bounds reads** in the binary decoder.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Never allow untrusted peers to write to the original document.
|
||||||
|
- Use separate document forks for suggestion-only users.
|
||||||
|
- Do not rely on server-side update filtering as a security measure.
|
||||||
|
- Authenticate and authorize peers before granting write access.
|
||||||
|
- Validate incoming binary messages and reject malformed data.
|
||||||
|
- Implement size limits on incoming updates where possible.
|
||||||
|
- Fuzz test the decoder and update application paths.
|
||||||
|
|
||||||
|
### 3.2 Supply Chain (Dependencies & npm Publishing)
|
||||||
|
|
||||||
|
Yjs is a widely-used library. Compromising it would impact many downstream projects.
|
||||||
|
|
||||||
|
**Yjs only depends on packages that are maintained by the Yjs maintainers themselves** (such as `lib0` and `@y/protocols`). This significantly reduces supply chain risk, as there are no third-party runtime dependencies that could be independently compromised.
|
||||||
|
|
||||||
|
- **Compromised npm publish token** leading to a malicious release.
|
||||||
|
- **Typosquatting** of the package name.
|
||||||
|
|
||||||
|
However, the project does use third-party **dev dependencies** (linters, test tools, build tools, etc.). A compromised dev dependency could execute malicious code on a maintainer's machine during development, potentially leading to credential theft or tampered releases. It is important to only install trusted dev dependencies and to review them carefully.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- All runtime dependencies are maintained by the Yjs team, eliminating third-party supply chain risk.
|
||||||
|
- Only install trusted, well-maintained dev dependencies and review new additions carefully.
|
||||||
|
- Enable MFA on all maintainer npm and GitHub accounts.
|
||||||
|
- Use `npm provenance` or signed releases where supported.
|
||||||
|
- Pin dependency versions and review lockfile changes in PRs.
|
||||||
|
- Monitor for typosquat packages.
|
||||||
|
|
||||||
|
### 3.3 Pull Requests & Code Contributions
|
||||||
|
|
||||||
|
- **Malicious code** submitted via pull requests (obfuscated backdoors, subtle logic changes).
|
||||||
|
- **CI exploitation** through crafted PR workflows that exfiltrate secrets.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Require at least one maintainer review before merging.
|
||||||
|
- Do not run CI with secrets on PRs from forks.
|
||||||
|
- Use branch protection rules on `main`.
|
||||||
|
- Review changes carefully, especially to binary encoding/decoding, crypto, and publishing scripts.
|
||||||
|
|
||||||
|
### 3.4 GitHub Actions & CI/CD
|
||||||
|
|
||||||
|
- **Secret exfiltration** from workflow runs.
|
||||||
|
- **Workflow injection** via untrusted input in PR titles, branch names, or commit messages.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Follow the principle of least privilege for workflow permissions.
|
||||||
|
- Avoid using `pull_request_target` with checkout of PR code.
|
||||||
|
- Pin action versions to specific commit SHAs.
|
||||||
|
- Limit secret access to required workflows only.
|
||||||
|
|
||||||
|
### 3.5 Maintainer Account Compromise
|
||||||
|
|
||||||
|
- **Account takeover** of a GitHub or npm maintainer account.
|
||||||
|
- Used to merge malicious PRs, publish backdoored packages, or modify CI.
|
||||||
|
|
||||||
|
**Mitigations:**
|
||||||
|
- Require MFA for all maintainers (GitHub and npm).
|
||||||
|
- Limit the number of accounts with publish access.
|
||||||
|
- Periodically review organization members and their permissions.
|
||||||
|
|
||||||
|
## 4. Risk Assessment
|
||||||
|
|
||||||
|
| Threat | Likelihood | Impact | Priority |
|
||||||
|
|--------|-----------|--------|----------|
|
||||||
|
| Malicious CRDT update from peer | Medium | High (data corruption) | High |
|
||||||
|
| Compromised npm publish | Low | Critical (supply chain) | High |
|
||||||
|
| Malicious pull request merged | Low | High | Medium |
|
||||||
|
| Dependency compromise | Low | High | Medium |
|
||||||
|
| CI secret exfiltration | Low | Medium | Medium |
|
||||||
|
| Maintainer account takeover | Low | Critical | High |
|
||||||
|
|
||||||
|
## 5. Out of Scope
|
||||||
|
|
||||||
|
The following are the responsibility of the application developer using Yjs, not the Yjs library itself:
|
||||||
|
|
||||||
|
- **Authentication and authorization** of peers connecting to a collaboration session.
|
||||||
|
- **Transport-layer security** (TLS/WSS) for data in transit.
|
||||||
|
- **Server-side access control** for who can read or write documents.
|
||||||
|
- **Application-level input validation** (e.g., sanitizing rich text content for XSS).
|
||||||
|
|
||||||
|
## 6. Review Schedule
|
||||||
|
|
||||||
|
This threat model should be reviewed:
|
||||||
|
|
||||||
|
- At least once per year.
|
||||||
|
- When a new major version is released.
|
||||||
|
- When a security incident occurs.
|
||||||
|
- When significant new features or dependencies are added.
|
||||||
130
attributing-content.md
Normal file
130
attributing-content.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# IdSets and IdMaps
|
||||||
|
|
||||||
|
`IdSet` is a data structure (formerly `DeleteSet`) that allows us to efficiently
|
||||||
|
represent ranges of ids in Yjs (all content is identifyable by ids).
|
||||||
|
|
||||||
|
`IdMap` is a new data structure that allows us to efficiently map ids to
|
||||||
|
attributes. It can be efficiently encoded.
|
||||||
|
|
||||||
|
We can perform all usual set operations on `IdMap`s and `IdSet`s: diff, merge,
|
||||||
|
intersect.
|
||||||
|
|
||||||
|
# Attribution of content
|
||||||
|
|
||||||
|
In order to implement a Google Docs-like versioning feature, we want to be able
|
||||||
|
to attribute content with additional information (who created the change,
|
||||||
|
when was this change created, ..).
|
||||||
|
|
||||||
|
When we click on a version in Google Docs, we might get annotated changes like
|
||||||
|
this:
|
||||||
|
|
||||||
|
```
|
||||||
|
# E.g. If Bob appends "world" to the previous version "hello "
|
||||||
|
[{ insert: 'hello' }, { insert: 'world', color: 'blue', creator: 'Bob', when: 'yesterday' }]
|
||||||
|
# E.g. If Bob deletes "world" from the previous version "hello world"
|
||||||
|
[{ insert: 'hello' }, { insert: 'world', backgroundColor: 'red', creator: 'Bob', when: 'yesterday' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
In Yjs, we can now "attribute" changes with additional information. When we
|
||||||
|
render content using methods like `toString()` or `toDelta()`, Yjs will render
|
||||||
|
the unattributed content as-is, but it will render the attributed content with
|
||||||
|
the additional information. As all changes in Yjs are identifyable by Ids, we
|
||||||
|
can use `IdMap`s to map changes to "attributions". For example, we could
|
||||||
|
attribute deletions and insertions of a change and render them:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// We create some initial content "Hello World!". Then we create another
|
||||||
|
// document that will have a bunch of changes (make "Hell" italic, replace "World"
|
||||||
|
// with "attributions").
|
||||||
|
const ydocVersion0 = new Y.Doc({ gc: false })
|
||||||
|
ydocVersion0.get().insert(0, 'Hello World!')
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocVersion0))
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert('attributions').done())
|
||||||
|
// this represents all insertions of ydoc
|
||||||
|
const insertionSet = Y.createInsertSetFromStructStore(ydoc.store, false)
|
||||||
|
const deleteSet = Y.createDeleteSetFromStructStore(ydoc.store)
|
||||||
|
// exclude the changes from `ydocVersion0`
|
||||||
|
const insertionSetDiff = Y.diffIdSet(insertionSet, Y.createInsertSetFromStructStore(ydocVersion0.store, false))
|
||||||
|
const deleteSetDiff = Y.diffIdSet(deleteSet, Y.createDeleteSetFromStructStore(ydocVersion0.store))
|
||||||
|
// assign attributes to the diff
|
||||||
|
const attributedInsertions = Y.createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute('insert', 'Bob')])
|
||||||
|
const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('delete', 'Bob')])
|
||||||
|
// now we can define a renderer that maps these changes to output. One of the
|
||||||
|
// implementations is the TwosetRenderer
|
||||||
|
const renderer = new Y.TwosetRenderer(attributedInsertions, attributedDeletions)
|
||||||
|
// we render the attributed content with the renderer
|
||||||
|
const attributedContent = ytext.toDelta({ renderer })
|
||||||
|
console.log(JSON.stringify(attributedContent.toJSON(), null, 2))
|
||||||
|
const expectedContent = delta.create().insert('Hell', { italic: true }, { format: { italic: ['Bob'] } }).insert('o ').insert('World', {}, { delete: ['Bob'] }).insert('attributions', {}, { insert: ['Bob'] }).insert('!')
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
|
||||||
|
// this is how the output would look like
|
||||||
|
const output = {
|
||||||
|
"type": "delta",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "insert",
|
||||||
|
"insert": "Hell",
|
||||||
|
"format": {
|
||||||
|
"italic": true
|
||||||
|
},
|
||||||
|
"attribution": { // no "insert" attribution: the insertion "Hell" is not attributed to anyone
|
||||||
|
"format": {
|
||||||
|
"italic": [ // the formatting attribute "italic" was added by Bob
|
||||||
|
"Bob"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "insert",
|
||||||
|
"insert": "o " // the insertion "o " has no attributions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "insert",
|
||||||
|
"insert": "World",
|
||||||
|
"attribution": { // the insertion "World" was deleted by Bob
|
||||||
|
"delete": [
|
||||||
|
"Bob"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "insert",
|
||||||
|
"insert": "attributions", // the insertion "attributions" was inserted by Bob
|
||||||
|
"attribution": {
|
||||||
|
"insert": [
|
||||||
|
"Bob"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "insert",
|
||||||
|
"insert": "!" // the insertion "!" has no attributions
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We get a similar output to Google Docs: Insertions, Deletions, and changes to
|
||||||
|
formatting (attributes) are clearly associated to users. It will be the job of
|
||||||
|
the editor to render those changes with background-color etc..
|
||||||
|
|
||||||
|
Of course, we could associated changes also to multiple users like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('insert', 'Bob'), Y.createContentAttribute('insert', 'OpenAI o3')])
|
||||||
|
```
|
||||||
|
|
||||||
|
You could use the same output to calculate a real diff as well (consisting of
|
||||||
|
deletions and insertions only, without Attributions).
|
||||||
|
|
||||||
|
`AbstractRenderer` is the abstract base class for renderers, which map
|
||||||
|
attributions onto content. It is possible to highlight arbitrary content with
|
||||||
|
this approach.
|
||||||
|
|
||||||
|
The attribution data is encoded very efficiently. The ids are encoded using
|
||||||
|
run-length encoding and the Attributes are de-duplicated and only encoded once.
|
||||||
|
The above example encodes in 27 bytes.
|
||||||
148
funding.json
Normal file
148
funding.json
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"version": "v1.0.0",
|
||||||
|
"entity": {
|
||||||
|
"type": "group",
|
||||||
|
"role": "steward",
|
||||||
|
"name": "Kevin Jahns",
|
||||||
|
"email": "kevin.jahns@protonmail.com",
|
||||||
|
"phone": "",
|
||||||
|
"description": "Independent OSS Developer maintaining Yjs and many related libraries. My goal is to make the web more (real-time) collaborative.",
|
||||||
|
"webpageUrl": {
|
||||||
|
"url": "https://yjs.dev",
|
||||||
|
"wellKnown": "https://yjs.dev/.well-known/funding-manifest-urls"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"guid": "yjs",
|
||||||
|
"name": "Yjs",
|
||||||
|
"description": "A library for building collaborative applications. #p2p #local-first #CRDT Funding this project will also enable me to maintain the other Yjs-related technologies.",
|
||||||
|
"webpageUrl": {
|
||||||
|
"url": "https://yjs.dev",
|
||||||
|
"wellKnown": "https://yjs.dev/.well-known/funding-manifest-urls"
|
||||||
|
},
|
||||||
|
"repositoryUrl": {
|
||||||
|
"url": "https://github.com/yjs/yjs"
|
||||||
|
},
|
||||||
|
"licenses": [
|
||||||
|
"spdx:MIT"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"collaboration",
|
||||||
|
"p2p",
|
||||||
|
"CRDT",
|
||||||
|
"rich-text",
|
||||||
|
"real-time"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "titanic",
|
||||||
|
"name": "Titanic",
|
||||||
|
"description": "A provider for syncing millions of docs efficiently with other peers. This will become the foundation for building real local-first apps with Yjs.",
|
||||||
|
"webpageUrl": {
|
||||||
|
"url": "https://github.com/yjs/titanic",
|
||||||
|
"wellKnown": "https://github.com/yjs/titanic/blob/main/.well-known/funding-manifest-urls"
|
||||||
|
},
|
||||||
|
"repositoryUrl": {
|
||||||
|
"url": "https://github.com/yjs/titanic",
|
||||||
|
"wellKnown": "https://github.com/yjs/titanic/blob/main/.well-known/funding-manifest-urls"
|
||||||
|
},
|
||||||
|
"licenses": [
|
||||||
|
"spdx:MIT"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"privacy",
|
||||||
|
"collaboration",
|
||||||
|
"p2p",
|
||||||
|
"CRDT",
|
||||||
|
"rich-text",
|
||||||
|
"real-time",
|
||||||
|
"web-development"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"guid": "github-sponsors",
|
||||||
|
"type": "payment-provider",
|
||||||
|
"address": "https://github.com/sponsors/dmonad",
|
||||||
|
"description": "For funding of the Yjs project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "yjs-opencollective",
|
||||||
|
"type": "payment-provider",
|
||||||
|
"address": "https://opencollective.com/y-collective/projects/yjs",
|
||||||
|
"description": "For funding Yjs via the OpenCollective."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plans": [
|
||||||
|
{
|
||||||
|
"guid": "supporter",
|
||||||
|
"status": "active",
|
||||||
|
"name": "Supporter",
|
||||||
|
"description": "",
|
||||||
|
"amount": 0,
|
||||||
|
"currency": "USD",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"channels": [
|
||||||
|
"github-sponsors",
|
||||||
|
"yjs-opencollective"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "titanic-funding",
|
||||||
|
"status": "active",
|
||||||
|
"name": "Titanic Funding",
|
||||||
|
"description": "Fund the next generation of local-first providers.",
|
||||||
|
"amount": 30000,
|
||||||
|
"currency": "USD",
|
||||||
|
"frequency": "one-time",
|
||||||
|
"channels": [
|
||||||
|
"github-sponsors",
|
||||||
|
"yjs-opencollective"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "bronze-sponsor",
|
||||||
|
"status": "active",
|
||||||
|
"name": "Bronze Sponsor",
|
||||||
|
"description": "This is the recommended plan for companies that use Yjs.",
|
||||||
|
"amount": 500,
|
||||||
|
"currency": "USD",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"channels": [
|
||||||
|
"github-sponsors",
|
||||||
|
"yjs-opencollective"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "silver-sponsor",
|
||||||
|
"status": "active",
|
||||||
|
"name": "Silver Sponsor",
|
||||||
|
"description": "This is the recommended plan for large/successful companies that use Yjs.",
|
||||||
|
"amount": 1000,
|
||||||
|
"currency": "USD",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"channels": [
|
||||||
|
"github-sponsors",
|
||||||
|
"yjs-opencollective"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"guid": "gold-sponsor",
|
||||||
|
"status": "active",
|
||||||
|
"name": "Gold Sponsor",
|
||||||
|
"description": "This is the recommended plan for successful companies that build their entire product around Yjs-related technologies.",
|
||||||
|
"amount": 3000,
|
||||||
|
"currency": "USD",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"channels": [
|
||||||
|
"github-sponsors",
|
||||||
|
"yjs-opencollective"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"history": null
|
||||||
|
}
|
||||||
|
}
|
||||||
53
global.d.ts
vendored
Normal file
53
global.d.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
declare type GC = import('./src/structs/GC.js').GC
|
||||||
|
declare type Item = import('./src/structs/Item.js').Item
|
||||||
|
declare type Skip = import('./src/structs/Skip.js').Skip
|
||||||
|
declare type IdRange = import('./src/utils/ids.js').IdRange
|
||||||
|
declare type IdSet = import('./src/utils/ids.js').IdSet
|
||||||
|
declare type IdMap<Attrs> = import('./src/utils/ids.js').IdMap<Attrs>
|
||||||
|
declare type AttrRanges<Attrs = any> = import('./src/utils/ids.js').AttrRanges<Attrs>
|
||||||
|
declare type AttrRange<Attrs = any> = import('./src/utils/ids.js').AttrRange<Attrs>
|
||||||
|
declare type ContentAttribute<V=any> = import('./src/utils/ids.js').ContentAttribute<V>
|
||||||
|
declare type ContentIds = import('./src/utils/ids.js').ContentIds
|
||||||
|
declare type ContentMap= import('./src/utils/ids.js').ContentMap
|
||||||
|
|
||||||
|
|
||||||
|
declare type BlockSet = import('./src/utils/BlockSet.js').BlockSet
|
||||||
|
declare type UpdateDecoderV1 = import('./src/utils/UpdateDecoder.js').UpdateDecoderV1
|
||||||
|
declare type UpdateDecoderV2 = import('./src/utils/UpdateDecoder.js').UpdateDecoderV2
|
||||||
|
declare type UpdateEncoderV1 = import('./src/utils/UpdateEncoder.js').UpdateEncoderV1
|
||||||
|
declare type UpdateEncoderV2 = import('./src/utils/UpdateEncoder.js').UpdateEncoderV2
|
||||||
|
declare type IdSetEncoderV1 = import('./src/utils/UpdateEncoder.js').IdSetEncoderV1
|
||||||
|
declare type IdSetEncoderV2 = import('./src/utils/UpdateEncoder.js').IdSetEncoderV2
|
||||||
|
declare type IdSetDecoderV1 = import('./src/utils/UpdateDecoder.js').IdSetDecoderV1
|
||||||
|
declare type IdSetDecoderV2 = import('./src/utils/UpdateDecoder.js').IdSetDecoderV2
|
||||||
|
|
||||||
|
declare type ID = import('./src/utils/ID.js').ID
|
||||||
|
declare type Transaction = import('./src/utils/Transaction.js').Transaction
|
||||||
|
declare type StructStore = import('./src/utils/StructStore.js').StructStore
|
||||||
|
declare type Doc = import('./src/utils/Doc.js').Doc
|
||||||
|
declare type YNode<DConf extends import('lib0/delta').DeltaConf = any> = import('./src/ynode.js').YNode<DConf>
|
||||||
|
declare type YEvent<DConf extends import('lib0/delta').DeltaConf> = import('./src/utils/YEvent.js').YEvent<DConf>
|
||||||
|
declare type EventHandler<ARG1=any,ARG2=any> = import('./src/utils/EventHandler.js').EventHandler<ARG1, ARG2>
|
||||||
|
|
||||||
|
declare type AbstractStruct = import('./src/structs/AbstractStruct.js').AbstractStruct
|
||||||
|
declare type AbstractContent = import('./src/structs/Item.js').AbstractContent
|
||||||
|
declare type ContentType = import('./src/structs/Item.js').ContentType
|
||||||
|
declare type ContentAny = import('./src/structs/Item.js').ContentAny
|
||||||
|
declare type ContentDoc = import('./src/structs/Item.js').ContentDoc
|
||||||
|
declare type ContentJSON = import('./src/structs/Item.js').ContentJSON
|
||||||
|
declare type ContentEmbed = import('./src/structs/Item.js').ContentEmbed
|
||||||
|
declare type ContentFormat = import('./src/structs/Item.js').ContentFormat
|
||||||
|
declare type ContentDeleted = import('./src/structs/Item.js').ContentDeleted
|
||||||
|
declare type ContentString = import('./src/structs/Item.js').ContentString
|
||||||
|
|
||||||
|
declare type DeltaConf = import('lib0/delta').DeltaConf
|
||||||
|
declare type Delta<DConf extends DeltaConf> = import('lib0/delta').Delta<DConf>
|
||||||
|
|
||||||
|
// @todo the below should have a separate Y/[mod] export
|
||||||
|
declare type StackItem = import('./src/utils/UndoManager.js').StackItem
|
||||||
|
declare type UndoManager = import('./src/utils/UndoManager.js').UndoManager
|
||||||
|
declare type AbstractRenderer = import('./src/utils/renderer-helpers.js').AbstractRenderer
|
||||||
|
declare type Attribution = import('./src/utils/renderer-helpers.js').Attribution
|
||||||
|
declare type AttributedContent<T = any> = import('./src/utils/renderer-helpers.js').AttributedContent<T>
|
||||||
|
|
||||||
|
declare type Snapshot = import('./src/utils/Snapshot.js').Snapshot
|
||||||
5935
package-lock.json
generated
Normal file
5935
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
90
package.json
Normal file
90
package.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"name": "@y/y",
|
||||||
|
"version": "14.0.0-rc.26",
|
||||||
|
"description": "Shared Editing Library",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"test": "NODE_ENV=development node ./tests/index.js --repetition-time 50",
|
||||||
|
"test-extensive": "node ./tests/index.js --production --repetition-time 10000",
|
||||||
|
"dist": "npm run clean && PRODUCTION=1 tsc --noEmit false && test -e dist/src/index.d.ts",
|
||||||
|
"lint": "markdownlint README.md && standard && tsc && npx dpdm --exit-code circular:1 --warning false --tree false src/index.js",
|
||||||
|
"debug": "npm run gentesthtml && 0serve -o test.html",
|
||||||
|
"debug:node": "node --inspect-brk tests/index.js",
|
||||||
|
"trace-deopt": "clear && node --trace-deopt ./tests/index.js",
|
||||||
|
"trace-opt": "clear && node --trace-opt ./tests/index.js",
|
||||||
|
"gentesthtml": "0gentesthtml --script ./tests/index.js --include-dependencies @y/protocols > test.html"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/src/index.d.ts",
|
||||||
|
"default": "./src/index.js"
|
||||||
|
},
|
||||||
|
"./meta": {
|
||||||
|
"types": "./dist/src/utils/meta.d.ts",
|
||||||
|
"default": "./src/utils/meta.js"
|
||||||
|
},
|
||||||
|
"./testHelper": {
|
||||||
|
"types": "./dist/tests/testHelper.d.ts",
|
||||||
|
"default": "./tests/testHelper.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist/src",
|
||||||
|
"tests/testHelper.js",
|
||||||
|
"global.d.ts"
|
||||||
|
],
|
||||||
|
"dictionaries": {
|
||||||
|
"test": "tests"
|
||||||
|
},
|
||||||
|
"standard": {
|
||||||
|
"ignore": [
|
||||||
|
"/dist",
|
||||||
|
"/node_modules"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/yjs/yjs.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Yjs",
|
||||||
|
"CRDT",
|
||||||
|
"offline",
|
||||||
|
"offline-first",
|
||||||
|
"shared-editing",
|
||||||
|
"concurrency",
|
||||||
|
"collaboration"
|
||||||
|
],
|
||||||
|
"author": "Kevin Jahns",
|
||||||
|
"email": "kevin.jahns@protonmail.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/yjs/yjs/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://docs.yjs.dev",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^1.0.0-rc.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.1",
|
||||||
|
"@y/protocols": "^1.0.6-rc.1",
|
||||||
|
"@y/y": ".",
|
||||||
|
"dpdm": "^4.0.1",
|
||||||
|
"markdownlint": "^0.41.1",
|
||||||
|
"markdownlint-cli": "^0.49.1",
|
||||||
|
"standard": "^17.1.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.0.0",
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
renderer.md
Normal file
234
renderer.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
|
||||||
|
# Attribution Feature
|
||||||
|
|
||||||
|
The Attribution feature extends Yjs types to provide rich metadata about content
|
||||||
|
changes, including information about who created, deleted, or formatted content.
|
||||||
|
This enables powerful collaborative editing features such as authorship tracking
|
||||||
|
and change visualization. The information about who performed which changes can
|
||||||
|
be handled by a separate CRDT (which is part of the renderer).
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
A `renderer` renders Content (with its Attributions) to a delta. It is the
|
||||||
|
central component for the attribution feature: pass it to methods like
|
||||||
|
`toDelta()` / `getDelta()` to render content together with attribution metadata.
|
||||||
|
|
||||||
|
Different implementations of Renderer are available for different use cases:
|
||||||
|
- `DiffRenderer`: Highlights the differences between two Yjs documents
|
||||||
|
- `SnapshotRenderer`: Highlights the differences between two snapshots
|
||||||
|
|
||||||
|
### Attributed Content
|
||||||
|
|
||||||
|
Attributed content includes standard Yjs operations enhanced with attribution metadata:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Standard content
|
||||||
|
[{ insert: 'hello world' }]
|
||||||
|
|
||||||
|
// Attributed content
|
||||||
|
[
|
||||||
|
{ insert: 'hello', attribution: { insert: ['kevin'] } },
|
||||||
|
{ insert: ' world', attribution: { insert: ['alice'] } }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Attribution
|
||||||
|
|
||||||
|
Deleted content is represented in attributed results to maintain authorship information and proper position tracking:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Shows deleted content with attribution
|
||||||
|
[
|
||||||
|
{ insert: 'hello ', attribution: { delete: ['kevin'] } },
|
||||||
|
{ insert: 'world' }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Y.Node
|
||||||
|
|
||||||
|
In Yjs v14 all shared types (text, array, map, xml) are instances of the unified
|
||||||
|
`Y.Node`.
|
||||||
|
|
||||||
|
#### `toDelta([{ renderer }])`
|
||||||
|
|
||||||
|
Returns the delta representation (lib0/delta) of the type's content, optionally
|
||||||
|
with attribution information.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `renderer` (optional): The renderer instance
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- A `Delta` describing the content, with attribution metadata if `renderer` is provided
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
// Content is inserted during collaborative editing
|
||||||
|
// Attribution is handled automatically by the server
|
||||||
|
|
||||||
|
// Without attribution
|
||||||
|
const d = ytext.toDelta()
|
||||||
|
// [{ insert: 'hello world' }]
|
||||||
|
|
||||||
|
// With attribution
|
||||||
|
const attributedDelta = ytext.toDelta({ renderer })
|
||||||
|
// [
|
||||||
|
// { insert: 'hello', attribution: { insert: ['kevin'] } },
|
||||||
|
// { insert: ' world', attribution: { insert: ['alice'] } }
|
||||||
|
// ]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `applyDelta(delta, [origin], [{ renderer }])`
|
||||||
|
|
||||||
|
Applies a delta (lib0/delta) on the shared type. The optional `origin` is stored
|
||||||
|
on the transaction (`transaction.origin`) and forwarded verbatim on the emitted
|
||||||
|
`'delta'` event (lib0 RDT spec), so listeners can recognize — and skip — changes
|
||||||
|
they produced themselves. When a `renderer` is provided, positions in the delta
|
||||||
|
are interpreted relative to the attributed (rendered) content.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `delta`: The changes to apply
|
||||||
|
- `origin` (optional): Origin of the transaction that applies this delta; defaults to `null`
|
||||||
|
- `renderer` (optional): The renderer instance
|
||||||
|
|
||||||
|
### YEvent
|
||||||
|
|
||||||
|
#### `getDelta([{ renderer, deep }])`
|
||||||
|
|
||||||
|
Returns the changes of an event as a delta, optionally rendered with attribution
|
||||||
|
information.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `renderer` (optional): The renderer instance
|
||||||
|
- `deep` (optional): Render child types as deltas
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- A `Delta` describing the changes, with attribution metadata if `renderer` is provided
|
||||||
|
|
||||||
|
## Position Adjustments
|
||||||
|
|
||||||
|
When working with attributed content, position calculations must account for deleted content that appears in the attributed representation but not in the standard representation.
|
||||||
|
|
||||||
|
### Example: Position Adjustment
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Standard content (length: 5)
|
||||||
|
ytext.toString() // "world"
|
||||||
|
|
||||||
|
// Attributed content (includes deleted content)
|
||||||
|
ytext.toDelta({ renderer })
|
||||||
|
// [
|
||||||
|
// { insert: 'hello ', attribution: { delete: ['kevin'] } }, // positions 0-5
|
||||||
|
// { insert: 'world' } // positions 6-10
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// To insert after "world":
|
||||||
|
// - Standard position: 5 (after "world")
|
||||||
|
// - Attributed position: 11 (after "world" accounting for deleted "hello ")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
Events in Yjs are enhanced to work with attributed content, automatically adjusting positions when attribution is considered.
|
||||||
|
|
||||||
|
### Event Position Adjustment
|
||||||
|
|
||||||
|
When a `renderer` is used, event positions are automatically adjusted to account for deleted content.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initial content: "hello world"
|
||||||
|
// User deletes "hello " (positions 0-6)
|
||||||
|
// Current visible content: "world"
|
||||||
|
|
||||||
|
ytext.observe((event, transaction) => {
|
||||||
|
// User wants to insert "!" after "world"
|
||||||
|
|
||||||
|
// Standard event (without attribution)
|
||||||
|
const standardDelta = event.getDelta()
|
||||||
|
// Shows insertion at position 5 (after "world" in visible content)
|
||||||
|
|
||||||
|
// Attributed event (with renderer)
|
||||||
|
const attributedDelta = event.getDelta({ renderer })
|
||||||
|
// Shows insertion at position 11 (accounting for deleted "hello ")
|
||||||
|
// [
|
||||||
|
// { insert: 'hello ', attribution: { delete: ['kevin'] } },
|
||||||
|
// { insert: 'world' },
|
||||||
|
// { insert: '!' } // inserted at attributed position 11
|
||||||
|
// ]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Authorship Visualization
|
||||||
|
|
||||||
|
Display content with visual indicators of who created each part:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderWithAuthorship(ytext, renderer) {
|
||||||
|
const attributedDelta = ytext.toDelta({ renderer })
|
||||||
|
|
||||||
|
return attributedDelta.children.map(op => {
|
||||||
|
const author = op.attribution?.insert?.[0] || 'unknown'
|
||||||
|
const isDeleted = op.attribution?.delete
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: op.insert,
|
||||||
|
author,
|
||||||
|
isDeleted,
|
||||||
|
className: `author-${author} ${isDeleted ? 'deleted' : ''}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Tracking
|
||||||
|
|
||||||
|
Track who made specific changes to content:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function trackChanges(ytext, renderer) {
|
||||||
|
ytext.observe((event, transaction) => {
|
||||||
|
const changes = event.getDelta({ renderer })
|
||||||
|
|
||||||
|
changes.children.forEach(change => {
|
||||||
|
if (change.attribution) {
|
||||||
|
console.log(`Change by ${change.attribution.insert?.[0] || change.attribution.delete?.[0]}:`, change)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Renderer Lifecycle
|
||||||
|
|
||||||
|
- Create one renderer per document or collaboration session
|
||||||
|
- Ensure the renderer is consistently used across all operations
|
||||||
|
- Pass the same renderer instance to all methods that need attribution
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Upgrading Existing Code
|
||||||
|
|
||||||
|
To add attribution support to existing Yjs applications:
|
||||||
|
|
||||||
|
1. **Add renderer**: Create and configure a renderer
|
||||||
|
2. **Update method calls**: Add the renderer parameter to relevant method calls
|
||||||
|
3. **Handle attributed content**: Update code to handle the new attribution metadata format
|
||||||
|
4. **Adjust position calculations**: Update position calculations to account for deleted content
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
The Attribution feature is fully backward compatible:
|
||||||
|
- All existing methods work without the renderer parameter
|
||||||
|
- Existing code continues to work unchanged
|
||||||
|
- Attribution is opt-in and doesn't affect performance when not used
|
||||||
15
rollup.config.js
Normal file
15
rollup.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// @todo remove
|
||||||
|
|
||||||
|
export default [{
|
||||||
|
input: {
|
||||||
|
yjs: './src/index.js',
|
||||||
|
testHelper: './tests/testHelper.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
dir: 'dist',
|
||||||
|
format: 'esm',
|
||||||
|
entryFileNames: '[name].js',
|
||||||
|
sourcemap: true
|
||||||
|
},
|
||||||
|
external: id => /^(lib0|@y)\//.test(id)
|
||||||
|
}]
|
||||||
66
src/index.js
Normal file
66
src/index.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/// <reference types="../global.d.ts" />
|
||||||
|
/** eslint-env browser */
|
||||||
|
|
||||||
|
// Order matters: follows internals.js ordering to avoid circular dependency issues
|
||||||
|
|
||||||
|
export { IdSet, equalIdSets, createDeleteSetFromStructStore, createInsertSetFromStructStore, diffIdSet, createIdSet, mergeIdSets, insertIntoIdSet, iterateStructsByIdSet, gcIdSet, readIdSet, writeIdSet, encodeIdSet, decodeIdSet, IdMap, createIdMap, createContentAttribute, ContentAttribute, diffIdMap, encodeIdMap, createIdMapFromIdSet, createIdSetFromIdMap, insertIntoIdMap, mergeIdMaps, readIdMap, writeIdMap, decodeIdMap, filterIdMap, intersectSets, intersectMaps, $idSet, $idMapAny, $idMap } from './utils/ids.js'
|
||||||
|
export { Doc } from './utils/Doc.js'
|
||||||
|
export { $doc } from './utils/schemas.js'
|
||||||
|
export { UpdateDecoderV1, UpdateDecoderV2 } from './utils/UpdateDecoder.js'
|
||||||
|
export { UpdateEncoderV1, UpdateEncoderV2 } from './utils/UpdateEncoder.js'
|
||||||
|
export { applyUpdate, applyUpdateV2, readUpdate, readUpdateV2, encodeStateAsUpdate, encodeStateAsUpdateV2, encodeStateVector, decodeStateVector, diffUpdate, diffUpdateV2, mergeUpdates, mergeUpdatesV2, createDocFromUpdate, createDocFromUpdateV2, cloneDoc } from './utils/encoding.js'
|
||||||
|
export { ID, createID, compareIDs, findRootTypeKey } from './utils/ID.js'
|
||||||
|
export { isParentOf } from './utils/isParentOf.js'
|
||||||
|
export { logNode } from './utils/logging.js'
|
||||||
|
export { createRelativePositionFromTypeIndex, createRelativePositionFromJSON, createAbsolutePositionFromRelativePosition, compareRelativePositions, AbsolutePosition, RelativePosition, relativePositionToJSON, encodeRelativePosition, decodeRelativePosition } from './utils/RelativePosition.js'
|
||||||
|
export { Snapshot, createSnapshot, snapshot, emptySnapshot, createDocFromSnapshot, decodeSnapshot, encodeSnapshot, decodeSnapshotV2, encodeSnapshotV2, equalSnapshots, snapshotContainsUpdate } from './utils/Snapshot.js'
|
||||||
|
export { findIndexSS, getItemCleanStart, getItemCleanEnd, tryGc } from './utils/transaction-helpers.js'
|
||||||
|
export { Transaction, transact, cleanupYTextFormatting } from './utils/Transaction.js'
|
||||||
|
export { UndoManager, undoContentIds } from './utils/UndoManager.js'
|
||||||
|
export { logUpdate, logUpdateV2, decodeUpdate, decodeUpdateV2, encodeStateVectorFromUpdate, encodeStateVectorFromUpdateV2, convertUpdateFormatV1ToV2, convertUpdateFormatV2ToV1, obfuscateUpdate, obfuscateUpdateV2, createContentIdsFromUpdate, createContentIdsFromUpdateV2, intersectUpdateWithContentIds, intersectUpdateWithContentIdsV2 } from './utils/updates.js'
|
||||||
|
export { YEvent, getPathTo } from './utils/YEvent.js'
|
||||||
|
export { AttributionsRenderer, createAttributionsRenderer, AbstractRenderer, createDiffRenderer, DiffRenderer, createSnapshotRenderer, SnapshotRenderer, $renderer } from './utils/Renderer.js'
|
||||||
|
export { diffDocsToDelta } from './utils/delta-helpers.js'
|
||||||
|
export { createRelativePositionsFromDeltaPositions, createDeltaPositionsFromRelativePositions, createRelativePositionFromDeltaPosition, createDeltaPositionFromRelativePosition } from './utils/position-helpers.js'
|
||||||
|
export { YNode as Node, getNodeChildren, nodeMapGetSnapshot, nodeMapGetAllSnapshot, $node, $nodeAny } from './ynode.js'
|
||||||
|
export { AbstractStruct } from './structs/AbstractStruct.js'
|
||||||
|
export { GC } from './structs/GC.js'
|
||||||
|
export { Item, ContentBinary, ContentDeleted, ContentDoc, ContentEmbed, ContentFormat, ContentJSON, ContentAny, ContentString, ContentType } from './structs/Item.js'
|
||||||
|
export { Skip } from './structs/Skip.js'
|
||||||
|
|
||||||
|
export * from './utils/meta.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./utils/ids.js').ContentIds} ContentIds
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @typedef {import('./utils/ids.js').ContentMap} ContentMap
|
||||||
|
*/
|
||||||
|
|
||||||
|
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
|
||||||
|
? globalThis
|
||||||
|
: typeof window !== 'undefined'
|
||||||
|
? window
|
||||||
|
// @ts-ignore
|
||||||
|
: typeof global !== 'undefined' ? global : {})
|
||||||
|
|
||||||
|
const importIdentifier = '__ $YJS14$ __'
|
||||||
|
|
||||||
|
if (glo[importIdentifier] === true) {
|
||||||
|
/**
|
||||||
|
* Dear reader of this message. Please take this seriously.
|
||||||
|
*
|
||||||
|
* If you see this message, make sure that you only import one version of Yjs. In many cases,
|
||||||
|
* your package manager installs two versions of Yjs that are used by different packages within your project.
|
||||||
|
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
|
||||||
|
* and others use the EcmaScript version of Yjs.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* 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 issues! - https://github.com/yjs/yjs/issues/438')
|
||||||
|
}
|
||||||
|
glo[importIdentifier] = true
|
||||||
64
src/structs/AbstractStruct.js
Normal file
64
src/structs/AbstractStruct.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as error from 'lib0/error'
|
||||||
|
|
||||||
|
export class AbstractStruct {
|
||||||
|
/**
|
||||||
|
* @param {ID} id
|
||||||
|
* @param {number} length
|
||||||
|
*/
|
||||||
|
constructor (id, length) {
|
||||||
|
this.id = id
|
||||||
|
this.length = length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
get deleted () {
|
||||||
|
throw error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge this struct with the item to the right.
|
||||||
|
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
|
||||||
|
* Also this method does *not* remove right from StructStore!
|
||||||
|
* @param {GC|Item|Skip} _right
|
||||||
|
* @return {boolean} whether this merged with right
|
||||||
|
*/
|
||||||
|
mergeWith (_right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder The encoder to write data to.
|
||||||
|
* @param {number} _offset
|
||||||
|
* @param {number} _encodingRef
|
||||||
|
*/
|
||||||
|
write (_encoder, _offset, _encodingRef) {
|
||||||
|
throw error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} _transaction
|
||||||
|
* @param {number} _offset
|
||||||
|
*/
|
||||||
|
integrate (_transaction, _offset) {
|
||||||
|
throw error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} _diff
|
||||||
|
* @return {GC|Item|Skip}
|
||||||
|
*/
|
||||||
|
splice (_diff) {
|
||||||
|
throw error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSet} idSet
|
||||||
|
* @param {AbstractStruct} struct
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const addStructToIdSet = (idSet, struct) => idSet.add(struct.id.client, struct.id.clock, struct.length)
|
||||||
75
src/structs/GC.js
Normal file
75
src/structs/GC.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { AbstractStruct, addStructToIdSet } from './AbstractStruct.js'
|
||||||
|
import { createID } from '../utils/ID.js'
|
||||||
|
|
||||||
|
export const structGCRefNumber = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export class GC extends AbstractStruct {
|
||||||
|
get deleted () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
delete () {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {GC | Skip | Item} right
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
mergeWith (right) {
|
||||||
|
if (this.constructor !== right.constructor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.length += right.length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {number} offset - @todo remove offset parameter
|
||||||
|
*/
|
||||||
|
integrate (transaction, offset) {
|
||||||
|
if (offset > 0) {
|
||||||
|
this.id.clock += offset
|
||||||
|
this.length -= offset
|
||||||
|
}
|
||||||
|
transaction.deleteSet.add(this.id.client, this.id.clock, this.length)
|
||||||
|
addStructToIdSet(transaction.insertSet, this)
|
||||||
|
transaction.doc.store.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {number} offset
|
||||||
|
* @param {number} offsetEnd
|
||||||
|
*/
|
||||||
|
write (encoder, offset, offsetEnd) {
|
||||||
|
encoder.writeInfo(structGCRefNumber)
|
||||||
|
encoder.writeLen(this.length - offset - offsetEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gc structs can't be spliced.
|
||||||
|
*
|
||||||
|
* If this feature is required in the future, then need to try to merge this struct after
|
||||||
|
* transaction.
|
||||||
|
*
|
||||||
|
* @param {number} diff
|
||||||
|
*/
|
||||||
|
splice (diff) {
|
||||||
|
const other = new GC(createID(this.id.client, this.id.clock + diff), this.length - diff)
|
||||||
|
this.length = diff
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {0}
|
||||||
|
*/
|
||||||
|
GC.prototype.ref = structGCRefNumber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {false}
|
||||||
|
*/
|
||||||
|
GC.prototype.isItem = false
|
||||||
1517
src/structs/Item.js
Normal file
1517
src/structs/Item.js
Normal file
File diff suppressed because it is too large
Load Diff
72
src/structs/Skip.js
Normal file
72
src/structs/Skip.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
|
||||||
|
import { AbstractStruct } from './AbstractStruct.js'
|
||||||
|
import { createID } from '../utils/ID.js'
|
||||||
|
|
||||||
|
export const structSkipRefNumber = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export class Skip extends AbstractStruct {
|
||||||
|
get deleted () {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
delete () {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Skip} right
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
mergeWith (right) {
|
||||||
|
if (this.constructor !== right.constructor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.length += right.length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {number} offset
|
||||||
|
*/
|
||||||
|
integrate (transaction, offset) {
|
||||||
|
if (offset > 0) {
|
||||||
|
this.id.clock += offset
|
||||||
|
this.length -= offset
|
||||||
|
}
|
||||||
|
const store = transaction.doc.store
|
||||||
|
store.skips.add(this.id.client, this.id.clock, this.length)
|
||||||
|
store.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {number} offset
|
||||||
|
*/
|
||||||
|
write (encoder, offset) {
|
||||||
|
encoder.writeInfo(structSkipRefNumber)
|
||||||
|
// write as VarUint because Skips can't make use of predictable length-encoding
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, this.length - offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} diff
|
||||||
|
*/
|
||||||
|
splice (diff) {
|
||||||
|
const other = new Skip(createID(this.id.client, this.id.clock + diff), this.length - diff)
|
||||||
|
this.length = diff
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {10}
|
||||||
|
*/
|
||||||
|
Skip.prototype.ref = structSkipRefNumber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {false}
|
||||||
|
*/
|
||||||
|
Skip.prototype.isItem = false
|
||||||
291
src/utils/BlockSet.js
Normal file
291
src/utils/BlockSet.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as binary from 'lib0/binary'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as number from 'lib0/number'
|
||||||
|
|
||||||
|
import { createID, ID } from './ID.js'
|
||||||
|
import { Item } from '../structs/Item.js'
|
||||||
|
import { readItemContent } from '../ynode.js'
|
||||||
|
import { findIndexCleanStart } from './transaction-helpers.js'
|
||||||
|
import { Skip } from '../structs/Skip.js'
|
||||||
|
import { createIdSet, IdRange } from './ids.js'
|
||||||
|
import { sliceStruct } from './updates.js'
|
||||||
|
import { GC } from '../structs/GC.js'
|
||||||
|
import { writeStructs } from './encoding-helpers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
|
||||||
|
* @return {BlockSet}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readBlockSet = (decoder) => {
|
||||||
|
const clientRefs = new BlockSet()
|
||||||
|
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||||
|
const numberOfBlocks = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
/**
|
||||||
|
* @type {Array<GC|Item|Skip>}
|
||||||
|
*/
|
||||||
|
const refs = new Array(numberOfBlocks)
|
||||||
|
const client = decoder.readClient()
|
||||||
|
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
clientRefs.clients.set(client, new BlockRange(refs))
|
||||||
|
for (let i = 0; i < numberOfBlocks; i++) {
|
||||||
|
const info = decoder.readInfo()
|
||||||
|
switch (binary.BITS5 & info) {
|
||||||
|
case 0: { // GC
|
||||||
|
const len = decoder.readLen()
|
||||||
|
refs[i] = new GC(createID(client, clock), len)
|
||||||
|
clock += len
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 10: { // Skip Block (nothing to apply)
|
||||||
|
// @todo we could reduce the amount of checks by adding Skip block to clientRefs so we know that something is missing.
|
||||||
|
const len = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
refs[i] = new Skip(createID(client, clock), len)
|
||||||
|
clock += len
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: { // Item with content
|
||||||
|
/**
|
||||||
|
* The optimized implementation doesn't use any variables because inlining variables is faster.
|
||||||
|
* Below a non-optimized version is shown that implements the basic algorithm with
|
||||||
|
* a few comments
|
||||||
|
*/
|
||||||
|
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||||
|
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||||
|
// and we read the next string as parentYKey.
|
||||||
|
// It indicates how we store/retrieve parent from `y.share`
|
||||||
|
// @type {string|null}
|
||||||
|
const block = new Item(
|
||||||
|
createID(client, clock),
|
||||||
|
null, // left
|
||||||
|
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||||
|
null, // right
|
||||||
|
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||||
|
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
|
||||||
|
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||||
|
readItemContent(decoder, info) // item content
|
||||||
|
)
|
||||||
|
refs[i] = block
|
||||||
|
clock += block.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clientRefs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {BlockSet} blocks
|
||||||
|
*/
|
||||||
|
export const writeBlockSet = (encoder, blocks) => {
|
||||||
|
// write # states that were updated
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, blocks.clients.size)
|
||||||
|
// Write items with higher client ids first
|
||||||
|
// This heavily improves the conflict algorithm.
|
||||||
|
array.from(blocks.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, blockrange]) => {
|
||||||
|
writeStructs(encoder, blockrange.refs, client, [new IdRange(0, number.MAX_SAFE_INTEGER)])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockRange {
|
||||||
|
/**
|
||||||
|
* @param {Array<Item|GC|Skip>} refs
|
||||||
|
*/
|
||||||
|
constructor (refs) {
|
||||||
|
this.i = 0
|
||||||
|
/**
|
||||||
|
* @type {Array<Item | GC | Skip>}
|
||||||
|
*/
|
||||||
|
this.refs = refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlockSet {
|
||||||
|
constructor () {
|
||||||
|
/**
|
||||||
|
* @type {Map<number, BlockRange>}
|
||||||
|
*/
|
||||||
|
this.clients = map.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
toIdSet () {
|
||||||
|
const inserts = createIdSet()
|
||||||
|
this.clients.forEach((ranges, clientid) => {
|
||||||
|
let lastClock = 0
|
||||||
|
let lastLen = 0
|
||||||
|
ranges.refs.forEach(block => {
|
||||||
|
if (block instanceof Skip) return
|
||||||
|
if (lastClock + lastLen === block.id.clock) {
|
||||||
|
// default case: extend prev entry
|
||||||
|
lastLen += block.length
|
||||||
|
} else {
|
||||||
|
lastLen > 0 && inserts.add(clientid, lastClock, lastLen)
|
||||||
|
lastClock = block.id.clock
|
||||||
|
lastLen = block.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
inserts.add(clientid, lastClock, lastLen)
|
||||||
|
})
|
||||||
|
return inserts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove id-ranges from update - convert them to skip if applicable.
|
||||||
|
*
|
||||||
|
* @param {IdSet} exclude
|
||||||
|
*/
|
||||||
|
exclude (exclude) {
|
||||||
|
const clientids = this.clients.size < exclude.clients.size ? this.clients.keys() : exclude.clients.keys()
|
||||||
|
for (const client of clientids) {
|
||||||
|
const range = exclude.clients.get(client)
|
||||||
|
const structs = this.clients.get(client)?.refs
|
||||||
|
if (range == null || structs == null) return
|
||||||
|
const firstStruct = structs[0]
|
||||||
|
const lastStruct = structs[structs.length - 1]
|
||||||
|
const idranges = range.getIds()
|
||||||
|
for (let i = 0; i < idranges.length; i++) {
|
||||||
|
const range = idranges[i]
|
||||||
|
let startIndex = 0
|
||||||
|
if (range.clock >= lastStruct.id.clock + lastStruct.length) continue
|
||||||
|
if (range.clock > firstStruct.id.clock) {
|
||||||
|
startIndex = findIndexCleanStart(null, structs, range.clock)
|
||||||
|
}
|
||||||
|
let endIndex = structs.length // must be set here, after structs is modified
|
||||||
|
if (range.clock + range.len <= firstStruct.id.clock) continue
|
||||||
|
if (range.clock + range.len < lastStruct.id.clock + lastStruct.length) {
|
||||||
|
endIndex = findIndexCleanStart(null, structs, range.clock + range.len)
|
||||||
|
}
|
||||||
|
if (startIndex < endIndex) {
|
||||||
|
structs[startIndex] = new Skip(new ID(client, range.clock), range.len)
|
||||||
|
const d = endIndex - startIndex
|
||||||
|
if (d > 1) {
|
||||||
|
structs.splice(startIndex + 1, d - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BlockSet} inserts
|
||||||
|
*/
|
||||||
|
insertInto (inserts) {
|
||||||
|
inserts.clients.forEach((newranges, clientid) => {
|
||||||
|
const ranges = this.clients.get(clientid)
|
||||||
|
if (ranges == null) {
|
||||||
|
this.clients.set(clientid, newranges)
|
||||||
|
} else {
|
||||||
|
const localIsLeft = ranges.refs[0].id.clock < newranges.refs[0].id.clock
|
||||||
|
const leftRanges = (localIsLeft ? ranges : newranges).refs
|
||||||
|
const rightRanges = (localIsLeft ? newranges : ranges).refs
|
||||||
|
const lastBlockLeft = array.last(leftRanges)
|
||||||
|
const firstBlockRight = rightRanges[0]
|
||||||
|
const gapSize = firstBlockRight.id.clock - lastBlockLeft.id.clock - lastBlockLeft.length
|
||||||
|
if (gapSize >= 0) {
|
||||||
|
// we can do a simple efficient merge
|
||||||
|
if (gapSize > 0) {
|
||||||
|
leftRanges.push(new Skip(new ID(clientid, lastBlockLeft.id.clock + lastBlockLeft.length), gapSize))
|
||||||
|
}
|
||||||
|
for (let i = 0; i < rightRanges.length; i++) {
|
||||||
|
leftRanges.push(rightRanges[i])
|
||||||
|
}
|
||||||
|
ranges.refs = leftRanges
|
||||||
|
} else {
|
||||||
|
// requires more computation because we need to filter duplicates
|
||||||
|
/**
|
||||||
|
* @type {Array<GC|Item|Skip>}
|
||||||
|
*/
|
||||||
|
const result = []
|
||||||
|
let nextExpectedClock = leftRanges[0].id.clock
|
||||||
|
/**
|
||||||
|
* @param {Item|GC|Skip} block
|
||||||
|
*/
|
||||||
|
const addToResult = block => {
|
||||||
|
result.push(block)
|
||||||
|
nextExpectedClock = block.id.clock + block.length
|
||||||
|
}
|
||||||
|
let li = 0
|
||||||
|
let ri = 0
|
||||||
|
/**
|
||||||
|
* @type {Item|GC|Skip|undefined}
|
||||||
|
*/
|
||||||
|
let lblock = leftRanges[li]
|
||||||
|
/**
|
||||||
|
* @type {Item|GC|Skip|undefined}
|
||||||
|
*/
|
||||||
|
let rblock = rightRanges[ri]
|
||||||
|
const applyLeft = () => {
|
||||||
|
if (lblock === undefined) return
|
||||||
|
// first try to consume left
|
||||||
|
// left: filter skips and known ops
|
||||||
|
while (lblock !== undefined && (lblock.constructor === Skip || lblock.id.clock + lblock.length <= nextExpectedClock)) {
|
||||||
|
lblock = leftRanges[++li]
|
||||||
|
}
|
||||||
|
// left: trim first op
|
||||||
|
if (lblock !== undefined && lblock.id.clock < nextExpectedClock && lblock.id.clock + lblock.length > nextExpectedClock) {
|
||||||
|
lblock = sliceStruct(lblock, lblock.id.clock + lblock.length - nextExpectedClock)
|
||||||
|
}
|
||||||
|
// left: add to result
|
||||||
|
while (lblock !== undefined && lblock.id.clock === nextExpectedClock && lblock.constructor !== Skip) {
|
||||||
|
addToResult(lblock)
|
||||||
|
lblock = leftRanges[++li]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const applyRight = () => {
|
||||||
|
// right: filter skips and known ops
|
||||||
|
while (rblock !== undefined && (rblock.constructor === Skip || rblock.id.clock + rblock.length <= nextExpectedClock)) {
|
||||||
|
rblock = rightRanges[++ri]
|
||||||
|
}
|
||||||
|
// right: trim first op
|
||||||
|
if (rblock !== undefined && rblock.id.clock < nextExpectedClock && rblock.id.clock + rblock.length > nextExpectedClock) {
|
||||||
|
rblock = sliceStruct(rblock, rblock.id.clock + rblock.length - nextExpectedClock)
|
||||||
|
}
|
||||||
|
// right: add to result
|
||||||
|
while (rblock !== undefined && rblock.id.clock === nextExpectedClock && rblock.constructor !== Skip) {
|
||||||
|
addToResult(rblock)
|
||||||
|
rblock = rightRanges[++ri]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (; li < leftRanges.length && ri < rightRanges.length;) {
|
||||||
|
applyLeft()
|
||||||
|
applyRight()
|
||||||
|
// add skip if necessary
|
||||||
|
const minNextClock = math.min(lblock?.id.clock || 0, rblock?.id.clock || 0)
|
||||||
|
const gapSize = minNextClock - nextExpectedClock
|
||||||
|
if (gapSize > 0) {
|
||||||
|
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (li < leftRanges.length) {
|
||||||
|
applyLeft()
|
||||||
|
if (lblock !== undefined) {
|
||||||
|
const gapSize = lblock.id.clock - nextExpectedClock
|
||||||
|
if (gapSize > 0) {
|
||||||
|
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (ri < rightRanges.length) {
|
||||||
|
applyRight()
|
||||||
|
if (rblock !== undefined) {
|
||||||
|
const gapSize = rblock.id.clock - nextExpectedClock
|
||||||
|
if (gapSize > 0) {
|
||||||
|
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.refs = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
inserts.clients.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
255
src/utils/Doc.js
Normal file
255
src/utils/Doc.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* @module Y
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
import * as random from 'lib0/random'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
import * as promise from 'lib0/promise'
|
||||||
|
|
||||||
|
import { StructStore } from './StructStore.js'
|
||||||
|
import { transact, generateNewClientId } from './Transaction.js'
|
||||||
|
import { YNode } from '../ynode.js'
|
||||||
|
import { $doc } from './schemas.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DocOpts
|
||||||
|
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
|
||||||
|
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
|
||||||
|
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
|
||||||
|
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
|
||||||
|
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
|
||||||
|
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
|
||||||
|
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
|
||||||
|
* @property {boolean} [DocOpts.isSuggestionDoc] Set to true if this document merely suggests
|
||||||
|
* changes. If this flag is not set in a suggestion document, automatic formatting changes will be
|
||||||
|
* displayed as suggestions, which might not be intended.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DocEvents
|
||||||
|
* @property {function(Doc):void} DocEvents.destroy
|
||||||
|
* @property {function(Doc):void} DocEvents.load
|
||||||
|
* @property {function(boolean, Doc):void} DocEvents.sync
|
||||||
|
* @property {function(Uint8Array<ArrayBuffer>, any, Doc, Transaction):void} DocEvents.update
|
||||||
|
* @property {function(Uint8Array<ArrayBuffer>, any, Doc, Transaction):void} DocEvents.updateV2
|
||||||
|
* @property {function(Doc):void} DocEvents.beforeAllTransactions
|
||||||
|
* @property {function(Transaction, Doc):void} DocEvents.beforeTransaction
|
||||||
|
* @property {function(Transaction, Doc):void} DocEvents.beforeObserverCalls
|
||||||
|
* @property {function(Transaction, Doc):void} DocEvents.afterTransaction
|
||||||
|
* @property {function(Transaction, Doc):void} DocEvents.afterTransactionCleanup
|
||||||
|
* @property {function(Doc, Array<Transaction>):void} DocEvents.afterAllTransactions
|
||||||
|
* @property {function({ loaded: Set<Doc>, added: Set<Doc>, removed: Set<Doc> }, Doc, Transaction):void} DocEvents.subdocs
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Yjs instance handles the state of shared data.
|
||||||
|
* @extends ObservableV2<DocEvents>
|
||||||
|
*/
|
||||||
|
export class Doc extends ObservableV2 {
|
||||||
|
/**
|
||||||
|
* @param {DocOpts} opts configuration
|
||||||
|
*/
|
||||||
|
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true, isSuggestionDoc = false } = {}) {
|
||||||
|
super()
|
||||||
|
this.gc = gc
|
||||||
|
this.gcFilter = gcFilter
|
||||||
|
this.clientID = generateNewClientId()
|
||||||
|
this.guid = guid
|
||||||
|
this.collectionid = collectionid
|
||||||
|
this.isSuggestionDoc = isSuggestionDoc
|
||||||
|
this.cleanupFormatting = !isSuggestionDoc
|
||||||
|
/**
|
||||||
|
* @type {Map<string, YNode>}
|
||||||
|
*/
|
||||||
|
this.share = new Map()
|
||||||
|
this.store = new StructStore()
|
||||||
|
/**
|
||||||
|
* @type {Transaction | null}
|
||||||
|
*/
|
||||||
|
this._transaction = null
|
||||||
|
/**
|
||||||
|
* @type {Array<Transaction>}
|
||||||
|
*/
|
||||||
|
this._transactionCleanups = []
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocs = new Set()
|
||||||
|
/**
|
||||||
|
* If this document is a subdocument - a document integrated into another document - then _item is defined.
|
||||||
|
* @type {Item?}
|
||||||
|
*/
|
||||||
|
this._item = null
|
||||||
|
this.shouldLoad = shouldLoad
|
||||||
|
this.autoLoad = autoLoad
|
||||||
|
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 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
|
||||||
|
this.isDestroyed = false
|
||||||
|
/**
|
||||||
|
* Promise that resolves once the document has been loaded from a persistence provider.
|
||||||
|
*/
|
||||||
|
this.whenLoaded = promise.create(resolve => {
|
||||||
|
this.on('load', () => {
|
||||||
|
this.isLoaded = true
|
||||||
|
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.isSynced && !this.isLoaded) {
|
||||||
|
this.emit('load', [this])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the parent document that you request to load data into this subdocument (if it is a subdocument).
|
||||||
|
*
|
||||||
|
* `load()` might be used in the future to request any provider to load the most current data.
|
||||||
|
*
|
||||||
|
* It is safe to call `load()` multiple times.
|
||||||
|
*/
|
||||||
|
load () {
|
||||||
|
const item = this._item
|
||||||
|
if (item !== null && !this.shouldLoad) {
|
||||||
|
transact(/** @type {any} */ (item.parent).doc, transaction => {
|
||||||
|
transaction.subdocsLoaded.add(this)
|
||||||
|
}, null, true)
|
||||||
|
}
|
||||||
|
this.shouldLoad = true
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubdocs () {
|
||||||
|
return this.subdocs
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubdocGuids () {
|
||||||
|
return new Set(array.from(this.subdocs).map(doc => doc.guid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes that happen inside of a transaction are bundled. This means that
|
||||||
|
* the observer fires _after_ the transaction is finished and that all changes
|
||||||
|
* that happened inside of the transaction are sent as one message to the
|
||||||
|
* other peers.
|
||||||
|
*
|
||||||
|
* @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 {boolean} [local]
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
transact (f, origin = null, local = true) {
|
||||||
|
return transact(this, f, origin, local)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a shared data type.
|
||||||
|
*
|
||||||
|
* Multiple calls of `ydoc.get(name)` yield the same result
|
||||||
|
* and do not overwrite each other. I.e.
|
||||||
|
* `ydoc.get(name) === ydoc.get(name)`
|
||||||
|
*
|
||||||
|
* After this method is called, the type is also available on `ydoc.share.get(name)`.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string?} name Type-name
|
||||||
|
*
|
||||||
|
* @return {YNode}
|
||||||
|
*/
|
||||||
|
get (key = '', name = null) {
|
||||||
|
return map.setIfUndefined(this.share, key, () => {
|
||||||
|
const t = new YNode(name)
|
||||||
|
t._integrate(this, null)
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the entire document into a js object, recursively traversing each yjs type
|
||||||
|
* Doesn't log types that have not been defined (using ydoc.getType(..)).
|
||||||
|
*
|
||||||
|
* @deprecated Do not use this method and rather call toJSON directly on the shared types.
|
||||||
|
*
|
||||||
|
* @return {Object<string, any>}
|
||||||
|
*/
|
||||||
|
toJSON () {
|
||||||
|
/**
|
||||||
|
* @type {Object<string, any>}
|
||||||
|
*/
|
||||||
|
const doc = {}
|
||||||
|
this.share.forEach((value, key) => {
|
||||||
|
doc[key] = value.toJSON()
|
||||||
|
})
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit `destroy` event and unregister all event handlers.
|
||||||
|
*/
|
||||||
|
destroy () {
|
||||||
|
this.isDestroyed = true
|
||||||
|
// Tear down the top-level shared types as RDTs (emits their `'destroy'` event so any bindings
|
||||||
|
// unsubscribe). Only top-level types are destroyed here; nested child types are not recursed.
|
||||||
|
this.share.forEach(type => type.destroy())
|
||||||
|
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
|
||||||
|
const item = this._item
|
||||||
|
if (item !== null) {
|
||||||
|
this._item = null
|
||||||
|
const content = /** @type {ContentDoc} */ (item.content)
|
||||||
|
/**
|
||||||
|
* new content doc which replaces the new one
|
||||||
|
*/
|
||||||
|
const contentDoc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
|
||||||
|
content.doc = contentDoc
|
||||||
|
contentDoc._item = item
|
||||||
|
transact(/** @type {any} */ (item).parent.doc, transaction => {
|
||||||
|
if (!item.deleted) {
|
||||||
|
transaction.subdocsAdded.add(contentDoc)
|
||||||
|
}
|
||||||
|
transaction.subdocsRemoved.add(this)
|
||||||
|
}, null, true)
|
||||||
|
}
|
||||||
|
this.emit('destroy', [this])
|
||||||
|
super.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Doc.prototype.$type = $doc
|
||||||
87
src/utils/EventHandler.js
Normal file
87
src/utils/EventHandler.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as f from 'lib0/function'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General event handler implementation.
|
||||||
|
*
|
||||||
|
* @template ARG0, ARG1
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export class EventHandler {
|
||||||
|
constructor () {
|
||||||
|
/**
|
||||||
|
* @type {Array<function(ARG0, ARG1):void>}
|
||||||
|
*/
|
||||||
|
this.l = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template ARG0,ARG1
|
||||||
|
* @returns {EventHandler<ARG0,ARG1>}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createEventHandler = () => new EventHandler()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an event listener that is called when
|
||||||
|
* {@link EventHandler#callEventListeners} is called.
|
||||||
|
*
|
||||||
|
* @template ARG0,ARG1
|
||||||
|
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||||
|
* @param {function(ARG0,ARG1):void} f The event handler.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const addEventHandlerListener = (eventHandler, f) =>
|
||||||
|
eventHandler.l.push(f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an event listener.
|
||||||
|
*
|
||||||
|
* @template ARG0,ARG1
|
||||||
|
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||||
|
* @param {function(ARG0,ARG1):void} f The event handler that was added with
|
||||||
|
* {@link EventHandler#addEventListener}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const removeEventHandlerListener = (eventHandler, f) => {
|
||||||
|
const l = eventHandler.l
|
||||||
|
const len = l.length
|
||||||
|
eventHandler.l = l.filter(g => f !== g)
|
||||||
|
if (len === eventHandler.l.length) {
|
||||||
|
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all event listeners.
|
||||||
|
* @template ARG0,ARG1
|
||||||
|
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const removeAllEventHandlerListeners = eventHandler => {
|
||||||
|
eventHandler.l.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call all event listeners that were added via
|
||||||
|
* {@link EventHandler#addEventListener}.
|
||||||
|
*
|
||||||
|
* @template ARG0,ARG1
|
||||||
|
* @param {EventHandler<ARG0,ARG1>} eventHandler
|
||||||
|
* @param {ARG0} arg0
|
||||||
|
* @param {ARG1} arg1
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const callEventHandlerListeners = (eventHandler, arg0, arg1) =>
|
||||||
|
f.callAll(eventHandler.l, [arg0, arg1])
|
||||||
87
src/utils/ID.js
Normal file
87
src/utils/ID.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as error from 'lib0/error'
|
||||||
|
|
||||||
|
export class ID {
|
||||||
|
/**
|
||||||
|
* @param {number} client client id
|
||||||
|
* @param {number} clock unique per client id, continuous number
|
||||||
|
*/
|
||||||
|
constructor (client, clock) {
|
||||||
|
/**
|
||||||
|
* Client id
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.client = client
|
||||||
|
/**
|
||||||
|
* unique per client id, continuous number
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.clock = clock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID | null} a
|
||||||
|
* @param {ID | null} b
|
||||||
|
* @return {boolean}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const compareIDs = (a, b) => a === b || (a !== null && b !== null && a.client === b.client && a.clock === b.clock)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createID = (client, clock) => new ID(client, clock)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {encoding.Encoder} encoder
|
||||||
|
* @param {ID} id
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeID = (encoder, id) => {
|
||||||
|
encoding.writeVarUint(encoder, id.client)
|
||||||
|
encoding.writeVarUint(encoder, id.clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ID.
|
||||||
|
* * If first varUint read is 0xFFFFFF a RootID is returned.
|
||||||
|
* * Otherwise an ID is returned
|
||||||
|
*
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
* @return {ID}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readID = decoder =>
|
||||||
|
createID(decoding.readVarUint(decoder), decoding.readVarUint(decoder))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top types are mapped from y.share.get(keyname) => type.
|
||||||
|
* `type` does not store any information about the `keyname`.
|
||||||
|
* This function finds the correct `keyname` for `type` and throws otherwise.
|
||||||
|
*
|
||||||
|
* @param {YNode<any>} type
|
||||||
|
* @return {string}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const findRootTypeKey = type => {
|
||||||
|
// @ts-ignore _y must be defined, otherwise unexpected case
|
||||||
|
for (const [key, value] of type.doc.share.entries()) {
|
||||||
|
if (value === type) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error.unexpectedCase()
|
||||||
|
}
|
||||||
344
src/utils/RelativePosition.js
Normal file
344
src/utils/RelativePosition.js
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as error from 'lib0/error'
|
||||||
|
|
||||||
|
import { Item, followRedone, ContentType } from '../structs/Item.js'
|
||||||
|
import { writeID, readID, compareIDs, findRootTypeKey, createID } from './ID.js'
|
||||||
|
import { rendererContentLength } from './renderer-helpers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A relative position is based on the Yjs model and is not affected by document changes.
|
||||||
|
* E.g. If you place a relative position before a certain character, it will always point to this character.
|
||||||
|
* If you place a relative position at the end of a type, it will always point to the end of the type.
|
||||||
|
*
|
||||||
|
* A numeric position is often unsuited for user selections, because it does not change when content is inserted
|
||||||
|
* before or after.
|
||||||
|
*
|
||||||
|
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the relative position.
|
||||||
|
*
|
||||||
|
* One of the properties must be defined.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Current cursor position is at position 10
|
||||||
|
* const relativePosition = createRelativePositionFromIndex(yText, 10)
|
||||||
|
* // modify yText
|
||||||
|
* yText.insert(0, 'abc')
|
||||||
|
* yText.delete(3, 10)
|
||||||
|
* // Compute the cursor position
|
||||||
|
* const absolutePosition = createAbsolutePositionFromRelativePosition(y, relativePosition)
|
||||||
|
* absolutePosition.type === yText // => true
|
||||||
|
* console.log('cursor location is ' + absolutePosition.index) // => cursor location is 3
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class RelativePosition {
|
||||||
|
/**
|
||||||
|
* @param {ID|null} type
|
||||||
|
* @param {string|null} tname
|
||||||
|
* @param {ID|null} item
|
||||||
|
* @param {number} assoc
|
||||||
|
*/
|
||||||
|
constructor (type, tname, item, assoc = 0) {
|
||||||
|
/**
|
||||||
|
* @type {ID|null}
|
||||||
|
*/
|
||||||
|
this.type = type
|
||||||
|
/**
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
this.tname = tname
|
||||||
|
/**
|
||||||
|
* @type {ID | null}
|
||||||
|
*/
|
||||||
|
this.item = item
|
||||||
|
/**
|
||||||
|
* A relative position is associated to a specific character. By default
|
||||||
|
* assoc >= 0, the relative position is associated to the character
|
||||||
|
* after the meant position.
|
||||||
|
* I.e. position 1 in 'ab' is associated to character 'b'.
|
||||||
|
*
|
||||||
|
* If assoc < 0, then the relative position is associated to the character
|
||||||
|
* before the meant position.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.assoc = assoc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {RelativePosition} rpos
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
export const relativePositionToJSON = rpos => {
|
||||||
|
const json = {}
|
||||||
|
if (rpos.type) {
|
||||||
|
json.type = rpos.type
|
||||||
|
}
|
||||||
|
if (rpos.tname) {
|
||||||
|
json.tname = rpos.tname
|
||||||
|
}
|
||||||
|
if (rpos.item) {
|
||||||
|
json.item = rpos.item
|
||||||
|
}
|
||||||
|
if (rpos.assoc != null) {
|
||||||
|
json.assoc = rpos.assoc
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @return {RelativePosition}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname ?? null, json.item == null ? null : createID(json.item.client, json.item.clock), json.assoc == null ? 0 : json.assoc)
|
||||||
|
|
||||||
|
export class AbsolutePosition {
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>} type
|
||||||
|
* @param {number} index
|
||||||
|
* @param {number} [assoc]
|
||||||
|
*/
|
||||||
|
constructor (type, index, assoc = 0) {
|
||||||
|
/**
|
||||||
|
* @type {YNode<any>}
|
||||||
|
*/
|
||||||
|
this.type = type
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.index = index
|
||||||
|
this.assoc = assoc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>} type
|
||||||
|
* @param {number} index
|
||||||
|
* @param {number} [assoc]
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>} type
|
||||||
|
* @param {ID|null} item
|
||||||
|
* @param {number} [assoc]
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createRelativePosition = (type, item, assoc) => {
|
||||||
|
let typeid = null
|
||||||
|
let tname = null
|
||||||
|
if (type._item === null) {
|
||||||
|
tname = findRootTypeKey(type)
|
||||||
|
} else {
|
||||||
|
typeid = createID(type._item.id.client, type._item.id.clock)
|
||||||
|
}
|
||||||
|
return new RelativePosition(typeid, tname, item, assoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a relativePosition based on a absolute position.
|
||||||
|
*
|
||||||
|
* @param {YNode} type The base type (e.g. YText or YArray).
|
||||||
|
* @param {number} index The absolute position.
|
||||||
|
* @param {number} [assoc]
|
||||||
|
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
|
||||||
|
* @return {RelativePosition}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0, renderer = null) => {
|
||||||
|
let t = type._start
|
||||||
|
if (assoc < 0) {
|
||||||
|
// associated to the left character or the beginning of a type, increment index if possible.
|
||||||
|
if (index === 0) {
|
||||||
|
return createRelativePosition(type, null, assoc)
|
||||||
|
}
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
while (t !== null) {
|
||||||
|
const len = rendererContentLength(renderer, t)
|
||||||
|
if (len > index) {
|
||||||
|
// case 1: found position somewhere in the linked list
|
||||||
|
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
|
||||||
|
}
|
||||||
|
index -= len
|
||||||
|
if (t.right === null && assoc < 0) {
|
||||||
|
// left-associated position, return last available id
|
||||||
|
return createRelativePosition(type, t.lastId, assoc)
|
||||||
|
}
|
||||||
|
t = t.right
|
||||||
|
}
|
||||||
|
return createRelativePosition(type, null, assoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {encoding.Encoder} encoder
|
||||||
|
* @param {RelativePosition} rpos
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeRelativePosition = (encoder, rpos) => {
|
||||||
|
const { type, tname, item, assoc } = rpos
|
||||||
|
if (item !== null) {
|
||||||
|
encoding.writeVarUint(encoder, 0)
|
||||||
|
writeID(encoder, item)
|
||||||
|
} else if (tname !== null) {
|
||||||
|
// case 2: found position at the end of the list and type is stored in y.share
|
||||||
|
encoding.writeUint8(encoder, 1)
|
||||||
|
encoding.writeVarString(encoder, tname)
|
||||||
|
} else if (type !== null) {
|
||||||
|
// case 3: found position at the end of the list and type is attached to an item
|
||||||
|
encoding.writeUint8(encoder, 2)
|
||||||
|
writeID(encoder, type)
|
||||||
|
} else {
|
||||||
|
throw error.unexpectedCase()
|
||||||
|
}
|
||||||
|
encoding.writeVarInt(encoder, assoc)
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {RelativePosition} rpos
|
||||||
|
* @return {Uint8Array}
|
||||||
|
*/
|
||||||
|
export const encodeRelativePosition = rpos => {
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
writeRelativePosition(encoder, rpos)
|
||||||
|
return encoding.toUint8Array(encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
* @return {RelativePosition}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readRelativePosition = decoder => {
|
||||||
|
let type = null
|
||||||
|
let tname = null
|
||||||
|
let itemID = null
|
||||||
|
switch (decoding.readVarUint(decoder)) {
|
||||||
|
case 0:
|
||||||
|
// case 1: found position somewhere in the linked list
|
||||||
|
itemID = readID(decoder)
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
// case 2: found position at the end of the list and type is stored in y.share
|
||||||
|
tname = decoding.readVarString(decoder)
|
||||||
|
break
|
||||||
|
case 2: {
|
||||||
|
// case 3: found position at the end of the list and type is attached to an item
|
||||||
|
type = readID(decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const assoc = decoding.hasContent(decoder) ? decoding.readVarInt(decoder) : 0
|
||||||
|
return new RelativePosition(type, tname, itemID, assoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} uint8Array
|
||||||
|
* @return {RelativePosition}
|
||||||
|
*/
|
||||||
|
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
const getItemWithOffset = (store, id) => {
|
||||||
|
const item = store.getItem(id)
|
||||||
|
const diff = id.clock - item.id.clock
|
||||||
|
return {
|
||||||
|
item, diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a relative position to an absolute position.
|
||||||
|
*
|
||||||
|
* If you want to share the relative position with other users, you should set
|
||||||
|
* `followUndoneDeletions` to false to get consistent results across all clients.
|
||||||
|
*
|
||||||
|
* When calculating the absolute position, we try to follow the "undone deletions". This yields
|
||||||
|
* better results for the user who performed undo. However, only the user who performed the undo
|
||||||
|
* will get the better results, the other users don't know which operations recreated a deleted
|
||||||
|
* range of content. There is more information in this ticket: https://github.com/yjs/yjs/issues/638
|
||||||
|
*
|
||||||
|
* @param {RelativePosition} rpos
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {boolean} followUndoneDeletions - whether to follow undone deletions - see https://github.com/yjs/yjs/issues/638
|
||||||
|
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
|
||||||
|
* @return {AbsolutePosition|null}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true, renderer = null) => {
|
||||||
|
const store = doc.store
|
||||||
|
const rightID = rpos.item
|
||||||
|
const typeID = rpos.type
|
||||||
|
const tname = rpos.tname
|
||||||
|
const assoc = rpos.assoc
|
||||||
|
let type = null
|
||||||
|
let index = 0
|
||||||
|
if (rightID !== null) {
|
||||||
|
if (store.getClock(rightID.client) <= rightID.clock) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const res = followUndoneDeletions ? followRedone(store, rightID) : getItemWithOffset(store, rightID)
|
||||||
|
const right = res.item
|
||||||
|
if (!(right instanceof Item)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
type = /** @type {YNode<any>} */ (right.parent)
|
||||||
|
// an index into a deleted type is meaningless - unless a renderer still renders the type
|
||||||
|
// (e.g. a deleted-but-rendered suggestion-mode subtree)
|
||||||
|
if (type._item === null || !type._item.deleted || rendererContentLength(renderer, type._item) > 0) {
|
||||||
|
index = rendererContentLength(renderer, right) === 0 ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
|
||||||
|
let n = right.left
|
||||||
|
while (n !== null) {
|
||||||
|
index += rendererContentLength(renderer, n)
|
||||||
|
n = n.left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tname !== null) {
|
||||||
|
type = doc.get(tname)
|
||||||
|
} else if (typeID !== null) {
|
||||||
|
if (store.getClock(typeID.client) <= typeID.clock) {
|
||||||
|
// type does not exist yet
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { item } = followUndoneDeletions ? followRedone(store, typeID) : { item: store.getItem(typeID) }
|
||||||
|
if (item instanceof Item && item.content instanceof ContentType) {
|
||||||
|
type = item.content.type
|
||||||
|
} else {
|
||||||
|
// struct is garbage collected
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error.unexpectedCase()
|
||||||
|
}
|
||||||
|
if (assoc >= 0) {
|
||||||
|
index = type._length
|
||||||
|
} else {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createAbsolutePosition(type, index, rpos.assoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {RelativePosition|null} a
|
||||||
|
* @param {RelativePosition|null} b
|
||||||
|
* @return {boolean}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const compareRelativePositions = (a, b) => a === b || (
|
||||||
|
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc
|
||||||
|
)
|
||||||
702
src/utils/Renderer.js
Normal file
702
src/utils/Renderer.js
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
|
||||||
|
import { getItemCleanStart } from './transaction-helpers.js'
|
||||||
|
import { diffIdSet, createInsertSetFromStructStore, createDeleteSetFromStructStore, insertIntoIdSet, mergeIdSets, intersectSets, createIdSet, createIdSetFromIdMap, writeIdSet, createIdMapFromIdSet, insertIntoIdMap, diffIdMap, createIdMap, mergeIdMaps, intersectMaps, createMaybeAttrRange, createContentAttribute } from './ids.js'
|
||||||
|
import { ContentDeleted, ContentFormat, ContentType } from '../structs/Item.js'
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { writeStructsFromIdSet } from './encoding-helpers.js'
|
||||||
|
import { applyUpdate, encodeStateAsUpdate } from './encoding.js'
|
||||||
|
import { UpdateEncoderV1 } from './UpdateEncoder.js'
|
||||||
|
import { transact } from './Transaction.js'
|
||||||
|
import { UndoManager, StackItem } from './UndoManager.js'
|
||||||
|
|
||||||
|
import { $renderer, AttributedContent } from './renderer-helpers.js'
|
||||||
|
|
||||||
|
export { AbstractRenderer, rendererContentLength, $renderer } from './renderer-helpers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a single content piece into `contents`, applying the rendering rules of
|
||||||
|
* {@link AttributionsRenderer}. `inRC` is whether the piece is in `renderedContent` (renders normally,
|
||||||
|
* even when the item is deleted — a "restore"); `attrs` is its attribution (or `null`).
|
||||||
|
*
|
||||||
|
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||||
|
* @param {AbstractContent} c
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {boolean} deleted - the item's doc deleted flag
|
||||||
|
* @param {boolean} inRC - whether this piece is in `renderedContent`
|
||||||
|
* @param {Array<ContentAttribute<any>>|null} attrs
|
||||||
|
* @param {0|1|2|3} shouldRender - see {@link AbstractRenderer#readContent}
|
||||||
|
*/
|
||||||
|
const pushAttributedPiece = (contents, c, clock, deleted, inRC, attrs, shouldRender) => {
|
||||||
|
if (inRC || attrs != null) {
|
||||||
|
// Visible: rendered normally (in `renderedContent`) and/or attributed. A restored deletion
|
||||||
|
// (`inRC`) renders as alive, so its effective `deleted` flag is cleared.
|
||||||
|
contents.push(new AttributedContent(c, clock, deleted && !inRC, attrs, shouldRender))
|
||||||
|
} else if (deleted && shouldRender !== 0 && shouldRender !== 3) {
|
||||||
|
// Genuinely deleted, unattributed, not restored: preserve the legacy retain/delete-op
|
||||||
|
// behavior (mode 3 renders unattributed deleted content as nothing — see DiffRenderer).
|
||||||
|
contents.push(new AttributedContent(c, clock, true, null, shouldRender))
|
||||||
|
}
|
||||||
|
// else: hidden alive content (or invisible deleted content under mode 0/3) — emit nothing.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders content with attributions, given a single `attributions` {@link ContentMap} of how to
|
||||||
|
* attribute content and an optional `renderedContent` {@link IdSet} of what renders.
|
||||||
|
*
|
||||||
|
* - `attributions` (inserts ∪ deletes) is merged into a single `renderAs` map; content it covers
|
||||||
|
* always renders, carrying its attribution.
|
||||||
|
* - `renderedContent` defines the content that renders *normally* — even if the item is marked
|
||||||
|
* deleted in the doc (a "restore"). It defaults to the doc's alive content (`inserts − deletes`),
|
||||||
|
* applied implicitly (a piece is in the default set ⟺ its item is not `deleted`), so the common
|
||||||
|
* "pure attribution overlay" case does no extra work. When a custom set is supplied it is
|
||||||
|
* authoritative: alive content omitted from it (and unattributed) is hidden.
|
||||||
|
*
|
||||||
|
* Restoring deleted content requires `gc: false` on the doc (the renderer does not rehydrate
|
||||||
|
* garbage-collected content, unlike {@link DiffRenderer}).
|
||||||
|
*
|
||||||
|
* @implements AbstractRenderer
|
||||||
|
*
|
||||||
|
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||||
|
*/
|
||||||
|
export class AttributionsRenderer extends ObservableV2 {
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the
|
||||||
|
* doc's alive content (`inserts − deletes`), applied implicitly.
|
||||||
|
*/
|
||||||
|
constructor (attributions, { renderedContent = null } = {}) {
|
||||||
|
super()
|
||||||
|
/**
|
||||||
|
* The two attribution maps merged into one — `readContent` consults this for how to attribute
|
||||||
|
* a piece.
|
||||||
|
* @type {IdMap<any>}
|
||||||
|
*/
|
||||||
|
this.renderAs = mergeIdMaps([attributions.inserts, attributions.deletes])
|
||||||
|
/**
|
||||||
|
* Coverage of `renderAs` (ids that carry attributions). The small set `hasItem` checks. May
|
||||||
|
* over-approximate what actually renders — `readContent` remains authoritative. See
|
||||||
|
* {@link AbstractRenderer#attributed}.
|
||||||
|
* @type {IdSet}
|
||||||
|
*/
|
||||||
|
this.attributed = createIdSetFromIdMap(this.renderAs)
|
||||||
|
/**
|
||||||
|
* Custom "content that renders normally" set, or `null` to use the implicit default
|
||||||
|
* (alive ⟺ `!item.deleted`).
|
||||||
|
* @type {IdSet?}
|
||||||
|
*/
|
||||||
|
this.renderedContent = renderedContent
|
||||||
|
/**
|
||||||
|
* Union of `renderedContent` and `attributed` — the single cheap "does this produce visible
|
||||||
|
* output?" set. Only materialized when a custom `renderedContent` is supplied.
|
||||||
|
* @type {IdSet?}
|
||||||
|
*/
|
||||||
|
this.rendered = renderedContent === null ? null : mergeIdSets([renderedContent, this.attributed])
|
||||||
|
}
|
||||||
|
|
||||||
|
get $type () { return $renderer }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasItem (item) {
|
||||||
|
const { client, clock } = item.id
|
||||||
|
if (this.attributed.intersects(client, clock, item.length)) return true
|
||||||
|
if (this.renderedContent === null) return false
|
||||||
|
// Custom renderedContent differs from the generic fast path for: deleted content that must be
|
||||||
|
// restored, and alive content that must be hidden (not fully covered).
|
||||||
|
return item.deleted
|
||||||
|
? this.renderedContent.intersects(client, clock, item.length)
|
||||||
|
: !this.renderedContent.covers(client, clock, item.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {boolean} deleted
|
||||||
|
* @param {AbstractContent} content
|
||||||
|
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||||
|
*/
|
||||||
|
readContent (contents, client, clock, deleted, content, shouldRender) {
|
||||||
|
const total = content.getLength()
|
||||||
|
/**
|
||||||
|
* Segments of the item partitioned by `renderedContent`, or `null` when `inRC` is uniform over
|
||||||
|
* the whole item (the common case: default set, or a custom set that fully contains/excludes
|
||||||
|
* the item). `null` lets us slice `renderAs` once.
|
||||||
|
* @type {Array<import('./ids.js').MaybeIdRange>?}
|
||||||
|
*/
|
||||||
|
let outer = null
|
||||||
|
let inRC = !deleted // implicit default: alive ⟺ rendered normally
|
||||||
|
if (this.renderedContent !== null) {
|
||||||
|
outer = this.renderedContent.slice(client, clock, total)
|
||||||
|
if (outer.length === 1) {
|
||||||
|
inRC = outer[0].exists
|
||||||
|
outer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outer === null) {
|
||||||
|
// Uniform `inRC` — a single `renderAs` slice fixes the attribution boundaries.
|
||||||
|
const slice = this.renderAs.slice(client, clock, total)
|
||||||
|
let rest = slice.length === 1 ? content : content.copy()
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
const s = slice[i]
|
||||||
|
const c = rest
|
||||||
|
if (i < slice.length - 1) rest = c.splice(s.len)
|
||||||
|
pushAttributedPiece(contents, c, s.clock, deleted, inRC, s.attrs, shouldRender)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// General case: partition by `renderedContent` (outer, fixes `inRC`/effectiveDeleted), then by
|
||||||
|
// `renderAs` (inner, fixes attrs). Multiple outer segments ⇒ multi-id (string-like) content, so
|
||||||
|
// copying to avoid mutating the caller's content is safe.
|
||||||
|
let rest = content.copy()
|
||||||
|
for (let oi = 0; oi < outer.length; oi++) {
|
||||||
|
const rcSeg = outer[oi]
|
||||||
|
const slice = this.renderAs.slice(client, rcSeg.clock, rcSeg.len)
|
||||||
|
for (let ii = 0; ii < slice.length; ii++) {
|
||||||
|
const s = slice[ii]
|
||||||
|
const last = oi === outer.length - 1 && ii === slice.length - 1
|
||||||
|
const c = rest
|
||||||
|
if (!last) rest = c.splice(s.len)
|
||||||
|
pushAttributedPiece(contents, c, s.clock, deleted, rcSeg.exists, s.attrs, shouldRender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
contentLength (item) {
|
||||||
|
if (!item.content.isCountable()) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const { client, clock } = item.id
|
||||||
|
if (this.renderedContent === null) {
|
||||||
|
// Default: alive content renders in full; deleted content only where attributed.
|
||||||
|
return item.deleted ? this.renderAs.coveredLength(client, clock, item.length) : item.length
|
||||||
|
}
|
||||||
|
// Custom: rendered length is |item ∩ rendered| (renderedContent ∪ attributed).
|
||||||
|
return /** @type {IdSet} */ (this.rendered).coveredLength(client, clock, item.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the doc's
|
||||||
|
* alive content (`inserts − deletes`).
|
||||||
|
*/
|
||||||
|
export const createAttributionsRenderer = (attributions, options) => new AttributionsRenderer(attributions, options)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {number} len
|
||||||
|
*/
|
||||||
|
const getItemContent = (store, client, clock, len) => {
|
||||||
|
// Retrieved item is never more fragmented than the newer item.
|
||||||
|
const prevItem = store.getItem(createID(client, clock))
|
||||||
|
const diffStart = clock - prevItem.id.clock
|
||||||
|
let content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||||
|
// trim itemContent to the correct size.
|
||||||
|
if (diffStart > 0) {
|
||||||
|
content = content.splice(diffStart)
|
||||||
|
}
|
||||||
|
if (len < content.getLength()) {
|
||||||
|
content.splice(len)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the whole subtree of an accepted pending node insert. A nested item can only exist in
|
||||||
|
* the suggestion doc if its parent does, so every attribute entry and every child of an accepted
|
||||||
|
* node belongs to the same suggestion - the attributes in particular: shipped without them, the
|
||||||
|
* node that reaches the base doc is not the node that was suggested (an image without its `src`,
|
||||||
|
* a paragraph without its text). Deleted nested items are transient (inserted and deleted within
|
||||||
|
* the suggestion) and are collected like the top-level `collectAll` case; the receiving document
|
||||||
|
* filters what it already holds.
|
||||||
|
*
|
||||||
|
* @param {import('../ynode.js').YNode} type
|
||||||
|
* @param {IdSet} inserts
|
||||||
|
* @param {IdSet} deletes
|
||||||
|
*/
|
||||||
|
const collectSubtree = (type, inserts, deletes) => {
|
||||||
|
/**
|
||||||
|
* @type {Array<Item|null>}
|
||||||
|
*/
|
||||||
|
const chains = [type._start]
|
||||||
|
type._map.forEach(item => { chains.push(item) })
|
||||||
|
for (const chain of chains) {
|
||||||
|
// list items chain to the right, map entries to the left (their overwritten values)
|
||||||
|
for (let item = chain; item != null; item = item.parentSub != null ? item.left : item.right) {
|
||||||
|
inserts.add(item.id.client, item.id.clock, item.length)
|
||||||
|
if (item.deleted) {
|
||||||
|
deletes.add(item.id.client, item.id.clock, item.length)
|
||||||
|
} else if (item.content instanceof ContentType) {
|
||||||
|
collectSubtree(item.content.type, inserts, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction?} tr - only specify this if you want to fill the content of deleted content
|
||||||
|
* @param {DiffRenderer} renderer
|
||||||
|
* @param {ID} start
|
||||||
|
* @param {ID} end
|
||||||
|
* @param {boolean} collectAll - collect as many items as possible. Accept adding redundant changes.
|
||||||
|
* An accepted node insert then also collects its subtree (see {@link collectSubtree}).
|
||||||
|
*/
|
||||||
|
const collectSuggestedChanges = (tr, renderer, start, end, collectAll) => {
|
||||||
|
const inserts = createIdSet()
|
||||||
|
const deletes = createIdSet()
|
||||||
|
const store = renderer._nextDoc.store
|
||||||
|
/**
|
||||||
|
* make sure to collect suggestions until all formats are closed
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
const openedCollectedFormats = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Item?}
|
||||||
|
*/
|
||||||
|
let item = store.getItem(start)
|
||||||
|
const endItem = start === end ? item : (end == null ? null : store.getItem(end))
|
||||||
|
|
||||||
|
// walk to the left and find first un-attributed change that is rendered
|
||||||
|
while (item.left != null) {
|
||||||
|
item = item.left
|
||||||
|
if (item.content instanceof ContentFormat && item.content.value == null) {
|
||||||
|
item = item.right
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!item.deleted) {
|
||||||
|
const slice = renderer.inserts.slice(item.id.client, item.id.clock, item.length)
|
||||||
|
if (slice.some(s => s.attrs === null)) {
|
||||||
|
for (let i = slice.length - 1; i >= 0; i--) {
|
||||||
|
const s = slice[i]
|
||||||
|
if (s.attrs == null) break
|
||||||
|
inserts.add(item.id.client, s.clock, s.len)
|
||||||
|
if (collectAll && item.content instanceof ContentType) {
|
||||||
|
collectSubtree(item.content.type, inserts, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item = item.right
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let foundEndItem = false
|
||||||
|
// eslint-disable-next-line
|
||||||
|
itemLoop: while (item != null) {
|
||||||
|
const itemClient = item.id.client
|
||||||
|
const slice = (item.deleted ? renderer.deletes : renderer.inserts).slice(itemClient, item.id.clock, item.length)
|
||||||
|
foundEndItem ||= item === endItem
|
||||||
|
if (item.deleted) {
|
||||||
|
// item probably gc'd content. Need to split item and fill with content again
|
||||||
|
for (let i = slice.length - 1; i >= 0; i--) {
|
||||||
|
const s = slice[i]
|
||||||
|
if (s.attrs != null || collectAll) {
|
||||||
|
deletes.add(itemClient, s.clock, s.len)
|
||||||
|
if (collectAll) {
|
||||||
|
// in case item has been added and deleted this might be necessary. the forked document
|
||||||
|
// will automatically filter this if it doesn't have it already.
|
||||||
|
inserts.add(itemClient, s.clock, s.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tr != null) {
|
||||||
|
const splicedItem = getItemCleanStart(tr, createID(itemClient, s.clock))
|
||||||
|
if (s.attrs != null) {
|
||||||
|
splicedItem.content = getItemContent(renderer._prevDocStore, itemClient, s.clock, s.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (item.content instanceof ContentFormat) {
|
||||||
|
const { key, value } = item.content
|
||||||
|
if (value == null) {
|
||||||
|
openedCollectedFormats.delete(key)
|
||||||
|
} else {
|
||||||
|
openedCollectedFormats.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
const s = slice[i]
|
||||||
|
if (s.attrs != null) {
|
||||||
|
inserts.add(itemClient, s.clock, s.len)
|
||||||
|
if (collectAll && item.content instanceof ContentType) {
|
||||||
|
collectSubtree(item.content.type, inserts, deletes)
|
||||||
|
}
|
||||||
|
} else if (foundEndItem && openedCollectedFormats.size === 0) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
break itemLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item = item.right
|
||||||
|
}
|
||||||
|
return { inserts, deletes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdMap<any>|undefined} attrs
|
||||||
|
* @param {IdSet} slice
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const extractAttributions = (attrs, slice) => attrs == null ? createIdMapFromIdSet(slice, []) : mergeIdMaps([intersectMaps(attrs, slice), createIdMapFromIdSet(slice, [])])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements AbstractRenderer
|
||||||
|
*
|
||||||
|
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||||
|
*/
|
||||||
|
export class DiffRenderer extends ObservableV2 {
|
||||||
|
/**
|
||||||
|
* @param {Doc} prevDoc
|
||||||
|
* @param {Doc} nextDoc
|
||||||
|
* @param {Object} [options] - options for the renderer
|
||||||
|
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||||
|
*/
|
||||||
|
constructor (prevDoc, nextDoc, { attributions = null } = {}) {
|
||||||
|
super()
|
||||||
|
const _nextDocInserts = createInsertSetFromStructStore(nextDoc.store, false) // unmaintained
|
||||||
|
const _prevDocInserts = createInsertSetFromStructStore(prevDoc.store, false) // unmaintained
|
||||||
|
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store) // maintained
|
||||||
|
const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store) // maintained
|
||||||
|
const insertDiff = diffIdSet(_nextDocInserts, _prevDocInserts)
|
||||||
|
const deleteDiff = diffIdSet(nextDocDeletes, prevDocDeletes)
|
||||||
|
this.inserts = extractAttributions(attributions?.inserts, insertDiff)
|
||||||
|
this.deletes = extractAttributions(attributions?.deletes, deleteDiff)
|
||||||
|
/**
|
||||||
|
* Raw coverage of `inserts` ∪ `deletes`, maintained alongside them. Over-approximates the
|
||||||
|
* actually-rendered set (e.g. it keeps suggested-inserts that were deleted later) —
|
||||||
|
* `readContent` remains authoritative. See {@link AbstractRenderer#attributed}.
|
||||||
|
* @type {IdSet}
|
||||||
|
*/
|
||||||
|
this.attributed = mergeIdSets([insertDiff, deleteDiff])
|
||||||
|
this._prevDoc = prevDoc
|
||||||
|
this._prevDocStore = prevDoc.store
|
||||||
|
this._nextDoc = nextDoc
|
||||||
|
// update before observer calls fired
|
||||||
|
this._nextBOH = nextDoc.on('beforeObserverCalls', tr => {
|
||||||
|
// update inserts
|
||||||
|
const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts)
|
||||||
|
insertIntoIdMap(this.inserts, extractAttributions(attributions?.inserts, diffInserts))
|
||||||
|
// update deletes
|
||||||
|
const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts)
|
||||||
|
insertIntoIdMap(this.deletes, extractAttributions(attributions?.deletes, diffDeletes))
|
||||||
|
insertIntoIdSet(this.attributed, diffInserts)
|
||||||
|
insertIntoIdSet(this.attributed, diffDeletes)
|
||||||
|
// @todo fire update ranges on `diffInserts` and `diffDeletes`
|
||||||
|
})
|
||||||
|
this._prevBOH = prevDoc.on('beforeObserverCalls', tr => {
|
||||||
|
insertIntoIdSet(_prevDocInserts, tr.insertSet)
|
||||||
|
insertIntoIdSet(prevDocDeletes, tr.deleteSet)
|
||||||
|
if (tr.insertSet.clients.size < 2) {
|
||||||
|
tr.insertSet.forEach((attrRange, client) => {
|
||||||
|
this.inserts.delete(client, attrRange.clock, attrRange.len)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.inserts = diffIdMap(this.inserts, tr.insertSet)
|
||||||
|
}
|
||||||
|
// insertIntoIdMap(this.deletes, createIdMapFromIdSet(intersectSets(tr.deleteSet, this.deletes), [createAttributionItem('acceptDelete', 'unknown')]))
|
||||||
|
if (tr.deleteSet.clients.size < 2) {
|
||||||
|
tr.deleteSet.forEach((attrRange, client) => {
|
||||||
|
this.deletes.delete(client, attrRange.clock, attrRange.len)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
|
||||||
|
}
|
||||||
|
// evict the accepted/rejected ranges from the coverage set, then re-add whatever the maps
|
||||||
|
// still claim: `tr.insertSet` only evicts insert-attributions and `tr.deleteSet` only evicts
|
||||||
|
// delete-attributions, but a single range can be covered by both maps (e.g. an accepted
|
||||||
|
// insert with a still-pending delete suggestion on the same ids must stay attributed).
|
||||||
|
const evicted = mergeIdSets([tr.insertSet, tr.deleteSet])
|
||||||
|
this.attributed = diffIdSet(this.attributed, evicted)
|
||||||
|
insertIntoIdSet(this.attributed, intersectSets(evicted, this.inserts))
|
||||||
|
insertIntoIdSet(this.attributed, intersectSets(evicted, this.deletes))
|
||||||
|
// fire event of "changed" attributions. exclude items that were added & deleted in the same
|
||||||
|
// transaction
|
||||||
|
this.emit('change', [diffIdSet(mergeIdSets([tr.insertSet, tr.deleteSet]), intersectSets(tr.insertSet, tr.deleteSet)), tr.origin, tr.local])
|
||||||
|
})
|
||||||
|
// changes from prevDoc should always flow into suggestionDoc
|
||||||
|
// changes from suggestionDoc only flow into ydoc if suggestion-mode is disabled
|
||||||
|
this._prevUpdateListener = prevDoc.on('update', (update, origin) => {
|
||||||
|
origin !== this && applyUpdate(nextDoc, update)
|
||||||
|
})
|
||||||
|
this._ndUpdateListener = nextDoc.on('update', (update, origin, _doc, tr) => {
|
||||||
|
// only if event is local and suggestion mode is enabled
|
||||||
|
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) {
|
||||||
|
applyUpdate(prevDoc, update, this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this._afterTrListener = nextDoc.on('afterTransaction', (tr) => {
|
||||||
|
// apply deletes on attributed deletes (content that is already deleted, but is rendered by
|
||||||
|
// the renderer)
|
||||||
|
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === tr.origin))) {
|
||||||
|
const attributedDeletes = tr.meta.get('attributedDeletes')
|
||||||
|
if (attributedDeletes != null) {
|
||||||
|
transact(prevDoc, () => {
|
||||||
|
// apply attributed deletes if there are any
|
||||||
|
const ds = new UpdateEncoderV1()
|
||||||
|
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
|
||||||
|
writeIdSet(ds, attributedDeletes)
|
||||||
|
applyUpdate(prevDoc, ds.toUint8Array())
|
||||||
|
}, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.suggestionMode = true
|
||||||
|
/**
|
||||||
|
* Optionally limit origins that may sync changes to the main doc if suggestion-mode is
|
||||||
|
* disabled.
|
||||||
|
*
|
||||||
|
* @type {Array<any>?}
|
||||||
|
*/
|
||||||
|
this.suggestionOrigins = null
|
||||||
|
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
|
||||||
|
prevDoc.on('destroy', this._destroyHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
get $type () { return $renderer }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasItem (item) {
|
||||||
|
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
super.destroy()
|
||||||
|
this._nextDoc.off('destroy', this._destroyHandler)
|
||||||
|
this._prevDoc.off('destroy', this._destroyHandler)
|
||||||
|
this._nextDoc.off('beforeObserverCalls', this._nextBOH)
|
||||||
|
this._prevDoc.off('beforeObserverCalls', this._prevBOH)
|
||||||
|
this._prevDoc.off('update', this._prevUpdateListener)
|
||||||
|
this._nextDoc.off('update', this._ndUpdateListener)
|
||||||
|
this._nextDoc.off('afterTransaction', this._afterTrListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptAllChanges () {
|
||||||
|
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAllChanges () {
|
||||||
|
this._prevDoc.transact(tr => {
|
||||||
|
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
|
||||||
|
const um = new UndoManager(this._prevDoc)
|
||||||
|
um.undoStack.push(new StackItem(tr.insertSet, tr.deleteSet))
|
||||||
|
um.undo()
|
||||||
|
um.destroy()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID} start
|
||||||
|
* @param {ID} end
|
||||||
|
*/
|
||||||
|
acceptChanges (start, end = start) {
|
||||||
|
const { inserts, deletes } = collectSuggestedChanges(null, this, start, end, true)
|
||||||
|
const encoder = new UpdateEncoderV1()
|
||||||
|
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||||
|
writeIdSet(encoder, deletes)
|
||||||
|
applyUpdate(this._prevDoc, encoder.toUint8Array())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID} start
|
||||||
|
* @param {ID} end
|
||||||
|
*/
|
||||||
|
rejectChanges (start, end = start) {
|
||||||
|
this._nextDoc.transact(tr => {
|
||||||
|
const { inserts, deletes } = collectSuggestedChanges(tr, this, start, end, false)
|
||||||
|
const encoder = new UpdateEncoderV1()
|
||||||
|
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
|
||||||
|
writeIdSet(encoder, deletes)
|
||||||
|
const um = new UndoManager(this._nextDoc)
|
||||||
|
um.undoStack.push(new StackItem(inserts, deletes))
|
||||||
|
um.undo()
|
||||||
|
um.destroy()
|
||||||
|
})
|
||||||
|
this.acceptChanges(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {boolean} deleted
|
||||||
|
* @param {AbstractContent} _content
|
||||||
|
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||||
|
*/
|
||||||
|
readContent (contents, client, clock, deleted, _content, shouldRender) {
|
||||||
|
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, _content.getLength())
|
||||||
|
/**
|
||||||
|
* @type {AbstractContent?}
|
||||||
|
*/
|
||||||
|
let content = slice.length === 1 ? _content : _content.copy()
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
const s = slice[i]
|
||||||
|
if (content == null || content instanceof ContentDeleted) {
|
||||||
|
if ((!shouldRender && s.attrs == null) || this.inserts.has(client, s.clock)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Retrieved item is never more fragmented than the newer item.
|
||||||
|
const prevItem = this._prevDocStore.getItem(createID(client, s.clock))
|
||||||
|
const diffStart = s.clock - prevItem.id.clock
|
||||||
|
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
|
||||||
|
// trim itemContent to the correct size.
|
||||||
|
if (diffStart > 0) {
|
||||||
|
content = content.splice(diffStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const c = /** @type {AbstractContent} */ (content)
|
||||||
|
const clen = c.getLength()
|
||||||
|
if (clen < s.len) {
|
||||||
|
slice.splice(i + 1, 0, createMaybeAttrRange(s.clock + clen, s.len - clen, s.attrs))
|
||||||
|
s.len = clen
|
||||||
|
}
|
||||||
|
content = s.len < clen ? c.splice(s.len) : null
|
||||||
|
// mode 3 (fresh content deleted in the same transaction) renders as an insert only where
|
||||||
|
// the renderer attributes it — unattributed deleted content is invisible and renders as
|
||||||
|
// *nothing* (there is nothing to insert, and a `delete` op would misapply: the consuming
|
||||||
|
// state has never seen this content)
|
||||||
|
if (!deleted || s.attrs != null || (shouldRender !== 0 && shouldRender !== 3)) {
|
||||||
|
contents.push(new AttributedContent(c, s.clock, deleted, s.attrs, shouldRender))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
contentLength (item) {
|
||||||
|
if (!item.deleted) {
|
||||||
|
return item.content.isCountable() ? item.length : 0
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {Array<AttributedContent<any>>}
|
||||||
|
*/
|
||||||
|
const cs = []
|
||||||
|
this.readContent(cs, item.id.client, item.id.clock, true, item.content, 0)
|
||||||
|
return cs.reduce((cnt, c) => cnt + ((c.attrs != null && c.content.isCountable()) ? c.content.getLength() : 0), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute changes from ydoc1 to ydoc2.
|
||||||
|
*
|
||||||
|
* @param {Doc} prevDoc
|
||||||
|
* @param {Doc} nextDoc
|
||||||
|
* @param {Object} [options] - options for the renderer
|
||||||
|
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
|
||||||
|
*/
|
||||||
|
export const createDiffRenderer = (prevDoc, nextDoc, options) => new DiffRenderer(prevDoc, nextDoc, options)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intended for projects that used the v13 snapshot feature. With this renderer you can
|
||||||
|
* read content similar to the previous snapshot api. Requires that `ydoc.gc` is turned off.
|
||||||
|
*
|
||||||
|
* @implements AbstractRenderer
|
||||||
|
*
|
||||||
|
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||||
|
*/
|
||||||
|
export class SnapshotRenderer extends ObservableV2 {
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} prevSnapshot
|
||||||
|
* @param {Snapshot} nextSnapshot
|
||||||
|
* @param {Object} [options] - options for the renderer
|
||||||
|
* @param {Array<ContentAttribute>} [options.attrs] - the attributes to apply to the diff
|
||||||
|
*/
|
||||||
|
constructor (prevSnapshot, nextSnapshot) {
|
||||||
|
super()
|
||||||
|
this.prevSnapshot = prevSnapshot
|
||||||
|
this.nextSnapshot = nextSnapshot
|
||||||
|
const inserts = createIdMap()
|
||||||
|
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createContentAttribute('change', '')])
|
||||||
|
nextSnapshot.sv.forEach((clock, client) => {
|
||||||
|
const prevClock = prevSnapshot.sv.get(client) || 0
|
||||||
|
inserts.add(client, 0, prevClock, []) // content is included in prevSnapshot is rendered without attributes
|
||||||
|
inserts.add(client, prevClock, clock - prevClock, [createContentAttribute('change', '')]) // content is rendered as "inserted"
|
||||||
|
})
|
||||||
|
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
|
||||||
|
/**
|
||||||
|
* Coverage of `attrs` (everything up to `nextSnapshot`). Content *after* the snapshot must be
|
||||||
|
* hidden rather than rendered normally, which a finite set cannot express — `hasItem`
|
||||||
|
* additionally claims all future content. See {@link AbstractRenderer#attributed}.
|
||||||
|
* @type {IdSet}
|
||||||
|
*/
|
||||||
|
this.attributed = createIdSetFromIdMap(this.attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
get $type () { return $renderer }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasItem (item) {
|
||||||
|
// claim future content (item ids at/after the snapshot's state vector, including clients the
|
||||||
|
// snapshot has never seen) — `readContent` hides it, the generic path would render it
|
||||||
|
return (this.nextSnapshot.sv.get(item.id.client) ?? 0) < item.id.clock + item.length ||
|
||||||
|
this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<AttributedContent<any>>} contents - where to write the result
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {boolean} _deleted
|
||||||
|
* @param {AbstractContent} content
|
||||||
|
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
|
||||||
|
*/
|
||||||
|
readContent (contents, client, clock, _deleted, content, shouldRender) {
|
||||||
|
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
|
||||||
|
const slice = this.attrs.slice(client, clock, content.getLength())
|
||||||
|
content = slice.length === 1 ? content : content.copy()
|
||||||
|
slice.forEach(s => {
|
||||||
|
const deleted = this.nextSnapshot.ds.has(client, s.clock)
|
||||||
|
const nonExistend = (this.nextSnapshot.sv.get(client) ?? 0) <= s.clock
|
||||||
|
const c = content
|
||||||
|
if (s.len < c.getLength()) {
|
||||||
|
content = c.splice(s.len)
|
||||||
|
}
|
||||||
|
if (nonExistend) return
|
||||||
|
if (shouldRender || !deleted || (s.attrs != null && s.attrs.length > 0)) {
|
||||||
|
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
|
||||||
|
if (s.attrs?.length === 0) {
|
||||||
|
attrsWithoutChange = null
|
||||||
|
}
|
||||||
|
contents.push(new AttributedContent(c, s.clock, deleted, attrsWithoutChange, shouldRender))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
contentLength (item) {
|
||||||
|
return item.content.isCountable()
|
||||||
|
? (item.deleted
|
||||||
|
? this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
|
||||||
|
: item.length
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} prevSnapshot
|
||||||
|
* @param {Snapshot} nextSnapshot
|
||||||
|
*/
|
||||||
|
export const createSnapshotRenderer = (prevSnapshot, nextSnapshot = prevSnapshot) => new SnapshotRenderer(prevSnapshot, nextSnapshot)
|
||||||
198
src/utils/Snapshot.js
Normal file
198
src/utils/Snapshot.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as set from 'lib0/set'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { createDeleteSetFromStructStore, createIdSet, iterateStructsByIdSet, equalIdSets, mergeIdSets, writeIdSet, readIdSet } from './ids.js'
|
||||||
|
import { getStateVector } from './StructStore.js'
|
||||||
|
import { findIndexSS, getItemCleanStart } from './transaction-helpers.js'
|
||||||
|
import { writeStateVector, readStateVector, applyUpdateV2 } from './encoding.js'
|
||||||
|
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1, IdSetDecoderV2 } from './UpdateDecoder.js'
|
||||||
|
import { UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||||
|
import { LazyStructReader } from './updates.js'
|
||||||
|
import { Doc } from './Doc.js'
|
||||||
|
|
||||||
|
export class Snapshot {
|
||||||
|
/**
|
||||||
|
* @param {IdSet} ds
|
||||||
|
* @param {Map<number,number>} sv state map
|
||||||
|
*/
|
||||||
|
constructor (ds, sv) {
|
||||||
|
/**
|
||||||
|
* @type {IdSet}
|
||||||
|
*/
|
||||||
|
this.ds = ds
|
||||||
|
/**
|
||||||
|
* State Map
|
||||||
|
* @type {Map<number,number>}
|
||||||
|
*/
|
||||||
|
this.sv = sv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} snap1
|
||||||
|
* @param {Snapshot} snap2
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
export const equalSnapshots = (snap1, snap2) => {
|
||||||
|
const sv1 = snap1.sv
|
||||||
|
const sv2 = snap2.sv
|
||||||
|
if (sv1.size !== sv2.size) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (const [key, value] of sv1.entries()) {
|
||||||
|
if (sv2.get(key) !== value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return equalIdSets(snap1.ds, snap2.ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
|
||||||
|
* @return {Uint8Array}
|
||||||
|
*/
|
||||||
|
export const encodeSnapshotV2 = (snapshot, encoder = new IdSetEncoderV2()) => {
|
||||||
|
writeIdSet(encoder, snapshot.ds)
|
||||||
|
writeStateVector(encoder, snapshot.sv)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @return {Uint8Array}
|
||||||
|
*/
|
||||||
|
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new IdSetEncoderV1())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} buf
|
||||||
|
* @param {IdSetDecoderV1 | IdSetDecoderV2} [decoder]
|
||||||
|
* @return {Snapshot}
|
||||||
|
*/
|
||||||
|
export const decodeSnapshotV2 = (buf, decoder = new IdSetDecoderV2(decoding.createDecoder(buf))) => {
|
||||||
|
return new Snapshot(readIdSet(decoder), readStateVector(decoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} buf
|
||||||
|
* @return {Snapshot}
|
||||||
|
*/
|
||||||
|
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new IdSetDecoderV1(decoding.createDecoder(buf)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSet} ds
|
||||||
|
* @param {Map<number,number>} sm
|
||||||
|
* @return {Snapshot}
|
||||||
|
*/
|
||||||
|
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
|
||||||
|
|
||||||
|
export const emptySnapshot = createSnapshot(createIdSet(), new Map())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @return {Snapshot}
|
||||||
|
*/
|
||||||
|
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
*/
|
||||||
|
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
|
||||||
|
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
|
||||||
|
const store = transaction.doc.store
|
||||||
|
// check if we already split for this snapshot
|
||||||
|
if (!meta.has(snapshot)) {
|
||||||
|
snapshot.sv.forEach((clock, client) => {
|
||||||
|
if (clock < store.getClock(client)) {
|
||||||
|
getItemCleanStart(transaction, createID(client, clock))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
iterateStructsByIdSet(transaction, snapshot.ds, _item => {})
|
||||||
|
meta.add(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* const ydoc = new Y.Doc({ gc: false })
|
||||||
|
* ydoc.get().insert(0, 'world!')
|
||||||
|
* const snapshot = Y.snapshot(ydoc)
|
||||||
|
* ydoc.get().insert(0, 'hello ')
|
||||||
|
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
|
||||||
|
* assert(restored.get().toString() === 'world!')
|
||||||
|
*
|
||||||
|
* @param {Doc} originDoc
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
|
||||||
|
* @return {Doc}
|
||||||
|
*/
|
||||||
|
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
|
||||||
|
if (originDoc.gc) {
|
||||||
|
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
|
||||||
|
throw new Error('Garbage-collection must be disabled in `originDoc`!')
|
||||||
|
}
|
||||||
|
const { sv, ds } = snapshot
|
||||||
|
|
||||||
|
const encoder = new UpdateEncoderV2()
|
||||||
|
originDoc.transact(transaction => {
|
||||||
|
let size = 0
|
||||||
|
sv.forEach(clock => {
|
||||||
|
if (clock > 0) {
|
||||||
|
size++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, size)
|
||||||
|
// splitting the structs before writing them to the encoder
|
||||||
|
for (const [client, clock] of sv) {
|
||||||
|
if (clock === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (clock < originDoc.store.getClock(client)) {
|
||||||
|
getItemCleanStart(transaction, createID(client, clock))
|
||||||
|
}
|
||||||
|
const structs = originDoc.store.clients.get(client) || []
|
||||||
|
const lastStructIndex = findIndexSS(structs, clock - 1)
|
||||||
|
// write # encoded structs
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1)
|
||||||
|
encoder.writeClient(client)
|
||||||
|
// first clock written is 0
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||||
|
for (let i = 0; i <= lastStructIndex; i++) {
|
||||||
|
structs[i].write(encoder, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeIdSet(encoder, ds)
|
||||||
|
})
|
||||||
|
|
||||||
|
applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot')
|
||||||
|
return newDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||||
|
*/
|
||||||
|
export const snapshotContainsUpdateV2 = (snapshot, update, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const structs = []
|
||||||
|
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||||
|
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||||
|
structs.push(curr)
|
||||||
|
if ((snapshot.sv.get(curr.id.client) || 0) < curr.id.clock + curr.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mergedDS = mergeIdSets([snapshot.ds, readIdSet(updateDecoder)])
|
||||||
|
return equalIdSets(snapshot.ds, mergedDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Snapshot} snapshot
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
*/
|
||||||
|
export const snapshotContainsUpdate = (snapshot, update) => snapshotContainsUpdateV2(snapshot, update, UpdateDecoderV1)
|
||||||
151
src/utils/StructStore.js
Normal file
151
src/utils/StructStore.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Skip } from '../structs/Skip.js'
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { createDeleteSetFromStructStore, createIdSet } from './ids.js'
|
||||||
|
import { findIndexSS } from './transaction-helpers.js'
|
||||||
|
|
||||||
|
export class StructStore {
|
||||||
|
constructor () {
|
||||||
|
/**
|
||||||
|
* @type {Map<number,Array<GC|Item|Skip>>}
|
||||||
|
*/
|
||||||
|
this.clients = new Map()
|
||||||
|
// this.ds = new IdSet()
|
||||||
|
/**
|
||||||
|
* @type {null | { missing: Map<number, number>, update: Uint8Array<ArrayBuffer> }}
|
||||||
|
*/
|
||||||
|
this.pendingStructs = null
|
||||||
|
/**
|
||||||
|
* @type {null | Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
this.pendingDs = null
|
||||||
|
this.skips = createIdSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
get ds () {
|
||||||
|
return createDeleteSetFromStructStore(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {GC|Item|Skip} struct
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
add (struct) {
|
||||||
|
let structs = this.clients.get(struct.id.client)
|
||||||
|
if (structs === undefined) {
|
||||||
|
structs = []
|
||||||
|
this.clients.set(struct.id.client, structs)
|
||||||
|
} else {
|
||||||
|
const lastStruct = structs[structs.length - 1]
|
||||||
|
if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) {
|
||||||
|
// this replaces an integrated skip
|
||||||
|
let index = findIndexSS(structs, struct.id.clock)
|
||||||
|
const skip = structs[index]
|
||||||
|
const diffStart = struct.id.clock - skip.id.clock
|
||||||
|
const diffEnd = skip.id.clock + skip.length - struct.id.clock - struct.length
|
||||||
|
if (diffStart > 0) {
|
||||||
|
structs.splice(index++, 0, new Skip(createID(struct.id.client, skip.id.clock), diffStart))
|
||||||
|
}
|
||||||
|
if (diffEnd > 0) {
|
||||||
|
structs.splice(index + 1, 0, new Skip(createID(struct.id.client, struct.id.clock + struct.length), diffEnd))
|
||||||
|
}
|
||||||
|
structs[index] = struct
|
||||||
|
this.skips.delete(struct.id.client, struct.id.clock, struct.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
structs.push(struct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||||
|
*
|
||||||
|
* @param {ID} id
|
||||||
|
* @return {GC|Item}
|
||||||
|
*/
|
||||||
|
get (id) {
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (this.clients.get(id.client))
|
||||||
|
return structs[findIndexSS(structs, id.clock)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||||
|
*
|
||||||
|
* @param {ID} id
|
||||||
|
* @return {Item}
|
||||||
|
*/
|
||||||
|
getItem (id) {
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (this.clients.get(id.client))
|
||||||
|
return /** @type {Item} */ (structs[findIndexSS(structs, id.clock)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next expected clock for a specific client.
|
||||||
|
*
|
||||||
|
* @param {number} client
|
||||||
|
* @return {number}
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
getClock (client) {
|
||||||
|
const structs = this.clients.get(client)
|
||||||
|
if (structs === undefined) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const lastStruct = structs[structs.length - 1]
|
||||||
|
return lastStruct.id.clock + lastStruct.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a binary search on a sorted array
|
||||||
|
* @param {ID} id
|
||||||
|
* @return {{ structs: Array<GC|Item|Skip>, index: number }}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
getIndex (id) {
|
||||||
|
const structs = this.clients.get(id.client) || []
|
||||||
|
const index = findIndexSS(structs, id.clock)
|
||||||
|
return { structs, index }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the states as a Map<client,clock>.
|
||||||
|
* Note that clock refers to the next expected clock id.
|
||||||
|
*
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @return {Map<number,number>}
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const getStateVector = store => {
|
||||||
|
const sm = new Map()
|
||||||
|
store.clients.forEach((structs, client) => {
|
||||||
|
const struct = structs[structs.length - 1]
|
||||||
|
sm.set(client, struct.id.clock + struct.length)
|
||||||
|
})
|
||||||
|
store.skips.clients.forEach((range, client) => {
|
||||||
|
sm.set(client, range.getIds()[0].clock)
|
||||||
|
})
|
||||||
|
return sm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StructStore} store
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const integrityCheck = store => {
|
||||||
|
store.clients.forEach(structs => {
|
||||||
|
for (let i = 1; i < structs.length; i++) {
|
||||||
|
const l = structs[i - 1]
|
||||||
|
const r = structs[i]
|
||||||
|
if (l.id.clock + l.length !== r.id.clock) {
|
||||||
|
throw new Error('StructStore failed integrity check')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
446
src/utils/Transaction.js
Normal file
446
src/utils/Transaction.js
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import * as error from 'lib0/error'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as logging from 'lib0/logging'
|
||||||
|
import { callAll } from 'lib0/function'
|
||||||
|
|
||||||
|
import { ContentFormat } from '../structs/Item.js'
|
||||||
|
import { getStateVector } from './StructStore.js'
|
||||||
|
import { callEventHandlerListeners } from './EventHandler.js'
|
||||||
|
import { createIdSet, iterateStructsByIdSet } from './ids.js'
|
||||||
|
import { GC } from '../structs/GC.js'
|
||||||
|
import { YEvent } from './YEvent.js'
|
||||||
|
import { writeUpdateMessageFromTransaction } from './encoding-helpers.js'
|
||||||
|
import { UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
|
||||||
|
import { findIndexSS, updateCurrentFormats, cleanupFormattingGap, tryGcDeleteSet, tryMerge, tryToMergeWithLefts, cleanupContextlessFormattingGap } from './transaction-helpers.js'
|
||||||
|
import * as random from 'lib0/random'
|
||||||
|
|
||||||
|
export const generateNewClientId = random.uint53
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transaction is created for every change on the Yjs model. It is possible
|
||||||
|
* to bundle changes on the Yjs model in a single transaction to
|
||||||
|
* minimize the number on messages sent and the number of observer calls.
|
||||||
|
* If possible the user of this library should bundle as many changes as
|
||||||
|
* possible. Here is an example to illustrate the advantages of bundling:
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const ydoc = new Y.Doc()
|
||||||
|
* const map = ydoc.get('map')
|
||||||
|
* // Log content when change is triggered
|
||||||
|
* map.observe(() => {
|
||||||
|
* console.log('change triggered')
|
||||||
|
* })
|
||||||
|
* // Each change on the map type triggers a log message:
|
||||||
|
* map.setAttr('a', 0) // => "change triggered"
|
||||||
|
* map.setAttr('b', 0) // => "change triggered"
|
||||||
|
* // When put in a transaction, it will trigger the log after the transaction:
|
||||||
|
* ydoc.transact(() => {
|
||||||
|
* map.setAttr('a', 1)
|
||||||
|
* map.setAttr('b', 1)
|
||||||
|
* }) // => "change triggered"
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export class Transaction {
|
||||||
|
/**
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {any} origin
|
||||||
|
* @param {boolean} local
|
||||||
|
*/
|
||||||
|
constructor (doc, origin, local) {
|
||||||
|
/**
|
||||||
|
* The Yjs instance.
|
||||||
|
* @type {Doc}
|
||||||
|
*/
|
||||||
|
this.doc = doc
|
||||||
|
/**
|
||||||
|
* Describes the set of deleted items by ids
|
||||||
|
*/
|
||||||
|
this.deleteSet = createIdSet()
|
||||||
|
/**
|
||||||
|
* Describes the set of items that are cleaned up / deleted by ids. It is a subset of
|
||||||
|
* this.deleteSet
|
||||||
|
*/
|
||||||
|
this.cleanUps = createIdSet()
|
||||||
|
/**
|
||||||
|
* Describes the set of inserted items by ids
|
||||||
|
*/
|
||||||
|
this.insertSet = createIdSet()
|
||||||
|
/**
|
||||||
|
* Holds the state before the transaction started.
|
||||||
|
* @type {Map<Number,Number>?}
|
||||||
|
*/
|
||||||
|
this._beforeState = null
|
||||||
|
/**
|
||||||
|
* Holds the state after the transaction.
|
||||||
|
* @type {Map<Number,Number>?}
|
||||||
|
*/
|
||||||
|
this._afterState = null
|
||||||
|
/**
|
||||||
|
* All types that were directly modified (property added or child
|
||||||
|
* inserted/deleted). New types are not included in this Set.
|
||||||
|
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
|
||||||
|
* @type {Map<YNode,Set<String|null>>}
|
||||||
|
*/
|
||||||
|
this.changed = new Map()
|
||||||
|
/**
|
||||||
|
* Stores the events for the types that observe also child elements.
|
||||||
|
* It is mainly used by `observeDeep`.
|
||||||
|
* @type {Map<YNode,Array<YEvent<any>>>}
|
||||||
|
*/
|
||||||
|
this.changedParentTypes = new Map()
|
||||||
|
/**
|
||||||
|
* @type {Array<AbstractStruct>}
|
||||||
|
*/
|
||||||
|
this._mergeStructs = []
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
this.origin = origin
|
||||||
|
/**
|
||||||
|
* Stores meta information on the transaction
|
||||||
|
* @type {Map<any,any>}
|
||||||
|
*/
|
||||||
|
this.meta = new Map()
|
||||||
|
/**
|
||||||
|
* Whether this change originates from this doc.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
this.local = local
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsAdded = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsRemoved = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Set<Doc>}
|
||||||
|
*/
|
||||||
|
this.subdocsLoaded = new Set()
|
||||||
|
/**
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
this._needFormattingCleanup = false
|
||||||
|
this._done = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the state before the transaction started.
|
||||||
|
*
|
||||||
|
* @deprecated
|
||||||
|
* @type {Map<Number,Number>}
|
||||||
|
*/
|
||||||
|
get beforeState () {
|
||||||
|
if (this._beforeState == null) {
|
||||||
|
const sv = getStateVector(this.doc.store)
|
||||||
|
this.insertSet.clients.forEach((ranges, client) => {
|
||||||
|
sv.set(client, ranges.getIds()[0].clock)
|
||||||
|
})
|
||||||
|
this._beforeState = sv
|
||||||
|
}
|
||||||
|
return this._beforeState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the state after the transaction.
|
||||||
|
*
|
||||||
|
* @deprecated
|
||||||
|
* @type {Map<Number,Number>}
|
||||||
|
*/
|
||||||
|
get afterState () {
|
||||||
|
if (!this._done) error.unexpectedCase()
|
||||||
|
if (this._afterState == null) {
|
||||||
|
const sv = getStateVector(this.doc.store)
|
||||||
|
this.insertSet.clients.forEach((_ranges, client) => {
|
||||||
|
const ranges = _ranges.getIds()
|
||||||
|
const d = ranges[ranges.length - 1]
|
||||||
|
sv.set(client, d.clock + d.len)
|
||||||
|
})
|
||||||
|
this._afterState = sv
|
||||||
|
}
|
||||||
|
return this._afterState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is experimental and subject to change / be removed.
|
||||||
|
*
|
||||||
|
* Ideally, we don't need this function at all. Formats should be cleaned up
|
||||||
|
* automatically after each change. This function iterates twice over the complete YText type
|
||||||
|
* and removes unnecessary formats. This is also helpful for testing.
|
||||||
|
*
|
||||||
|
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
|
||||||
|
*
|
||||||
|
* @param {YNode} type
|
||||||
|
* @return {number} How many formats have been cleaned up.
|
||||||
|
*/
|
||||||
|
export const cleanupYTextFormatting = type => {
|
||||||
|
if (!type.doc?.cleanupFormatting) return 0
|
||||||
|
let res = 0
|
||||||
|
transact(/** @type {Doc} */ (type.doc), transaction => {
|
||||||
|
let start = /** @type {Item} */ (type._start)
|
||||||
|
let end = type._start
|
||||||
|
let startFormats = map.create()
|
||||||
|
const currentFormats = map.copy(startFormats)
|
||||||
|
while (end) {
|
||||||
|
if (end.deleted === false) {
|
||||||
|
switch (end.content.constructor) {
|
||||||
|
case ContentFormat:
|
||||||
|
updateCurrentFormats(currentFormats, /** @type {ContentFormat} */ (end.content))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
res += cleanupFormattingGap(transaction, start, end, startFormats, currentFormats)
|
||||||
|
startFormats = map.copy(currentFormats)
|
||||||
|
start = end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end = end.right
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<Transaction>} transactionCleanups
|
||||||
|
* @param {number} i
|
||||||
|
*/
|
||||||
|
const cleanupTransactions = (transactionCleanups, i) => {
|
||||||
|
if (i < transactionCleanups.length) {
|
||||||
|
const transaction = transactionCleanups[i]
|
||||||
|
transaction._done = true
|
||||||
|
const doc = transaction.doc
|
||||||
|
const store = doc.store
|
||||||
|
const ds = transaction.deleteSet
|
||||||
|
const mergeStructs = transaction._mergeStructs
|
||||||
|
// insertIntoIdSet(store.ds, ds)
|
||||||
|
try {
|
||||||
|
doc.emit('beforeObserverCalls', [transaction, doc])
|
||||||
|
/**
|
||||||
|
* An array of event callbacks.
|
||||||
|
*
|
||||||
|
* Each callback is called even if the other ones throw errors.
|
||||||
|
*
|
||||||
|
* @type {Array<function():void>}
|
||||||
|
*/
|
||||||
|
const fs = []
|
||||||
|
// observe events on changed types. Deleted types are included: `callTypeObservers` tracks
|
||||||
|
// the event in `changedParentTypes` unconditionally (so changes inside a deleted type — e.g.
|
||||||
|
// a suggestion-deleted tombstone that a custom renderer still renders — bubble to live
|
||||||
|
// ancestors) and decides itself whether the type's own observers fire.
|
||||||
|
transaction.changed.forEach((subs, itemtype) =>
|
||||||
|
fs.push(() => {
|
||||||
|
itemtype._callObserver(transaction, subs)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fs.push(() => {
|
||||||
|
// deep observe events + the RDT `'delta'` channel. `changedParentTypes` holds the changed
|
||||||
|
// type AND all of its ancestors, so both `observeDeep` and `'delta'` bubble identically.
|
||||||
|
transaction.changedParentTypes.forEach((events, type) => {
|
||||||
|
// We need to think about the possibility that the user transforms the
|
||||||
|
// Y.Doc in the event.
|
||||||
|
// Deleted types are tracked (so changes inside them bubble to live ancestors) but fire
|
||||||
|
// only while something still renders them — i.e. they have a renderer attached.
|
||||||
|
if (type._item !== null && type._item.deleted && type._renderer === null) return
|
||||||
|
const hasDeep = type._dEH.l.length > 0
|
||||||
|
const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0
|
||||||
|
const maintaining = type._delta !== null
|
||||||
|
if (!hasDeep && !hasDeltaListeners && !maintaining) return
|
||||||
|
/**
|
||||||
|
* @type {YEvent<any>}
|
||||||
|
*/
|
||||||
|
const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null))
|
||||||
|
if (hasDeep) {
|
||||||
|
callEventHandlerListeners(type._dEH, deepEventHandler, transaction)
|
||||||
|
}
|
||||||
|
if (hasDeltaListeners || maintaining) {
|
||||||
|
// the type-rooted deep delta of this transaction (a nested `modify` chain for ancestors)
|
||||||
|
const change = /** @type {any} */ (deepEventHandler.getDelta({ renderer: type._renderer, deep: true }).done())
|
||||||
|
// a base-renderer type doesn't render deleted content, so a change that happened
|
||||||
|
// entirely inside a deleted subtree renders to an empty delta — don't emit those no-ops
|
||||||
|
if (!change.isEmpty()) {
|
||||||
|
type._delta?.apply(change) // keep the cache current (incl. ancestors and diff-renderer attributions) — builder API only, see the YNode._delta fingerprint-memo invariant
|
||||||
|
if (hasDeltaListeners) type.emit('delta', [change, transaction.origin])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
|
||||||
|
callAll(fs, [])
|
||||||
|
if (transaction._needFormattingCleanup && doc.cleanupFormatting) {
|
||||||
|
cleanupYTextAfterTransaction(transaction)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Replace deleted items with ItemDeleted / GC.
|
||||||
|
// This is where content is actually remove from the Yjs Doc.
|
||||||
|
if (doc.gc) {
|
||||||
|
tryGcDeleteSet(transaction, ds, doc.gcFilter)
|
||||||
|
}
|
||||||
|
tryMerge(ds, store)
|
||||||
|
|
||||||
|
// on all affected store.clients props, try to merge
|
||||||
|
transaction.insertSet.clients.forEach((ids, client) => {
|
||||||
|
const firstClock = ids.getIds()[0].clock
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||||
|
// we iterate from right to left so we can safely remove entries
|
||||||
|
const firstChangePos = math.max(findIndexSS(structs, firstClock), 1)
|
||||||
|
for (let i = structs.length - 1; i >= firstChangePos;) {
|
||||||
|
i -= 1 + tryToMergeWithLefts(structs, i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// try to merge mergeStructs
|
||||||
|
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
|
||||||
|
// but at the moment DS does not handle duplicates
|
||||||
|
for (let i = mergeStructs.length - 1; i >= 0; i--) {
|
||||||
|
const { client, clock } = mergeStructs[i].id
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||||
|
const replacedStructPos = findIndexSS(structs, clock)
|
||||||
|
if (replacedStructPos + 1 < structs.length) {
|
||||||
|
if (tryToMergeWithLefts(structs, replacedStructPos + 1) > 1) {
|
||||||
|
continue // no need to perform next check, both are already merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replacedStructPos > 0) {
|
||||||
|
tryToMergeWithLefts(structs, replacedStructPos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!transaction.local && transaction.insertSet.clients.has(doc.clientID)) {
|
||||||
|
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
|
||||||
|
doc.clientID = generateNewClientId()
|
||||||
|
}
|
||||||
|
// @todo Merge all the transactions into one and provide send the data as a single update message
|
||||||
|
doc.emit('afterTransactionCleanup', [transaction, doc])
|
||||||
|
if (doc._observers.has('update')) {
|
||||||
|
const encoder = new UpdateEncoderV1()
|
||||||
|
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||||
|
if (hasContent) {
|
||||||
|
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (doc._observers.has('updateV2')) {
|
||||||
|
const encoder = new UpdateEncoderV2()
|
||||||
|
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
|
||||||
|
if (hasContent) {
|
||||||
|
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction
|
||||||
|
if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
|
||||||
|
subdocsAdded.forEach(subdoc => {
|
||||||
|
subdoc.clientID = doc.clientID
|
||||||
|
if (subdoc.collectionid == null) {
|
||||||
|
subdoc.collectionid = doc.collectionid
|
||||||
|
}
|
||||||
|
doc.subdocs.add(subdoc)
|
||||||
|
})
|
||||||
|
subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
|
||||||
|
doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction])
|
||||||
|
subdocsRemoved.forEach(subdoc => subdoc.destroy())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionCleanups.length <= i + 1) {
|
||||||
|
doc._transactionCleanups = []
|
||||||
|
doc.emit('afterAllTransactions', [doc, transactionCleanups])
|
||||||
|
} else {
|
||||||
|
cleanupTransactions(transactionCleanups, i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will be called by the transaction once the event handlers are called to potentially cleanup
|
||||||
|
* formats.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
*/
|
||||||
|
export const cleanupYTextAfterTransaction = transaction => {
|
||||||
|
/**
|
||||||
|
* @type {Set<YNode>}
|
||||||
|
*/
|
||||||
|
const needFullCleanup = new Set()
|
||||||
|
// check if another formatting item was inserted
|
||||||
|
const doc = transaction.doc
|
||||||
|
iterateStructsByIdSet(transaction, transaction.insertSet, (item) => {
|
||||||
|
if (
|
||||||
|
!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC
|
||||||
|
) {
|
||||||
|
needFullCleanup.add(/** @type {any} */ (item).parent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// cleanup in a new transaction
|
||||||
|
transact(doc, (t) => {
|
||||||
|
iterateStructsByIdSet(transaction, transaction.deleteSet, item => {
|
||||||
|
if (item instanceof GC || !(/** @type {YNode} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YNode} */ (item.parent))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parent = /** @type {YNode} */ (item.parent)
|
||||||
|
if (item.content.constructor === ContentFormat) {
|
||||||
|
needFullCleanup.add(parent)
|
||||||
|
} else {
|
||||||
|
// If no format was inserted or deleted, we can make due with contextless
|
||||||
|
// formatting cleanups.
|
||||||
|
// Contextless: it is not necessary to compute currentFormats for the affected position.
|
||||||
|
cleanupContextlessFormattingGap(t, item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// If a formatting item was inserted, we simply clean the whole type.
|
||||||
|
// We need to compute currentFormats for the current position anyway.
|
||||||
|
for (const yText of needFullCleanup) {
|
||||||
|
cleanupYTextFormatting(yText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the functionality of `y.transact(()=>{..})`
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {function(Transaction):T} f
|
||||||
|
* @param {any} [origin=true]
|
||||||
|
* @param {boolean} [local=true]
|
||||||
|
* @return {T}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const transact = (doc, f, origin = null, local = true) => {
|
||||||
|
const transactionCleanups = doc._transactionCleanups
|
||||||
|
let initialCall = false
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let result = null
|
||||||
|
if (doc._transaction === null) {
|
||||||
|
initialCall = true
|
||||||
|
doc._transaction = new Transaction(doc, origin, local)
|
||||||
|
transactionCleanups.push(doc._transaction)
|
||||||
|
if (transactionCleanups.length === 1) {
|
||||||
|
doc.emit('beforeAllTransactions', [doc])
|
||||||
|
}
|
||||||
|
doc.emit('beforeTransaction', [doc._transaction, doc])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
result = f(doc._transaction)
|
||||||
|
} finally {
|
||||||
|
if (initialCall) {
|
||||||
|
const finishCleanup = doc._transaction === transactionCleanups[0]
|
||||||
|
doc._transaction = null
|
||||||
|
if (finishCleanup) {
|
||||||
|
// The first transaction ended, now process observer calls.
|
||||||
|
// Observer call may create new transactions for which we need to call the observers and do cleanup.
|
||||||
|
// We don't want to nest these calls, so we execute these calls one after
|
||||||
|
// another.
|
||||||
|
// Also we need to ensure that all cleanups are called, even if the
|
||||||
|
// observes throw errors.
|
||||||
|
// This file is full of hacky try {} finally {} blocks to ensure that an
|
||||||
|
// event can throw errors and also that the cleanup is called.
|
||||||
|
cleanupTransactions(transactionCleanups, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
545
src/utils/UndoManager.js
Normal file
545
src/utils/UndoManager.js
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import * as time from 'lib0/time'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
import * as logging from 'lib0/logging'
|
||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
|
||||||
|
import { mergeIdSets, iterateStructsByIdSet, diffIdSet } from './ids.js'
|
||||||
|
import { Item, followRedone } from '../structs/Item.js'
|
||||||
|
|
||||||
|
import { transact } from './Transaction.js'
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { isParentOf } from './isParentOf.js'
|
||||||
|
import { getItemCleanStart } from './transaction-helpers.js'
|
||||||
|
import { Doc } from './Doc.js'
|
||||||
|
import { YNode } from '../ynode.js'
|
||||||
|
|
||||||
|
export class StackItem {
|
||||||
|
/**
|
||||||
|
* @param {IdSet} insertions
|
||||||
|
* @param {IdSet} deletions
|
||||||
|
*/
|
||||||
|
constructor (insertions, deletions) {
|
||||||
|
this.inserts = insertions
|
||||||
|
this.deletes = deletions
|
||||||
|
/**
|
||||||
|
* Use this to save and restore metadata like selection range
|
||||||
|
*/
|
||||||
|
this.meta = new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {Transaction} tr
|
||||||
|
* @param {UndoManager} um
|
||||||
|
* @param {StackItem} stackItem
|
||||||
|
*/
|
||||||
|
const clearUndoManagerStackItem = (tr, um, stackItem) => {
|
||||||
|
iterateStructsByIdSet(tr, stackItem.deletes, item => {
|
||||||
|
if (item instanceof Item && um.scope.some(type => type === tr.doc || isParentOf(/** @type {YNode} */ (type), item))) {
|
||||||
|
keepItem(item, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UndoManager} undoManager
|
||||||
|
* @param {Array<StackItem>} stack
|
||||||
|
* @param {'undo'|'redo'} eventType
|
||||||
|
* @return {StackItem?}
|
||||||
|
*/
|
||||||
|
const popStackItem = (undoManager, stack, eventType) => {
|
||||||
|
/**
|
||||||
|
* Keep a reference to the transaction so we can fire the event with the changedParentTypes
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let _tr = null
|
||||||
|
const doc = undoManager.doc
|
||||||
|
const scope = undoManager.scope
|
||||||
|
transact(doc, transaction => {
|
||||||
|
while (stack.length > 0 && undoManager.currStackItem === null) {
|
||||||
|
const store = doc.store
|
||||||
|
const stackItem = /** @type {StackItem} */ (stack.pop())
|
||||||
|
/**
|
||||||
|
* @type {Set<Item>}
|
||||||
|
*/
|
||||||
|
const itemsToRedo = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Array<Item>}
|
||||||
|
*/
|
||||||
|
const itemsToDelete = []
|
||||||
|
let performedChange = false
|
||||||
|
iterateStructsByIdSet(transaction, stackItem.inserts, struct => {
|
||||||
|
if (struct instanceof Item) {
|
||||||
|
if (struct.redone !== null) {
|
||||||
|
let { item, diff } = followRedone(store, struct.id)
|
||||||
|
if (diff > 0) {
|
||||||
|
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
|
||||||
|
}
|
||||||
|
struct = item
|
||||||
|
}
|
||||||
|
if (!struct.deleted && scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), /** @type {Item} */ (struct)))) {
|
||||||
|
itemsToDelete.push(struct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
iterateStructsByIdSet(transaction, stackItem.deletes, struct => {
|
||||||
|
if (
|
||||||
|
struct instanceof Item &&
|
||||||
|
scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), struct)) &&
|
||||||
|
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
|
||||||
|
!stackItem.inserts.hasId(struct.id)
|
||||||
|
) {
|
||||||
|
itemsToRedo.add(struct)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
itemsToRedo.forEach(struct => {
|
||||||
|
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteAttributeChanges, undoManager) !== null || performedChange
|
||||||
|
})
|
||||||
|
// We want to delete in reverse order so that children are deleted before
|
||||||
|
// parents, so we have more information available when items are filtered.
|
||||||
|
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
|
||||||
|
const item = itemsToDelete[i]
|
||||||
|
if (undoManager.deleteFilter(item)) {
|
||||||
|
item.delete(transaction)
|
||||||
|
performedChange = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
undoManager.currStackItem = performedChange ? stackItem : null
|
||||||
|
}
|
||||||
|
transaction.changed.forEach((subProps, type) => {
|
||||||
|
// destroy search marker if necessary
|
||||||
|
if (subProps.has(null) && type._searchMarker) {
|
||||||
|
type._searchMarker.length = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_tr = transaction
|
||||||
|
}, undoManager)
|
||||||
|
const res = undoManager.currStackItem
|
||||||
|
if (res != null) {
|
||||||
|
const changedParentTypes = _tr.changedParentTypes
|
||||||
|
undoManager.emit('stack-item-popped', [{ stackItem: res, type: eventType, changedParentTypes, origin: undoManager }, undoManager])
|
||||||
|
undoManager.currStackItem = null
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UndoManagerOptions
|
||||||
|
* @property {number} [UndoManagerOptions.captureTimeout=500]
|
||||||
|
* @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false.
|
||||||
|
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
|
||||||
|
* 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
|
||||||
|
* undo/redo scope.
|
||||||
|
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
|
||||||
|
* @property {boolean} [ignoreRemoteAttributeChanges] By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`)
|
||||||
|
* @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} StackItemEvent
|
||||||
|
* @property {StackItem} StackItemEvent.stackItem
|
||||||
|
* @property {any} StackItemEvent.origin
|
||||||
|
* @property {'undo'|'redo'} StackItemEvent.type
|
||||||
|
* @property {Map<YNode,Array<YEvent<any>>>} StackItemEvent.changedParentTypes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
|
||||||
|
* the redo-stack. You may store additional stack information via the
|
||||||
|
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
|
||||||
|
* Fires 'stack-item-popped' event when a stack item was popped from either the
|
||||||
|
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
|
||||||
|
*
|
||||||
|
* @extends {ObservableV2<{'stack-item-added':function(StackItemEvent, UndoManager):void, 'stack-item-popped': function(StackItemEvent, UndoManager):void, 'stack-cleared': function({ undoStackCleared: boolean, redoStackCleared: boolean }):void, 'stack-item-updated': function(StackItemEvent, UndoManager):void }>}
|
||||||
|
*/
|
||||||
|
export class UndoManager extends ObservableV2 {
|
||||||
|
/**
|
||||||
|
* @param {Doc|YNode|Array<YNode>} typeScope Limits the scope of the UndoManager. If this is set to a ydoc instance, all changes on that ydoc will be undone. If set to a specific type, only changes on that type or its children will be undone. Also accepts an array of types.
|
||||||
|
* @param {UndoManagerOptions} options
|
||||||
|
*/
|
||||||
|
constructor (typeScope, {
|
||||||
|
captureTimeout = 500,
|
||||||
|
captureTransaction = _tr => true,
|
||||||
|
deleteFilter = () => true,
|
||||||
|
trackedOrigins = new Set([null]),
|
||||||
|
ignoreRemoteAttributeChanges = false,
|
||||||
|
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc)
|
||||||
|
} = {}) {
|
||||||
|
super()
|
||||||
|
/**
|
||||||
|
* @type {Array<YNode | Doc>}
|
||||||
|
*/
|
||||||
|
this.scope = []
|
||||||
|
this.doc = doc
|
||||||
|
this.addToScope(typeScope)
|
||||||
|
this.deleteFilter = deleteFilter
|
||||||
|
trackedOrigins.add(this)
|
||||||
|
this.trackedOrigins = trackedOrigins
|
||||||
|
this.captureTransaction = captureTransaction
|
||||||
|
/**
|
||||||
|
* @type {Array<StackItem>}
|
||||||
|
*/
|
||||||
|
this.undoStack = []
|
||||||
|
/**
|
||||||
|
* @type {Array<StackItem>}
|
||||||
|
*/
|
||||||
|
this.redoStack = []
|
||||||
|
/**
|
||||||
|
* Whether the client is currently undoing (calling UndoManager.undo)
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
this.undoing = false
|
||||||
|
this.redoing = false
|
||||||
|
/**
|
||||||
|
* The currently popped stack item if UndoManager.undoing or UndoManager.redoing
|
||||||
|
*
|
||||||
|
* @type {StackItem|null}
|
||||||
|
*/
|
||||||
|
this.currStackItem = null
|
||||||
|
this.lastChange = 0
|
||||||
|
this.ignoreRemoteAttributeChanges = ignoreRemoteAttributeChanges
|
||||||
|
this.captureTimeout = captureTimeout
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
*/
|
||||||
|
this.afterTransactionHandler = transaction => {
|
||||||
|
// Only track certain transactions
|
||||||
|
if (
|
||||||
|
!this.captureTransaction(transaction) ||
|
||||||
|
!this.scope.some(type => transaction.changedParentTypes.has(/** @type {YNode} */ (type)) || type === this.doc) ||
|
||||||
|
(!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const undoing = this.undoing
|
||||||
|
const redoing = this.redoing
|
||||||
|
const stack = undoing ? this.redoStack : this.undoStack
|
||||||
|
if (undoing) {
|
||||||
|
this.stopCapturing() // next undo should not be appended to last stack item
|
||||||
|
} else if (!redoing) {
|
||||||
|
// neither undoing nor redoing: delete redoStack
|
||||||
|
this.clear(false, true)
|
||||||
|
}
|
||||||
|
const insertions = transaction.insertSet
|
||||||
|
const now = time.getUnixTime()
|
||||||
|
let didAdd = false
|
||||||
|
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
|
||||||
|
// append change to last stack op
|
||||||
|
const lastOp = stack[stack.length - 1]
|
||||||
|
lastOp.deletes = mergeIdSets([lastOp.deletes, transaction.deleteSet])
|
||||||
|
lastOp.inserts = mergeIdSets([lastOp.inserts, insertions])
|
||||||
|
} else {
|
||||||
|
// create a new stack op
|
||||||
|
stack.push(new StackItem(insertions, transaction.deleteSet))
|
||||||
|
didAdd = true
|
||||||
|
}
|
||||||
|
if (!undoing && !redoing) {
|
||||||
|
this.lastChange = now
|
||||||
|
}
|
||||||
|
// make sure that deleted structs are not gc'd
|
||||||
|
iterateStructsByIdSet(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
|
||||||
|
if (item instanceof Item && this.scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), item))) {
|
||||||
|
keepItem(item, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* @type {[StackItemEvent, UndoManager]}
|
||||||
|
*/
|
||||||
|
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
|
||||||
|
if (didAdd) {
|
||||||
|
this.emit('stack-item-added', changeEvent)
|
||||||
|
} else {
|
||||||
|
this.emit('stack-item-updated', changeEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.destroy = this.destroy.bind(this)
|
||||||
|
this.doc.on('afterTransaction', this.afterTransactionHandler)
|
||||||
|
this.doc.on('destroy', this.destroy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the scope.
|
||||||
|
*
|
||||||
|
* @param {Array<YNode | Doc> | YNode | Doc} ynodes
|
||||||
|
*/
|
||||||
|
addToScope (ynodes) {
|
||||||
|
const tmpSet = new Set(this.scope)
|
||||||
|
ynodes = array.isArray(ynodes) ? ynodes : [ynodes]
|
||||||
|
ynodes.forEach(ynode => {
|
||||||
|
if (!tmpSet.has(ynode)) {
|
||||||
|
tmpSet.add(ynode)
|
||||||
|
if (ynode instanceof YNode ? ynode.doc !== this.doc : ynode !== this.doc) logging.warn('[yjs#509] Not same Y.Doc') // use MultiDocUndoManager instead. also see https://github.com/yjs/yjs/issues/509
|
||||||
|
this.scope.push(ynode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} origin
|
||||||
|
*/
|
||||||
|
addTrackedOrigin (origin) {
|
||||||
|
this.trackedOrigins.add(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} origin
|
||||||
|
*/
|
||||||
|
removeTrackedOrigin (origin) {
|
||||||
|
this.trackedOrigins.delete(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear (clearUndoStack = true, clearRedoStack = true) {
|
||||||
|
if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
|
||||||
|
this.doc.transact(tr => {
|
||||||
|
if (clearUndoStack) {
|
||||||
|
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||||
|
this.undoStack = []
|
||||||
|
}
|
||||||
|
if (clearRedoStack) {
|
||||||
|
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
|
||||||
|
this.redoStack = []
|
||||||
|
}
|
||||||
|
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UndoManager merges Undo-StackItem if they are created within time-gap
|
||||||
|
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
|
||||||
|
* StackItem won't be merged.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // without stopCapturing
|
||||||
|
* ytext.insert(0, 'a')
|
||||||
|
* ytext.insert(1, 'b')
|
||||||
|
* um.undo()
|
||||||
|
* ytext.toString() // => '' (note that 'ab' was removed)
|
||||||
|
* // with stopCapturing
|
||||||
|
* ytext.insert(0, 'a')
|
||||||
|
* um.stopCapturing()
|
||||||
|
* ytext.insert(0, 'b')
|
||||||
|
* um.undo()
|
||||||
|
* ytext.toString() // => 'a' (note that only 'b' was removed)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
stopCapturing () {
|
||||||
|
this.lastChange = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo last changes on type.
|
||||||
|
*
|
||||||
|
* @return {StackItem?} Returns StackItem if a change was applied
|
||||||
|
*/
|
||||||
|
undo () {
|
||||||
|
this.undoing = true
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = popStackItem(this, this.undoStack, 'undo')
|
||||||
|
} finally {
|
||||||
|
this.undoing = false
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo last undo operation.
|
||||||
|
*
|
||||||
|
* @return {StackItem?} Returns StackItem if a change was applied
|
||||||
|
*/
|
||||||
|
redo () {
|
||||||
|
this.redoing = true
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = popStackItem(this, this.redoStack, 'redo')
|
||||||
|
} finally {
|
||||||
|
this.redoing = false
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are undo steps available?
|
||||||
|
*
|
||||||
|
* @return {boolean} `true` if undo is possible
|
||||||
|
*/
|
||||||
|
canUndo () {
|
||||||
|
return this.undoStack.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are redo steps available?
|
||||||
|
*
|
||||||
|
* @return {boolean} `true` if redo is possible
|
||||||
|
*/
|
||||||
|
canRedo () {
|
||||||
|
return this.redoStack.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
this.trackedOrigins.delete(this)
|
||||||
|
this.doc.off('afterTransaction', this.afterTransactionHandler)
|
||||||
|
this.doc.off('destroy', this.destroy)
|
||||||
|
super.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
*
|
||||||
|
* This is not guaranteed to work on documents with gc enabled!
|
||||||
|
*
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {ContentIds} contentIds
|
||||||
|
* @param {UndoManagerOptions} opts
|
||||||
|
*/
|
||||||
|
export const undoContentIds = (ydoc, contentIds, opts = {}) => {
|
||||||
|
const um = new UndoManager(ydoc, opts)
|
||||||
|
um.undoStack.push(new StackItem(diffIdSet(contentIds.inserts, contentIds.deletes), diffIdSet(contentIds.deletes, contentIds.inserts)))
|
||||||
|
um.undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<StackItem>} stack
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => s.deletes.hasId(id))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redoes the effect of this operation.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction The Yjs instance.
|
||||||
|
* @param {Item} item
|
||||||
|
* @param {Set<Item>} redoitems
|
||||||
|
* @param {IdSet} itemsToDelete
|
||||||
|
* @param {boolean} ignoreRemoteAttributeChanges
|
||||||
|
* @param {import('../utils/UndoManager.js').UndoManager} um
|
||||||
|
*
|
||||||
|
* @return {Item|null}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) => {
|
||||||
|
const doc = transaction.doc
|
||||||
|
const store = doc.store
|
||||||
|
const ownClientID = doc.clientID
|
||||||
|
const redone = item.redone
|
||||||
|
if (redone !== null) {
|
||||||
|
return getItemCleanStart(transaction, redone)
|
||||||
|
}
|
||||||
|
let parentItem = /** @type {YNode} */ (item.parent)._item
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let left = null
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let right
|
||||||
|
// make sure that parent is redone
|
||||||
|
if (parentItem !== null && parentItem.deleted === true) {
|
||||||
|
// try to undo parent if it will be undone anyway
|
||||||
|
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) === null)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
while (parentItem.redone !== null) {
|
||||||
|
parentItem = getItemCleanStart(transaction, parentItem.redone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {YNode}
|
||||||
|
*/
|
||||||
|
const parentType = /** @type {YNode} */ (parentItem === null ? item.parent : /** @type {ContentType} */ (parentItem.content).type)
|
||||||
|
|
||||||
|
if (item.parentSub === null) {
|
||||||
|
// Is an array item. Insert at the old position
|
||||||
|
left = item.left
|
||||||
|
right = item
|
||||||
|
// find next cloned_redo items
|
||||||
|
while (left !== null) {
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let leftTrace = left
|
||||||
|
// trace redone until parent matches
|
||||||
|
while (leftTrace !== null && /** @type {YNode} */ (leftTrace.parent)._item !== parentItem) {
|
||||||
|
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
|
||||||
|
}
|
||||||
|
if (leftTrace !== null && /** @type {YNode} */ (leftTrace.parent)._item === parentItem) {
|
||||||
|
left = leftTrace
|
||||||
|
break
|
||||||
|
}
|
||||||
|
left = left.left
|
||||||
|
}
|
||||||
|
while (right !== null) {
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let rightTrace = right
|
||||||
|
// trace redone until parent matches
|
||||||
|
while (rightTrace !== null && /** @type {YNode} */ (rightTrace.parent)._item !== parentItem) {
|
||||||
|
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
|
||||||
|
}
|
||||||
|
if (rightTrace !== null && /** @type {YNode} */ (rightTrace.parent)._item === parentItem) {
|
||||||
|
right = rightTrace
|
||||||
|
break
|
||||||
|
}
|
||||||
|
right = right.right
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
right = null
|
||||||
|
if (item.right && !ignoreRemoteAttributeChanges) {
|
||||||
|
left = item
|
||||||
|
// 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.
|
||||||
|
while (left !== null && left.right !== null && (left.right.redone || itemsToDelete.hasId(left.right.id) || isDeletedByUndoStack(um.undoStack, left.right.id) || isDeletedByUndoStack(um.redoStack, left.right.id))) {
|
||||||
|
left = left.right
|
||||||
|
// follow redone
|
||||||
|
while (left.redone) left = getItemCleanStart(transaction, left.redone)
|
||||||
|
}
|
||||||
|
if (left && left.right !== null) {
|
||||||
|
// It is not possible to redo this item because it conflicts with a
|
||||||
|
// change from another client
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
left = parentType._map.get(item.parentSub) || null
|
||||||
|
}
|
||||||
|
if (left !== null && /** @type {YNode} */ (left.parent)._item !== parentItem) {
|
||||||
|
left = parentType._map.get(item.parentSub) || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextClock = store.getClock(ownClientID)
|
||||||
|
const nextId = createID(ownClientID, nextClock)
|
||||||
|
const redoneItem = new Item(
|
||||||
|
nextId,
|
||||||
|
left, left && left.lastId,
|
||||||
|
right, right && right.id,
|
||||||
|
parentType,
|
||||||
|
item.parentSub,
|
||||||
|
item.content.copy()
|
||||||
|
)
|
||||||
|
item.redone = nextId
|
||||||
|
keepItem(redoneItem, true)
|
||||||
|
redoneItem.integrate(transaction, 0)
|
||||||
|
return redoneItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that neither item nor any of its parents is ever deleted.
|
||||||
|
*
|
||||||
|
* This property does not persist when storing it into a database or when
|
||||||
|
* sending it to other peers
|
||||||
|
*
|
||||||
|
* @param {Item|null} item
|
||||||
|
* @param {boolean} keep
|
||||||
|
*/
|
||||||
|
export const keepItem = (item, keep) => {
|
||||||
|
while (item !== null && item.keep !== keep) {
|
||||||
|
item.keep = keep
|
||||||
|
item = /** @type {YNode} */ (item.parent)._item
|
||||||
|
}
|
||||||
|
}
|
||||||
284
src/utils/UpdateDecoder.js
Normal file
284
src/utils/UpdateDecoder.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import * as buffer from 'lib0/buffer'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
|
||||||
|
import { ID, createID } from './ID.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {IdSetDecoderV1 | IdSetDecoderV2} IdSetDecoder
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class IdSetDecoderV1 {
|
||||||
|
/**
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
*/
|
||||||
|
constructor (decoder) {
|
||||||
|
this.restDecoder = decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDsCurVal () {
|
||||||
|
// nop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
readDsClock () {
|
||||||
|
return decoding.readVarUint(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
readDsLen () {
|
||||||
|
return decoding.readVarUint(this.restDecoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDecoderV1 extends IdSetDecoderV1 {
|
||||||
|
/**
|
||||||
|
* @return {ID}
|
||||||
|
*/
|
||||||
|
readLeftID () {
|
||||||
|
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {ID}
|
||||||
|
*/
|
||||||
|
readRightID () {
|
||||||
|
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the next client id.
|
||||||
|
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||||
|
*/
|
||||||
|
readClient () {
|
||||||
|
return decoding.readVarUint(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
readInfo () {
|
||||||
|
return decoding.readUint8(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
readString () {
|
||||||
|
return decoding.readVarString(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean} isKey
|
||||||
|
*/
|
||||||
|
readParentInfo () {
|
||||||
|
return decoding.readVarUint(this.restDecoder) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
readTypeRef () {
|
||||||
|
return decoding.readVarUint(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write len of a struct - well suited for Opt RLE encoder.
|
||||||
|
*
|
||||||
|
* @return {number} len
|
||||||
|
*/
|
||||||
|
readLen () {
|
||||||
|
return decoding.readVarUint(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
readAny () {
|
||||||
|
return decoding.readAny(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Uint8Array}
|
||||||
|
*/
|
||||||
|
readBuf () {
|
||||||
|
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy implementation uses JSON parse. We use any-decoding in v2.
|
||||||
|
*
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
readJSON () {
|
||||||
|
return JSON.parse(decoding.readVarString(this.restDecoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
readKey () {
|
||||||
|
return decoding.readVarString(this.restDecoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdSetDecoderV2 {
|
||||||
|
/**
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
*/
|
||||||
|
constructor (decoder) {
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.dsCurrVal = 0
|
||||||
|
this.restDecoder = decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDsCurVal () {
|
||||||
|
this.dsCurrVal = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
readDsClock () {
|
||||||
|
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
|
||||||
|
return this.dsCurrVal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
readDsLen () {
|
||||||
|
const diff = decoding.readVarUint(this.restDecoder) + 1
|
||||||
|
this.dsCurrVal += diff
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDecoderV2 extends IdSetDecoderV2 {
|
||||||
|
/**
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
*/
|
||||||
|
constructor (decoder) {
|
||||||
|
super(decoder)
|
||||||
|
/**
|
||||||
|
* List of cached keys. If the keys[id] does not exist, we read a new key
|
||||||
|
* from stringEncoder and push it to keys.
|
||||||
|
*
|
||||||
|
* @type {Array<string>}
|
||||||
|
*/
|
||||||
|
this.keys = []
|
||||||
|
decoding.readVarUint(decoder) // read feature flag - currently unused
|
||||||
|
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||||
|
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
|
||||||
|
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {ID}
|
||||||
|
*/
|
||||||
|
readLeftID () {
|
||||||
|
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {ID}
|
||||||
|
*/
|
||||||
|
readRightID () {
|
||||||
|
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the next client id.
|
||||||
|
* Use this in favor of readID whenever possible to reduce the number of objects created.
|
||||||
|
*/
|
||||||
|
readClient () {
|
||||||
|
return this.clientDecoder.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
readInfo () {
|
||||||
|
return /** @type {number} */ (this.infoDecoder.read())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
readString () {
|
||||||
|
return this.stringDecoder.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
readParentInfo () {
|
||||||
|
return this.parentInfoDecoder.read() === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number} An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
readTypeRef () {
|
||||||
|
return this.typeRefDecoder.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write len of a struct - well suited for Opt RLE encoder.
|
||||||
|
*
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
readLen () {
|
||||||
|
return this.lenDecoder.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
readAny () {
|
||||||
|
return decoding.readAny(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Uint8Array}
|
||||||
|
*/
|
||||||
|
readBuf () {
|
||||||
|
return decoding.readVarUint8Array(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is mainly here for legacy purposes.
|
||||||
|
*
|
||||||
|
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||||
|
*
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
readJSON () {
|
||||||
|
return decoding.readAny(this.restDecoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
readKey () {
|
||||||
|
const keyClock = this.keyClockDecoder.read()
|
||||||
|
if (keyClock < this.keys.length) {
|
||||||
|
return this.keys[keyClock]
|
||||||
|
} else {
|
||||||
|
const key = this.stringDecoder.read()
|
||||||
|
this.keys.push(key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/utils/UpdateEncoder.js
Normal file
323
src/utils/UpdateEncoder.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import * as error from 'lib0/error'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
|
||||||
|
export class IdSetEncoderV1 {
|
||||||
|
constructor () {
|
||||||
|
this.restEncoder = encoding.createEncoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
toUint8Array () {
|
||||||
|
return encoding.toUint8Array(this.restEncoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetIdSetCurVal () {
|
||||||
|
// nop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} clock
|
||||||
|
*/
|
||||||
|
writeIdSetClock (clock) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} len
|
||||||
|
*/
|
||||||
|
writeIdSetLen (len) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateEncoderV1 extends IdSetEncoderV1 {
|
||||||
|
/**
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
writeLeftID (id) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, id.client)
|
||||||
|
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
writeRightID (id) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, id.client)
|
||||||
|
encoding.writeVarUint(this.restEncoder, id.clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use writeClient and writeClock instead of writeID if possible.
|
||||||
|
* @param {number} client
|
||||||
|
*/
|
||||||
|
writeClient (client) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
writeInfo (info) {
|
||||||
|
encoding.writeUint8(this.restEncoder, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} s
|
||||||
|
*/
|
||||||
|
writeString (s) {
|
||||||
|
encoding.writeVarString(this.restEncoder, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} isYKey
|
||||||
|
*/
|
||||||
|
writeParentInfo (isYKey) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
writeTypeRef (info) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write len of a struct - well suited for Opt RLE encoder.
|
||||||
|
*
|
||||||
|
* @param {number} len
|
||||||
|
*/
|
||||||
|
writeLen (len) {
|
||||||
|
encoding.writeVarUint(this.restEncoder, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} any
|
||||||
|
*/
|
||||||
|
writeAny (any) {
|
||||||
|
encoding.writeAny(this.restEncoder, any)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} buf
|
||||||
|
*/
|
||||||
|
writeBuf (buf) {
|
||||||
|
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} embed
|
||||||
|
*/
|
||||||
|
writeJSON (embed) {
|
||||||
|
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
writeKey (key) {
|
||||||
|
encoding.writeVarString(this.restEncoder, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdSetEncoderV2 {
|
||||||
|
constructor () {
|
||||||
|
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
|
||||||
|
this.dsCurrVal = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
toUint8Array () {
|
||||||
|
return encoding.toUint8Array(this.restEncoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetIdSetCurVal () {
|
||||||
|
this.dsCurrVal = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} clock
|
||||||
|
*/
|
||||||
|
writeIdSetClock (clock) {
|
||||||
|
const diff = clock - this.dsCurrVal
|
||||||
|
this.dsCurrVal = clock
|
||||||
|
encoding.writeVarUint(this.restEncoder, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} len
|
||||||
|
*/
|
||||||
|
writeIdSetLen (len) {
|
||||||
|
if (len === 0) {
|
||||||
|
error.unexpectedCase()
|
||||||
|
}
|
||||||
|
encoding.writeVarUint(this.restEncoder, len - 1)
|
||||||
|
this.dsCurrVal += len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateEncoderV2 extends IdSetEncoderV2 {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
/**
|
||||||
|
* @type {Map<string,number>}
|
||||||
|
*/
|
||||||
|
this.keyMap = new Map()
|
||||||
|
/**
|
||||||
|
* Refers to the next unique key-identifier to me used.
|
||||||
|
* See writeKey method for more information.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.keyClock = 0
|
||||||
|
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||||
|
this.clientEncoder = new encoding.UintOptRleEncoder()
|
||||||
|
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||||
|
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
|
||||||
|
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||||
|
this.stringEncoder = new encoding.StringEncoder()
|
||||||
|
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
|
||||||
|
this.typeRefEncoder = new encoding.UintOptRleEncoder()
|
||||||
|
this.lenEncoder = new encoding.UintOptRleEncoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
toUint8Array () {
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
encoding.writeVarUint(encoder, 0) // this is a feature flag that we might use in the future
|
||||||
|
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
|
||||||
|
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
|
||||||
|
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
|
||||||
|
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
|
||||||
|
// @note The rest encoder is appended! (note the missing var)
|
||||||
|
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
|
||||||
|
return encoding.toUint8Array(encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
writeLeftID (id) {
|
||||||
|
this.clientEncoder.write(id.client)
|
||||||
|
this.leftClockEncoder.write(id.clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ID} id
|
||||||
|
*/
|
||||||
|
writeRightID (id) {
|
||||||
|
this.clientEncoder.write(id.client)
|
||||||
|
this.rightClockEncoder.write(id.clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} client
|
||||||
|
*/
|
||||||
|
writeClient (client) {
|
||||||
|
this.clientEncoder.write(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
writeInfo (info) {
|
||||||
|
this.infoEncoder.write(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} s
|
||||||
|
*/
|
||||||
|
writeString (s) {
|
||||||
|
this.stringEncoder.write(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} isYKey
|
||||||
|
*/
|
||||||
|
writeParentInfo (isYKey) {
|
||||||
|
this.parentInfoEncoder.write(isYKey ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} info An unsigned 8-bit integer
|
||||||
|
*/
|
||||||
|
writeTypeRef (info) {
|
||||||
|
this.typeRefEncoder.write(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write len of a struct - well suited for Opt RLE encoder.
|
||||||
|
*
|
||||||
|
* @param {number} len
|
||||||
|
*/
|
||||||
|
writeLen (len) {
|
||||||
|
this.lenEncoder.write(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} any
|
||||||
|
*/
|
||||||
|
writeAny (any) {
|
||||||
|
encoding.writeAny(this.restEncoder, any)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} buf
|
||||||
|
*/
|
||||||
|
writeBuf (buf) {
|
||||||
|
encoding.writeVarUint8Array(this.restEncoder, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is mainly here for legacy purposes.
|
||||||
|
*
|
||||||
|
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
|
||||||
|
*
|
||||||
|
* @param {any} embed
|
||||||
|
*/
|
||||||
|
writeJSON (embed) {
|
||||||
|
encoding.writeAny(this.restEncoder, embed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
|
||||||
|
* occur very often. For a 3d application, the key `position` might occur very often.
|
||||||
|
*
|
||||||
|
* We cache these keys in a Map and refer to them via a unique number.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
writeKey (key) {
|
||||||
|
const clock = this.keyMap.get(key)
|
||||||
|
if (clock === undefined) {
|
||||||
|
/**
|
||||||
|
* @todo uncomment to introduce this feature finally
|
||||||
|
*
|
||||||
|
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
|
||||||
|
* Furthermore, I forgot to set the keyclock. So everything was working fine.
|
||||||
|
*
|
||||||
|
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
|
||||||
|
*
|
||||||
|
* I don't know yet how to reintroduce this feature..
|
||||||
|
*
|
||||||
|
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
// this.keyMap.set(key, this.keyClock)
|
||||||
|
this.keyClockEncoder.write(this.keyClock++)
|
||||||
|
this.stringEncoder.write(key)
|
||||||
|
} else {
|
||||||
|
this.keyClockEncoder.write(clock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {IdSetEncoderV1 | IdSetEncoderV2} IdSetEncoder
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @typedef {UpdateEncoderV1 | UpdateEncoderV2} UpdateEncoder
|
||||||
|
*/
|
||||||
194
src/utils/YEvent.js
Normal file
194
src/utils/YEvent.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as set from 'lib0/set'
|
||||||
|
|
||||||
|
import { diffIdSet, intersectSets, mergeIdSets } from './ids.js'
|
||||||
|
import { createAbsolutePositionFromRelativePosition, createRelativePosition } from './RelativePosition.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template {DeltaConf} DConf
|
||||||
|
* YEvent describes the changes on a YNode.
|
||||||
|
*/
|
||||||
|
export class YEvent {
|
||||||
|
/**
|
||||||
|
* @param {YNode<DConf>} target The changed type.
|
||||||
|
* @param {import('./Transaction.js').Transaction} transaction
|
||||||
|
* @param {Set<any>?} subs The keys that changed
|
||||||
|
*/
|
||||||
|
constructor (target, transaction, subs) {
|
||||||
|
/**
|
||||||
|
* The type on which this event was created on.
|
||||||
|
* @type {import('../ynode.js').YNode<DConf>}
|
||||||
|
*/
|
||||||
|
this.target = target
|
||||||
|
/**
|
||||||
|
* The current target on which the observe callback is called.
|
||||||
|
* @type {YNode<any>}
|
||||||
|
*/
|
||||||
|
this.currentTarget = target
|
||||||
|
/**
|
||||||
|
* The transaction that triggered this event.
|
||||||
|
* @type {import('./Transaction.js').Transaction}
|
||||||
|
*/
|
||||||
|
this.transaction = transaction
|
||||||
|
/**
|
||||||
|
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>|null}
|
||||||
|
*/
|
||||||
|
this._delta = null
|
||||||
|
/**
|
||||||
|
* @type {Delta<DConf>|null}
|
||||||
|
*/
|
||||||
|
this._deltaDeep = null
|
||||||
|
/**
|
||||||
|
* Whether the children changed.
|
||||||
|
* @type {Boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this.childListChanged = false
|
||||||
|
/**
|
||||||
|
* Set of all changed attributes.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
this.keysChanged = new Set()
|
||||||
|
subs?.forEach((sub) => {
|
||||||
|
if (sub === null) {
|
||||||
|
this.childListChanged = true
|
||||||
|
} else {
|
||||||
|
this.keysChanged.add(sub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a struct is deleted by this event.
|
||||||
|
*
|
||||||
|
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||||
|
*
|
||||||
|
* @param {AbstractStruct} struct
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
deletes (struct) {
|
||||||
|
return this.transaction.deleteSet.hasId(struct.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a struct is added by this event.
|
||||||
|
*
|
||||||
|
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
|
||||||
|
*
|
||||||
|
* @param {AbstractStruct} struct
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
adds (struct) {
|
||||||
|
return this.transaction.insertSet.hasId(struct.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template {boolean} [Deep=false]
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to the target type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
|
||||||
|
* @param {Deep} [opts.deep]
|
||||||
|
* @return {Deep extends true ? Delta<DConf> : Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getDelta ({ renderer = this.target._renderer, deep } = {}) {
|
||||||
|
const insertSet = this.transaction.insertSet
|
||||||
|
const deleteSet = this.transaction.deleteSet
|
||||||
|
// content that was both inserted AND deleted by this transaction is normally invisible — but a
|
||||||
|
// renderer may still render it (e.g. remote content integrated under a suggestion-deleted
|
||||||
|
// parent is auto-deleted yet rendered with a delete attribution). Render
|
||||||
|
// `(I ∪ D) − ((I ∩ D) − renderer.attributed)`: the symmetric difference plus the attributed
|
||||||
|
// part of the intersection.
|
||||||
|
const both = intersectSets(insertSet, deleteSet)
|
||||||
|
const itemsToRender = mergeIdSets(
|
||||||
|
(both.isEmpty() || renderer === null)
|
||||||
|
? [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet)]
|
||||||
|
: [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet), intersectSets(both, renderer.attributed)]
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* @todo this should be done only one in the transaction step
|
||||||
|
*
|
||||||
|
* @type {Map<YNode,Set<string|null>>|null}
|
||||||
|
*/
|
||||||
|
let modified = this.transaction.changed
|
||||||
|
if (deep) {
|
||||||
|
// need to add deep changes to copy of modified
|
||||||
|
const dchanged = new Map()
|
||||||
|
modified.forEach((attrs, type) => {
|
||||||
|
dchanged.set(type, new Set(attrs))
|
||||||
|
})
|
||||||
|
for (let m of modified.keys()) {
|
||||||
|
while (m._item != null) {
|
||||||
|
const item = m._item
|
||||||
|
const ms = map.setIfUndefined(dchanged, item?.parent, set.create)
|
||||||
|
if (item && !ms.has(item.parentSub)) {
|
||||||
|
ms.add(item.parentSub)
|
||||||
|
m = /** @type {any} */ (item.parent)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modified = dchanged
|
||||||
|
}
|
||||||
|
return /** @type {any} */ (this.target.toDelta({ renderer, itemsToRender, retainDeletes: true, insertedItems: insertSet, deep: !!deep, modified }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the changes in the delta format.
|
||||||
|
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
|
||||||
|
*
|
||||||
|
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
get delta () {
|
||||||
|
return /** @type {any} */ (this._delta ?? (this._delta = this.getDelta().done()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the changes in the delta format.
|
||||||
|
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
|
||||||
|
*
|
||||||
|
* @type {Delta<DConf>} The Delta representation of this type.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
get deltaDeep () {
|
||||||
|
return /** @type {any} */ (this._deltaDeep ?? (this._deltaDeep = /** @type {any} */ (this.getDelta({ deep: true }))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the path from this type to the specified target.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // `child` should be accessible via `type.get(path[0]).get(path[1])..`
|
||||||
|
* const path = type.getPathTo(child)
|
||||||
|
* // assuming `type instanceof YArray`
|
||||||
|
* console.log(path) // might look like => [2, 'key1']
|
||||||
|
* child === type.get(path[0]).get(path[1])
|
||||||
|
*
|
||||||
|
* @param {YNode} parent
|
||||||
|
* @param {YNode} child target
|
||||||
|
* @param {AbstractRenderer?} renderer
|
||||||
|
* @return {Array<string|number>} Path to the target
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const getPathTo = (parent, child, renderer = null) => {
|
||||||
|
const path = []
|
||||||
|
const doc = /** @type {Doc} */ (parent.doc)
|
||||||
|
while (child._item !== null && child !== parent) {
|
||||||
|
if (child._item.parentSub !== null) {
|
||||||
|
// parent is map-ish
|
||||||
|
path.unshift(child._item.parentSub)
|
||||||
|
} else {
|
||||||
|
const parent = /** @type {import('../ynode.js').YNode} */ (child._item.parent)
|
||||||
|
// parent is array-ish
|
||||||
|
const apos = /** @type {import('../utils/RelativePosition.js').AbsolutePosition} */ (createAbsolutePositionFromRelativePosition(createRelativePosition(parent, child._item.id), doc, false, renderer))
|
||||||
|
path.unshift(apos.index)
|
||||||
|
}
|
||||||
|
child = /** @type {YNode} */ (child._item.parent)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
0
src/utils/content-helper.js
Normal file
0
src/utils/content-helper.js
Normal file
32
src/utils/delta-helpers.js
Normal file
32
src/utils/delta-helpers.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
import { createInsertSetFromStructStore, createDeleteSetFromStructStore, diffIdSet, mergeIdSets } from './ids.js'
|
||||||
|
import { createDiffRenderer } from './Renderer.js'
|
||||||
|
import { computeModifiedFromItems } from '../ynode.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} v1
|
||||||
|
* @param {Doc} v2
|
||||||
|
* @return {delta.DeltaBuilderAny}
|
||||||
|
*/
|
||||||
|
export const diffDocsToDelta = (v1, v2, { renderer = createDiffRenderer(v1, v2) } = {}) => {
|
||||||
|
const insertDiff = diffIdSet(createInsertSetFromStructStore(v2.store, false), createInsertSetFromStructStore(v1.store, false))
|
||||||
|
const deleteDiff = diffIdSet(createDeleteSetFromStructStore(v2.store), createDeleteSetFromStructStore(v1.store))
|
||||||
|
// don't render items that have been inserted and then deleted
|
||||||
|
const insertsOnly = diffIdSet(insertDiff, deleteDiff)
|
||||||
|
const itemsToRender = mergeIdSets([insertsOnly, deleteDiff])
|
||||||
|
/**
|
||||||
|
* @type {Map<YNode, Set<string|null>>}
|
||||||
|
*/
|
||||||
|
const changedTypes = computeModifiedFromItems(v2.store, itemsToRender)
|
||||||
|
const d = delta.create()
|
||||||
|
v2.share.forEach((type, typename) => {
|
||||||
|
const typeConf = changedTypes.get(type)
|
||||||
|
if (typeConf) {
|
||||||
|
const shareDelta = type.toDelta({
|
||||||
|
renderer, itemsToRender, retainDeletes: true, modified: changedTypes, deep: true
|
||||||
|
})
|
||||||
|
d.modifyAttr(typename, shareDelta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
110
src/utils/encoding-helpers.js
Normal file
110
src/utils/encoding-helpers.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
|
||||||
|
import { findIndexSS } from './transaction-helpers.js'
|
||||||
|
import { Skip } from '../structs/Skip.js'
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { writeIdSet } from './ids.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {Array<GC|Item|Skip>} structs All structs by `client`
|
||||||
|
* @param {number} client
|
||||||
|
* @param {Array<IdRange>} idranges
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeStructs = (encoder, structs, client, idranges) => {
|
||||||
|
let structsToWrite = 0 // this accounts for the skips
|
||||||
|
/**
|
||||||
|
* @type {Array<{ start: number, end: number, startClock: number, endClock: number }>}
|
||||||
|
*/
|
||||||
|
const indexRanges = []
|
||||||
|
const firstPossibleClock = structs[0].id.clock
|
||||||
|
const lastStruct = array.last(structs)
|
||||||
|
const lastPossibleClock = lastStruct.id.clock + lastStruct.length
|
||||||
|
idranges.forEach(idrange => {
|
||||||
|
const startClock = math.max(idrange.clock, firstPossibleClock)
|
||||||
|
const endClock = math.min(idrange.clock + idrange.len, lastPossibleClock)
|
||||||
|
if (startClock >= endClock) return // structs for this range do not exist
|
||||||
|
// inclusive start
|
||||||
|
const start = findIndexSS(structs, startClock)
|
||||||
|
// exclusive end
|
||||||
|
const end = findIndexSS(structs, endClock - 1) + 1
|
||||||
|
structsToWrite += end - start
|
||||||
|
indexRanges.push({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
startClock,
|
||||||
|
endClock
|
||||||
|
})
|
||||||
|
})
|
||||||
|
structsToWrite += idranges.length - 1
|
||||||
|
// start writing with this clock. this is updated to the next clock that we expect to write
|
||||||
|
let clock = indexRanges[0].startClock
|
||||||
|
// write # encoded structs
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, structsToWrite)
|
||||||
|
encoder.writeClient(client)
|
||||||
|
// write clock
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, clock)
|
||||||
|
indexRanges.forEach(indexRange => {
|
||||||
|
const skipLen = indexRange.startClock - clock
|
||||||
|
if (skipLen > 0) {
|
||||||
|
new Skip(createID(client, clock), skipLen).write(encoder, 0)
|
||||||
|
clock += skipLen
|
||||||
|
}
|
||||||
|
for (let i = indexRange.start; i < indexRange.end; i++) {
|
||||||
|
const struct = structs[i]
|
||||||
|
const structEnd = struct.id.clock + struct.length
|
||||||
|
const offsetEnd = math.max(structEnd - indexRange.endClock, 0)
|
||||||
|
struct.write(encoder, clock - struct.id.clock, offsetEnd)
|
||||||
|
clock = structEnd - offsetEnd
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {IdSet} idset
|
||||||
|
*
|
||||||
|
* @todo at the moment this writes the full deleteset range
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeStructsFromIdSet = (encoder, store, idset) => {
|
||||||
|
// write # states that were updated
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, idset.clients.size)
|
||||||
|
// Write items with higher client ids first
|
||||||
|
// This heavily improves the conflict algorithm.
|
||||||
|
array.from(idset.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, ids]) => {
|
||||||
|
const idRanges = ids.getIds()
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||||
|
writeStructs(encoder, structs, client, idRanges)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeStructsFromTransaction = (encoder, transaction) => writeStructsFromIdSet(encoder, transaction.doc.store, transaction.insertSet)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @return {boolean} Whether data was written.
|
||||||
|
*/
|
||||||
|
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
|
||||||
|
if (transaction.deleteSet.clients.size === 0 && transaction.insertSet.clients.size === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeStructsFromTransaction(encoder, transaction)
|
||||||
|
writeIdSet(encoder, transaction.deleteSet)
|
||||||
|
return true
|
||||||
|
}
|
||||||
697
src/utils/encoding.js
Normal file
697
src/utils/encoding.js
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
/**
|
||||||
|
* @module encoding
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* We use the first five bits in the info flag for determining the type of the struct.
|
||||||
|
*
|
||||||
|
* 0: GC
|
||||||
|
* 1: Item with Deleted content
|
||||||
|
* 2: Item with JSON content
|
||||||
|
* 3: Item with Binary content
|
||||||
|
* 4: Item with String content
|
||||||
|
* 5: Item with Embed content (for richtext content)
|
||||||
|
* 6: Item with Format content (a formatting marker for richtext content)
|
||||||
|
* 7: Item with Type
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
|
||||||
|
import { getStateVector, StructStore } from './StructStore.js'
|
||||||
|
import { getItemCleanStart, getItemCleanEnd } from './transaction-helpers.js'
|
||||||
|
import { createIdSet, IdRange, readAndApplyDeleteSet, readIdSet, mergeIdSets, writeIdSet } from './ids.js'
|
||||||
|
import { createID, ID } from './ID.js'
|
||||||
|
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1 } from './UpdateDecoder.js'
|
||||||
|
import { UpdateEncoderV1, UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||||
|
import { convertUpdateFormatV2ToV1, LazyStructReader, LazyStructWriter, writeStructToLazyStructWriter, finishLazyStructWriting } from './updates.js'
|
||||||
|
import { readBlockSet, writeBlockSet } from './BlockSet.js'
|
||||||
|
import { Skip } from '../structs/Skip.js'
|
||||||
|
import { Item } from '../structs/Item.js'
|
||||||
|
import { GC } from '../structs/GC.js'
|
||||||
|
import { Doc } from './Doc.js'
|
||||||
|
import { writeStructs } from './encoding-helpers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {Map<number,number>} _sm
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeClientsStructs = (encoder, store, _sm) => {
|
||||||
|
// we filter all valid _sm entries into sm
|
||||||
|
const sm = new Map()
|
||||||
|
_sm.forEach((clock, client) => {
|
||||||
|
// only write if new structs are available
|
||||||
|
if (store.getClock(client) > clock) {
|
||||||
|
sm.set(client, clock)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
getStateVector(store).forEach((_clock, client) => {
|
||||||
|
if (!_sm.has(client)) {
|
||||||
|
sm.set(client, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// write # states that were updated
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, sm.size)
|
||||||
|
// Write items with higher client ids first
|
||||||
|
// This heavily improves the conflict algorithm.
|
||||||
|
array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||||
|
const lastStruct = structs[structs.length - 1]
|
||||||
|
writeStructs(encoder, structs, client, [new IdRange(clock, lastStruct.id.clock + lastStruct.length - clock)])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume computing structs generated by struct readers.
|
||||||
|
*
|
||||||
|
* While there is something to do, we integrate structs in this order
|
||||||
|
* 1. top element on stack, if stack is not empty
|
||||||
|
* 2. next element from current struct reader (if empty, use next struct reader)
|
||||||
|
*
|
||||||
|
* If struct causally depends on another struct (ref.missing), we put next reader of
|
||||||
|
* `ref.id.client` on top of stack.
|
||||||
|
*
|
||||||
|
* At some point we find a struct that has no causal dependencies,
|
||||||
|
* then we start emptying the stack.
|
||||||
|
*
|
||||||
|
* It is not possible to have circles: i.e. struct1 (from client1) depends on struct2 (from client2)
|
||||||
|
* depends on struct3 (from client1). Therefore the max stack size is equal to `structReaders.length`.
|
||||||
|
*
|
||||||
|
* This method is implemented in a way so that we can resume computation if this update
|
||||||
|
* causally depends on another update.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {BlockSet} clientsStructRefs
|
||||||
|
* @return { null | { update: Uint8Array<ArrayBuffer>, missing: Map<number,number> } }
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
const integrateStructs = (transaction, store, clientsStructRefs) => {
|
||||||
|
/**
|
||||||
|
* @type {Array<Item | GC | Skip>}
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
let clientsStructRefsIds = array.from(clientsStructRefs.clients.keys()).sort((a, b) => a - b)
|
||||||
|
if (clientsStructRefsIds.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const getNextStructTarget = () => {
|
||||||
|
if (clientsStructRefsIds.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.clients.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||||
|
while (nextStructsTarget.refs.length === nextStructsTarget.i) {
|
||||||
|
clientsStructRefsIds.pop()
|
||||||
|
if (clientsStructRefsIds.length > 0) {
|
||||||
|
nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.clients.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextStructsTarget
|
||||||
|
}
|
||||||
|
let curStructsTarget = getNextStructTarget()
|
||||||
|
if (curStructsTarget === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {StructStore}
|
||||||
|
*/
|
||||||
|
const restStructs = new StructStore()
|
||||||
|
const missingSV = new Map()
|
||||||
|
/**
|
||||||
|
* @param {number} client
|
||||||
|
* @param {number} clock
|
||||||
|
*/
|
||||||
|
const updateMissingSv = (client, clock) => {
|
||||||
|
const mclock = missingSV.get(client)
|
||||||
|
if (mclock == null || mclock > clock) {
|
||||||
|
missingSV.set(client, clock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {GC|Item|Skip}
|
||||||
|
*/
|
||||||
|
let stackHead = /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
|
||||||
|
// caching the state because it is used very often
|
||||||
|
const state = new Map()
|
||||||
|
|
||||||
|
// // caching the state because it is used very often
|
||||||
|
// const currentInsertSet = createIdSet()
|
||||||
|
// clientsStructRefsIds.forEach(clientId => {
|
||||||
|
// currentInsertSet.clients.set(clientid, new IdRanges(_createInsertSliceFromStructs(store.clients.get(clientId) ?? [], false)))
|
||||||
|
// })
|
||||||
|
|
||||||
|
const addStackToRestSS = () => {
|
||||||
|
for (const item of stack) {
|
||||||
|
const client = item.id.client
|
||||||
|
const inapplicableItems = clientsStructRefs.clients.get(client)
|
||||||
|
if (inapplicableItems) {
|
||||||
|
// decrement because we weren't able to apply previous operation
|
||||||
|
inapplicableItems.i--
|
||||||
|
restStructs.clients.set(client, inapplicableItems.refs.slice(inapplicableItems.i))
|
||||||
|
clientsStructRefs.clients.delete(client)
|
||||||
|
inapplicableItems.i = 0
|
||||||
|
inapplicableItems.refs = []
|
||||||
|
} else {
|
||||||
|
// item was the last item on clientsStructRefs and the field was already cleared. Add item to restStructs and continue
|
||||||
|
restStructs.clients.set(client, [item])
|
||||||
|
}
|
||||||
|
// remove client from clientsStructRefsIds to prevent users from applying the same update again
|
||||||
|
clientsStructRefsIds = clientsStructRefsIds.filter(c => c !== client)
|
||||||
|
}
|
||||||
|
stack.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterate over all struct readers until we are done
|
||||||
|
while (true) {
|
||||||
|
if (stackHead.constructor !== Skip) {
|
||||||
|
const localClock = map.setIfUndefined(state, stackHead.id.client, () => store.getClock(stackHead.id.client))
|
||||||
|
const offset = localClock - stackHead.id.clock
|
||||||
|
const missing = getMissing(/** @type {any} */ (stackHead), transaction, store)
|
||||||
|
if (missing !== null) {
|
||||||
|
stack.push(stackHead)
|
||||||
|
// get the struct reader that has the missing struct
|
||||||
|
/**
|
||||||
|
* @type {{ refs: Array<GC|Item|Skip>, i: number }}
|
||||||
|
*/
|
||||||
|
const structRefs = clientsStructRefs.clients.get(/** @type {number} */ (missing)) || { refs: [], i: 0 }
|
||||||
|
if (structRefs.refs.length === structRefs.i || missing === stackHead.id.client || stack.some(s => s.id.client === missing)) { // @todo this could be optimized!
|
||||||
|
// This update message causally depends on another update message that doesn't exist yet
|
||||||
|
updateMissingSv(/** @type {number} */ (missing), store.getClock(missing))
|
||||||
|
addStackToRestSS()
|
||||||
|
} else {
|
||||||
|
stackHead = structRefs.refs[structRefs.i++]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// all fine, apply the stackhead
|
||||||
|
// but first add a skip to structs if necessary
|
||||||
|
if (offset < 0) {
|
||||||
|
const skip = new Skip(createID(stackHead.id.client, localClock), -offset)
|
||||||
|
skip.integrate(transaction, 0)
|
||||||
|
}
|
||||||
|
stackHead.integrate(transaction, 0)
|
||||||
|
state.set(stackHead.id.client, math.max(stackHead.id.clock + stackHead.length, localClock))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// iterate to next stackHead
|
||||||
|
if (stack.length > 0) {
|
||||||
|
stackHead = /** @type {GC|Item} */ (stack.pop())
|
||||||
|
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
|
||||||
|
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||||
|
} else {
|
||||||
|
curStructsTarget = getNextStructTarget()
|
||||||
|
if (curStructsTarget === null) {
|
||||||
|
// we are done!
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (restStructs.clients.size > 0) {
|
||||||
|
const encoder = new UpdateEncoderV2()
|
||||||
|
writeClientsStructs(encoder, restStructs, new Map())
|
||||||
|
// write empty deleteset
|
||||||
|
// writeDeleteSet(encoder, new DeleteSet())
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, 0) // => no need for an extra function call, just write 0 deletes
|
||||||
|
return { missing: missingSV, update: encoder.toUint8Array() }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and apply a document update.
|
||||||
|
*
|
||||||
|
* This function has the same effect as `applyUpdate` but accepts a decoder.
|
||||||
|
*
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||||
|
* @param {UpdateDecoderV1 | UpdateDecoderV2} [structDecoder]
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
|
||||||
|
ydoc.transact(transaction => {
|
||||||
|
// force that transaction.local is set to non-local
|
||||||
|
transaction.local = false
|
||||||
|
let retry = false
|
||||||
|
const doc = transaction.doc
|
||||||
|
const store = doc.store
|
||||||
|
// let start = performance.now()
|
||||||
|
const ss = readBlockSet(structDecoder)
|
||||||
|
const knownState = createIdSet()
|
||||||
|
ss.clients.forEach((_, client) => {
|
||||||
|
const storeStructs = store.clients.get(client)
|
||||||
|
if (storeStructs) {
|
||||||
|
const last = storeStructs[storeStructs.length - 1]
|
||||||
|
knownState.add(client, 0, last.id.clock + last.length)
|
||||||
|
// remove known items from ss
|
||||||
|
store.skips.clients.get(client)?.getIds().forEach(idrange => {
|
||||||
|
knownState.delete(client, idrange.clock, idrange.len)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// remove known items from ss
|
||||||
|
ss.exclude(knownState)
|
||||||
|
// console.log('time to read structs: ', performance.now() - start) // @todo remove
|
||||||
|
// start = performance.now()
|
||||||
|
// console.log('time to merge: ', performance.now() - start) // @todo remove
|
||||||
|
// start = performance.now()
|
||||||
|
const restStructs = integrateStructs(transaction, store, ss)
|
||||||
|
const pending = store.pendingStructs
|
||||||
|
if (pending) {
|
||||||
|
// check if we can apply something
|
||||||
|
for (const [client, clock] of pending.missing) {
|
||||||
|
if (ss.clients.has(client) || clock < store.getClock(client)) {
|
||||||
|
retry = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (restStructs) {
|
||||||
|
// merge restStructs into store.pending
|
||||||
|
for (const [client, clock] of restStructs.missing) {
|
||||||
|
const mclock = pending.missing.get(client)
|
||||||
|
if (mclock == null || mclock > clock) {
|
||||||
|
pending.missing.set(client, clock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.update = mergeUpdatesV2([pending.update, restStructs.update])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.pendingStructs = restStructs
|
||||||
|
}
|
||||||
|
// console.log('time to integrate: ', performance.now() - start) // @todo remove
|
||||||
|
// start = performance.now()
|
||||||
|
const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store)
|
||||||
|
if (store.pendingDs) {
|
||||||
|
// @todo we could make a lower-bound state-vector check as we do above
|
||||||
|
const pendingDSUpdate = new UpdateDecoderV2(decoding.createDecoder(store.pendingDs))
|
||||||
|
decoding.readVarUint(pendingDSUpdate.restDecoder) // read 0 structs, because we only encode deletes in pendingdsupdate
|
||||||
|
const dsRest2 = readAndApplyDeleteSet(pendingDSUpdate, transaction, store)
|
||||||
|
if (dsRest && dsRest2) {
|
||||||
|
// case 1: ds1 != null && ds2 != null
|
||||||
|
store.pendingDs = mergeUpdatesV2([dsRest, dsRest2])
|
||||||
|
} else {
|
||||||
|
// case 2: ds1 != null
|
||||||
|
// case 3: ds2 != null
|
||||||
|
// case 4: ds1 == null && ds2 == null
|
||||||
|
store.pendingDs = dsRest || dsRest2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Either dsRest == null && pendingDs == null OR dsRest != null
|
||||||
|
store.pendingDs = dsRest
|
||||||
|
}
|
||||||
|
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
|
||||||
|
// start = performance.now()
|
||||||
|
|
||||||
|
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
|
||||||
|
// start = performance.now()
|
||||||
|
if (retry) {
|
||||||
|
const update = /** @type {{update: Uint8Array}} */ (store.pendingStructs).update
|
||||||
|
store.pendingStructs = null
|
||||||
|
applyUpdateV2(transaction.doc, update)
|
||||||
|
}
|
||||||
|
}, transactionOrigin, false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and apply a document update.
|
||||||
|
*
|
||||||
|
* This function has the same effect as `applyUpdate` but accepts a decoder.
|
||||||
|
*
|
||||||
|
* @param {decoding.Decoder} decoder
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new UpdateDecoderV1(decoder))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||||
|
*
|
||||||
|
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||||
|
*
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||||
|
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const decoder = decoding.createDecoder(update)
|
||||||
|
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
|
||||||
|
*
|
||||||
|
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
|
||||||
|
*
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
|
||||||
|
* only write the operations that are missing.
|
||||||
|
*
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
|
||||||
|
writeClientsStructs(encoder, doc.store, targetStateVector)
|
||||||
|
writeIdSet(encoder, doc.store.ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||||
|
* only write the operations that are missing.
|
||||||
|
*
|
||||||
|
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||||
|
*
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} [encoder]
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
|
||||||
|
const targetStateVector = decodeStateVector(encodedTargetStateVector)
|
||||||
|
writeStateAsUpdate(encoder, doc, targetStateVector)
|
||||||
|
const updates = [encoder.toUint8Array()]
|
||||||
|
// also add the pending updates (if there are any)
|
||||||
|
if (doc.store.pendingDs) {
|
||||||
|
updates.push(doc.store.pendingDs)
|
||||||
|
}
|
||||||
|
if (doc.store.pendingStructs) {
|
||||||
|
updates.push(diffUpdateV2(doc.store.pendingStructs.update, encodedTargetStateVector))
|
||||||
|
}
|
||||||
|
if (updates.length > 1) {
|
||||||
|
if (encoder.constructor === UpdateEncoderV1) {
|
||||||
|
return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update)))
|
||||||
|
} else if (encoder.constructor === UpdateEncoderV2) {
|
||||||
|
return mergeUpdatesV2(updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
|
||||||
|
* only write the operations that are missing.
|
||||||
|
*
|
||||||
|
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
|
||||||
|
*
|
||||||
|
* @param {Doc} doc
|
||||||
|
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read state vector from Decoder and return as Map
|
||||||
|
*
|
||||||
|
* @param {IdSetDecoderV1 | IdSetDecoderV2} decoder
|
||||||
|
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const readStateVector = decoder => {
|
||||||
|
const ss = new Map()
|
||||||
|
const ssLength = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
for (let i = 0; i < ssLength; i++) {
|
||||||
|
const client = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
const clock = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
ss.set(client, clock)
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* This function works similarly to `readUpdateV2`.
|
||||||
|
*
|
||||||
|
* @param {Array<Uint8Array<ArrayBuffer>>} updates
|
||||||
|
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||||
|
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||||
|
if (updates.length === 1) {
|
||||||
|
return updates[0]
|
||||||
|
} else if (updates.length === 0) {
|
||||||
|
return encodeStateAsUpdateV2(new Doc(), new Uint8Array([0]), new YEncoder())
|
||||||
|
}
|
||||||
|
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
|
||||||
|
const blocksets = updateDecoders.map(dec => readBlockSet(dec))
|
||||||
|
|
||||||
|
const mergedBlockset = blocksets[0]
|
||||||
|
for (let i = 1; i < blocksets.length; i++) {
|
||||||
|
mergedBlockset.insertInto(blocksets[i])
|
||||||
|
}
|
||||||
|
const updateEncoder = new YEncoder()
|
||||||
|
writeBlockSet(updateEncoder, mergedBlockset)
|
||||||
|
const dss = updateDecoders.map(decoder => readIdSet(decoder))
|
||||||
|
const ds = mergeIdSets(dss)
|
||||||
|
writeIdSet(updateEncoder, ds)
|
||||||
|
return updateEncoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<Uint8Array<ArrayBuffer>>} updates
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const mergeUpdates = updates => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {Uint8Array} sv
|
||||||
|
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||||
|
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||||
|
*/
|
||||||
|
export const diffUpdateV2 = (update, sv, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||||
|
const state = decodeStateVector(sv)
|
||||||
|
const encoder = new YEncoder()
|
||||||
|
const lazyStructWriter = new LazyStructWriter(encoder)
|
||||||
|
const decoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const reader = new LazyStructReader(decoder, false)
|
||||||
|
while (reader.curr) {
|
||||||
|
const curr = reader.curr
|
||||||
|
const currClient = curr.id.client
|
||||||
|
const svClock = state.get(currClient) || 0
|
||||||
|
if (reader.curr.constructor === Skip) {
|
||||||
|
// the first written struct shouldn't be a skip
|
||||||
|
reader.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (curr.id.clock + curr.length > svClock) {
|
||||||
|
writeStructToLazyStructWriter(lazyStructWriter, curr, math.max(svClock - curr.id.clock, 0), 0)
|
||||||
|
reader.next()
|
||||||
|
while (reader.curr && reader.curr.id.client === currClient) {
|
||||||
|
writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0, 0)
|
||||||
|
reader.next()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// read until something new comes up
|
||||||
|
while (reader.curr && reader.curr.id.client === currClient && reader.curr.id.clock + reader.curr.length <= svClock) {
|
||||||
|
reader.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishLazyStructWriting(lazyStructWriter)
|
||||||
|
// write ds
|
||||||
|
const ds = readIdSet(decoder)
|
||||||
|
writeIdSet(encoder, ds)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @todo remove this in favor of intersectupdate
|
||||||
|
*
|
||||||
|
* @param {Uint8Array<ArrayBuffer>} update
|
||||||
|
* @param {Uint8Array<ArrayBuffer>} sv
|
||||||
|
*/
|
||||||
|
export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read decodedState and return State as Map.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} decodedState
|
||||||
|
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
// export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read decodedState and return State as Map.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} decodedState
|
||||||
|
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const decodeStateVector = decodedState => readStateVector(new IdSetDecoderV1(decoding.createDecoder(decodedState)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
|
||||||
|
* @param {Map<number,number>} sv
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeStateVector = (encoder, sv) => {
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, sv.size)
|
||||||
|
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, clock)
|
||||||
|
})
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
|
||||||
|
* @param {Doc} doc
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode State as Uint8Array.
|
||||||
|
*
|
||||||
|
* @param {Doc|Map<number,number>} doc
|
||||||
|
* @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const encodeStateVectorV2 = (doc, encoder = new IdSetEncoderV2()) => {
|
||||||
|
if (doc instanceof Map) {
|
||||||
|
writeStateVector(encoder, doc)
|
||||||
|
} else {
|
||||||
|
writeDocumentStateVector(encoder, doc)
|
||||||
|
}
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode State as Uint8Array.
|
||||||
|
*
|
||||||
|
* @param {Doc|Map<number,number>} doc
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const encodeStateVector = doc => encodeStateVectorV2(doc, new IdSetEncoderV1())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the creator clientID of the missing op or define missing items and return null.
|
||||||
|
*
|
||||||
|
* @param {Item} struct
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @return {null | number}
|
||||||
|
*/
|
||||||
|
const getMissing = (struct, transaction, store) => {
|
||||||
|
if (struct.constructor !== Item) return null
|
||||||
|
// we may not access these variables anymore after they have been written!
|
||||||
|
const origin = struct.origin
|
||||||
|
const rightOrigin = struct.rightOrigin
|
||||||
|
const parent = struct.parent
|
||||||
|
if (origin && (origin.clock >= store.getClock(origin.client) || store.skips.hasId(origin))) {
|
||||||
|
return origin.client
|
||||||
|
}
|
||||||
|
if (rightOrigin && (rightOrigin.clock >= store.getClock(rightOrigin.client) || store.skips.hasId(rightOrigin))) {
|
||||||
|
return rightOrigin.client
|
||||||
|
}
|
||||||
|
if (parent && parent.constructor === ID && (parent.clock >= store.getClock(parent.client) || store.skips.hasId(parent))) {
|
||||||
|
return parent.client
|
||||||
|
}
|
||||||
|
// We have all missing ids, now find the items
|
||||||
|
if (origin) {
|
||||||
|
struct.left = getItemCleanEnd(transaction, store, origin)
|
||||||
|
// copy left id to so that the original id can be gc'd
|
||||||
|
struct.origin = struct.left.lastId
|
||||||
|
}
|
||||||
|
if (rightOrigin) {
|
||||||
|
struct.right = getItemCleanStart(transaction, rightOrigin)
|
||||||
|
struct.rightOrigin = struct.right.id
|
||||||
|
}
|
||||||
|
if ((struct.left && struct.left.constructor === GC) || (struct.right && struct.right.constructor === GC)) {
|
||||||
|
struct.parent = null
|
||||||
|
} else if (parent == null) {
|
||||||
|
// only set parent if this shouldn't be garbage collected
|
||||||
|
if (struct.left && struct.left.constructor === Item) {
|
||||||
|
struct.parent = struct.left.parent
|
||||||
|
struct.parentSub = struct.left.parentSub
|
||||||
|
} else if (struct.right && struct.right.constructor === Item) {
|
||||||
|
struct.parent = struct.right.parent
|
||||||
|
struct.parentSub = struct.right.parentSub
|
||||||
|
}
|
||||||
|
} else if (parent.constructor === ID) {
|
||||||
|
const parentItem = store.getItem(parent)
|
||||||
|
if (parentItem.constructor === GC) {
|
||||||
|
struct.parent = null
|
||||||
|
} else {
|
||||||
|
struct.parent = /** @type {ContentType} */ (parentItem.content).type
|
||||||
|
}
|
||||||
|
} else if (typeof parent === 'string') {
|
||||||
|
struct.parent = transaction.doc.get(parent)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {import('./Doc.js').DocOpts} opts
|
||||||
|
*/
|
||||||
|
export const createDocFromUpdate = (update, opts = {}) => {
|
||||||
|
const ydoc = new Doc(opts)
|
||||||
|
applyUpdate(ydoc, update)
|
||||||
|
return ydoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {import('./Doc.js').DocOpts} opts
|
||||||
|
*/
|
||||||
|
export const createDocFromUpdateV2 = (update, opts = {}) => {
|
||||||
|
const ydoc = new Doc(opts)
|
||||||
|
applyUpdateV2(ydoc, update)
|
||||||
|
return ydoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Doc} ydoc
|
||||||
|
* @param {import('./Doc.js').DocOpts} [opts]
|
||||||
|
*/
|
||||||
|
export const cloneDoc = (ydoc, opts) => {
|
||||||
|
const clone = new Doc(opts)
|
||||||
|
applyUpdate(clone, encodeStateAsUpdate(ydoc))
|
||||||
|
return clone
|
||||||
|
}
|
||||||
1752
src/utils/ids.js
Normal file
1752
src/utils/ids.js
Normal file
File diff suppressed because it is too large
Load Diff
19
src/utils/isParentOf.js
Normal file
19
src/utils/isParentOf.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Check if `parent` is a parent of `child`.
|
||||||
|
*
|
||||||
|
* @param {YNode} parent
|
||||||
|
* @param {Item|null} child
|
||||||
|
* @return {Boolean} Whether `parent` is a parent of `child`.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const isParentOf = (parent, child) => {
|
||||||
|
while (child !== null) {
|
||||||
|
if (child.parent === parent) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
child = /** @type {YNode} */ (child.parent)._item
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
17
src/utils/logging.js
Normal file
17
src/utils/logging.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Convenient helper to log type information.
|
||||||
|
*
|
||||||
|
* Do not use in productive systems as the output can be immense!
|
||||||
|
*
|
||||||
|
* @param {YNode<any>} type
|
||||||
|
*/
|
||||||
|
export const logNode = type => {
|
||||||
|
const res = []
|
||||||
|
let n = type._start
|
||||||
|
while (n) {
|
||||||
|
res.push(n)
|
||||||
|
n = n.right
|
||||||
|
}
|
||||||
|
console.log('Children: ', res)
|
||||||
|
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
|
||||||
|
}
|
||||||
230
src/utils/meta.js
Normal file
230
src/utils/meta.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Meta API for describing Yjs documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as s from 'lib0/schema'
|
||||||
|
|
||||||
|
import {
|
||||||
|
$idSet,
|
||||||
|
$idMapAny,
|
||||||
|
createIdSetFromIdMap,
|
||||||
|
createDeleteSetFromStructStore,
|
||||||
|
createInsertSetFromStructStore,
|
||||||
|
diffIdMap,
|
||||||
|
mergeIdMaps,
|
||||||
|
createIdSet,
|
||||||
|
createIdMap,
|
||||||
|
mergeIdSets,
|
||||||
|
diffIdSet,
|
||||||
|
createIdMapFromIdSet,
|
||||||
|
writeIdSet,
|
||||||
|
readIdSet,
|
||||||
|
writeIdMap,
|
||||||
|
readIdMap,
|
||||||
|
intersectMaps,
|
||||||
|
intersectSets,
|
||||||
|
filterIdMap
|
||||||
|
} from './ids.js'
|
||||||
|
import { IdSetEncoderV2 } from './UpdateEncoder.js'
|
||||||
|
import { IdSetDecoderV2 } from './UpdateDecoder.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSet} inserts
|
||||||
|
* @param {IdSet} deletes
|
||||||
|
* @return {ContentIds}
|
||||||
|
*/
|
||||||
|
export const createContentIds = (inserts = createIdSet(), deletes = createIdSet()) => ({ inserts, deletes })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} contentMap
|
||||||
|
*/
|
||||||
|
export const createContentIdsFromContentMap = contentMap => createContentIds(
|
||||||
|
createIdSetFromIdMap(contentMap.inserts),
|
||||||
|
createIdSetFromIdMap(contentMap.deletes)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./Doc.js').Doc} ydoc
|
||||||
|
* @param {boolean} insertsContainDeletes - whether the returned `inserts` set includes content that
|
||||||
|
* the doc has already deleted. `true` yields a full structural snapshot (every inserted id);
|
||||||
|
* `false` yields only the doc's currently-visible content (`inserts − deletes`), which is what you
|
||||||
|
* want when the snapshot defines what should render.
|
||||||
|
*/
|
||||||
|
export const createContentIdsFromDoc = (ydoc, insertsContainDeletes) => createContentIds(
|
||||||
|
createInsertSetFromStructStore(ydoc.store, !insertsContainDeletes),
|
||||||
|
createDeleteSetFromStructStore(ydoc.store)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./Doc.js').Doc} ydocPrev
|
||||||
|
* @param {import('./Doc.js').Doc} ydocNext
|
||||||
|
*/
|
||||||
|
export const createContentIdsFromDocDiff = (ydocPrev, ydocNext) =>
|
||||||
|
excludeContentIds(createContentIdsFromDoc(ydocPrev, true), createContentIdsFromDoc(ydocNext, true))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentIds} content
|
||||||
|
* @param {ContentIds} excludeContent
|
||||||
|
*/
|
||||||
|
export const excludeContentIds = (content, excludeContent) =>
|
||||||
|
createContentIds(diffIdSet(content.inserts, excludeContent.inserts), diffIdSet(content.deletes, excludeContent.deletes))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} content
|
||||||
|
* @param {ContentIds | ContentMap} excludeContent
|
||||||
|
*/
|
||||||
|
export const excludeContentMap = (content, excludeContent) => createContentMap(
|
||||||
|
diffIdMap(content.inserts, excludeContent.inserts),
|
||||||
|
diffIdMap(content.deletes, excludeContent.deletes)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<ContentMap>} contents
|
||||||
|
*/
|
||||||
|
export const mergeContentMaps = contents => createContentMap(
|
||||||
|
mergeIdMaps(contents.map(c => c.inserts)),
|
||||||
|
mergeIdMaps(contents.map(c => c.deletes))
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<ContentIds>} contents
|
||||||
|
*/
|
||||||
|
export const mergeContentIds = contents => createContentIds(
|
||||||
|
mergeIdSets(contents.map(c => c.inserts)),
|
||||||
|
mergeIdSets(contents.map(c => c.deletes))
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdMap<any>} [inserts]
|
||||||
|
* @param {IdMap<any>} [deletes]
|
||||||
|
* @return {ContentMap}
|
||||||
|
*/
|
||||||
|
export const createContentMap = (inserts = createIdMap(), deletes = createIdMap()) => ({ inserts, deletes })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentIds} contentIds
|
||||||
|
* @param {Array<ContentAttribute<any>>} insertAttrs
|
||||||
|
* @param {Array<ContentAttribute<any>>} [deleteAttrs]
|
||||||
|
*/
|
||||||
|
export const createContentMapFromContentIds = (contentIds, insertAttrs, deleteAttrs = insertAttrs) => createContentMap(
|
||||||
|
createIdMapFromIdSet(contentIds.inserts, insertAttrs),
|
||||||
|
createIdMapFromIdSet(contentIds.deletes, deleteAttrs)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
|
||||||
|
* @param {ContentIds} contentIds
|
||||||
|
*/
|
||||||
|
export const writeContentIds = (encoder, contentIds) => {
|
||||||
|
writeIdSet(encoder, contentIds.inserts)
|
||||||
|
writeIdSet(encoder, contentIds.deletes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentIds} contentIds
|
||||||
|
*/
|
||||||
|
export const encodeContentIds = contentIds => {
|
||||||
|
const encoder = new IdSetEncoderV2()
|
||||||
|
writeContentIds(encoder, contentIds)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo this encoding needs to be heavily optimized for production
|
||||||
|
*
|
||||||
|
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
|
||||||
|
* @return {ContentIds}
|
||||||
|
*/
|
||||||
|
export const readContentIds = decoder => createContentIds(
|
||||||
|
readIdSet(decoder),
|
||||||
|
readIdSet(decoder)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array<any>} buf
|
||||||
|
*/
|
||||||
|
export const decodeContentIds = buf => readContentIds(new IdSetDecoderV2(decoding.createDecoder(buf)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo this encoding needs to be heavily optimized for production
|
||||||
|
*
|
||||||
|
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
|
||||||
|
* @param {ContentMap} contentMap
|
||||||
|
*/
|
||||||
|
export const writeContentMap = (encoder, contentMap) => {
|
||||||
|
writeIdMap(encoder, contentMap.inserts)
|
||||||
|
writeIdMap(encoder, contentMap.deletes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo this encoding needs to be heavily optimized for production
|
||||||
|
*
|
||||||
|
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
|
||||||
|
* @return {ContentMap} contentMap
|
||||||
|
*/
|
||||||
|
export const readContentMap = (decoder) => createContentMap(
|
||||||
|
readIdMap(decoder),
|
||||||
|
readIdMap(decoder)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} contentMap
|
||||||
|
*/
|
||||||
|
export const encodeContentMap = contentMap => {
|
||||||
|
const encoder = new IdSetEncoderV2()
|
||||||
|
writeContentMap(encoder, contentMap)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentMap} mapA
|
||||||
|
* @param {ContentMap|ContentIds} mapB
|
||||||
|
*/
|
||||||
|
export const intersectContentMap = (mapA, mapB) => createContentMap(
|
||||||
|
intersectMaps(mapA.inserts, mapB.inserts),
|
||||||
|
intersectMaps(mapA.deletes, mapB.deletes)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ContentIds} setA
|
||||||
|
* @param {ContentIds|ContentMap} setB
|
||||||
|
*/
|
||||||
|
export const intersectContentIds = (setA, setB) => createContentIds(
|
||||||
|
intersectSets(setA.inserts, setB.inserts),
|
||||||
|
intersectSets(setA.deletes, setB.deletes)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array<any>} buf
|
||||||
|
*/
|
||||||
|
export const decodeContentMap = buf => readContentMap(new IdSetDecoderV2(decoding.createDecoder(buf)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo filter by array of content instead
|
||||||
|
* @param {ContentMap} contentMap
|
||||||
|
* @param {(c:Array<ContentAttribute<any>>)=>boolean} insertPredicate
|
||||||
|
* @param {(c:Array<ContentAttribute<any>>)=>boolean} deletePredicate
|
||||||
|
*/
|
||||||
|
export const filterContentMap = (contentMap, insertPredicate, deletePredicate) => createContentMap(filterIdMap(contentMap.inserts, insertPredicate), filterIdMap(contentMap.deletes, deletePredicate))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema of {@link ContentIds} - the shape produced by {@link createContentIds}.
|
||||||
|
*
|
||||||
|
* Structural: a `ContentIds` is a plain object literal, so there is no prototype to tag. The
|
||||||
|
* *nominal* {@link $idSet} checks on the two fields are what discriminate it from a
|
||||||
|
* {@link ContentMap} in both directions.
|
||||||
|
*
|
||||||
|
* @type {s.Schema<ContentIds>}
|
||||||
|
*/
|
||||||
|
export const $contentIds = s.$object({ inserts: $idSet, deletes: $idSet })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema of {@link ContentMap} - the shape produced by {@link createContentMap}.
|
||||||
|
*
|
||||||
|
* See {@link $contentIds}. Use {@link $idMap} on the individual fields to additionally constrain
|
||||||
|
* the mapped-value type; `ContentMap` itself hardcodes `IdMap<any>`.
|
||||||
|
*
|
||||||
|
* @type {s.Schema<ContentMap>}
|
||||||
|
*/
|
||||||
|
export const $contentMap = s.$object({ inserts: $idMapAny, deletes: $idMapAny })
|
||||||
314
src/utils/position-helpers.js
Normal file
314
src/utils/position-helpers.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import * as dpos from 'lib0/delta/position'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
|
||||||
|
import { createRelativePosition, createAbsolutePositionFromRelativePosition } from './RelativePosition.js'
|
||||||
|
import { rendererContentLength } from './renderer-helpers.js'
|
||||||
|
import { getPathTo } from './YEvent.js'
|
||||||
|
import { isParentOf } from './isParentOf.js'
|
||||||
|
import { createID, findRootTypeKey } from './ID.js'
|
||||||
|
import { $doc } from './schemas.js'
|
||||||
|
import { Item, ContentType, followRedone } from '../structs/Item.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform lib0 delta positions ({@link DeltaPosition} — path-based, ephemeral, relative to a
|
||||||
|
* rendered delta tree) to {@link import('./RelativePosition.js').RelativePosition}s (item-ID-based,
|
||||||
|
* converging) and back. Use this to persist or sync cursors/marks (e.g. the result of
|
||||||
|
* `position.marksToPositions(node.delta)`) robustly through Yjs.
|
||||||
|
*
|
||||||
|
* Both directions operate on a set of positions and return results 1:1 with the input (`null` for
|
||||||
|
* positions that can't be resolved). Extra fields of a `MarkPos` (`id`, `attrs`) are ignored — zip
|
||||||
|
* them back yourself: `marksToPositions(d).map((m, i) => ({ id: m.id, rpos: rposs[i] }))`.
|
||||||
|
*
|
||||||
|
* A delta position is only meaningful against the delta produced by a specific renderer. Both
|
||||||
|
* transforms default to the root node's renderer (`root.useRenderer(..)`) — the same default that
|
||||||
|
* `node.toDelta()` / `node.delta` use — so positions taken from those deltas resolve correctly
|
||||||
|
* without options. When the delta was rendered differently (e.g. a doc-rooted `diffDocsToDelta`),
|
||||||
|
* pass that renderer explicitly.
|
||||||
|
*
|
||||||
|
* @module position-helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('lib0/delta/position').Pos} DeltaPosition
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./Renderer.js').AbstractRenderer} AbstractRenderer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>|Doc} root
|
||||||
|
* @param {{ renderer?: AbstractRenderer|null }} opts
|
||||||
|
* @return {AbstractRenderer|null}
|
||||||
|
*/
|
||||||
|
const resolveRenderer = (root, opts) => opts.renderer !== undefined ? opts.renderer : ($doc.check(root) ? null : root._renderer)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The child node at a rendered slot / map entry: node children are always ContentType items.
|
||||||
|
* Scalars (and subdocs) yield `null` — a delta position cannot descend into them.
|
||||||
|
*
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {YNode<any>|null}
|
||||||
|
*/
|
||||||
|
const contentTypeChild = item => item.content.constructor === ContentType ? /** @type {ContentType} */ (item.content).type : null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current map entry for `key`, unless it is absent or deleted without being rendered
|
||||||
|
* (mirrors the renderer-aware map lookup of `applyDelta`).
|
||||||
|
*
|
||||||
|
* @param {YNode<any>} node
|
||||||
|
* @param {string} key
|
||||||
|
* @param {AbstractRenderer|null} renderer
|
||||||
|
* @return {Item|null}
|
||||||
|
*/
|
||||||
|
const mapItemGet = (node, key, renderer) => {
|
||||||
|
const it = node._map.get(key)
|
||||||
|
return it === undefined || (it.deleted && rendererContentLength(renderer, it) === 0) ? null : it
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform delta positions (paths relative to `root`) to relative positions.
|
||||||
|
*
|
||||||
|
* Positions are resolved as a batch: one linked-list walk per visited node serves all positions
|
||||||
|
* inside it, so many cursors in the same text cost a single traversal.
|
||||||
|
*
|
||||||
|
* When `root` is a Doc, paths must start with a root-type name (matching the doc-rooted deltas of
|
||||||
|
* `diffDocsToDelta`). A terminal string step (attribute leaf, e.g. `['key']`) anchors the map
|
||||||
|
* entry's item — such a relative position round-trips through this module, but its index is
|
||||||
|
* meaningless to `createAbsolutePositionFromRelativePosition`. Unresolvable positions map to
|
||||||
|
* `null`: the empty path `[]` (a node reference doesn't round-trip), descent into scalars or
|
||||||
|
* subdocs, missing attributes, and out-of-range non-terminal indexes.
|
||||||
|
*
|
||||||
|
* @param {YNode<any>|Doc} root
|
||||||
|
* @param {Array<DeltaPosition>} positions
|
||||||
|
* @param {{ renderer?: AbstractRenderer|null }} [opts]
|
||||||
|
* @return {Array<import('./RelativePosition.js').RelativePosition|null>}
|
||||||
|
*/
|
||||||
|
export const createRelativePositionsFromDeltaPositions = (root, positions, opts = {}) => {
|
||||||
|
const renderer = resolveRenderer(root, opts)
|
||||||
|
/**
|
||||||
|
* @type {Array<import('./RelativePosition.js').RelativePosition|null>}
|
||||||
|
*/
|
||||||
|
const results = new Array(positions.length).fill(null)
|
||||||
|
/**
|
||||||
|
* @typedef {{ posIdx: number, pos: DeltaPosition, depth: number }} Task
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @type {Map<YNode<any>, Array<Task>>}
|
||||||
|
*/
|
||||||
|
let layer = new Map()
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
const pos = positions[i]
|
||||||
|
if (pos == null || pos.path.length === 0) continue
|
||||||
|
if ($doc.check(root)) {
|
||||||
|
const step0 = pos.path[0]
|
||||||
|
// doc-rooted paths start with a root-type name; root types are not items, so a terminal
|
||||||
|
// string at doc level has nothing to anchor
|
||||||
|
if (typeof step0 !== 'string' || pos.path.length === 1) continue
|
||||||
|
map.setIfUndefined(layer, root.get(step0), () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 1 })
|
||||||
|
} else {
|
||||||
|
map.setIfUndefined(layer, root, () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (layer.size > 0) {
|
||||||
|
/**
|
||||||
|
* @type {Map<YNode<any>, Array<Task>>}
|
||||||
|
*/
|
||||||
|
const nextLayer = new Map()
|
||||||
|
layer.forEach((tasks, node) => {
|
||||||
|
/**
|
||||||
|
* @type {Array<{ eff: number, isLast: boolean, task: Task }>}
|
||||||
|
*/
|
||||||
|
const walkTargets = []
|
||||||
|
for (const task of tasks) {
|
||||||
|
const { posIdx, pos, depth } = task
|
||||||
|
const step = pos.path[depth]
|
||||||
|
const isLast = depth === pos.path.length - 1
|
||||||
|
if (typeof step === 'string') {
|
||||||
|
const it = mapItemGet(node, step, renderer)
|
||||||
|
if (it === null) continue
|
||||||
|
if (isLast) {
|
||||||
|
// attribute leaf: anchor the item holding the attribute value
|
||||||
|
results[posIdx] = createRelativePosition(node, it.lastId, pos.assoc === -1 ? -1 : 0)
|
||||||
|
} else {
|
||||||
|
const child = contentTypeChild(it)
|
||||||
|
if (child !== null) {
|
||||||
|
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx, pos, depth: depth + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (Number.isInteger(step) && step >= 0) {
|
||||||
|
if (isLast && pos.assoc === -1 && step === 0) {
|
||||||
|
results[posIdx] = createRelativePosition(node, null, -1)
|
||||||
|
} else {
|
||||||
|
// a left-associated cursor gap binds to the preceding character (cf. createRelativePositionFromTypeIndex)
|
||||||
|
walkTargets.push({ eff: isLast && pos.assoc === -1 ? step - 1 : step, isLast, task })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (walkTargets.length > 0) {
|
||||||
|
// one walk over the item list resolves all targets of this node
|
||||||
|
walkTargets.sort((a, b) => a.eff - b.eff)
|
||||||
|
let item = node._start
|
||||||
|
let itemLen = item === null ? 0 : rendererContentLength(renderer, item)
|
||||||
|
let cum = 0
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let last = null
|
||||||
|
for (const { eff, isLast, task } of walkTargets) {
|
||||||
|
while (item !== null && cum + itemLen <= eff) {
|
||||||
|
cum += itemLen
|
||||||
|
last = item
|
||||||
|
item = item.right
|
||||||
|
itemLen = item === null ? 0 : rendererContentLength(renderer, item)
|
||||||
|
}
|
||||||
|
if (item !== null) {
|
||||||
|
const offset = eff - cum
|
||||||
|
if (isLast) {
|
||||||
|
results[task.posIdx] = createRelativePosition(node, createID(item.id.client, item.id.clock + offset), task.pos.assoc === -1 ? -1 : 0)
|
||||||
|
} else {
|
||||||
|
const child = contentTypeChild(item)
|
||||||
|
if (child !== null) {
|
||||||
|
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx: task.posIdx, pos: task.pos, depth: task.depth + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isLast) {
|
||||||
|
// walked past the end — same semantics as createRelativePositionFromTypeIndex
|
||||||
|
const yassoc = task.pos.assoc === -1 ? -1 : 0
|
||||||
|
results[task.posIdx] = createRelativePosition(node, yassoc < 0 && last !== null ? last.lastId : null, yassoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
layer = nextLayer
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>} node
|
||||||
|
* @param {AbstractRenderer|null} renderer
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
const renderedLength = (node, renderer) => {
|
||||||
|
let len = 0
|
||||||
|
for (let item = node._start; item !== null; item = item.right) {
|
||||||
|
len += rendererContentLength(renderer, item)
|
||||||
|
}
|
||||||
|
return len
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform relative positions to delta positions (paths relative to `root`).
|
||||||
|
*
|
||||||
|
* Resolution is batched: the path from `root` to a node is computed once per distinct node, however
|
||||||
|
* many positions live inside it. Positions outside of `root`'s subtree (or from another doc) map to
|
||||||
|
* `null`, as do attribute-leaf anchors whose attribute no longer exists.
|
||||||
|
*
|
||||||
|
* `followUndoneDeletions` (default `true`) matches `createAbsolutePositionFromRelativePosition` —
|
||||||
|
* set it to `false` for consistent results across clients (https://github.com/yjs/yjs/issues/638).
|
||||||
|
* The path from `root` down to the position's node never follows undone deletions.
|
||||||
|
*
|
||||||
|
* @param {YNode<any>|Doc} root
|
||||||
|
* @param {Array<import('./RelativePosition.js').RelativePosition|null>} rposs - `null` entries (e.g. from the forward transform) map to `null`
|
||||||
|
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
|
||||||
|
* @return {Array<DeltaPosition|null>}
|
||||||
|
*/
|
||||||
|
export const createDeltaPositionsFromRelativePositions = (root, rposs, opts = {}) => {
|
||||||
|
const renderer = resolveRenderer(root, opts)
|
||||||
|
const followUndoneDeletions = opts.followUndoneDeletions ?? true
|
||||||
|
const isDocRoot = $doc.check(root)
|
||||||
|
const doc = isDocRoot ? /** @type {Doc} */ (root) : /** @type {YNode<any>} */ (root).doc
|
||||||
|
/**
|
||||||
|
* @type {Array<DeltaPosition|null>}
|
||||||
|
*/
|
||||||
|
const results = new Array(rposs.length).fill(null)
|
||||||
|
if (doc === null) return results
|
||||||
|
const store = doc.store
|
||||||
|
/**
|
||||||
|
* @param {YNode<any>} node
|
||||||
|
* @return {Array<string|number>|null}
|
||||||
|
*/
|
||||||
|
const pathFromRoot = node => {
|
||||||
|
if (isDocRoot) {
|
||||||
|
if (node.doc !== doc) return null
|
||||||
|
let top = node
|
||||||
|
while (top._item !== null) {
|
||||||
|
top = /** @type {YNode<any>} */ (top._item.parent)
|
||||||
|
}
|
||||||
|
return [findRootTypeKey(top), ...getPathTo(top, node, renderer)]
|
||||||
|
}
|
||||||
|
if (node !== root && (node._item === null || !isParentOf(/** @type {YNode<any>} */ (root), node._item))) return null
|
||||||
|
return getPathTo(/** @type {YNode<any>} */ (root), node, renderer)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {Map<YNode<any>, Array<string|number>|null>}
|
||||||
|
*/
|
||||||
|
const pathCache = new Map()
|
||||||
|
/**
|
||||||
|
* @type {Map<YNode<any>, number>}
|
||||||
|
*/
|
||||||
|
const renderedLenCache = new Map()
|
||||||
|
for (let i = 0; i < rposs.length; i++) {
|
||||||
|
const rpos = rposs[i]
|
||||||
|
if (rpos == null) continue
|
||||||
|
/**
|
||||||
|
* @type {YNode<any>}
|
||||||
|
*/
|
||||||
|
let node
|
||||||
|
/**
|
||||||
|
* @type {string|number}
|
||||||
|
*/
|
||||||
|
let terminal
|
||||||
|
if (rpos.item !== null) {
|
||||||
|
const rightID = rpos.item
|
||||||
|
if (store.getClock(rightID.client) <= rightID.clock) continue
|
||||||
|
const { item } = followUndoneDeletions ? followRedone(store, rightID) : { item: store.getItem(rightID) }
|
||||||
|
if (!(item instanceof Item)) continue
|
||||||
|
if (item.parentSub !== null) {
|
||||||
|
// attribute leaf (created by createRelativePositionsFromDeltaPositions)
|
||||||
|
node = /** @type {YNode<any>} */ (item.parent)
|
||||||
|
if (mapItemGet(node, item.parentSub, renderer) === null) continue
|
||||||
|
terminal = item.parentSub
|
||||||
|
} else {
|
||||||
|
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
|
||||||
|
if (apos === null) continue
|
||||||
|
node = apos.type
|
||||||
|
terminal = apos.index
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
|
||||||
|
if (apos === null) continue
|
||||||
|
node = apos.type
|
||||||
|
// the end-of-type index of createAbsolutePositionFromRelativePosition is the raw length —
|
||||||
|
// a delta position must index the rendered tree
|
||||||
|
terminal = renderer !== null && rpos.assoc >= 0
|
||||||
|
? map.setIfUndefined(renderedLenCache, node, () => renderedLength(apos.type, renderer))
|
||||||
|
: apos.index
|
||||||
|
}
|
||||||
|
const prefix = map.setIfUndefined(pathCache, node, () => pathFromRoot(/** @type {YNode<any>} */ (node)))
|
||||||
|
if (prefix === null) continue
|
||||||
|
results[i] = dpos.create(prefix.concat([terminal]), rpos.assoc < 0 ? -1 : 1)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a single delta position to a relative position - see {@link createRelativePositionsFromDeltaPositions}.
|
||||||
|
*
|
||||||
|
* @param {YNode<any>|Doc} root
|
||||||
|
* @param {DeltaPosition} pos
|
||||||
|
* @param {{ renderer?: AbstractRenderer|null }} [opts]
|
||||||
|
* @return {import('./RelativePosition.js').RelativePosition|null}
|
||||||
|
*/
|
||||||
|
export const createRelativePositionFromDeltaPosition = (root, pos, opts) => createRelativePositionsFromDeltaPositions(root, [pos], opts)[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a single relative position to a delta position - see {@link createDeltaPositionsFromRelativePositions}.
|
||||||
|
*
|
||||||
|
* @param {YNode<any>|Doc} root
|
||||||
|
* @param {import('./RelativePosition.js').RelativePosition|null} rpos
|
||||||
|
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
|
||||||
|
* @return {DeltaPosition|null}
|
||||||
|
*/
|
||||||
|
export const createDeltaPositionFromRelativePosition = (root, rpos, opts) => createDeltaPositionsFromRelativePositions(root, [rpos], opts)[0]
|
||||||
123
src/utils/renderer-helpers.js
Normal file
123
src/utils/renderer-helpers.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import * as s from 'lib0/schema'
|
||||||
|
import * as error from 'lib0/error'
|
||||||
|
import { ObservableV2 } from 'lib0/observable'
|
||||||
|
|
||||||
|
import { createIdSet } from './ids.js'
|
||||||
|
|
||||||
|
export const attributionJsonSchema = s.$object({
|
||||||
|
insert: s.$array(s.$string).optional,
|
||||||
|
insertAt: s.$number.optional,
|
||||||
|
delete: s.$array(s.$string).optional,
|
||||||
|
deleteAt: s.$number.optional,
|
||||||
|
format: s.$record(s.$string, s.$array(s.$string)).optional,
|
||||||
|
formatAt: s.$number.optional
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo rename this to `insertBy`, `insertAt`, ..
|
||||||
|
*
|
||||||
|
* @typedef {s.Unwrap<typeof attributionJsonSchema>} Attribution
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
export class AttributedContent {
|
||||||
|
/**
|
||||||
|
* @param {AbstractContent} content
|
||||||
|
* @param {number} clock
|
||||||
|
* @param {boolean} deleted
|
||||||
|
* @param {Array<ContentAttribute<T>> | null} attrs
|
||||||
|
* @param {0|1|2|3} renderBehavior
|
||||||
|
*/
|
||||||
|
constructor (content, clock, deleted, attrs, renderBehavior) {
|
||||||
|
this.content = content
|
||||||
|
this.clock = clock
|
||||||
|
this.deleted = deleted
|
||||||
|
this.attrs = attrs
|
||||||
|
this.render = renderBehavior === 0 ? false : (renderBehavior === 1 ? (!deleted || attrs != null) : true)
|
||||||
|
/**
|
||||||
|
* Fresh content that was deleted in the same transaction (mode `3`): even in a
|
||||||
|
* `retainDeletes` render it must produce an *insert* — the consuming state (e.g. the
|
||||||
|
* maintained `delta` cache) has never seen this content, so there is nothing to retain.
|
||||||
|
*/
|
||||||
|
this.fresh = renderBehavior === 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for renderers. A renderer renders Content (with Attributions) to a delta.
|
||||||
|
*
|
||||||
|
* Should fire an event when the attributions changed _after_ the original change happens. This
|
||||||
|
* Event will be used to update the attribution on the current content.
|
||||||
|
*
|
||||||
|
* Only items claimed via {@link AbstractRenderer#hasItem} reach the renderer — everything else is
|
||||||
|
* rendered by the generic fast path (as-is, without attributions, deleted content invisible).
|
||||||
|
*
|
||||||
|
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
|
||||||
|
*/
|
||||||
|
export class AbstractRenderer extends ObservableV2 {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
/**
|
||||||
|
* Ids of the content this renderer may render non-normally (attributed, restored, hidden, …).
|
||||||
|
* May over-approximate — {@link AbstractRenderer#readContent} remains authoritative for what
|
||||||
|
* is actually rendered. Content outside this set is rendered by the generic fast path without
|
||||||
|
* consulting the renderer.
|
||||||
|
*
|
||||||
|
* @type {IdSet}
|
||||||
|
*/
|
||||||
|
this.attributed = createIdSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `item` (any part of its id range) must be rendered by this renderer. Items for which
|
||||||
|
* this returns `false` are rendered by the generic fast path.
|
||||||
|
*
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasItem (item) {
|
||||||
|
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<AttributedContent<any>>} _contents - where to write the result
|
||||||
|
* @param {number} _client
|
||||||
|
* @param {number} _clock
|
||||||
|
* @param {boolean} _deleted
|
||||||
|
* @param {AbstractContent} _content
|
||||||
|
* @param {0|1|2|3} _shouldRender - 0: if undeleted or attributed, render as a retain operation. 1: render only if undeleted or attributed. 2: render as insert operation (if unattributed and deleted, render as delete). 3: fresh content deleted in the same transaction, marked {@link AttributedContent#fresh} — render as insert where attributed (it must insert even in a `retainDeletes` render: the consuming state has never seen it), as *nothing* where unattributed (invisible; a delete op would misapply).
|
||||||
|
*/
|
||||||
|
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
|
||||||
|
error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the length of the attributed content. This is used by iterators that walk through the
|
||||||
|
* content.
|
||||||
|
*
|
||||||
|
* If the content is not countable, it should return 0.
|
||||||
|
*
|
||||||
|
* @param {Item} _item
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
contentLength (_item) {
|
||||||
|
error.methodUnimplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $renderer = AbstractRenderer.prototype.$type = s.$type('y:renderer', AbstractRenderer)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered length of `item` under `renderer`: the generic rule — alive countable content renders
|
||||||
|
* at full length, everything else at length `0` — unless the renderer claims the item.
|
||||||
|
*
|
||||||
|
* @param {AbstractRenderer?} renderer
|
||||||
|
* @param {Item} item
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
export const rendererContentLength = (renderer, item) =>
|
||||||
|
renderer !== null && renderer.hasItem(item)
|
||||||
|
? renderer.contentLength(item)
|
||||||
|
: ((item.deleted || !item.content.isCountable()) ? 0 : item.length)
|
||||||
3
src/utils/schemas.js
Normal file
3
src/utils/schemas.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import * as s from 'lib0/schema'
|
||||||
|
|
||||||
|
export const $doc = /** @type {s.Schema<Doc>} */ (s.$type('y:doc', null))
|
||||||
413
src/utils/transaction-helpers.js
Normal file
413
src/utils/transaction-helpers.js
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as error from 'lib0/error'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as set from 'lib0/set'
|
||||||
|
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These modules don't require any imports.
|
||||||
|
* These helpers are used by items to integrate themselves
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a binary search on a sorted array
|
||||||
|
* @param {Array<Item|GC|Skip>} structs
|
||||||
|
* @param {number} clock
|
||||||
|
* @return {number}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const findIndexSS = (structs, clock) => {
|
||||||
|
let left = 0
|
||||||
|
let right = structs.length - 1
|
||||||
|
let mid = structs[right]
|
||||||
|
let midclock = mid.id.clock
|
||||||
|
if (midclock === clock) {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
// @todo does it even make sense to pivot the search?
|
||||||
|
// If a good split misses, it might actually increase the time to find the correct item.
|
||||||
|
// Currently, the only advantage is that search with pivoting might find the item on the first try.
|
||||||
|
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
|
||||||
|
while (left <= right) {
|
||||||
|
mid = structs[midindex]
|
||||||
|
midclock = mid.id.clock
|
||||||
|
if (midclock <= clock) {
|
||||||
|
if (clock < midclock + mid.length) {
|
||||||
|
return midindex
|
||||||
|
}
|
||||||
|
left = midindex + 1
|
||||||
|
} else {
|
||||||
|
right = midindex - 1
|
||||||
|
}
|
||||||
|
midindex = math.floor((left + right) / 2)
|
||||||
|
}
|
||||||
|
// Always check state before looking for a struct in StructStore
|
||||||
|
// Therefore the case of not finding a struct is unexpected
|
||||||
|
throw error.unexpectedCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction?} transaction
|
||||||
|
* @param {Array<Item|GC|Skip>} structs
|
||||||
|
* @param {number} clock
|
||||||
|
*/
|
||||||
|
export const findIndexCleanStart = (transaction, structs, clock) => {
|
||||||
|
const index = findIndexSS(structs, clock)
|
||||||
|
const struct = structs[index]
|
||||||
|
if (struct.id.clock < clock) {
|
||||||
|
structs.splice(index + 1, 0, splitStruct(transaction, struct, clock - struct.id.clock))
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {ID} id
|
||||||
|
* @return {Item}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const getItemCleanStart = (transaction, id) => {
|
||||||
|
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
|
||||||
|
return structs[findIndexCleanStart(transaction, structs, id.clock)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {StructStore} store
|
||||||
|
* @param {ID} id
|
||||||
|
* @return {Item}
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const getItemCleanEnd = (transaction, store, id) => {
|
||||||
|
const structs = store.clients.get(id.client) || []
|
||||||
|
const index = findIndexSS(structs, id.clock)
|
||||||
|
const struct = structs[index]
|
||||||
|
if (id.clock !== struct.id.clock + struct.length - 1 && struct.isItem) {
|
||||||
|
structs.splice(index + 1, 0, /** @type {Item} */ (struct).split(transaction, id.clock - struct.id.clock + 1))
|
||||||
|
}
|
||||||
|
return /** @type {Item} */ (struct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace `item` with `newitem` in store
|
||||||
|
* @param {Transaction} tr
|
||||||
|
* @param {GC|Item} struct
|
||||||
|
* @param {GC|Item} newStruct
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const replaceStruct = (tr, struct, newStruct) => {
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (tr.doc.store.clients.get(struct.id.client))
|
||||||
|
structs[findIndexSS(structs, struct.id.clock)] = newStruct
|
||||||
|
tr._mergeStructs.push(newStruct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over a range of structs
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {Array<Item|GC>} structs
|
||||||
|
* @param {number} clockStart Inclusive start
|
||||||
|
* @param {number} len
|
||||||
|
* @param {function(GC|Item):void} f
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const iterateStructs = (transaction, structs, clockStart, len, f) => {
|
||||||
|
if (len === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clockEnd = clockStart + len
|
||||||
|
let index = findIndexCleanStart(transaction, structs, clockStart)
|
||||||
|
let struct
|
||||||
|
do {
|
||||||
|
struct = structs[index++]
|
||||||
|
if (clockEnd < struct.id.clock + struct.length) {
|
||||||
|
findIndexCleanStart(transaction, structs, clockEnd)
|
||||||
|
}
|
||||||
|
f(struct)
|
||||||
|
} while (index < structs.length && structs[index].id.clock < clockEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over a range of structs without splitting the structs. You will get content that you did
|
||||||
|
* not expect.
|
||||||
|
*
|
||||||
|
* @param {Array<Item|GC>} structs
|
||||||
|
* @param {number} clockStart Inclusive start
|
||||||
|
* @param {number} len
|
||||||
|
* @param {(struct: GC|Item|Skip, offset:number, len:number)=>void} f
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const iterateStructsWithoutSplits = (structs, clockStart, len, f) => {
|
||||||
|
if (len === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clockEnd = clockStart + len
|
||||||
|
for (
|
||||||
|
let index = findIndexSS(structs, clockStart), struct = structs[index], clock = struct.id.clock;
|
||||||
|
clock < clockEnd;
|
||||||
|
struct = structs[++index], clock = struct.id.clock
|
||||||
|
) {
|
||||||
|
const offset = clock < clockStart ? clockStart - clock : 0
|
||||||
|
f(struct, offset, math.min(clock + struct.length, clockEnd) - clock)
|
||||||
|
if (index + 1 === structs.length) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* More generalized version of splitItem. Split leftStruct into two structs
|
||||||
|
* @param {Transaction?} transaction
|
||||||
|
* @param {Item|GC|Skip} leftStruct
|
||||||
|
* @param {number} diff
|
||||||
|
* @return {GC|Item|Skip}
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export const splitStruct = (transaction, leftStruct, diff) => {
|
||||||
|
if (leftStruct.isItem) {
|
||||||
|
return /** @type {Item} */ (leftStruct).split(transaction, diff)
|
||||||
|
} else {
|
||||||
|
const rightItem = leftStruct.splice(diff)
|
||||||
|
transaction?._mergeStructs.push(rightItem)
|
||||||
|
return rightItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const nextID = transaction => {
|
||||||
|
const y = transaction.doc
|
||||||
|
return createID(y.clientID, y.store.getClock(y.clientID))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `type.parent` was added in current transaction, `type` technically
|
||||||
|
* did not change, it was just added and we should not fire events for `type`.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {YNode} type
|
||||||
|
* @param {string|null} parentSub
|
||||||
|
*/
|
||||||
|
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
|
||||||
|
const item = type._item
|
||||||
|
if (item === null || !transaction.insertSet.hasId(item.id)) {
|
||||||
|
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<GC | Item | Skip>} structs
|
||||||
|
* @param {number} pos
|
||||||
|
* @return {number} # of merged structs
|
||||||
|
*/
|
||||||
|
export const tryToMergeWithLefts = (structs, pos) => {
|
||||||
|
let right = structs[pos]
|
||||||
|
let left = structs[pos - 1]
|
||||||
|
let i = pos
|
||||||
|
for (; i > 0; right = left, left = structs[--i - 1]) {
|
||||||
|
if (left.deleted === right.deleted && left.constructor === right.constructor) {
|
||||||
|
if (left.mergeWith(/** @type {any} */ (right))) {
|
||||||
|
if (right.isItem && /** @type {Item} */ (right).parentSub !== null && /** @type {YNode} */ (/** @type {Item} */ (right).parent)._map.get(/** @type {Item} */ (right).parentSub) === right) {
|
||||||
|
/** @type {YNode} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const merged = pos - i
|
||||||
|
if (merged) {
|
||||||
|
// remove all merged structs from the array
|
||||||
|
structs.splice(pos + 1 - merged, merged)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} tr
|
||||||
|
* @param {IdSet} ds
|
||||||
|
* @param {function(Item):boolean} gcFilter
|
||||||
|
*/
|
||||||
|
export const tryGcDeleteSet = (tr, ds, gcFilter) => {
|
||||||
|
for (const [client, _deleteItems] of ds.clients.entries()) {
|
||||||
|
const deleteItems = _deleteItems.getIds()
|
||||||
|
const structs = /** @type {Array<Item>} */ (tr.doc.store.clients.get(client))
|
||||||
|
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||||
|
const deleteItem = deleteItems[di]
|
||||||
|
const endDeleteItemClock = deleteItem.clock + deleteItem.len
|
||||||
|
for (
|
||||||
|
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
|
||||||
|
si < structs.length && struct.id.clock < endDeleteItemClock;
|
||||||
|
struct = structs[++si]
|
||||||
|
) {
|
||||||
|
const struct = structs[si]
|
||||||
|
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (struct.isItem && struct.deleted && !(struct).keep && gcFilter(/** @type {Item} */ (struct))) {
|
||||||
|
/** @type {Item} */ (struct).gc(tr, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {IdSet} ds
|
||||||
|
* @param {StructStore} store
|
||||||
|
*/
|
||||||
|
export const tryMerge = (ds, store) => {
|
||||||
|
// try to merge deleted / gc'd items
|
||||||
|
// merge from right to left for better efficiency and so we don't miss any merge targets
|
||||||
|
ds.clients.forEach((_deleteItems, client) => {
|
||||||
|
const deleteItems = _deleteItems.getIds()
|
||||||
|
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
|
||||||
|
for (let di = deleteItems.length - 1; di >= 0; di--) {
|
||||||
|
const deleteItem = deleteItems[di]
|
||||||
|
// start with merging the item next to the last deleted item
|
||||||
|
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
|
||||||
|
for (
|
||||||
|
let si = mostRightIndexToCheck, struct = structs[si];
|
||||||
|
si > 0 && struct.id.clock >= deleteItem.clock;
|
||||||
|
struct = structs[si]
|
||||||
|
) {
|
||||||
|
si -= 1 + tryToMergeWithLefts(structs, si)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} tr
|
||||||
|
* @param {IdSet} idset
|
||||||
|
* @param {function(Item):boolean} gcFilter
|
||||||
|
*/
|
||||||
|
export const tryGc = (tr, idset, gcFilter) => {
|
||||||
|
tryGcDeleteSet(tr, idset, gcFilter)
|
||||||
|
tryMerge(idset, tr.doc.store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {Item | null} item
|
||||||
|
*/
|
||||||
|
export const cleanupContextlessFormattingGap = (transaction, item) => {
|
||||||
|
if (!transaction.doc.cleanupFormatting) return 0
|
||||||
|
// iterate until item.right is null or content
|
||||||
|
while (item && item.right && (item.right.deleted || !item.right.countable)) {
|
||||||
|
item = item.right
|
||||||
|
}
|
||||||
|
const attrs = new Set()
|
||||||
|
// iterate back until a content item is found
|
||||||
|
while (item && (item.deleted || !item.countable)) {
|
||||||
|
if (!item.deleted && item.content.getRef() === 6) { // is a ContentFormat
|
||||||
|
const key = /** @type {ContentFormat} */ (item.content).key
|
||||||
|
if (attrs.has(key)) {
|
||||||
|
item.delete(transaction)
|
||||||
|
transaction.cleanUps.add(item.id.client, item.id.clock, item.length)
|
||||||
|
} else {
|
||||||
|
attrs.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item = item.left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Map<string,any>} currentFormats
|
||||||
|
* @param {ContentFormat} format
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const updateCurrentFormats = (currentFormats, { key, value }) => {
|
||||||
|
if (value === null) {
|
||||||
|
currentFormats.delete(key)
|
||||||
|
} else {
|
||||||
|
currentFormats.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this function after string content has been deleted in order to
|
||||||
|
* clean up formatting Items.
|
||||||
|
*
|
||||||
|
* @param {Transaction} transaction
|
||||||
|
* @param {Item} start
|
||||||
|
* @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
|
||||||
|
* @param {Map<string,any>} startFormats
|
||||||
|
* @param {Map<string,any>} currFormats
|
||||||
|
* @return {number} The amount of formatting Items deleted.
|
||||||
|
*
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
export const cleanupFormattingGap = (transaction, start, curr, startFormats, currFormats) => {
|
||||||
|
if (!transaction.doc.cleanupFormatting) return 0
|
||||||
|
/**
|
||||||
|
* @type {Item|null}
|
||||||
|
*/
|
||||||
|
let end = start
|
||||||
|
/**
|
||||||
|
* @type {Map<string,ContentFormat>}
|
||||||
|
*/
|
||||||
|
const endFormats = map.create()
|
||||||
|
while (end && (!end.countable || end.deleted)) {
|
||||||
|
if (!end.deleted && end.content.getRef() === 6) {
|
||||||
|
const cf = /** @type {ContentFormat} */ (end.content)
|
||||||
|
endFormats.set(cf.key, cf)
|
||||||
|
}
|
||||||
|
end = end.right
|
||||||
|
}
|
||||||
|
let cleanups = 0
|
||||||
|
let reachedCurr = false
|
||||||
|
while (start !== end) {
|
||||||
|
if (curr === start) {
|
||||||
|
reachedCurr = true
|
||||||
|
}
|
||||||
|
if (!start.deleted) {
|
||||||
|
const content = start.content
|
||||||
|
if (content.getRef() === 6) { // is ContentFormat
|
||||||
|
const { key, value } = /** @type {ContentFormat} */ (content)
|
||||||
|
const startFormatValue = startFormats.get(key) ?? null
|
||||||
|
if (endFormats.get(key) !== content || startFormatValue === value) {
|
||||||
|
// Either this format is overwritten or it is not necessary because the format already existed.
|
||||||
|
start.delete(transaction)
|
||||||
|
transaction.cleanUps.add(start.id.client, start.id.clock, start.length)
|
||||||
|
cleanups++
|
||||||
|
if (!reachedCurr && (currFormats.get(key) ?? null) === value && startFormatValue !== value) {
|
||||||
|
if (startFormatValue === null) {
|
||||||
|
currFormats.delete(key)
|
||||||
|
} else {
|
||||||
|
currFormats.set(key, startFormatValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reachedCurr && !start.deleted) {
|
||||||
|
updateCurrentFormats(currFormats, /** @type {ContentFormat} */ (content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = /** @type {Item} */ (start.right)
|
||||||
|
}
|
||||||
|
return cleanups
|
||||||
|
}
|
||||||
576
src/utils/updates.js
Normal file
576
src/utils/updates.js
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import * as binary from 'lib0/binary'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
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 map from 'lib0/map'
|
||||||
|
import * as string from 'lib0/string'
|
||||||
|
|
||||||
|
import { readIdSet, writeIdSet, createIdSet, intersectSets } from './ids.js'
|
||||||
|
|
||||||
|
import { createID } from './ID.js'
|
||||||
|
import { IdSetEncoderV1, IdSetEncoderV2, UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
|
||||||
|
import { UpdateDecoderV1, UpdateDecoderV2 } from './UpdateDecoder.js'
|
||||||
|
import { GC } from '../structs/GC.js'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ContentAny,
|
||||||
|
ContentBinary,
|
||||||
|
ContentDeleted,
|
||||||
|
ContentDoc,
|
||||||
|
ContentEmbed,
|
||||||
|
ContentFormat,
|
||||||
|
ContentJSON,
|
||||||
|
ContentString,
|
||||||
|
ContentType
|
||||||
|
} from '../structs/Item.js'
|
||||||
|
import {
|
||||||
|
readItemContent
|
||||||
|
} from '../ynode.js'
|
||||||
|
import { Skip } from '../structs/Skip.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||||
|
*/
|
||||||
|
function * lazyStructReaderGenerator (decoder) {
|
||||||
|
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
for (let i = 0; i < numOfStateUpdates; i++) {
|
||||||
|
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
const client = decoder.readClient()
|
||||||
|
let clock = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
for (let i = 0; i < numberOfStructs; i++) {
|
||||||
|
const info = decoder.readInfo()
|
||||||
|
// @todo use switch instead of ifs
|
||||||
|
if (info === 10) {
|
||||||
|
const len = decoding.readVarUint(decoder.restDecoder)
|
||||||
|
yield new Skip(createID(client, clock), len)
|
||||||
|
clock += len
|
||||||
|
} else if ((binary.BITS5 & info) !== 0) {
|
||||||
|
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
|
||||||
|
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
|
||||||
|
// and we read the next string as parentYKey.
|
||||||
|
// It indicates how we store/retrieve parent from `y.share`
|
||||||
|
// @type {string|null}
|
||||||
|
const struct = new Item(
|
||||||
|
createID(client, clock),
|
||||||
|
null, // left
|
||||||
|
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
|
||||||
|
null, // right
|
||||||
|
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
|
||||||
|
// @ts-ignore Force writing a string here.
|
||||||
|
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
|
||||||
|
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
|
||||||
|
readItemContent(decoder, info) // item content
|
||||||
|
)
|
||||||
|
yield struct
|
||||||
|
clock += struct.length
|
||||||
|
} else {
|
||||||
|
const len = decoder.readLen()
|
||||||
|
yield new GC(createID(client, clock), len)
|
||||||
|
clock += len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LazyStructReader {
|
||||||
|
/**
|
||||||
|
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
|
||||||
|
* @param {boolean} filterSkips
|
||||||
|
*/
|
||||||
|
constructor (decoder, filterSkips) {
|
||||||
|
this.gen = lazyStructReaderGenerator(decoder)
|
||||||
|
/**
|
||||||
|
* @type {null | Item | Skip | GC}
|
||||||
|
*/
|
||||||
|
this.curr = null
|
||||||
|
this.done = false
|
||||||
|
this.filterSkips = filterSkips
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Item | GC | Skip |null}
|
||||||
|
*/
|
||||||
|
next () {
|
||||||
|
// ignore "Skip" structs
|
||||||
|
do {
|
||||||
|
this.curr = this.gen.next().value || null
|
||||||
|
} while (this.filterSkips && this.curr !== null && this.curr.constructor === Skip)
|
||||||
|
return this.curr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
*/
|
||||||
|
export const logUpdate = update => logUpdateV2(update, UpdateDecoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||||
|
*/
|
||||||
|
export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const structs = []
|
||||||
|
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||||
|
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||||
|
structs.push(curr)
|
||||||
|
}
|
||||||
|
logging.print('Structs: ', structs)
|
||||||
|
const ds = readIdSet(updateDecoder)
|
||||||
|
logging.print('DeleteSet: ', ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
*/
|
||||||
|
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const structs = []
|
||||||
|
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||||
|
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||||
|
structs.push(curr)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
structs,
|
||||||
|
ds: readIdSet(updateDecoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LazyStructWriter {
|
||||||
|
/**
|
||||||
|
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
|
||||||
|
*/
|
||||||
|
constructor (encoder) {
|
||||||
|
this.currClient = 0
|
||||||
|
this.startClock = 0
|
||||||
|
this.written = 0
|
||||||
|
this.encoder = encoder
|
||||||
|
/**
|
||||||
|
* We want to write operations lazily, but also we need to know beforehand how many operations we want to write for each client.
|
||||||
|
*
|
||||||
|
* This kind of meta-information (#clients, #structs-per-client-written) is written to the restEncoder.
|
||||||
|
*
|
||||||
|
* We fragment the restEncoder and store a slice of it per-client until we know how many clients there are.
|
||||||
|
* When we flush (toUint8Array) we write the restEncoder using the fragments and the meta-information.
|
||||||
|
*
|
||||||
|
* @type {Array<{ written: number, restEncoder: Uint8Array }>}
|
||||||
|
*/
|
||||||
|
this.clientStructs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {typeof IdSetEncoderV1 | typeof IdSetEncoderV2} YEncoder
|
||||||
|
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const encodeStateVectorFromUpdateV2 = (update, YEncoder = IdSetEncoderV2, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const encoder = new YEncoder()
|
||||||
|
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
|
||||||
|
let curr = updateDecoder.curr
|
||||||
|
if (curr !== null) {
|
||||||
|
let size = 0
|
||||||
|
let currClient = curr.id.client
|
||||||
|
let stopCounting = curr.id.clock !== 0 // must start at 0
|
||||||
|
let currClock = stopCounting ? 0 : curr.id.clock + curr.length
|
||||||
|
for (; curr !== null; curr = updateDecoder.next()) {
|
||||||
|
if (currClient !== curr.id.client) {
|
||||||
|
if (currClock !== 0) {
|
||||||
|
size++
|
||||||
|
// We found a new client
|
||||||
|
// write what we have to the encoder
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, currClient)
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, currClock)
|
||||||
|
}
|
||||||
|
currClient = curr.id.client
|
||||||
|
currClock = 0
|
||||||
|
stopCounting = curr.id.clock !== 0
|
||||||
|
}
|
||||||
|
// we ignore skips
|
||||||
|
if (curr.constructor === Skip) {
|
||||||
|
stopCounting = true
|
||||||
|
}
|
||||||
|
if (!stopCounting) {
|
||||||
|
currClock = curr.id.clock + curr.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// write what we have
|
||||||
|
if (currClock !== 0) {
|
||||||
|
size++
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, currClient)
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, currClock)
|
||||||
|
}
|
||||||
|
// prepend the size of the state vector
|
||||||
|
const enc = encoding.createEncoder()
|
||||||
|
encoding.writeVarUint(enc, size)
|
||||||
|
encoding.writeBinaryEncoder(enc, encoder.restEncoder)
|
||||||
|
encoder.restEncoder = enc
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
} else {
|
||||||
|
encoding.writeVarUint(encoder.restEncoder, 0)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const encodeStateVectorFromUpdate = update => encodeStateVectorFromUpdateV2(update, IdSetEncoderV1, UpdateDecoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
|
||||||
|
* @return {ContentIds}
|
||||||
|
*/
|
||||||
|
export const createContentIdsFromUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
|
||||||
|
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const lazyDecoder = new LazyStructReader(updateDecoder, true)
|
||||||
|
const inserts = createIdSet()
|
||||||
|
let lastClientId = -1
|
||||||
|
let lastClock = 0
|
||||||
|
let lastLen = 0
|
||||||
|
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||||
|
const currId = curr.id
|
||||||
|
if (lastClientId === currId.client && lastClock + lastLen === currId.clock) {
|
||||||
|
// default case: extend prev entry
|
||||||
|
lastLen += curr.length
|
||||||
|
} else {
|
||||||
|
if (lastClientId >= 0) {
|
||||||
|
inserts.add(lastClientId, lastClock, lastLen)
|
||||||
|
}
|
||||||
|
lastClientId = currId.client
|
||||||
|
lastClock = currId.clock
|
||||||
|
lastLen = curr.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastClientId >= 0) {
|
||||||
|
inserts.add(lastClientId, lastClock, lastLen)
|
||||||
|
}
|
||||||
|
const deletes = readIdSet(updateDecoder)
|
||||||
|
return { inserts, deletes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @return {ContentIds}
|
||||||
|
*/
|
||||||
|
export const createContentIdsFromUpdate = update => createContentIdsFromUpdateV2(update, UpdateDecoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is intended to slice any kind of struct and retrieve the right part.
|
||||||
|
* It does not handle side-effects, so it should only be used by the lazy-encoder.
|
||||||
|
*
|
||||||
|
* @param {Item | GC | Skip} left
|
||||||
|
* @param {number} diff
|
||||||
|
* @return {Item | GC | Skip}
|
||||||
|
*/
|
||||||
|
export const sliceStruct = (left, diff) => {
|
||||||
|
if (left.constructor === GC) {
|
||||||
|
const { client, clock } = left.id
|
||||||
|
return new GC(createID(client, clock + diff), left.length - diff)
|
||||||
|
} else if (left.constructor === Skip) {
|
||||||
|
const { client, clock } = left.id
|
||||||
|
return new Skip(createID(client, clock + diff), left.length - diff)
|
||||||
|
} else {
|
||||||
|
const leftItem = /** @type {Item} */ (left)
|
||||||
|
const { client, clock } = leftItem.id
|
||||||
|
return new Item(
|
||||||
|
createID(client, clock + diff),
|
||||||
|
null,
|
||||||
|
createID(client, clock + diff - 1),
|
||||||
|
null,
|
||||||
|
leftItem.rightOrigin,
|
||||||
|
leftItem.parent,
|
||||||
|
leftItem.parentSub,
|
||||||
|
leftItem.content.splice(diff)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LazyStructWriter} lazyWriter
|
||||||
|
*/
|
||||||
|
const flushLazyStructWriter = lazyWriter => {
|
||||||
|
if (lazyWriter.written > 0) {
|
||||||
|
lazyWriter.clientStructs.push({ written: lazyWriter.written, restEncoder: encoding.toUint8Array(lazyWriter.encoder.restEncoder) })
|
||||||
|
lazyWriter.encoder.restEncoder = encoding.createEncoder()
|
||||||
|
lazyWriter.written = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LazyStructWriter} lazyWriter
|
||||||
|
* @param {Item | GC | Skip} struct
|
||||||
|
* @param {number} offset
|
||||||
|
* @param {number} offsetEnd
|
||||||
|
*/
|
||||||
|
export const writeStructToLazyStructWriter = (lazyWriter, struct, offset, offsetEnd) => {
|
||||||
|
// flush curr if we start another client
|
||||||
|
if (lazyWriter.written > 0 && lazyWriter.currClient !== struct.id.client) {
|
||||||
|
flushLazyStructWriter(lazyWriter)
|
||||||
|
}
|
||||||
|
if (lazyWriter.written === 0) {
|
||||||
|
lazyWriter.currClient = struct.id.client
|
||||||
|
// write next client
|
||||||
|
lazyWriter.encoder.writeClient(struct.id.client)
|
||||||
|
// write startClock
|
||||||
|
encoding.writeVarUint(lazyWriter.encoder.restEncoder, struct.id.clock + offset)
|
||||||
|
}
|
||||||
|
struct.write(lazyWriter.encoder, offset, offsetEnd)
|
||||||
|
lazyWriter.written++
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Call this function when we collected all parts and want to
|
||||||
|
* put all the parts together. After calling this method,
|
||||||
|
* you can continue using the UpdateEncoder.
|
||||||
|
*
|
||||||
|
* @param {LazyStructWriter} lazyWriter
|
||||||
|
*/
|
||||||
|
export const finishLazyStructWriting = (lazyWriter) => {
|
||||||
|
flushLazyStructWriter(lazyWriter)
|
||||||
|
|
||||||
|
// this is a fresh encoder because we called flushCurr
|
||||||
|
const restEncoder = lazyWriter.encoder.restEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Now we put all the fragments together.
|
||||||
|
* This works similarly to `writeClientsStructs`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// write # states that were updated - i.e. the clients
|
||||||
|
encoding.writeVarUint(restEncoder, lazyWriter.clientStructs.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < lazyWriter.clientStructs.length; i++) {
|
||||||
|
const partStructs = lazyWriter.clientStructs[i]
|
||||||
|
/**
|
||||||
|
* Works similarly to `writeStructs`
|
||||||
|
*/
|
||||||
|
// write # encoded structs
|
||||||
|
encoding.writeVarUint(restEncoder, partStructs.written)
|
||||||
|
// write the rest of the fragment
|
||||||
|
encoding.writeUint8Array(restEncoder, partStructs.restEncoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {function(Item|GC|Skip):Item|GC|Skip} blockTransformer
|
||||||
|
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
|
||||||
|
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
|
||||||
|
*/
|
||||||
|
export const convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => {
|
||||||
|
const updateDecoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const lazyDecoder = new LazyStructReader(updateDecoder, false)
|
||||||
|
const updateEncoder = new YEncoder()
|
||||||
|
const lazyWriter = new LazyStructWriter(updateEncoder)
|
||||||
|
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
|
||||||
|
writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0, 0)
|
||||||
|
}
|
||||||
|
finishLazyStructWriting(lazyWriter)
|
||||||
|
const ds = readIdSet(updateDecoder)
|
||||||
|
writeIdSet(updateEncoder, ds)
|
||||||
|
return updateEncoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ObfuscatorOptions
|
||||||
|
* @property {boolean} [ObfuscatorOptions.formatting=true]
|
||||||
|
* @property {boolean} [ObfuscatorOptions.subdocs=true]
|
||||||
|
* @property {boolean} [ObfuscatorOptions.name=true] Whether to obfuscate nodeName / hookName
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ObfuscatorOptions} obfuscator
|
||||||
|
*/
|
||||||
|
const createObfuscator = ({ formatting = true, subdocs = true, name = 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 (name) {
|
||||||
|
const type = /** @type {ContentType} */ (content).type
|
||||||
|
if (type.name != null) {
|
||||||
|
type.name = map.setIfUndefined(nodeNameCache, type.name, () => 'typename-' + 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.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
|
||||||
|
*/
|
||||||
|
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, f.id, UpdateDecoderV1, UpdateEncoderV2)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
*/
|
||||||
|
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, f.id, UpdateDecoderV2, UpdateEncoderV1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter an update to only include content specified by a ContentIds pattern.
|
||||||
|
*
|
||||||
|
* This function extracts a subset of an update, keeping only the structs whose IDs
|
||||||
|
* are present in `contentIds.inserts` and only the delete set entries that are
|
||||||
|
* present in `contentIds.deletes`.
|
||||||
|
*
|
||||||
|
* Note: If a struct partially overlaps with the contentIds pattern, only the
|
||||||
|
* overlapping portion is included in the result.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {ContentIds} contentIds - Pattern specifying which content to include
|
||||||
|
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
|
||||||
|
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const intersectUpdateWithContentIdsV2 = (update, contentIds, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
|
||||||
|
const { inserts, deletes } = contentIds
|
||||||
|
const encoder = new YEncoder()
|
||||||
|
const lazyStructWriter = new LazyStructWriter(encoder)
|
||||||
|
const decoder = new YDecoder(decoding.createDecoder(update))
|
||||||
|
const reader = new LazyStructReader(decoder, true)
|
||||||
|
|
||||||
|
while (reader.curr) {
|
||||||
|
const currClientId = reader.curr.id.client
|
||||||
|
let nextClock = reader.curr.id.clock
|
||||||
|
let firstWrite = false
|
||||||
|
while (reader.curr != null && reader.curr.id.client === currClientId) {
|
||||||
|
const curr = reader.curr
|
||||||
|
for (const slice of inserts.slice(currClientId, curr.id.clock, curr.length)) {
|
||||||
|
if (slice.exists) {
|
||||||
|
const skipLen = slice.clock - nextClock
|
||||||
|
if (skipLen > 0 && firstWrite) {
|
||||||
|
// write missing skip
|
||||||
|
writeStructToLazyStructWriter(lazyStructWriter, new Skip(createID(currClientId, nextClock), skipLen), 0, 0)
|
||||||
|
}
|
||||||
|
// write sliced content
|
||||||
|
writeStructToLazyStructWriter(lazyStructWriter, curr, slice.clock - curr.id.clock, (curr.id.clock + curr.length) - (slice.clock + slice.len))
|
||||||
|
nextClock = slice.clock + slice.len
|
||||||
|
firstWrite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishLazyStructWriting(lazyStructWriter)
|
||||||
|
// Filter the delete set to only include entries in contentIds.deletes
|
||||||
|
const ds = readIdSet(decoder)
|
||||||
|
const filteredDs = intersectSets(ds, deletes)
|
||||||
|
writeIdSet(encoder, filteredDs)
|
||||||
|
return encoder.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter an update (V1 format) to only include content specified by a ContentIds pattern.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {ContentIds} contentIds - Pattern specifying which content to include
|
||||||
|
* @return {Uint8Array<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
export const intersectUpdateWithContentIds = (update, contentIds) =>
|
||||||
|
intersectUpdateWithContentIdsV2(update, contentIds, UpdateDecoderV1, UpdateEncoderV1)
|
||||||
2824
src/ynode.js
Normal file
2824
src/ynode.js
Normal file
File diff suppressed because it is too large
Load Diff
176
test.html
Normal file
176
test.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Testing @y/y</title>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@y/y": "./src/index.js",
|
||||||
|
"@y/y/meta": "./src/utils/meta.js",
|
||||||
|
"@y/y/testHelper": "./tests/testHelper.js",
|
||||||
|
"@y/y/package.json": "./package.json",
|
||||||
|
"lib0/package.json": "./node_modules/lib0/package.json",
|
||||||
|
"lib0/array": "./node_modules/lib0/src/array.js",
|
||||||
|
"lib0/binary": "./node_modules/lib0/src/binary.js",
|
||||||
|
"lib0/broadcastchannel": "./node_modules/lib0/src/broadcastchannel.js",
|
||||||
|
"lib0/buffer": "./node_modules/lib0/src/buffer.js",
|
||||||
|
"lib0/cache": "./node_modules/lib0/src/cache.js",
|
||||||
|
"lib0/conditions": "./node_modules/lib0/src/conditions.js",
|
||||||
|
"lib0/crypto/jwt": "./node_modules/lib0/src/crypto/jwt.js",
|
||||||
|
"lib0/crypto/aes-gcm": "./node_modules/lib0/src/crypto/aes-gcm.js",
|
||||||
|
"lib0/delta": "./node_modules/lib0/src/delta/delta.js",
|
||||||
|
"lib0/delta/transformer": "./node_modules/lib0/src/delta/transformer.js",
|
||||||
|
"lib0/delta/position": "./node_modules/lib0/src/delta/position.js",
|
||||||
|
"lib0/delta/transformer/inline": "./node_modules/lib0/src/delta/transformer/inline.js",
|
||||||
|
"lib0/delta/transformer/children": "./node_modules/lib0/src/delta/transformer/children.js",
|
||||||
|
"lib0/delta/transformer/id": "./node_modules/lib0/src/delta/transformer/id.js",
|
||||||
|
"lib0/delta/transformer/rename": "./node_modules/lib0/src/delta/transformer/rename.js",
|
||||||
|
"lib0/delta/transformer/filter": "./node_modules/lib0/src/delta/transformer/filter.js",
|
||||||
|
"lib0/delta/transformer/pipe": "./node_modules/lib0/src/delta/transformer/pipe.js",
|
||||||
|
"lib0/delta/transformer/attr": "./node_modules/lib0/src/delta/transformer/attr.js",
|
||||||
|
"lib0/delta/transformer/project": "./node_modules/lib0/src/delta/transformer/project.js",
|
||||||
|
"lib0/delta/transformer/value": "./node_modules/lib0/src/delta/transformer/value.js",
|
||||||
|
"lib0/delta/transformer/rename-attrs": "./node_modules/lib0/src/delta/transformer/rename-attrs.js",
|
||||||
|
"lib0/delta/transformer/attribution-to-format": "./node_modules/lib0/src/delta/transformer/attribution-to-format.js",
|
||||||
|
"lib0/delta/transformer/full-attributions": "./node_modules/lib0/src/delta/transformer/full-attributions.js",
|
||||||
|
"lib0/delta/rdt": "./node_modules/lib0/src/delta/rdt.js",
|
||||||
|
"lib0/delta/rdt/delta": "./node_modules/lib0/src/delta/rdt/delta.js",
|
||||||
|
"lib0/delta/rdt/dom": "./node_modules/lib0/src/delta/rdt/dom.js",
|
||||||
|
"lib0/crypto/ecdsa": "./node_modules/lib0/src/crypto/ecdsa.js",
|
||||||
|
"lib0/crypto/rsa-oaep": "./node_modules/lib0/src/crypto/rsa-oaep.js",
|
||||||
|
"lib0/hash/fnv1a": "./node_modules/lib0/src/hash/fnv1a.js",
|
||||||
|
"lib0/hash/rabin": "./node_modules/lib0/src/hash/rabin.js",
|
||||||
|
"lib0/hash/sha256": "./node_modules/lib0/src/hash/sha256.js",
|
||||||
|
"lib0/decoding": "./node_modules/lib0/src/decoding.js",
|
||||||
|
"lib0/diff/patience": "./node_modules/lib0/src/diff/patience.js",
|
||||||
|
"lib0/diff": "./node_modules/lib0/src/diff.js",
|
||||||
|
"lib0/dom": "./node_modules/lib0/src/dom.js",
|
||||||
|
"lib0/encoding": "./node_modules/lib0/src/encoding.js",
|
||||||
|
"lib0/environment": "./node_modules/lib0/src/environment.js",
|
||||||
|
"lib0/error": "./node_modules/lib0/src/error.js",
|
||||||
|
"lib0/eventloop": "./node_modules/lib0/src/eventloop.js",
|
||||||
|
"lib0/function": "./node_modules/lib0/src/function.js",
|
||||||
|
"lib0/indexeddb": "./node_modules/lib0/src/indexeddb.js",
|
||||||
|
"lib0/iterator": "./node_modules/lib0/src/iterator.js",
|
||||||
|
"lib0/json": "./node_modules/lib0/src/json.js",
|
||||||
|
"lib0/list": "./node_modules/lib0/src/list.js",
|
||||||
|
"lib0/logging": "./node_modules/lib0/src/logging.js",
|
||||||
|
"lib0/map": "./node_modules/lib0/src/map.js",
|
||||||
|
"lib0/math": "./node_modules/lib0/src/math.js",
|
||||||
|
"lib0/metric": "./node_modules/lib0/src/metric.js",
|
||||||
|
"lib0/mutex": "./node_modules/lib0/src/mutex.js",
|
||||||
|
"lib0/number": "./node_modules/lib0/src/number.js",
|
||||||
|
"lib0/object": "./node_modules/lib0/src/object.js",
|
||||||
|
"lib0/observable": "./node_modules/lib0/src/observable.js",
|
||||||
|
"lib0/pair": "./node_modules/lib0/src/pair.js",
|
||||||
|
"lib0/prng": "./node_modules/lib0/src/prng.js",
|
||||||
|
"lib0/promise": "./node_modules/lib0/src/promise.js",
|
||||||
|
"lib0/queue": "./node_modules/lib0/src/queue.js",
|
||||||
|
"lib0/random": "./node_modules/lib0/src/random.js",
|
||||||
|
"lib0/set": "./node_modules/lib0/src/set.js",
|
||||||
|
"lib0/sort": "./node_modules/lib0/src/sort.js",
|
||||||
|
"lib0/statistics": "./node_modules/lib0/src/statistics.js",
|
||||||
|
"lib0/storage": "./node_modules/lib0/src/storage.js",
|
||||||
|
"lib0/string": "./node_modules/lib0/src/string.js",
|
||||||
|
"lib0/symbol": "./node_modules/lib0/src/symbol.js",
|
||||||
|
"lib0/traits": "./node_modules/lib0/src/trait/traits.js",
|
||||||
|
"lib0/trait/fingerprint": "./node_modules/lib0/src/trait/fingerprint.js",
|
||||||
|
"lib0/testing": "./node_modules/lib0/src/testing.js",
|
||||||
|
"lib0/time": "./node_modules/lib0/src/time.js",
|
||||||
|
"lib0/url": "./node_modules/lib0/src/url.js",
|
||||||
|
"lib0/websocket": "./node_modules/lib0/src/websocket.js",
|
||||||
|
"lib0/webcrypto": "./node_modules/lib0/src/webcrypto.js",
|
||||||
|
"lib0/ts": "./node_modules/lib0/src/ts.js",
|
||||||
|
"lib0/performance": "./node_modules/lib0/src/performance.js",
|
||||||
|
"lib0/schema": "./node_modules/lib0/src/schema.js",
|
||||||
|
"@y/protocols/sync": "./node_modules/@y/protocols/src/sync.js",
|
||||||
|
"@y/protocols/awareness": "./node_modules/@y/protocols/src/awareness.js",
|
||||||
|
"@y/protocols/auth": "./node_modules/@y/protocols/src/auth.js"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"./node_modules/@y/protocols/": {
|
||||||
|
"lib0/package.json": "./node_modules/lib0/package.json",
|
||||||
|
"lib0/array": "./node_modules/lib0/src/array.js",
|
||||||
|
"lib0/binary": "./node_modules/lib0/src/binary.js",
|
||||||
|
"lib0/broadcastchannel": "./node_modules/lib0/src/broadcastchannel.js",
|
||||||
|
"lib0/buffer": "./node_modules/lib0/src/buffer.js",
|
||||||
|
"lib0/cache": "./node_modules/lib0/src/cache.js",
|
||||||
|
"lib0/conditions": "./node_modules/lib0/src/conditions.js",
|
||||||
|
"lib0/crypto/jwt": "./node_modules/lib0/src/crypto/jwt.js",
|
||||||
|
"lib0/crypto/aes-gcm": "./node_modules/lib0/src/crypto/aes-gcm.js",
|
||||||
|
"lib0/delta": "./node_modules/lib0/src/delta/delta.js",
|
||||||
|
"lib0/delta/transformer": "./node_modules/lib0/src/delta/transformer.js",
|
||||||
|
"lib0/delta/position": "./node_modules/lib0/src/delta/position.js",
|
||||||
|
"lib0/delta/transformer/inline": "./node_modules/lib0/src/delta/transformer/inline.js",
|
||||||
|
"lib0/delta/transformer/children": "./node_modules/lib0/src/delta/transformer/children.js",
|
||||||
|
"lib0/delta/transformer/id": "./node_modules/lib0/src/delta/transformer/id.js",
|
||||||
|
"lib0/delta/transformer/rename": "./node_modules/lib0/src/delta/transformer/rename.js",
|
||||||
|
"lib0/delta/transformer/filter": "./node_modules/lib0/src/delta/transformer/filter.js",
|
||||||
|
"lib0/delta/transformer/pipe": "./node_modules/lib0/src/delta/transformer/pipe.js",
|
||||||
|
"lib0/delta/transformer/attr": "./node_modules/lib0/src/delta/transformer/attr.js",
|
||||||
|
"lib0/delta/transformer/project": "./node_modules/lib0/src/delta/transformer/project.js",
|
||||||
|
"lib0/delta/transformer/value": "./node_modules/lib0/src/delta/transformer/value.js",
|
||||||
|
"lib0/delta/transformer/rename-attrs": "./node_modules/lib0/src/delta/transformer/rename-attrs.js",
|
||||||
|
"lib0/delta/transformer/attribution-to-format": "./node_modules/lib0/src/delta/transformer/attribution-to-format.js",
|
||||||
|
"lib0/delta/transformer/full-attributions": "./node_modules/lib0/src/delta/transformer/full-attributions.js",
|
||||||
|
"lib0/delta/rdt": "./node_modules/lib0/src/delta/rdt.js",
|
||||||
|
"lib0/delta/rdt/delta": "./node_modules/lib0/src/delta/rdt/delta.js",
|
||||||
|
"lib0/delta/rdt/dom": "./node_modules/lib0/src/delta/rdt/dom.js",
|
||||||
|
"lib0/crypto/ecdsa": "./node_modules/lib0/src/crypto/ecdsa.js",
|
||||||
|
"lib0/crypto/rsa-oaep": "./node_modules/lib0/src/crypto/rsa-oaep.js",
|
||||||
|
"lib0/hash/fnv1a": "./node_modules/lib0/src/hash/fnv1a.js",
|
||||||
|
"lib0/hash/rabin": "./node_modules/lib0/src/hash/rabin.js",
|
||||||
|
"lib0/hash/sha256": "./node_modules/lib0/src/hash/sha256.js",
|
||||||
|
"lib0/decoding": "./node_modules/lib0/src/decoding.js",
|
||||||
|
"lib0/diff/patience": "./node_modules/lib0/src/diff/patience.js",
|
||||||
|
"lib0/diff": "./node_modules/lib0/src/diff.js",
|
||||||
|
"lib0/dom": "./node_modules/lib0/src/dom.js",
|
||||||
|
"lib0/encoding": "./node_modules/lib0/src/encoding.js",
|
||||||
|
"lib0/environment": "./node_modules/lib0/src/environment.js",
|
||||||
|
"lib0/error": "./node_modules/lib0/src/error.js",
|
||||||
|
"lib0/eventloop": "./node_modules/lib0/src/eventloop.js",
|
||||||
|
"lib0/function": "./node_modules/lib0/src/function.js",
|
||||||
|
"lib0/indexeddb": "./node_modules/lib0/src/indexeddb.js",
|
||||||
|
"lib0/iterator": "./node_modules/lib0/src/iterator.js",
|
||||||
|
"lib0/json": "./node_modules/lib0/src/json.js",
|
||||||
|
"lib0/list": "./node_modules/lib0/src/list.js",
|
||||||
|
"lib0/logging": "./node_modules/lib0/src/logging.js",
|
||||||
|
"lib0/map": "./node_modules/lib0/src/map.js",
|
||||||
|
"lib0/math": "./node_modules/lib0/src/math.js",
|
||||||
|
"lib0/metric": "./node_modules/lib0/src/metric.js",
|
||||||
|
"lib0/mutex": "./node_modules/lib0/src/mutex.js",
|
||||||
|
"lib0/number": "./node_modules/lib0/src/number.js",
|
||||||
|
"lib0/object": "./node_modules/lib0/src/object.js",
|
||||||
|
"lib0/observable": "./node_modules/lib0/src/observable.js",
|
||||||
|
"lib0/pair": "./node_modules/lib0/src/pair.js",
|
||||||
|
"lib0/prng": "./node_modules/lib0/src/prng.js",
|
||||||
|
"lib0/promise": "./node_modules/lib0/src/promise.js",
|
||||||
|
"lib0/queue": "./node_modules/lib0/src/queue.js",
|
||||||
|
"lib0/random": "./node_modules/lib0/src/random.js",
|
||||||
|
"lib0/set": "./node_modules/lib0/src/set.js",
|
||||||
|
"lib0/sort": "./node_modules/lib0/src/sort.js",
|
||||||
|
"lib0/statistics": "./node_modules/lib0/src/statistics.js",
|
||||||
|
"lib0/storage": "./node_modules/lib0/src/storage.js",
|
||||||
|
"lib0/string": "./node_modules/lib0/src/string.js",
|
||||||
|
"lib0/symbol": "./node_modules/lib0/src/symbol.js",
|
||||||
|
"lib0/traits": "./node_modules/lib0/src/trait/traits.js",
|
||||||
|
"lib0/trait/fingerprint": "./node_modules/lib0/src/trait/fingerprint.js",
|
||||||
|
"lib0/testing": "./node_modules/lib0/src/testing.js",
|
||||||
|
"lib0/time": "./node_modules/lib0/src/time.js",
|
||||||
|
"lib0/url": "./node_modules/lib0/src/url.js",
|
||||||
|
"lib0/websocket": "./node_modules/lib0/src/websocket.js",
|
||||||
|
"lib0/webcrypto": "./node_modules/lib0/src/webcrypto.js",
|
||||||
|
"lib0/ts": "./node_modules/lib0/src/ts.js",
|
||||||
|
"lib0/performance": "./node_modules/lib0/src/performance.js",
|
||||||
|
"lib0/schema": "./node_modules/lib0/src/schema.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="./tests/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
236
tests/IdMap.tests.js
Normal file
236
tests/IdMap.tests.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as ids from '../src/utils/ids.js'
|
||||||
|
import * as prng from 'lib0/prng'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import { compareIdmaps as compareIdMaps, createIdMap, ID, createRandomIdSet, createRandomIdMap, createContentAttribute } from './testHelper.js'
|
||||||
|
import * as YY from '../src/index.js'
|
||||||
|
import * as time from 'lib0/time'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {Array<[number, number, number, Array<T>]>} ops
|
||||||
|
*/
|
||||||
|
const simpleConstructAttrs = ops => {
|
||||||
|
const attrs = createIdMap()
|
||||||
|
ops.forEach(op => {
|
||||||
|
attrs.add(op[0], op[1], op[2], op[3].map(v => createContentAttribute('', v)))
|
||||||
|
})
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testAmMerge = _tc => {
|
||||||
|
const attrs = [42]
|
||||||
|
t.group('filter out empty items (1))', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 0, attrs]]),
|
||||||
|
simpleConstructAttrs([])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (2))', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 0, attrs]]),
|
||||||
|
simpleConstructAttrs([])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (3 - end))', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs]]),
|
||||||
|
simpleConstructAttrs([[0, 1, 1, attrs]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (4 - middle))', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs], [0, 3, 1, attrs]]),
|
||||||
|
simpleConstructAttrs([[0, 1, 1, attrs], [0, 3, 1, attrs]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (5 - beginning))', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 1, attrs], [0, 3, 1, attrs]]),
|
||||||
|
simpleConstructAttrs([[0, 2, 1, attrs], [0, 3, 1, attrs]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('merge of overlapping id ranges', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 2, attrs], [0, 0, 2, attrs]]),
|
||||||
|
simpleConstructAttrs([[0, 0, 3, attrs]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('construct without hole', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 2, attrs], [0, 3, 1, attrs]]),
|
||||||
|
simpleConstructAttrs([[0, 1, 3, attrs]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('no merge of overlapping id ranges with different attributes', () => {
|
||||||
|
compareIdMaps(
|
||||||
|
simpleConstructAttrs([[0, 1, 2, [1]], [0, 0, 2, [2]]]),
|
||||||
|
simpleConstructAttrs([[0, 0, 1, [2]], [0, 1, 1, [1, 2]], [0, 2, 1, [1]]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatMergingMultipleIdMaps = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 5
|
||||||
|
/**
|
||||||
|
* @type {Array<IdMap<number>>}
|
||||||
|
*/
|
||||||
|
const sets = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
sets.push(createRandomIdMap(tc.prng, clients, clockRange, [1, 2, 3]))
|
||||||
|
}
|
||||||
|
const merged = ids.mergeIdMaps(sets)
|
||||||
|
const mergedReverse = ids.mergeIdMaps(sets.reverse())
|
||||||
|
compareIdMaps(merged, mergedReverse)
|
||||||
|
const composed = ids.createIdMap()
|
||||||
|
for (let iclient = 0; iclient < clients; iclient++) {
|
||||||
|
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
|
||||||
|
const mergedHas = merged.hasId(new ID(iclient, iclock))
|
||||||
|
const oneHas = sets.some(ids => ids.hasId(new ID(iclient, iclock)))
|
||||||
|
t.assert(mergedHas === oneHas)
|
||||||
|
const mergedAttrs = merged.sliceId(new ID(iclient, iclock), 1)
|
||||||
|
mergedAttrs.forEach(a => {
|
||||||
|
if (a.attrs != null) {
|
||||||
|
composed.add(iclient, a.clock, a.len, a.attrs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compareIdMaps(merged, composed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDiffing = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const attrs = [1, 2, 3]
|
||||||
|
const idset1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||||
|
const idset2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||||
|
const merged = ids.mergeIdMaps([idset1, idset2])
|
||||||
|
const e1 = ids.diffIdMap(idset1, idset2)
|
||||||
|
const e2 = ids.diffIdMap(merged, idset2)
|
||||||
|
compareIdMaps(e1, e2)
|
||||||
|
const copy = YY.decodeIdMap(YY.encodeIdMap(e1))
|
||||||
|
compareIdMaps(e1, copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDiffing2 = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const attrs = [1, 2, 3]
|
||||||
|
const idmap1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||||
|
const idmap2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||||
|
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const merged = ids.mergeIdMaps([idmap1, idmap2])
|
||||||
|
const mergedExcluded = ids.diffIdMap(merged, idsExclude)
|
||||||
|
const e1 = ids.diffIdMap(idmap1, idsExclude)
|
||||||
|
const e2 = ids.diffIdMap(idmap2, idsExclude)
|
||||||
|
const excludedMerged = ids.mergeIdMaps([e1, e2])
|
||||||
|
compareIdMaps(mergedExcluded, excludedMerged)
|
||||||
|
const copy = YY.decodeIdMap(YY.encodeIdMap(mergedExcluded))
|
||||||
|
compareIdMaps(mergedExcluded, copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDeletes = tc => {
|
||||||
|
const clients = 1
|
||||||
|
const clockRange = 100
|
||||||
|
const idset = createRandomIdMap(tc.prng, clients, clockRange, [])
|
||||||
|
const client = Array.from(idset.clients.keys())[0]
|
||||||
|
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||||
|
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
|
||||||
|
const idsetOfDeletes = ids.createIdMap()
|
||||||
|
idsetOfDeletes.add(client, clock, len, [])
|
||||||
|
const diffed = ids.diffIdMap(idset, idsetOfDeletes)
|
||||||
|
idset.delete(client, clock, len)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
t.assert(!idset.has(client, clock + i))
|
||||||
|
}
|
||||||
|
compareIdMaps(idset, diffed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomIntersects = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const ids1 = createRandomIdMap(tc.prng, clients, clockRange, [1])
|
||||||
|
const ids2 = createRandomIdMap(tc.prng, clients, clockRange, ['two'])
|
||||||
|
const intersected = ids.intersectMaps(ids1, ids2)
|
||||||
|
for (let client = 0; client < clients; client++) {
|
||||||
|
for (let clock = 0; clock < clockRange; clock++) {
|
||||||
|
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
|
||||||
|
/**
|
||||||
|
* @type {Array<any>?}
|
||||||
|
*/
|
||||||
|
const slice1 = ids1.slice(client, clock, 1)[0].attrs
|
||||||
|
/**
|
||||||
|
* @type {Array<any>?}
|
||||||
|
*/
|
||||||
|
const slice2 = ids2.slice(client, clock, 1)[0].attrs
|
||||||
|
/**
|
||||||
|
* @type {Array<any>?}
|
||||||
|
*/
|
||||||
|
const expectedAttrs = (slice1 != null && slice2 != null) ? slice1.concat(slice2) : null
|
||||||
|
const attrs = intersected.slice(client, clock, 1)[0].attrs
|
||||||
|
t.assert(attrs?.length === expectedAttrs?.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diffed1 = ids.diffIdMap(ids1, ids2)
|
||||||
|
const altDiffed1 = ids.diffIdMap(ids1, intersected)
|
||||||
|
compareIdMaps(diffed1, altDiffed1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUserAttributionEncodingBenchmark = tc => {
|
||||||
|
/**
|
||||||
|
* @todo debug why this approach needs 30 bytes per item
|
||||||
|
* @todo it should be possible to only use a single idmap and, in each attr entry, encode the diff
|
||||||
|
* to the previous entries (e.g. remove a,b, insert c,d)
|
||||||
|
*/
|
||||||
|
const attributions = createIdMap()
|
||||||
|
const currentTime = time.getUnixTime()
|
||||||
|
const ydoc = new YY.Doc()
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.insertSet, [createContentAttribute('insert', 'userX'), createContentAttribute('insertAt', currentTime)]))
|
||||||
|
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.deleteSet, [createContentAttribute('delete', 'userX'), createContentAttribute('deleteAt', currentTime)]))
|
||||||
|
})
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
const N = 10000
|
||||||
|
t.measureTime(`time to attribute ${N / 1000}k changes`, () => {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
if (i % 2 > 0 && ytext.length > 0) {
|
||||||
|
const pos = prng.int31(tc.prng, 0, ytext.length)
|
||||||
|
const delLen = prng.int31(tc.prng, 0, ytext.length - pos)
|
||||||
|
ytext.delete(pos, delLen)
|
||||||
|
} else {
|
||||||
|
ytext.insert(prng.int31(tc.prng, 0, ytext.length), prng.word(tc.prng))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.measureTime('time to encode attributions map', () => {
|
||||||
|
/**
|
||||||
|
* @todo I can optimize size by encoding only the differences to the prev item.
|
||||||
|
*/
|
||||||
|
const encAttributions = ids.encodeIdMap(attributions)
|
||||||
|
t.info('encoded size: ' + encAttributions.byteLength)
|
||||||
|
t.info('size per change: ' + math.floor((encAttributions.byteLength / N) * 100) / 100 + ' bytes')
|
||||||
|
})
|
||||||
|
}
|
||||||
397
tests/IdSet.tests.js
Normal file
397
tests/IdSet.tests.js
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as d from '../src/utils/ids.js'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as prng from 'lib0/prng'
|
||||||
|
import { compareIdSets, createRandomIdSet, ID } from './testHelper.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<[number, number, number]>} ops
|
||||||
|
*/
|
||||||
|
const simpleConstructIdSet = ops => {
|
||||||
|
const idset = d.createIdSet()
|
||||||
|
ops.forEach(op => {
|
||||||
|
idset.add(op[0], op[1], op[2])
|
||||||
|
})
|
||||||
|
return idset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdsetMerge = _tc => {
|
||||||
|
t.group('filter out empty items (1))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 0]]),
|
||||||
|
simpleConstructIdSet([])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (2))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 0], [0, 2, 0]]),
|
||||||
|
simpleConstructIdSet([])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (3 - end))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 1], [0, 2, 0]]),
|
||||||
|
simpleConstructIdSet([[0, 1, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (4 - middle))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 1], [0, 2, 0], [0, 3, 1]]),
|
||||||
|
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('filter out empty items (5 - beginning))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 0], [0, 2, 1], [0, 3, 1]]),
|
||||||
|
simpleConstructIdSet([[0, 2, 1], [0, 3, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('merge of overlapping id ranges', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 2], [0, 0, 2]]),
|
||||||
|
simpleConstructIdSet([[0, 0, 3]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('construct without hole', () => {
|
||||||
|
compareIdSets(
|
||||||
|
simpleConstructIdSet([[0, 1, 2], [0, 3, 1]]),
|
||||||
|
simpleConstructIdSet([[0, 1, 3]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDiffing = _tc => {
|
||||||
|
t.group('simple case (1))', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]]),
|
||||||
|
simpleConstructIdSet([[0, 3, 1]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 1, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('subset left', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 1, 1]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 2, 2]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('subset right', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 3, 1]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 1, 2]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('subset middle', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 2, 1]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('overlapping left', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 0, 2]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 2, 2]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('overlapping right', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 3, 5]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 1, 2]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('overlapping completely', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3]]),
|
||||||
|
simpleConstructIdSet([[0, 0, 5]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
t.group('overlapping into new range', () => {
|
||||||
|
compareIdSets(
|
||||||
|
d.diffIdSet(
|
||||||
|
simpleConstructIdSet([[0, 1, 3], [0, 5, 2]]),
|
||||||
|
simpleConstructIdSet([[0, 0, 6]])
|
||||||
|
),
|
||||||
|
simpleConstructIdSet([[0, 6, 1]])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDiffing = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const ds1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const ds2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const merged = d.mergeIdSets([ds1, ds2])
|
||||||
|
const e1 = d.diffIdSet(ds1, ds2)
|
||||||
|
const e2 = d.diffIdSet(merged, ds2)
|
||||||
|
compareIdSets(e1, e2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDeletes = tc => {
|
||||||
|
const clients = 1
|
||||||
|
const clockRange = 100
|
||||||
|
const idset = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const client = Array.from(idset.clients.keys())[0]
|
||||||
|
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||||
|
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
|
||||||
|
const idsetOfDeletes = d.createIdSet()
|
||||||
|
idsetOfDeletes.add(client, clock, len)
|
||||||
|
const diffed = d.diffIdSet(idset, idsetOfDeletes)
|
||||||
|
idset.delete(client, clock, len)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
t.assert(!idset.has(client, clock + i))
|
||||||
|
}
|
||||||
|
compareIdSets(idset, diffed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatMergingMultipleIdsets = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
/**
|
||||||
|
* @type {Array<d.IdSet>}
|
||||||
|
*/
|
||||||
|
const idss = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
idss.push(createRandomIdSet(tc.prng, clients, clockRange))
|
||||||
|
}
|
||||||
|
const merged = d.mergeIdSets(idss)
|
||||||
|
const mergedReverse = d.mergeIdSets(idss.reverse())
|
||||||
|
compareIdSets(merged, mergedReverse)
|
||||||
|
const composed = d.createIdSet()
|
||||||
|
for (let iclient = 0; iclient < clients; iclient++) {
|
||||||
|
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
|
||||||
|
const mergedHas = merged.hasId(new ID(iclient, iclock))
|
||||||
|
const oneHas = idss.some(ids => ids.hasId(new ID(iclient, iclock)))
|
||||||
|
t.assert(mergedHas === oneHas)
|
||||||
|
if (oneHas) {
|
||||||
|
composed.add(iclient, iclock, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compareIdSets(merged, composed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomDiffing2 = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const merged = d.mergeIdSets([ids1, ids2])
|
||||||
|
const mergedExcluded = d.diffIdSet(merged, idsExclude)
|
||||||
|
const e1 = d.diffIdSet(ids1, idsExclude)
|
||||||
|
const e2 = d.diffIdSet(ids2, idsExclude)
|
||||||
|
const excludedMerged = d.mergeIdSets([e1, e2])
|
||||||
|
compareIdSets(mergedExcluded, excludedMerged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomIntersects = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
const intersected = d.intersectSets(ids1, ids2)
|
||||||
|
for (let client = 0; client < clients; client++) {
|
||||||
|
for (let clock = 0; clock < clockRange; clock++) {
|
||||||
|
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diffed1 = d.diffIdSet(ids1, ids2)
|
||||||
|
const altDiffed1 = d.diffIdSet(ids1, intersected)
|
||||||
|
compareIdSets(diffed1, altDiffed1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allocation-free `covers` / `coveredLength` must agree with per-id `has` and with the
|
||||||
|
* allocating `slice(...)` reduction.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatRandomCoversAndCoveredLength = tc => {
|
||||||
|
const clients = 4
|
||||||
|
const clockRange = 100
|
||||||
|
const ids = createRandomIdSet(tc.prng, clients, clockRange)
|
||||||
|
for (let iter = 0; iter < 300; iter++) {
|
||||||
|
const client = prng.int31(tc.prng, 0, clients - 1)
|
||||||
|
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||||
|
const len = prng.int31(tc.prng, 1, clockRange - clock + 5) // may exceed the range to cover edges
|
||||||
|
// reference by scanning every id in the range
|
||||||
|
let refCovered = 0
|
||||||
|
let refCovers = true
|
||||||
|
for (let c = clock; c < clock + len; c++) {
|
||||||
|
if (ids.has(client, c)) refCovered++
|
||||||
|
else refCovers = false
|
||||||
|
}
|
||||||
|
t.assert(ids.covers(client, clock, len) === refCovers)
|
||||||
|
t.assert(ids.coveredLength(client, clock, len) === refCovered)
|
||||||
|
// cross-check against the allocating slice
|
||||||
|
const sliceCovered = ids.slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)
|
||||||
|
t.assert(ids.coveredLength(client, clock, len) === sliceCovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The struct that covers `clock` for the document's local client.
|
||||||
|
*
|
||||||
|
* @param {Y.Doc} doc
|
||||||
|
* @param {number} clock
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
const structAt = (doc, clock) => {
|
||||||
|
const structs = /** @type {Array<any>} */ (doc.store.clients.get(doc.clientID))
|
||||||
|
return structs[Y.findIndexSS(structs, clock)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `gcIdSet` collects the deleted content referenced by an IdSet on a `gc: false` document,
|
||||||
|
* without changing the observable state, and the result still converges with a peer.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGcIdSetBasic = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const yarr = doc.get('array')
|
||||||
|
yarr.insert(0, ['a', 'b', 'c', 'd'])
|
||||||
|
yarr.delete(1, 2) // delete 'b' (clock 1) and 'c' (clock 2)
|
||||||
|
// gc is disabled, so the deleted content is retained
|
||||||
|
t.assert(structAt(doc, 1).content instanceof Y.ContentAny)
|
||||||
|
|
||||||
|
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
|
||||||
|
|
||||||
|
// observable state is unchanged
|
||||||
|
t.compare(yarr.toArray(), ['a', 'd'])
|
||||||
|
// every collectible deleted item is now collected
|
||||||
|
doc.store.clients.forEach(structs => {
|
||||||
|
structs.forEach(struct => {
|
||||||
|
if (struct.constructor === Y.Item && struct.deleted && !struct.keep) {
|
||||||
|
t.assert(struct.content instanceof Y.ContentDeleted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// still converges with a fresh peer
|
||||||
|
const peer = new Y.Doc()
|
||||||
|
Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc))
|
||||||
|
t.compare(peer.get('array').toArray(), ['a', 'd'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `gcIdSet` splits structs at the IdSet boundaries, so only the exact referenced content is
|
||||||
|
* collected - the rest of a partially-covered deleted run is preserved.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGcIdSetPartialRange = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const yarr = doc.get('array')
|
||||||
|
yarr.insert(0, ['a', 'b', 'c', 'd', 'e'])
|
||||||
|
yarr.delete(1, 4) // delete 'b','c','d','e' (clocks 1..4), retained because gc is off
|
||||||
|
|
||||||
|
// collect only the middle of the deleted run: clocks 2..3 ('c', 'd')
|
||||||
|
const ids = d.createIdSet()
|
||||||
|
ids.add(doc.clientID, 2, 2)
|
||||||
|
Y.gcIdSet(doc, ids)
|
||||||
|
|
||||||
|
t.compare(yarr.toArray(), ['a'])
|
||||||
|
// 'b' (clock 1) and 'e' (clock 4) are outside the IdSet -> still retained
|
||||||
|
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
|
||||||
|
t.assert(structAt(doc, 4).deleted && structAt(doc, 4).content instanceof Y.ContentAny)
|
||||||
|
// 'c','d' (clocks 2..3) were inside the IdSet -> collected
|
||||||
|
t.assert(structAt(doc, 2).content instanceof Y.ContentDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids that don't reference collectible content (live content) are skipped.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGcIdSetSkipsLiveContent = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const yarr = doc.get('array')
|
||||||
|
yarr.insert(0, ['a', 'b', 'c'])
|
||||||
|
yarr.delete(1, 1) // delete only 'b'
|
||||||
|
|
||||||
|
// pass *all* ids (live + deleted); only the deleted one is collectible
|
||||||
|
Y.gcIdSet(doc, Y.createInsertSetFromStructStore(doc.store, false))
|
||||||
|
|
||||||
|
t.compare(yarr.toArray(), ['a', 'c'])
|
||||||
|
t.assert(structAt(doc, 0).content instanceof Y.ContentAny) // 'a' is live -> untouched
|
||||||
|
t.assert(structAt(doc, 2).content instanceof Y.ContentAny) // 'c' is live -> untouched
|
||||||
|
t.assert(structAt(doc, 1).content instanceof Y.ContentDeleted) // 'b' was deleted -> collected
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calling `gcIdSet` twice is a no-op the second time.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGcIdSetIdempotent = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const yarr = doc.get('array')
|
||||||
|
yarr.insert(0, ['a', 'b', 'c'])
|
||||||
|
yarr.delete(0, 2)
|
||||||
|
const ds = Y.createDeleteSetFromStructStore(doc.store)
|
||||||
|
Y.gcIdSet(doc, ds)
|
||||||
|
const encoded = Y.encodeStateAsUpdate(doc)
|
||||||
|
Y.gcIdSet(doc, ds) // second pass changes nothing
|
||||||
|
t.compare(yarr.toArray(), ['c'])
|
||||||
|
t.compare(Y.encodeStateAsUpdate(doc), encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items flagged with `keep` (e.g. protected by an UndoManager) are not collected.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGcIdSetRespectsKeep = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const yarr = doc.get('array')
|
||||||
|
yarr.insert(0, ['a', 'b', 'c'])
|
||||||
|
yarr.delete(1, 1) // delete 'b' (clock 1)
|
||||||
|
structAt(doc, 1).keep = true // protect it from gc
|
||||||
|
|
||||||
|
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
|
||||||
|
|
||||||
|
// 'b' was kept -> still retained, not collected
|
||||||
|
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
|
||||||
|
}
|
||||||
2348
tests/attribution.tests.js
Normal file
2348
tests/attribution.tests.js
Normal file
File diff suppressed because it is too large
Load Diff
69
tests/compatibility.tests.js
Normal file
69
tests/compatibility.tests.js
Normal file
File diff suppressed because one or more lines are too long
197
tests/delta.tests.js
Normal file
197
tests/delta.tests.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as s from 'lib0/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta is a versatile format enabling you to efficiently describe changes. It is part of lib0, so
|
||||||
|
* that non-yjs applications can use it without consuming the full Yjs package. It is well suited
|
||||||
|
* for efficiently describing state & changesets.
|
||||||
|
*
|
||||||
|
* Assume we start with the text "hello world". Now we want to delete " world" and add an
|
||||||
|
* exclamation mark. The final content should be "hello!" ("hello world" => "hello!")
|
||||||
|
*
|
||||||
|
* In most editors, you would describe the necessary changes as replace operations using indexes.
|
||||||
|
* However, this might become ambiguous when many changes are involved.
|
||||||
|
*
|
||||||
|
* - delete range 5-11
|
||||||
|
* - insert "!" at position 11
|
||||||
|
*
|
||||||
|
* Using the delta format, you can describe the changes similar to what you would do in an text editor.
|
||||||
|
* The "|" describes the current cursor position.
|
||||||
|
*
|
||||||
|
* - d.retain(5) - "|hello world" => "hello| world" - jump over the next five characters
|
||||||
|
* - d.delete(6) - "hello| world" => "hello|" - delete the next 6 characres
|
||||||
|
* - d.insert('!') - "hello!|" - insert "!" at the current position
|
||||||
|
* => compact form: d.retain(5).delete(6).insert('!')
|
||||||
|
*
|
||||||
|
* You can also apply the changes in two distinct steps and then rebase the op so that you can apply
|
||||||
|
* them in two distinct steps.
|
||||||
|
* - delete " world": d1 = delta.create().retain(5).delete(6)
|
||||||
|
* - insert "!": d2 = delta.create().retain(11).insert('!')
|
||||||
|
* - rebase d2 on-top of d1: d2.rebase(d1) == delta.create().retain(5).insert('!')
|
||||||
|
* - merge into a single change: d1.apply(d2) == delta.create().retain(5).delete(6).insert(!)
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaBasics = _tc => {
|
||||||
|
// the state of our text document
|
||||||
|
const state = delta.create().insert('hello world')
|
||||||
|
// describe changes: delete " world" & insert "!"
|
||||||
|
const change = delta.create().retain(5).delete(6).insert('!')
|
||||||
|
// apply changes to state
|
||||||
|
state.apply(change)
|
||||||
|
// compare state to expected state
|
||||||
|
t.assert(state.equals(delta.create().insert('hello!').done()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lib0 also ships a schema library that can be used to validate JSON objects and custom data types,
|
||||||
|
* like Yjs types.
|
||||||
|
*
|
||||||
|
* As a convention, schemas are usually prefixed with a $ sign. This clarifies the difference
|
||||||
|
* between a schema, and an instance of a schema.
|
||||||
|
*
|
||||||
|
* const $myobj = s.$object({ key: s.$number })
|
||||||
|
* let inputValue: any
|
||||||
|
* if ($myobj.check(inputValue)) {
|
||||||
|
* inputValue // is validated and of type $myobj
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* We can also define the expected values on a delta.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaBasicSchema = _tc => {
|
||||||
|
const $d = delta.$delta({ attrs: { key: s.$string }, children: s.$number, text: false })
|
||||||
|
const d = delta.create($d)
|
||||||
|
// @ts-expect-error
|
||||||
|
d.setAttr('key', false) // invalid change: will throw a type error
|
||||||
|
t.fails(() => {
|
||||||
|
// @ts-expect-error
|
||||||
|
d.apply(delta.create().setAttr('key', false)) // invalid delta: will throw a type error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deltas can describe changes on attributes and children. Textual insertions are children. But we
|
||||||
|
* may also insert json-objects and other deltas as children.
|
||||||
|
* Key-value pairs can be represented as attributes. This "convoluted" changeset enables us to
|
||||||
|
* describe many changes in the same breath:
|
||||||
|
*
|
||||||
|
* delta.create().setAttr('a', 42).retain(5).delete(6).insert('!').deleteAttr('b')
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaValues = _tc => {
|
||||||
|
const change = delta.create().setAttr('a', 42).deleteAttr('b').retain(5).delete(6).insert('!').insert([{ my: 'custom object' }])
|
||||||
|
// iterate through attribute changes
|
||||||
|
for (const attrChange of change.attrs) {
|
||||||
|
if (delta.$insertOp.check(attrChange)) {
|
||||||
|
console.log(`set ${attrChange.key} to ${attrChange.value}`)
|
||||||
|
} else if (delta.$deleteOp.check(attrChange)) {
|
||||||
|
console.log(`delete ${attrChange.key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// iterate through child changes
|
||||||
|
for (const childChange of change.children) {
|
||||||
|
if (delta.$retainOp.check(childChange)) {
|
||||||
|
console.log(`retain ${childChange.retain} child items`)
|
||||||
|
} else if (delta.$deleteOp.check(childChange)) {
|
||||||
|
console.log(`delete ${childChange.delete} child items`)
|
||||||
|
} else if (delta.$insertOp.check(childChange)) {
|
||||||
|
console.log('insert child items:', childChange.insert)
|
||||||
|
} else if (delta.$textOp.check(childChange)) {
|
||||||
|
console.log('insert textual content', childChange.insert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new delta defines changes on attributes (key-value) and child elements (list & text), but can
|
||||||
|
* also be used to describe the current state of a document.
|
||||||
|
*
|
||||||
|
* 1. apply a delta to change a yjs type
|
||||||
|
* 2. observe deltas to read the differences
|
||||||
|
* 3. merge deltas to reflect multiple changes in a single delta
|
||||||
|
* 4. All Yjs types fully support the delta format. It is no longer necessary to define the type (such as Y.Array)
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBasics = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ynode = ydoc.get('my data')
|
||||||
|
/**
|
||||||
|
* @type {delta.Delta<{attrs: { a: number }, children: { my: string }, text: true }>}
|
||||||
|
*/
|
||||||
|
let observedDelta = delta.create()
|
||||||
|
ynode.observe(event => {
|
||||||
|
observedDelta = event.deltaDeep
|
||||||
|
console.log('ynode changed:', observedDelta.toJSON())
|
||||||
|
})
|
||||||
|
// define a change: set attribute: a=42
|
||||||
|
const attrChange = delta.create().setAttr('a', 42).done()
|
||||||
|
// define a change: insert textual content and an object
|
||||||
|
const childChange = delta.create().insert('hello').insert([{ my: 'object' }]).done()
|
||||||
|
// merge changes
|
||||||
|
const mergedChanges = delta.create(delta.$deltaAny)
|
||||||
|
mergedChanges.apply(attrChange)
|
||||||
|
mergedChanges.apply(childChange).done()
|
||||||
|
console.log('merged changes: ', mergedChanges.toJSON())
|
||||||
|
ynode.applyDelta(mergedChanges)
|
||||||
|
// the observed change should equal the applied change
|
||||||
|
t.assert(observedDelta.equals(mergedChanges))
|
||||||
|
// read the current state of the yjs types as a delta
|
||||||
|
const currState = ynode.toDeltaDeep()
|
||||||
|
t.assert(currState.equals(mergedChanges)) // equal to the changes that we applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deltas allow us to describe the differences between two Yjs documents though "Attributions".
|
||||||
|
*
|
||||||
|
* - We can attribute changes to a user, or a group of users
|
||||||
|
* - There are 'insert', 'delete', and 'format' attributions
|
||||||
|
* - When we render attributions, we render inserted & deleted content as an insertions with special
|
||||||
|
* attributes which allow you to..
|
||||||
|
* -- Render deleted content using a strikethrough: I.e. `hello w̶o̶r̶l̶d̶!`
|
||||||
|
* -- Render attributed insertions using a background color.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testAttributions = _tc => {
|
||||||
|
const ydocV1 = new Y.Doc()
|
||||||
|
const ynodeV1 = ydocV1.get('txt')
|
||||||
|
ynodeV1.applyDelta(delta.create().insert('hello world').done())
|
||||||
|
// create a new version with updated content
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||||
|
const ynode = ydoc.get('txt')
|
||||||
|
// delete " world" and insert exclamation mark "!".
|
||||||
|
ynode.applyDelta(delta.create().retain(5).delete(6).insert('!').done())
|
||||||
|
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||||
|
// get the attributed differences
|
||||||
|
const attributedContent = ynode.toDelta({ renderer })
|
||||||
|
console.log('attributed content', attributedContent.toJSON())
|
||||||
|
t.assert(attributedContent.equals(delta.create().insert('hello').insert(' world', null, { delete: [] }).insert('!', null, { insert: [] }).done()))
|
||||||
|
// for editor bindings, it is also necessary to observe changes and get the attributed changes
|
||||||
|
ynode.observe(event => {
|
||||||
|
const attributedChange = event.getDelta({ renderer })
|
||||||
|
console.log('the attributed change', attributedChange.toJSON())
|
||||||
|
t.assert(attributedChange.done().equals(delta.create().retain(11).insert('!', null, { insert: [] }).done()))
|
||||||
|
const unattributedChange = event.delta
|
||||||
|
console.log('the UNattributed change', unattributedChange.toJSON())
|
||||||
|
t.assert(unattributedChange.equals(delta.create().retain(5).insert('!').done()))
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Content now has different representations.
|
||||||
|
* - The UNattributed representation renders the latest state, without history.
|
||||||
|
* - The attributed representation renders the differences.
|
||||||
|
*
|
||||||
|
* Attributed: 'hello<delete> world</delete><insert>!</insert>'
|
||||||
|
* UNattributed: 'world!'
|
||||||
|
*/
|
||||||
|
// Apply a change to the attributed content
|
||||||
|
ynode.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer })
|
||||||
|
// // Equivalent to applying a change to the UNattributed content:
|
||||||
|
// ynode.applyDelta(delta.create().retain(5).insert('!'))
|
||||||
|
}
|
||||||
354
tests/doc.tests.js
Normal file
354
tests/doc.tests.js
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testAfterTransactionRecursion = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yxml = ydoc.get('')
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
if (tr.origin === 'test') {
|
||||||
|
yxml.toJSON()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ydoc.transact(_tr => {
|
||||||
|
for (let i = 0; i < 15000; i++) {
|
||||||
|
yxml.push([new Y.Node('a')])
|
||||||
|
}
|
||||||
|
}, 'test')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testFindTypeInOtherDoc = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ymap = ydoc.get()
|
||||||
|
const ytext = ymap.setAttr('ytext', new Y.Node())
|
||||||
|
const ydocClone = new Y.Doc()
|
||||||
|
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
/**
|
||||||
|
* @param {Y.Node} ynode
|
||||||
|
* @param {Y.Doc} otherYdoc
|
||||||
|
* @return {Y.Node}
|
||||||
|
*/
|
||||||
|
const findTypeInOtherYdoc = (ynode, otherYdoc) => {
|
||||||
|
const ydoc = /** @type {Y.Doc} */ (ynode.doc)
|
||||||
|
if (ynode._item === null) {
|
||||||
|
/**
|
||||||
|
* If is a root type, we need to find the root key in the original ydoc
|
||||||
|
* and use it to get the type in the other ydoc.
|
||||||
|
*/
|
||||||
|
const rootKey = Array.from(ydoc.share.keys()).find(
|
||||||
|
(key) => ydoc.share.get(key) === ynode
|
||||||
|
)
|
||||||
|
if (rootKey == null) {
|
||||||
|
throw new Error('type does not exist in other ydoc')
|
||||||
|
}
|
||||||
|
return otherYdoc.get(rootKey)
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* If it is a sub type, we use the item id to find the history type.
|
||||||
|
*/
|
||||||
|
const ynodeItem = ynode._item
|
||||||
|
const otherStructs = otherYdoc.store.clients.get(ynodeItem.id.client) ?? []
|
||||||
|
const itemIndex = Y.findIndexSS(
|
||||||
|
otherStructs,
|
||||||
|
ynodeItem.id.clock
|
||||||
|
)
|
||||||
|
const otherItem = /** @type {Y.Item} */ (otherStructs[itemIndex])
|
||||||
|
const otherContent = /** @type {Y.ContentType} */ (otherItem.content)
|
||||||
|
return /** @type {Y.Node} */ (otherContent.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.assert(findTypeInOtherYdoc(ymap, ydocClone) != null)
|
||||||
|
t.assert(findTypeInOtherYdoc(ytext, ydocClone) != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testClientIdDuplicateChange = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
doc1.clientID = 0
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
doc2.clientID = 0
|
||||||
|
t.assert(doc2.clientID === doc1.clientID)
|
||||||
|
doc1.get('a').insert(0, [1, 2])
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||||
|
t.assert(doc2.clientID !== doc1.clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGetTypeEmptyId = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
doc1.get('').insert(0, 'h')
|
||||||
|
doc1.get().insert(1, 'i')
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||||
|
t.assert(doc2.get().toString() === 'hi')
|
||||||
|
t.assert(doc2.get('').toString() === 'hi')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testToJSON = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
|
||||||
|
|
||||||
|
const arr = doc.get('array')
|
||||||
|
arr.push(['test1'])
|
||||||
|
|
||||||
|
const map = doc.get('map')
|
||||||
|
map.setAttr('k1', 'v1')
|
||||||
|
const map2 = new Y.Node()
|
||||||
|
map.setAttr('k2', map2)
|
||||||
|
map2.setAttr('m2k1', 'm2v1')
|
||||||
|
t.compare(doc.toJSON(), {
|
||||||
|
array: { children: ['test1'] },
|
||||||
|
map: {
|
||||||
|
attrs: {
|
||||||
|
k1: 'v1',
|
||||||
|
k2: {
|
||||||
|
attrs: {
|
||||||
|
m2k1: 'm2v1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'doc.toJSON has array and recursive map')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSubdoc = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
doc.load() // doesn't do anything
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @type {Array<any>|null}
|
||||||
|
*/
|
||||||
|
let event = /** @type {any} */ (null)
|
||||||
|
doc.on('subdocs', subdocs => {
|
||||||
|
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
|
||||||
|
})
|
||||||
|
const subdocs = doc.get('mysubdocs')
|
||||||
|
const docA = new Y.Doc({ guid: 'a' })
|
||||||
|
docA.load()
|
||||||
|
subdocs.setAttr('a', docA)
|
||||||
|
t.compare(event, [['a'], [], ['a']])
|
||||||
|
|
||||||
|
event = null
|
||||||
|
subdocs.getAttr('a').load()
|
||||||
|
t.assert(event === null)
|
||||||
|
|
||||||
|
event = null
|
||||||
|
subdocs.getAttr('a').destroy()
|
||||||
|
t.compare(event, [['a'], ['a'], []])
|
||||||
|
subdocs.getAttr('a').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
subdocs.setAttr('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
|
||||||
|
t.compare(event, [['a'], [], []])
|
||||||
|
subdocs.getAttr('b').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
const docC = new Y.Doc({ guid: 'c' })
|
||||||
|
docC.load()
|
||||||
|
subdocs.setAttr('c', docC)
|
||||||
|
t.compare(event, [['c'], [], ['c']])
|
||||||
|
|
||||||
|
t.compare(Array.from(doc.getSubdocGuids()), ['a', 'c'])
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
{
|
||||||
|
t.compare(Array.from(doc2.getSubdocs()), [])
|
||||||
|
/**
|
||||||
|
* @type {Array<any>|null}
|
||||||
|
*/
|
||||||
|
let event = /** @type {any} */ (null)
|
||||||
|
doc2.on('subdocs', subdocs => {
|
||||||
|
event = [Array.from(subdocs.added).map(d => d.guid), Array.from(subdocs.removed).map(d => d.guid), Array.from(subdocs.loaded).map(d => d.guid)]
|
||||||
|
})
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
t.compare(event, [['a', 'a', 'c'], [], []])
|
||||||
|
|
||||||
|
doc2.get('mysubdocs').getAttr('a').load()
|
||||||
|
t.compare(event, [[], [], ['a']])
|
||||||
|
|
||||||
|
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||||
|
|
||||||
|
doc2.get('mysubdocs').deleteAttr('a')
|
||||||
|
t.compare(event, [[], ['a'], []])
|
||||||
|
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSubdocLoadEdgeCases = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yarray = ydoc.get()
|
||||||
|
const subdoc1 = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let lastEvent = null
|
||||||
|
ydoc.on('subdocs', event => {
|
||||||
|
lastEvent = event
|
||||||
|
})
|
||||||
|
yarray.insert(0, [subdoc1])
|
||||||
|
t.assert(subdoc1.shouldLoad)
|
||||||
|
t.assert(subdoc1.autoLoad === false)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||||
|
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||||
|
subdoc1.destroy()
|
||||||
|
const subdoc2 = yarray.get(0)
|
||||||
|
t.assert(subdoc1 !== subdoc2)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||||
|
// load
|
||||||
|
subdoc2.load()
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||||
|
// apply from remote
|
||||||
|
const ydoc2 = new Y.Doc()
|
||||||
|
ydoc2.on('subdocs', event => {
|
||||||
|
lastEvent = event
|
||||||
|
})
|
||||||
|
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
const subdoc3 = ydoc2.get().get(0)
|
||||||
|
t.assert(subdoc3.shouldLoad === false)
|
||||||
|
t.assert(subdoc3.autoLoad === false)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
|
||||||
|
// load
|
||||||
|
subdoc3.load()
|
||||||
|
t.assert(subdoc3.shouldLoad)
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSubdocLoadEdgeCasesAutoload = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yarray = ydoc.get()
|
||||||
|
const subdoc1 = new Y.Doc({ autoLoad: true })
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let lastEvent = null
|
||||||
|
ydoc.on('subdocs', event => {
|
||||||
|
lastEvent = event
|
||||||
|
})
|
||||||
|
yarray.insert(0, [subdoc1])
|
||||||
|
t.assert(subdoc1.shouldLoad)
|
||||||
|
t.assert(subdoc1.autoLoad)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||||
|
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||||
|
subdoc1.destroy()
|
||||||
|
const subdoc2 = yarray.get(0)
|
||||||
|
t.assert(subdoc1 !== subdoc2)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||||
|
// load
|
||||||
|
subdoc2.load()
|
||||||
|
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||||
|
// apply from remote
|
||||||
|
const ydoc2 = new Y.Doc()
|
||||||
|
ydoc2.on('subdocs', event => {
|
||||||
|
lastEvent = event
|
||||||
|
})
|
||||||
|
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
const subdoc3 = ydoc2.get().get(0)
|
||||||
|
t.assert(subdoc1.shouldLoad)
|
||||||
|
t.assert(subdoc1.autoLoad)
|
||||||
|
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||||
|
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSubdocsUndo = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const elems = ydoc.get()
|
||||||
|
const undoManager = new Y.UndoManager(elems)
|
||||||
|
const subdoc = new Y.Doc()
|
||||||
|
// @ts-ignore
|
||||||
|
elems.insert(0, [subdoc])
|
||||||
|
undoManager.undo()
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(elems.length === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testLoadDocsEvent = async _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
t.assert(ydoc.isLoaded === false)
|
||||||
|
let loadedEvent = false
|
||||||
|
ydoc.on('load', () => {
|
||||||
|
loadedEvent = true
|
||||||
|
})
|
||||||
|
ydoc.emit('load', [ydoc])
|
||||||
|
await ydoc.whenLoaded
|
||||||
|
t.assert(loadedEvent)
|
||||||
|
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)
|
||||||
|
}
|
||||||
75
tests/encoding.tests.js
Normal file
75
tests/encoding.tests.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as t from 'lib0/testing'
|
||||||
|
|
||||||
|
import {
|
||||||
|
contentRefs,
|
||||||
|
readContentDeleted,
|
||||||
|
readContentBinary,
|
||||||
|
readContentString,
|
||||||
|
readContentJSON,
|
||||||
|
readContentEmbed,
|
||||||
|
readContentType,
|
||||||
|
readContentFormat,
|
||||||
|
readContentAny,
|
||||||
|
readContentDoc
|
||||||
|
} from '../src/ynode.js'
|
||||||
|
|
||||||
|
import * as Y from '../src/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testStructReferences = _tc => {
|
||||||
|
t.assert(contentRefs.length === 11)
|
||||||
|
t.assert(contentRefs[1] === readContentDeleted)
|
||||||
|
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
|
||||||
|
t.assert(contentRefs[3] === readContentBinary)
|
||||||
|
t.assert(contentRefs[4] === readContentString)
|
||||||
|
t.assert(contentRefs[5] === readContentEmbed)
|
||||||
|
t.assert(contentRefs[6] === readContentFormat)
|
||||||
|
t.assert(contentRefs[7] === readContentType)
|
||||||
|
t.assert(contentRefs[8] === readContentAny)
|
||||||
|
t.assert(contentRefs[9] === readContentDoc)
|
||||||
|
// contentRefs[10] is reserved for Skip structs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reported here: https://github.com/yjs/yjs/issues/308
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDiffStateVectorOfUpdateIsEmpty = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let sv = null
|
||||||
|
ydoc.get().insert(0, 'a')
|
||||||
|
ydoc.on('update', update => {
|
||||||
|
sv = Y.encodeStateVectorFromUpdate(update)
|
||||||
|
})
|
||||||
|
// should produce an update with an empty state vector (because previous ops are missing)
|
||||||
|
ydoc.get().insert(0, 'a')
|
||||||
|
t.assert(sv !== null && sv.byteLength === 1 && sv[0] === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reported here: https://github.com/yjs/yjs/issues/308
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDiffStateVectorOfUpdateIgnoresSkips = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {Array<Uint8Array<ArrayBuffer>>}
|
||||||
|
*/
|
||||||
|
const updates = []
|
||||||
|
ydoc.on('update', update => {
|
||||||
|
updates.push(update)
|
||||||
|
})
|
||||||
|
ydoc.get().insert(0, 'a')
|
||||||
|
ydoc.get().insert(0, 'b')
|
||||||
|
ydoc.get().insert(0, 'c')
|
||||||
|
const update13 = Y.mergeUpdates([updates[0], updates[2]])
|
||||||
|
const sv = Y.encodeStateVectorFromUpdate(update13)
|
||||||
|
const state = Y.decodeStateVector(sv)
|
||||||
|
t.assert(state.get(ydoc.clientID) === 1)
|
||||||
|
t.assert(state.size === 1)
|
||||||
|
}
|
||||||
39
tests/index.js
Normal file
39
tests/index.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
|
||||||
|
import * as map from './y-map.tests.js'
|
||||||
|
import * as array from './y-array.tests.js'
|
||||||
|
import * as text from './y-text.tests.js'
|
||||||
|
import * as xml from './y-xml.tests.js'
|
||||||
|
import * as encoding from './encoding.tests.js'
|
||||||
|
import * as undoredo from './undo-redo.tests.js'
|
||||||
|
import * as compatibility from './compatibility.tests.js'
|
||||||
|
import * as doc from './doc.tests.js'
|
||||||
|
import * as snapshot from './snapshot.tests.js'
|
||||||
|
import * as updates from './updates.tests.js'
|
||||||
|
import * as relativePositions from './relativePositions.tests.js'
|
||||||
|
import * as idset from './IdSet.tests.js'
|
||||||
|
import * as idmap from './IdMap.tests.js'
|
||||||
|
import * as attribution from './attribution.tests.js'
|
||||||
|
import * as delta from './delta.tests.js'
|
||||||
|
import * as schema from './schema.tests.js'
|
||||||
|
|
||||||
|
import { runTests } from 'lib0/testing'
|
||||||
|
import { isBrowser, isNode } from 'lib0/environment'
|
||||||
|
import * as log from 'lib0/logging'
|
||||||
|
|
||||||
|
if (isBrowser) {
|
||||||
|
log.createVConsole(document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tests = {
|
||||||
|
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, idset, idmap, attribution, delta, schema
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const success = await runTests(tests)
|
||||||
|
/* istanbul ignore next */
|
||||||
|
if (isNode) {
|
||||||
|
process.exit(success ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
372
tests/relativePositions.tests.js
Normal file
372
tests/relativePositions.tests.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as dpos from 'lib0/delta/position'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Y.Node<{text:true}>} ytext
|
||||||
|
*/
|
||||||
|
const checkRelativePositions = ytext => {
|
||||||
|
// test if all positions are encoded and restored correctly
|
||||||
|
for (let i = 0; i < ytext.length; i++) {
|
||||||
|
// for all types of associations..
|
||||||
|
for (let assoc = -1; assoc < 2; assoc++) {
|
||||||
|
const rpos = Y.createRelativePositionFromTypeIndex(ytext, i, assoc)
|
||||||
|
const encodedRpos = Y.encodeRelativePosition(rpos)
|
||||||
|
const decodedRpos = Y.decodeRelativePosition(encodedRpos)
|
||||||
|
const absPos = /** @type {Y.AbsolutePosition} */ (Y.createAbsolutePositionFromRelativePosition(decodedRpos, /** @type {Y.Doc} */ (ytext.doc)))
|
||||||
|
t.assert(absPos.index === i)
|
||||||
|
t.assert(absPos.assoc === assoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase1 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
ytext.insert(0, 'abc')
|
||||||
|
ytext.insert(0, 'z')
|
||||||
|
ytext.insert(0, 'y')
|
||||||
|
ytext.insert(0, 'x')
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase2 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'abc')
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase3 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'abc')
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
ytext.insert(0, 'xyz')
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase4 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase5 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, '2')
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase6 = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
checkRelativePositions(ytext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testing https://github.com/yjs/yjs/issues/657
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionCase7 = _tc => {
|
||||||
|
const docA = new Y.Doc()
|
||||||
|
const textA = docA.get('text')
|
||||||
|
textA.insert(0, 'abcde')
|
||||||
|
// Create a relative position at index 2 in 'textA'
|
||||||
|
const relativePosition = Y.createRelativePositionFromTypeIndex(textA, 2)
|
||||||
|
// Verify that the absolutes positions on 'docA' are the same
|
||||||
|
const absolutePositionWithFollow =
|
||||||
|
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, true)
|
||||||
|
const absolutePositionWithoutFollow =
|
||||||
|
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, false)
|
||||||
|
t.assert(absolutePositionWithFollow?.index === 2)
|
||||||
|
t.assert(absolutePositionWithoutFollow?.index === 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionAssociationDifference = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, '2')
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
const rposRight = Y.createRelativePositionFromTypeIndex(ytext, 1, 0)
|
||||||
|
const rposLeft = Y.createRelativePositionFromTypeIndex(ytext, 1, -1)
|
||||||
|
ytext.insert(1, 'x')
|
||||||
|
const posRight = Y.createAbsolutePositionFromRelativePosition(rposRight, ydoc)
|
||||||
|
const posLeft = Y.createAbsolutePositionFromRelativePosition(rposLeft, ydoc)
|
||||||
|
t.assert(posRight != null && posRight.index === 2)
|
||||||
|
t.assert(posLeft != null && posLeft.index === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRelativePositionWithUndo = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'hello world')
|
||||||
|
const rpos = Y.createRelativePositionFromTypeIndex(ytext, 1)
|
||||||
|
const um = new Y.UndoManager(ytext)
|
||||||
|
ytext.delete(0, 6)
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 0)
|
||||||
|
um.undo()
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 1)
|
||||||
|
const posWithoutFollow = Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)
|
||||||
|
console.log({ posWithoutFollow })
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)?.index === 6)
|
||||||
|
const ydocClone = new Y.Doc()
|
||||||
|
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone)?.index === 6)
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone, false)?.index === 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsFlatTextRoundtrip = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'hello world')
|
||||||
|
const positions = [dpos.create([0]), dpos.create([5]), dpos.create([5], -1), dpos.create([11])]
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||||
|
t.assert(rposs.every(rpos => rpos !== null))
|
||||||
|
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||||
|
positions.forEach((pos, i) => {
|
||||||
|
const b = back[i]
|
||||||
|
t.assert(b !== null && dpos.equals(b, pos))
|
||||||
|
})
|
||||||
|
// the singular wrappers behave like a one-element batch
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||||
|
t.assert(Y.compareRelativePositions(rpos, rposs[1]))
|
||||||
|
const single = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||||
|
t.assert(single !== null && dpos.equals(single, positions[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsNested = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yroot = ydoc.get('arr')
|
||||||
|
yroot.insert(0, ['a', new Y.Node(), 'b'])
|
||||||
|
const child = /** @type {Y.Node<any>} */ (yroot.get(1))
|
||||||
|
child.insert(0, 'xyz')
|
||||||
|
const positions = [dpos.create([1, 2]), dpos.create([0])]
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions)
|
||||||
|
t.assert(rposs.every(rpos => rpos !== null))
|
||||||
|
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs)
|
||||||
|
positions.forEach((pos, i) => {
|
||||||
|
const b = back[i]
|
||||||
|
t.assert(b !== null && dpos.equals(b, pos))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsAttrDescent = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ymap = ydoc.get('map')
|
||||||
|
const child = ymap.setAttr('k', new Y.Node())
|
||||||
|
child.insert(0, 'ab')
|
||||||
|
const pos = dpos.create(['k', 1])
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||||
|
t.assert(rpos !== null)
|
||||||
|
const back = Y.createDeltaPositionFromRelativePosition(ymap, rpos)
|
||||||
|
t.assert(back !== null && dpos.equals(back, pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsAttrLeaf = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ymap = ydoc.get('map')
|
||||||
|
ymap.setAttr('k', 42)
|
||||||
|
const pos = dpos.create(['k'])
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||||
|
t.assert(rpos !== null && rpos.item !== null)
|
||||||
|
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||||
|
// the attribute-leaf anchor survives overwriting the attribute
|
||||||
|
ymap.setAttr('k', 43)
|
||||||
|
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||||
|
// .. but not deleting it
|
||||||
|
ymap.deleteAttr('k')
|
||||||
|
t.assert(Y.createDeltaPositionFromRelativePosition(ymap, rpos) === null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsDocRooted = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get('text')
|
||||||
|
ytext.insert(0, 'hello')
|
||||||
|
// doc-rooted paths start with a root-type name; numeric or terminal-string steps at doc level are unresolvable
|
||||||
|
const positions = [dpos.create(['text', 3]), dpos.create([0]), dpos.create(['text'])]
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(ydoc, positions)
|
||||||
|
t.assert(rposs[0] !== null && rposs[1] === null && rposs[2] === null)
|
||||||
|
const back = Y.createDeltaPositionFromRelativePosition(ydoc, rposs[0])
|
||||||
|
t.assert(back !== null && dpos.equals(back, positions[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsAssocThroughEdit = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, '2')
|
||||||
|
ytext.insert(0, '1')
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, [dpos.create([1]), dpos.create([1], -1)])
|
||||||
|
ytext.insert(1, 'x')
|
||||||
|
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||||
|
t.assert(back[0] !== null && dpos.equals(back[0], dpos.create([2])))
|
||||||
|
t.assert(back[1] !== null && dpos.equals(back[1], dpos.create([1], -1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsSurviveConcurrentEdit = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get('text')
|
||||||
|
ytext.insert(0, 'hello')
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([2]))
|
||||||
|
t.assert(rpos !== null)
|
||||||
|
const encoded = Y.encodeRelativePosition(/** @type {Y.RelativePosition} */ (rpos))
|
||||||
|
const ydoc2 = new Y.Doc()
|
||||||
|
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
const ytext2 = ydoc2.get('text')
|
||||||
|
ytext2.insert(0, 'abc')
|
||||||
|
const back = Y.createDeltaPositionFromRelativePosition(ytext2, Y.decodeRelativePosition(encoded))
|
||||||
|
t.assert(back !== null && dpos.equals(back, dpos.create([5])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsWithRenderer = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'hello world')
|
||||||
|
const v1 = Y.cloneDoc(ydoc)
|
||||||
|
ytext.delete(1, 6)
|
||||||
|
ytext.insert(1, 'x')
|
||||||
|
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||||
|
const pos = dpos.create([9]) // pos after "hello wo" in the rendered diff
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, pos, { renderer })
|
||||||
|
t.assert(rpos !== null)
|
||||||
|
const backWith = Y.createDeltaPositionFromRelativePosition(ytext, rpos, { renderer })
|
||||||
|
t.assert(backWith !== null && dpos.equals(backWith, pos))
|
||||||
|
const backWithout = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||||
|
t.assert(backWithout !== null && dpos.equals(backWithout, dpos.create([3])))
|
||||||
|
// the node's own renderer is the default - matching toDelta / node.delta
|
||||||
|
ytext.useRenderer(renderer)
|
||||||
|
const rpos2 = Y.createRelativePositionFromDeltaPosition(ytext, pos)
|
||||||
|
t.assert(Y.compareRelativePositions(rpos, rpos2))
|
||||||
|
const backDefault = Y.createDeltaPositionFromRelativePosition(ytext, rpos2)
|
||||||
|
t.assert(backDefault !== null && dpos.equals(backDefault, pos))
|
||||||
|
// explicit renderer: null overrides the node's renderer
|
||||||
|
const backNull = Y.createDeltaPositionFromRelativePosition(ytext, rpos2, { renderer: null })
|
||||||
|
t.assert(backNull !== null && dpos.equals(backNull, dpos.create([3])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsUnresolvable = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'abc')
|
||||||
|
const positions = [
|
||||||
|
dpos.create([0, 0]), // descend into a scalar
|
||||||
|
dpos.create(['missing', 0]), // missing attribute
|
||||||
|
dpos.create([]), // a node reference doesn't round-trip
|
||||||
|
dpos.create([10, 0]), // out-of-range non-terminal index
|
||||||
|
dpos.create([1]) // resolvable - other entries must not affect it
|
||||||
|
]
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||||
|
t.assert(rposs[0] === null && rposs[1] === null && rposs[2] === null && rposs[3] === null)
|
||||||
|
t.assert(rposs[4] !== null)
|
||||||
|
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||||
|
t.assert(back[0] === null && back[1] === null && back[2] === null && back[3] === null)
|
||||||
|
t.assert(back[4] !== null && dpos.equals(back[4], positions[4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsEndOfNode = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get()
|
||||||
|
ytext.insert(0, 'hello')
|
||||||
|
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||||
|
t.assert(rpos !== null && rpos.item === null)
|
||||||
|
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([5])))
|
||||||
|
// an end-of-node position sticks to the end
|
||||||
|
ytext.insert(5, ' world')
|
||||||
|
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([11])))
|
||||||
|
// empty node
|
||||||
|
const yempty = ydoc.get('empty')
|
||||||
|
const rposE = Y.createRelativePositionFromDeltaPosition(yempty, dpos.create([0]))
|
||||||
|
t.assert(rposE !== null && rposE.item === null)
|
||||||
|
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(yempty, rposE)), dpos.create([0])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions inside a nested node that is deleted but still rendered (e.g. a suggestion-mode
|
||||||
|
* subtree delete). Requires gc: false - with gc enabled, the deleted subtree's content is
|
||||||
|
* destroyed and such positions are unresolvable.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeltaPositionsInDeletedRenderedSubtree = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const yroot = ydoc.get('r')
|
||||||
|
yroot.insert(0, [new Y.Node(), 'x'])
|
||||||
|
const mid = /** @type {Y.Node<any>} */ (yroot.get(0))
|
||||||
|
mid.insert(0, ['q', new Y.Node()])
|
||||||
|
const leaf = /** @type {Y.Node<any>} */ (mid.get(1))
|
||||||
|
leaf.insert(0, 'hello')
|
||||||
|
const v1 = Y.cloneDoc(ydoc)
|
||||||
|
yroot.delete(0, 1) // delete the whole `mid` subtree - the diff renderer still renders it
|
||||||
|
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||||
|
// the underlying primitive resolves indexes inside the deleted-but-rendered type
|
||||||
|
const rposLeaf = Y.createRelativePositionFromTypeIndex(leaf, 3, 0, renderer)
|
||||||
|
t.assert(Y.createAbsolutePositionFromRelativePosition(rposLeaf, ydoc, true, renderer)?.index === 3)
|
||||||
|
// .. and delta positions round-trip through the deleted subtree
|
||||||
|
const positions = [dpos.create([0, 1, 3]), dpos.create([0, 0]), dpos.create([1])]
|
||||||
|
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions, { renderer })
|
||||||
|
t.assert(rposs.every(rpos => rpos !== null))
|
||||||
|
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs, { renderer })
|
||||||
|
positions.forEach((pos, i) => {
|
||||||
|
const b = back[i]
|
||||||
|
t.assert(b !== null && dpos.equals(b, pos))
|
||||||
|
})
|
||||||
|
}
|
||||||
327
tests/schema.tests.js
Normal file
327
tests/schema.tests.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as s from 'lib0/schema'
|
||||||
|
import * as Y from '../src/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} val
|
||||||
|
* @return {ContentAttribute<any>}
|
||||||
|
*/
|
||||||
|
const attr = val => Y.createContentAttribute('u', val)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IdMap holding one attribute value per range, all on client 1.
|
||||||
|
*
|
||||||
|
* @param {Array<any>} vals
|
||||||
|
* @return {IdMap<any>}
|
||||||
|
*/
|
||||||
|
const idMapOf = vals => {
|
||||||
|
const m = Y.createIdMap()
|
||||||
|
vals.forEach((v, i) => m.add(1, i * 10, 10, [attr(v)]))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {IdSet}
|
||||||
|
*/
|
||||||
|
const idSetOf = () => {
|
||||||
|
const set = Y.createIdSet()
|
||||||
|
set.add(1, 0, 10)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every schema on the public surface, paired with a value it must accept.
|
||||||
|
*
|
||||||
|
* The explicit return annotation is required: without it the mixed tuples infer a union element
|
||||||
|
* type and destructuring `$schema` loses `.check`.
|
||||||
|
*
|
||||||
|
* @return {Array<[string, s.Schema<any>, any]>}
|
||||||
|
*/
|
||||||
|
const $schemas = () => [
|
||||||
|
['$idSet', Y.$idSet, Y.createIdSet()],
|
||||||
|
['$idMapAny', Y.$idMapAny, Y.createIdMap()],
|
||||||
|
['$idMap($string)', Y.$idMap(s.$string), idMapOf(['a'])],
|
||||||
|
['$contentIds', Y.$contentIds, Y.createContentIds()],
|
||||||
|
['$contentMap', Y.$contentMap, Y.createContentMap()],
|
||||||
|
['$doc', Y.$doc, new Y.Doc()],
|
||||||
|
['$nodeAny', Y.$nodeAny, new Y.Doc().get('n')],
|
||||||
|
['$node({name:"p"})', Y.$node({ name: 'p' }), new Y.Doc().get('n', 'p')]
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSchemasAcceptTheirOwnInstance = _tc => {
|
||||||
|
$schemas().forEach(([name, $schema, instance]) => {
|
||||||
|
t.group(name, () => {
|
||||||
|
t.assert($schema.check(instance))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No schema accepts a foreign value, and none throws. `s.$object` returns false for null and reads
|
||||||
|
* its shape's keys off the value, so primitives / arrays / Maps fail on the `undefined` fields.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSchemasRejectJunk = _tc => {
|
||||||
|
const junk = [null, undefined, {}, 0, 1, '', 'idSet', [], true, false, new Map(), new Set(), () => {}]
|
||||||
|
$schemas().forEach(([name, $schema]) => {
|
||||||
|
t.group(name, () => {
|
||||||
|
junk.forEach(j => {
|
||||||
|
t.assert(!$schema.check(j), `must reject ${typeof j} ${String(j)}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nominal tags discriminate IdSet from IdMap in both directions. Structurally an IdSet
|
||||||
|
* (`{clients}`) is a *subset* of an IdMap (`{clients, attrsH, attrs}`), and `s.$object` always
|
||||||
|
* permits excess properties - so only a nominal check can tell them apart.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdSetIdMapDiscrimination = _tc => {
|
||||||
|
t.assert(!Y.$idSet.check(Y.createIdMap()), '$idSet rejects an IdMap')
|
||||||
|
t.assert(!Y.$idMapAny.check(Y.createIdSet()), '$idMapAny rejects an IdSet')
|
||||||
|
t.assert(!Y.$idMap(s.$string).check(Y.createIdSet()), '$idMap rejects an IdSet')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testContentIdsVsContentMap = _tc => {
|
||||||
|
const cIds = Y.createContentIds()
|
||||||
|
const cMap = Y.createContentMap()
|
||||||
|
t.group('positive', () => {
|
||||||
|
t.assert(Y.$contentIds.check(cIds))
|
||||||
|
t.assert(Y.$contentMap.check(cMap))
|
||||||
|
})
|
||||||
|
t.group('both directions', () => {
|
||||||
|
t.assert(!Y.$contentIds.check(cMap), '$contentIds rejects a ContentMap')
|
||||||
|
t.assert(!Y.$contentMap.check(cIds), '$contentMap rejects a ContentIds')
|
||||||
|
})
|
||||||
|
t.group('mixed shapes', () => {
|
||||||
|
const a = { inserts: Y.createIdSet(), deletes: Y.createIdMap() }
|
||||||
|
const b = { inserts: Y.createIdMap(), deletes: Y.createIdSet() }
|
||||||
|
t.assert(!Y.$contentIds.check(a))
|
||||||
|
t.assert(!Y.$contentIds.check(b))
|
||||||
|
t.assert(!Y.$contentMap.check(a))
|
||||||
|
t.assert(!Y.$contentMap.check(b))
|
||||||
|
})
|
||||||
|
t.group('missing fields', () => {
|
||||||
|
t.assert(!Y.$contentIds.check({ inserts: Y.createIdSet() }), 'deletes missing')
|
||||||
|
t.assert(!Y.$contentIds.check({ deletes: Y.createIdSet() }), 'inserts missing')
|
||||||
|
})
|
||||||
|
t.group('excess properties are allowed - s.$object reads the shape keys', () => {
|
||||||
|
t.assert(Y.$contentIds.check({ ...Y.createContentIds(), extra: 1 }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $idMap($attrs) checks the mapped values, not merely the nominal type.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdMapValueSchema = _tc => {
|
||||||
|
t.group('accepts the matching value type, rejects the other', () => {
|
||||||
|
t.assert(Y.$idMap(s.$string).check(idMapOf(['a', 'b'])))
|
||||||
|
t.assert(!Y.$idMap(s.$string).check(idMapOf([1, 2])))
|
||||||
|
t.assert(Y.$idMap(s.$number).check(idMapOf([1, 2])))
|
||||||
|
t.assert(!Y.$idMap(s.$number).check(idMapOf(['a', 'b'])))
|
||||||
|
})
|
||||||
|
t.group('a single mismatching value is enough to reject', () => {
|
||||||
|
t.assert(!Y.$idMap(s.$string).check(idMapOf(['a', 1])))
|
||||||
|
t.assert(!Y.$idMap(s.$number).check(idMapOf([1, 'a'])))
|
||||||
|
})
|
||||||
|
t.group('an empty map holds no values, so it satisfies every $attrs', () => {
|
||||||
|
t.assert(Y.$idMap(s.$string).check(Y.createIdMap()))
|
||||||
|
t.assert(Y.$idMap(s.$number).check(Y.createIdMap()))
|
||||||
|
})
|
||||||
|
t.group('$idMapAny ignores the value type', () => {
|
||||||
|
t.assert(Y.$idMapAny.check(idMapOf(['a'])))
|
||||||
|
t.assert(Y.$idMapAny.check(idMapOf([1])))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `check()` must not mutate its argument. `AttrRanges#getIds` sorts and splices in place, so a check
|
||||||
|
* built on it would silently reorder the caller's map; `everyAttr` iterates the raw ranges instead.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdMapCheckDoesNotMutate = _tc => {
|
||||||
|
const m = Y.createIdMap()
|
||||||
|
// deliberately out of clock order, so a sort would be observable
|
||||||
|
m.add(1, 10, 5, [attr('b')])
|
||||||
|
m.add(1, 0, 10, [attr('a')])
|
||||||
|
const ranges = /** @type {any} */ (m.clients.get(1))
|
||||||
|
t.assert(ranges.sorted === false, 'precondition: not yet sorted')
|
||||||
|
t.assert(Y.$idMap(s.$string).check(m))
|
||||||
|
t.assert(ranges.sorted === false, 'check() must not have sorted the ranges')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every real IdMap producer yields a map that $idMap validates correctly.
|
||||||
|
*
|
||||||
|
* Every producer fed only string values must be accepted by `$idMap($string)`. The `keepsValues`
|
||||||
|
* flag drives the far more important negative assertion: `$idMap($number)` must *reject* it. Only
|
||||||
|
* the three genuinely-emptying producers are exempt - they hold nothing, so they satisfy every
|
||||||
|
* `$attrs` vacuously and cannot discriminate. Without the negative, `$idMap($string).check` would
|
||||||
|
* pass just as happily on a producer that silently dropped every attribute.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdMapProducers = _tc => {
|
||||||
|
/**
|
||||||
|
* @type {Array<[string, () => IdMap<any>, boolean]>}
|
||||||
|
*/
|
||||||
|
const producers = [
|
||||||
|
['createIdMap', () => Y.createIdMap(), false],
|
||||||
|
['add', () => idMapOf(['k']), true],
|
||||||
|
['mergeIdMaps', () => Y.mergeIdMaps([idMapOf(['k']), idMapOf(['j'])]), true],
|
||||||
|
['diffIdMap (exclude all)', () => Y.diffIdMap(idMapOf(['k']), idSetOf()), false],
|
||||||
|
['diffIdMap (partial)', () => {
|
||||||
|
const exclude = Y.createIdSet()
|
||||||
|
exclude.add(1, 0, 5)
|
||||||
|
return Y.diffIdMap(idMapOf(['k']), exclude)
|
||||||
|
}, true],
|
||||||
|
['intersectMaps', () => Y.intersectMaps(idMapOf(['k']), idMapOf(['k'])), true],
|
||||||
|
['createIdMapFromIdSet', () => Y.createIdMapFromIdSet(idSetOf(), [attr('k')]), true],
|
||||||
|
['filterIdMap (keep)', () => Y.filterIdMap(idMapOf(['k']), () => true), true],
|
||||||
|
['filterIdMap (drop)', () => Y.filterIdMap(idMapOf(['k']), () => false), false],
|
||||||
|
['decodeIdMap(encodeIdMap)', () => Y.decodeIdMap(Y.encodeIdMap(idMapOf(['k']))), true],
|
||||||
|
['insertIntoIdMap(dest, IdMap)', () => {
|
||||||
|
const dest = Y.createIdMap()
|
||||||
|
Y.insertIntoIdMap(dest, idMapOf(['k']))
|
||||||
|
return dest
|
||||||
|
}, true]
|
||||||
|
]
|
||||||
|
producers.forEach(([name, produce, keepsValues]) => {
|
||||||
|
t.group(name, () => {
|
||||||
|
const m = produce()
|
||||||
|
t.assert(Y.$idMapAny.check(m), 'is an IdMap')
|
||||||
|
t.assert(Y.$idMap(s.$string).check(m), 'holds only string values (vacuously so when empty)')
|
||||||
|
if (keepsValues) {
|
||||||
|
t.assert(!Y.$idMap(s.$number).check(m), 'carries its value through, so it rejects $number')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `insertIntoIdMap`'s `src` may be an IdSet, which puts attribute-less `IdRanges` into an `IdMap`.
|
||||||
|
* `$idMap` must not throw on those - `IdRange#attrs` is `[]` by design, so they are vacuously fine.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIdMapWithIdRanges = _tc => {
|
||||||
|
t.group('an IdSet merged into an IdMap', () => {
|
||||||
|
const dest = Y.createIdMap()
|
||||||
|
Y.insertIntoIdMap(dest, idSetOf())
|
||||||
|
t.assert(Y.$idMap(s.$string).check(dest), 'no attributes present, so nothing can violate $attrs')
|
||||||
|
t.assert(Y.$idMap(s.$number).check(dest))
|
||||||
|
})
|
||||||
|
t.group('a map holding both AttrRanges and IdRanges still checks its values', () => {
|
||||||
|
const dest = idMapOf(['k']) // client 1 -> AttrRanges
|
||||||
|
const other = Y.createIdSet()
|
||||||
|
other.add(2, 0, 10) // client 2 -> IdRanges
|
||||||
|
Y.insertIntoIdMap(dest, other)
|
||||||
|
t.assert(Y.$idMap(s.$string).check(dest))
|
||||||
|
t.assert(!Y.$idMap(s.$number).check(dest), 'client 1 still holds a string')
|
||||||
|
})
|
||||||
|
t.group('an IdRanges container that later receives AttrRanges on the SAME client', () => {
|
||||||
|
// `_insertIntoIdSet` picks the container class from whichever `src` reaches a client first, and
|
||||||
|
// its append branch copies elements without checking their class. So an IdSet-first / IdMap-
|
||||||
|
// second sequence leaves real AttrRanges inside an `IdRanges`. `IdRanges#everyAttr` must
|
||||||
|
// iterate rather than shortcut to `true`, or these attributes become invisible to `$idMap`.
|
||||||
|
const dest = Y.createIdMap()
|
||||||
|
const set = Y.createIdSet()
|
||||||
|
set.add(1, 0, 10)
|
||||||
|
Y.insertIntoIdMap(dest, set) // client 1 -> IdRanges container
|
||||||
|
Y.insertIntoIdMap(dest, idMapOf([42])) // same client -> AttrRange(42) appended into it
|
||||||
|
t.assert(/** @type {any} */ (dest.clients.get(1)).constructor.name === 'IdRanges', 'precondition: still an IdRanges')
|
||||||
|
t.assert(!Y.$idMap(s.$string).check(dest), 'the leaked number 42 must be seen')
|
||||||
|
t.assert(!Y.$idMap(s.$never).check(dest), '$never must reject a map that holds a value')
|
||||||
|
t.assert(Y.$idMap(s.$number).check(dest), 'and accepted for the matching schema')
|
||||||
|
t.group('the verdict agrees with an encode round-trip', () => {
|
||||||
|
const rt = Y.decodeIdMap(Y.encodeIdMap(dest))
|
||||||
|
t.assert(Y.$idMap(s.$string).check(rt) === Y.$idMap(s.$string).check(dest))
|
||||||
|
t.assert(Y.$idMap(s.$number).check(rt) === Y.$idMap(s.$number).check(dest))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testNodeSchemas = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const unnamed = ydoc.get('unnamed')
|
||||||
|
const para = ydoc.get('para', 'p')
|
||||||
|
t.group('$nodeAny', () => {
|
||||||
|
t.assert(Y.$nodeAny.check(unnamed))
|
||||||
|
t.assert(Y.$nodeAny.check(para))
|
||||||
|
t.assert(!Y.$nodeAny.check(ydoc), 'a Doc is not a Node')
|
||||||
|
t.assert(!Y.$doc.check(para), 'a Node is not a Doc')
|
||||||
|
})
|
||||||
|
t.group('$node(conf) checks the name', () => {
|
||||||
|
t.assert(Y.$node({ name: 'p' }).check(para))
|
||||||
|
t.assert(!Y.$node({ name: 'h1' }).check(para), 'discriminates between named nodes')
|
||||||
|
})
|
||||||
|
t.group('a conf without a name accepts any node', () => {
|
||||||
|
t.assert(Y.$node({ text: true }).check(para))
|
||||||
|
t.assert(Y.$node({ text: true }).check(unnamed))
|
||||||
|
t.assert(Y.$node({}).check(unnamed))
|
||||||
|
})
|
||||||
|
t.group('every ReadableDeltaConf name form', () => {
|
||||||
|
t.assert(Y.$node({ name: ['p', 'h1'] }).check(para), 'array form')
|
||||||
|
t.assert(!Y.$node({ name: ['h1', 'h2'] }).check(para))
|
||||||
|
t.assert(Y.$node({ name: s.$string }).check(para), 'schema form')
|
||||||
|
})
|
||||||
|
t.group('a named conf rejects a nameless node', () => {
|
||||||
|
// Deliberately stricter than lib0's $Delta.check, which skips the name when name == null: a
|
||||||
|
// nameless *node* is definitively a fragment, whereas a nameless *delta* asserts nothing.
|
||||||
|
t.assert(unnamed.name === null, 'precondition')
|
||||||
|
t.assert(!Y.$node({ name: 'p' }).check(unnamed))
|
||||||
|
t.assert(!Y.$node({ name: s.$string }).check(unnamed))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tags are interned in a global cross-install registry, so re-declaring a name returns the very
|
||||||
|
* same schema object. That is what makes `$type` sound across duplicate yjs installations, and what
|
||||||
|
* an `x.$type === $idMapAny` dispatch would rely on.
|
||||||
|
*
|
||||||
|
* Identity (`===`), never `.equals()` - `$Type#equals` compares an unset `.shape`, so any two
|
||||||
|
* `$Type`s compare equal.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testTagInterning = _tc => {
|
||||||
|
t.assert(s.$type('y:idSet', null) === Y.$idSet)
|
||||||
|
t.assert(s.$type('y:idMap', null) === Y.$idMapAny)
|
||||||
|
t.assert(s.$type('y:node', null) === Y.$nodeAny)
|
||||||
|
t.assert(s.$type('y:doc', null) === Y.$doc)
|
||||||
|
t.assert(s.$type('y:renderer', null) === Y.$renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prototype stamps are what `check()` reads.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testPrototypeStamps = _tc => {
|
||||||
|
t.assert(Y.IdSet.prototype.$type === Y.$idSet)
|
||||||
|
t.assert(Y.IdMap.prototype.$type === Y.$idMapAny)
|
||||||
|
t.assert(Y.Node.prototype.$type === Y.$nodeAny)
|
||||||
|
t.assert(Y.Doc.prototype.$type === Y.$doc)
|
||||||
|
t.group('instances carry the tag', () => {
|
||||||
|
t.assert(Y.createIdSet().$type === Y.$idSet)
|
||||||
|
t.assert(Y.createIdMap().$type === Y.$idMapAny)
|
||||||
|
t.assert(new Y.Doc().get('n').$type === Y.$nodeAny)
|
||||||
|
t.assert(new Y.Doc().$type === Y.$doc)
|
||||||
|
})
|
||||||
|
}
|
||||||
244
tests/snapshot.tests.js
Normal file
244
tests/snapshot.tests.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import { init } from './testHelper.js'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBasic = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
ydoc.get().insert(0, 'world!')
|
||||||
|
const snapshot = Y.snapshot(ydoc)
|
||||||
|
ydoc.get().insert(0, 'hello ')
|
||||||
|
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
|
||||||
|
t.assert(restored.get().toDelta().equals(delta.create().insert('world!').done()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBasicXmlAttributes = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const yxml = ydoc.get().setAttr('el', new Y.Node('div'))
|
||||||
|
const snapshot1 = Y.snapshot(ydoc)
|
||||||
|
yxml.setAttr('a', '1')
|
||||||
|
const snapshot2 = Y.snapshot(ydoc)
|
||||||
|
yxml.setAttr('a', '2')
|
||||||
|
t.compare(yxml.getAttrs(), { a: '2' })
|
||||||
|
t.compare(yxml.getAttrs(snapshot2), { a: '1' })
|
||||||
|
t.compare(yxml.getAttrs(snapshot1), {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBasicRestoreSnapshot = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, ['hello'])
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
doc.get('array').insert(1, ['world'])
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toArray(), ['hello'])
|
||||||
|
t.compare(doc.get('array').toJSON().children, ['hello', 'world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testEmptyRestoreSnapshot = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
snap.sv.set(9999, 0)
|
||||||
|
doc.get().insert(0, ['world'])
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get().toArray(), [])
|
||||||
|
t.compare(doc.get().toArray(), ['world'])
|
||||||
|
|
||||||
|
// now this snapshot reflects the latest state. It should still work.
|
||||||
|
const snap2 = Y.snapshot(doc)
|
||||||
|
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
|
||||||
|
t.compare(docRestored2.get().toArray(), ['world'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRestoreSnapshotWithSubType = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, [new Y.Node()])
|
||||||
|
const subMap = doc.get('array').get(0)
|
||||||
|
subMap.setAttr('key1', 'value1')
|
||||||
|
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
subMap.setAttr('key2', 'value2')
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toJSON().children, [{
|
||||||
|
attrs: {
|
||||||
|
key1: 'value1'
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
t.compare(doc.get('array').toJSON().children, [{
|
||||||
|
attrs: {
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2'
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRestoreDeletedItem1 = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, ['item1', 'item2'])
|
||||||
|
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
doc.get('array').delete(0)
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toArray(), ['item1', 'item2'])
|
||||||
|
t.compare(doc.get('array').toArray(), ['item2'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRestoreLeftItem = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, ['item1'])
|
||||||
|
doc.get('map').setAttr('test', 1)
|
||||||
|
doc.get('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
doc.get('array').delete(1)
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toArray(), ['item0', 'item1'])
|
||||||
|
t.compare(doc.get('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItemsBase = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, ['item1'])
|
||||||
|
doc.get('array').delete(0)
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
doc.get('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toArray(), [])
|
||||||
|
t.compare(doc.get('array').toArray(), ['item0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testDeletedItems2 = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
doc.get('array').insert(0, ['item1', 'item2', 'item3'])
|
||||||
|
doc.get('array').delete(1)
|
||||||
|
const snap = Y.snapshot(doc)
|
||||||
|
doc.get('array').insert(0, ['item0'])
|
||||||
|
|
||||||
|
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||||
|
|
||||||
|
t.compare(docRestored.get('array').toArray(), ['item1', 'item3'])
|
||||||
|
t.compare(doc.get('array').toArray(), ['item0', 'item1', 'item3'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDependentChanges = tc => {
|
||||||
|
const { array0, array1, testConnector } = init(tc, { users: 2 })
|
||||||
|
|
||||||
|
if (!array0.doc) {
|
||||||
|
throw new Error('no document 0')
|
||||||
|
}
|
||||||
|
if (!array1.doc) {
|
||||||
|
throw new Error('no document 1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Y.Doc}
|
||||||
|
*/
|
||||||
|
const doc0 = array0.doc
|
||||||
|
/**
|
||||||
|
* @type {Y.Doc}
|
||||||
|
*/
|
||||||
|
const doc1 = array1.doc
|
||||||
|
|
||||||
|
doc0.gc = false
|
||||||
|
doc1.gc = false
|
||||||
|
|
||||||
|
array0.insert(0, ['user1item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(1, ['user2item1'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const snap = Y.snapshot(array0.doc)
|
||||||
|
|
||||||
|
array0.insert(2, ['user1item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.insert(3, ['user2item2'])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
|
||||||
|
t.compare(docRestored0.get('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
|
||||||
|
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
|
||||||
|
t.compare(docRestored1.get('array').toArray(), ['user1item1', 'user2item1'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testContainsUpdate = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {Array<Uint8Array>}
|
||||||
|
*/
|
||||||
|
const updates = []
|
||||||
|
ydoc.on('update', update => {
|
||||||
|
updates.push(update)
|
||||||
|
})
|
||||||
|
const yarr = ydoc.get()
|
||||||
|
const snapshot1 = Y.snapshot(ydoc)
|
||||||
|
yarr.insert(0, [1])
|
||||||
|
const snapshot2 = Y.snapshot(ydoc)
|
||||||
|
yarr.delete(0, 1)
|
||||||
|
const snapshotFinal = Y.snapshot(ydoc)
|
||||||
|
t.assert(!Y.snapshotContainsUpdate(snapshot1, updates[0]))
|
||||||
|
t.assert(!Y.snapshotContainsUpdate(snapshot2, updates[1]))
|
||||||
|
t.assert(Y.snapshotContainsUpdate(snapshot2, updates[0]))
|
||||||
|
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[0]))
|
||||||
|
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reported by https://github.com/yjs/yjs/issues/767
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testContainsUpdate2 = _tc => {
|
||||||
|
const local = new Y.Doc()
|
||||||
|
const remote = new Y.Doc()
|
||||||
|
local.get('t').insert(0, 'abcdefghij')
|
||||||
|
local.get('t').delete(0, 3)
|
||||||
|
Y.applyUpdate(remote, Y.encodeStateAsUpdate(local))
|
||||||
|
const snap = Y.snapshot(remote)
|
||||||
|
local.get('t').delete(0, 3)
|
||||||
|
const update = Y.encodeStateAsUpdate(local)
|
||||||
|
t.assert(!Y.snapshotContainsUpdate(snap, update))
|
||||||
|
}
|
||||||
595
tests/testHelper.js
Normal file
595
tests/testHelper.js
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as prng from 'lib0/prng'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as syncProtocol from '@y/protocols/sync'
|
||||||
|
import * as object from 'lib0/object'
|
||||||
|
import * as map from 'lib0/map'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as list from 'lib0/list'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
import { createIdSet, createIdMap, encodeIdMap } from '../src/utils/ids.js'
|
||||||
|
|
||||||
|
export * from '../src/index.js'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// @ts-ignore
|
||||||
|
window.Y = Y // eslint-disable-line
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TestYInstance} y // publish message created by `y` to all other online clients
|
||||||
|
* @param {Uint8Array} m
|
||||||
|
*/
|
||||||
|
const broadcastMessage = (y, m) => {
|
||||||
|
if (y.tc.onlineConns.has(y)) {
|
||||||
|
y.tc.onlineConns.forEach(remoteYInstance => {
|
||||||
|
if (remoteYInstance !== y) {
|
||||||
|
remoteYInstance._receive(m, y)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let useV2 = false
|
||||||
|
|
||||||
|
export const encV1 = {
|
||||||
|
encodeStateAsUpdate: Y.encodeStateAsUpdate,
|
||||||
|
mergeUpdates: Y.mergeUpdates,
|
||||||
|
applyUpdate: Y.applyUpdate,
|
||||||
|
logUpdate: Y.logUpdate,
|
||||||
|
updateEventName: /** @type {'update'} */ ('update'),
|
||||||
|
diffUpdate: Y.diffUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encV2 = {
|
||||||
|
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||||
|
mergeUpdates: Y.mergeUpdatesV2,
|
||||||
|
applyUpdate: Y.applyUpdateV2,
|
||||||
|
logUpdate: Y.logUpdateV2,
|
||||||
|
updateEventName: /** @type {'updateV2'} */ ('updateV2'),
|
||||||
|
diffUpdate: Y.diffUpdateV2
|
||||||
|
}
|
||||||
|
|
||||||
|
export let enc = encV1
|
||||||
|
|
||||||
|
const useV1Encoding = () => {
|
||||||
|
useV2 = false
|
||||||
|
enc = encV1
|
||||||
|
}
|
||||||
|
|
||||||
|
const useV2Encoding = () => {
|
||||||
|
console.error('sync protocol doesnt support v2 protocol yet, fallback to v1 encoding') // @Todo
|
||||||
|
useV2 = false
|
||||||
|
enc = encV1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends {Y.Doc}
|
||||||
|
*/
|
||||||
|
export class TestYInstance extends Y.Doc {
|
||||||
|
/**
|
||||||
|
* @param {TestConnector} testConnector
|
||||||
|
* @param {number} clientID
|
||||||
|
*/
|
||||||
|
constructor (testConnector, clientID) {
|
||||||
|
super()
|
||||||
|
this.userID = clientID // overwriting clientID
|
||||||
|
/**
|
||||||
|
* @type {TestConnector}
|
||||||
|
*/
|
||||||
|
this.tc = testConnector
|
||||||
|
/**
|
||||||
|
* @type {Map<TestYInstance, Array<Uint8Array>>}
|
||||||
|
*/
|
||||||
|
this.receiving = new Map()
|
||||||
|
testConnector.allConns.add(this)
|
||||||
|
/**
|
||||||
|
* The list of received updates.
|
||||||
|
* We are going to merge them later using Y.mergeUpdates and check if the resulting document is correct.
|
||||||
|
* @type {Array<Uint8Array<ArrayBuffer>>}
|
||||||
|
*/
|
||||||
|
this.updates = []
|
||||||
|
// set up observe on local model
|
||||||
|
this.on(enc.updateEventName, (update, origin) => {
|
||||||
|
if (origin !== testConnector) {
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
syncProtocol.writeUpdate(encoder, update)
|
||||||
|
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||||
|
}
|
||||||
|
this.updates.push(update)
|
||||||
|
})
|
||||||
|
this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from TestConnector.
|
||||||
|
*/
|
||||||
|
disconnect () {
|
||||||
|
this.receiving = new Map()
|
||||||
|
this.tc.onlineConns.delete(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append yourself to the list of known Y instances in testconnector.
|
||||||
|
* Also initiate sync with all clients.
|
||||||
|
*/
|
||||||
|
connect () {
|
||||||
|
if (!this.tc.onlineConns.has(this)) {
|
||||||
|
this.tc.onlineConns.add(this)
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
syncProtocol.writeSyncStep1(encoder, /** @type {any} */ (this))
|
||||||
|
// publish SyncStep1
|
||||||
|
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||||
|
this.tc.onlineConns.forEach(remoteYInstance => {
|
||||||
|
if (remoteYInstance !== this) {
|
||||||
|
// remote instance sends instance to this instance
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
syncProtocol.writeSyncStep1(encoder, /** @type {any} */ (remoteYInstance))
|
||||||
|
this._receive(encoding.toUint8Array(encoder), remoteYInstance)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive a message from another client. This message is only appended to the list of receiving messages.
|
||||||
|
* TestConnector decides when this client actually reads this message.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} message
|
||||||
|
* @param {TestYInstance} remoteClient
|
||||||
|
*/
|
||||||
|
_receive (message, remoteClient) {
|
||||||
|
map.setIfUndefined(this.receiving, remoteClient, () => /** @type {Array<Uint8Array>} */ ([])).push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps track of TestYInstances.
|
||||||
|
*
|
||||||
|
* The TestYInstances add/remove themselves from the list of connections maiained in this object.
|
||||||
|
* I think it makes sense. Deal with it.
|
||||||
|
*/
|
||||||
|
export class TestConnector {
|
||||||
|
/**
|
||||||
|
* @param {prng.PRNG} gen
|
||||||
|
*/
|
||||||
|
constructor (gen) {
|
||||||
|
/**
|
||||||
|
* @type {Set<TestYInstance>}
|
||||||
|
*/
|
||||||
|
this.allConns = new Set()
|
||||||
|
/**
|
||||||
|
* @type {Set<TestYInstance>}
|
||||||
|
*/
|
||||||
|
this.onlineConns = new Set()
|
||||||
|
/**
|
||||||
|
* @type {prng.PRNG}
|
||||||
|
*/
|
||||||
|
this.prng = gen
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Y instance and add it to the list of connections
|
||||||
|
* @param {number} clientID
|
||||||
|
*/
|
||||||
|
createY (clientID) {
|
||||||
|
return new TestYInstance(this, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose random connection and flush a random message from a random sender.
|
||||||
|
*
|
||||||
|
* If this function was unable to flush a message, because there are no more messages to flush, it returns false. true otherwise.
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
flushRandomMessage () {
|
||||||
|
const gen = this.prng
|
||||||
|
const conns = Array.from(this.onlineConns).filter(conn => conn.receiving.size > 0)
|
||||||
|
if (conns.length > 0) {
|
||||||
|
const receiver = prng.oneOf(gen, conns)
|
||||||
|
const [sender, messages] = prng.oneOf(gen, Array.from(receiver.receiving))
|
||||||
|
const m = messages.shift()
|
||||||
|
if (messages.length === 0) {
|
||||||
|
receiver.receiving.delete(sender)
|
||||||
|
}
|
||||||
|
if (m === undefined) {
|
||||||
|
return this.flushRandomMessage()
|
||||||
|
}
|
||||||
|
const encoder = encoding.createEncoder()
|
||||||
|
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
|
||||||
|
// do not publish data created when this function is executed (could be ss2 or update message)
|
||||||
|
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, /** @type {any} */ (receiver), receiver.tc)
|
||||||
|
if (encoding.length(encoder) > 0) {
|
||||||
|
// send reply message
|
||||||
|
sender._receive(encoding.toUint8Array(encoder), receiver)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean} True iff this function actually flushed something
|
||||||
|
*/
|
||||||
|
flushAllMessages () {
|
||||||
|
let didSomething = false
|
||||||
|
while (this.flushRandomMessage()) {
|
||||||
|
didSomething = true
|
||||||
|
}
|
||||||
|
return didSomething
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAll () {
|
||||||
|
this.allConns.forEach(conn => conn.connect())
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectAll () {
|
||||||
|
this.allConns.forEach(conn => conn.disconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAll () {
|
||||||
|
this.reconnectAll()
|
||||||
|
this.flushAllMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean} Whether it was possible to disconnect a random connection.
|
||||||
|
*/
|
||||||
|
disconnectRandom () {
|
||||||
|
if (this.onlineConns.size === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {boolean} Whether it was possible to reconnect a random connection.
|
||||||
|
*/
|
||||||
|
reconnectRandom () {
|
||||||
|
/**
|
||||||
|
* @type {Array<TestYInstance>}
|
||||||
|
*/
|
||||||
|
const reconnectable = []
|
||||||
|
this.allConns.forEach(conn => {
|
||||||
|
if (!this.onlineConns.has(conn)) {
|
||||||
|
reconnectable.push(conn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (reconnectable.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prng.oneOf(this.prng, reconnectable).connect()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
* @param {{users?:number}} conf
|
||||||
|
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||||
|
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Node<any>,array1:Y.Node<any>,array2:Y.Node<any>,map0:Y.Node<any>,map1:Y.Node<any>,map2:Y.Node<any>,map3:Y.Node<any>,text0:Y.Node,text1:Y.Node,text2:Y.Node,xml0:Y.Node,xml1:Y.Node,xml2:Y.Node}}
|
||||||
|
*/
|
||||||
|
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>}
|
||||||
|
*/
|
||||||
|
const result = {
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
const gen = tc.prng
|
||||||
|
// choose an encoding approach at random
|
||||||
|
if (prng.bool(gen)) {
|
||||||
|
useV2Encoding()
|
||||||
|
} else {
|
||||||
|
useV1Encoding()
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConnector = new TestConnector(gen)
|
||||||
|
result.testConnector = testConnector
|
||||||
|
for (let i = 0; i < users; i++) {
|
||||||
|
const y = testConnector.createY(i)
|
||||||
|
y.clientID = i
|
||||||
|
result.users.push(y)
|
||||||
|
result['array' + i] = y.get('array')
|
||||||
|
result['map' + i] = y.get('map')
|
||||||
|
result['xml' + i] = y.get('xml')
|
||||||
|
result['text' + i] = y.get('text')
|
||||||
|
}
|
||||||
|
testConnector.syncAll()
|
||||||
|
result.testObjects = result.users.map(initTestObject || (() => null))
|
||||||
|
useV1Encoding()
|
||||||
|
return /** @type {any} */ (result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Y.IdSet} idSet1
|
||||||
|
* @param {Y.IdSet} idSet2
|
||||||
|
*/
|
||||||
|
export const compareIdSets = (idSet1, idSet2) => {
|
||||||
|
t.assert(idSet1.clients.size === idSet2.clients.size)
|
||||||
|
for (const [client, _items1] of idSet1.clients.entries()) {
|
||||||
|
const items1 = _items1.getIds()
|
||||||
|
const items2 = idSet2.clients.get(client)?.getIds()
|
||||||
|
t.assert(items2 !== undefined && items1.length === items2.length)
|
||||||
|
for (let i = 0; i < items1.length; i++) {
|
||||||
|
const di1 = items1[i]
|
||||||
|
const di2 = /** @type {Array<import('../src/utils/ids.js').IdRange>} */ (items2)[i]
|
||||||
|
t.assert(di1.clock === di2.clock && di1.len === di2.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* only use for testing
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {Array<Y.ContentAttribute<T>>} attrs
|
||||||
|
* @param {Y.ContentAttribute<T>} attr
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const _idmapAttrsHas = (attrs, attr) => {
|
||||||
|
const hash = attr.hash()
|
||||||
|
return attrs.find(a => a.hash() === hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* only use for testing
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {Array<Y.ContentAttribute<T>>} a
|
||||||
|
* @param {Array<Y.ContentAttribute<T>>} b
|
||||||
|
*/
|
||||||
|
export const _idmapAttrsEqual = (a, b) => a.length === b.length && a.every(v => _idmapAttrsHas(b, v))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that all attributes exist. Also create a copy and compare it to the original.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {Y.IdMap<T>} idmap
|
||||||
|
*/
|
||||||
|
export const validateIdMap = idmap => {
|
||||||
|
const copy = Y.createIdMap()
|
||||||
|
idmap.clients.forEach((ranges, client) => {
|
||||||
|
ranges.getIds().forEach(range => {
|
||||||
|
range.attrs.forEach(attr => {
|
||||||
|
t.assert(idmap.attrs.has(attr))
|
||||||
|
t.assert(idmap.attrsH.get(attr.hash()) === attr)
|
||||||
|
copy.add(client, range.clock, range.len, range.attrs.slice())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.assert(copy.clients.get(client)?.getIds().length === ranges.getIds().length)
|
||||||
|
})
|
||||||
|
t.assert(idmap.attrsH.size === idmap.attrs.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {Y.IdMap<T>} idmap1
|
||||||
|
* @param {Y.IdMap<T>} idmap2
|
||||||
|
*/
|
||||||
|
export const compareIdmaps = (idmap1, idmap2) => {
|
||||||
|
t.assert(idmap1.clients.size === idmap2.clients.size)
|
||||||
|
for (const [client, _items1] of idmap1.clients.entries()) {
|
||||||
|
const items1 = _items1.getIds()
|
||||||
|
const items2 = idmap2.clients.get(client)?.getIds()
|
||||||
|
t.assert(items2 !== undefined && items1.length === items2.length)
|
||||||
|
for (let i = 0; i < items1.length; i++) {
|
||||||
|
const di1 = items1[i]
|
||||||
|
const di2 = /** @type {Array<import('../src/utils/ids.js').AttrRange<T>>} */ (items2)[i]
|
||||||
|
t.assert(di1.clock === di2.clock && di1.len === di2.len && _idmapAttrsEqual(di1.attrs, di2.attrs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateIdMap(idmap1)
|
||||||
|
validateIdMap(idmap2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {prng.PRNG} gen
|
||||||
|
* @param {number} clients
|
||||||
|
* @param {number} clockRange (max clock - exclusive - by each client)
|
||||||
|
*/
|
||||||
|
export const createRandomIdSet = (gen, clients, clockRange) => {
|
||||||
|
const maxOpLen = 5
|
||||||
|
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
|
||||||
|
const idset = createIdSet()
|
||||||
|
for (let i = 0; i < numOfOps; i++) {
|
||||||
|
const client = prng.uint32(gen, 0, clients - 1)
|
||||||
|
const clockStart = prng.uint32(gen, 0, clockRange)
|
||||||
|
const len = prng.uint32(gen, 0, clockRange - clockStart)
|
||||||
|
idset.add(client, clockStart, len)
|
||||||
|
}
|
||||||
|
if (idset.clients.size === clients && clients > 1 && prng.bool(gen)) {
|
||||||
|
idset.clients.delete(prng.uint32(gen, 0, clients))
|
||||||
|
}
|
||||||
|
return idset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {prng.PRNG} gen
|
||||||
|
* @param {number} clients
|
||||||
|
* @param {number} clockRange (max clock - exclusive - by each client)
|
||||||
|
* @param {Array<T>} attrChoices (max clock - exclusive - by each client)
|
||||||
|
* @return {Y.IdMap<T>}
|
||||||
|
*/
|
||||||
|
export const createRandomIdMap = (gen, clients, clockRange, attrChoices) => {
|
||||||
|
const maxOpLen = 5
|
||||||
|
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
|
||||||
|
const idMap = createIdMap()
|
||||||
|
for (let i = 0; i < numOfOps; i++) {
|
||||||
|
const client = prng.uint32(gen, 0, clients - 1)
|
||||||
|
const clockStart = prng.uint32(gen, 0, clockRange)
|
||||||
|
const len = prng.uint32(gen, 0, clockRange - clockStart)
|
||||||
|
const attrs = [prng.oneOf(gen, attrChoices)]
|
||||||
|
// maybe add another attr
|
||||||
|
if (prng.bool(gen)) {
|
||||||
|
const a = prng.oneOf(gen, attrChoices)
|
||||||
|
if (attrs.find(attr => attr === a) == null) {
|
||||||
|
attrs.push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idMap.add(client, clockStart, len, attrs.map(v => Y.createContentAttribute('', v)))
|
||||||
|
}
|
||||||
|
t.info(`Created IdMap with ${numOfOps} ranges and ${attrChoices.length} different attributes. Encoded size: ${encodeIdMap(idMap).byteLength}`)
|
||||||
|
return idMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. reconnect and flush all
|
||||||
|
* 2. user 0 gc
|
||||||
|
* 3. get type content
|
||||||
|
* 4. disconnect & reconnect all (so gc is propagated)
|
||||||
|
* 5. compare os, ds, ss
|
||||||
|
*
|
||||||
|
* @param {Array<TestYInstance>} users
|
||||||
|
*/
|
||||||
|
export const compare = users => {
|
||||||
|
users.forEach(u => u.connect())
|
||||||
|
while (users[0].tc.flushAllMessages()) {} // eslint-disable-line
|
||||||
|
// For each document, merge all received document updates with Y.mergeUpdates and create a new document which will be added to the list of "users"
|
||||||
|
// This ensures that mergeUpdates works correctly
|
||||||
|
const mergedDocs = users.map(user => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
enc.applyUpdate(ydoc, enc.mergeUpdates(user.updates))
|
||||||
|
return ydoc
|
||||||
|
})
|
||||||
|
users.push(.../** @type {any} */(mergedDocs))
|
||||||
|
const userArrayValues = users.map(u => u.get('array').toJSON().children ?? [])
|
||||||
|
const userMapValues = users.map(u => u.get('map').toJSON().attrs ?? {})
|
||||||
|
// @todo fix type error here
|
||||||
|
// @ts-ignore
|
||||||
|
const userXmlValues = users.map(u => u.get('xml').toString())
|
||||||
|
const userTextValues = users.map(u => u.get('text').toDeltaDeep())
|
||||||
|
for (const u of users) {
|
||||||
|
t.assert(u.store.pendingDs === null)
|
||||||
|
t.assert(u.store.pendingStructs === null)
|
||||||
|
}
|
||||||
|
// Test Map iterator
|
||||||
|
const ymapkeys = Array.from(users[0].get('map').attrKeys())
|
||||||
|
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
|
||||||
|
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
|
||||||
|
// Compare all users
|
||||||
|
for (let i = 0; i < users.length - 1; i++) {
|
||||||
|
t.compare(userArrayValues[i].length, users[i].get('array').length)
|
||||||
|
t.compare(userArrayValues[i], userArrayValues[i + 1])
|
||||||
|
t.compare(userMapValues[i], userMapValues[i + 1])
|
||||||
|
t.compare(userXmlValues[i], userXmlValues[i + 1])
|
||||||
|
t.compare(list.toArray(userTextValues[i].children).map(a => (delta.$textOp.check(a) || delta.$insertOp.check(a)) ? a.insert.length : 0).reduce((a, b) => a + b, 0), users[i].get('text').length)
|
||||||
|
t.compare(userTextValues[i], userTextValues[i + 1], '', (_constructor, a, b) => {
|
||||||
|
if (a instanceof Y.Node) {
|
||||||
|
t.compare(a.toJSON(), b.toJSON())
|
||||||
|
} else if (a !== b) {
|
||||||
|
t.fail('Deltas dont match')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
|
||||||
|
Y.equalIdSets(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
|
||||||
|
compareStructStores(users[i].store, users[i + 1].store)
|
||||||
|
t.compare(Y.encodeSnapshot(Y.snapshot(users[i])), Y.encodeSnapshot(Y.snapshot(users[i + 1])))
|
||||||
|
}
|
||||||
|
users.forEach(user => {
|
||||||
|
compareIdSets(user.store.ds, Y.createDeleteSetFromStructStore(user.store))
|
||||||
|
})
|
||||||
|
users.map(u => u.destroy())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Y.Item?} a
|
||||||
|
* @param {Y.Item?} b
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {StructStore} ss1
|
||||||
|
* @param {StructStore} ss2
|
||||||
|
*/
|
||||||
|
export const compareStructStores = (ss1, ss2) => {
|
||||||
|
t.assert(ss1.clients.size === ss2.clients.size)
|
||||||
|
for (const [client, structs1] of ss1.clients) {
|
||||||
|
const structs2 = /** @type {Array<Y.AbstractStruct>} */ (ss2.clients.get(client))
|
||||||
|
t.assert(structs2 !== undefined && structs1.length === structs2.length)
|
||||||
|
for (let i = 0; i < structs1.length; i++) {
|
||||||
|
const s1 = structs1[i]
|
||||||
|
const s2 = structs2[i]
|
||||||
|
// checks for abstract struct
|
||||||
|
if (
|
||||||
|
s1.constructor !== s2.constructor ||
|
||||||
|
!Y.compareIDs(s1.id, s2.id) ||
|
||||||
|
s1.deleted !== s2.deleted ||
|
||||||
|
// @ts-ignore
|
||||||
|
s1.length !== s2.length
|
||||||
|
) {
|
||||||
|
t.fail('Structs dont match')
|
||||||
|
}
|
||||||
|
if (s1 instanceof Y.Item) {
|
||||||
|
if (
|
||||||
|
!(s2 instanceof Y.Item) ||
|
||||||
|
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
|
||||||
|
!compareItemIDs(s1.right, s2.right) ||
|
||||||
|
!Y.compareIDs(s1.origin, s2.origin) ||
|
||||||
|
!Y.compareIDs(s1.rightOrigin, s2.rightOrigin) ||
|
||||||
|
s1.parentSub !== s2.parentSub
|
||||||
|
) {
|
||||||
|
return t.fail('Items dont match')
|
||||||
|
}
|
||||||
|
// make sure that items are connected correctly
|
||||||
|
t.assert(s1.left === null || s1.left.right === s1)
|
||||||
|
t.assert(s1.right === null || s1.right.left === s1)
|
||||||
|
t.assert(s2.left === null || s2.left.right === s2)
|
||||||
|
t.assert(s2.right === null || s2.right.left === s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @callback InitTestObjectCallback
|
||||||
|
* @param {TestYInstance} y
|
||||||
|
* @return {T}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
|
||||||
|
* @param {number} iterations
|
||||||
|
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||||
|
*/
|
||||||
|
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
|
||||||
|
const gen = tc.prng
|
||||||
|
const result = init(tc, { users: 5 }, initTestObject)
|
||||||
|
const { testConnector, users } = result
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
if (prng.int32(gen, 0, 100) <= 2) {
|
||||||
|
// 2% chance to disconnect/reconnect a random user
|
||||||
|
if (prng.bool(gen)) {
|
||||||
|
testConnector.disconnectRandom()
|
||||||
|
} else {
|
||||||
|
testConnector.reconnectRandom()
|
||||||
|
}
|
||||||
|
} else if (prng.int32(gen, 0, 100) <= 1) {
|
||||||
|
// 1% chance to flush all
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
} else if (prng.int32(gen, 0, 100) <= 50) {
|
||||||
|
// 50% chance to flush a random message
|
||||||
|
testConnector.flushRandomMessage()
|
||||||
|
}
|
||||||
|
const user = prng.int32(gen, 0, users.length - 1)
|
||||||
|
const test = prng.oneOf(gen, mods)
|
||||||
|
test(users[user], gen, result.testObjects[user])
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {ReturnType<typeof applyRandomTests>} ApplyRandomTestsResult
|
||||||
|
*/
|
||||||
825
tests/undo-redo.tests.js
Normal file
825
tests/undo-redo.tests.js
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import { init } from './testHelper.js' // eslint-disable-line
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
|
||||||
|
export const testInconsistentFormat = () => {
|
||||||
|
/**
|
||||||
|
* @param {Y.Doc} ydoc
|
||||||
|
*/
|
||||||
|
const testYjsMerge = ydoc => {
|
||||||
|
const content = ydoc.get('text')
|
||||||
|
content.format(0, 6, { bold: null })
|
||||||
|
content.format(6, 4, { type: 'text' })
|
||||||
|
t.compare(content.toDelta(), delta.create().insert('Merge Test', { type: 'text' }).insert(' After', { type: 'text', italic: true }).done())
|
||||||
|
}
|
||||||
|
const initializeYDoc = () => {
|
||||||
|
const yDoc = new Y.Doc({ gc: false })
|
||||||
|
const content = yDoc.get('text')
|
||||||
|
content.insert(0, ' After', { type: 'text', italic: true })
|
||||||
|
content.insert(0, 'Test', { type: 'text' })
|
||||||
|
content.insert(0, 'Merge ', { type: 'text', bold: true })
|
||||||
|
return yDoc
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const yDoc = initializeYDoc()
|
||||||
|
testYjsMerge(yDoc)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const initialYDoc = initializeYDoc()
|
||||||
|
const yDoc = new Y.Doc({ gc: false })
|
||||||
|
Y.applyUpdate(yDoc, Y.encodeStateAsUpdate(initialYDoc))
|
||||||
|
testYjsMerge(yDoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export const testUndoText = tc => {
|
||||||
|
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||||
|
const undoManager = new Y.UndoManager(text0)
|
||||||
|
|
||||||
|
// items that are added & deleted in the same transaction won't be undo
|
||||||
|
text0.insert(0, 'test')
|
||||||
|
text0.delete(0, 4)
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === '')
|
||||||
|
|
||||||
|
// follow redone items
|
||||||
|
text0.insert(0, 'a')
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
text0.delete(0, 1)
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === 'a')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === '')
|
||||||
|
|
||||||
|
text0.insert(0, 'abc')
|
||||||
|
text1.insert(0, 'xyz')
|
||||||
|
testConnector.syncAll()
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === 'xyz')
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(text0.toString() === 'abcxyz')
|
||||||
|
testConnector.syncAll()
|
||||||
|
text1.delete(0, 1)
|
||||||
|
testConnector.syncAll()
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === 'xyz')
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(text0.toString() === 'bcxyz')
|
||||||
|
// test marks
|
||||||
|
text0.format(1, 3, { bold: true })
|
||||||
|
t.compare(text0.toDelta(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(text0.toDelta(), delta.create().insert('bcxyz').done())
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(text0.toDelta(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test case to fix #241
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testEmptyTypeScope = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const um = new Y.UndoManager([], { doc: ydoc })
|
||||||
|
const yarray = ydoc.get()
|
||||||
|
um.addToScope(yarray)
|
||||||
|
yarray.insert(0, [1])
|
||||||
|
um.undo()
|
||||||
|
t.assert(yarray.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRejectUpdateExample = _tc => {
|
||||||
|
const tmpydoc1 = new Y.Doc()
|
||||||
|
tmpydoc1.get('restricted').insert(0, [1])
|
||||||
|
tmpydoc1.get('public').insert(0, [1])
|
||||||
|
const update1 = Y.encodeStateAsUpdate(tmpydoc1)
|
||||||
|
const tmpydoc2 = new Y.Doc()
|
||||||
|
tmpydoc2.get('public').insert(0, [2])
|
||||||
|
const update2 = Y.encodeStateAsUpdate(tmpydoc2)
|
||||||
|
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const restrictedType = ydoc.get('restricted')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assume this function handles incoming updates via a communication channel like websockets.
|
||||||
|
* Changes to the `ydoc.getMap('restricted')` type should be rejected.
|
||||||
|
*
|
||||||
|
* - set up undo manager on the restricted types
|
||||||
|
* - cache pending* updates from the Ydoc to avoid certain attacks
|
||||||
|
* - apply received update and check whether the restricted type (or any of its children) has been changed.
|
||||||
|
* - catch errors that might try to circumvent the restrictions
|
||||||
|
* - undo changes on restricted types
|
||||||
|
* - reapply pending* updates
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
*/
|
||||||
|
const updateHandler = (update) => {
|
||||||
|
// don't handle changes of the local undo manager, which is used to undo invalid changes
|
||||||
|
const um = new Y.UndoManager(restrictedType, { trackedOrigins: new Set(['remote change']) })
|
||||||
|
const beforePendingDs = ydoc.store.pendingDs
|
||||||
|
const beforePendingStructs = ydoc.store.pendingStructs?.update
|
||||||
|
try {
|
||||||
|
Y.applyUpdate(ydoc, update, 'remote change')
|
||||||
|
} finally {
|
||||||
|
while (um.undoStack.length) {
|
||||||
|
um.undo()
|
||||||
|
}
|
||||||
|
um.destroy()
|
||||||
|
ydoc.store.pendingDs = beforePendingDs
|
||||||
|
ydoc.store.pendingStructs = null
|
||||||
|
if (beforePendingStructs) {
|
||||||
|
Y.applyUpdateV2(ydoc, beforePendingStructs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateHandler(update1)
|
||||||
|
updateHandler(update2)
|
||||||
|
t.assert(restrictedType.length === 0)
|
||||||
|
t.assert(ydoc.get('public').length === 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test case to fix #241
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testGlobalScope = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const um = new Y.UndoManager(ydoc)
|
||||||
|
const yarray = ydoc.get()
|
||||||
|
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 text = doc.get()
|
||||||
|
text.insert(0, '1221')
|
||||||
|
|
||||||
|
const manager = new Y.UndoManager(text)
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
text.insert(3, '3')
|
||||||
|
|
||||||
|
manager.undo()
|
||||||
|
manager.undo()
|
||||||
|
|
||||||
|
text.insert(2, '3')
|
||||||
|
|
||||||
|
t.compareStrings(text.toString(), '12321')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoMap = tc => {
|
||||||
|
const { testConnector, map0, map1 } = init(tc, { users: 2 })
|
||||||
|
map0.setAttr('a', 0)
|
||||||
|
const undoManager = new Y.UndoManager(map0)
|
||||||
|
map0.setAttr('a', 1)
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(map0.getAttr('a') === 0)
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(map0.getAttr('a') === 1)
|
||||||
|
// testing sub-types and if it can restore a whole type
|
||||||
|
const subType = new Y.Node()
|
||||||
|
map0.setAttr('a', subType)
|
||||||
|
subType.setAttr('x', 42)
|
||||||
|
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(map0.getAttr('a') === 1)
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
|
||||||
|
testConnector.syncAll()
|
||||||
|
// if content is overwritten by another user, undo operations should be skipped
|
||||||
|
map1.setAttr('a', 44)
|
||||||
|
testConnector.syncAll()
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(map0.getAttr('a') === 44)
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(map0.getAttr('a') === 44)
|
||||||
|
|
||||||
|
// test setting value multiple times
|
||||||
|
map0.setAttr('b', 'initial')
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
map0.setAttr('b', 'val1')
|
||||||
|
map0.setAttr('b', 'val2')
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(map0.getAttr('b') === 'initial')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: undoing a deletion of an embedded type together with an attribute
|
||||||
|
* change on it must restore the attribute consistently for remote peers. The
|
||||||
|
* redone attribute item used to keep an origin from the original (deleted)
|
||||||
|
* parent, so the value was correct locally but dropped after sync.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoEmbeddedTypeAttribute = tc => {
|
||||||
|
const { testConnector, text0, text1 } = init(tc, { users: 2 })
|
||||||
|
const button = new Y.Node()
|
||||||
|
button.setAttr('type', 'button')
|
||||||
|
button.setAttr('test', true)
|
||||||
|
button.insert(0, 'Click me')
|
||||||
|
text0.insert(0, [button])
|
||||||
|
testConnector.syncAll()
|
||||||
|
|
||||||
|
const undoManager = new Y.UndoManager(text0)
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
// change an attribute, then delete the embedded type
|
||||||
|
button.setAttr('type', 'paragraph')
|
||||||
|
text0.delete(0, 1)
|
||||||
|
// undo both operations at once
|
||||||
|
undoManager.undo()
|
||||||
|
|
||||||
|
const expected = { type: 'button', test: true }
|
||||||
|
t.compare(text0.get(0).getAttrs(), expected)
|
||||||
|
testConnector.syncAll()
|
||||||
|
// remote peer must converge to the same attributes
|
||||||
|
t.compare(text1.get(0).getAttrs(), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoArray = tc => {
|
||||||
|
const { testConnector, array0, array1 } = init(tc, { users: 3 })
|
||||||
|
const undoManager = new Y.UndoManager(array0)
|
||||||
|
array0.insert(0, [1, 2, 3])
|
||||||
|
array1.insert(0, [4, 5, 6])
|
||||||
|
testConnector.syncAll()
|
||||||
|
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toArray(), [4, 5, 6])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.delete(0, 1) // user1 deletes [1]
|
||||||
|
testConnector.syncAll()
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toArray(), [4, 5, 6])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toArray(), [2, 3, 4, 5, 6])
|
||||||
|
array0.delete(0, 5)
|
||||||
|
// test nested structure
|
||||||
|
const ymap = new Y.Node()
|
||||||
|
array0.insert(0, [ymap])
|
||||||
|
t.compare(array0.toJSON().children, [{}])
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
ymap.setAttr('a', 1)
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toJSON().children, [{}])
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toJSON().children, [{}])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
|
||||||
|
testConnector.syncAll()
|
||||||
|
array1.get(0).setAttr('b', 2)
|
||||||
|
testConnector.syncAll()
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { a: 1, b: 2 } }])
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(array0.toJSON().children, [{ attrs: { a: 1, b: 2 } }])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoXml = tc => {
|
||||||
|
const { xml0 } = init(tc, { users: 3 })
|
||||||
|
const undoManager = new Y.UndoManager(xml0)
|
||||||
|
const child = new Y.Node('p')
|
||||||
|
xml0.insert(0, [child])
|
||||||
|
const textchild = Y.Node.from(delta.create().insert('content'))
|
||||||
|
child.insert(0, [textchild])
|
||||||
|
// format textchild and revert that change
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
textchild.format(3, 4, { bold: true })
|
||||||
|
const v1 = delta.create().insert([delta.create('p').insert([delta.create().insert('con').insert('tent', { bold: true }).done()]).done()]).done()
|
||||||
|
const v2 = delta.create().insert([delta.create('p').insert([delta.create().insert('content').done()]).done()]).done()
|
||||||
|
t.compare(xml0.toDeltaDeep(), v1)
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(xml0.toDeltaDeep(), v2)
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(xml0.toDeltaDeep(), v1)
|
||||||
|
xml0.delete(0, 1)
|
||||||
|
t.compare(xml0.toDeltaDeep(), delta.create().done())
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(xml0.toDeltaDeep(), v1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoEvents = tc => {
|
||||||
|
const { text0 } = init(tc, { users: 3 })
|
||||||
|
const undoManager = new Y.UndoManager(text0)
|
||||||
|
let counter = 0
|
||||||
|
let receivedMetadata = -1
|
||||||
|
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||||
|
t.assert(event.type != null)
|
||||||
|
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
|
||||||
|
event.stackItem.meta.set('test', counter++)
|
||||||
|
})
|
||||||
|
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
|
||||||
|
t.assert(event.type != null)
|
||||||
|
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
|
||||||
|
receivedMetadata = event.stackItem.meta.get('test')
|
||||||
|
})
|
||||||
|
text0.insert(0, 'abc')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(receivedMetadata === 0)
|
||||||
|
undoManager.redo()
|
||||||
|
t.assert(receivedMetadata === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testTrackClass = tc => {
|
||||||
|
const { users, text0 } = init(tc, { users: 3 })
|
||||||
|
// only track origins that are numbers
|
||||||
|
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
|
||||||
|
users[0].transact(() => {
|
||||||
|
text0.insert(0, 'abc')
|
||||||
|
}, 42)
|
||||||
|
t.assert(text0.toString() === 'abc')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.toString() === '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testTypeScope = tc => {
|
||||||
|
const { array0 } = init(tc, { users: 3 })
|
||||||
|
// only track origins that are numbers
|
||||||
|
const text0 = new Y.Node()
|
||||||
|
const text1 = new Y.Node()
|
||||||
|
array0.insert(0, [text0, text1])
|
||||||
|
const undoManager = new Y.UndoManager(text0)
|
||||||
|
const undoManagerBoth = new Y.UndoManager([text0, text1])
|
||||||
|
text1.insert(0, 'abc')
|
||||||
|
t.assert(undoManager.undoStack.length === 0)
|
||||||
|
t.assert(undoManagerBoth.undoStack.length === 1)
|
||||||
|
t.assert(text1.toString() === 'abc')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text1.toString() === 'abc')
|
||||||
|
undoManagerBoth.undo()
|
||||||
|
t.assert(text1.toString() === '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoInEmbed = tc => {
|
||||||
|
const { text0 } = init(tc, { users: 3 })
|
||||||
|
const undoManager = new Y.UndoManager(text0)
|
||||||
|
const nestedText = Y.Node.from(delta.create().insert('initial text'))
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
text0.insert(0, [nestedText], { bold: true })
|
||||||
|
t.assert(nestedText.toString() === 'initial text')
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
nestedText.delete(0, nestedText.length)
|
||||||
|
nestedText.insert(0, 'other text')
|
||||||
|
t.assert(nestedText.toString() === 'other text')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(nestedText.toString() === 'initial text')
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(text0.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testUndoDeleteFilter = tc => {
|
||||||
|
const array0 = init(tc, { users: 3 }).array0
|
||||||
|
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
|
||||||
|
const map0 = new Y.Node()
|
||||||
|
map0.setAttr('hi', 1)
|
||||||
|
const map1 = new Y.Node()
|
||||||
|
array0.insert(0, [map0, map1])
|
||||||
|
undoManager.undo()
|
||||||
|
t.assert(array0.length === 1)
|
||||||
|
array0.get(0)
|
||||||
|
t.assert(Array.from(array0.get(0).attrKeys()).length === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoUntilChangePerformed = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
doc.on('update', update => Y.applyUpdate(doc2, update))
|
||||||
|
doc2.on('update', update => Y.applyUpdate(doc, update))
|
||||||
|
|
||||||
|
const yArray = doc.get('array')
|
||||||
|
const yArray2 = doc2.get('array')
|
||||||
|
const yMap = new Y.Node()
|
||||||
|
yMap.setAttr('hello', 'world')
|
||||||
|
yArray.push([yMap])
|
||||||
|
const yMap2 = new Y.Node()
|
||||||
|
yMap2.setAttr('key', 'value')
|
||||||
|
yArray.push([yMap2])
|
||||||
|
|
||||||
|
const undoManager = new Y.UndoManager([yArray], { trackedOrigins: new Set([doc.clientID]) })
|
||||||
|
const undoManager2 = new Y.UndoManager([doc2.get('array')], { trackedOrigins: new Set([doc2.clientID]) })
|
||||||
|
|
||||||
|
Y.transact(doc, () => yMap2.setAttr('key', 'value modified'), doc.clientID)
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
Y.transact(doc, () => yMap.setAttr('hello', 'world modified'), doc.clientID)
|
||||||
|
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
|
||||||
|
undoManager2.undo()
|
||||||
|
undoManager.undo()
|
||||||
|
t.compareStrings(yMap2.getAttr('key'), 'value')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://github.com/yjs/yjs/issues/317
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoNestedUndoIssue = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const design = doc.get()
|
||||||
|
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
|
||||||
|
|
||||||
|
const text = new Y.Node()
|
||||||
|
|
||||||
|
const blocks1 = new Y.Node()
|
||||||
|
const blocks1block = new Y.Node()
|
||||||
|
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks1block.setAttr('text', 'Type Something')
|
||||||
|
blocks1.push([blocks1block])
|
||||||
|
text.setAttr('blocks', blocks1block)
|
||||||
|
design.setAttr('text', text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks2 = new Y.Node()
|
||||||
|
const blocks2block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks2block.setAttr('text', 'Something')
|
||||||
|
blocks2.push([blocks2block])
|
||||||
|
text.setAttr('blocks', blocks2block)
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks3 = new Y.Node()
|
||||||
|
const blocks3block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks3block.setAttr('text', 'Something Else')
|
||||||
|
blocks3.push([blocks3block])
|
||||||
|
text.setAttr('blocks', blocks3block)
|
||||||
|
})
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something Else' }) }) }))
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({}))
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something Else' }) }) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://github.com/yjs/yjs/issues/355
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testConsecutiveRedoBug = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const yRoot = doc.get()
|
||||||
|
const undoMgr = new Y.UndoManager(yRoot)
|
||||||
|
|
||||||
|
let yPoint = new Y.Node()
|
||||||
|
yPoint.setAttr('x', 0)
|
||||||
|
yPoint.setAttr('y', 0)
|
||||||
|
yRoot.setAttr('a', yPoint)
|
||||||
|
undoMgr.stopCapturing()
|
||||||
|
|
||||||
|
yPoint.setAttr('x', 100)
|
||||||
|
yPoint.setAttr('y', 100)
|
||||||
|
undoMgr.stopCapturing()
|
||||||
|
|
||||||
|
yPoint.setAttr('x', 200)
|
||||||
|
yPoint.setAttr('y', 200)
|
||||||
|
undoMgr.stopCapturing()
|
||||||
|
|
||||||
|
yPoint.setAttr('x', 300)
|
||||||
|
yPoint.setAttr('y', 300)
|
||||||
|
undoMgr.stopCapturing()
|
||||||
|
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 300, y: 300 })
|
||||||
|
|
||||||
|
undoMgr.undo() // x=200, y=200
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
|
||||||
|
undoMgr.undo() // x=100, y=100
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
|
||||||
|
undoMgr.undo() // x=0, y=0
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
|
||||||
|
undoMgr.undo() // nil
|
||||||
|
t.compare(yRoot.getAttr('a'), undefined)
|
||||||
|
|
||||||
|
undoMgr.redo() // x=0, y=0
|
||||||
|
yPoint = yRoot.getAttr('a')
|
||||||
|
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
|
||||||
|
undoMgr.redo() // x=100, y=100
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
|
||||||
|
undoMgr.redo() // x=200, y=200
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
|
||||||
|
undoMgr.redo() // expected x=300, y=300, actually nil
|
||||||
|
t.compare(yPoint.toJSON().attrs, { x: 300, y: 300 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://github.com/yjs/yjs/issues/304
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoXmlBug = _tc => {
|
||||||
|
const origin = 'origin'
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const fragment = doc.get('t')
|
||||||
|
const undoManager = new Y.UndoManager(fragment, {
|
||||||
|
captureTimeout: 0,
|
||||||
|
trackedOrigins: new Set([origin])
|
||||||
|
})
|
||||||
|
|
||||||
|
// create element
|
||||||
|
doc.transact(() => {
|
||||||
|
const e = new Y.Node('test-node')
|
||||||
|
e.setAttr('a', '100')
|
||||||
|
e.setAttr('b', '0')
|
||||||
|
fragment.insert(fragment.length, [e])
|
||||||
|
}, origin)
|
||||||
|
|
||||||
|
// change one attribute
|
||||||
|
doc.transact(() => {
|
||||||
|
const e = fragment.get(0)
|
||||||
|
e.setAttr('a', '200')
|
||||||
|
}, origin)
|
||||||
|
|
||||||
|
// change both attributes
|
||||||
|
doc.transact(() => {
|
||||||
|
const e = fragment.get(0)
|
||||||
|
e.setAttr('a', '180')
|
||||||
|
e.setAttr('b', '50')
|
||||||
|
}, origin)
|
||||||
|
|
||||||
|
undoManager.undo()
|
||||||
|
undoManager.undo()
|
||||||
|
undoManager.undo()
|
||||||
|
|
||||||
|
undoManager.redo()
|
||||||
|
undoManager.redo()
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(fragment.toString(), '<test-node a="180" b="50" />')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported in https://github.com/yjs/yjs/issues/343
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoBlockBug = _tc => {
|
||||||
|
const doc = new Y.Doc({ gc: false })
|
||||||
|
const design = doc.get()
|
||||||
|
|
||||||
|
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
|
||||||
|
|
||||||
|
const text = new Y.Node()
|
||||||
|
|
||||||
|
const blocks1 = new Y.Node()
|
||||||
|
const blocks1block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks1block.setAttr('text', '1')
|
||||||
|
blocks1.push([blocks1block])
|
||||||
|
|
||||||
|
text.setAttr('blocks', blocks1block)
|
||||||
|
design.setAttr('text', text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks2 = new Y.Node()
|
||||||
|
const blocks2block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks2block.setAttr('text', '2')
|
||||||
|
blocks2.push([blocks2block])
|
||||||
|
text.setAttr('blocks', blocks2block)
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks3 = new Y.Node()
|
||||||
|
const blocks3block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks3block.setAttr('text', '3')
|
||||||
|
blocks3.push([blocks3block])
|
||||||
|
text.setAttr('blocks', blocks3block)
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks4 = new Y.Node()
|
||||||
|
const blocks4block = new Y.Node()
|
||||||
|
doc.transact(() => {
|
||||||
|
blocks4block.setAttr('text', '4')
|
||||||
|
blocks4.push([blocks4block])
|
||||||
|
text.setAttr('blocks', blocks4block)
|
||||||
|
})
|
||||||
|
|
||||||
|
// {"text":{"blocks":{"text":"4"}}}
|
||||||
|
undoManager.undo() // {"text":{"blocks":{"3"}}}
|
||||||
|
undoManager.undo() // {"text":{"blocks":{"text":"2"}}}
|
||||||
|
undoManager.undo() // {"text":{"blocks":{"text":"1"}}}
|
||||||
|
undoManager.undo() // {}
|
||||||
|
undoManager.redo() // {"text":{"blocks":{"text":"1"}}}
|
||||||
|
undoManager.redo() // {"text":{"blocks":{"text":"2"}}}
|
||||||
|
undoManager.redo() // {"text":{"blocks":{"text":"3"}}}
|
||||||
|
undoManager.redo() // {"text":{}}
|
||||||
|
t.compare(design.toJSON().attrs, { text: { attrs: { blocks: { attrs: { text: '4' } } } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo text formatting delete should not corrupt peer state.
|
||||||
|
*
|
||||||
|
* @see https://github.com/yjs/yjs/issues/392
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoDeleteTextFormat = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const text = doc.get()
|
||||||
|
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
const text2 = doc2.get()
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
const undoManager = new Y.UndoManager(text)
|
||||||
|
|
||||||
|
text.format(13, 7, { bold: true })
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
|
||||||
|
text.format(16, 4, { bold: null })
|
||||||
|
undoManager.stopCapturing()
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
|
||||||
|
undoManager.undo()
|
||||||
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||||
|
|
||||||
|
const expect = delta.create()
|
||||||
|
.insert('Attack ships ')
|
||||||
|
.insert('on fire', { bold: true })
|
||||||
|
.insert(' off the shoulder of Orion.')
|
||||||
|
t.compare(text.toDelta(), expect)
|
||||||
|
t.compare(text2.toDelta(), expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo text formatting delete should not corrupt peer state.
|
||||||
|
*
|
||||||
|
* @see https://github.com/yjs/yjs/issues/392
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBehaviorOfIgnoreRemoteAttributeChangesProperty = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
|
||||||
|
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
|
||||||
|
const map1 = doc.get()
|
||||||
|
const map2 = doc2.get()
|
||||||
|
const um1 = new Y.UndoManager(map1, { ignoreRemoteAttributeChanges: true })
|
||||||
|
map1.setAttr('x', 1)
|
||||||
|
map2.setAttr('x', 2)
|
||||||
|
map1.setAttr('x', 3)
|
||||||
|
map2.setAttr('x', 4)
|
||||||
|
um1.undo()
|
||||||
|
t.assert(map1.getAttr('x') === 2)
|
||||||
|
t.assert(map2.getAttr('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.get()
|
||||||
|
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
|
||||||
|
doc.transact(() => {
|
||||||
|
const e = new Y.Node('test')
|
||||||
|
e.setAttr('a', '1')
|
||||||
|
e.setAttr('b', '2')
|
||||||
|
fragment.insert(0, [e])
|
||||||
|
})
|
||||||
|
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
|
||||||
|
doc.transact(() => {
|
||||||
|
// change attribute "b" and delete test-node
|
||||||
|
const e = fragment.get(0)
|
||||||
|
e.setAttr('b', '3')
|
||||||
|
fragment.delete(0)
|
||||||
|
}, origin)
|
||||||
|
t.compareStrings(fragment.toString(), '')
|
||||||
|
undoManager.undo()
|
||||||
|
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.setAttr('a', 'a')
|
||||||
|
map0.deleteAttr('a')
|
||||||
|
map0.setAttr('a', 'b')
|
||||||
|
map0.deleteAttr('a')
|
||||||
|
map0.setAttr('a', 'c')
|
||||||
|
map0.deleteAttr('a')
|
||||||
|
map0.setAttr('a', 'd')
|
||||||
|
t.compare(map0.toJSON().attrs, { a: 'd' })
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON(), {})
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON().attrs, { a: 'c' })
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON(), {})
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON().attrs, { a: 'b' })
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON(), {})
|
||||||
|
undoManager.undo()
|
||||||
|
t.compare(map0.toJSON().attrs, { a: 'a' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It should expose the StackItem being processed if undoing
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testUndoDoingStackItem = async (_tc) => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const text = doc.get('text')
|
||||||
|
const undoManager = new Y.UndoManager([text])
|
||||||
|
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||||
|
event.stackItem.meta.set('str', '42')
|
||||||
|
})
|
||||||
|
let metaUndo = /** @type {any} */ (null)
|
||||||
|
let metaRedo = /** @type {any} */ (null)
|
||||||
|
text.observe((event) => {
|
||||||
|
const /** @type {Y.UndoManager} */ origin = event.transaction.origin
|
||||||
|
if (origin === undoManager && origin.undoing) {
|
||||||
|
metaUndo = origin.currStackItem?.meta.get('str')
|
||||||
|
} else if (origin === undoManager && origin.redoing) {
|
||||||
|
metaRedo = origin.currStackItem?.meta.get('str')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
text.insert(0, 'abc')
|
||||||
|
undoManager.undo()
|
||||||
|
undoManager.redo()
|
||||||
|
t.compare(metaUndo, '42', 'currStackItem is accessible while undoing')
|
||||||
|
t.compare(metaRedo, '42', 'currStackItem is accessible while redoing')
|
||||||
|
t.compare(undoManager.currStackItem, null, 'currStackItem is null after observe/transaction')
|
||||||
|
}
|
||||||
461
tests/updates.tests.js
Normal file
461
tests/updates.tests.js
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import { init, compare } from './testHelper.js' // eslint-disable-line
|
||||||
|
import { readBlockSet } from '../src/utils/BlockSet.js'
|
||||||
|
import { readIdSet, writeIdSet } from '../src/utils/ids.js'
|
||||||
|
import { UpdateDecoderV2 } from '../src/utils/UpdateDecoder.js'
|
||||||
|
import { UpdateEncoderV2 } from '../src/utils/UpdateEncoder.js'
|
||||||
|
import * as encoding from 'lib0/encoding'
|
||||||
|
import * as decoding from 'lib0/decoding'
|
||||||
|
import * as object from 'lib0/object'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
import * as array from 'lib0/array'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Enc
|
||||||
|
* @property {function(Array<Uint8Array<ArrayBuffer>>):Uint8Array<ArrayBuffer>} Enc.mergeUpdates
|
||||||
|
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateAsUpdate
|
||||||
|
* @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate
|
||||||
|
* @property {function(Uint8Array):void} Enc.logUpdate
|
||||||
|
* @property {function(Uint8Array):{deletes:Y.IdSet,inserts:Y.IdSet}} Enc.readUpdateToContentIds
|
||||||
|
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateVector
|
||||||
|
* @property {function(Uint8Array):Uint8Array<ArrayBuffer>} Enc.encodeStateVectorFromUpdate
|
||||||
|
* @property {'update'|'updateV2'} Enc.updateEventName
|
||||||
|
* @property {string} Enc.description
|
||||||
|
* @property {function(Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>):Uint8Array<ArrayBuffer>} Enc.diffUpdate
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Enc}
|
||||||
|
*/
|
||||||
|
const encV1 = {
|
||||||
|
mergeUpdates: Y.mergeUpdates,
|
||||||
|
encodeStateAsUpdate: Y.encodeStateAsUpdate,
|
||||||
|
applyUpdate: Y.applyUpdate,
|
||||||
|
logUpdate: Y.logUpdate,
|
||||||
|
readUpdateToContentIds: Y.createContentIdsFromUpdate,
|
||||||
|
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdate,
|
||||||
|
encodeStateVector: Y.encodeStateVector,
|
||||||
|
updateEventName: 'update',
|
||||||
|
description: 'V1',
|
||||||
|
diffUpdate: Y.diffUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Enc}
|
||||||
|
*/
|
||||||
|
const encV2 = {
|
||||||
|
mergeUpdates: Y.mergeUpdatesV2,
|
||||||
|
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||||
|
applyUpdate: Y.applyUpdateV2,
|
||||||
|
logUpdate: Y.logUpdateV2,
|
||||||
|
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
|
||||||
|
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
|
||||||
|
encodeStateVector: Y.encodeStateVector,
|
||||||
|
updateEventName: 'updateV2',
|
||||||
|
description: 'V2',
|
||||||
|
diffUpdate: Y.diffUpdateV2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Enc}
|
||||||
|
*/
|
||||||
|
const encDoc = {
|
||||||
|
mergeUpdates: (updates) => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
updates.forEach(update => {
|
||||||
|
Y.applyUpdateV2(ydoc, update)
|
||||||
|
})
|
||||||
|
return Y.encodeStateAsUpdateV2(ydoc)
|
||||||
|
},
|
||||||
|
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||||
|
applyUpdate: Y.applyUpdateV2,
|
||||||
|
logUpdate: Y.logUpdateV2,
|
||||||
|
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
|
||||||
|
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
|
||||||
|
encodeStateVector: Y.encodeStateVector,
|
||||||
|
updateEventName: 'updateV2',
|
||||||
|
description: 'Merge via Y.Doc',
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} update
|
||||||
|
* @param {Uint8Array} sv
|
||||||
|
*/
|
||||||
|
diffUpdate: (update, sv) => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
Y.applyUpdateV2(ydoc, update)
|
||||||
|
return Y.encodeStateAsUpdateV2(ydoc, sv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoders = [encV1, encV2, encDoc]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<Y.Doc>} users
|
||||||
|
* @param {Enc} enc
|
||||||
|
*/
|
||||||
|
const fromUpdates = (users, enc) => {
|
||||||
|
const updates = users.map(user =>
|
||||||
|
enc.encodeStateAsUpdate(user)
|
||||||
|
)
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
enc.applyUpdate(ydoc, enc.mergeUpdates(updates))
|
||||||
|
return ydoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testMergeUpdates = tc => {
|
||||||
|
const { users, array0, array1 } = init(tc, { users: 3 })
|
||||||
|
|
||||||
|
array0.insert(0, [1])
|
||||||
|
array1.insert(0, [2])
|
||||||
|
|
||||||
|
compare(users)
|
||||||
|
encoders.forEach(enc => {
|
||||||
|
const merged = fromUpdates(users, enc)
|
||||||
|
t.compareArrays(array0.toArray(), merged.get('array').toArray())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testKeyEncoding = tc => {
|
||||||
|
const { users, text0, text1 } = init(tc, { users: 2 })
|
||||||
|
|
||||||
|
text0.insert(0, 'a', { italic: true })
|
||||||
|
text0.insert(0, 'b')
|
||||||
|
text0.insert(0, 'c', { italic: true })
|
||||||
|
|
||||||
|
const update = Y.encodeStateAsUpdateV2(users[0])
|
||||||
|
Y.applyUpdateV2(users[1], update)
|
||||||
|
|
||||||
|
const c = text1.toDelta()
|
||||||
|
t.compare(
|
||||||
|
c,
|
||||||
|
delta.create()
|
||||||
|
.insert('c', { italic: true })
|
||||||
|
.insert('b')
|
||||||
|
.insert('a', { italic: true })
|
||||||
|
.done()
|
||||||
|
)
|
||||||
|
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Y.Doc} ydoc
|
||||||
|
* @param {Array<Uint8Array<ArrayBuffer>>} updates - expecting at least 4 updates
|
||||||
|
* @param {Enc} enc
|
||||||
|
* @param {boolean} hasDeletes
|
||||||
|
*/
|
||||||
|
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
|
||||||
|
const cases = []
|
||||||
|
// Case 1: Simple case, simply merge everything
|
||||||
|
cases.push(enc.mergeUpdates(updates))
|
||||||
|
|
||||||
|
// Case 2: Overlapping updates
|
||||||
|
cases.push(enc.mergeUpdates([
|
||||||
|
enc.mergeUpdates(updates.slice(2)),
|
||||||
|
enc.mergeUpdates(updates.slice(0, 2))
|
||||||
|
]))
|
||||||
|
|
||||||
|
// Case 3: Overlapping updates
|
||||||
|
cases.push(enc.mergeUpdates([
|
||||||
|
enc.mergeUpdates(updates.slice(2)),
|
||||||
|
enc.mergeUpdates(updates.slice(1, 3)),
|
||||||
|
updates[0]
|
||||||
|
]))
|
||||||
|
|
||||||
|
// Case 4: Separated updates (containing skips)
|
||||||
|
cases.push(enc.mergeUpdates([
|
||||||
|
enc.mergeUpdates([updates[0], updates[2]]),
|
||||||
|
enc.mergeUpdates([updates[1], updates[3]]),
|
||||||
|
enc.mergeUpdates(updates.slice(4))
|
||||||
|
]))
|
||||||
|
|
||||||
|
// Case 5: overlapping with many duplicates
|
||||||
|
cases.push(enc.mergeUpdates(cases))
|
||||||
|
|
||||||
|
// const targetState = enc.encodeStateAsUpdate(ydoc)
|
||||||
|
// t.info('Target State: ')
|
||||||
|
// enc.logUpdate(targetState)
|
||||||
|
|
||||||
|
cases.forEach((mergedUpdates, i) => {
|
||||||
|
t.info(`State Case $${i} (${enc.description}):`)
|
||||||
|
// enc.logUpdate(updates)
|
||||||
|
const merged = new Y.Doc({ gc: false })
|
||||||
|
enc.applyUpdate(merged, mergedUpdates)
|
||||||
|
t.compareArrays(merged.get().toArray(), ydoc.get().toArray())
|
||||||
|
t.compare(enc.encodeStateVector(merged), enc.encodeStateVectorFromUpdate(mergedUpdates))
|
||||||
|
if (enc.updateEventName !== 'update') { // @todo should this also work on legacy updates?
|
||||||
|
for (let j = 1; j < updates.length; j++) {
|
||||||
|
const partMerged = enc.mergeUpdates(updates.slice(j))
|
||||||
|
const partMeta = enc.readUpdateToContentIds(partMerged)
|
||||||
|
const targetSV = enc.encodeStateVectorFromUpdate(enc.mergeUpdates(updates.slice(0, j)))
|
||||||
|
const diffed = enc.diffUpdate(mergedUpdates, targetSV)
|
||||||
|
const diffedMeta = enc.readUpdateToContentIds(diffed)
|
||||||
|
t.compare(partMeta.inserts, diffedMeta.inserts)
|
||||||
|
{
|
||||||
|
// We can'd do the following
|
||||||
|
// - t.compare(diffed, mergedDeletes)
|
||||||
|
// because diffed contains the set of all deletes.
|
||||||
|
// So we add all deletes from `diffed` to `partDeletes` and compare then
|
||||||
|
const decoder = decoding.createDecoder(diffed)
|
||||||
|
const updateDecoder = new UpdateDecoderV2(decoder)
|
||||||
|
readBlockSet(updateDecoder)
|
||||||
|
const ds = readIdSet(updateDecoder)
|
||||||
|
const updateEncoder = new UpdateEncoderV2()
|
||||||
|
encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs
|
||||||
|
writeIdSet(updateEncoder, ds)
|
||||||
|
const deletesUpdate = updateEncoder.toUint8Array()
|
||||||
|
const mergedDeletes = Y.mergeUpdatesV2([deletesUpdate, partMerged])
|
||||||
|
if (!hasDeletes || enc !== encDoc) {
|
||||||
|
// deletes will almost definitely lead to different encoders because of the mergeStruct feature that is present in encDoc
|
||||||
|
t.compare(diffed, mergedDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meta = enc.readUpdateToContentIds(mergedUpdates)
|
||||||
|
meta.inserts.clients.forEach(range => { t.assert(range.getIds()[0].clock === 0) })
|
||||||
|
meta.inserts.clients.forEach((range, client) => {
|
||||||
|
const structs = /** @type {Array<Y.Item>} */ (merged.store.clients.get(client))
|
||||||
|
const lastStruct = structs[structs.length - 1]
|
||||||
|
const lastIdRange = array.last(range.getIds())
|
||||||
|
t.assert(lastStruct.id.clock + lastStruct.length === lastIdRange.clock + lastIdRange.len)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testMergeUpdates1 = _tc => {
|
||||||
|
encoders.forEach((enc) => {
|
||||||
|
t.info(`Using encoder: ${enc.description}`)
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||||
|
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||||
|
const array = ydoc.get()
|
||||||
|
array.insert(0, [1])
|
||||||
|
array.insert(0, [2])
|
||||||
|
array.insert(0, [3])
|
||||||
|
array.insert(0, [4])
|
||||||
|
checkUpdateCases(ydoc, updates, enc, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testMergeUpdates2 = _tc => {
|
||||||
|
encoders.forEach((enc, _i) => {
|
||||||
|
t.info(`Using encoder: ${enc.description}`)
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||||
|
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||||
|
const array = ydoc.get()
|
||||||
|
array.insert(0, [1, 2])
|
||||||
|
array.delete(1, 1)
|
||||||
|
array.insert(0, [3, 4])
|
||||||
|
array.delete(1, 2)
|
||||||
|
checkUpdateCases(ydoc, updates, enc, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testMergeUpdatesStressTest = _tc => {
|
||||||
|
const N = 100
|
||||||
|
const M = 100
|
||||||
|
encoders.forEach((enc, _i) => {
|
||||||
|
t.info(`Using encoder: ${enc.description}`)
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||||
|
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||||
|
const array = ydoc.get()
|
||||||
|
for (let clientid = 0; clientid < N; clientid++) {
|
||||||
|
ydoc.clientID = clientid
|
||||||
|
for (let i = 0; i < M; i++) {
|
||||||
|
array.push([i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.measureTime('merge via Y.mergeUpdates', () => {
|
||||||
|
enc.mergeUpdates(updates)
|
||||||
|
})
|
||||||
|
t.measureTime('merge via Y.applyUpdate on Y.Doc', () => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
updates.forEach(update => {
|
||||||
|
enc.applyUpdate(ydoc, update)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testMergePendingUpdates = _tc => {
|
||||||
|
const yDoc = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {Array<Uint8Array>}
|
||||||
|
*/
|
||||||
|
const serverUpdates = []
|
||||||
|
yDoc.on('update', (update, _origin, _c) => {
|
||||||
|
serverUpdates.splice(serverUpdates.length, 0, update)
|
||||||
|
})
|
||||||
|
const yText = yDoc.get('textBlock')
|
||||||
|
yText.applyDelta(delta.create().insert('r').done())
|
||||||
|
yText.applyDelta(delta.create().insert('o').done())
|
||||||
|
yText.applyDelta(delta.create().insert('n').done())
|
||||||
|
yText.applyDelta(delta.create().insert('e').done())
|
||||||
|
yText.applyDelta(delta.create().insert('n').done())
|
||||||
|
|
||||||
|
const yDoc1 = new Y.Doc()
|
||||||
|
Y.applyUpdate(yDoc1, serverUpdates[0])
|
||||||
|
const update1 = Y.encodeStateAsUpdate(yDoc1)
|
||||||
|
|
||||||
|
const yDoc2 = new Y.Doc()
|
||||||
|
Y.applyUpdate(yDoc2, update1)
|
||||||
|
Y.applyUpdate(yDoc2, serverUpdates[1])
|
||||||
|
const update2 = Y.encodeStateAsUpdate(yDoc2)
|
||||||
|
|
||||||
|
const yDoc3 = new Y.Doc()
|
||||||
|
Y.applyUpdate(yDoc3, update2)
|
||||||
|
Y.applyUpdate(yDoc3, serverUpdates[3])
|
||||||
|
const update3 = Y.encodeStateAsUpdate(yDoc3)
|
||||||
|
|
||||||
|
const yDoc4 = new Y.Doc()
|
||||||
|
Y.applyUpdate(yDoc4, update3)
|
||||||
|
Y.applyUpdate(yDoc4, serverUpdates[2])
|
||||||
|
const update4 = Y.encodeStateAsUpdate(yDoc4)
|
||||||
|
|
||||||
|
const yDoc5 = new Y.Doc()
|
||||||
|
Y.applyUpdate(yDoc5, update4)
|
||||||
|
Y.applyUpdate(yDoc5, serverUpdates[4])
|
||||||
|
Y.encodeStateAsUpdate(yDoc5)
|
||||||
|
|
||||||
|
const yText5 = yDoc5.get('textBlock')
|
||||||
|
t.compareStrings(yText5.toString(), 'nenor')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testObfuscateUpdates = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get('text')
|
||||||
|
const ymap = ydoc.get('map')
|
||||||
|
const yarray = ydoc.get('array')
|
||||||
|
// test ytext
|
||||||
|
ytext.applyDelta(delta.create().insert('text', { bold: true }).insert([{ href: 'supersecreturl' }]).done())
|
||||||
|
// test ymap
|
||||||
|
ymap.setAttr('key', 'secret1')
|
||||||
|
ymap.setAttr('key', 'secret2')
|
||||||
|
// test yarray with subtype & subdoc
|
||||||
|
const subtype = new Y.Node('secretnodename')
|
||||||
|
const subdoc = new Y.Doc({ guid: 'secret' })
|
||||||
|
subtype.setAttr('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.get('text')
|
||||||
|
const omap = odoc.get('map')
|
||||||
|
const oarray = odoc.get('array')
|
||||||
|
// test ytext
|
||||||
|
const d = /** @type {any} */ (otext.toDelta().toJSON().children)
|
||||||
|
t.assert(d.length === 2)
|
||||||
|
t.assert(d[0].insert !== 'text' && d[0].insert.length === 4)
|
||||||
|
t.assert(object.length(d[0].format) === 1)
|
||||||
|
t.assert(!object.hasProperty(d[0].format, 'bold'))
|
||||||
|
t.assert(object.length(d[1].insert) === 1)
|
||||||
|
t.assert(object.hasProperty(d[1], 'insert'))
|
||||||
|
// test ymap
|
||||||
|
t.assert(omap.attrSize === 1)
|
||||||
|
t.assert(!omap.hasAttr('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.Node} */ (result[2])
|
||||||
|
const osubdoc = result[3]
|
||||||
|
// test subtype
|
||||||
|
t.assert(osubtype.name !== subtype.name)
|
||||||
|
t.assert(object.length(osubtype.getAttrs()) === 1)
|
||||||
|
t.assert(osubtype.getAttr('attr') === undefined)
|
||||||
|
// test subdoc
|
||||||
|
t.assert(osubdoc.guid !== subdoc.guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testIntersectDoc = () => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
ydoc.get().setAttr('k', 1)
|
||||||
|
const c1 = Y.createContentIdsFromDoc(ydoc, true)
|
||||||
|
ydoc.get().setAttr('k', 2)
|
||||||
|
|
||||||
|
const v1 = Y.intersectUpdateWithContentIds(Y.encodeStateAsUpdate(ydoc), c1)
|
||||||
|
const y1 = new Y.Doc()
|
||||||
|
Y.applyUpdate(y1, v1)
|
||||||
|
t.assert(ydoc.get().getAttr('k'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selecting a sparse mid-stream range of content-ids must not drop the selection
|
||||||
|
* just because earlier structs of the same client were not selected.
|
||||||
|
*
|
||||||
|
* @see https://github.com/yjs/yjs/issues/781
|
||||||
|
*/
|
||||||
|
export const testIntersectSparseContentIds = () => {
|
||||||
|
const src = new Y.Doc()
|
||||||
|
src.transact(() => {
|
||||||
|
const m = src.get('m')
|
||||||
|
for (let i = 0; i < 10; i++) m.setAttr(`k${i}`, i) // clocks 0..9, single client
|
||||||
|
})
|
||||||
|
const update = Y.encodeStateAsUpdate(src)
|
||||||
|
const cids = Y.createContentIdsFromUpdate(update)
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
let client = 0
|
||||||
|
cids.inserts.clients.forEach((_r, c) => { client = c })
|
||||||
|
|
||||||
|
// Select ONLY clocks [5, 7) — a mid-stream range.
|
||||||
|
const sel = Y.createIdSet()
|
||||||
|
sel.add(client, 5, 2)
|
||||||
|
const chunk = Y.intersectUpdateWithContentIds(update, {
|
||||||
|
inserts: sel,
|
||||||
|
deletes: Y.createIdSet()
|
||||||
|
})
|
||||||
|
// Each map key is a separate length-1 item, so clocks [5,7) yield two structs.
|
||||||
|
const structs = Y.decodeUpdate(chunk).structs.filter(s => !(s instanceof Y.Skip))
|
||||||
|
t.assert(structs.every(s => s.id.client === client))
|
||||||
|
t.compare(structs.map(s => [s.id.clock, s.length]), [[5, 1], [6, 1]])
|
||||||
|
|
||||||
|
// A selection split across a gap must round-trip both ranges with a Skip in between.
|
||||||
|
const sel2 = Y.createIdSet()
|
||||||
|
sel2.add(client, 1, 2) // clocks 1,2
|
||||||
|
sel2.add(client, 7, 1) // clock 7
|
||||||
|
const chunk2 = Y.intersectUpdateWithContentIds(update, {
|
||||||
|
inserts: sel2,
|
||||||
|
deletes: Y.createIdSet()
|
||||||
|
})
|
||||||
|
const structs2 = Y.decodeUpdate(chunk2).structs.filter(s => !(s instanceof Y.Skip))
|
||||||
|
t.compare(structs2.map(s => [s.id.clock, s.length]), [[1, 1], [2, 1], [7, 1]])
|
||||||
|
|
||||||
|
// A full-coverage selection must round-trip byte-identically to the source update.
|
||||||
|
const selAll = Y.createIdSet()
|
||||||
|
cids.inserts.clients.forEach((ranges, c) => {
|
||||||
|
ranges.getIds().forEach(r => selAll.add(c, r.clock, r.len))
|
||||||
|
})
|
||||||
|
const chunkAll = Y.intersectUpdateWithContentIds(update, {
|
||||||
|
inserts: selAll,
|
||||||
|
deletes: Y.createIdSet()
|
||||||
|
})
|
||||||
|
t.compare(chunkAll, update)
|
||||||
|
}
|
||||||
666
tests/y-array.tests.js
Normal file
666
tests/y-array.tests.js
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as prng from 'lib0/prng'
|
||||||
|
import * as math from 'lib0/math'
|
||||||
|
import * as env from 'lib0/environment'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
|
||||||
|
const isDevMode = env.getVariable('node_env') === 'development'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testBasicUpdate = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
const doc2 = new Y.Doc()
|
||||||
|
doc1.get('array').insert(0, ['hi'])
|
||||||
|
const update = Y.encodeStateAsUpdate(doc1)
|
||||||
|
Y.applyUpdate(doc2, update)
|
||||||
|
t.compare(doc2.get('array').toArray(), ['hi'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testFailsObjectManipulationInDevMode = _tc => {
|
||||||
|
if (isDevMode) {
|
||||||
|
t.info('running in dev mode')
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const a = [1, 2, 3]
|
||||||
|
const b = { o: 1 }
|
||||||
|
doc.get('test').insert(0, [a])
|
||||||
|
doc.get('map').setAttr('k', b)
|
||||||
|
t.fails(() => {
|
||||||
|
a[0] = 42
|
||||||
|
})
|
||||||
|
t.fails(() => {
|
||||||
|
b.o = 42
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
t.info('not in dev mode')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSlice = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
const arr = doc1.get('array')
|
||||||
|
arr.insert(0, [1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(0), [1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(1), [2, 3])
|
||||||
|
t.compareArrays(arr.slice(0, -1), [1, 2])
|
||||||
|
arr.insert(0, [0])
|
||||||
|
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
|
||||||
|
t.compareArrays(arr.slice(0, 2), [0, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testArrayFrom = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
const db1 = doc1.get('root')
|
||||||
|
const nestedArray1 = Y.Node.from(delta.create().insert([0, 1, 2]))
|
||||||
|
db1.setAttr('array', nestedArray1)
|
||||||
|
t.compare(nestedArray1.toArray(), [0, 1, 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debugging yjs#297 - a critical bug connected to the search-marker approach
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testLengthIssue = _tc => {
|
||||||
|
const doc1 = new Y.Doc()
|
||||||
|
const arr = doc1.get('array')
|
||||||
|
arr.push([0, 1, 2, 3])
|
||||||
|
arr.delete(0)
|
||||||
|
arr.insert(0, [0])
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
doc1.transact(() => {
|
||||||
|
arr.delete(1)
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
arr.insert(1, [1])
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
arr.delete(2)
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
arr.insert(2, [2])
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
})
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
arr.delete(1)
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
arr.insert(1, [1])
|
||||||
|
t.assert(arr.length === arr.toArray().length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debugging yjs#314
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testLengthIssue2 = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const next = doc.get()
|
||||||
|
doc.transact(() => {
|
||||||
|
next.insert(0, ['group2'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
next.insert(1, ['rectangle3'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
next.delete(0)
|
||||||
|
next.insert(0, ['rectangle3'])
|
||||||
|
})
|
||||||
|
next.delete(1)
|
||||||
|
doc.transact(() => {
|
||||||
|
next.insert(1, ['ellipse4'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
next.insert(2, ['ellipse3'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
next.insert(3, ['ellipse2'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
doc.transact(() => {
|
||||||
|
t.fails(() => {
|
||||||
|
next.insert(5, ['rectangle2'])
|
||||||
|
})
|
||||||
|
next.insert(4, ['rectangle2'])
|
||||||
|
})
|
||||||
|
doc.transact(() => {
|
||||||
|
// this should not throw an error message
|
||||||
|
next.delete(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
console.log(next.toArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeleteInsert = tc => {
|
||||||
|
const { users, array0 } = init(tc, { users: 2 })
|
||||||
|
array0.delete(0, 0)
|
||||||
|
t.describe('Does not throw when deleting zero elements with position 0')
|
||||||
|
t.fails(() => {
|
||||||
|
array0.delete(1, 1)
|
||||||
|
})
|
||||||
|
array0.insert(0, ['A'])
|
||||||
|
array0.delete(1, 0)
|
||||||
|
t.describe('Does not throw when deleting zero elements with valid position 1')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertThreeElementsTryRegetProperty = tc => {
|
||||||
|
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||||
|
array0.insert(0, [1, true, false])
|
||||||
|
t.compare(array0.toDelta(), delta.create().insert([1, true, false]).done(), 'content works')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.compare(array1.toDelta(), delta.create().insert([1, true, false]).done(), 'comparison works after sync')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testConcurrentInsertWithThreeConflicts = tc => {
|
||||||
|
const { users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||||
|
array0.insert(0, [0])
|
||||||
|
array1.insert(0, [1])
|
||||||
|
array2.insert(0, [2])
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testConcurrentInsertDeleteWithThreeConflicts = tc => {
|
||||||
|
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||||
|
array0.insert(0, ['x', 'y', 'z'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
array0.insert(1, [0])
|
||||||
|
array1.delete(0)
|
||||||
|
array1.delete(1, 1)
|
||||||
|
array2.insert(1, [2])
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertionsInLateSync = tc => {
|
||||||
|
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||||
|
array0.insert(0, ['x', 'y'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
users[1].disconnect()
|
||||||
|
users[2].disconnect()
|
||||||
|
array0.insert(1, ['user0'])
|
||||||
|
array1.insert(1, ['user1'])
|
||||||
|
array2.insert(1, ['user2'])
|
||||||
|
users[1].connect()
|
||||||
|
users[2].connect()
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDisconnectReallyPreventsSendingMessages = tc => {
|
||||||
|
const { testConnector, users, array0, array1 } = init(tc, { users: 3 })
|
||||||
|
array0.insert(0, ['x', 'y'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
users[1].disconnect()
|
||||||
|
users[2].disconnect()
|
||||||
|
array0.insert(1, ['user0'])
|
||||||
|
array1.insert(1, ['user1'])
|
||||||
|
t.compare(array0.toJSON().children, ['x', 'user0', 'y'])
|
||||||
|
t.compare(array1.toJSON().children, ['x', 'user1', 'y'])
|
||||||
|
users[1].connect()
|
||||||
|
users[2].connect()
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testDeletionsInLateSync = tc => {
|
||||||
|
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||||
|
array0.insert(0, ['x', 'y'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
users[1].disconnect()
|
||||||
|
array1.delete(1, 1)
|
||||||
|
array0.delete(0, 2)
|
||||||
|
users[1].connect()
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertThenMergeDeleteOnSync = tc => {
|
||||||
|
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||||
|
array0.insert(0, ['x', 'y', 'z'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
users[0].disconnect()
|
||||||
|
array1.delete(0, 3)
|
||||||
|
users[0].connect()
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertAndDeleteEvents = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>?}
|
||||||
|
*/
|
||||||
|
let event = null
|
||||||
|
array0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
array0.insert(0, [0, 1, 2])
|
||||||
|
t.assert(event !== null)
|
||||||
|
event = null
|
||||||
|
array0.delete(0)
|
||||||
|
t.assert(event !== null)
|
||||||
|
event = null
|
||||||
|
array0.delete(0, 2)
|
||||||
|
t.assert(event !== null)
|
||||||
|
event = null
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testNestedObserverEvents = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Array<number>}
|
||||||
|
*/
|
||||||
|
const vals = []
|
||||||
|
array0.observe(() => {
|
||||||
|
if (array0.length === 1) {
|
||||||
|
// inserting, will call this observer again
|
||||||
|
// we expect that this observer is called after this event handler finishedn
|
||||||
|
array0.insert(1, [1])
|
||||||
|
vals.push(0)
|
||||||
|
} else {
|
||||||
|
// this should be called the second time an element is inserted (above case)
|
||||||
|
vals.push(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
array0.insert(0, [0])
|
||||||
|
t.compareArrays(vals, [0, 1])
|
||||||
|
t.compareArrays(array0.toArray(), [0, 1])
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertAndDeleteEventsForTypes = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>|null}
|
||||||
|
*/
|
||||||
|
let event = null
|
||||||
|
array0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
array0.insert(0, [new Y.Node()])
|
||||||
|
t.assert(event !== null)
|
||||||
|
event = null
|
||||||
|
array0.delete(0)
|
||||||
|
t.assert(event !== null)
|
||||||
|
event = null
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testChangeEvent = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {delta.Delta<any>}
|
||||||
|
*/
|
||||||
|
let d = delta.create()
|
||||||
|
array0.observe(e => {
|
||||||
|
d = e.delta
|
||||||
|
})
|
||||||
|
const newArr = new Y.Node()
|
||||||
|
array0.insert(0, [newArr, 4, 'dtrn'])
|
||||||
|
t.assert(d !== null && d.children.len === 1)
|
||||||
|
t.compare(d, delta.create().insert([newArr, 4, 'dtrn']).done())
|
||||||
|
array0.delete(0, 2)
|
||||||
|
t.assert(d !== null && d.children.len === 1)
|
||||||
|
t.compare(d.toJSON().children, [{ delete: 2 }])
|
||||||
|
array0.insert(1, [0.1])
|
||||||
|
t.assert(d !== null && d.children.len === 2)
|
||||||
|
t.compare(d, delta.create().retain(1).insert([0.1]).done())
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testInsertAndDeleteEventsForTypes2 = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Array<Object<string,any>>}
|
||||||
|
*/
|
||||||
|
const events = []
|
||||||
|
array0.observe(e => {
|
||||||
|
events.push(e)
|
||||||
|
})
|
||||||
|
array0.insert(0, ['hi', new Y.Node()])
|
||||||
|
t.assert(events.length === 1, 'Event is triggered exactly once for insertion of two elements')
|
||||||
|
array0.delete(1)
|
||||||
|
t.assert(events.length === 2, 'Event is triggered exactly once for deletion')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This issue has been reported here https://github.com/yjs/yjs/issues/155
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testNewChildDoesNotEmitEventInTransaction = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 2 })
|
||||||
|
let fired = false
|
||||||
|
users[0].transact(() => {
|
||||||
|
const newMap = new Y.Node()
|
||||||
|
newMap.observe(() => {
|
||||||
|
fired = true
|
||||||
|
})
|
||||||
|
array0.insert(0, [newMap])
|
||||||
|
newMap.setAttr('tst', 42)
|
||||||
|
})
|
||||||
|
t.assert(!fired, 'Event does not trigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGarbageCollector = tc => {
|
||||||
|
const { testConnector, users, array0 } = init(tc, { users: 3 })
|
||||||
|
array0.insert(0, ['x', 'y', 'z'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
users[0].disconnect()
|
||||||
|
array0.delete(0, 3)
|
||||||
|
users[0].connect()
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testEventTargetIsSetCorrectlyOnLocal = tc => {
|
||||||
|
const { array0, users } = init(tc, { users: 3 })
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let event
|
||||||
|
array0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
array0.insert(0, ['stuff'])
|
||||||
|
t.assert(event.target === array0, '"target" property is set correctly')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testEventTargetIsSetCorrectlyOnRemote = tc => {
|
||||||
|
const { testConnector, array0, array1, users } = init(tc, { users: 3 })
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let event
|
||||||
|
array0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
array1.insert(0, ['stuff'])
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.assert(event.target === array0, '"target" property is set correctly')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIteratingArrayContainingTypes = _tc => {
|
||||||
|
const y = new Y.Doc()
|
||||||
|
const arr = y.get('arr')
|
||||||
|
const numItems = 10
|
||||||
|
for (let i = 0; i < numItems; i++) {
|
||||||
|
const map = new Y.Node()
|
||||||
|
map.setAttr('value', i)
|
||||||
|
arr.push([map])
|
||||||
|
}
|
||||||
|
let cnt = 0
|
||||||
|
for (const item of arr.toArray()) {
|
||||||
|
t.assert(item.getAttr('value') === cnt++, 'value is correct')
|
||||||
|
}
|
||||||
|
y.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testAttributedContent = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
/**
|
||||||
|
* @type {Y.Node<{ children: number }>}
|
||||||
|
*/
|
||||||
|
const yarray = ydoc.get()
|
||||||
|
yarray.insert(0, [1, 2])
|
||||||
|
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||||
|
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||||
|
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||||
|
})
|
||||||
|
t.group('insert / delete', () => {
|
||||||
|
ydoc.transact(() => {
|
||||||
|
yarray.delete(0, 1)
|
||||||
|
yarray.insert(1, [42])
|
||||||
|
})
|
||||||
|
const expectedContent = delta.create().insert([1], null, { delete: [] }).insert([2]).insert([42], null, { insert: [] })
|
||||||
|
const attributedContent = yarray.toDelta({ renderer })
|
||||||
|
console.log(attributedContent.toJSON())
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let _uniqueNumber = 0
|
||||||
|
const getUniqueNumber = () => _uniqueNumber++
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Array<function(Doc,prng.PRNG,any):void>}
|
||||||
|
*/
|
||||||
|
const arrayTransactions = [
|
||||||
|
function insert (user, gen) {
|
||||||
|
const yarray = user.get('array')
|
||||||
|
const uniqueNumber = getUniqueNumber()
|
||||||
|
const content = []
|
||||||
|
const len = prng.int32(gen, 1, 4)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
content.push(uniqueNumber)
|
||||||
|
}
|
||||||
|
const pos = prng.int32(gen, 0, yarray.length)
|
||||||
|
const oldContent = yarray.toArray()
|
||||||
|
yarray.insert(pos, content)
|
||||||
|
oldContent.splice(pos, 0, ...content)
|
||||||
|
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
|
||||||
|
},
|
||||||
|
function insertTypeArray (user, gen) {
|
||||||
|
const yarray = user.get('array')
|
||||||
|
const pos = prng.int32(gen, 0, yarray.length)
|
||||||
|
yarray.insert(pos, [new Y.Node()])
|
||||||
|
const array2 = yarray.get(pos)
|
||||||
|
array2.insert(0, [1, 2, 3, 4])
|
||||||
|
},
|
||||||
|
function insertTypeMap (user, gen) {
|
||||||
|
const yarray = user.get('array')
|
||||||
|
const pos = prng.int32(gen, 0, yarray.length)
|
||||||
|
yarray.insert(pos, [new Y.Node()])
|
||||||
|
const map = yarray.get(pos)
|
||||||
|
map.setAttr('someprop', 42)
|
||||||
|
map.setAttr('someprop', 43)
|
||||||
|
map.setAttr('someprop', 44)
|
||||||
|
},
|
||||||
|
function insertTypeNull (user, gen) {
|
||||||
|
const yarray = user.get('array')
|
||||||
|
const pos = prng.int32(gen, 0, yarray.length)
|
||||||
|
yarray.insert(pos, [null])
|
||||||
|
},
|
||||||
|
function _delete (user, gen) {
|
||||||
|
const yarray = user.get('array')
|
||||||
|
const length = yarray.length
|
||||||
|
if (length > 0) {
|
||||||
|
let somePos = prng.int32(gen, 0, length - 1)
|
||||||
|
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
|
||||||
|
if (prng.bool(gen)) {
|
||||||
|
const type = yarray.get(somePos)
|
||||||
|
if (type instanceof Y.Node && type.length > 0) {
|
||||||
|
somePos = prng.int32(gen, 0, type.length - 1)
|
||||||
|
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
|
||||||
|
type.delete(somePos, delLength)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const oldContent = yarray.toArray()
|
||||||
|
yarray.delete(somePos, delLength)
|
||||||
|
oldContent.splice(somePos, delLength)
|
||||||
|
t.compareArrays(yarray.toArray(), oldContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests6 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests40 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests42 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests43 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 43)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests44 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests45 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests46 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 46)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests300 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests400 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests500 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests600 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests1000 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests1800 = tc => {
|
||||||
|
applyRandomTests(tc, arrayTransactions, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests3000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, arrayTransactions, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests5000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, arrayTransactions, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYarrayTests30000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, arrayTransactions, 30000)
|
||||||
|
}
|
||||||
727
tests/y-map.tests.js
Normal file
727
tests/y-map.tests.js
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||||
|
import { AttributionsRenderer } from '../src/utils/Renderer.js'
|
||||||
|
import { createIdMapFromIdSet } from '../src/utils/ids.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as prng from 'lib0/prng'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
import * as s from 'lib0/schema'
|
||||||
|
import * as object from 'lib0/object'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testIterators = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
/**
|
||||||
|
* @type {Y.Node<{attrs: { [k:string]: number} }>}
|
||||||
|
*/
|
||||||
|
const ymap = ydoc.get()
|
||||||
|
// we are only checking if the type assumptions are correct
|
||||||
|
/**
|
||||||
|
* @type {Array<number>}
|
||||||
|
*/
|
||||||
|
const vals = Array.from(ymap.attrValues())
|
||||||
|
/**
|
||||||
|
* @type {Array<[string,number]>}
|
||||||
|
*/
|
||||||
|
const entries = Array.from(ymap.attrEntries())
|
||||||
|
/**
|
||||||
|
* @type {Array<string>}
|
||||||
|
*/
|
||||||
|
const keys = Array.from(ymap.attrKeys())
|
||||||
|
console.log(vals, entries, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testNestedMapEvent = () => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ymap = ydoc.get()
|
||||||
|
const ymapNested = ymap.setAttr('nested', new Y.Node())
|
||||||
|
let called = 0
|
||||||
|
ymap.observeDeep(event => {
|
||||||
|
const d = event.deltaDeep
|
||||||
|
called++
|
||||||
|
t.compare(d, delta.create().modifyAttr('nested', delta.create().setAttr('k', 'v')).done())
|
||||||
|
})
|
||||||
|
ymapNested.setAttr('k', 'v')
|
||||||
|
t.assert(called === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testNestedMapEvent2 = () => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yarr = ydoc.get()
|
||||||
|
const ymapNested = new Y.Node()
|
||||||
|
yarr.insert(0, [ymapNested])
|
||||||
|
let called = 0
|
||||||
|
yarr.observeDeep(event => {
|
||||||
|
const d = event.deltaDeep
|
||||||
|
called++
|
||||||
|
t.compare(d, delta.create().modify(delta.create().setAttr('k', 'v')))
|
||||||
|
})
|
||||||
|
ymapNested.setAttr('k', 'v')
|
||||||
|
t.assert(called === 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computing event changes after transaction should result in an error. See yjs#539
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testMapEventError = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const ymap = doc.get()
|
||||||
|
/**
|
||||||
|
* @type {any}
|
||||||
|
*/
|
||||||
|
let event = null
|
||||||
|
ymap.observe((e) => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
t.fails(() => {
|
||||||
|
t.info(event.keys)
|
||||||
|
})
|
||||||
|
t.fails(() => {
|
||||||
|
t.info(event.keys)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testMapHavingIterableAsConstructorParamTests = tc => {
|
||||||
|
const { map0 } = init(tc, { users: 1 })
|
||||||
|
const m1 = Y.Node.from(delta.create().setAttr('number', 1).setAttr('string', 'hello'))
|
||||||
|
map0.setAttr('m1', m1)
|
||||||
|
t.assert(m1.getAttr('number') === 1)
|
||||||
|
t.assert(m1.getAttr('string') === 'hello')
|
||||||
|
const m2 = Y.Node.from(delta.create(delta.$deltaAny).setAttrs({ object: { x: 1 }, boolean: true }).done())
|
||||||
|
map0.setAttr('m2', m2)
|
||||||
|
t.assert(m2.getAttr('object')?.x === 1)
|
||||||
|
t.assert(m2.getAttr('boolean') === true)
|
||||||
|
const m3 = new Y.Node()
|
||||||
|
m3.applyDelta(m1.toDelta())
|
||||||
|
m3.applyDelta(m2.toDelta())
|
||||||
|
map0.setAttr('m3', m3)
|
||||||
|
t.assert(m3.getAttr('number') === 1)
|
||||||
|
t.assert(m3.getAttr('string') === 'hello')
|
||||||
|
t.assert(m3.getAttr('object')?.x === 1)
|
||||||
|
t.assert(m3.getAttr('boolean') === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testBasicMapTests = tc => {
|
||||||
|
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
|
||||||
|
users[2].disconnect()
|
||||||
|
|
||||||
|
map0.setAttr('null', null)
|
||||||
|
map0.setAttr('number', 1)
|
||||||
|
map0.setAttr('string', 'hello Y')
|
||||||
|
map0.setAttr('object', { key: { key2: 'value' } })
|
||||||
|
map0.setAttr('y-map', new Y.Node())
|
||||||
|
map0.setAttr('boolean1', true)
|
||||||
|
map0.setAttr('boolean0', false)
|
||||||
|
const map = map0.getAttr('y-map')
|
||||||
|
map.setAttr('y-array', new Y.Node())
|
||||||
|
const array = map.getAttr('y-array')
|
||||||
|
array.insert(0, [0])
|
||||||
|
array.insert(0, [-1])
|
||||||
|
|
||||||
|
t.assert(map0.getAttr('null') === null, 'client 0 computed the change (null)')
|
||||||
|
t.assert(map0.getAttr('number') === 1, 'client 0 computed the change (number)')
|
||||||
|
t.assert(map0.getAttr('string') === 'hello Y', 'client 0 computed the change (string)')
|
||||||
|
t.assert(map0.getAttr('boolean0') === false, 'client 0 computed the change (boolean)')
|
||||||
|
t.assert(map0.getAttr('boolean1') === true, 'client 0 computed the change (boolean)')
|
||||||
|
t.compare(map0.getAttr('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
|
||||||
|
t.assert(map0.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 0 computed the change (type)')
|
||||||
|
t.assert(map0.attrSize === 7, 'client 0 map has correct size')
|
||||||
|
|
||||||
|
users[2].connect()
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
|
||||||
|
t.assert(map1.getAttr('null') === null, 'client 1 received the update (null)')
|
||||||
|
t.assert(map1.getAttr('number') === 1, 'client 1 received the update (number)')
|
||||||
|
t.assert(map1.getAttr('string') === 'hello Y', 'client 1 received the update (string)')
|
||||||
|
t.assert(map1.getAttr('boolean0') === false, 'client 1 computed the change (boolean)')
|
||||||
|
t.assert(map1.getAttr('boolean1') === true, 'client 1 computed the change (boolean)')
|
||||||
|
t.compare(map1.getAttr('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
|
||||||
|
t.assert(map1.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 1 received the update (type)')
|
||||||
|
t.assert(map1.attrSize === 7, 'client 1 map has correct size')
|
||||||
|
|
||||||
|
// compare disconnected user
|
||||||
|
t.assert(map2.getAttr('null') === null, 'client 2 received the update (null) - was disconnected')
|
||||||
|
t.assert(map2.getAttr('number') === 1, 'client 2 received the update (number) - was disconnected')
|
||||||
|
t.assert(map2.getAttr('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
|
||||||
|
t.assert(map2.getAttr('boolean0') === false, 'client 2 computed the change (boolean)')
|
||||||
|
t.assert(map2.getAttr('boolean1') === true, 'client 2 computed the change (boolean)')
|
||||||
|
t.compare(map2.getAttr('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
|
||||||
|
t.assert(map2.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetOfMapProperty = tc => {
|
||||||
|
const { testConnector, users, map0 } = init(tc, { users: 2 })
|
||||||
|
map0.setAttr('stuff', 'stuffy')
|
||||||
|
map0.setAttr('undefined', undefined)
|
||||||
|
map0.setAttr('null', null)
|
||||||
|
t.compare(map0.getAttr('stuff'), 'stuffy')
|
||||||
|
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.compare(u.getAttr('stuff'), 'stuffy')
|
||||||
|
t.assert(u.getAttr('undefined') === undefined, 'undefined')
|
||||||
|
t.compare(u.getAttr('null'), null, 'null')
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testYmapSetsYmap = tc => {
|
||||||
|
const { users, map0 } = init(tc, { users: 2 })
|
||||||
|
const map = map0.setAttr('Map', new Y.Node())
|
||||||
|
t.assert(map0.getAttr('Map') === map)
|
||||||
|
map.setAttr('one', 1)
|
||||||
|
t.compare(map.getAttr('one'), 1)
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testYmapSetsYarray = tc => {
|
||||||
|
const { users, map0 } = init(tc, { users: 2 })
|
||||||
|
const array = map0.setAttr('Array', new Y.Node())
|
||||||
|
t.assert(array === map0.getAttr('Array'))
|
||||||
|
array.insert(0, [1, 2, 3])
|
||||||
|
// @ts-ignore
|
||||||
|
t.compare(map0.toJSON().attrs, { Array: { children: [1, 2, 3] } })
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetOfMapPropertySyncs = tc => {
|
||||||
|
const { testConnector, users, map0 } = init(tc, { users: 2 })
|
||||||
|
map0.setAttr('stuff', 'stuffy')
|
||||||
|
t.compare(map0.getAttr('stuff'), 'stuffy')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.compare(u.getAttr('stuff'), 'stuffy')
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetOfMapPropertyWithConflict = tc => {
|
||||||
|
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.compare(u.getAttr('stuff'), 'c1')
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSizeAndDeleteOfMapProperty = tc => {
|
||||||
|
const { map0 } = init(tc, { users: 1 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map0.setAttr('otherstuff', 'c1')
|
||||||
|
t.assert(map0.attrSize === 2, `map size is ${map0.attrSize} expected 2`)
|
||||||
|
map0.deleteAttr('stuff')
|
||||||
|
t.assert(map0.attrSize === 1, `map size after delete is ${map0.attrSize}, expected 1`)
|
||||||
|
map0.deleteAttr('otherstuff')
|
||||||
|
t.assert(map0.attrSize === 0, `map size after delete is ${map0.attrSize}, expected 0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetAndDeleteOfMapProperty = tc => {
|
||||||
|
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
map1.deleteAttr('stuff')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.assert(u.getAttr('stuff') === undefined)
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSetAndClearOfMapProperties = tc => {
|
||||||
|
const { testConnector, users, map0 } = init(tc, { users: 1 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map0.setAttr('otherstuff', 'c1')
|
||||||
|
map0.clearAttrs()
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.assert(u.getAttr('stuff') === undefined)
|
||||||
|
t.assert(u.getAttr('otherstuff') === undefined)
|
||||||
|
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSetAndClearOfMapPropertiesWithConflicts = tc => {
|
||||||
|
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
map1.setAttr('stuff', 'c2')
|
||||||
|
map2.setAttr('stuff', 'c3')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
map0.setAttr('otherstuff', 'c0')
|
||||||
|
map1.setAttr('otherstuff', 'c1')
|
||||||
|
map2.setAttr('otherstuff', 'c2')
|
||||||
|
map3.setAttr('otherstuff', 'c3')
|
||||||
|
map3.clearAttrs()
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.assert(u.getAttr('stuff') === undefined)
|
||||||
|
t.assert(u.getAttr('otherstuff') === undefined)
|
||||||
|
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
|
||||||
|
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
map1.setAttr('stuff', 'c2')
|
||||||
|
map2.setAttr('stuff', 'c3')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.compare(u.getAttr('stuff'), 'c3')
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
|
||||||
|
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
|
||||||
|
map0.setAttr('stuff', 'c0')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
map1.setAttr('stuff', 'c2')
|
||||||
|
map2.setAttr('stuff', 'c3')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
map0.setAttr('stuff', 'deleteme')
|
||||||
|
map1.setAttr('stuff', 'c1')
|
||||||
|
map2.setAttr('stuff', 'c2')
|
||||||
|
map3.setAttr('stuff', 'c3')
|
||||||
|
map3.deleteAttr('stuff')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
for (const user of users) {
|
||||||
|
const u = user.get('map')
|
||||||
|
t.assert(u.getAttr('stuff') === undefined)
|
||||||
|
}
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object<string,any>} is
|
||||||
|
* @param {Object<string,any>} should
|
||||||
|
*/
|
||||||
|
const compareEvent = (is, should) => {
|
||||||
|
for (const key in should) {
|
||||||
|
t.compare(should[key], is[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
|
||||||
|
const { users, map0 } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>}
|
||||||
|
*/
|
||||||
|
let event = {}
|
||||||
|
map0.observe(e => {
|
||||||
|
event = e // just put it on event, should be thrown synchronously anyway
|
||||||
|
})
|
||||||
|
map0.setAttr('stuff', 4)
|
||||||
|
compareEvent(event, {
|
||||||
|
target: map0,
|
||||||
|
keysChanged: new Set(['stuff'])
|
||||||
|
})
|
||||||
|
// update, oldValue is in contents
|
||||||
|
map0.setAttr('stuff', new Y.Node())
|
||||||
|
compareEvent(event, {
|
||||||
|
target: map0,
|
||||||
|
keysChanged: new Set(['stuff'])
|
||||||
|
})
|
||||||
|
// update, oldValue is in opContents
|
||||||
|
map0.setAttr('stuff', 5)
|
||||||
|
// delete
|
||||||
|
map0.deleteAttr('stuff')
|
||||||
|
compareEvent(event, {
|
||||||
|
keysChanged: new Set(['stuff']),
|
||||||
|
target: map0
|
||||||
|
})
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testThrowsDeleteEventsOnClear = tc => {
|
||||||
|
const { users, map0 } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>}
|
||||||
|
*/
|
||||||
|
let event = {}
|
||||||
|
map0.observe(e => {
|
||||||
|
event = e // just put it on event, should be thrown synchronously anyway
|
||||||
|
})
|
||||||
|
// set values
|
||||||
|
map0.setAttr('stuff', 4)
|
||||||
|
map0.setAttr('otherstuff', new Y.Node())
|
||||||
|
// clear
|
||||||
|
map0.clearAttrs()
|
||||||
|
compareEvent(event, {
|
||||||
|
keysChanged: new Set(['stuff', 'otherstuff']),
|
||||||
|
target: map0
|
||||||
|
})
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testChangeEvent = tc => {
|
||||||
|
const { map0, users } = init(tc, { users: 2 })
|
||||||
|
/**
|
||||||
|
* @type {delta.Delta<any>?}
|
||||||
|
*/
|
||||||
|
let changes = delta.create()
|
||||||
|
map0.observe(e => {
|
||||||
|
changes = e.delta
|
||||||
|
})
|
||||||
|
map0.setAttr('a', 1)
|
||||||
|
let keyChange = changes.attrs.a
|
||||||
|
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 1)
|
||||||
|
map0.setAttr('a', 2)
|
||||||
|
keyChange = changes.attrs.a
|
||||||
|
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||||
|
users[0].transact(() => {
|
||||||
|
map0.setAttr('a', 3)
|
||||||
|
map0.setAttr('a', 4)
|
||||||
|
})
|
||||||
|
keyChange = changes.attrs.a
|
||||||
|
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 4)
|
||||||
|
users[0].transact(() => {
|
||||||
|
map0.setAttr('b', 1)
|
||||||
|
map0.setAttr('b', 2)
|
||||||
|
})
|
||||||
|
keyChange = changes.attrs.b
|
||||||
|
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||||
|
users[0].transact(() => {
|
||||||
|
map0.setAttr('c', 1)
|
||||||
|
map0.deleteAttr('c')
|
||||||
|
})
|
||||||
|
t.assert(changes !== null && object.isEmpty(changes.attrs))
|
||||||
|
users[0].transact(() => {
|
||||||
|
map0.setAttr('d', 1)
|
||||||
|
map0.setAttr('d', 2)
|
||||||
|
})
|
||||||
|
keyChange = changes.attrs.d
|
||||||
|
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const map = doc.get('map')
|
||||||
|
|
||||||
|
let updateCalled = false
|
||||||
|
let throwingObserverCalled = false
|
||||||
|
let throwingDeepObserverCalled = false
|
||||||
|
doc.on('update', () => {
|
||||||
|
updateCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const throwingObserver = () => {
|
||||||
|
throwingObserverCalled = true
|
||||||
|
throw new Error('Failure')
|
||||||
|
}
|
||||||
|
|
||||||
|
const throwingDeepObserver = () => {
|
||||||
|
throwingDeepObserverCalled = true
|
||||||
|
throw new Error('Failure')
|
||||||
|
}
|
||||||
|
|
||||||
|
map.observe(throwingObserver)
|
||||||
|
map.observeDeep(throwingDeepObserver)
|
||||||
|
|
||||||
|
t.fails(() => {
|
||||||
|
map.setAttr('y', '2')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.assert(updateCalled)
|
||||||
|
t.assert(throwingObserverCalled)
|
||||||
|
t.assert(throwingDeepObserverCalled)
|
||||||
|
|
||||||
|
// check if it works again
|
||||||
|
updateCalled = false
|
||||||
|
throwingObserverCalled = false
|
||||||
|
throwingDeepObserverCalled = false
|
||||||
|
t.fails(() => {
|
||||||
|
map.setAttr('z', '3')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.assert(updateCalled)
|
||||||
|
t.assert(throwingObserverCalled)
|
||||||
|
t.assert(throwingDeepObserverCalled)
|
||||||
|
|
||||||
|
t.assert(map.getAttr('z') === '3')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testYmapEventHasCorrectValueWhenSettingAPrimitive = tc => {
|
||||||
|
const { users, map0 } = init(tc, { users: 3 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>}
|
||||||
|
*/
|
||||||
|
let event = {}
|
||||||
|
map0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
map0.setAttr('stuff', 2)
|
||||||
|
t.compare(event.value, event.target.get(event.name))
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc => {
|
||||||
|
const { users, map0, map1, testConnector } = init(tc, { users: 3 })
|
||||||
|
/**
|
||||||
|
* @type {Object<string,any>}
|
||||||
|
*/
|
||||||
|
let event = {}
|
||||||
|
map0.observe(e => {
|
||||||
|
event = e
|
||||||
|
})
|
||||||
|
map1.setAttr('stuff', 2)
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.compare(event.value, event.target.get(event.name))
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testAttributedContent = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const ymap = ydoc.get()
|
||||||
|
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||||
|
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||||
|
renderer = new AttributionsRenderer(Y.createContentMap(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])))
|
||||||
|
})
|
||||||
|
t.group('initial value', () => {
|
||||||
|
ymap.setAttr('test', 42)
|
||||||
|
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 42, attribution: { insert: [] } }) }
|
||||||
|
const attributedContent = ymap.toDelta({ renderer })
|
||||||
|
console.log(attributedContent.toJSON())
|
||||||
|
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||||
|
})
|
||||||
|
t.group('overwrite value', () => {
|
||||||
|
ymap.setAttr('test', 'fourtytwo')
|
||||||
|
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { insert: [] } }) }
|
||||||
|
const attributedContent = ymap.toDelta({ renderer })
|
||||||
|
console.log(attributedContent)
|
||||||
|
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||||
|
})
|
||||||
|
t.group('delete value', () => {
|
||||||
|
ymap.deleteAttr('test')
|
||||||
|
// Snapshot-mode `toDelta(renderer)` (no `itemsToRender` opt) must not emit
|
||||||
|
// `DeleteAttrOp`. An attribute deleted under attribution is still
|
||||||
|
// observable in the rendered state with its prior value and a `delete`
|
||||||
|
// attribution marker - symmetric with how soft-deleted content children
|
||||||
|
// surface as `InsertOp` with `{ delete: [] }` rather than `DeleteOp`.
|
||||||
|
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { delete: [] } }) }
|
||||||
|
const attributedContent = ymap.toDelta({ renderer })
|
||||||
|
console.log(attributedContent.toJSON())
|
||||||
|
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Array<function(Doc,prng.PRNG):void>}
|
||||||
|
*/
|
||||||
|
const mapTransactions = [
|
||||||
|
function set (user, gen) {
|
||||||
|
const key = prng.oneOf(gen, ['one', 'two'])
|
||||||
|
const value = prng.utf16String(gen)
|
||||||
|
user.get('map').setAttr(key, value)
|
||||||
|
},
|
||||||
|
function setType (user, gen) {
|
||||||
|
const key = prng.oneOf(gen, ['one', 'two'])
|
||||||
|
const type = new Y.Node()
|
||||||
|
user.get('map').setAttr(key, type)
|
||||||
|
if (prng.bool(gen)) {
|
||||||
|
type.insert(0, [1, 2, 3, 4])
|
||||||
|
} else {
|
||||||
|
type.setAttr('deepkey', 'deepvalue')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function _delete (user, gen) {
|
||||||
|
const key = prng.oneOf(gen, ['one', 'two'])
|
||||||
|
user.get('map').deleteAttr(key)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests10 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests40 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests42 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests43 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 43)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests44 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests45 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests46 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 46)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests300 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests400 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests500 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests600 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests1000 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests1800 = tc => {
|
||||||
|
applyRandomTests(tc, mapTransactions, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests5000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, mapTransactions, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests10000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, mapTransactions, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testRepeatGeneratingYmapTests100000 = tc => {
|
||||||
|
t.skip(!t.production)
|
||||||
|
applyRandomTests(tc, mapTransactions, 100000)
|
||||||
|
}
|
||||||
2385
tests/y-text.tests.js
Normal file
2385
tests/y-text.tests.js
Normal file
File diff suppressed because it is too large
Load Diff
473
tests/y-xml.tests.js
Normal file
473
tests/y-xml.tests.js
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import * as Y from '../src/index.js'
|
||||||
|
import { init, compare } from './testHelper.js'
|
||||||
|
import * as t from 'lib0/testing'
|
||||||
|
import * as delta from 'lib0/delta'
|
||||||
|
|
||||||
|
export const testCustomTypings = () => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ymap = ydoc.get()
|
||||||
|
/**
|
||||||
|
* @type {Y.Node<{ attrs: { num: number, str: string, [k:string]: number|string } }>}
|
||||||
|
*/
|
||||||
|
const yxml = ymap.setAttr('yxml', new Y.Node('test'))
|
||||||
|
/**
|
||||||
|
* @type {number|undefined}
|
||||||
|
*/
|
||||||
|
const num = yxml.getAttr('num')
|
||||||
|
/**
|
||||||
|
* @type {string|undefined}
|
||||||
|
*/
|
||||||
|
const str = yxml.getAttr('str')
|
||||||
|
/**
|
||||||
|
* @type {object|number|string|undefined}
|
||||||
|
*/
|
||||||
|
const dtrn = yxml.getAttr('dtrn')
|
||||||
|
const attrs = yxml.getAttrs()
|
||||||
|
/**
|
||||||
|
* @type {object|number|string|undefined}
|
||||||
|
*/
|
||||||
|
const any = attrs.shouldBeAny
|
||||||
|
console.log({ num, str, dtrn, attrs, any })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testSetProperty = tc => {
|
||||||
|
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
|
||||||
|
xml0.setAttr('height', '10')
|
||||||
|
t.assert(xml0.getAttr('height') === '10', 'Simple set+get works')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.assert(xml1.getAttr('height') === '10', 'Simple set+get works (remote)')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} tc
|
||||||
|
*/
|
||||||
|
export const testHasProperty = tc => {
|
||||||
|
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
|
||||||
|
xml0.setAttr('height', '10')
|
||||||
|
t.assert(xml0.hasAttr('height'), 'Simple set+has works')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.assert(xml1.hasAttr('height'), 'Simple set+has works (remote)')
|
||||||
|
xml0.deleteAttr('height')
|
||||||
|
t.assert(!xml0.hasAttr('height'), 'Simple set+remove+has works')
|
||||||
|
testConnector.flushAllMessages()
|
||||||
|
t.assert(!xml1.hasAttr('height'), 'Simple set+remove+has works (remote)')
|
||||||
|
compare(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testYtextAttributes = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const ytext = ydoc.get('')
|
||||||
|
ytext.observe(event => {
|
||||||
|
t.assert(event.delta.attrs.test?.type === 'insert')
|
||||||
|
})
|
||||||
|
ytext.setAttr('test', 42)
|
||||||
|
t.compare(ytext.getAttr('test'), 42)
|
||||||
|
t.compare(ytext.getAttrs(), { test: 42 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testSiblings = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yxml = ydoc.get()
|
||||||
|
const first = new Y.Node()
|
||||||
|
const second = new Y.Node('p')
|
||||||
|
yxml.insert(0, [first, second])
|
||||||
|
t.assert(first.parent === /** @type {Y.Node<any>} */ (yxml))
|
||||||
|
t.assert(yxml.parent === null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testClone = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yxml = ydoc.get()
|
||||||
|
const first = new Y.Node('text')
|
||||||
|
const second = new Y.Node('p')
|
||||||
|
const third = new Y.Node('p')
|
||||||
|
yxml.push([first, second, third])
|
||||||
|
t.compareArrays(yxml.toArray(), [first, second, third])
|
||||||
|
const cloneYxml = yxml.clone()
|
||||||
|
ydoc.get('copyarr').insert(0, [cloneYxml])
|
||||||
|
t.assert(cloneYxml.length === 3)
|
||||||
|
t.compare(cloneYxml.toJSON(), yxml.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testFormattingBug = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yxml = ydoc.get()
|
||||||
|
const q = delta.create()
|
||||||
|
.insert('A', { em: {}, strong: {} })
|
||||||
|
.insert('B', { em: {} })
|
||||||
|
.insert('C', { em: {}, strong: {} })
|
||||||
|
yxml.applyDelta(q)
|
||||||
|
t.compare(yxml.toDelta(), q)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testElement = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
const yxmlel = ydoc.get()
|
||||||
|
const text1 = new Y.Node('text1')
|
||||||
|
const text2 = new Y.Node('text2')
|
||||||
|
yxmlel.insert(0, [text1, text2])
|
||||||
|
t.compareArrays(yxmlel.toArray(), [text1, text2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testFragmentAttributedContent = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const yfragment = new Y.Node()
|
||||||
|
const elem1 = Y.Node.from(delta.create().insert('hello'))
|
||||||
|
const elem2 = new Y.Node()
|
||||||
|
const elem3 = Y.Node.from(delta.create().insert('world'))
|
||||||
|
yfragment.insert(0, [elem1, elem2])
|
||||||
|
ydoc.get().insert(0, [yfragment])
|
||||||
|
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||||
|
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||||
|
})
|
||||||
|
t.group('insert / delete', () => {
|
||||||
|
ydoc.transact(() => {
|
||||||
|
yfragment.delete(0, 1)
|
||||||
|
yfragment.insert(1, [elem3])
|
||||||
|
})
|
||||||
|
const expectedContent = delta.create().insert([elem1], null, { delete: [] }).insert([elem2]).insert([elem3], null, { insert: [] })
|
||||||
|
const attributedContent = yfragment.toDelta({ renderer })
|
||||||
|
console.log(attributedContent.toJSON())
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(elem1.toDelta({ renderer }).toJSON(), delta.create().insert('hello', null, { delete: [] }).toJSON())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testElementAttributedContent = _tc => {
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
const yelement = ydoc.get('p')
|
||||||
|
const elem1 = delta.create().insert('hello').done()
|
||||||
|
const elem2 = delta.create('span').done()
|
||||||
|
const elem3 = delta.create().insert('world').done()
|
||||||
|
yelement.insert(0, [elem1, elem2])
|
||||||
|
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||||
|
ydoc.on('afterTransaction', tr => {
|
||||||
|
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||||
|
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||||
|
})
|
||||||
|
t.group('insert / delete', () => {
|
||||||
|
ydoc.transact(() => {
|
||||||
|
yelement.delete(0, 1)
|
||||||
|
yelement.insert(1, [elem3])
|
||||||
|
yelement.setAttr('key', '42')
|
||||||
|
})
|
||||||
|
const expectedContent = delta.create()
|
||||||
|
.insert([delta.create().insert('hello', null, { delete: [] })], null, { delete: [] })
|
||||||
|
.insert([elem2])
|
||||||
|
.insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] })
|
||||||
|
.setAttr('key', '42', { insert: [] })
|
||||||
|
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||||
|
console.log('retrieved content', attributedContent.toJSON())
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testElementAttributedContentViaDiffer = _tc => {
|
||||||
|
const ydocV1 = new Y.Doc()
|
||||||
|
ydocV1.get('p').insert(0, [delta.create().insert('hello'), delta.create('span')])
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||||
|
const yelement = ydoc.get('p')
|
||||||
|
const elem2 = yelement.get(1) // new Y.XmlElement('span')
|
||||||
|
const elem3 = Y.Node.from(delta.create().insert('world'))
|
||||||
|
ydoc.transact(() => {
|
||||||
|
yelement.delete(0, 1)
|
||||||
|
yelement.insert(1, [elem3])
|
||||||
|
yelement.setAttr('key', '42')
|
||||||
|
})
|
||||||
|
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||||
|
const expectedContent = delta.create().insert([delta.create().insert('hello')], null, { delete: [] }).insert([elem2.toDeltaDeep()]).insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] }).setAttr('key', '42', { insert: [] })
|
||||||
|
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||||
|
console.log('children', attributedContent.toJSON().children)
|
||||||
|
console.log('attributes', attributedContent.toJSON().attrs)
|
||||||
|
t.compare(attributedContent.toJSON(), expectedContent.toJSON())
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||||
|
t.group('test getContentDeep', () => {
|
||||||
|
const expectedContent = delta.create()
|
||||||
|
.insert(
|
||||||
|
[delta.create().insert('hello')],
|
||||||
|
null,
|
||||||
|
{ delete: [] }
|
||||||
|
)
|
||||||
|
.insert([delta.create('span')])
|
||||||
|
.insert([
|
||||||
|
delta.create().insert('world', null, { insert: [] })
|
||||||
|
], null, { insert: [] })
|
||||||
|
.setAttr('key', '42', { insert: [] })
|
||||||
|
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||||
|
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||||
|
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||||
|
console.log('attributes', attributedContent.toJSON().attrs)
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||||
|
t.assert(attributedContent.name === null)
|
||||||
|
})
|
||||||
|
ydoc.transact(() => {
|
||||||
|
elem3.insert(0, 'big')
|
||||||
|
})
|
||||||
|
t.group('test getContentDeep after some more updates', () => {
|
||||||
|
t.info('expecting DiffRenderer to auto update itself')
|
||||||
|
const expectedContent = delta.create()
|
||||||
|
.insert(
|
||||||
|
[delta.create().insert('hello')],
|
||||||
|
null,
|
||||||
|
{ delete: [] }
|
||||||
|
)
|
||||||
|
.insert([delta.create('span')])
|
||||||
|
.insert([
|
||||||
|
delta.create().insert('bigworld', null, { insert: [] })
|
||||||
|
], null, { insert: [] })
|
||||||
|
.setAttr('key', '42', { insert: [] })
|
||||||
|
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||||
|
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||||
|
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||||
|
console.log('attributes', attributedContent.toJSON().attrs)
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||||
|
t.assert(attributedContent.name === null)
|
||||||
|
})
|
||||||
|
Y.applyUpdate(ydocV1, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
t.group('test getContentDeep both docs synced', () => {
|
||||||
|
t.info('expecting DiffRenderer to auto update itself')
|
||||||
|
const expectedContent = delta.create().insert([delta.create('span')]).insert([
|
||||||
|
delta.create().insert('bigworld')
|
||||||
|
]).setAttr('key', '42')
|
||||||
|
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||||
|
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||||
|
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||||
|
console.log('attributes', attributedContent.toJSON().attrs)
|
||||||
|
t.assert(attributedContent.equals(expectedContent))
|
||||||
|
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42' } })
|
||||||
|
t.assert(attributedContent.name === null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testRendererSimpleExample = _tc => {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
ydoc.clientID = 0
|
||||||
|
// create some initial content
|
||||||
|
ydoc.get().insert(0, [delta.create().insert('hello world')])
|
||||||
|
const ydocFork = new Y.Doc()
|
||||||
|
ydocFork.clientID = 1
|
||||||
|
Y.applyUpdate(ydocFork, Y.encodeStateAsUpdate(ydoc))
|
||||||
|
// modify the fork
|
||||||
|
// append a span element
|
||||||
|
ydocFork.get().insert(1, [new Y.Node('span')])
|
||||||
|
const ytext = ydocFork.get().get(0)
|
||||||
|
// make "hello" italic
|
||||||
|
ytext.format(0, 5, { italic: true })
|
||||||
|
ytext.insert(11, 'deleteme')
|
||||||
|
ytext.delete(11, 8)
|
||||||
|
ytext.insert(11, '!')
|
||||||
|
// highlight the changes
|
||||||
|
console.log(JSON.stringify(ydocFork.get().toDeltaDeep({ renderer: Y.createDiffRenderer(ydoc, ydocFork) }), null, 2))
|
||||||
|
/* =>
|
||||||
|
{
|
||||||
|
"children": {
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"insert": [
|
||||||
|
{
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"insert": "hello",
|
||||||
|
"attributes": {
|
||||||
|
"italic": true
|
||||||
|
},
|
||||||
|
"attribution": {
|
||||||
|
"attributes": {
|
||||||
|
"italic": [] -- the attribute "italic" was changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"insert": " world" -- "world" remains unchanged
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"insert": "!",
|
||||||
|
"attribution": {
|
||||||
|
"insert": [] -- "!" was inserted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"insert": [
|
||||||
|
{
|
||||||
|
"nodeName": "span",
|
||||||
|
"children": {
|
||||||
|
"ops": []
|
||||||
|
},
|
||||||
|
"attributes": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attribution": {
|
||||||
|
"insert": [] -- A <span/> tag was inserted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a delta tree and collect every op whose constructor matches one of the
|
||||||
|
* forbidden names. Returns a list of `{ path, opType, op }` for reporting.
|
||||||
|
*
|
||||||
|
* @param {any} d
|
||||||
|
* @param {Array<string>} forbidden - constructor names that should never appear
|
||||||
|
* @param {string} [path]
|
||||||
|
* @param {Array<{path:string, opType:string, op:any}>} [acc]
|
||||||
|
*/
|
||||||
|
const collectForbiddenOps = (d, forbidden, path = '$', acc = []) => {
|
||||||
|
if (d == null || typeof d !== 'object') return acc
|
||||||
|
if (d.attrs != null) {
|
||||||
|
for (const attrOp of d.attrs) {
|
||||||
|
const name = attrOp?.constructor?.name
|
||||||
|
if (name && forbidden.includes(name)) {
|
||||||
|
acc.push({ path: `${path}.attrs[${attrOp.key}]`, opType: name, op: attrOp })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d.children?.start != null) {
|
||||||
|
let op = d.children.start
|
||||||
|
let i = 0
|
||||||
|
while (op != null) {
|
||||||
|
const name = op.constructor?.name
|
||||||
|
if (name && forbidden.includes(name)) {
|
||||||
|
acc.push({ path: `${path}.children[${i}]`, opType: name, op })
|
||||||
|
}
|
||||||
|
if (op.insert && Array.isArray(op.insert)) {
|
||||||
|
op.insert.forEach((/** @type {any} */ nested, /** @type {number} */ j) => collectForbiddenOps(nested, forbidden, `${path}.children[${i}].insert[${j}]`, acc))
|
||||||
|
}
|
||||||
|
if (op.value != null) {
|
||||||
|
collectForbiddenOps(op.value, forbidden, `${path}.children[${i}].value`, acc)
|
||||||
|
}
|
||||||
|
op = op.next
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reproduces the y-prosemirror issue #247 contract violation at the @y/y
|
||||||
|
* level: `ynode.toDeltaDeep({ renderer })` is supposed to surface soft-deleted content
|
||||||
|
* as positive ops (`SetAttrOp` / `InsertOp`) carrying attribution metadata,
|
||||||
|
* never as `DeleteAttrOp` / `DeleteOp`. Today, when a parent YXmlElement is
|
||||||
|
* itself soft-deleted under attribution, `nodeMapGetDelta` (ynode.js:1928)
|
||||||
|
* and the children traversal in `toDelta` emit raw delete ops for the
|
||||||
|
* cascaded child setAttr / content items - which downstream consumers
|
||||||
|
* (lib0/delta `diff`, y-prosemirror's PM mapper) cannot handle.
|
||||||
|
*
|
||||||
|
* Expected after fix: walking the delta returned by `parent.toDeltaDeep({ renderer })`
|
||||||
|
* finds zero `DeleteAttrOp` entries in any `attrs` map and zero `DeleteOp`
|
||||||
|
* entries in any `children` list, at every nesting level.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testToDeltaDeepEmitsNoDeleteOpsForSoftDeletedParent = _tc => {
|
||||||
|
// Base doc: a fragment containing one YXmlElement that has an `id` attr
|
||||||
|
// and one nested text child.
|
||||||
|
const ydocV1 = new Y.Doc({ gc: false })
|
||||||
|
const parentV1 = ydocV1.get('frag')
|
||||||
|
const childV1 = new Y.Node('item')
|
||||||
|
childV1.setAttr('id', 'C')
|
||||||
|
childV1.insert(0, [delta.create().insert('hello')])
|
||||||
|
parentV1.insert(0, [childV1])
|
||||||
|
|
||||||
|
// Forked doc: copy V1, then suggestion-delete the YXmlElement child by
|
||||||
|
// removing it from the fragment. Under the diff AM this surfaces as a
|
||||||
|
// soft-deleted child whose own `id` setAttr Item is cascade-tombstoned.
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||||
|
const parent = ydoc.get('frag')
|
||||||
|
ydoc.transact(() => {
|
||||||
|
parent.delete(0, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||||
|
const rendered = parent.toDeltaDeep({ renderer })
|
||||||
|
|
||||||
|
// The cascade should surface as positive ops with attribution, not as
|
||||||
|
// delete ops. Find any DeleteAttrOp / DeleteOp anywhere in the tree.
|
||||||
|
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
|
||||||
|
if (offenders.length > 0) {
|
||||||
|
console.log('Forbidden delete ops in toDeltaDeep output:')
|
||||||
|
for (const o of offenders) {
|
||||||
|
console.log(` ${o.opType} at ${o.path}`)
|
||||||
|
}
|
||||||
|
console.log('Full delta:', JSON.stringify(rendered.toJSON(), null, 2))
|
||||||
|
}
|
||||||
|
t.assert(
|
||||||
|
offenders.length === 0,
|
||||||
|
`toDeltaDeep(renderer) emitted ${offenders.length} forbidden delete op(s) for a soft-deleted parent (issue #247 / y-prosemirror)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion to the cascade case above: an explicit `deleteAttr` under a diff
|
||||||
|
* AM must also surface as a positive `SetAttrOp` carrying the prior value and
|
||||||
|
* `{ delete: [] }` attribution, never as a `DeleteAttrOp`. The same contract
|
||||||
|
* applies whether the attribute deletion came from a parent-cascade or from
|
||||||
|
* a direct `ynode.deleteAttr(key)` call.
|
||||||
|
*
|
||||||
|
* @param {t.TestCase} _tc
|
||||||
|
*/
|
||||||
|
export const testToDeltaDeepRendersExplicitDeleteAttrAsSetAttrWithAttribution = _tc => {
|
||||||
|
const ydocV1 = new Y.Doc({ gc: false })
|
||||||
|
ydocV1.get('p').setAttr('id', 'C')
|
||||||
|
const ydoc = new Y.Doc({ gc: false })
|
||||||
|
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||||
|
ydoc.transact(() => {
|
||||||
|
ydoc.get('p').deleteAttr('id')
|
||||||
|
})
|
||||||
|
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||||
|
const rendered = ydoc.get('p').toDeltaDeep({ renderer })
|
||||||
|
|
||||||
|
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
|
||||||
|
t.assert(offenders.length === 0, 'no DeleteAttrOp / DeleteOp from explicit deleteAttr under diff AM')
|
||||||
|
t.compare(
|
||||||
|
rendered.toJSON().attrs,
|
||||||
|
{ id: { type: 'insert', value: 'C', attribution: { delete: [] } } },
|
||||||
|
'explicit deleteAttr surfaces as SetAttrOp with prior value and delete attribution'
|
||||||
|
)
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"lib": ["ES2021", "dom"],
|
||||||
|
"module": "node16",
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"paths": {
|
||||||
|
"yjs": ["./src/index.js"],
|
||||||
|
"yjs/testHelper": ["./tests/testHelper.js"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": ["./global.d.ts"],
|
||||||
|
"include": ["./src/**/*.js", "./tests/**/*.js"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user