diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts
index 70bdc0dec7..5b9b8c96d9 100644
--- a/src/json-crdt-extensions/peritext/block/Inline.ts
+++ b/src/json-crdt-extensions/peritext/block/Inline.ts
@@ -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;
}
diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts
index 3d699c4513..9ed5be3368 100644
--- a/src/json-crdt-extensions/peritext/editor/Editor.ts
+++ b/src/json-crdt-extensions/peritext/editor/Editor.ts
@@ -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 = (range: Range): 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 {
public readonly saved: EditorSlices;
public readonly extra: EditorSlices;
@@ -461,6 +481,82 @@ export class Editor {
if (unit) this.select(unit);
}
+ // --------------------------------------------------------------- formatting
+
+ protected getSliceStore(slice: PersistedSlice): EditorSlices | 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 = this.saved): void {
+ // TODO: handle mutually exclusive slices (, )
+ 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 = 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 = 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): Point {
diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
index 7464d062b0..aa988f058d 100644
--- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts
+++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
@@ -355,7 +355,7 @@ export class Overlay 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:
diff --git a/src/json-crdt-extensions/peritext/rga/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts
index 77120630c4..7d3154fdd7 100644
--- a/src/json-crdt-extensions/peritext/rga/Point.ts
+++ b/src/json-crdt-extensions/peritext/rga/Point.ts
@@ -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';
@@ -481,6 +481,10 @@ export class Point implements Pick, Printable {
return this.step(length / 2);
}
+ public key(): number {
+ return hashId(this.id) + (this.anchor ? 0 : 1);
+ }
+
// ----------------------------------------------------------------- Stateful
public refresh(): number {
diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
index 0548e24705..b8d12c4b51 100644
--- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
+++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
@@ -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';
@@ -165,7 +172,10 @@ export class PersistedSlice extends Range 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 {
diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts
index 9a94169987..901fffd3c9 100644
--- a/src/json-crdt-extensions/peritext/slice/Slices.ts
+++ b/src/json-crdt-extensions/peritext/slice/Slices.ts
@@ -107,11 +107,11 @@ export class Slices implements Stateful, Printable {
}
public insStack(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
- return this.ins(range, SliceBehavior.Stack, type, data);
+ return this.ins(range, SliceBehavior.Many, type, data);
}
public insOverwrite(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
- return this.ins(range, SliceBehavior.Overwrite, type, data);
+ return this.ins(range, SliceBehavior.One, type, data);
}
public insErase(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
@@ -139,6 +139,7 @@ export class Slices 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();
diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
index 96cd84e769..50ee4c4df8 100644
--- a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
+++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
@@ -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});
});
@@ -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) {
@@ -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}) => {
diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts
index 4fd36bf551..a3adf9852c 100644
--- a/src/json-crdt-extensions/peritext/slice/constants.ts
+++ b/src/json-crdt-extensions/peritext/slice/constants.ts
@@ -13,7 +13,7 @@ export enum CursorAnchor {
* Built-in slice types.
*/
export enum CommonSliceType {
- // Block slices
+ // ---------------------------------------------------- block slices (0 to 64)
p = 0, //
blockquote = 1, //
codeblock = 2, //
@@ -36,7 +36,7 @@ export enum CommonSliceType {
aside = 19, //
` is created.
+ */
+ type?: number | string;
+
+ /**
+ * Arbitrary data associated with the formatting. Usually, stored with
+ * annotations of "stack" behavior, for example, an "" tag annotation may
+ * store the href attribute in this field.
+ *
+ * @default undefined
+ */
data?: unknown;
- behavior?: 'stack' | 'overwrite' | 'erase';
+
+ /**
+ * Specifies the behavior of the annotation. If `'many'`, the annotation of
+ * this type will be stacked on top of each other, and all of them will be
+ * applied to the text, with the last annotation on top. If `'one'`,
+ * the annotation is not stacked, only one such annotation can be applied per
+ * character. The `'erase'` behavior is used to remove the `'many`' or
+ * `'one'` annotation from the the given range.
+ *
+ * The special `'clear'` behavior is used to remove all annotations
+ * that intersect with any part of any of the cursors in the document. Usage:
+ *
+ * ```js
+ * {type: 'clear'}
+ * ```
+ *
+ * @default 'one'
+ */
+ behavior?: 'one' | 'many' | 'erase' | 'clear';
+
+ /**
+ * The slice set where the annotation will be stored. `'saved'` is the main
+ * document, which is persisted and replicated across all clients. `'extra'`
+ * is an ephemeral document, which is not persisted but can be replicated
+ * across clients. `'local'` is a local document, which is accessible only to
+ * the local client, for example, for storing cursor or selection information.
+ *
+ * @default 'saved'
+ */
store?: 'saved' | 'extra' | 'local';
- pos?: [start: Position, end: Position][];
}
// biome-ignore lint: empty interface is expected
@@ -205,6 +257,7 @@ export type PeritextEventMap = {
insert: InsertDetail;
delete: DeleteDetail;
cursor: CursorDetail;
- inline: InlineDetail;
+ format: FormatDetail;
+ // remove: FormatDetail;
marker: MarkerDetail;
};
diff --git a/src/json-crdt-peritext-ui/renderers/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/debug/RenderBlock.tsx
rename to src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/debug/RenderInline.tsx
rename to src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/debug/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/debug/RenderPeritext.tsx
rename to src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/debug/context.ts b/src/json-crdt-peritext-ui/plugins/debug/context.ts
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/debug/context.ts
rename to src/json-crdt-peritext-ui/plugins/debug/context.ts
diff --git a/src/json-crdt-peritext-ui/renderers/debug/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/index.tsx
similarity index 86%
rename from src/json-crdt-peritext-ui/renderers/debug/index.tsx
rename to src/json-crdt-peritext-ui/plugins/debug/index.tsx
index 7fc232b485..69308820ee 100644
--- a/src/json-crdt-peritext-ui/renderers/debug/index.tsx
+++ b/src/json-crdt-peritext-ui/plugins/debug/index.tsx
@@ -2,9 +2,9 @@ import * as React from 'react';
import {RenderInline} from './RenderInline';
import {RenderBlock} from './RenderBlock';
import {RenderPeritext, type RenderPeritextProps} from './RenderPeritext';
-import type {RendererMap} from '../../react/types';
+import type {PeritextPlugin} from '../../react/types';
-export const renderers = (options?: Pick): RendererMap => ({
+export const renderers = (options?: Pick): PeritextPlugin => ({
inline: (props, children) => {children},
block: (props, children) => {children},
peritext: (props, children, ctx) => (
diff --git a/src/json-crdt-peritext-ui/renderers/default/Button/index.tsx b/src/json-crdt-peritext-ui/plugins/minimal/Button/index.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/default/Button/index.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/Button/index.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/default/Chrome/index.tsx b/src/json-crdt-peritext-ui/plugins/minimal/Chrome/index.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/default/Chrome/index.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/Chrome/index.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/default/RenderAnchor.tsx b/src/json-crdt-peritext-ui/plugins/minimal/RenderAnchor.tsx
similarity index 83%
rename from src/json-crdt-peritext-ui/renderers/default/RenderAnchor.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/RenderAnchor.tsx
index de18e5e2d1..34ad670399 100644
--- a/src/json-crdt-peritext-ui/renderers/default/RenderAnchor.tsx
+++ b/src/json-crdt-peritext-ui/plugins/minimal/RenderAnchor.tsx
@@ -1,12 +1,21 @@
// biome-ignore lint: React is used for JSX
import * as React from 'react';
-import {rule} from 'nano-theme';
+import {rule, keyframes} from 'nano-theme';
import {Char} from '../../constants';
import {DefaultRendererColors} from './constants';
import {usePeritext} from '../../react';
import {useSyncStore} from '../../react/hooks';
import type {AnchorViewProps} from '../../react/selection/AnchorView';
+export const fadeInAnimation = keyframes({
+ from: {
+ tr: 'scale(0)',
+ },
+ to: {
+ tr: 'scale(1)',
+ },
+});
+
const blockClass = rule({
pos: 'relative',
d: 'inline-block',
@@ -26,6 +35,8 @@ const innerClass = rule({
h: 'calc(min(16px,0.5em))',
bdrad: '50%/30%',
bg: DefaultRendererColors.ActiveCursor,
+ an: fadeInAnimation + ' .25s ease-out',
+ animationFillMode: 'forwards',
});
export interface RenderAnchorProps extends AnchorViewProps {}
diff --git a/src/json-crdt-peritext-ui/renderers/default/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/minimal/RenderCaret.tsx
similarity index 84%
rename from src/json-crdt-peritext-ui/renderers/default/RenderCaret.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/RenderCaret.tsx
index 8fb101d4ad..d055e7faa4 100644
--- a/src/json-crdt-peritext-ui/renderers/default/RenderCaret.tsx
+++ b/src/json-crdt-peritext-ui/plugins/minimal/RenderCaret.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import useHarmonicIntervalFn from 'react-use/lib/useHarmonicIntervalFn';
-import {rule} from 'nano-theme';
+import {keyframes, rule} from 'nano-theme';
import {usePeritext} from '../../react/context';
import {useSyncStore} from '../../react/hooks';
import type {CaretViewProps} from '../../react/selection/CaretView';
@@ -8,6 +8,15 @@ import {DefaultRendererColors} from './constants';
const ms = 350;
+export const moveAnimation = keyframes({
+ from: {
+ tr: 'scale(1.2)',
+ },
+ to: {
+ tr: 'scale(1)',
+ },
+});
+
const blockClass = rule({
pos: 'relative',
d: 'inline-block',
@@ -28,6 +37,8 @@ const innerClass = rule({
bg: DefaultRendererColors.ActiveCursor,
bdrad: '0.0625em',
'mix-blend-mode': 'multiply',
+ an: moveAnimation + ' .25s ease-out',
+ animationFillMode: 'forwards',
});
export interface RenderCaretProps extends CaretViewProps {
@@ -49,7 +60,7 @@ export const RenderCaret: React.FC = ({italic, children}) => {
};
if (italic) {
- style.transform = 'rotate(11deg)';
+ style.rotate = '11deg';
}
return (
diff --git a/src/json-crdt-peritext-ui/renderers/default/RenderFocus.tsx b/src/json-crdt-peritext-ui/plugins/minimal/RenderFocus.tsx
similarity index 97%
rename from src/json-crdt-peritext-ui/renderers/default/RenderFocus.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/RenderFocus.tsx
index 77fdfa88b5..2ca403d744 100644
--- a/src/json-crdt-peritext-ui/renderers/default/RenderFocus.tsx
+++ b/src/json-crdt-peritext-ui/plugins/minimal/RenderFocus.tsx
@@ -51,7 +51,7 @@ export const RenderFocus: React.FC = ({left, italic, children}
const style: React.CSSProperties = focus ? {} : {background: DefaultRendererColors.InactiveCursor, animation: 'none'};
if (italic) {
- style.transform = 'rotate(11deg)';
+ style.rotate = '11deg';
}
return (
diff --git a/src/json-crdt-peritext-ui/renderers/default/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/minimal/RenderInline.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/default/RenderInline.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/RenderInline.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/default/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/minimal/RenderPeritext.tsx
similarity index 100%
rename from src/json-crdt-peritext-ui/renderers/default/RenderPeritext.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/RenderPeritext.tsx
diff --git a/src/json-crdt-peritext-ui/renderers/default/TopToolbar/index.tsx b/src/json-crdt-peritext-ui/plugins/minimal/TopToolbar/index.tsx
similarity index 77%
rename from src/json-crdt-peritext-ui/renderers/default/TopToolbar/index.tsx
rename to src/json-crdt-peritext-ui/plugins/minimal/TopToolbar/index.tsx
index 845e06bc43..976233d99c 100644
--- a/src/json-crdt-peritext-ui/renderers/default/TopToolbar/index.tsx
+++ b/src/json-crdt-peritext-ui/plugins/minimal/TopToolbar/index.tsx
@@ -29,11 +29,13 @@ export const TopToolbar: React.FC = () => {
const [complete] = ctx.peritext.overlay.stat(ctx.peritext.editor.cursor);
const button = (type: string | number, name: React.ReactNode) => (
-