Skip to content

Commit

Permalink
Merge pull request #761 from streamich/peritext-stacked-inline-commands
Browse files Browse the repository at this point in the history
Peritext formatting when selection is collapsed
  • Loading branch information
streamich authored Nov 10, 2024
2 parents 325687e + 970ad28 commit dd20452
Show file tree
Hide file tree
Showing 18 changed files with 298 additions and 120 deletions.
12 changes: 7 additions & 5 deletions src/json-crdt-extensions/peritext/block/Inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {ChunkSlice} from '../util/ChunkSlice';
import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint';
import {Cursor} from '../editor/Cursor';
import {hashId} from '../../../json-crdt/hash';
import {formatType} from '../slice/util';
import type {AbstractRga} from '../../../json-crdt/nodes/rga';
import type {Printable} from 'tree-dump/lib/types';
import type {PathStep} from '@jsonjoy.com/json-pointer';
Expand Down Expand Up @@ -269,16 +270,17 @@ export class Inline extends Range implements Printable {
'attributes' +
printTree(
tab,
attrKeys.map(
(key) => () =>
key +
attrKeys.map((key) => () => {
return (
formatType(key) +
' = ' +
stringify(
attr[key].map((attr) =>
attr.slice instanceof Cursor ? [attr.slice.type, attr.slice.data()] : attr.slice.data(),
),
),
),
)
);
}),
),
!this.texts.length
? null
Expand Down
127 changes: 96 additions & 31 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {printTree} from 'tree-dump/lib/printTree';
import {Cursor} from './Cursor';
import {stringify} from '../../../json-text/stringify';
import {CursorAnchor, SliceBehavior} from '../slice/constants';
import {EditorSlices} from './EditorSlices';
import {next, prev} from 'sonic-forest/lib/util';
Expand All @@ -7,12 +9,15 @@ 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 {ValueSyncStore} from '../../../util/events/sync-store';
import {formatType} from '../slice/util';
import type {CommonSliceType} 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';
import type {Printable} from 'tree-dump';

/**
* For inline boolean ("Overwrite") slices, both range endpoints should be
Expand All @@ -32,11 +37,18 @@ const makeRangeExtendable = <T>(range: Range<T>): void => {
}
};

export class Editor<T = string> {
export class Editor<T = string> implements Printable {
public readonly saved: EditorSlices<T>;
public readonly extra: EditorSlices<T>;
public readonly local: EditorSlices<T>;

/**
* Formatting which will be applied to the next inserted text. This is a
* temporary store for formatting which is not yet applied to the text, but
* will be if the cursor is not moved.
*/
public readonly pending = new ValueSyncStore<Map<CommonSliceType | string | number, unknown>>(new Map());

constructor(public readonly txt: Peritext<T>) {
this.saved = new EditorSlices(txt, txt.savedSlices);
this.extra = new EditorSlices(txt, txt.extraSlices);
Expand All @@ -49,7 +61,7 @@ export class Editor<T = string> {

// ------------------------------------------------------------------ cursors

public addCursor(range: Range<T>, anchor: CursorAnchor = CursorAnchor.Start): Cursor<T> {
public addCursor(range: Range<T> = this.txt.rangeAt(0), anchor: CursorAnchor = CursorAnchor.Start): Cursor<T> {
const cursor = this.txt.localSlices.ins<Cursor<T>, typeof Cursor>(
range,
SliceBehavior.Cursor,
Expand All @@ -73,7 +85,7 @@ export class Editor<T = string> {
for (let i: Cursor<T> | undefined, iterator = this.cursors0(); (i = iterator()); )
if (!cursor) cursor = i;
else this.local.del(i);
return cursor ?? this.addCursor(this.txt.rangeAt(0));
return cursor ?? this.addCursor();
}

public cursors0(): UndefIterator<Cursor<T>> {
Expand All @@ -98,6 +110,11 @@ export class Editor<T = string> {
return cnt;
}

/** Returns true if there is at least one cursor in the document. */
public hasCursor(): boolean {
return !!this.cursors0()();
}

public delCursor(cursor: Cursor<T>): void {
this.local.del(cursor);
}
Expand All @@ -113,12 +130,18 @@ export class Editor<T = string> {
* the range is removed and the text is inserted at the start of the range.
*/
public insert(text: string): void {
let cnt = 0;
this.forCursor((cursor) => {
cnt++;
if (!this.hasCursor()) this.addCursor();
for (let cursor: Cursor<T> | undefined, i = this.cursors0(); (cursor = i()); ) {
cursor.insert(text);
});
if (!cnt) this.cursor.insert(text);
const pending = this.pending.value;
if (pending.size) {
this.pending.next(new Map());
const start = cursor.start.clone();
start.step(-text.length);
const range = this.txt.range(start, cursor.end.clone());
for (const [type, data] of pending) this.toggleRangeExclFmt(range, type, data);
}
}
}

/**
Expand Down Expand Up @@ -491,30 +514,63 @@ export class Editor<T = string> {
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);
}
protected toggleRangeExclFmt(
range: Range<T>,
type: CommonSliceType | string | number,
data?: unknown,
store: EditorSlices<T> = this.saved,
): void {
if (range.isCollapsed()) throw new Error('Range is collapsed');
const txt = this.txt;
const overlay = txt.overlay;
const [complete] = overlay.stat(range, 1e6);
const needToRemoveFormatting = complete.has(type);
makeRangeExtendable(range);
const contained = overlay.findContained(range);
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(range, 1e6);
const needsErase = complete2.has(type) || partial2.has(type);
if (needsErase) store.slices.insErase(range, type);
} else {
if (range.start.isAbs()) {
const start = txt.pointStart();
if (!start) return;
if (start.cmpSpatial(range.end) >= 0) return;
range.start = start;
}
if (range.end.isAbs()) {
const end = txt.pointEnd();
if (!end) return;
if (end.cmpSpatial(range.start) <= 0) return;
range.end = end;
}
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);
store.slices.insOverwrite(range, type, data);
}
}

public toggleExclFmt(
type: CommonSliceType | string | number,
data?: unknown,
store: EditorSlices<T> = this.saved,
): void {
// TODO: handle mutually exclusive slices (<sub>, <sub>)
this.txt.overlay.refresh();
CURSORS: for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) {
if (cursor.isCollapsed()) {
const pending = this.pending.value;
if (pending.has(type)) pending.delete(type);
else pending.set(type, data);
this.pending.next(pending);
continue CURSORS;
}
this.toggleRangeExclFmt(cursor, type, data, store);
}
}

Expand Down Expand Up @@ -572,4 +628,13 @@ export class Editor<T = string> {
const txt = this.txt;
return txt.pointStart() ?? txt.pointAbsStart();
}

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

public toString(tab: string = ''): string {
const pending = this.pending.value;
const pendingFormatted = {} as any;
for (const [type, data] of pending) pendingFormatted[formatType(type)] = data;
return 'Editor' + printTree(tab, [() => `pending ${stringify(pendingFormatted)}`]);
}
}
1 change: 1 addition & 0 deletions src/json-crdt-extensions/peritext/slice/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum CommonSliceType {
page = 18, // Page break
aside = 19, // <aside>
embed = 20, // <embed>, <iframe>, <object>, <video>, <audio>, etc.
column = 21, // <div style="column-count: ..."> (represents 2 and 3 column layouts)

// ------------------------------------------------ inline slices (-64 to -1)
Cursor = -1,
Expand Down
9 changes: 9 additions & 0 deletions src/json-crdt-extensions/peritext/slice/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {CommonSliceType} from './constants';
import type {SliceType} from '../slice/types';

export const validateType = (type: SliceType) => {
Expand Down Expand Up @@ -25,3 +26,11 @@ export const validateType = (type: SliceType) => {
throw new Error('INVALID_TYPE');
}
};

export const formatType = (type: SliceType): string => {
let formatted: string = JSON.stringify(type);
const num = Number(type);
if ((typeof type === 'number' || num + '' === type) && Math.abs(num) <= 64 && CommonSliceType[num])
formatted = '<' + CommonSliceType[num] + '>';
return formatted;
};
2 changes: 1 addition & 1 deletion src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: true}), renderers]} />
<PeritextView peritext={peritext} renderers={[renderers, debugRenderers({enabled: true})]} />
</div>
</Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ const blockClass = drule({
bd: 0,
col: 'black',
ff: 'inherit',
fz: '14px',
fw: 500,
lh: '1.15em',
mr: 'none',
Expand All @@ -16,19 +15,24 @@ const blockClass = drule({

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
small?: boolean;
children: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({active, children, ...rest}) => {
export const Button: React.FC<ButtonProps> = ({active, small, children, ...rest}) => {
const className =
(rest.className || '') +
blockClass({
bdrad: active ? '.6em' : '.4em',
bdrad: active ? '12px' : '6px',
bg: active ? '#07f' : 'rgba(61, 37, 20, .08)',
col: active ? 'white' : 'black',
fz: small ? '11px' : '14px',
'&:hover': {
bg: active ? '#06e' : 'rgba(61, 37, 20, .12)',
},
'&:active': {
bg: active ? '#05d' : 'rgba(61, 37, 20, .18)',
},
});

return (
Expand Down
24 changes: 24 additions & 0 deletions src/json-crdt-peritext-ui/components/ButtonGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// biome-ignore lint: React is used for JSX
import * as React from 'react';
import {rule} from 'nano-theme';

const blockClass = rule({
d: 'flex',
flw: 'wrap',
columnGap: '4px',
rowGap: '4px',
w: '100%',
maxW: '100%',
bxz: 'border-box',
ff: 'Inter, ui-sans-serif, system-ui, -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
lh: '24px',
mr: 0,
});

export interface ButtonGroup {
children?: React.ReactNode;
}

export const ButtonGroup: React.FC<ButtonGroup> = ({children}) => {
return <div className={blockClass}>{children}</div>;
};
2 changes: 1 addition & 1 deletion src/json-crdt-peritext-ui/events/PeritextEventDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap {
}
case 'one': {
if (type === undefined) throw new Error('TYPE_REQUIRED');
editor.toggleExclusiveFormatting(type, data, slices);
editor.toggleExclFmt(type, data, slices);
break;
}
case 'erase': {
Expand Down
52 changes: 52 additions & 0 deletions src/json-crdt-peritext-ui/plugins/debug/Console/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// biome-ignore lint: React is used for JSX
import * as React from 'react';
import {rule} from 'nano-theme';
import {useDebugCtx} from '../context';
import {ButtonGroup} from '../../../components/ButtonGroup';
import {Button} from '../../../components/Button';
import {useSyncStore} from '../../../react/hooks';

const consoleClass = rule({
bxz: 'border-box',
bg: '#fafafa',
fz: '8px',
mr: '8px 0',
pd: '8px 16px',
bdrad: '8px',
});

// biome-ignore lint: empty interface
export type ConsoleProps = {};

export const Console: React.FC<ConsoleProps> = () => {
const {ctx, flags} = useDebugCtx();
const dom = useSyncStore(flags.dom);
const editor = useSyncStore(flags.editor);
const peritext = useSyncStore(flags.peritext);
const model = useSyncStore(flags.model);

if (!ctx) return null;

return (
<div className={consoleClass}>
<ButtonGroup>
<Button small active={dom} onClick={() => flags.dom.next(!dom)}>
DOM
</Button>
<Button small active={editor} onClick={() => flags.editor.next(!editor)}>
Editor
</Button>
<Button small active={peritext} onClick={() => flags.peritext.next(!peritext)}>
Peritext
</Button>
<Button small active={model} onClick={() => flags.model.next(!model)}>
Model
</Button>
</ButtonGroup>
{!!dom && <pre>{ctx.dom + ''}</pre>}
{!!editor && <pre>{ctx.peritext.editor + ''}</pre>}
{!!peritext && <pre>{ctx.peritext + ''}</pre>}
{!!model && <pre>{ctx.peritext.model + ''}</pre>}
</div>
);
};
Loading

0 comments on commit dd20452

Please sign in to comment.