Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content Model Cache improvement - Step 1: Introduce Readonly types and mutate utility #2629

Merged
merged 20 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-dom/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export { createDivider } from './modelApi/creators/createDivider';
export { createListLevel } from './modelApi/creators/createListLevel';
export { createEmptyModel } from './modelApi/creators/createEmptyModel';

export { mutateBlock, mutateSegments, mutateSegment } from './modelApi/common/mutate';
export { addBlock } from './modelApi/common/addBlock';
export { addCode } from './modelApi/common/addDecorators';
export { addLink } from './modelApi/common/addDecorators';
Expand Down
107 changes: 107 additions & 0 deletions packages/roosterjs-content-model-dom/lib/modelApi/common/mutate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type {
MutableType,
ReadonlyContentModelBlock,
ReadonlyContentModelBlockGroup,
ReadonlyContentModelListItem,
ReadonlyContentModelParagraph,
ReadonlyContentModelSegment,
ReadonlyContentModelTable,
ShallowMutableContentModelParagraph,
ShallowMutableContentModelSegment,
} from 'roosterjs-content-model-types';

/**
* Convert a readonly block to mutable block, clear cached element if exist
* @param block The block to convert from
* @returns The same block object of its related mutable type
*/
export function mutateBlock<T extends ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock>(
block: T
): MutableType<T> {
if (block.cachedElement) {
delete block.cachedElement;
}

if (isTable(block)) {
block.rows.forEach(row => {
delete row.cachedElement;
});
} else if (isListItem(block)) {
block.levels.forEach(level => delete level.cachedElement);
}

const result = (block as unknown) as MutableType<T>;

return result;
}

/**
* Convert segments of a readonly paragraph to be mutable.
* Segments that are not belong to the given paragraph will be skipped
* @param paragraph The readonly paragraph to convert from
* @param segments The segments to convert from
*/
export function mutateSegments(
paragraph: ReadonlyContentModelParagraph,
segments: ReadonlyContentModelSegment[]
): [ShallowMutableContentModelParagraph, ShallowMutableContentModelSegment[], number[]] {
const mutablePara = mutateBlock(paragraph);
const result: [
ShallowMutableContentModelParagraph,
ShallowMutableContentModelSegment[],
number[]
] = [mutablePara, [], []];

if (segments) {
segments.forEach(segment => {
const index = paragraph.segments.indexOf(segment);

if (index >= 0) {
result[1].push(mutablePara.segments[index]);
result[2].push(index);
}
});
}

return result;
}

/**
* Convert a readonly segment to be mutable, together with its owner paragraph
* If the segment does not belong to the given paragraph, return null for the segment
* @param paragraph The readonly paragraph to convert from
* @param segment The segment to convert from
*/
export function mutateSegment<T extends ReadonlyContentModelSegment>(
paragraph: ReadonlyContentModelParagraph,
segment: T,
callback?: (
segment: MutableType<T>,
paragraph: ShallowMutableContentModelParagraph,
index: number
) => void
): [ShallowMutableContentModelParagraph, MutableType<T> | null, number] {
const [mutablePara, mutableSegments, indexes] = mutateSegments(paragraph, [segment]);
const mutableSegment =
(mutableSegments[0] as ReadonlyContentModelSegment) == segment
? (mutableSegments[0] as MutableType<T>)
: null;

if (callback && mutableSegment) {
callback(mutableSegments[0] as MutableType<T>, mutablePara, indexes[0]);
}

return [mutablePara, mutableSegment, indexes[0] ?? -1];
}

function isTable(
obj: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock
): obj is ReadonlyContentModelTable {
return (obj as ReadonlyContentModelTable).blockType == 'Table';
}

function isListItem(
obj: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock
): obj is ReadonlyContentModelListItem {
return (obj as ReadonlyContentModelListItem).blockGroupType == 'ListItem';
}
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,13 @@ function cloneListItem(
item: ContentModelListItem,
options: CloneModelOptions
): ContentModelListItem {
const { formatHolder, levels } = item;
const { formatHolder, levels, cachedElement } = item;

return Object.assign(
{
formatHolder: cloneSelectionMarker(formatHolder),
levels: levels.map(cloneListLevel),
cachedElement: handleCachedElement(cachedElement, 'cache', options),
},
cloneBlockBase(item),
cloneBlockGroupBase(item, options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ export function getSegmentTextFormat(segment: ContentModelSegment): ContentModel
}

const removeUndefinedValues = (format: ContentModelSegmentFormat): ContentModelSegmentFormat => {
const textFormat: Record<string, string | undefined | boolean> = {};
const textFormat: Record<string, string | undefined | boolean | never[]> = {};
Object.keys(format).filter(key => {
const value = format[key as keyof ContentModelSegmentFormat];

if (value !== undefined) {
textFormat[key] = value;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument';
import { createListItem } from '../../../lib/modelApi/creators/createListItem';
import { createListLevel } from '../../../lib/modelApi/creators/createListLevel';
import { createParagraph } from '../../../lib/modelApi/creators/createParagraph';
import { createTable } from '../../../lib/modelApi/creators/createTable';
import { createTableCell } from '../../../lib/modelApi/creators/createTableCell';
import { createText } from '../../../lib/modelApi/creators/createText';
import { mutateBlock, mutateSegment, mutateSegments } from '../../../lib/modelApi/common/mutate';

const mockedCache = 'CACHE' as any;

describe('mutate', () => {
it('mutate a block without cache', () => {
const block = {} as any;

const mutatedBlock = mutateBlock(block);

expect(mutatedBlock).toBe(block);
expect(mutatedBlock).toEqual({} as any);
});

it('mutate a block with cache', () => {
const block = {} as any;

block.cachedElement = mockedCache;

const mutatedBlock = mutateBlock(block);

expect(mutatedBlock).toBe(block);
expect(mutatedBlock).toEqual({} as any);
});

it('mutate a block group with cache', () => {
const doc = createContentModelDocument();
const para = createParagraph();

doc.cachedElement = mockedCache;
para.cachedElement = mockedCache;

doc.blocks.push(para);

const mutatedBlock = mutateBlock(doc);

expect(mutatedBlock).toBe(doc);
expect(mutatedBlock).toEqual({
blockGroupType: 'Document',
blocks: [
{
blockType: 'Paragraph',
segments: [],
format: {},
cachedElement: mockedCache,
},
],
} as any);
});

it('mutate a table', () => {
const table = createTable(1);
const cell = createTableCell();
const para = createParagraph();

table.cachedElement = mockedCache;
table.rows[0].cachedElement = mockedCache;
cell.cachedElement = mockedCache;
para.cachedElement = mockedCache;

cell.blocks.push(para);
table.rows[0].cells.push(cell);

const mutatedBlock = mutateBlock(table);

expect(mutatedBlock).toBe(table);
expect(mutatedBlock).toEqual({
blockType: 'Table',
rows: [
{
cells: [
{
blockGroupType: 'TableCell',
blocks: [
{
blockType: 'Paragraph',
segments: [],
format: {},
cachedElement: mockedCache,
},
],
format: {},
spanLeft: false,
spanAbove: false,
isHeader: false,
dataset: {},
cachedElement: mockedCache,
},
],
height: 0,
format: {},
},
],
format: {},
widths: [],
dataset: {},
} as any);
});

it('mutate a list', () => {
const level = createListLevel('OL');
const list = createListItem([level]);
const para = createParagraph();

level.cachedElement = mockedCache;
list.cachedElement = mockedCache;
para.cachedElement = mockedCache;

list.blocks.push(para);

const mutatedBlock = mutateBlock(list);

expect(mutatedBlock).toBe(list);
expect(mutatedBlock).toEqual({
blockType: 'BlockGroup',
format: {},
blockGroupType: 'ListItem',
blocks: [
{
blockType: 'Paragraph',
segments: [],
format: {},
cachedElement: mockedCache,
},
],
levels: [{ listType: 'OL', format: {}, dataset: {} }],
formatHolder: {
segmentType: 'SelectionMarker',
isSelected: false,
format: {},
},
} as any);
});
});

describe('mutateSegments', () => {
it('empty paragraph', () => {
const para = createParagraph();

para.cachedElement = mockedCache;

const result = mutateSegments(para, []);

expect(result).toEqual([para, [], []]);
expect(result[0].cachedElement).toBeUndefined();
});

it('Paragraph with correct segments', () => {
const para = createParagraph();

para.cachedElement = mockedCache;

const text1 = createText('test1');
const text2 = createText('test2');
const text3 = createText('test3');
const text4 = createText('test4');

para.segments.push(text1, text2, text3, text4);

const result = mutateSegments(para, [text2, text4]);

expect(result).toEqual([para, [text2, text4], [1, 3]]);
expect(result[0].cachedElement).toBeUndefined();
});

it('Paragraph with incorrect segments', () => {
const para = createParagraph();

para.cachedElement = mockedCache;

const text1 = createText('test1');
const text2 = createText('test2');
const text3 = createText('test3');
const text4 = createText('test4');

para.segments.push(text1, text2, text3);

const result = mutateSegments(para, [text2, text4]);

expect(result).toEqual([para, [text2], [1]]);
expect(result[0].cachedElement).toBeUndefined();
});
});

describe('mutateSegment', () => {
let callbackSpy: jasmine.Spy;

beforeEach(() => {
callbackSpy = jasmine.createSpy('callback');
});

it('Paragraph with correct segment', () => {
const para = createParagraph();

para.cachedElement = mockedCache;

const text1 = createText('test1');
const text2 = createText('test2');
const text3 = createText('test3');

para.segments.push(text1, text2, text3);

const result = mutateSegment(para, text2, callbackSpy);

expect(result).toEqual([para, text2, 1]);
expect(result[0].cachedElement).toBeUndefined();
expect(callbackSpy).toHaveBeenCalledTimes(1);
expect(callbackSpy).toHaveBeenCalledWith(text2, para, 1);
});

it('Paragraph with incorrect segment', () => {
const para = createParagraph();

para.cachedElement = mockedCache;

const text1 = createText('test1');
const text2 = createText('test2');
const text3 = createText('test3');

para.segments.push(text1, text3);

const result = mutateSegment(para, text2, callbackSpy);

expect(result).toEqual([para, null, -1]);
expect(result[0].cachedElement).toBeUndefined();
expect(callbackSpy).toHaveBeenCalledTimes(0);
});
});
Loading
Loading