diff --git a/src/types/YText.js b/src/types/YText.js index ae6474b9..83bf0d19 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -291,7 +291,8 @@ const formatText = (transaction, parent, currPos, length, attributes) => { const negatedAttributes = insertAttributes(transaction, parent, currPos, attributes) // iterate until first non-format or null is found // delete all formats with attributes[format.key] != null - while (length > 0 && currPos.right !== null) { + // also check the attributes after the first non-format as we do not want to insert redundant negated attributes there + while (currPos.right !== null && (length > 0 || currPos.right.content.constructor === ContentFormat)) { if (!currPos.right.deleted) { switch (currPos.right.content.constructor) { case ContentFormat: { diff --git a/tests/undo-redo.tests.js b/tests/undo-redo.tests.js index 40e6a718..4394e9ab 100644 --- a/tests/undo-redo.tests.js +++ b/tests/undo-redo.tests.js @@ -527,3 +527,41 @@ export const testUndoBlockBug = tc => { undoManager.redo() // {"text":{}} t.compare(design.toJSON(), { text: { blocks: { text: '4' } } }) } + +/** + * Undo text formatting delete should not corrupt peer state. + * + * @see https://github.com/yjs/yjs/issues/392 + * @param {t.TestCase} tc + */ +export const testUndoDeleteTextFormat = tc => { + const doc = new Y.Doc() + const text = doc.getText() + text.insert(0, 'Attack ships on fire off the shoulder of Orion.') + const doc2 = new Y.Doc() + const text2 = doc2.getText(); + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + const undoManager = new Y.UndoManager(text) + + text.format(13, 7, { bold: true }) + undoManager.stopCapturing() + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + text.format(16, 4, { bold: null }) + undoManager.stopCapturing() + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + undoManager.undo() + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + const expect = [ + { insert: 'Attack ships ' }, + { + insert: 'on fire', + attributes: { bold: true } + }, + { insert: ' off the shoulder of Orion.' } + ] + t.compare(text.toDelta(), expect) + t.compare(text2.toDelta(), expect) +} diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index cf60a106..4f2fe090 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -607,6 +607,35 @@ export const testFormattingBug = async tc => { console.log(text1.toDelta()) } +/** + * Delete formatting should not leave redundant formatting items. + * + * @param {t.TestCase} tc + */ +export const testDeleteFormatting = tc => { + const doc = new Y.Doc() + const text = doc.getText() + text.insert(0, 'Attack ships on fire off the shoulder of Orion.') + + const doc2 = new Y.Doc() + const text2 = doc2.getText() + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + text.format(13, 7, { bold: true }) + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + text.format(16, 4, { bold: null }) + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + const expected = [ + { insert: 'Attack ships ' }, + { insert: 'on ', attributes: { bold: true } }, + { insert: 'fire off the shoulder of Orion.' } + ] + t.compare(text.toDelta(), expected) + t.compare(text2.toDelta(), expected) +} + // RANDOM TESTS let charCounter = 0