Skip to content

Commit

Permalink
Merge pull request #756 from streamich/peritext-inline-commands
Browse files Browse the repository at this point in the history
Peritext inline commands
  • Loading branch information
streamich authored Nov 10, 2024
2 parents cfde302 + 32889ab commit 8b7548d
Show file tree
Hide file tree
Showing 39 changed files with 353 additions and 145 deletions.
4 changes: 2 additions & 2 deletions src/json-crdt-extensions/peritext/block/Inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,12 @@ export class Inline extends Range implements Printable {
stack.push(this.createAttr(slice));
break;
}
case SliceBehavior.Stack: {
case SliceBehavior.Many: {
const stack: InlineAttrStack = attr[type] ?? (attr[type] = []);
stack.push(this.createAttr(slice));
break;
}
case SliceBehavior.Overwrite: {
case SliceBehavior.One: {
attr[type] = [this.createAttr(slice)];
break;
}
Expand Down
96 changes: 96 additions & 0 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,32 @@ import {isLetter, isPunctuation, isWhitespace} from './util';
import {Anchor} from '../rga/constants';
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
import {UndefEndIter, type UndefIterator} from '../../../util/iterator';
import {PersistedSlice} from '../slice/PersistedSlice';
import type {SliceType} from '../slice';
import type {ChunkSlice} from '../util/ChunkSlice';
import type {Peritext} from '../Peritext';
import type {Point} from '../rga/Point';
import type {Range} from '../rga/Range';
import type {CharIterator, CharPredicate, Position, TextRangeUnit} from './types';

/**
* For inline boolean ("Overwrite") slices, both range endpoints should be
* attached to {@link Anchor.Before} as per the Peritext paper. This way, say
* bold text, automatically extends to include the next character typed as
* user types.
*
* @param range The range to be adjusted.
*/
const makeRangeExtendable = <T>(range: Range<T>): void => {
if (range.end.anchor !== Anchor.Before || range.start.anchor !== Anchor.Before) {
const start = range.start.clone();
const end = range.end.clone();
start.refBefore();
end.refBefore();
range.set(start, end);
}
};

export class Editor<T = string> {
public readonly saved: EditorSlices<T>;
public readonly extra: EditorSlices<T>;
Expand Down Expand Up @@ -461,6 +481,82 @@ export class Editor<T = string> {
if (unit) this.select(unit);
}

// --------------------------------------------------------------- formatting

protected getSliceStore(slice: PersistedSlice<T>): EditorSlices<T> | undefined {
const sid = slice.id.sid;
if (sid === this.saved.slices.set.doc.clock.sid) return this.saved;
if (sid === this.extra.slices.set.doc.clock.sid) return this.extra;
if (sid === this.local.slices.set.doc.clock.sid) return this.local;
return;
}

public toggleExclusiveFormatting(type: SliceType, data?: unknown, store: EditorSlices<T> = this.saved): void {
// TODO: handle mutually exclusive slices (<sub>, <sub>)
const overlay = this.txt.overlay;
overlay.refresh(); // TODO: Refresh for `overlay.stat()` calls. Is it actually needed?
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
const [complete] = overlay.stat(cursor, 1e6);
const needToRemoveFormatting = complete.has(type);
makeRangeExtendable(cursor);
const contained = overlay.findContained(cursor);
for (const slice of contained) {
if (slice instanceof PersistedSlice && slice.type === type) {
const deletionStore = this.getSliceStore(slice);
if (deletionStore) deletionStore.del(slice.id);
}
}
if (needToRemoveFormatting) {
overlay.refresh();
const [complete2, partial2] = overlay.stat(cursor, 1e6);
const needsErase = complete2.has(type) || partial2.has(type);
if (needsErase) store.insErase(type);
} else {
if (cursor.start.isAbs() || cursor.end.isAbs()) continue;
store.insOverwrite(type, data);
}
}
}

public eraseFormatting(store: EditorSlices<T> = this.saved): void {
const overlay = this.txt.overlay;
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
overlay.refresh();
const contained = overlay.findContained(cursor);
for (const slice of contained) {
if (slice instanceof PersistedSlice) {
switch (slice.behavior) {
case SliceBehavior.One:
case SliceBehavior.Many:
case SliceBehavior.Erase: {
const deletionStore = this.getSliceStore(slice);
if (deletionStore) deletionStore.del(slice.id);
}
}
}
}
overlay.refresh();
const overlapping = overlay.findOverlapping(cursor);
for (const slice of overlapping) {
switch (slice.behavior) {
case SliceBehavior.One:
case SliceBehavior.Many: {
store.insErase(slice.type);
}
}
}
}
}

public clearFormatting(store: EditorSlices<T> = this.saved): void {
const overlay = this.txt.overlay;
overlay.refresh();
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
const overlapping = overlay.findOverlapping(cursor);
for (const slice of overlapping) store.del(slice.id);
}
}

// ------------------------------------------------------------------ various

public point(at: Position<T>): Point<T> {
Expand Down
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 @@ -355,7 +355,7 @@ export class Overlay<T = string> implements Printable, Stateful {
if (typeof type === 'object') continue LAYERS;
const behavior = slice.behavior;
BEHAVIOR: switch (behavior) {
case SliceBehavior.Overwrite:
case SliceBehavior.One:
current.add(type);
break BEHAVIOR;
case SliceBehavior.Erase:
Expand Down
6 changes: 5 additions & 1 deletion src/json-crdt-extensions/peritext/rga/Point.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {compare, type ITimestampStruct, printTs, equal, tick, containsId} from '../../../json-crdt-patch/clock';
import {Anchor} from './constants';
import {ChunkSlice} from '../util/ChunkSlice';
import {updateId} from '../../../json-crdt/hash';
import {hashId, updateId} from '../../../json-crdt/hash';
import {Position} from '../constants';
import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga';
import type {Stateful} from '../types';
Expand Down Expand Up @@ -481,6 +481,10 @@ export class Point<T = string> implements Pick<Stateful, 'refresh'>, Printable {
return this.step(length / 2);
}

public key(): number {
return hashId(this.id) + (this.anchor ? 0 : 1);
}

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

public refresh(): number {
Expand Down
14 changes: 12 additions & 2 deletions src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import {Range} from '../rga/Range';
import {updateNode} from '../../../json-crdt/hash';
import {printTree} from 'tree-dump/lib/printTree';
import type {Anchor} from '../rga/constants';
import {SliceHeaderMask, SliceHeaderShift, SliceBehavior, SliceTupleIndex, SliceBehaviorName} from './constants';
import {
SliceHeaderMask,
SliceHeaderShift,
SliceBehavior,
SliceTupleIndex,
SliceBehaviorName,
CommonSliceType,
} from './constants';
import {CONST} from '../../../json-hash';
import {Timestamp} from '../../../json-crdt-patch/clock';
import type {VecNode} from '../../../json-crdt/nodes';
Expand Down Expand Up @@ -165,7 +172,10 @@ export class PersistedSlice<T = string> extends Range<T> implements MutableSlice
// ---------------------------------------------------------------- Printable

public toStringName(): string {
return 'Range';
if (typeof this.type === 'number' && Math.abs(this.type) <= 64 && CommonSliceType[this.type]) {
return `slice [${SliceBehaviorName[this.behavior]}] <${CommonSliceType[this.type]}>`;
}
return `slice [${SliceBehaviorName[this.behavior]}] ${JSON.stringify(this.type)}`;
}

protected toStringHeaderName(): string {
Expand Down
5 changes: 3 additions & 2 deletions src/json-crdt-extensions/peritext/slice/Slices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ export class Slices<T = string> implements Stateful, Printable {
}

public insStack(range: Range<T>, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice<T> {
return this.ins(range, SliceBehavior.Stack, type, data);
return this.ins(range, SliceBehavior.Many, type, data);
}

public insOverwrite(range: Range<T>, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice<T> {
return this.ins(range, SliceBehavior.Overwrite, type, data);
return this.ins(range, SliceBehavior.One, type, data);
}

public insErase(range: Range<T>, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice<T> {
Expand Down Expand Up @@ -139,6 +139,7 @@ export class Slices<T = string> implements Stateful, Printable {
this.list.del(id);
const set = this.set;
const api = set.doc.api;
if (!set.findById(id)) return;
// TODO: Is it worth checking if the slice is already deleted?
api.builder.del(set.id, [tss(id.sid, id.time, 1)]);
api.apply();
Expand Down
10 changes: 5 additions & 5 deletions src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ describe('.ins()', () => {
test('can insert a slice', () => {
const {peritext, slices} = setup();
const range = peritext.rangeAt(12, 7);
const slice = slices.ins(range, SliceBehavior.Stack, 'b', {bold: true});
const slice = slices.ins(range, SliceBehavior.Many, 'b', {bold: true});
expect(peritext.savedSlices.size()).toBe(1);
expect(slice.start).toStrictEqual(range.start);
expect(slice.end).toStrictEqual(range.end);
expect(slice.behavior).toBe(SliceBehavior.Stack);
expect(slice.behavior).toBe(SliceBehavior.Many);
expect(slice.type).toBe('b');
expect(slice.data()).toStrictEqual({bold: true});
});
Expand Down Expand Up @@ -80,7 +80,7 @@ describe('.ins()', () => {
const ranges = [r1, r2, r3, r4];
const types = ['b', ['li', 'ul'], 0, 123, [1, 2, 3]];
const datas = [{bold: true}, {list: 'ul'}, 0, 123, [1, 2, 3], null, undefined];
const behaviors = [SliceBehavior.Stack, SliceBehavior.Erase, SliceBehavior.Overwrite, SliceBehavior.Marker];
const behaviors = [SliceBehavior.Many, SliceBehavior.Erase, SliceBehavior.One, SliceBehavior.Marker];
for (const range of ranges) {
for (const type of types) {
for (const data of datas) {
Expand Down Expand Up @@ -176,8 +176,8 @@ describe('.refresh()', () => {
};

testSliceUpdate('slice behavior change', ({slice}) => {
slice.update({behavior: SliceBehavior.Stack});
expect(slice.behavior).toBe(SliceBehavior.Stack);
slice.update({behavior: SliceBehavior.Many});
expect(slice.behavior).toBe(SliceBehavior.Many);
});

testSliceUpdate('slice type change', ({slice}) => {
Expand Down
46 changes: 31 additions & 15 deletions src/json-crdt-extensions/peritext/slice/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export enum CursorAnchor {
* Built-in slice types.
*/
export enum CommonSliceType {
// Block slices
// ---------------------------------------------------- block slices (0 to 64)
p = 0, // <p>
blockquote = 1, // <blockquote>
codeblock = 2, // <pre><code>
Expand All @@ -36,7 +36,7 @@ export enum CommonSliceType {
aside = 19, // <aside>
embed = 20, // <embed>, <iframe>, <object>, <video>, <audio>, etc.

// Inline slices
// ------------------------------------------------ inline slices (-64 to -1)
Cursor = -1,
RemoteCursor = -2,
b = -3, // <b>
Expand Down Expand Up @@ -77,27 +77,43 @@ export enum SliceHeaderShift {

export enum SliceBehavior {
/**
* A Split slice, which is used to mark a block split position in the document.
* For example, paragraph, heading, blockquote, etc.
* The `Marker` slices are used to mark a block split position in the
* document. For example, paragraph, heading, blockquote, etc. It separates
* adjacent blocks and is used to determine the block type of the contents
* following the marker, until the next marker is encountered.
*/
Marker = 0b000,

/**
* Appends attributes to a stack of attributes for a specific slice type. This
* is useful when the same slice type can have multiple attributes, like
* inline comments, highlights, etc.
* The `Many` slices are inline formatting annotations, which allow one
* or more annotations of the same type to apply to the same text. Slices with
* behavior `Many` are appended to the stack of attributes for a specific
* slice type. With the most recent annotation on top.
*
* Slices with behavior `Many` are used for inline formatting, like for links,
* comments, etc. Where multiple annotations of the same type can be applied
* to the same text.
*/
Stack = 0b001,
Many = 0b001,

/**
* Overwrites the stack of attributes for a specific slice type. Could be used
* for simple inline formatting, like bold, italic, etc.
* The slices with behavior `One` are used for inline formatting annotations,
* they overwrite the stack of attributes for a specific slice type. This type
* of slice is used when only one annotation of a specific type can be applied
* to the same text. For example, those could be used for simple inline
* formatting, like bold, italic, etc.
*/
Overwrite = 0b010,
One = 0b010,

/**
* Removes all attributes for a specific slice type. For example, could be
* used to reverse inline formatting, like bold, italic, etc.
* The `Erase` slices are used to soft remove all annotations
* (`Many` or `One`) of the same type which are applied to the same text
* range. The erase slices soft remove only the annotations which were applied
* before the erase slice, as determined by the logical clock (there could
* be many layers of annotations applied and erased).
*
* Usually slices with behavior `Erase` are used to reverse inline exclusive
* (`One`) inline formatting, like bold, italic, etc.
*/
Erase = 0b011,

Expand All @@ -111,8 +127,8 @@ export enum SliceBehavior {

export enum SliceBehaviorName {
Marker = SliceBehavior.Marker,
Stack = SliceBehavior.Stack,
Overwrite = SliceBehavior.Overwrite,
Many = SliceBehavior.Many,
One = SliceBehavior.One,
Erase = SliceBehavior.Erase,
Cursor = SliceBehavior.Cursor,
}
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const updateAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | unde
if (value === null) {
savedSlices.ins(range, SliceBehavior.Erase, key);
} else {
savedSlices.ins(range, SliceBehavior.Overwrite, key, konst(value));
savedSlices.ins(range, SliceBehavior.One, key, konst(value));
}
}
};
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt-extensions/quill-delta/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const getAttributes = (overlayPoint: OverlayPoint): QuillDeltaAttributes
const slice = layers[i];
if (!(slice instanceof PersistedSlice)) continue;
switch (slice.behavior) {
case SliceBehavior.Overwrite: {
case SliceBehavior.One: {
const tag = slice.type as PathStep;
if (tag) attributes[tag] = slice.data();
break;
Expand Down
6 changes: 3 additions & 3 deletions src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import * as React from 'react';
import {Provider, GlobalCss} from 'nano-theme';
import {ModelWithExt, ext} from '../../../json-crdt-extensions';
import {PeritextView} from '../../react';
import {renderers} from '../../renderers/default';
import {renderers as debugRenderers} from '../../renderers/debug';
import {renderers} from '../../plugins/minimal';
import {renderers as debugRenderers} from '../../plugins/debug';

export const App: React.FC = () => {
const [[model, peritext]] = React.useState(() => {
Expand All @@ -17,7 +17,7 @@ export const App: React.FC = () => {
<Provider theme={'light'}>
<GlobalCss />
<div style={{maxWidth: '640px', fontSize: '21px', margin: '32px auto'}}>
<PeritextView peritext={peritext} renderers={[debugRenderers({enabled: false}), renderers]} />
<PeritextView peritext={peritext} renderers={[debugRenderers({enabled: true}), renderers]} />
</div>
</Provider>
);
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt-peritext-ui/dom/InputController.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {unit} from './util';
import type {Peritext} from '../../json-crdt-extensions/peritext';
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
import type {TypedEventTarget} from '../events/TypedEventTarget';
import type {TypedEventTarget} from '../../util/events/TypedEventTarget';
import type {CompositionController} from './CompositionController';
import type {UiLifeCycles} from './types';

Expand Down
Loading

0 comments on commit 8b7548d

Please sign in to comment.