Skip to content

Commit d266f88

Browse files
authored
Merge pull request #773 from streamich/peritext-rendering-surface
Peritext rendering surface: `cursor` plugin
2 parents 1bf55cd + dd69708 commit d266f88

File tree

26 files changed

+809
-42
lines changed

26 files changed

+809
-42
lines changed

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
import {printTs, tick} from '../../../json-crdt-patch';
2-
import type {Point} from '../rga/Point';
32
import {CursorAnchor} from '../slice/constants';
43
import {PersistedSlice} from '../slice/PersistedSlice';
4+
import type {Point} from '../rga/Point';
55

6+
/**
7+
* Cursor is a slice that represents an explicitly highlighted place in the
8+
* text to the user. The {@link Cursor} is a {@link Range}, it has a `start`
9+
* {@link Point} and an `end` {@link Point}.
10+
*
11+
* The {@link Cursor} can be a caret (collapsed cursor) or a selection (range
12+
* expanded cursor). The caret is said to be "collapsed", its `start` and `end`
13+
* {@link Point}s are the same. When the selection is said to be "expanded", its
14+
* `start` and `end` {@link Point}s are different.
15+
*
16+
* The `start` {@link Point} is always the one that comes first in the text, it
17+
* is less then or equal to the `end` {@link Point} in the spatial (text) order.
18+
*
19+
* An expanded selection cursor has a *focus* and an *anchor* side. The *focus*
20+
* side is the one that moves when the user presses the arrow keys. The *anchor*
21+
* side is the one that stays in place when the user presses the arrow keys. The
22+
* side of the anchor is determined by the {@link Cursor#anchorSide} property.
23+
*/
624
export class Cursor<T = string> extends PersistedSlice<T> {
725
public get anchorSide(): CursorAnchor {
826
return this.type as CursorAnchor;

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,17 @@ export class Editor<T = string> implements Printable {
672672
const pending = this.pending.value;
673673
const pendingFormatted = {} as any;
674674
for (const [type, data] of pending) pendingFormatted[formatType(type)] = data;
675-
return 'Editor' + printTree(tab, [() => `pending ${stringify(pendingFormatted)}`]);
675+
return (
676+
'Editor' +
677+
printTree(tab, [
678+
(tab) =>
679+
'cursors' +
680+
printTree(
681+
tab,
682+
[...this.cursors()].map((cursor) => (tab) => cursor.toString(tab)),
683+
),
684+
() => `pending ${stringify(pendingFormatted)}`,
685+
])
686+
);
676687
}
677688
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import * as React from 'react';
22
import {Provider, GlobalCss} from 'nano-theme';
33
import {ModelWithExt, ext} from '../../../json-crdt-extensions';
44
import {PeritextView} from '../../react';
5-
import {renderers} from '../../plugins/minimal';
5+
import {cursorPlugin} from '../../plugins/cursor';
6+
import {renderers} from '../../plugins/default';
67
import {renderers as debugRenderers} from '../../plugins/debug';
78

89
export const App: React.FC = () => {
@@ -20,8 +21,8 @@ export const App: React.FC = () => {
2021
return (
2122
<Provider theme={'light'}>
2223
<GlobalCss />
23-
<div style={{maxWidth: '640px', fontSize: '21px', margin: '32px auto'}}>
24-
<PeritextView peritext={peritext} renderers={[renderers, debugRenderers({enabled: true})]} />
24+
<div style={{maxWidth: '690px', fontSize: '21px', lineHeight: '1.7em', margin: '32px auto'}}>
25+
<PeritextView peritext={peritext} plugins={[cursorPlugin, renderers, debugRenderers({enabled: false})]} />
2526
</div>
2627
</Provider>
2728
);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as React from 'react';
2+
import {keyframes, rule} from 'nano-theme';
3+
4+
const scoreAnimation = keyframes({
5+
from: {
6+
op: 0.8,
7+
tr: 'scale(1.2)',
8+
},
9+
to: {
10+
op: 0,
11+
tr: 'scale(.7)',
12+
vis: 'hidden',
13+
},
14+
});
15+
16+
const shakingAnimation = keyframes({
17+
'0%': {tr: 'translateX(0), scale(1.2)', op: 1},
18+
'10%': {tr: 'translateX(-2px)'},
19+
'20%': {tr: 'translateX(2px)'},
20+
'30%': {tr: 'translateX(-1px)'},
21+
'40%': {tr: 'translateX(1px)'},
22+
'50%': {tr: 'translateX(0), scale(1)'},
23+
'100%': {op: 0, vis: 'hidden'},
24+
});
25+
26+
const scoreClass = rule({
27+
ff: 'Inter, ui-sans-serif, system-ui, -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
28+
pos: 'absolute',
29+
b: '0.9em',
30+
l: '.75em',
31+
fz: '.4em',
32+
op: 0.5,
33+
an: scoreAnimation + ' .5s ease-out forwards',
34+
ws: 'nowrap',
35+
pe: 'none',
36+
us: 'none',
37+
});
38+
39+
const scoreDeltaClass = rule({
40+
pos: 'absolute',
41+
b: '1.3em',
42+
l: '1.2em',
43+
fz: '.5em',
44+
fw: 'bold',
45+
op: 0.5,
46+
col: '#07f',
47+
an: scoreAnimation + ' .3s ease-out forwards',
48+
pe: 'none',
49+
us: 'none',
50+
});
51+
52+
export interface CaretScoreProps {
53+
score: number;
54+
delta: number;
55+
onRender?: () => void;
56+
}
57+
58+
export const CaretScore: React.FC<CaretScoreProps> = React.memo(({score, delta, onRender}) => {
59+
// biome-ignore lint: lint/correctness/useExhaustiveDependencies
60+
React.useEffect(() => {
61+
onRender?.();
62+
}, []);
63+
64+
const scoreMsg =
65+
score > 100 && score <= 120
66+
? 'Typing Spree!'
67+
: score > 200 && score <= 208
68+
? 'Go, go, go!'
69+
: score > 300 && score <= 320
70+
? 'Rampage!'
71+
: score > 400 && score <= 408
72+
? "Let's go!"
73+
: score > 500 && score <= 520
74+
? 'Unstoppable!'
75+
: score > 600 && score <= 608
76+
? 'Good stuff!'
77+
: score > 700 && score <= 708
78+
? 'Alright, alright!'
79+
: score > 1000 && score <= 1030
80+
? 'Godlike!'
81+
: score > 1500 && score <= 1530
82+
? 'Bingo, bango, bongo!'
83+
: score > 2000 && score <= 2030
84+
? 'Legendary!'
85+
: score > 3000 && score <= 3040
86+
? 'Beyond Godlike!'
87+
: score > 5000 && score <= 5040
88+
? 'Wicked Sick!'
89+
: score > 10000 && score <= 10050
90+
? 'Monster Type!'
91+
: score > 20000 && score <= 20050
92+
? 'Ultra Type!'
93+
: score > 50000 && score <= 50100
94+
? 'M-M-M-Monster Type!'
95+
: score;
96+
97+
return (
98+
<>
99+
{score >= 24 && (
100+
<span
101+
contentEditable={false}
102+
className={scoreClass}
103+
style={{animation: typeof scoreMsg === 'string' ? shakingAnimation + ' .7s ease-out forwards' : undefined}}
104+
>
105+
{scoreMsg}
106+
</span>
107+
)}
108+
{(typeof scoreMsg === 'string' || (score > 42 && delta > 1)) && (
109+
<span contentEditable={false} className={scoreDeltaClass}>
110+
+{delta}
111+
</span>
112+
)}
113+
</>
114+
);
115+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// biome-ignore lint: React is used for JSX
2+
import * as React from 'react';
3+
import {rule, keyframes} from 'nano-theme';
4+
import {DefaultRendererColors} from './constants';
5+
import {usePeritext} from '../../react';
6+
import {useSyncStore} from '../../react/hooks';
7+
import type {AnchorViewProps} from '../../react/selection/AnchorView';
8+
9+
export const fadeInAnimation = keyframes({
10+
from: {
11+
tr: 'scale(0)',
12+
},
13+
to: {
14+
tr: 'scale(1)',
15+
},
16+
});
17+
18+
const blockClass = rule({
19+
pos: 'relative',
20+
pe: 'none',
21+
us: 'none',
22+
w: '0px',
23+
h: '100%',
24+
va: 'center',
25+
});
26+
27+
const innerClass = rule({
28+
pos: 'absolute',
29+
l: 'calc(max(-6px,-0.2em))',
30+
b: '-.15em',
31+
w: 'calc(min(12px,0.4em))',
32+
h: 'calc(min(16px,0.5em))',
33+
bdrad: '50%/20%',
34+
bg: DefaultRendererColors.ActiveCursor,
35+
an: fadeInAnimation + ' .25s ease-out',
36+
animationFillMode: 'forwards',
37+
});
38+
39+
export interface RenderAnchorProps extends AnchorViewProps {
40+
children: React.ReactNode;
41+
}
42+
43+
export const RenderAnchor: React.FC<RenderAnchorProps> = ({children}) => {
44+
const {dom} = usePeritext();
45+
const focus = useSyncStore(dom.cursor.focus);
46+
47+
const style = focus ? undefined : {background: DefaultRendererColors.InactiveCursor};
48+
49+
return (
50+
<span className={blockClass} contentEditable={false}>
51+
{children}
52+
<span className={innerClass} style={style} />
53+
</span>
54+
);
55+
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as React from 'react';
2+
import useHarmonicIntervalFn from 'react-use/lib/useHarmonicIntervalFn';
3+
import {keyframes, rule} from 'nano-theme';
4+
import {usePeritext} from '../../react/context';
5+
import {useSyncStore} from '../../react/hooks';
6+
import {DefaultRendererColors} from './constants';
7+
import {CommonSliceType} from '../../../json-crdt-extensions';
8+
import {useCursorPlugin} from './context';
9+
import {CaretScore} from '../../components/CaretScore';
10+
import type {CaretViewProps} from '../../react/selection/CaretView';
11+
12+
const ms = 350;
13+
14+
const moveAnimation = keyframes({
15+
from: {
16+
tr: 'scale(1.2)',
17+
},
18+
to: {
19+
tr: 'scale(1)',
20+
},
21+
});
22+
23+
const blockClass = rule({
24+
pos: 'relative',
25+
pe: 'none',
26+
us: 'none',
27+
w: '0px',
28+
h: '100%',
29+
bg: 'black',
30+
va: 'bottom',
31+
});
32+
33+
const innerClass = rule({
34+
pos: 'absolute',
35+
b: '-.18em',
36+
l: '-.065em',
37+
w: 'calc(max(.2em, 2px))',
38+
h: '1.5em',
39+
bg: DefaultRendererColors.ActiveCursor,
40+
bdl: `1px dotted ${DefaultRendererColors.InactiveCursor}`,
41+
bdrad: '0.0625em',
42+
'mix-blend-mode': 'multiply',
43+
an: moveAnimation + ' .25s ease-out forwards',
44+
});
45+
46+
export interface RenderCaretProps extends CaretViewProps {
47+
children: React.ReactNode;
48+
}
49+
50+
export const RenderCaret: React.FC<RenderCaretProps> = ({italic, children}) => {
51+
const ctx = usePeritext();
52+
const pending = useSyncStore(ctx.peritext.editor.pending);
53+
const [show, setShow] = React.useState(true);
54+
useHarmonicIntervalFn(() => setShow(Date.now() % (ms + ms) > ms), ms);
55+
const {dom} = usePeritext();
56+
const focus = useSyncStore(dom.cursor.focus);
57+
const plugin = useCursorPlugin();
58+
59+
const score = plugin.score.value;
60+
const delta = plugin.scoreDelta.value;
61+
62+
const style: React.CSSProperties = {
63+
background: !focus
64+
? DefaultRendererColors.InactiveCursor
65+
: show
66+
? DefaultRendererColors.ActiveCursor
67+
: 'transparent',
68+
};
69+
70+
if (italic || pending.has(CommonSliceType.i)) {
71+
style.rotate = '11deg';
72+
}
73+
74+
return (
75+
<span className={blockClass}>
76+
{children}
77+
{score !== plugin.lastVisScore.value && (
78+
<CaretScore
79+
score={score}
80+
delta={delta}
81+
onRender={() => {
82+
plugin.lastVisScore.value = score;
83+
}}
84+
/>
85+
)}
86+
<span className={innerClass} style={style} />
87+
</span>
88+
);
89+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// biome-ignore lint: React is used for JSX
2+
import * as React from 'react';
3+
import {rule, drule, keyframes} from 'nano-theme';
4+
import {DefaultRendererColors} from './constants';
5+
import {usePeritext} from '../../react';
6+
import {useSyncStore} from '../../react/hooks';
7+
import type {FocusViewProps} from '../../react/selection/FocusView';
8+
9+
const width = 0.14;
10+
const animationTime = '1s';
11+
12+
const animation = keyframes({
13+
'from,to': {
14+
bg: DefaultRendererColors.ActiveCursor,
15+
},
16+
'50%': {
17+
bg: 'transparent',
18+
},
19+
});
20+
21+
const blockClass = rule({
22+
pos: 'relative',
23+
pe: 'none',
24+
us: 'none',
25+
w: '0px',
26+
h: '100%',
27+
va: 'bottom',
28+
});
29+
30+
const innerClass = drule({
31+
an: `${animationTime} ${animation} step-end infinite`,
32+
pos: 'absolute',
33+
w: `calc(max(${width}em, 2px))`,
34+
t: '-.175em',
35+
h: '1.45em',
36+
bg: DefaultRendererColors.ActiveCursor,
37+
'mix-blend-mode': 'multiply',
38+
});
39+
40+
export interface RenderFocusProps extends FocusViewProps {
41+
children: React.ReactNode;
42+
}
43+
44+
export const RenderFocus: React.FC<RenderFocusProps> = ({left, italic, children}) => {
45+
const {dom} = usePeritext();
46+
const focus = useSyncStore(dom.cursor.focus);
47+
48+
const style: React.CSSProperties = focus ? {} : {background: DefaultRendererColors.InactiveCursor, animation: 'none'};
49+
50+
if (italic) {
51+
style.rotate = '11deg';
52+
}
53+
54+
return (
55+
<span className={blockClass}>
56+
{children}
57+
<span
58+
className={innerClass({
59+
bdrad: left ? `0 ${width * 0.5}em ${width * 0.5}em 0` : `${width * 0.5}em 0 0 ${width * 0.5}em`,
60+
})}
61+
style={style}
62+
/>
63+
</span>
64+
);
65+
};

0 commit comments

Comments
 (0)