prosemirror-rs 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +27 -0
- package/README.md +129 -0
- package/build.rs +3 -0
- package/copy-artifact.mjs +39 -0
- package/index.d.ts +113 -0
- package/index.js +37 -0
- package/package.json +28 -0
- package/prosemirror-rs.linux-x64-gnu.node +0 -0
- package/prosemirror_rs.node +0 -0
- package/src/lib.rs +285 -0
- package/tests/editor.test.js +303 -0
package/Cargo.toml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "prosemirror-rs-node"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
authors = [
|
|
5
|
+
"Johannes Wilm <johannes@fiduswriter.org>",
|
|
6
|
+
"Daniel Seiler <me@dseiler.eu>",
|
|
7
|
+
]
|
|
8
|
+
edition = "2021"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
description = "Node.js bindings for prosemirror-rs"
|
|
11
|
+
repository = "https://github.com/fiduswriter/prosemirror-rs"
|
|
12
|
+
homepage = "https://github.com/fiduswriter/prosemirror-rs"
|
|
13
|
+
# This crate is published via npm/napi-rs, not crates.io
|
|
14
|
+
publish = false
|
|
15
|
+
|
|
16
|
+
[lib]
|
|
17
|
+
name = "prosemirror_rs"
|
|
18
|
+
crate-type = ["cdylib"]
|
|
19
|
+
|
|
20
|
+
[dependencies]
|
|
21
|
+
napi = { version = "2", features = ["napi4"] }
|
|
22
|
+
napi-derive = "2"
|
|
23
|
+
prosemirror = { path = ".." }
|
|
24
|
+
serde_json = "1"
|
|
25
|
+
|
|
26
|
+
[build-dependencies]
|
|
27
|
+
napi-build = "2"
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# prosemirror-rs — Node.js bindings
|
|
2
|
+
|
|
3
|
+
Node.js bindings for [`prosemirror`](https://crates.io/crates/prosemirror), a Rust
|
|
4
|
+
implementation of [ProseMirror](https://prosemirror.net)'s document model and
|
|
5
|
+
transform pipeline.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install prosemirror-rs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Design goals
|
|
14
|
+
|
|
15
|
+
- **Zero unnecessary copies.** The schema and document live entirely in Rust
|
|
16
|
+
memory. Only JSON strings cross the JavaScript/Rust boundary.
|
|
17
|
+
- **Wire-efficient.** Steps arriving as JSON (e.g. from a WebSocket) can be
|
|
18
|
+
passed directly to `applyStepsJson()` without any JS-level parsing.
|
|
19
|
+
- **Database-efficient.** `docJson()` serializes the document in Rust and
|
|
20
|
+
returns a plain JS `string`, ready to write to a database with no
|
|
21
|
+
intermediate objects.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
const { Editor } = require('prosemirror-rs');
|
|
27
|
+
|
|
28
|
+
const schemaJson = JSON.stringify({
|
|
29
|
+
nodes: {
|
|
30
|
+
doc: { content: 'paragraph+' },
|
|
31
|
+
paragraph: { content: 'text*', group: 'block' },
|
|
32
|
+
text: { group: 'inline' },
|
|
33
|
+
},
|
|
34
|
+
marks: { strong: {}, em: {} },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const docJson = JSON.stringify({
|
|
38
|
+
type: 'doc',
|
|
39
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const editor = new Editor(schemaJson, docJson);
|
|
43
|
+
console.log(editor.version); // 0
|
|
44
|
+
|
|
45
|
+
// Typical server loop: steps arrive as raw JSON from a WebSocket client
|
|
46
|
+
wss.on('message', (raw) => {
|
|
47
|
+
const data = JSON.parse(raw); // parse envelope only
|
|
48
|
+
const ok = editor.applyStepsJson( // steps stay as JSON string
|
|
49
|
+
JSON.stringify(data.steps)
|
|
50
|
+
);
|
|
51
|
+
if (ok) {
|
|
52
|
+
db.execute('UPDATE docs SET body = $1', [editor.docJson()]);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Building from source
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd node
|
|
61
|
+
npm run build # runs cargo build --release + copies the .node artifact
|
|
62
|
+
npm test # build + run the test suite
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API reference
|
|
66
|
+
|
|
67
|
+
### `new Editor(schemaJson, docJson)`
|
|
68
|
+
|
|
69
|
+
Create an editor. Both arguments are JSON strings (schema spec and initial
|
|
70
|
+
document). Throws `Error` on malformed input.
|
|
71
|
+
|
|
72
|
+
### `editor.applyStep(stepJson) → boolean`
|
|
73
|
+
|
|
74
|
+
Apply one step supplied as a JSON string. Returns `true` on success, `false`
|
|
75
|
+
if the step cannot be applied (document is left unchanged). Throws `Error`
|
|
76
|
+
on invalid JSON.
|
|
77
|
+
|
|
78
|
+
### `editor.applyStepsJson(stepsJson) → boolean`
|
|
79
|
+
|
|
80
|
+
**Preferred method for incoming network data.** Accepts a JSON *array* of
|
|
81
|
+
steps as a single string — passed directly to Rust and parsed there, so
|
|
82
|
+
nothing touches JS's JSON machinery.
|
|
83
|
+
|
|
84
|
+
The batch is **atomic**: if any step fails the document and version are rolled
|
|
85
|
+
back entirely and `false` is returned. Throws `Error` on invalid JSON.
|
|
86
|
+
|
|
87
|
+
### `editor.applySteps(steps) → boolean`
|
|
88
|
+
|
|
89
|
+
Convenience method for when steps are constructed or modified in JavaScript.
|
|
90
|
+
Each element of `steps` is a JSON string for one step.
|
|
91
|
+
|
|
92
|
+
The batch is **atomic**: if any step fails the document and version are rolled
|
|
93
|
+
back entirely and `false` is returned. Throws `Error` on invalid JSON.
|
|
94
|
+
|
|
95
|
+
### `editor.reset(docJson)`
|
|
96
|
+
|
|
97
|
+
Replace the document with a new one, reusing the already-parsed schema.
|
|
98
|
+
Resets the version counter to zero. Throws `Error` on malformed input.
|
|
99
|
+
|
|
100
|
+
### `editor.docJson() → string`
|
|
101
|
+
|
|
102
|
+
Return the current document as a compact JSON string. Serialized entirely in
|
|
103
|
+
Rust; only the final string is passed to JavaScript — suitable for direct
|
|
104
|
+
database writes with no intermediate objects.
|
|
105
|
+
|
|
106
|
+
### `editor.version` *(number, read-only)*
|
|
107
|
+
|
|
108
|
+
Number of steps successfully applied since construction or the last `reset()`.
|
|
109
|
+
Use as a document version counter in collaborative-editing protocols.
|
|
110
|
+
|
|
111
|
+
## Credits
|
|
112
|
+
|
|
113
|
+
The underlying Rust library was originally written by
|
|
114
|
+
**Daniel Seiler** ([Xiphoseer](https://github.com/Xiphoseer), <me@dseiler.eu>),
|
|
115
|
+
who designed and implemented the document model, transform pipeline, and
|
|
116
|
+
runtime schema system. Currently maintained by
|
|
117
|
+
**Johannes Wilm** ([FidusWriter](https://fiduswriter.org),
|
|
118
|
+
<johannes@fiduswriter.org>).
|
|
119
|
+
|
|
120
|
+
ProseMirror is by **Marijn Haverbeke** and contributors —
|
|
121
|
+
see [prosemirror.net](https://prosemirror.net).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT — see [LICENSE](../LICENSE).
|
|
126
|
+
|
|
127
|
+
Copyright 2026 Johannes Wilm
|
|
128
|
+
Copyright 2020 Daniel Seiler
|
|
129
|
+
Copyright 2015–2026 Marijn Haverbeke and others
|
package/build.rs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies the compiled Rust native addon to a platform-specific .node file.
|
|
3
|
+
*
|
|
4
|
+
* The filename follows the napi-rs triple convention so that index.js can
|
|
5
|
+
* load the correct binary at runtime. Called automatically by `npm run build`
|
|
6
|
+
* after `cargo build --release`.
|
|
7
|
+
*/
|
|
8
|
+
import { cpSync } from 'fs';
|
|
9
|
+
import { platform, arch } from 'os';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
// Map OS → shared library extension
|
|
16
|
+
function libExtension() {
|
|
17
|
+
const p = platform();
|
|
18
|
+
if (p === 'win32') return '.dll';
|
|
19
|
+
if (p === 'darwin') return '.dylib';
|
|
20
|
+
return '.so';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Map Node platform+arch → napi-rs triple
|
|
24
|
+
function triple() {
|
|
25
|
+
const p = platform();
|
|
26
|
+
const a = arch();
|
|
27
|
+
if (p === 'darwin' && a === 'x64') return 'darwin-x64';
|
|
28
|
+
if (p === 'darwin' && a === 'arm64') return 'darwin-arm64';
|
|
29
|
+
if (p === 'linux' && a === 'x64') return 'linux-x64-gnu';
|
|
30
|
+
if (p === 'linux' && a === 'arm64') return 'linux-arm64-gnu';
|
|
31
|
+
if (p === 'win32' && a === 'x64') return 'win32-x64-msvc';
|
|
32
|
+
throw new Error(`Unsupported platform/arch: ${p}-${a}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const src = join(__dirname, '..', 'target', 'release', `libprosemirror_rs${libExtension()}`);
|
|
36
|
+
const dest = join(__dirname, `prosemirror-rs.${triple()}.node`);
|
|
37
|
+
|
|
38
|
+
cpSync(src, dest);
|
|
39
|
+
console.log(`Copied ${src} → ${dest}`);
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js bindings for prosemirror-rs.
|
|
3
|
+
*
|
|
4
|
+
* Provides a memory- and CPU-efficient interface to ProseMirror's document
|
|
5
|
+
* model and transform pipeline. Document state lives entirely in Rust; only
|
|
6
|
+
* JSON strings cross the JavaScript/Rust boundary.
|
|
7
|
+
*
|
|
8
|
+
* Schema caching:
|
|
9
|
+
* - The first `new Editor(schemaJson, ...)` call parses the schema and stores
|
|
10
|
+
* it in a global Rust cache keyed by the exact schema-JSON string.
|
|
11
|
+
* - All subsequent `Editor` constructions with the same string reuse the
|
|
12
|
+
* cached schema at the cost of a single `Arc` clone.
|
|
13
|
+
*/
|
|
14
|
+
export declare class Editor {
|
|
15
|
+
/**
|
|
16
|
+
* Create a new Editor.
|
|
17
|
+
*
|
|
18
|
+
* The parsed schema is cached inside Rust (keyed by the exact
|
|
19
|
+
* `schemaJson` string), so repeated construction with the same schema
|
|
20
|
+
* only parses it once.
|
|
21
|
+
*
|
|
22
|
+
* @param schemaJson ProseMirror schema specification as a JSON string.
|
|
23
|
+
* @param docJson Initial document state as a JSON string.
|
|
24
|
+
* @throws {Error} If either string is not valid JSON, or the schema /
|
|
25
|
+
* document does not conform to the ProseMirror spec.
|
|
26
|
+
*/
|
|
27
|
+
constructor(schemaJson: string, docJson: string);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply a single step to the document.
|
|
31
|
+
*
|
|
32
|
+
* @param stepJson The step as a JSON string.
|
|
33
|
+
* @returns `true` if applied successfully, `false` if the step could not
|
|
34
|
+
* be applied (document is left unchanged).
|
|
35
|
+
* @throws {Error} If `stepJson` is not valid JSON or not a recognised step type.
|
|
36
|
+
*/
|
|
37
|
+
applyStep(stepJson: string): boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply a batch of steps supplied as a single JSON array string, atomically.
|
|
41
|
+
*
|
|
42
|
+
* Preferred method when steps arrive from a network client: the string is
|
|
43
|
+
* passed directly to Rust and parsed there, so nothing touches JS's JSON
|
|
44
|
+
* machinery.
|
|
45
|
+
*
|
|
46
|
+
* All steps are parsed before any are applied, so a malformed array throws
|
|
47
|
+
* without mutating the document.
|
|
48
|
+
*
|
|
49
|
+
* The batch is fully atomic: if any step fails to apply the document is
|
|
50
|
+
* rolled back to its state before the call, leaving it completely unchanged.
|
|
51
|
+
* The version counter is likewise rolled back.
|
|
52
|
+
*
|
|
53
|
+
* @param stepsJson A JSON array of step objects, e.g.
|
|
54
|
+
* `'[{"stepType":"replace",...},...]'`.
|
|
55
|
+
* @returns `true` if every step applied successfully; `false` if any step
|
|
56
|
+
* failed (document and version are rolled back entirely).
|
|
57
|
+
* @throws {Error} If `stepsJson` is not a valid JSON array of steps.
|
|
58
|
+
*/
|
|
59
|
+
applyStepsJson(stepsJson: string): boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply a batch of steps from a JS array of JSON strings, atomically.
|
|
63
|
+
*
|
|
64
|
+
* Use this when steps are constructed or modified in JS. For steps arriving
|
|
65
|
+
* directly from a network client prefer `applyStepsJson` to avoid unnecessary
|
|
66
|
+
* JS-level JSON parsing.
|
|
67
|
+
*
|
|
68
|
+
* All steps are parsed before any are applied, so a bad JSON string throws
|
|
69
|
+
* without mutating the document.
|
|
70
|
+
*
|
|
71
|
+
* The batch is fully atomic: if any step fails to apply the document is
|
|
72
|
+
* rolled back to its state before the call, leaving it completely unchanged.
|
|
73
|
+
* The version counter is likewise rolled back.
|
|
74
|
+
*
|
|
75
|
+
* @param steps An array where each element is a JSON string for one step.
|
|
76
|
+
* @returns `true` if every step applied successfully; `false` if any step
|
|
77
|
+
* failed (document and version are rolled back entirely).
|
|
78
|
+
* @throws {Error} If any element is not valid step JSON.
|
|
79
|
+
*/
|
|
80
|
+
applySteps(steps: string[]): boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reset the document to a new state, reusing the already-parsed schema.
|
|
84
|
+
*
|
|
85
|
+
* More efficient than constructing a brand-new `Editor` when you need to
|
|
86
|
+
* restore a snapshot (e.g. after an unrecoverable conflict), because the
|
|
87
|
+
* schema is never re-parsed — only the document JSON is processed.
|
|
88
|
+
* The version counter is reset to zero.
|
|
89
|
+
*
|
|
90
|
+
* @param docJson The replacement document as a JSON string.
|
|
91
|
+
* @throws {Error} If `docJson` is not valid JSON or does not conform to
|
|
92
|
+
* the schema.
|
|
93
|
+
*/
|
|
94
|
+
reset(docJson: string): void;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialize the current document to a JSON string.
|
|
98
|
+
*
|
|
99
|
+
* Serialization happens entirely in Rust; only the resulting string is
|
|
100
|
+
* passed to JavaScript. Suitable for saving directly to a database without
|
|
101
|
+
* creating any intermediate JS objects.
|
|
102
|
+
*
|
|
103
|
+
* @returns The current document as a compact JSON string.
|
|
104
|
+
*/
|
|
105
|
+
docJson(): string;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Number of steps successfully applied since construction or last `reset()`.
|
|
109
|
+
*
|
|
110
|
+
* Use as a document version counter in collaborative-editing protocols.
|
|
111
|
+
*/
|
|
112
|
+
readonly version: number;
|
|
113
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync } = require('fs');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
|
|
6
|
+
const { platform, arch } = process;
|
|
7
|
+
|
|
8
|
+
// Map Node.js platform + arch → napi-rs platform triple.
|
|
9
|
+
// Every triple listed here must have a corresponding build in the CI matrix.
|
|
10
|
+
const TRIPLES = {
|
|
11
|
+
'darwin-x64': 'prosemirror-rs.darwin-x64.node',
|
|
12
|
+
'darwin-arm64': 'prosemirror-rs.darwin-arm64.node',
|
|
13
|
+
'linux-x64': 'prosemirror-rs.linux-x64-gnu.node',
|
|
14
|
+
'linux-arm64': 'prosemirror-rs.linux-arm64-gnu.node',
|
|
15
|
+
'win32-x64': 'prosemirror-rs.win32-x64-msvc.node',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const key = `${platform}-${arch}`;
|
|
19
|
+
const filename = TRIPLES[key];
|
|
20
|
+
|
|
21
|
+
if (!filename) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Unsupported platform/architecture: ${key}. ` +
|
|
24
|
+
`prosemirror-rs distributes prebuilt binaries for ` +
|
|
25
|
+
`darwin-x64, darwin-arm64, linux-x64, linux-arm64, and win32-x64.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const localPath = join(__dirname, filename);
|
|
30
|
+
if (!existsSync(localPath)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`No native binary found for ${key} at ${filename}. ` +
|
|
33
|
+
`Try reinstalling the package, or build from source with \`npm run build\`.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = require(localPath);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prosemirror-rs",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Node.js bindings for prosemirror-rs: a Rust implementation of ProseMirror's document model and transform pipeline",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "cargo build --release && node copy-artifact.mjs",
|
|
9
|
+
"test": "npm run build && node --test tests/editor.test.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"prosemirror",
|
|
13
|
+
"collaborative-editing",
|
|
14
|
+
"editor",
|
|
15
|
+
"document",
|
|
16
|
+
"napi",
|
|
17
|
+
"rust"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/fiduswriter/prosemirror-rs"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/fiduswriter/prosemirror-rs#readme",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
Binary file
|
|
Binary file
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::sync::{Arc, Mutex, OnceLock};
|
|
3
|
+
|
|
4
|
+
use napi::bindgen_prelude::*;
|
|
5
|
+
use napi_derive::napi;
|
|
6
|
+
use prosemirror::dynamic::types::Dyn;
|
|
7
|
+
use prosemirror::dynamic::{DynamicNode, DynamicSchema};
|
|
8
|
+
use prosemirror::transform::Step;
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Schema cache
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/// Global cache mapping raw schema-JSON strings → parsed schemas.
|
|
15
|
+
///
|
|
16
|
+
/// Keyed by the exact bytes of the JSON string, so two textually-identical
|
|
17
|
+
/// strings always hit the same entry. Parsing a schema is the expensive
|
|
18
|
+
/// part of Editor construction; once cached, every subsequent `Editor::new`
|
|
19
|
+
/// for the same schema is just an `Arc` clone + a document parse.
|
|
20
|
+
static SCHEMA_CACHE: OnceLock<Mutex<HashMap<String, Arc<DynamicSchema>>>> = OnceLock::new();
|
|
21
|
+
|
|
22
|
+
fn get_or_create_schema(schema_json: &str) -> napi::Result<Arc<DynamicSchema>> {
|
|
23
|
+
let cache = SCHEMA_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
|
24
|
+
// Recover from a poisoned lock (would only happen if a previous thread panicked mid-insert).
|
|
25
|
+
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
26
|
+
|
|
27
|
+
if let Some(existing) = guard.get(schema_json) {
|
|
28
|
+
return Ok(Arc::clone(existing));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let schema_val: serde_json::Value = serde_json::from_str(schema_json)
|
|
32
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid schema JSON: {e}")))?;
|
|
33
|
+
let schema = DynamicSchema::from_json(&schema_val)
|
|
34
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid schema: {e}")))?;
|
|
35
|
+
|
|
36
|
+
let arc = Arc::new(schema);
|
|
37
|
+
guard.insert(schema_json.to_owned(), Arc::clone(&arc));
|
|
38
|
+
Ok(arc)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Editor
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/// A stateful ProseMirror document editor backed by Rust.
|
|
46
|
+
///
|
|
47
|
+
/// The schema and document state live entirely in Rust memory. Only JSON
|
|
48
|
+
/// strings cross the JavaScript/Rust boundary, keeping data-transfer overhead
|
|
49
|
+
/// to the absolute minimum for each operation:
|
|
50
|
+
///
|
|
51
|
+
/// * Steps arrive as a JSON string → parsed in Rust → applied in Rust.
|
|
52
|
+
/// * The document is serialized in Rust → returned as a plain JS `string`.
|
|
53
|
+
///
|
|
54
|
+
/// The parsed schema is automatically cached inside Rust, keyed by the exact
|
|
55
|
+
/// schema-JSON string. Constructing many `Editor` objects that share the
|
|
56
|
+
/// same schema therefore only pays the parse cost once.
|
|
57
|
+
#[napi]
|
|
58
|
+
pub struct Editor {
|
|
59
|
+
schema: Arc<DynamicSchema>,
|
|
60
|
+
doc: DynamicNode,
|
|
61
|
+
version: usize,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[napi]
|
|
65
|
+
impl Editor {
|
|
66
|
+
/// Create a new Editor.
|
|
67
|
+
///
|
|
68
|
+
/// The parsed schema is cached inside Rust (keyed by the exact
|
|
69
|
+
/// `schemaJson` string), so repeated construction with the same schema
|
|
70
|
+
/// only parses it once.
|
|
71
|
+
///
|
|
72
|
+
/// @param schemaJson ProseMirror schema specification as a JSON string.
|
|
73
|
+
/// @param docJson Initial document state as a JSON string.
|
|
74
|
+
/// @throws {Error} If either string is not valid JSON, or the schema /
|
|
75
|
+
/// document does not conform to the ProseMirror spec.
|
|
76
|
+
#[napi(constructor)]
|
|
77
|
+
pub fn new(schema_json: String, doc_json: String) -> napi::Result<Self> {
|
|
78
|
+
let schema = get_or_create_schema(&schema_json)?;
|
|
79
|
+
|
|
80
|
+
let doc_val: serde_json::Value = serde_json::from_str(&doc_json)
|
|
81
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document JSON: {e}")))?;
|
|
82
|
+
let doc = schema
|
|
83
|
+
.node_from_json(&doc_val)
|
|
84
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document: {e}")))?;
|
|
85
|
+
|
|
86
|
+
Ok(Editor { schema, doc, version: 0 })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Apply a single step to the document.
|
|
90
|
+
///
|
|
91
|
+
/// @param stepJson The step as a JSON string.
|
|
92
|
+
/// @returns `true` if applied successfully, `false` if the step could not
|
|
93
|
+
/// be applied (document is left unchanged).
|
|
94
|
+
/// @throws {Error} If `stepJson` is not valid JSON or not a recognised step type.
|
|
95
|
+
#[napi]
|
|
96
|
+
pub fn apply_step(&mut self, step_json: String) -> napi::Result<bool> {
|
|
97
|
+
let result = {
|
|
98
|
+
let schema = &self.schema;
|
|
99
|
+
let doc = &self.doc;
|
|
100
|
+
schema.with_types(|| -> napi::Result<Option<DynamicNode>> {
|
|
101
|
+
let step: Step<Dyn> = serde_json::from_str(&step_json)
|
|
102
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid step JSON: {e}")))?;
|
|
103
|
+
Ok(step.apply(doc).ok())
|
|
104
|
+
})
|
|
105
|
+
}?;
|
|
106
|
+
|
|
107
|
+
if let Some(new_doc) = result {
|
|
108
|
+
self.doc = new_doc;
|
|
109
|
+
self.version += 1;
|
|
110
|
+
Ok(true)
|
|
111
|
+
} else {
|
|
112
|
+
Ok(false)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Apply a batch of steps supplied as a single JSON array string, atomically.
|
|
117
|
+
///
|
|
118
|
+
/// **This is the preferred method when steps arrive from a network client.**
|
|
119
|
+
/// The entire string is handed to Rust and parsed there in one pass — no
|
|
120
|
+
/// JS JSON machinery is involved, and no intermediate JS objects are created.
|
|
121
|
+
///
|
|
122
|
+
/// All steps are parsed before any are applied, so a malformed JSON array
|
|
123
|
+
/// throws without mutating the document.
|
|
124
|
+
///
|
|
125
|
+
/// The batch is fully atomic: if any step fails to apply the document is
|
|
126
|
+
/// rolled back to its state before the call, leaving it completely
|
|
127
|
+
/// unchanged. The version counter is likewise rolled back.
|
|
128
|
+
///
|
|
129
|
+
/// @param stepsJson A JSON array of step objects, e.g.
|
|
130
|
+
/// `'[{"stepType":"replace",...},...]'`.
|
|
131
|
+
/// @returns `true` if every step applied successfully; `false` if any
|
|
132
|
+
/// step failed (document and version are rolled back entirely).
|
|
133
|
+
/// @throws {Error} If `stepsJson` is not a valid JSON array of steps.
|
|
134
|
+
#[napi]
|
|
135
|
+
pub fn apply_steps_json(&mut self, steps_json: String) -> napi::Result<bool> {
|
|
136
|
+
// Phase 1: parse the whole array before touching the document.
|
|
137
|
+
let steps: Vec<Step<Dyn>> = {
|
|
138
|
+
let schema = &self.schema;
|
|
139
|
+
schema.with_types(|| {
|
|
140
|
+
serde_json::from_str(&steps_json)
|
|
141
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid steps JSON: {e}")))
|
|
142
|
+
})?
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// A snapshot is only needed when there are at least two steps: with a
|
|
146
|
+
// single step the document is either untouched (failure) or cleanly
|
|
147
|
+
// advanced (success), so no previously-committed state can need rolling back.
|
|
148
|
+
let mut snapshot: Option<(DynamicNode, usize)> = if steps.len() > 1 {
|
|
149
|
+
Some((self.doc.clone(), self.version))
|
|
150
|
+
} else {
|
|
151
|
+
None
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Phase 2: apply each step; roll back and return false on the first failure.
|
|
155
|
+
for step in steps {
|
|
156
|
+
let result = {
|
|
157
|
+
let schema = &self.schema;
|
|
158
|
+
let doc = &self.doc;
|
|
159
|
+
schema.with_types(|| step.apply(doc))
|
|
160
|
+
};
|
|
161
|
+
match result {
|
|
162
|
+
Ok(new_doc) => {
|
|
163
|
+
self.doc = new_doc;
|
|
164
|
+
self.version += 1;
|
|
165
|
+
}
|
|
166
|
+
Err(_) => {
|
|
167
|
+
// Option::take moves the contents out via &mut self rather
|
|
168
|
+
// than an unconditional move, which the borrow checker would
|
|
169
|
+
// reject inside a loop body.
|
|
170
|
+
if let Some((snap_doc, snap_version)) = snapshot.take() {
|
|
171
|
+
self.doc = snap_doc;
|
|
172
|
+
self.version = snap_version;
|
|
173
|
+
}
|
|
174
|
+
return Ok(false);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
Ok(true)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Apply a batch of steps from a JS array of JSON strings, atomically.
|
|
182
|
+
///
|
|
183
|
+
/// Use this when steps are constructed or modified in JS (e.g.
|
|
184
|
+
/// programmatically building a step object and calling `JSON.stringify`).
|
|
185
|
+
/// For steps that arrive directly from a network client prefer
|
|
186
|
+
/// `applyStepsJson` to avoid unnecessary JS-level parsing.
|
|
187
|
+
///
|
|
188
|
+
/// All steps are parsed before any are applied, so a bad JSON string
|
|
189
|
+
/// throws without mutating the document.
|
|
190
|
+
///
|
|
191
|
+
/// The batch is fully atomic: if any step fails to apply the document is
|
|
192
|
+
/// rolled back to its state before the call, leaving it completely
|
|
193
|
+
/// unchanged. The version counter is likewise rolled back.
|
|
194
|
+
///
|
|
195
|
+
/// @param steps An array where each element is a JSON string for one step.
|
|
196
|
+
/// @returns `true` if every step applied successfully; `false` if any
|
|
197
|
+
/// step failed (document and version are rolled back entirely).
|
|
198
|
+
/// @throws {Error} If any element is not valid step JSON.
|
|
199
|
+
#[napi]
|
|
200
|
+
pub fn apply_steps(&mut self, steps: Vec<String>) -> napi::Result<bool> {
|
|
201
|
+
// Parse all steps up-front so that a bad step throws
|
|
202
|
+
// before any mutation takes place.
|
|
203
|
+
let parsed: Vec<Step<Dyn>> = {
|
|
204
|
+
let schema = &self.schema;
|
|
205
|
+
schema.with_types(|| {
|
|
206
|
+
steps
|
|
207
|
+
.iter()
|
|
208
|
+
.map(|s| {
|
|
209
|
+
serde_json::from_str::<Step<Dyn>>(s)
|
|
210
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid step JSON: {e}")))
|
|
211
|
+
})
|
|
212
|
+
.collect::<napi::Result<Vec<_>>>()
|
|
213
|
+
})?
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Snapshot the pre-batch state so we can roll back cheaply.
|
|
217
|
+
let snapshot = self.doc.clone();
|
|
218
|
+
let snapshot_version = self.version;
|
|
219
|
+
|
|
220
|
+
for step in parsed {
|
|
221
|
+
let result = {
|
|
222
|
+
let schema = &self.schema;
|
|
223
|
+
let doc = &self.doc;
|
|
224
|
+
schema.with_types(|| step.apply(doc))
|
|
225
|
+
};
|
|
226
|
+
match result {
|
|
227
|
+
Ok(new_doc) => {
|
|
228
|
+
self.doc = new_doc;
|
|
229
|
+
self.version += 1;
|
|
230
|
+
}
|
|
231
|
+
Err(_) => {
|
|
232
|
+
self.doc = snapshot;
|
|
233
|
+
self.version = snapshot_version;
|
|
234
|
+
return Ok(false);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
Ok(true)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Reset the document to a new state, reusing the already-parsed schema.
|
|
242
|
+
///
|
|
243
|
+
/// This is more efficient than constructing a brand-new `Editor` when
|
|
244
|
+
/// you need to restore a snapshot (e.g. after an unrecoverable conflict),
|
|
245
|
+
/// because the schema is never re-parsed — only the document JSON is
|
|
246
|
+
/// processed. The version counter is reset to zero.
|
|
247
|
+
///
|
|
248
|
+
/// @param docJson The replacement document as a JSON string.
|
|
249
|
+
/// @throws {Error} If `docJson` is not valid JSON or does not conform to
|
|
250
|
+
/// the schema.
|
|
251
|
+
#[napi]
|
|
252
|
+
pub fn reset(&mut self, doc_json: String) -> napi::Result<()> {
|
|
253
|
+
let doc_val: serde_json::Value = serde_json::from_str(&doc_json)
|
|
254
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document JSON: {e}")))?;
|
|
255
|
+
let doc = self
|
|
256
|
+
.schema
|
|
257
|
+
.node_from_json(&doc_val)
|
|
258
|
+
.map_err(|e| napi::Error::new(Status::InvalidArg, format!("Invalid document: {e}")))?;
|
|
259
|
+
self.doc = doc;
|
|
260
|
+
self.version = 0;
|
|
261
|
+
Ok(())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// Serialize the current document to a JSON string.
|
|
265
|
+
///
|
|
266
|
+
/// Serialization happens entirely in Rust; only the resulting string is
|
|
267
|
+
/// passed to JavaScript. This makes the method suitable for saving the
|
|
268
|
+
/// document directly to a database without creating any intermediate
|
|
269
|
+
/// JS objects.
|
|
270
|
+
///
|
|
271
|
+
/// @returns The document as a compact JSON string.
|
|
272
|
+
#[napi]
|
|
273
|
+
pub fn doc_json(&self) -> napi::Result<String> {
|
|
274
|
+
serde_json::to_string(&self.doc)
|
|
275
|
+
.map_err(|e| napi::Error::new(Status::GenericFailure, format!("Serialization error: {e}")))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Number of steps successfully applied since construction (or last `reset()`).
|
|
279
|
+
///
|
|
280
|
+
/// Use as a document version counter in collaborative-editing protocols.
|
|
281
|
+
#[napi(getter)]
|
|
282
|
+
pub fn version(&self) -> u32 {
|
|
283
|
+
self.version as u32
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the prosemirror-rs Node.js bindings.
|
|
4
|
+
*
|
|
5
|
+
* Covers: construction, docJson, applyStep, applyStepsJson, applySteps,
|
|
6
|
+
* reset, version, error handling, atomicity, and schema caching.
|
|
7
|
+
*
|
|
8
|
+
* Run via: npm test (from the node/ directory)
|
|
9
|
+
* Or: node --test tests/ (after building the native addon)
|
|
10
|
+
*/
|
|
11
|
+
const { test } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { Editor } = require('../index.js');
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Fixtures
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const SCHEMA = JSON.stringify({
|
|
20
|
+
nodes: {
|
|
21
|
+
doc: { content: 'paragraph+' },
|
|
22
|
+
paragraph: { content: 'text*', group: 'block' },
|
|
23
|
+
text: { group: 'inline' },
|
|
24
|
+
},
|
|
25
|
+
marks: { strong: {}, em: {} },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const DOC = JSON.stringify({
|
|
29
|
+
type: 'doc',
|
|
30
|
+
content: [{ type: 'paragraph', content: [
|
|
31
|
+
{ type: 'text', text: 'hello' },
|
|
32
|
+
]}],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Replace step: insert 'x' at position 2 (inside first paragraph, after 'h').
|
|
36
|
+
// Stays valid across repeated inserts because the paragraph grows each time.
|
|
37
|
+
const INSERT_STEP = JSON.stringify({
|
|
38
|
+
stepType: 'replace',
|
|
39
|
+
from: 2,
|
|
40
|
+
to: 2,
|
|
41
|
+
slice: { content: [{ type: 'text', text: 'x' }] },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// A step that points way past the end of the document → always fails to apply.
|
|
45
|
+
const BAD_POSITION_STEP = JSON.stringify({
|
|
46
|
+
stepType: 'replace',
|
|
47
|
+
from: 9999,
|
|
48
|
+
to: 9999,
|
|
49
|
+
slice: { content: [{ type: 'text', text: 'x' }] },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Construction
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
test('creates an editor with initial version 0', () => {
|
|
57
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
58
|
+
assert.equal(editor.version, 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('constructor throws on invalid schema JSON', () => {
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => new Editor('not-valid-json', DOC),
|
|
64
|
+
/Invalid schema JSON/,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('constructor throws on invalid doc JSON', () => {
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => new Editor(SCHEMA, 'not-valid-json'),
|
|
71
|
+
/Invalid document JSON/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('constructor throws when doc JSON is not a node object', () => {
|
|
76
|
+
// A JSON array cannot be deserialized as a document node.
|
|
77
|
+
assert.throws(
|
|
78
|
+
() => new Editor(SCHEMA, JSON.stringify([])),
|
|
79
|
+
/Invalid document/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// docJson
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('docJson returns a valid JSON string containing the initial text', () => {
|
|
88
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
89
|
+
const raw = editor.docJson();
|
|
90
|
+
assert.equal(typeof raw, 'string');
|
|
91
|
+
const doc = JSON.parse(raw);
|
|
92
|
+
assert.equal(doc.type, 'doc');
|
|
93
|
+
assert.ok(raw.includes('hello'), `Expected "hello" in: ${raw}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// applyStep
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
test('applyStep returns true on success and increments version', () => {
|
|
101
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
102
|
+
const ok = editor.applyStep(INSERT_STEP);
|
|
103
|
+
assert.equal(ok, true);
|
|
104
|
+
assert.equal(editor.version, 1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('applyStep mutates the document', () => {
|
|
108
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
109
|
+
editor.applyStep(INSERT_STEP);
|
|
110
|
+
const doc = JSON.parse(editor.docJson());
|
|
111
|
+
// The inserted 'x' should appear in the serialised document.
|
|
112
|
+
assert.ok(JSON.stringify(doc).includes('x'));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('applyStep accumulates version across multiple calls', () => {
|
|
116
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
117
|
+
editor.applyStep(INSERT_STEP);
|
|
118
|
+
editor.applyStep(INSERT_STEP);
|
|
119
|
+
editor.applyStep(INSERT_STEP);
|
|
120
|
+
assert.equal(editor.version, 3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('applyStep returns false when the step cannot be applied', () => {
|
|
124
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
125
|
+
const ok = editor.applyStep(BAD_POSITION_STEP);
|
|
126
|
+
assert.equal(ok, false);
|
|
127
|
+
assert.equal(editor.version, 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('applyStep leaves the document unchanged on failure', () => {
|
|
131
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
132
|
+
const before = editor.docJson();
|
|
133
|
+
editor.applyStep(BAD_POSITION_STEP);
|
|
134
|
+
assert.equal(editor.docJson(), before);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('applyStep throws on invalid JSON', () => {
|
|
138
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
139
|
+
assert.throws(() => editor.applyStep('not-json'), /Invalid step JSON/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// applyStepsJson
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
test('applyStepsJson applies all steps from a JSON array string', () => {
|
|
147
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
148
|
+
const stepsJson = JSON.stringify([
|
|
149
|
+
JSON.parse(INSERT_STEP),
|
|
150
|
+
JSON.parse(INSERT_STEP),
|
|
151
|
+
]);
|
|
152
|
+
const ok = editor.applyStepsJson(stepsJson);
|
|
153
|
+
assert.equal(ok, true);
|
|
154
|
+
assert.equal(editor.version, 2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('applyStepsJson is atomic: rolls back version on step failure', () => {
|
|
158
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
159
|
+
const stepsJson = JSON.stringify([
|
|
160
|
+
JSON.parse(INSERT_STEP),
|
|
161
|
+
JSON.parse(BAD_POSITION_STEP),
|
|
162
|
+
]);
|
|
163
|
+
const ok = editor.applyStepsJson(stepsJson);
|
|
164
|
+
assert.equal(ok, false);
|
|
165
|
+
assert.equal(editor.version, 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('applyStepsJson is atomic: rolls back document on step failure', () => {
|
|
169
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
170
|
+
const before = editor.docJson();
|
|
171
|
+
const stepsJson = JSON.stringify([
|
|
172
|
+
JSON.parse(INSERT_STEP),
|
|
173
|
+
JSON.parse(BAD_POSITION_STEP),
|
|
174
|
+
]);
|
|
175
|
+
editor.applyStepsJson(stepsJson);
|
|
176
|
+
assert.equal(editor.docJson(), before);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('applyStepsJson succeeds for an empty array', () => {
|
|
180
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
181
|
+
const ok = editor.applyStepsJson('[]');
|
|
182
|
+
assert.equal(ok, true);
|
|
183
|
+
assert.equal(editor.version, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('applyStepsJson throws on invalid JSON', () => {
|
|
187
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
188
|
+
assert.throws(() => editor.applyStepsJson('not-json'), /Invalid steps JSON/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// applySteps
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
test('applySteps applies all steps from an array of JSON strings', () => {
|
|
196
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
197
|
+
const ok = editor.applySteps([INSERT_STEP, INSERT_STEP]);
|
|
198
|
+
assert.equal(ok, true);
|
|
199
|
+
assert.equal(editor.version, 2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('applySteps is atomic: rolls back version on step failure', () => {
|
|
203
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
204
|
+
const ok = editor.applySteps([INSERT_STEP, BAD_POSITION_STEP]);
|
|
205
|
+
assert.equal(ok, false);
|
|
206
|
+
assert.equal(editor.version, 0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('applySteps is atomic: rolls back document on step failure', () => {
|
|
210
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
211
|
+
const before = editor.docJson();
|
|
212
|
+
editor.applySteps([INSERT_STEP, BAD_POSITION_STEP]);
|
|
213
|
+
assert.equal(editor.docJson(), before);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('applySteps succeeds for an empty array', () => {
|
|
217
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
218
|
+
const ok = editor.applySteps([]);
|
|
219
|
+
assert.equal(ok, true);
|
|
220
|
+
assert.equal(editor.version, 0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('applySteps throws on an invalid step JSON string', () => {
|
|
224
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
225
|
+
assert.throws(() => editor.applySteps(['not-json']), /Invalid step JSON/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('applySteps throws before mutating when a later element is invalid JSON', () => {
|
|
229
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
230
|
+
const before = editor.docJson();
|
|
231
|
+
// All steps are parsed first, so the bad second element prevents any mutation.
|
|
232
|
+
assert.throws(() => editor.applySteps([INSERT_STEP, 'not-json']));
|
|
233
|
+
assert.equal(editor.docJson(), before);
|
|
234
|
+
assert.equal(editor.version, 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// reset
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
test('reset restores version to 0', () => {
|
|
242
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
243
|
+
editor.applyStep(INSERT_STEP);
|
|
244
|
+
editor.applyStep(INSERT_STEP);
|
|
245
|
+
assert.equal(editor.version, 2);
|
|
246
|
+
|
|
247
|
+
editor.reset(DOC);
|
|
248
|
+
assert.equal(editor.version, 0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('reset replaces the document', () => {
|
|
252
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
253
|
+
editor.applyStep(INSERT_STEP); // mutate
|
|
254
|
+
|
|
255
|
+
editor.reset(DOC);
|
|
256
|
+
// After reset the document should match the original (no 'x' inserted).
|
|
257
|
+
const doc = JSON.parse(editor.docJson());
|
|
258
|
+
assert.equal(doc.type, 'doc');
|
|
259
|
+
assert.ok(!JSON.stringify(doc.content).startsWith('[{"type":"paragraph","content":[{"type":"text","text":"x'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('reset allows applying steps again after rollback', () => {
|
|
263
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
264
|
+
editor.applyStep(INSERT_STEP);
|
|
265
|
+
editor.reset(DOC);
|
|
266
|
+
|
|
267
|
+
const ok = editor.applyStep(INSERT_STEP);
|
|
268
|
+
assert.equal(ok, true);
|
|
269
|
+
assert.equal(editor.version, 1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('reset throws on invalid JSON', () => {
|
|
273
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
274
|
+
assert.throws(() => editor.reset('not-json'), /Invalid document JSON/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('reset throws when new doc JSON is not a node object', () => {
|
|
278
|
+
const editor = new Editor(SCHEMA, DOC);
|
|
279
|
+
// A JSON array cannot be deserialized as a document node.
|
|
280
|
+
assert.throws(() => editor.reset(JSON.stringify([])), /Invalid document/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Schema caching
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
test('multiple editors with the same schema all work correctly', () => {
|
|
288
|
+
const editors = Array.from({ length: 5 }, () => new Editor(SCHEMA, DOC));
|
|
289
|
+
for (const editor of editors) {
|
|
290
|
+
assert.equal(editor.version, 0);
|
|
291
|
+
const doc = JSON.parse(editor.docJson());
|
|
292
|
+
assert.equal(doc.type, 'doc');
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('editors with the same schema do not share document state', () => {
|
|
297
|
+
const editorA = new Editor(SCHEMA, DOC);
|
|
298
|
+
const editorB = new Editor(SCHEMA, DOC);
|
|
299
|
+
|
|
300
|
+
editorA.applyStep(INSERT_STEP);
|
|
301
|
+
assert.equal(editorA.version, 1);
|
|
302
|
+
assert.equal(editorB.version, 0); // B is untouched
|
|
303
|
+
});
|