Skip to content

Commit 827f4e4

Browse files
authored
Merge pull request #844 from streamich/soft-line-boundaries
Soft line boundaries & debug mode improvements
2 parents a06abc8 + 61f348b commit 827f4e4

29 files changed

+818
-155
lines changed

src/json-crdt-extensions/peritext/Peritext.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {Slices} from './slice/Slices';
88
import {LocalSlices} from './slice/LocalSlices';
99
import {Overlay} from './overlay/Overlay';
1010
import {Chars} from './constants';
11-
import {interval} from '../../json-crdt-patch/clock';
11+
import {interval, tick} from '../../json-crdt-patch/clock';
1212
import {Model, type StrApi} from '../../json-crdt/model';
1313
import {CONST, updateNum} from '../../json-hash';
1414
import {SESSION} from '../../json-crdt-patch/constants';
@@ -22,6 +22,7 @@ import type {MarkerSlice} from './slice/MarkerSlice';
2222
import type {SliceSchema, SliceType} from './slice/types';
2323
import type {SchemaToJsonNode} from '../../json-crdt/schema/types';
2424
import type {AbstractRga} from '../../json-crdt/nodes/rga';
25+
import type {ChunkSlice} from './util/ChunkSlice';
2526

2627
const EXTRA_SLICES_SCHEMA = s.vec(s.arr<SliceSchema>([]));
2728

@@ -183,6 +184,14 @@ export class Peritext<T = string> implements Printable {
183184
return Range.from(this.str, p1, p2);
184185
}
185186

187+
public rangeFromChunkSlice(slice: ChunkSlice<T>): Range<T> {
188+
const startId = slice.off === 0 ? slice.chunk.id : tick(slice.chunk.id, slice.off);
189+
const endId = tick(slice.chunk.id, slice.off + slice.len - 1);
190+
const start = this.point(startId, Anchor.Before);
191+
const end = this.point(endId, Anchor.After);
192+
return this.range(start, end);
193+
}
194+
186195
/**
187196
* Creates a range from two points, the points have to be in the correct
188197
* order.

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

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@ import type {Peritext} from '../Peritext';
2121
import type {ChunkSlice} from '../util/ChunkSlice';
2222
import type {MarkerSlice} from '../slice/MarkerSlice';
2323
import type {SliceRegistry} from '../registry/SliceRegistry';
24-
import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice} from './types';
24+
import type {
25+
CharIterator,
26+
CharPredicate,
27+
Position,
28+
TextRangeUnit,
29+
ViewStyle,
30+
ViewRange,
31+
ViewSlice,
32+
EditorUi,
33+
} from './types';
2534

2635
/**
2736
* For inline boolean ("Overwrite") slices, both range endpoints should be
@@ -468,7 +477,7 @@ export class Editor<T = string> implements Printable {
468477
* @param unit The unit of move per step: "char", "word", "line", etc.
469478
* @returns The destination point after the move.
470479
*/
471-
public skip(point: Point<T>, steps: number, unit: TextRangeUnit): Point<T> {
480+
public skip(point: Point<T>, steps: number, unit: TextRangeUnit, ui?: EditorUi<T>): Point<T> {
472481
if (!steps) return point;
473482
switch (unit) {
474483
case 'point': {
@@ -485,10 +494,13 @@ export class Editor<T = string> implements Printable {
485494
return point;
486495
}
487496
case 'line': {
488-
if (steps > 0) for (let i = 0; i < steps; i++) point = this.eol(point);
489-
else for (let i = 0; i < -steps; i++) point = this.bol(point);
497+
if (steps > 0) for (let i = 0; i < steps; i++) point = ui?.eol?.(point, 1) ?? this.eol(point);
498+
else for (let i = 0; i < -steps; i++) point = ui?.eol?.(point, -1) ?? this.bol(point);
490499
return point;
491500
}
501+
case 'vert': {
502+
return ui?.vert?.(point, steps) || point;
503+
}
492504
case 'block': {
493505
if (steps > 0) for (let i = 0; i < steps; i++) point = this.eob(point);
494506
else for (let i = 0; i < -steps; i++) point = this.bob(point);
@@ -507,26 +519,32 @@ export class Editor<T = string> implements Printable {
507519
* @param endpoint 0 for "focus", 1 for "anchor", 2 for both.
508520
* @param collapse Whether to collapse the range to a single point.
509521
*/
510-
public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true): void {
522+
public move(
523+
steps: number,
524+
unit: TextRangeUnit,
525+
endpoint: 0 | 1 | 2 = 0,
526+
collapse: boolean = true,
527+
ui?: EditorUi<T>,
528+
): void {
511529
this.forCursor((cursor) => {
512530
switch (endpoint) {
513531
case 0: {
514532
let point = cursor.focus();
515-
point = this.skip(point, steps, unit);
533+
point = this.skip(point, steps, unit, ui);
516534
if (collapse) cursor.set(point);
517535
else cursor.setEndpoint(point, 0);
518536
break;
519537
}
520538
case 1: {
521539
let point = cursor.anchor();
522-
point = this.skip(point, steps, unit);
540+
point = this.skip(point, steps, unit, ui);
523541
if (collapse) cursor.set(point);
524542
else cursor.setEndpoint(point, 1);
525543
break;
526544
}
527545
case 2: {
528-
const start = this.skip(cursor.start, steps, unit);
529-
const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit);
546+
const start = this.skip(cursor.start, steps, unit, ui);
547+
const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit, ui);
530548
cursor.set(start, end);
531549
break;
532550
}
@@ -572,24 +590,24 @@ export class Editor<T = string> implements Printable {
572590
* @param unit Unit of the range expansion.
573591
* @returns Range which contains the specified unit.
574592
*/
575-
public range(point: Point<T>, unit: TextRangeUnit): Range<T> | undefined {
593+
public range(point: Point<T>, unit: TextRangeUnit, ui?: EditorUi<T>): Range<T> | undefined {
576594
if (unit === 'word') return this.rangeWord(point);
577-
const point1 = this.skip(point, -1, unit);
578-
const point2 = this.skip(point, 1, unit);
595+
const point1 = this.skip(point, -1, unit, ui);
596+
const point2 = this.skip(point, 1, unit, ui);
579597
return this.txt.range(point1, point2);
580598
}
581599

582-
public select(unit: TextRangeUnit): void {
600+
public select(unit: TextRangeUnit, ui?: EditorUi<T>): void {
583601
this.forCursor((cursor) => {
584-
const range = this.range(cursor.start, unit);
602+
const range = this.range(cursor.start, unit, ui);
585603
if (range) cursor.set(range.start, range.end, CursorAnchor.Start);
586604
else this.delCursors;
587605
});
588606
}
589607

590-
public selectAt(at: Position<T>, unit: TextRangeUnit | ''): void {
608+
public selectAt(at: Position<T>, unit: TextRangeUnit | '', ui?: EditorUi<T>): void {
591609
this.cursor.set(this.point(at));
592-
if (unit) this.select(unit);
610+
if (unit) this.select(unit, ui);
593611
}
594612

595613
// --------------------------------------------------------------- formatting

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,44 @@ export type CharIterator<T> = UndefIterator<ChunkSlice<T>>;
88
export type CharPredicate<T> = (char: T) => boolean;
99

1010
export type Position<T = string> = number | [at: number, anchor: 0 | 1] | Point<T>;
11-
export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'block' | 'all';
11+
export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'vert' | 'block' | 'all';
1212

1313
export type ViewRange = [text: string, textPosition: number, slices: ViewSlice[]];
1414

1515
export type ViewSlice = [header: number, x1: number, x2: number, type: SliceType, data?: unknown];
1616

1717
export type ViewStyle = [behavior: SliceBehavior, type: SliceType, data?: unknown];
18+
19+
/**
20+
* UI API which can be injected during various methods of the editor. Used to
21+
* perform editor function while taking into account the visual representation
22+
* of the document, such as word wrapping.
23+
*/
24+
export interface EditorUi<T = string> {
25+
/**
26+
* Visually skips to the end or beginning of the line. Visually as in, it will
27+
* respect the visual line breaks created by word wrapping.
28+
*
29+
* Skips just one line, regardless of the magnitude of the `steps` parameter.
30+
*
31+
* @param point The point from which to start skipping.
32+
* @param steps The direction to skip. Positive for forward, negative for
33+
* backward. Does not respect the magnitude of the steps, always performs
34+
* one step.
35+
* @returns The point after skipping the specified number of lines, or
36+
* undefined if no such point exists.
37+
*/
38+
eol?(point: Point<T>, steps: number): Point<T> | undefined;
39+
40+
/**
41+
* Used when user presses "ArrowUp" or "ArrowDown" keys. It will skip to the
42+
* position in the next visual line, while trying to preserve the horizontal
43+
* offset from the beginning of the line.
44+
*
45+
* @param point The point from which to start skipping.
46+
* @param steps Number of lines to skip.
47+
* @returns The point after skipping the specified number of lines, or
48+
* undefined if no such point exists.
49+
*/
50+
vert?(point: Point<T>, steps: number): Point<T> | undefined;
51+
}

src/json-crdt-extensions/peritext/rga/Point.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ export class Point<T = string> implements Pick<Stateful, 'refresh'>, Printable {
150150
return this.anchor === Anchor.Before ? pos : pos + 1;
151151
}
152152

153+
/**
154+
* @returns Returns `true` if the point is at the very start of the string, i.e.
155+
* there are no visible characters before it.
156+
*/
157+
public isStart(): boolean {
158+
const chunk = this.chunk();
159+
if (!chunk) return true;
160+
if (!chunk.del && chunk.id.time < this.id.time) return false;
161+
const l = chunk.l;
162+
return l ? !l.len : true;
163+
}
164+
153165
/**
154166
* Goes to the next visible character in the string. The `move` parameter
155167
* specifies how many characters to move the cursor by. If the cursor reaches

src/json-crdt-peritext-ui/__demos__/components/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {CursorPlugin} from '../../plugins/cursor';
77
import {ToolbarPlugin} from '../../plugins/toolbar';
88
import {DebugPlugin} from '../../plugins/debug';
99
import {BlocksPlugin} from '../../plugins/blocks';
10+
import {ValueSyncStore} from '../../../util/events/sync-store';
1011

1112
const markdown =
1213
'The German __automotive sector__ is in the process of *cutting ' +
@@ -36,10 +37,11 @@ export const App: React.FC = () => {
3637
}, [model]);
3738

3839
const plugins = React.useMemo(() => {
40+
const debugEnabled = new ValueSyncStore(false);
3941
const cursorPlugin = new CursorPlugin();
40-
const toolbarPlugin = new ToolbarPlugin();
42+
const toolbarPlugin = new ToolbarPlugin({debug: debugEnabled});
4143
const blocksPlugin = new BlocksPlugin();
42-
const debugPlugin = new DebugPlugin({enabled: false});
44+
const debugPlugin = new DebugPlugin({enabled: debugEnabled});
4345
return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin];
4446
}, []);
4547

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// biome-ignore lint: React is used for JSX
2+
import * as React from 'react';
3+
import {rule, theme} from 'nano-theme';
4+
5+
const labelClass = rule({
6+
...theme.font.mono.bold,
7+
d: 'flex',
8+
ai: 'center',
9+
fz: '9px',
10+
pd: '0 4px',
11+
mr: '-1px',
12+
bdrad: '10px',
13+
bg: 'rgba(0,0,0)',
14+
lh: '14px',
15+
h: '14px',
16+
col: 'white',
17+
bd: '1px solid #fff',
18+
});
19+
20+
const labelSecondClass = rule({
21+
...theme.font.mono.bold,
22+
d: 'flex',
23+
fz: '8px',
24+
mr: '2px -2px 2px 4px',
25+
pd: '0 4px',
26+
bdrad: '10px',
27+
bg: 'rgba(255,255,255)',
28+
lh: '10px',
29+
h: '10px',
30+
col: '#000',
31+
});
32+
33+
export interface DebugLabelProps {
34+
right?: React.ReactNode;
35+
small?: boolean;
36+
children?: React.ReactNode;
37+
}
38+
39+
export const DebugLabel: React.FC<DebugLabelProps> = ({right, small, children}) => {
40+
const style = small ? {fontSize: '7px', lineHeight: '10px', height: '10px', padding: '0 2px'} : void 0;
41+
42+
return (
43+
<span className={labelClass} style={style}>
44+
{children}
45+
{!!right && <span className={labelSecondClass}>{right}</span>}
46+
</span>
47+
);
48+
};

src/json-crdt-peritext-ui/dom/CursorController.ts

Lines changed: 4 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {ValueSyncStore} from '../../util/events/sync-store';
55
import type {Printable} from 'tree-dump';
66
import type {KeyController} from './KeyController';
77
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
8-
import type {Rect, UiLifeCycles} from './types';
8+
import type {UiLifeCycles} from './types';
99
import type {Peritext} from '../../json-crdt-extensions/peritext';
1010
import type {Inline} from '../../json-crdt-extensions/peritext/block/Inline';
1111

@@ -55,45 +55,6 @@ export class CursorController implements UiLifeCycles, Printable {
5555
return -1;
5656
}
5757

58-
public caretRect(): Rect | undefined {
59-
const el = document.getElementById(this.caretId);
60-
if (!el) return;
61-
const rect = el.getBoundingClientRect();
62-
return rect;
63-
}
64-
65-
/**
66-
* Find text position at similar x coordinate on the next line.
67-
*
68-
* @param direction 1 for next line, -1 for previous line.
69-
* @returns The position at similar x coordinate on the next line, or
70-
* undefined if not found.
71-
*
72-
* @todo Implement similar functionality for finding soft line breaks (end
73-
* and start of lines). Or use `.getClientRects()` trick with `Range`
74-
* object, see: https://www.bennadel.com/blog/4310-detecting-rendered-line-breaks-in-a-text-node-in-javascript.htm
75-
*/
76-
public getNextLinePos(direction: 1 | -1 = 1): number | undefined {
77-
const rect = this.caretRect();
78-
if (!rect) return;
79-
const {x, y, width, height} = rect;
80-
const halfWidth = width / 2;
81-
const halfHeight = height / 2;
82-
const currentPos = this.opts.txt.editor.cursor.focus().viewPos();
83-
const caretPos = this.posAtPoint(x + halfWidth, y + halfHeight);
84-
if (currentPos !== caretPos) return;
85-
for (let i = 1; i < 16; i++) {
86-
const dy = i * direction * halfHeight;
87-
const pos = this.posAtPoint(x + halfWidth, y + dy);
88-
if (pos !== -1 && pos !== caretPos) {
89-
if (direction < 0) {
90-
if (pos < caretPos) return pos;
91-
} else if (pos > caretPos) return pos;
92-
}
93-
}
94-
return undefined;
95-
}
96-
9758
/** -------------------------------------------------- {@link UiLifeCycles} */
9859

9960
public start(): void {
@@ -201,16 +162,9 @@ export class CursorController implements UiLifeCycles, Printable {
201162
switch (key) {
202163
case 'ArrowUp':
203164
case 'ArrowDown': {
165+
event.preventDefault();
204166
const direction = key === 'ArrowUp' ? -1 : 1;
205-
const at = this.getNextLinePos(direction);
206-
if (at !== undefined) {
207-
event.preventDefault();
208-
if (event.shiftKey) {
209-
et.cursor({at, edge: 'focus'});
210-
} else {
211-
et.cursor({at});
212-
}
213-
}
167+
et.move(direction, 'vert', event.shiftKey ? 'focus' : 'both');
214168
break;
215169
}
216170
case 'ArrowLeft':
@@ -219,6 +173,7 @@ export class CursorController implements UiLifeCycles, Printable {
219173
event.preventDefault();
220174
if (event.shiftKey) et.move(direction, unit(event) || 'char', 'focus');
221175
else if (event.metaKey) et.move(direction, 'line');
176+
else if (event.altKey && event.ctrlKey) et.move(direction, 'point');
222177
else if (event.altKey || event.ctrlKey) et.move(direction, 'word');
223178
else et.move(direction);
224179
break;

0 commit comments

Comments
 (0)