Update Quill editor to fix consistency issues (#152)

* chore: rm logs

* fix: richtext delta event

* fix(quill): move apply delta to editor part

* fix: consistency issue with Quill
- Quill assumes there is always a \n at the end of the line
- Quill assumes \n does not merge with other delta item
- Quill assumes there is no inline format inside {insert: '\n'} delta
  item
This commit is contained in:
Zixuan Chen 2023-11-04 20:03:15 +08:00 committed by GitHub
parent 5a9baebba0
commit b1895bba26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 140 additions and 87 deletions

View file

@ -120,7 +120,7 @@ impl ToJson for StyleMeta {
fn to_json_value(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (key, style) in self.iter() {
let value = if matches!(style.data, LoroValue::Null | LoroValue::Bool(_)) {
let value = if !key.contains_id() {
serde_json::to_value(&style.data).unwrap()
} else {
let mut value = serde_json::Map::new();

View file

@ -76,7 +76,6 @@ impl DiffCalculator {
after: &crate::VersionVector,
after_frontiers: Option<&Frontiers>,
) -> Vec<InternalContainerDiff> {
debug_dbg!(&before, &after, &oplog);
if self.has_all {
let include_before = self.last_vv.includes_vv(before);
let include_after = self.last_vv.includes_vv(after);

View file

@ -4,7 +4,7 @@ use fxhash::{FxHashMap, FxHashSet};
use loro_common::{HasCounterSpan, HasIdSpan, HasLamportSpan, TreeID};
use rle::{HasLength, RleVec, Sliceable};
use serde_columnar::{columnar, iter_from_bytes, to_vec};
use std::{borrow::Cow, cmp::Ordering, ops::Deref, sync::Arc};
use std::{borrow::Cow, ops::Deref, sync::Arc};
use zerovec::{vecs::Index32, VarZeroVec};
use crate::{

View file

@ -198,7 +198,6 @@ impl OpLog {
/// This is the only place to update the `OpLog.changes`
pub(crate) fn insert_new_change(&mut self, mut change: Change, _: EnsureChangeDepsAreAtTheEnd) {
debug_log::debug_log!("importing {} ", change.id);
let entry = self.changes.entry(change.id.peer).or_default();
match entry.last_mut() {
Some(last) => {

View file

@ -503,11 +503,10 @@ fn change_to_diff(
},
)
}
Diff::Text(
Delta::new()
let diff = Delta::new()
.retain(start as usize)
.retain_with_meta((end - start) as usize, meta),
)
.retain_with_meta((end - start) as usize, meta);
Diff::Text(diff)
}
EventHint::InsertText { pos, styles, .. } => {
let slice = op.content.as_list().unwrap().as_insert_text().unwrap().0;

View file

@ -647,9 +647,10 @@ pub mod wasm {
impl From<StyleMeta> for JsValue {
fn from(value: StyleMeta) -> Self {
// TODO: refactor: should we extract the common code of ToJson and ToJsValue
let obj = Object::new();
for (key, style) in value.iter() {
let value = if matches!(style.data, LoroValue::Null | LoroValue::Bool(_)) {
let value = if !key.contains_id() {
JsValue::from(style.data)
} else {
let value = Object::new();

View file

@ -305,3 +305,27 @@ fn case9() {
]),
);
}
#[test]
fn insert_after_link() {
let doc_a = init("The fox jumped.");
let doc_b = clone(&doc_a, 2);
mark(&doc_a, 0..3, Kind::Link);
merge(&doc_a, &doc_b);
insert(&doc_a, 3, "a");
merge(&doc_a, &doc_b);
expect_result(
&doc_a,
serde_json::json!([
{"insert": "The", "attributes": {"link": true}},
{"insert": "a fox jumped."},
]),
);
expect_result(
&doc_b,
serde_json::json!([
{"insert": "The", "attributes": {"link": true}},
{"insert": "a fox jumped."},
]),
);
}

View file

@ -17,7 +17,7 @@
"devDependencies": {
"@types/quill": "^1.3.7",
"@vitejs/plugin-vue": "^4.1.0",
"typescript": "^5.0.2",
"typescript": "^5.2.0",
"vite": "^4.3.2",
"vite-plugin-top-level-await": "^1.3.0",
"vite-plugin-wasm": "^3.2.2",

View file

@ -70,7 +70,6 @@
versionObj[key.toString()] = value;
}
const versionStr = JSON.stringify(versionObj, null, 2);
console.log(map, versionStr);
editorVersions[this_index] = versionStr;
});
});

View file

@ -3,12 +3,21 @@
*/
import { Delta, Loro, LoroText, setDebug } from "loro-crdt";
import Quill, { DeltaStatic, Sources } from "quill";
import Quill, { DeltaOperation, DeltaStatic, Sources } from "quill";
// @ts-ignore
import isEqual from "is-equal";
// setDebug("*");
const Delta = Quill.import("delta");
// setDebug("*");
const EXPAND_CONFIG: { [key in string]: 'before' | 'after' | 'both' | 'none' } = {
bold: 'after',
italic: 'after',
underline: 'after',
link: 'none',
header: 'none',
}
export class QuillBinding {
private richtext: LoroText;
@ -19,15 +28,6 @@ export class QuillBinding {
this.quill = quill;
this.richtext = doc.getText("text");
this.richtext.subscribe(doc, (event) => {
// Promise.resolve().then(() => {
// let delta: DeltaType = new Delta(
// richtext.getAnnSpans(),
// );
// quill.setContents(
// delta,
// "this" as any,
// );
// });
Promise.resolve().then(() => {
if (!event.local && event.diff.type == "text") {
console.log(doc.peerId, "CRDT_EVENT", event);
@ -57,6 +57,7 @@ export class QuillBinding {
delta.push(d);
index += length;
}
quill.updateContents(new Delta(delta), "this" as any);
const a = this.richtext.toDelta();
const b = this.quill.getContents().ops;
@ -89,17 +90,63 @@ export class QuillBinding {
// update content
const ops = delta.ops;
if (origin !== ("this" as any)) {
this.richtext.applyDelta(ops);
this.applyDelta(ops);
const a = this.richtext.toDelta();
const b = this.quill.getContents().ops;
console.log(this.doc.peerId, "COMPARE AFTER QUILL_EVENT");
assertEqual(a, b as any);
console.log(this.doc.peerId, "CHECK_MATCH", { delta }, a, b);
console.log("SIZE", this.doc.exportFrom().length);
this.doc.debugHistory();
}
}
};
applyDelta(delta: DeltaOperation[]) {
let index = 0;
for (const op of delta) {
if (op.retain != null) {
let end = index + op.retain;
if (op.attributes) {
if (index == this.richtext.length) {
this.richtext.insert(index, "\n");
}
for (const key of Object.keys(op.attributes)) {
let value = op.attributes[key];
if (value == null) {
this.richtext.unmark({ start: index, end, expand: EXPAND_CONFIG[key] }, key)
} else {
this.richtext.mark({ start: index, end, expand: EXPAND_CONFIG[key] }, key, value,)
}
}
}
index += op.retain;
} else if (op.insert != null) {
if (typeof op.insert == "string") {
let end = index + op.insert.length;
this.richtext.insert(index, op.insert);
if (op.attributes) {
for (const key of Object.keys(op.attributes)) {
let value = op.attributes[key];
if (value == null) {
this.richtext.unmark({ start: index, end, expand: EXPAND_CONFIG[key] }, key)
} else {
this.richtext.mark({ start: index, end, expand: EXPAND_CONFIG[key] }, key, value)
}
}
}
index = end;
} else {
throw new Error("Not implemented")
}
} else if (op.delete != null) {
this.richtext.delete(index, op.delete);
} else {
throw new Error("Unreachable")
}
}
this.doc.commit();
}
destroy() {
// TODO: unobserve
this.quill.off("editor-change", this.quillObserver);
@ -115,7 +162,9 @@ function assertEqual(a: Delta<string>[], b: Delta<string>[]): boolean {
}
/**
* Removes the pending '\n's if it has no attributes.
* Removes the ending '\n's if it has no attributes.
*
* Extract line-break to a single op
*
* Normalize attributes field
*/
@ -151,8 +200,37 @@ export const normQuillDelta = (delta: Delta<string>[]) => {
if (ins.length === 0) {
delta.pop();
}
return delta;
}
}
return delta;
const ans: Delta<string>[] = []
for (const span of delta) {
if (span.insert != null && span.insert.includes("\n")) {
const lines = span.insert.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length !== 0) {
ans.push({ ...span, insert: line });
}
if (i < lines.length - 1) {
const attr = { ...span.attributes };
const v: Delta<string> = { insert: "\n" };
for (const style of ['bold', 'link', 'italic', 'underline']) {
if (attr && attr[style]) {
delete attr[style];
}
}
if (Object.keys(attr || {}).length > 0) {
v.attributes = attr;
}
ans.push(v);
}
}
} else {
ans.push(span);
}
}
return ans;
};

View file

@ -39,8 +39,8 @@ importers:
specifier: ^4.1.0
version: registry.npmmirror.com/@vitejs/plugin-vue@4.1.0(vite@4.3.2)(vue@3.2.47)
typescript:
specifier: ^5.0.2
version: registry.npmmirror.com/typescript@5.0.3
specifier: ^5.2.0
version: registry.npmmirror.com/typescript@5.2.2
vite:
specifier: ^4.3.2
version: registry.npmmirror.com/vite@4.3.2
@ -52,7 +52,7 @@ importers:
version: registry.npmmirror.com/vite-plugin-wasm@3.2.2(vite@4.3.2)
vue-tsc:
specifier: ^1.4.2
version: registry.npmmirror.com/vue-tsc@1.4.2(typescript@5.0.3)
version: registry.npmmirror.com/vue-tsc@1.4.2(typescript@5.2.2)
loro-js:
dependencies:
@ -1507,7 +1507,7 @@ packages:
muggle-string: registry.npmmirror.com/muggle-string@0.2.2
dev: true
registry.npmmirror.com/@volar/typescript@1.4.0(typescript@5.0.3):
registry.npmmirror.com/@volar/typescript@1.4.0(typescript@5.2.2):
resolution: {integrity: sha512-r6OMHj/LeS86iQy3LEjjS+qpmHr9I7BiH8gAwp9WEJP76FHlMPi/EPDQxhf3VcMQ/w6Pi5aBczqI+I3akr9t4g==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@volar/typescript/-/typescript-1.4.0.tgz}
id: registry.npmmirror.com/@volar/typescript/1.4.0
name: '@volar/typescript'
@ -1516,7 +1516,7 @@ packages:
typescript: '*'
dependencies:
'@volar/language-core': registry.npmmirror.com/@volar/language-core@1.4.0
typescript: registry.npmmirror.com/typescript@5.0.3
typescript: registry.npmmirror.com/typescript@5.2.2
dev: true
registry.npmmirror.com/@volar/vue-language-core@1.4.2:
@ -1535,14 +1535,14 @@ packages:
vue-template-compiler: registry.npmmirror.com/vue-template-compiler@2.7.15
dev: true
registry.npmmirror.com/@volar/vue-typescript@1.4.2(typescript@5.0.3):
registry.npmmirror.com/@volar/vue-typescript@1.4.2(typescript@5.2.2):
resolution: {integrity: sha512-A1m1cSvS0Pf7Sm9q0S/1riV4RQQeH2h5gGo0vR9fGK2SrAStvh4HuuxPOX4N9uMDbRsNMhC0ILXwtlvjQ/IXJA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@volar/vue-typescript/-/vue-typescript-1.4.2.tgz}
id: registry.npmmirror.com/@volar/vue-typescript/1.4.2
name: '@volar/vue-typescript'
version: 1.4.2
deprecated: 'WARNING: This project has been renamed to @vue/typescript. Install using @vue/typescript instead.'
dependencies:
'@volar/typescript': registry.npmmirror.com/@volar/typescript@1.4.0(typescript@5.0.3)
'@volar/typescript': registry.npmmirror.com/@volar/typescript@1.4.0(typescript@5.2.2)
'@volar/vue-language-core': registry.npmmirror.com/@volar/vue-language-core@1.4.2
transitivePeerDependencies:
- typescript
@ -1830,12 +1830,6 @@ packages:
version: 1.0.2
dev: true
registry.npmmirror.com/blueimp-md5@2.19.0:
resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz}
name: blueimp-md5
version: 2.19.0
dev: true
registry.npmmirror.com/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz}
name: brace-expansion
@ -1939,22 +1933,6 @@ packages:
version: 1.1.4
dev: true
registry.npmmirror.com/concordance@5.0.4:
resolution: {integrity: sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/concordance/-/concordance-5.0.4.tgz}
name: concordance
version: 5.0.4
engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=14'}
dependencies:
date-time: registry.npmmirror.com/date-time@3.1.0
esutils: registry.npmmirror.com/esutils@2.0.3
fast-diff: registry.npmmirror.com/fast-diff@1.3.0
js-string-escape: registry.npmmirror.com/js-string-escape@1.0.1
lodash: registry.npmmirror.com/lodash@4.17.21
md5-hex: registry.npmmirror.com/md5-hex@3.0.1
semver: registry.npmmirror.com/semver@7.5.4
well-known-symbols: registry.npmmirror.com/well-known-symbols@2.0.0
dev: true
registry.npmmirror.com/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz}
name: cross-spawn
@ -2975,13 +2953,6 @@ packages:
version: 2.0.0
dev: true
registry.npmmirror.com/js-string-escape@1.0.1:
resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/js-string-escape/-/js-string-escape-1.0.1.tgz}
name: js-string-escape
version: 1.0.1
engines: {node: '>= 0.8'}
dev: true
registry.npmmirror.com/js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz}
name: js-yaml
@ -3041,12 +3012,6 @@ packages:
version: 4.6.2
dev: true
registry.npmmirror.com/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz}
name: lodash
version: 4.17.21
dev: true
registry.npmmirror.com/loupe@2.3.6:
resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/loupe/-/loupe-2.3.6.tgz}
name: loupe
@ -3376,17 +3341,6 @@ packages:
hasBin: true
dev: true
registry.npmmirror.com/pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz}
name: pretty-format
version: 27.5.1
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
ansi-regex: registry.npmmirror.com/ansi-regex@5.0.1
ansi-styles: registry.npmmirror.com/ansi-styles@5.2.0
react-is: registry.npmmirror.com/react-is@17.0.2
dev: true
registry.npmmirror.com/pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz}
name: pretty-format
@ -3844,11 +3798,11 @@ packages:
is-typed-array: registry.npmmirror.com/is-typed-array@1.1.12
dev: false
registry.npmmirror.com/typescript@5.0.3:
resolution: {integrity: sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/typescript/-/typescript-5.0.3.tgz}
registry.npmmirror.com/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/typescript/-/typescript-5.2.2.tgz}
name: typescript
version: 5.0.3
engines: {node: '>=12.20'}
version: 5.2.2
engines: {node: '>=14.17'}
hasBin: true
dev: true
@ -4087,7 +4041,7 @@ packages:
he: registry.npmmirror.com/he@1.2.0
dev: true
registry.npmmirror.com/vue-tsc@1.4.2(typescript@5.0.3):
registry.npmmirror.com/vue-tsc@1.4.2(typescript@5.2.2):
resolution: {integrity: sha512-8VFjVekJuFtFG+N4rEimoR0OvNubhoTIMl2dlvbpyAD40LVPR1PN2SUc2qZPnWGGRsXZAVmFgiBHX0RB20HGyA==, registry: http://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.4.2.tgz}
id: registry.npmmirror.com/vue-tsc/1.4.2
name: vue-tsc
@ -4097,9 +4051,9 @@ packages:
typescript: '*'
dependencies:
'@volar/vue-language-core': registry.npmmirror.com/@volar/vue-language-core@1.4.2
'@volar/vue-typescript': registry.npmmirror.com/@volar/vue-typescript@1.4.2(typescript@5.0.3)
'@volar/vue-typescript': registry.npmmirror.com/@volar/vue-typescript@1.4.2(typescript@5.2.2)
semver: registry.npmmirror.com/semver@7.5.4
typescript: registry.npmmirror.com/typescript@5.0.3
typescript: registry.npmmirror.com/typescript@5.2.2
dev: true
registry.npmmirror.com/vue@3.2.47: