Skip to content

Commit eb3ab59

Browse files
authored
Merge pull request #839 from streamich/block-discriminants
Peritext block element discriminants
2 parents 772e247 + 241077f commit eb3ab59

File tree

10 files changed

+457
-178
lines changed

10 files changed

+457
-178
lines changed

src/json-crdt-extensions/peritext/block/Inline.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,15 @@ export class Inline<T = string> extends Range<T> implements Printable {
317317
printTree(
318318
tab,
319319
attrKeys.map((key) => () => {
320-
return (
321-
formatType(key) +
322-
' = ' +
323-
stringify(
324-
attr[key].map((attr) =>
325-
attr.slice instanceof Cursor ? [attr.slice.type, attr.slice.data()] : attr.slice.data(),
326-
),
327-
)
328-
);
320+
return key === '-1'
321+
? '▚ (cursor)'
322+
: formatType(key) +
323+
' = ' +
324+
stringify(
325+
attr[key].map((attr) =>
326+
attr.slice instanceof Cursor ? [attr.slice.type, attr.slice.data()] : attr.slice.data(),
327+
),
328+
);
329329
}),
330330
),
331331
!texts.length

src/json-crdt-extensions/peritext/editor/Editor.ts

Lines changed: 157 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
1-
import {printTree} from 'tree-dump/lib/printTree';
21
import {Cursor} from './Cursor';
3-
import {stringify} from '../../../json-text/stringify';
4-
import {CursorAnchor, SliceBehavior, SliceHeaderMask, SliceHeaderShift} from '../slice/constants';
2+
import {Anchor} from '../rga/constants';
3+
import {formatType} from '../slice/util';
54
import {EditorSlices} from './EditorSlices';
65
import {next, prev} from 'sonic-forest/lib/util';
7-
import {isLetter, isPunctuation, isWhitespace} from './util';
8-
import {Anchor} from '../rga/constants';
9-
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
10-
import {UndefEndIter, type UndefIterator} from '../../../util/iterator';
6+
import {printTree} from 'tree-dump/lib/printTree';
7+
import {createRegistry} from '../registry/registry';
118
import {PersistedSlice} from '../slice/PersistedSlice';
9+
import {stringify} from '../../../json-text/stringify';
10+
import {CommonSliceType, type SliceTypeSteps, type SliceType} from '../slice';
11+
import {isLetter, isPunctuation, isWhitespace, stepsEqual} from './util';
1212
import {ValueSyncStore} from '../../../util/events/sync-store';
13-
import {formatType} from '../slice/util';
14-
import {CommonSliceType, type SliceType} from '../slice';
13+
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
14+
import {UndefEndIter, type UndefIterator} from '../../../util/iterator';
1515
import {tick, Timespan, type ITimespanStruct} from '../../../json-crdt-patch';
16-
import type {ChunkSlice} from '../util/ChunkSlice';
17-
import type {Peritext} from '../Peritext';
16+
import {CursorAnchor, SliceBehavior, SliceHeaderMask, SliceHeaderShift, SliceTypeCon} from '../slice/constants';
1817
import type {Point} from '../rga/Point';
1918
import type {Range} from '../rga/Range';
20-
import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice} from './types';
2119
import type {Printable} from 'tree-dump';
20+
import type {Peritext} from '../Peritext';
21+
import type {ChunkSlice} from '../util/ChunkSlice';
2222
import type {MarkerSlice} from '../slice/MarkerSlice';
23+
import type {SliceRegistry} from '../registry/SliceRegistry';
24+
import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice} from './types';
2325

2426
/**
2527
* For inline boolean ("Overwrite") slices, both range endpoints should be
@@ -51,6 +53,8 @@ export class Editor<T = string> implements Printable {
5153
*/
5254
public readonly pending = new ValueSyncStore<Map<CommonSliceType | string | number, unknown>>(new Map());
5355

56+
public registry: SliceRegistry = createRegistry();
57+
5458
constructor(public readonly txt: Peritext<T>) {
5559
this.saved = new EditorSlices(txt, txt.savedSlices);
5660
this.extra = new EditorSlices(txt, txt.extraSlices);
@@ -590,6 +594,45 @@ export class Editor<T = string> implements Printable {
590594

591595
// --------------------------------------------------------------- formatting
592596

597+
public eraseFormatting(store: EditorSlices<T> = this.saved): void {
598+
const overlay = this.txt.overlay;
599+
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
600+
overlay.refresh();
601+
const contained = overlay.findContained(cursor);
602+
for (const slice of contained) {
603+
if (slice instanceof PersistedSlice) {
604+
switch (slice.behavior) {
605+
case SliceBehavior.One:
606+
case SliceBehavior.Many:
607+
case SliceBehavior.Erase:
608+
slice.del();
609+
}
610+
}
611+
}
612+
overlay.refresh();
613+
const overlapping = overlay.findOverlapping(cursor);
614+
for (const slice of overlapping) {
615+
switch (slice.behavior) {
616+
case SliceBehavior.One:
617+
case SliceBehavior.Many: {
618+
store.insErase(slice.type);
619+
}
620+
}
621+
}
622+
}
623+
}
624+
625+
public clearFormatting(store: EditorSlices<T> = this.saved): void {
626+
const overlay = this.txt.overlay;
627+
overlay.refresh();
628+
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
629+
const overlapping = overlay.findOverlapping(cursor);
630+
for (const slice of overlapping) store.del(slice.id);
631+
}
632+
}
633+
634+
// -------------------------------------------------------- inline formatting
635+
593636
protected toggleRangeExclFmt(
594637
range: Range<T>,
595638
type: CommonSliceType | string | number,
@@ -645,54 +688,7 @@ export class Editor<T = string> implements Printable {
645688
}
646689
}
647690

648-
public eraseFormatting(store: EditorSlices<T> = this.saved): void {
649-
const overlay = this.txt.overlay;
650-
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
651-
overlay.refresh();
652-
const contained = overlay.findContained(cursor);
653-
for (const slice of contained) {
654-
if (slice instanceof PersistedSlice) {
655-
switch (slice.behavior) {
656-
case SliceBehavior.One:
657-
case SliceBehavior.Many:
658-
case SliceBehavior.Erase:
659-
slice.del();
660-
}
661-
}
662-
}
663-
overlay.refresh();
664-
const overlapping = overlay.findOverlapping(cursor);
665-
for (const slice of overlapping) {
666-
switch (slice.behavior) {
667-
case SliceBehavior.One:
668-
case SliceBehavior.Many: {
669-
store.insErase(slice.type);
670-
}
671-
}
672-
}
673-
}
674-
}
675-
676-
public clearFormatting(store: EditorSlices<T> = this.saved): void {
677-
const overlay = this.txt.overlay;
678-
overlay.refresh();
679-
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
680-
const overlapping = overlay.findOverlapping(cursor);
681-
for (const slice of overlapping) store.del(slice.id);
682-
}
683-
}
684-
685-
public split(type?: SliceType, data?: unknown, slices: EditorSlices<T> = this.saved): void {
686-
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
687-
this.collapseCursor(cursor);
688-
if (type === void 0) {
689-
// TODO: detect current block type
690-
type = CommonSliceType.p;
691-
}
692-
slices.insMarker(type, data);
693-
cursor.move(1);
694-
}
695-
}
691+
// --------------------------------------------------------- block formatting
696692

697693
/**
698694
* Returns block split marker of the block inside which the point is located.
@@ -704,6 +700,21 @@ export class Editor<T = string> implements Printable {
704700
return this.txt.overlay.getOrNextLowerMarker(point)?.marker;
705701
}
706702

703+
/**
704+
* Returns the block type at the given point. Block type is a nested array of
705+
* tags, e.g. `['p']`, `['blockquote', 'p']`, `['ul', 'li', 'p']`, etc.
706+
*
707+
* @param point The point to get the block type at.
708+
* @returns Current block type at the point.
709+
*/
710+
public getBlockType(point: Point<T>): [type: SliceTypeSteps, marker?: MarkerSlice<T> | undefined] {
711+
const marker = this.getMarker(point);
712+
if (!marker) return [[SliceTypeCon.p]];
713+
let steps = marker?.type ?? [SliceTypeCon.p];
714+
if (!Array.isArray(steps)) steps = [steps];
715+
return [steps, marker];
716+
}
717+
707718
/**
708719
* Insert a block split at the start of the document. The start of the
709720
* document is defined as immediately after all deleted characters starting
@@ -739,6 +750,92 @@ export class Editor<T = string> implements Printable {
739750
return this.insStartMarker(type);
740751
}
741752

753+
public getContainerPath(steps: SliceTypeSteps): SliceTypeSteps {
754+
const registry = this.registry;
755+
const length = steps.length;
756+
for (let i = length - 1; i >= 0; i--) {
757+
const step = steps[i];
758+
const tag = Array.isArray(step) ? step[0] : step;
759+
const isContainer = registry.isContainer(tag);
760+
if (isContainer) return steps.slice(0, i + 1);
761+
}
762+
return [];
763+
}
764+
765+
public getDeepestCommonContainer(steps1: SliceTypeSteps, steps2: SliceTypeSteps): number {
766+
const length1 = steps1.length;
767+
const length2 = steps2.length;
768+
const min = Math.min(length1, length2);
769+
for (let i = 0; i < min; i++) {
770+
const step1 = steps1[i];
771+
const step2 = steps2[i];
772+
const tag1 = Array.isArray(step1) ? step1[0] : step1;
773+
const tag2 = Array.isArray(step2) ? step2[0] : step2;
774+
const disc1 = Array.isArray(step1) ? step1[1] : 0;
775+
const disc2 = Array.isArray(step2) ? step2[1] : 0;
776+
if (tag1 !== tag2 || disc1 !== disc2) return i - 1;
777+
if (!this.registry.isContainer(tag1)) return i - 1;
778+
}
779+
return min;
780+
}
781+
782+
/**
783+
* @param at Point at which split block split happens.
784+
* @param slices The slices set to use.
785+
* @returns True if a marker was inserted, false if it was updated.
786+
*/
787+
public splitAt(at: Point<T>, slices: EditorSlices<T> = this.saved): boolean {
788+
const [type, marker] = this.getBlockType(at);
789+
const prevMarker = marker ? this.getMarker(marker.start.copy((p) => p.halfstep(-1))) : void 0;
790+
if (marker && prevMarker) {
791+
const rangeFromMarker = this.txt.range(marker.start, at);
792+
const noLeadingText = rangeFromMarker.length() <= 1;
793+
if (noLeadingText) {
794+
const markerSteps = marker.typeSteps();
795+
const prevMarkerSteps = prevMarker.typeSteps();
796+
if (markerSteps.length > 1) {
797+
const areMarkerTypesEqual = stepsEqual(markerSteps, prevMarkerSteps);
798+
if (areMarkerTypesEqual) {
799+
const i = this.getDeepestCommonContainer(markerSteps, prevMarkerSteps);
800+
if (i >= 0) {
801+
const newType = [...markerSteps];
802+
const step = newType[i];
803+
const tag = Array.isArray(step) ? step[0] : step;
804+
const disc = Array.isArray(step) ? step[1] : 0;
805+
newType[i] = [tag, (disc + 1) % 8];
806+
marker.update({type: newType});
807+
return false;
808+
}
809+
}
810+
}
811+
}
812+
}
813+
const containerPath = this.getContainerPath(type);
814+
const newType = containerPath.concat([CommonSliceType.p]);
815+
slices.insMarker(newType);
816+
return true;
817+
}
818+
819+
public split(type?: SliceType, data?: unknown, slices: EditorSlices<T> = this.saved): void {
820+
if (type === void 0) {
821+
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
822+
this.collapseCursor(cursor);
823+
const didInsertMarker = this.splitAt(cursor.start, slices);
824+
if (didInsertMarker) cursor.move(1);
825+
}
826+
} else {
827+
for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
828+
this.collapseCursor(cursor);
829+
if (type === void 0) {
830+
// TODO: detect current block type
831+
type = CommonSliceType.p;
832+
}
833+
slices.insMarker(type, data);
834+
cursor.move(1);
835+
}
836+
}
837+
}
838+
742839
// ---------------------------------------------------------- export / import
743840

744841
public export(range: Range<T>): ViewRange {

src/json-crdt-extensions/peritext/editor/util.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {SliceTypeSteps} from '../slice';
12
import type {CharPredicate} from './types';
23

34
const LETTER_REGEX = /(\p{Letter}|\d|_)/u;
@@ -6,3 +7,20 @@ const WHITESPACE_REGEX = /\s/;
67
export const isLetter: CharPredicate<string> = (char: string) => LETTER_REGEX.test(char[0]);
78
export const isWhitespace: CharPredicate<string> = (char: string) => WHITESPACE_REGEX.test(char[0]);
89
export const isPunctuation: CharPredicate<string> = (char: string) => !isLetter(char) && !isWhitespace(char);
10+
11+
/**
12+
* Compares two block slice types, ignores tag discriminants.
13+
*/
14+
export const stepsEqual = (a: SliceTypeSteps, b: SliceTypeSteps): boolean => {
15+
const lenA = a.length;
16+
const lenB = b.length;
17+
if (lenA !== lenB) return false;
18+
for (let i = 0; i < lenA; i++) {
19+
const stepA = a[i];
20+
const stepB = b[i];
21+
const tagA = Array.isArray(stepA) ? stepA[0] : stepA;
22+
const tagB = Array.isArray(stepB) ? stepB[0] : stepB;
23+
if (tagA !== tagB) return false;
24+
}
25+
return true;
26+
};

src/json-crdt-extensions/peritext/registry/SliceRegistry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export class SliceRegistryEntry<
1919
}
2020

2121
constructor(
22+
/**
23+
* Specifies whether the slice is an inline or block element. And if it is
24+
* an inline element, whether multiple instances of the same tag are allowed
25+
* to be applied to a range of tex - "Many", or only one instance - "One".
26+
*/
2227
public readonly behavior: Behavior,
2328

2429
/**
@@ -93,6 +98,10 @@ export class SliceRegistryEntry<
9398
}
9499

95100
/**
101+
* Slice registry contains a record of possible inline an block formatting
102+
* annotations. Each entry in the registry is a {@link SliceRegistryEntry} that
103+
* specifies the behavior, tag, and other properties of the slice.
104+
*
96105
* @todo Consider moving the registry under the `/transfer` directory. Or maybe
97106
* `/slices` directory.
98107
*/
@@ -102,6 +111,7 @@ export class SliceRegistry {
102111

103112
public add(entry: SliceRegistryEntry<any, any, any>): void {
104113
const {tag, fromHtml} = entry;
114+
this.map.set(tag, entry);
105115
const _fromHtml = this._fromHtml;
106116
if (fromHtml) {
107117
for (const htmlTag in fromHtml) {
@@ -115,6 +125,11 @@ export class SliceRegistry {
115125
if (tagStr && typeof tagStr === 'string') _fromHtml.set(tagStr, [[entry, () => [tag, null]]]);
116126
}
117127

128+
public isContainer(tag: TagType): boolean {
129+
const entry = this.map.get(tag);
130+
return entry?.container ?? false;
131+
}
132+
118133
public toHtml(el: PeritextMlElement): ReturnType<ToHtmlConverter<any>> | undefined {
119134
const entry = this.map.get(el[0]);
120135
return entry?.toHtml ? entry?.toHtml(el) : void 0;

0 commit comments

Comments
 (0)