-
-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #624 from streamich/block-inlines
Block level `Inline` class implementation
- Loading branch information
Showing
6 changed files
with
417 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import {printTree} from 'tree-dump/lib/printTree'; | ||
import {OverlayPoint} from '../overlay/OverlayPoint'; | ||
import {stringify} from '../../../json-text/stringify'; | ||
import {SliceBehavior} from '../slice/constants'; | ||
import {Range} from '../rga/Range'; | ||
import {ChunkSlice} from '../util/ChunkSlice'; | ||
import {updateNum} from '../../../json-hash'; | ||
import type {AbstractRga} from '../../../json-crdt/nodes/rga'; | ||
import type {Printable} from 'tree-dump/lib/types'; | ||
import type {PathStep} from '../../../json-pointer'; | ||
import type {Slice} from '../slice/types'; | ||
import type {Peritext} from '../Peritext'; | ||
|
||
export type InlineAttributes = Record<string | number, unknown>; | ||
|
||
/** | ||
* The `Inline` class represents a range of inline text within a block, which | ||
* has the same annotations and formatting for all of its text contents, i.e. | ||
* its text contents can be rendered as a single (`<span>`) element. However, | ||
* the text contents might still be composed of multiple {@link ChunkSlice}s, | ||
* which are the smallest units of text and need to be concatenated to get the | ||
* full text content of the inline. | ||
*/ | ||
export class Inline extends Range implements Printable { | ||
public static create(txt: Peritext, start: OverlayPoint, end: OverlayPoint) { | ||
const texts: ChunkSlice[] = []; | ||
txt.overlay.chunkSlices0(undefined, start, end, (chunk, off, len) => { | ||
if (txt.overlay.isMarker(chunk.id)) return; | ||
texts.push(new ChunkSlice(chunk, off, len)); | ||
}); | ||
return new Inline(txt.str, start, end, texts); | ||
} | ||
|
||
constructor( | ||
rga: AbstractRga<string>, | ||
public start: OverlayPoint, | ||
public end: OverlayPoint, | ||
|
||
/** | ||
* @todo PERF: for performance reasons, we should consider not passing in | ||
* this array. Maybe pass in just the initial chunk and the offset. However, | ||
* maybe even the just is not necessary, as the `.start` point should have | ||
* its chunk cached, or will have it cached after the first access. | ||
*/ | ||
public readonly texts: ChunkSlice[], | ||
) { | ||
super(rga, start, end); | ||
} | ||
|
||
/** | ||
* @returns A stable unique identifier of this *inline* within a list of other | ||
* inlines of the parent block. Can be used for UI libraries to track the | ||
* identity of the inline across renders. | ||
*/ | ||
public key(): number { | ||
return updateNum(this.start.refresh(), this.end.refresh()); | ||
} | ||
|
||
/** | ||
* @returns The full text content of the inline, which is the concatenation | ||
* of all the underlying {@link ChunkSlice}s. | ||
*/ | ||
public str(): string { | ||
let str = ''; | ||
for (const slice of this.texts) str += slice.view(); | ||
return str; | ||
} | ||
|
||
/** | ||
* @returns The position of the inline withing the text. | ||
*/ | ||
public pos(): number { | ||
const chunkSlice = this.texts[0]; | ||
if (!chunkSlice) return -1; | ||
const chunk = chunkSlice.chunk; | ||
const pos = this.rga.pos(chunk); | ||
return pos + chunkSlice.off; | ||
} | ||
|
||
/** | ||
* @returns Returns the attributes of the inline, which are the slice | ||
* annotations and formatting applied to the inline. | ||
*/ | ||
public attr(): InlineAttributes { | ||
const attr: InlineAttributes = {}; | ||
const point = this.start as OverlayPoint; | ||
const slices: Slice[] = this.texts.length ? point.layers : point.markers; | ||
const length = slices.length; | ||
for (let i = 0; i < length; i++) { | ||
const slice = slices[i]; | ||
const type = slice.type as PathStep; | ||
switch (slice.behavior) { | ||
case SliceBehavior.Cursor: | ||
case SliceBehavior.Stack: { | ||
let dataList: unknown[] = (attr[type] as unknown[]) || (attr[type] = []); | ||
if (!Array.isArray(dataList)) dataList = attr[type] = [dataList]; | ||
let data = slice.data(); | ||
if (data === undefined) data = 1; | ||
dataList.push(data); | ||
break; | ||
} | ||
case SliceBehavior.Overwrite: { | ||
let data = slice.data(); | ||
if (data === undefined) data = 1; | ||
attr[type] = data; | ||
break; | ||
} | ||
case SliceBehavior.Erase: { | ||
delete attr[type]; | ||
break; | ||
} | ||
} | ||
} | ||
// TODO: Iterate over the markers... | ||
return attr; | ||
} | ||
|
||
// ---------------------------------------------------------------- Printable | ||
|
||
public toString(tab: string = ''): string { | ||
const str = this.str(); | ||
const truncate = str.length > 32; | ||
const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); | ||
const startFormatted = this.start.toString(tab, true); | ||
const range = | ||
this.start.cmp(this.end) === 0 ? startFormatted : `${startFormatted} ↔ ${this.end.toString(tab, true)}`; | ||
const header = `${this.constructor.name} ${range} ${text}`; | ||
const marks = this.attr(); | ||
const markKeys = Object.keys(marks); | ||
return ( | ||
header + | ||
printTree(tab, [ | ||
!marks | ||
? null | ||
: (tab) => | ||
'attributes' + | ||
printTree( | ||
tab, | ||
markKeys.map((key) => () => key + ' = ' + stringify(marks[key])), | ||
), | ||
!this.texts.length | ||
? null | ||
: (tab) => | ||
'texts' + | ||
printTree( | ||
tab, | ||
this.texts.map((text) => (tab) => text.toString(tab)), | ||
), | ||
]) | ||
); | ||
} | ||
} |
98 changes: 98 additions & 0 deletions
98
src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import {Timestamp} from '../../../../json-crdt-patch'; | ||
import {updateId} from '../../../../json-crdt/hash'; | ||
import {updateNum} from '../../../../json-hash'; | ||
import {Kit, setupKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; | ||
import {Point} from '../../rga/Point'; | ||
import {Inline} from '../Inline'; | ||
|
||
describe('range hash', () => { | ||
test('computes unique hash - 1', () => { | ||
const {peritext} = setupKit(); | ||
const p1 = new Point(peritext.str, new Timestamp(12313123, 41), 0); | ||
const p2 = new Point(peritext.str, new Timestamp(12313123, 41), 1); | ||
const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0); | ||
const p4 = new Point(peritext.str, new Timestamp(12313123, 43), 1); | ||
const hash1 = updateNum(p1.refresh(), p2.refresh()); | ||
const hash2 = updateNum(p3.refresh(), p4.refresh()); | ||
expect(hash1).not.toBe(hash2); | ||
}); | ||
|
||
test('computes unique hash - 2', () => { | ||
const {peritext} = setupKit(); | ||
const p1 = new Point(peritext.str, new Timestamp(12313123, 61), 0); | ||
const p2 = new Point(peritext.str, new Timestamp(12313123, 23), 1); | ||
const p3 = new Point(peritext.str, new Timestamp(12313123, 60), 0); | ||
const p4 = new Point(peritext.str, new Timestamp(12313123, 56), 1); | ||
const hash1 = updateNum(p1.refresh(), p2.refresh()); | ||
const hash2 = updateNum(p3.refresh(), p4.refresh()); | ||
expect(hash1).not.toBe(hash2); | ||
}); | ||
|
||
test('computes unique hash - 3', () => { | ||
const {peritext} = setupKit(); | ||
const p1 = new Point(peritext.str, new Timestamp(12313123, 43), 0); | ||
const p2 = new Point(peritext.str, new Timestamp(12313123, 61), 1); | ||
const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0); | ||
const p4 = new Point(peritext.str, new Timestamp(12313123, 60), 1); | ||
const hash1 = updateNum(p1.refresh(), p2.refresh()); | ||
const hash2 = updateNum(p3.refresh(), p4.refresh()); | ||
expect(hash1).not.toBe(hash2); | ||
}); | ||
|
||
test('computes unique hash - 4', () => { | ||
const hash1 = updateNum(updateId(0, new Timestamp(2, 7)), updateId(1, new Timestamp(2, 7))); | ||
const hash2 = updateNum(updateId(0, new Timestamp(2, 6)), updateId(1, new Timestamp(2, 40))); | ||
expect(hash1).not.toBe(hash2); | ||
}); | ||
}); | ||
|
||
const runKeyTests = (setup: () => Kit) => { | ||
describe('.key()', () => { | ||
test('construct unique keys for all ranges', () => { | ||
const {peritext} = setup(); | ||
const overlay = peritext.overlay; | ||
const length = peritext.strApi().length(); | ||
const keys = new Map<number | string, Inline>(); | ||
let cnt = 0; | ||
for (let i = 0; i < length; i++) { | ||
for (let j = 1; j <= length - i; j++) { | ||
peritext.editor.cursor.setAt(i, j); | ||
overlay.refresh(); | ||
const [start, end] = [...overlay.points()]; | ||
const inline = Inline.create(peritext, start, end); | ||
if (keys.has(inline.key())) { | ||
const inline2 = keys.get(inline.key())!; | ||
// tslint:disable-next-line:no-console | ||
console.error('DUPLICATE HASH:', inline.key()); | ||
// tslint:disable-next-line:no-console | ||
console.log('INLINE 1:', inline.start.id, inline.start.anchor, inline.end.id, inline.end.anchor); | ||
// tslint:disable-next-line:no-console | ||
console.log('INLINE 2:', inline2.start.id, inline2.start.anchor, inline2.end.id, inline2.end.anchor); | ||
throw new Error('Duplicate key'); | ||
} | ||
keys.set(inline.key(), inline); | ||
cnt++; | ||
} | ||
} | ||
expect(keys.size).toBe(cnt); | ||
}); | ||
}); | ||
}; | ||
|
||
describe('Inline', () => { | ||
describe('lorem ipsum', () => { | ||
runKeyTests(() => setupKit('lorem ipsum dolor sit amet consectetur adipiscing elit')); | ||
}); | ||
|
||
describe('numbers "0123456789", no edits', () => { | ||
runKeyTests(setupNumbersKit); | ||
}); | ||
|
||
describe('numbers "0123456789", with default schema and tombstones', () => { | ||
runKeyTests(setupNumbersWithTombstonesKit); | ||
}); | ||
|
||
describe('numbers "0123456789", with default schema and tombstones and constant sid', () => { | ||
runKeyTests(() => setupNumbersWithTombstonesKit(12313123)); | ||
}); | ||
}); |
Oops, something went wrong.