diff --git a/packages/@atjson/util/src/index.ts b/packages/@atjson/util/src/index.ts index 86c8814af..81f1c1891 100644 --- a/packages/@atjson/util/src/index.ts +++ b/packages/@atjson/util/src/index.ts @@ -145,6 +145,16 @@ export function compareSliceTokens(a: SortableSlice, b: SortableSlice) { return 0; } +function compareRanges( + [start1, end1]: [number, number], + [start2, end2]: [number, number] +) { + if (start1 === start2) { + return end1 - end2; + } + return start1 - start2; +} + /** * Extracts slices from a document for the special `slice` * type provided by atjson. This function removes slices @@ -390,6 +400,30 @@ export function extractSlices(value: { }); } + // Normalize the ranges again, as we may have extended ranges into each other + if (rangesToDelete.length > 1) { + const normalizedRanges = [rangesToDelete[0]]; + // First sort the ranges + rangesToDelete.sort(compareRanges); + for ( + let j = 1, lastRange = rangesToDelete[0]; + j < rangesToDelete.length; + j++ + ) { + const currentRange = rangesToDelete[j]; + // Since they are sorted, check if the current range can be merged into the + // previous one + if (currentRange[0] <= lastRange[1] && lastRange[1] <= currentRange[1]) { + lastRange[1] = currentRange[1]; + } else { + // Otherwise we can add the range to the list + normalizedRanges.push(currentRange); + lastRange = currentRange; + } + } + rangesToDelete = normalizedRanges; + } + let firstRange = rangesToDelete[0]; let text = firstRange ? value.text.slice(0, firstRange[0]) : ""; let lastEnd = firstRange ? firstRange[1] : 0; diff --git a/packages/@atjson/util/test/extract-slices.test.ts b/packages/@atjson/util/test/extract-slices.test.ts index 0cd6c7710..19af8997e 100644 --- a/packages/@atjson/util/test/extract-slices.test.ts +++ b/packages/@atjson/util/test/extract-slices.test.ts @@ -422,6 +422,12 @@ describe("extractSlices", () => { refs: ["B00000000"], }, }, + { + id: "M00000003", + type: "italic", + range: "(6..7]", + attributes: {}, + }, ], }; let [doc, slices] = extractSlices(original); @@ -429,6 +435,18 @@ describe("extractSlices", () => { expect(slices.get("M00000001")?.text).toEqual("B"); expect(slices.get("M00000002")?.text).toEqual("C"); expect(doc.text).toEqual("D"); + expect(doc.marks).toMatchInlineSnapshot(` + [ + { + "attributes": {}, + "end": 2, + "id": "M00000003", + "range": "(1..2]", + "start": 1, + "type": "italic", + }, + ] + `); }); test("start slice position matches", () => { @@ -542,13 +560,242 @@ describe("extractSlices", () => { refs: ["B00000000"], }, }, + { + id: "M00000003", + type: "italic", + range: "(5..6]", + attributes: {}, + }, + ], + }; + let [doc, slices] = extractSlices(original); + expect(slices.get("M00000000")?.text).toEqual("A"); + expect(slices.get("M00000001")?.text).toEqual("BB"); + expect(slices.get("M00000002")?.text).toEqual("C"); + expect(doc.text).toEqual("D"); + expect(doc.marks).toMatchInlineSnapshot(` + [ + { + "attributes": {}, + "end": 2, + "id": "M00000003", + "range": "(1..2]", + "start": 1, + "type": "italic", + }, + ] + `); + }); + + test("marks overlapping multiple slices", () => { + const original = { + text: "ABCBD", + blocks: [ + { + id: "B00000000", + type: "paragraph", + parents: [], + selfClosing: false, + attributes: {}, + }, + ], + marks: [ + { + id: "M00000000", + type: "slice", + range: "(1..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000001", + type: "slice", + range: "(2..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000002", + type: "slice", + range: "(3..4]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000003", + type: "italic", + range: "(5..6]", + attributes: {}, + }, + { + id: "M00000004", + type: "bold", + range: "(1..6]", + attributes: {}, + }, ], }; + let [doc, slices] = extractSlices(original); expect(slices.get("M00000000")?.text).toEqual("A"); + expect(slices.get("M00000000")?.marks).toHaveLength(0); expect(slices.get("M00000001")?.text).toEqual("BB"); + expect(slices.get("M00000001")?.marks).toHaveLength(0); expect(slices.get("M00000002")?.text).toEqual("C"); + expect(slices.get("M00000002")?.marks).toHaveLength(0); expect(doc.text).toEqual("D"); + expect(doc.marks).toMatchInlineSnapshot(` + [ + { + "attributes": {}, + "end": 2, + "id": "M00000003", + "range": "(1..2]", + "start": 1, + "type": "italic", + }, + { + "attributes": {}, + "end": 2, + "id": "M00000004", + "range": "(1..2]", + "start": 1, + "type": "bold", + }, + ] + `); + }); + + test("marks contained in multiple slices", () => { + const original = { + text: "ABCBD", + blocks: [ + { + id: "B00000000", + type: "paragraph", + parents: [], + selfClosing: false, + attributes: {}, + }, + ], + marks: [ + { + id: "M00000000", + type: "slice", + range: "(1..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000001", + type: "slice", + range: "(2..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000002", + type: "slice", + range: "(3..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000003", + type: "italic", + range: "(3..5]", + attributes: {}, + }, + ], + }; + + let [doc, slices] = extractSlices(original); + expect(slices.get("M00000000")?.text).toEqual("A"); + expect(slices.get("M00000000")?.marks).toHaveLength(0); + expect(slices.get("M00000001")?.text).toEqual("B"); + expect(slices.get("M00000001")?.marks).toHaveLength(0); + expect(slices.get("M00000002")?.text).toEqual("CB"); + expect(slices.get("M00000002")?.marks).toMatchInlineSnapshot(` + [ + { + "attributes": {}, + "end": 3, + "id": "M00000002-M00000003", + "range": "(1..3]", + "start": 1, + "type": "italic", + }, + ] + `); + expect(doc.text).toEqual("D"); + expect(doc.marks).toHaveLength(0); + }); + + test("marks crossing multiple slices but contained in none are dropped", () => { + const original = { + text: "ABCBD", + blocks: [ + { + id: "B00000000", + type: "paragraph", + parents: [], + selfClosing: false, + attributes: {}, + }, + ], + marks: [ + { + id: "M00000000", + type: "slice", + range: "(1..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000001", + type: "slice", + range: "(2..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000002", + type: "slice", + range: "(3..5]", + attributes: { + refs: ["B00000000"], + }, + }, + { + id: "M00000003", + type: "italic", + // this mark is split by multiple slices so it is not + // fully contained in any of them. Currently this means + // it is not a part of any slice. In the future we may split + // this mark like we do blocks + range: "(1..5]", + attributes: {}, + }, + ], + }; + + let [doc, slices] = extractSlices(original); + expect(slices.get("M00000000")?.text).toEqual("A"); + expect(slices.get("M00000000")?.marks).toHaveLength(0); + expect(slices.get("M00000001")?.text).toEqual("B"); + expect(slices.get("M00000001")?.marks).toHaveLength(0); + expect(slices.get("M00000002")?.text).toEqual("CB"); + expect(slices.get("M00000002")?.marks).toHaveLength(0); + expect(doc.text).toEqual("D"); + expect(doc.marks).toHaveLength(0); }); test("hanging overlapping slices", () => { @@ -580,12 +827,30 @@ describe("extractSlices", () => { refs: ["B00000000"], }, }, + { + id: "M00000002", + type: "italic", + range: "(4..5]", + attributes: {}, + }, ], }; let [doc, slices] = extractSlices(original); expect(slices.get("M00000000")?.text).toEqual("AB"); expect(slices.get("M00000001")?.text).toEqual("BC"); expect(doc.text).toEqual("D"); + expect(doc.marks).toMatchInlineSnapshot(` + [ + { + "attributes": {}, + "end": 2, + "id": "M00000002", + "range": "(1..2]", + "start": 1, + "type": "italic", + }, + ] + `); }); describe("retain", () => {