Skip to content

Commit

Permalink
Merge pull request #631 from streamich/peritext-block
Browse files Browse the repository at this point in the history
Initial Peritext blocks implementation
  • Loading branch information
streamich authored Jun 7, 2024
2 parents 27cc16a + 1d5e94d commit ba65aa0
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 18 deletions.
7 changes: 5 additions & 2 deletions src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {CONST, updateNum} from '../../json-hash';
import {SESSION} from '../../json-crdt-patch/constants';
import {s} from '../../json-crdt-patch';
import {ExtraSlices} from './slice/ExtraSlices';
import {Blocks} from './block/Blocks';
import type {ITimestampStruct} from '../../json-crdt-patch/clock';
import type {Printable} from 'tree-dump/lib/types';
import type {MarkerSlice} from './slice/MarkerSlice';
Expand Down Expand Up @@ -52,6 +53,7 @@ export class Peritext<T = string> implements Printable {

public readonly editor: Editor<T>;
public readonly overlay = new Overlay<T>(this);
public readonly blocks: Blocks;

/**
* Creates a new Peritext context.
Expand Down Expand Up @@ -82,6 +84,7 @@ export class Peritext<T = string> implements Printable {
});
this.localSlices = new LocalSlices(this, localSlicesModel.root.node().get(0)!);
this.editor = new Editor<T>(this);
this.blocks = new Blocks(this as Peritext);
}

public strApi(): StrApi {
Expand Down Expand Up @@ -286,8 +289,8 @@ export class Peritext<T = string> implements Printable {

public refresh(): number {
let state: number = CONST.START_STATE;
this.overlay.refresh();
state = updateNum(state, this.overlay.hash);
state = updateNum(state, this.overlay.refresh());
state = updateNum(state, this.blocks.refresh());
return (this.hash = state);
}
}
95 changes: 95 additions & 0 deletions src/json-crdt-extensions/peritext/block/Block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {printTree} from 'tree-dump/lib/printTree';
import {CONST, updateJson, updateNum} from '../../../json-hash';
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
import type {Path} from '../../../json-pointer';
import type {Printable} from 'tree-dump';
import type {Peritext} from '../Peritext';
import type {Stateful} from '../types';

export interface IBlock {
readonly path: Path;
readonly parent: IBlock | null;
}

export class Block<Attr = unknown> implements IBlock, Printable, Stateful {
public parent: Block | null = null;

public children: Block[] = [];

constructor(
public readonly txt: Peritext,
public readonly path: Path,
/** @todo rename this */
public readonly marker: MarkerOverlayPoint | undefined,
) {}

/**
* @returns Stable unique identifier within a list of blocks. Used for React
* or other rendering library keys.
*/
public key(): number | string {
if (!this.marker) return this.tag();
const id = this.marker.id;
return id.sid.toString(36) + id.time.toString(36);
}

public tag(): number | string {
const path = this.path;
const length = path.length;
return length ? path[length - 1] : '';
}

public attr(): Attr | undefined {
return this.marker?.data() as Attr | undefined;
}

// ----------------------------------------------------------------- Stateful

public hash: number = 0;

public refresh(): number {
const {path, children} = this;
let state = CONST.START_STATE;
state = updateJson(state, path);
const marker = this.marker;
if (marker) {
state = updateNum(state, marker.marker.refresh());
state = updateNum(state, marker.textHash);
} else {
state = updateNum(state, this.txt.overlay.leadingTextHash);
}
for (let i = 0; i < children.length; i++) state = updateNum(state, children[i].refresh());
return (this.hash = state);
}

// ---------------------------------------------------------------- Printable

protected toStringHeader(): string {
const hash = `#${this.hash.toString(36).slice(-4)}`;
const tag = `<${this.path.join('.')}>`;
const header = `${this.constructor.name} ${hash} ${tag}`;
return header;
}

public toString(tab: string = ''): string {
const header = this.toStringHeader();
const hasChildren = !!this.children.length;
return (
header +
printTree(tab, [
this.marker ? (tab) => this.marker!.toString(tab) : null,
this.marker && hasChildren ? () => '' : null,
hasChildren
? (tab) =>
'children' +
printTree(
tab,
this.children.map(
(child, i) => (tab) => `${i + 1}. ` + child.toString(tab + ' ' + ' '.repeat(String(i + 1).length)),
),
)
: null,
])
);
}
}
64 changes: 64 additions & 0 deletions src/json-crdt-extensions/peritext/block/Blocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Block} from './Block';
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
import {commonLength} from '../util/commonLength';
import {printTree} from 'tree-dump/lib/printTree';
import {LeafBlock} from './LeafBlock';
import type {Path} from '../../../json-pointer';
import type {Stateful} from '../types';
import type {Printable} from 'tree-dump/lib/types';
import type {Peritext} from '../Peritext';

export class Blocks implements Printable, Stateful {
public readonly root: Block;

constructor(public readonly txt: Peritext) {
this.root = new Block(txt, [], undefined);
}

// ---------------------------------------------------------------- Printable

public toString(tab: string = ''): string {
return this.constructor.name + printTree(tab, [(tab) => this.root.toString(tab)]);
}

// ----------------------------------------------------------------- Stateful

public hash: number = 0;

public refresh(): number {
this.refreshBlocks();
return (this.hash = this.root.refresh());
}

private insertBlock(parent: Block, path: Path, marker: undefined | MarkerOverlayPoint): Block {
const txt = this.txt;
const common = commonLength(path, parent.path);
while (parent.path.length > common && parent.parent) parent = parent.parent as Block;
while (parent.path.length + 1 < path.length) {
const block = new Block(txt, path.slice(0, parent.path.length + 1), undefined);
block.parent = parent;
parent.children.push(block);
parent = block;
}
const block = new LeafBlock(txt, path, marker);
block.parent = parent;
parent.children.push(block);
return block;
}

protected refreshBlocks(): void {
this.root.children = [];
let parent = this.root;
let markerPoint: undefined | MarkerOverlayPoint;
const txt = this.txt;
const overlay = txt.overlay;
this.insertBlock(parent, [0], undefined);
const iterator = overlay.markers0(undefined);
while ((markerPoint = iterator())) {
const type = markerPoint.type();
const path = type instanceof Array ? type : [type];
const block = this.insertBlock(parent, path, markerPoint);
if (block.parent) parent = block.parent;
}
}
}
21 changes: 21 additions & 0 deletions src/json-crdt-extensions/peritext/block/LeafBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {printTree} from 'tree-dump/lib/printTree';
import {Block} from './Block';
import type {Path} from '../../../json-pointer';

export interface IBlock<Attr = unknown> {
readonly path: Path;
readonly attr?: Attr;
readonly parent: IBlock | null;
}

export class LeafBlock<Attr = unknown> extends Block<Attr> {
protected toStringHeader(): string {
const header = `${super.toStringHeader()}`;
return header;
}

public toString(tab: string = ''): string {
const header = this.toStringHeader();
return header + printTree(tab, [this.marker ? (tab) => this.marker!.toString(tab) : null]);
}
}
42 changes: 42 additions & 0 deletions src/json-crdt-extensions/peritext/block/__tests__/Block.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {setupHelloWorldKit} from '../../__tests__/setup';
import {Block} from '../Block';

const setup = () => {
const kit = setupHelloWorldKit();
kit.peritext.editor.cursor.setAt(6);
const data = {
source: 'http://example.com',
};
kit.peritext.editor.saved.insMarker(['li', 'blockquote'], data);
kit.peritext.refresh();
const marker = kit.peritext.overlay.markers().next().value!;
const type = marker.type();
const path = type instanceof Array ? type : [type];
const block = new Block(kit.peritext, path, marker);
return {
...kit,
block,
marker,
};
};

test('can retrieve the "tag"', () => {
const {block} = setup();
expect(block.tag()).toBe('blockquote');
});

test('can retrieve marker data as "attributes"', () => {
const {block} = setup();
expect(block.attr()).toEqual({source: 'http://example.com'});
});

describe('refresh()', () => {
test('returns the same hash, when no changes were made', () => {
const {block} = setup();
const hash1 = block.refresh();
expect(hash1).toBe(block.hash);
const hash2 = block.refresh();
expect(hash2).toBe(hash1);
expect(hash2).toBe(block.hash);
});
});
18 changes: 18 additions & 0 deletions src/json-crdt-extensions/peritext/block/__tests__/Blocks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {setupHelloWorldKit} from '../../__tests__/setup';
import {Block} from '../Block';
import {LeafBlock} from '../LeafBlock';

test('can construct a two-paragraph document', () => {
const {peritext} = setupHelloWorldKit();
peritext.editor.cursor.setAt(6);
peritext.editor.saved.insMarker('p');
peritext.refresh();
const blocks = peritext.blocks;
const paragraph1 = blocks.root.children[0];
const paragraph2 = blocks.root.children[1];
expect(blocks.root instanceof Block).toBe(true);
expect(paragraph1 instanceof LeafBlock).toBe(true);
expect(paragraph2 instanceof LeafBlock).toBe(true);
expect(paragraph1.path).toEqual([0]);
expect(paragraph2.path).toEqual(['p']);
});
2 changes: 1 addition & 1 deletion src/json-crdt-extensions/peritext/overlay/Overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export class Overlay<T = string> implements Printable, Stateful {
};
}

public markers(): IterableIterator<MarkerOverlayPoint<T>> {
public markers(): UndefEndIter<MarkerOverlayPoint<T>> {
return new UndefEndIter(this.markers0(undefined));
}

Expand Down
14 changes: 0 additions & 14 deletions src/json-crdt-extensions/peritext/overlay/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
import type {OverlayPoint} from './OverlayPoint';

export type BlockTag = [
/**
* Developer specified type of the block. For example, 'title', 'paragraph',
* 'image', etc. For performance reasons, it is better to use a number to
* represent the type.
*/
type: number | number[],

/**
* Any custom attributes that the developer wants to add to the block.
*/
attr?: undefined | unknown,
];

/**
* Represents a two adjacent overlay points. The first point is the point
* that is closer to the start of the document, and the second point is the
Expand Down
7 changes: 7 additions & 0 deletions src/json-crdt-extensions/peritext/util/commonLength.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type {Path} from '../../../json-pointer';

export const commonLength = (a: Path, b: Path): number => {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) i++;
return i;
};
2 changes: 1 addition & 1 deletion src/util/iterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type UndefIterator<T> = () => undefined | T;
export class UndefEndIter<T> implements IterableIterator<T> {
constructor(private readonly i: UndefIterator<T>) {}

public next(): IteratorResult<T> {
public next(): IteratorResult<T, T> {
const value = this.i();
return new IterRes(value, value === undefined) as IteratorResult<T>;
}
Expand Down

0 comments on commit ba65aa0

Please sign in to comment.