Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f7def34
feat(json-crdt-peritext-ui): 🎸 pass through caret Point position
streamich Mar 26, 2025
e4107c1
feat(json-crdt-peritext-ui): 🎸 highlight characters immediately adjac…
streamich Mar 26, 2025
b9c547b
feat(json-crdt-peritext-ui): 🎸 improve display of caret adjacent char…
streamich Mar 26, 2025
946900f
feat(json-crdt-peritext-ui): 🎸 add right EOL indicator to debug mode …
streamich Mar 26, 2025
a1b7f98
feat(json-crdt-peritext-ui): 🎸 render soft line beginning in debug mode
streamich Mar 26, 2025
2903178
fix(json-crdt-peritext-ui): 🐛 correctly format slice type tags
streamich Mar 26, 2025
b0660fa
feat(json-crdt-peritext-ui): 🎸 add ability to move caret to soft line…
streamich Mar 27, 2025
06ae980
feat(json-crdt-peritext-ui): 🎸 improve display of block debug labels
streamich Mar 27, 2025
f0b5e0e
feat(json-crdt-peritext-ui): 🎸 show debug labels over inline formatting
streamich Mar 27, 2025
2b3387e
feat(json-crdt-peritext-ui): 🎸 improve debug label display
streamich Mar 27, 2025
fe6d0b1
feat(json-crdt-peritext-ui): 🎸 improve display of debug labels, add s…
streamich Mar 27, 2025
d574fb0
feat(json-crdt-peritext-ui): 🎸 hide cursor inline debug label
streamich Mar 27, 2025
b17c4ba
feat(json-crdt-peritext-ui): 🎸 add ability to move point-by-point usi…
streamich Mar 27, 2025
346a41a
feat(json-crdt-peritext-ui): 🎸 render caret anchor point
streamich Mar 27, 2025
5c19869
feat(json-crdt-peritext-ui): 🎸 make debug plugin enabled state reactive
streamich Mar 27, 2025
1c1ab58
feat(json-crdt-peritext-ui): 🎸 display debug button in top toolbar
streamich Mar 27, 2025
558f171
feat(json-crdt-peritext-ui): 🎸 display word just locations in debug mode
streamich Mar 27, 2025
834f7dc
feat(json-crdt-peritext-ui): 🎸 introduce concept of rendering surface…
streamich Mar 28, 2025
93a31ba
feat(json-crdt-peritext-ui): 🎸 implement utility for retrieving line …
streamich Mar 28, 2025
6eb56a8
feat(json-crdt-peritext-ui): 🎸 create <CharOverlay> helper for debugg…
streamich Mar 28, 2025
c73013e
feat(json-crdt-peritext-ui): 🎸 use the new <CharOverlay> in debug hig…
streamich Mar 28, 2025
3f8a636
feat(json-crdt-peritext-ui): 🎸 show previous and next line boundaries
streamich Mar 29, 2025
a46ac8a
refactor(json-crdt-peritext-ui): 💡 simplify DOM body API interface, j…
streamich Mar 29, 2025
9271737
feat(json-crdt-peritext-ui): 🎸 add ability to compute point x coordin…
streamich Mar 29, 2025
becd481
chore(json-crdt-peritext-ui): 🤖 log cursor position
streamich Mar 29, 2025
87edfe7
feat(json-crdt-peritext-ui): 🎸 compute previous line caret position
streamich Mar 30, 2025
d0dad61
feat(json-crdt-peritext-ui): 🎸 render prev and next line caret destin…
streamich Mar 30, 2025
9d9d4f6
feat(json-crdt-peritext-ui): 🎸 add 'line-vert' cursor movement unit
streamich Mar 31, 2025
779b891
feat(json-crdt-extensions): 🎸 implement EditorApi injectable for Edit…
streamich Mar 31, 2025
966c017
feat(json-crdt-peritext-ui): 🎸 wire in vertical cursor movement using…
streamich Mar 31, 2025
30c6e14
chore(json-crdt-peritext-ui): 🤖 remove unused code
streamich Mar 31, 2025
61f348b
style: 💄 fix linter issues
streamich Mar 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {Slices} from './slice/Slices';
import {LocalSlices} from './slice/LocalSlices';
import {Overlay} from './overlay/Overlay';
import {Chars} from './constants';
import {interval} from '../../json-crdt-patch/clock';
import {interval, tick} from '../../json-crdt-patch/clock';
import {Model, type StrApi} from '../../json-crdt/model';
import {CONST, updateNum} from '../../json-hash';
import {SESSION} from '../../json-crdt-patch/constants';
Expand All @@ -22,6 +22,7 @@ import type {MarkerSlice} from './slice/MarkerSlice';
import type {SliceSchema, SliceType} from './slice/types';
import type {SchemaToJsonNode} from '../../json-crdt/schema/types';
import type {AbstractRga} from '../../json-crdt/nodes/rga';
import type {ChunkSlice} from './util/ChunkSlice';

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

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

public rangeFromChunkSlice(slice: ChunkSlice<T>): Range<T> {
const startId = slice.off === 0 ? slice.chunk.id : tick(slice.chunk.id, slice.off);
const endId = tick(slice.chunk.id, slice.off + slice.len - 1);
const start = this.point(startId, Anchor.Before);
const end = this.point(endId, Anchor.After);
return this.range(start, end);
}

/**
* Creates a range from two points, the points have to be in the correct
* order.
Expand Down
50 changes: 34 additions & 16 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ import type {Peritext} from '../Peritext';
import type {ChunkSlice} from '../util/ChunkSlice';
import type {MarkerSlice} from '../slice/MarkerSlice';
import type {SliceRegistry} from '../registry/SliceRegistry';
import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice} from './types';
import type {
CharIterator,
CharPredicate,
Position,
TextRangeUnit,
ViewStyle,
ViewRange,
ViewSlice,
EditorUi,
} from './types';

/**
* For inline boolean ("Overwrite") slices, both range endpoints should be
Expand Down Expand Up @@ -468,7 +477,7 @@ export class Editor<T = string> implements Printable {
* @param unit The unit of move per step: "char", "word", "line", etc.
* @returns The destination point after the move.
*/
public skip(point: Point<T>, steps: number, unit: TextRangeUnit): Point<T> {
public skip(point: Point<T>, steps: number, unit: TextRangeUnit, ui?: EditorUi<T>): Point<T> {
if (!steps) return point;
switch (unit) {
case 'point': {
Expand All @@ -485,10 +494,13 @@ export class Editor<T = string> implements Printable {
return point;
}
case 'line': {
if (steps > 0) for (let i = 0; i < steps; i++) point = this.eol(point);
else for (let i = 0; i < -steps; i++) point = this.bol(point);
if (steps > 0) for (let i = 0; i < steps; i++) point = ui?.eol?.(point, 1) ?? this.eol(point);
else for (let i = 0; i < -steps; i++) point = ui?.eol?.(point, -1) ?? this.bol(point);
return point;
}
case 'vert': {
return ui?.vert?.(point, steps) || point;
}
case 'block': {
if (steps > 0) for (let i = 0; i < steps; i++) point = this.eob(point);
else for (let i = 0; i < -steps; i++) point = this.bob(point);
Expand All @@ -507,26 +519,32 @@ export class Editor<T = string> implements Printable {
* @param endpoint 0 for "focus", 1 for "anchor", 2 for both.
* @param collapse Whether to collapse the range to a single point.
*/
public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true): void {
public move(
steps: number,
unit: TextRangeUnit,
endpoint: 0 | 1 | 2 = 0,
collapse: boolean = true,
ui?: EditorUi<T>,
): void {
this.forCursor((cursor) => {
switch (endpoint) {
case 0: {
let point = cursor.focus();
point = this.skip(point, steps, unit);
point = this.skip(point, steps, unit, ui);
if (collapse) cursor.set(point);
else cursor.setEndpoint(point, 0);
break;
}
case 1: {
let point = cursor.anchor();
point = this.skip(point, steps, unit);
point = this.skip(point, steps, unit, ui);
if (collapse) cursor.set(point);
else cursor.setEndpoint(point, 1);
break;
}
case 2: {
const start = this.skip(cursor.start, steps, unit);
const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit);
const start = this.skip(cursor.start, steps, unit, ui);
const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit, ui);
cursor.set(start, end);
break;
}
Expand Down Expand Up @@ -572,24 +590,24 @@ export class Editor<T = string> implements Printable {
* @param unit Unit of the range expansion.
* @returns Range which contains the specified unit.
*/
public range(point: Point<T>, unit: TextRangeUnit): Range<T> | undefined {
public range(point: Point<T>, unit: TextRangeUnit, ui?: EditorUi<T>): Range<T> | undefined {
if (unit === 'word') return this.rangeWord(point);
const point1 = this.skip(point, -1, unit);
const point2 = this.skip(point, 1, unit);
const point1 = this.skip(point, -1, unit, ui);
const point2 = this.skip(point, 1, unit, ui);
return this.txt.range(point1, point2);
}

public select(unit: TextRangeUnit): void {
public select(unit: TextRangeUnit, ui?: EditorUi<T>): void {
this.forCursor((cursor) => {
const range = this.range(cursor.start, unit);
const range = this.range(cursor.start, unit, ui);
if (range) cursor.set(range.start, range.end, CursorAnchor.Start);
else this.delCursors;
});
}

public selectAt(at: Position<T>, unit: TextRangeUnit | ''): void {
public selectAt(at: Position<T>, unit: TextRangeUnit | '', ui?: EditorUi<T>): void {
this.cursor.set(this.point(at));
if (unit) this.select(unit);
if (unit) this.select(unit, ui);
}

// --------------------------------------------------------------- formatting
Expand Down
36 changes: 35 additions & 1 deletion src/json-crdt-extensions/peritext/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,44 @@ export type CharIterator<T> = UndefIterator<ChunkSlice<T>>;
export type CharPredicate<T> = (char: T) => boolean;

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

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

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

export type ViewStyle = [behavior: SliceBehavior, type: SliceType, data?: unknown];

/**
* UI API which can be injected during various methods of the editor. Used to
* perform editor function while taking into account the visual representation
* of the document, such as word wrapping.
*/
export interface EditorUi<T = string> {
/**
* Visually skips to the end or beginning of the line. Visually as in, it will
* respect the visual line breaks created by word wrapping.
*
* Skips just one line, regardless of the magnitude of the `steps` parameter.
*
* @param point The point from which to start skipping.
* @param steps The direction to skip. Positive for forward, negative for
* backward. Does not respect the magnitude of the steps, always performs
* one step.
* @returns The point after skipping the specified number of lines, or
* undefined if no such point exists.
*/
eol?(point: Point<T>, steps: number): Point<T> | undefined;

/**
* Used when user presses "ArrowUp" or "ArrowDown" keys. It will skip to the
* position in the next visual line, while trying to preserve the horizontal
* offset from the beginning of the line.
*
* @param point The point from which to start skipping.
* @param steps Number of lines to skip.
* @returns The point after skipping the specified number of lines, or
* undefined if no such point exists.
*/
vert?(point: Point<T>, steps: number): Point<T> | undefined;
}
12 changes: 12 additions & 0 deletions src/json-crdt-extensions/peritext/rga/Point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ export class Point<T = string> implements Pick<Stateful, 'refresh'>, Printable {
return this.anchor === Anchor.Before ? pos : pos + 1;
}

/**
* @returns Returns `true` if the point is at the very start of the string, i.e.
* there are no visible characters before it.
*/
public isStart(): boolean {
const chunk = this.chunk();
if (!chunk) return true;
if (!chunk.del && chunk.id.time < this.id.time) return false;
const l = chunk.l;
return l ? !l.len : true;
}

/**
* Goes to the next visible character in the string. The `move` parameter
* specifies how many characters to move the cursor by. If the cursor reaches
Expand Down
6 changes: 4 additions & 2 deletions src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {CursorPlugin} from '../../plugins/cursor';
import {ToolbarPlugin} from '../../plugins/toolbar';
import {DebugPlugin} from '../../plugins/debug';
import {BlocksPlugin} from '../../plugins/blocks';
import {ValueSyncStore} from '../../../util/events/sync-store';

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

const plugins = React.useMemo(() => {
const debugEnabled = new ValueSyncStore(false);
const cursorPlugin = new CursorPlugin();
const toolbarPlugin = new ToolbarPlugin();
const toolbarPlugin = new ToolbarPlugin({debug: debugEnabled});
const blocksPlugin = new BlocksPlugin();
const debugPlugin = new DebugPlugin({enabled: false});
const debugPlugin = new DebugPlugin({enabled: debugEnabled});
return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin];
}, []);

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

const labelClass = rule({
...theme.font.mono.bold,
d: 'flex',
ai: 'center',
fz: '9px',
pd: '0 4px',
mr: '-1px',
bdrad: '10px',
bg: 'rgba(0,0,0)',
lh: '14px',
h: '14px',
col: 'white',
bd: '1px solid #fff',
});

const labelSecondClass = rule({
...theme.font.mono.bold,
d: 'flex',
fz: '8px',
mr: '2px -2px 2px 4px',
pd: '0 4px',
bdrad: '10px',
bg: 'rgba(255,255,255)',
lh: '10px',
h: '10px',
col: '#000',
});

export interface DebugLabelProps {
right?: React.ReactNode;
small?: boolean;
children?: React.ReactNode;
}

export const DebugLabel: React.FC<DebugLabelProps> = ({right, small, children}) => {
const style = small ? {fontSize: '7px', lineHeight: '10px', height: '10px', padding: '0 2px'} : void 0;

return (
<span className={labelClass} style={style}>
{children}
{!!right && <span className={labelSecondClass}>{right}</span>}
</span>
);
};
53 changes: 4 additions & 49 deletions src/json-crdt-peritext-ui/dom/CursorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {ValueSyncStore} from '../../util/events/sync-store';
import type {Printable} from 'tree-dump';
import type {KeyController} from './KeyController';
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
import type {Rect, UiLifeCycles} from './types';
import type {UiLifeCycles} from './types';
import type {Peritext} from '../../json-crdt-extensions/peritext';
import type {Inline} from '../../json-crdt-extensions/peritext/block/Inline';

Expand Down Expand Up @@ -55,45 +55,6 @@ export class CursorController implements UiLifeCycles, Printable {
return -1;
}

public caretRect(): Rect | undefined {
const el = document.getElementById(this.caretId);
if (!el) return;
const rect = el.getBoundingClientRect();
return rect;
}

/**
* Find text position at similar x coordinate on the next line.
*
* @param direction 1 for next line, -1 for previous line.
* @returns The position at similar x coordinate on the next line, or
* undefined if not found.
*
* @todo Implement similar functionality for finding soft line breaks (end
* and start of lines). Or use `.getClientRects()` trick with `Range`
* object, see: https://www.bennadel.com/blog/4310-detecting-rendered-line-breaks-in-a-text-node-in-javascript.htm
*/
public getNextLinePos(direction: 1 | -1 = 1): number | undefined {
const rect = this.caretRect();
if (!rect) return;
const {x, y, width, height} = rect;
const halfWidth = width / 2;
const halfHeight = height / 2;
const currentPos = this.opts.txt.editor.cursor.focus().viewPos();
const caretPos = this.posAtPoint(x + halfWidth, y + halfHeight);
if (currentPos !== caretPos) return;
for (let i = 1; i < 16; i++) {
const dy = i * direction * halfHeight;
const pos = this.posAtPoint(x + halfWidth, y + dy);
if (pos !== -1 && pos !== caretPos) {
if (direction < 0) {
if (pos < caretPos) return pos;
} else if (pos > caretPos) return pos;
}
}
return undefined;
}

/** -------------------------------------------------- {@link UiLifeCycles} */

public start(): void {
Expand Down Expand Up @@ -201,16 +162,9 @@ export class CursorController implements UiLifeCycles, Printable {
switch (key) {
case 'ArrowUp':
case 'ArrowDown': {
event.preventDefault();
const direction = key === 'ArrowUp' ? -1 : 1;
const at = this.getNextLinePos(direction);
if (at !== undefined) {
event.preventDefault();
if (event.shiftKey) {
et.cursor({at, edge: 'focus'});
} else {
et.cursor({at});
}
}
et.move(direction, 'vert', event.shiftKey ? 'focus' : 'both');
break;
}
case 'ArrowLeft':
Expand All @@ -219,6 +173,7 @@ export class CursorController implements UiLifeCycles, Printable {
event.preventDefault();
if (event.shiftKey) et.move(direction, unit(event) || 'char', 'focus');
else if (event.metaKey) et.move(direction, 'line');
else if (event.altKey && event.ctrlKey) et.move(direction, 'point');
else if (event.altKey || event.ctrlKey) et.move(direction, 'word');
else et.move(direction);
break;
Expand Down
Loading