Skip to content

Commit

Permalink
feat(json-crdt-peritext-ui): 🎸 create cursor plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Nov 12, 2024
1 parent f69e93e commit 43e86c6
Show file tree
Hide file tree
Showing 14 changed files with 154 additions and 99 deletions.
3 changes: 2 additions & 1 deletion src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import {Provider, GlobalCss} from 'nano-theme';
import {ModelWithExt, ext} from '../../../json-crdt-extensions';
import {PeritextView} from '../../react';
import {cursorPlugin} from '../../plugins/cursor';
import {renderers} from '../../plugins/default';
import {renderers as debugRenderers} from '../../plugins/debug';

Expand All @@ -21,7 +22,7 @@ export const App: React.FC = () => {
<Provider theme={'light'}>
<GlobalCss />
<div style={{maxWidth: '690px', fontSize: '21px', lineHeight: '1.7em', margin: '32px auto'}}>
<PeritextView peritext={peritext} renderers={[renderers, debugRenderers({enabled: false})]} />
<PeritextView peritext={peritext} renderers={[cursorPlugin, renderers, debugRenderers({enabled: false})]} />
</div>
</Provider>
);
Expand Down
6 changes: 3 additions & 3 deletions src/json-crdt-peritext-ui/components/CaretScore/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {keyframes, rule} from 'nano-theme';

const scoreAnimation = keyframes({
from: {
op: 0.7,
op: 0.8,
tr: 'scale(1.2)',
},
to: {
Expand Down Expand Up @@ -38,12 +38,12 @@ const scoreClass = rule({

const scoreDeltaClass = rule({
pos: 'absolute',
b: '1.27em',
b: '1.3em',
l: '1.2em',
fz: '.5em',
fw: 'bold',
op: 0.5,
col: 'blue',
col: '#07f',
an: scoreAnimation + ' .3s ease-out forwards',
pe: 'none',
us: 'none',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {usePeritext} from '../../react/context';
import {useSyncStore} from '../../react/hooks';
import {DefaultRendererColors} from './constants';
import {CommonSliceType} from '../../../json-crdt-extensions';
import {useDefaultPlugin} from './context';
import {useCursorPlugin} from './context';
import {CaretScore} from '../../components/CaretScore';
import type {CaretViewProps} from '../../react/selection/CaretView';

Expand Down Expand Up @@ -54,7 +54,7 @@ export const RenderCaret: React.FC<RenderCaretProps> = ({italic, children}) => {
useHarmonicIntervalFn(() => setShow(Date.now() % (ms + ms) > ms), ms);
const {dom} = usePeritext();
const focus = useSyncStore(dom.cursor.focus);
const plugin = useDefaultPlugin();
const plugin = useCursorPlugin();

const score = plugin.score.value;
const delta = plugin.scoreDelta.value;
Expand Down
45 changes: 45 additions & 0 deletions src/json-crdt-peritext-ui/plugins/cursor/RenderInline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// biome-ignore lint: React is used for JSX
import * as React from 'react';
import {usePeritext} from '../../react';
import {useSyncStore} from '../../react/hooks';
import {DefaultRendererColors} from './constants';
import type {InlineViewProps} from '../../react/InlineView';

interface RenderInlineSelectionProps extends RenderInlineProps {
selection: [left: 'anchor' | 'focus' | '', right: 'anchor' | 'focus' | ''];
}

const RenderInlineSelection: React.FC<RenderInlineSelectionProps> = (props) => {
const {children, selection} = props;
const {dom} = usePeritext();
const focus = useSyncStore(dom.cursor.focus);

const [left, right] = selection;
const style: React.CSSProperties = {
backgroundColor: focus ? DefaultRendererColors.ActiveSelection : DefaultRendererColors.InactiveSelection,
borderRadius: left === 'anchor' ? '.25em 1px 1px .25em' : right === 'anchor' ? '1px .25em .25em 1px' : '1px',
};

return <span style={style}>{children}</span>;
};

export interface RenderInlineProps extends InlineViewProps {
children: React.ReactNode;
}

export const RenderInline: React.FC<RenderInlineProps> = (props) => {
const {inline, children} = props;
const selection = inline.selection();

let element = children;

if (selection) {
element = (
<RenderInlineSelection {...props} selection={selection}>
{element}
</RenderInlineSelection>
);
}

return element;
};
59 changes: 59 additions & 0 deletions src/json-crdt-peritext-ui/plugins/cursor/RenderPeritext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from 'react';
import {context, type CursorPluginContextValue} from './context';
import {ValueSyncStore} from '../../../util/events/sync-store';
import type {ChangeDetail} from '../../events/types';
import type {PeritextSurfaceContextValue, PeritextViewProps} from '../../react';

export interface RenderPeritextProps extends PeritextViewProps {
ctx?: PeritextSurfaceContextValue;
children?: React.ReactNode;
}

export const RenderPeritext: React.FC<RenderPeritextProps> = ({ctx, children}) => {
const value: CursorPluginContextValue = React.useMemo(
() => ({
ctx,
score: new ValueSyncStore(0),
scoreDelta: new ValueSyncStore(0),
lastVisScore: new ValueSyncStore(0),
}),
[ctx],
);

React.useEffect(() => {
const dom = ctx?.dom;
if (!dom || !value) return;
let lastNow: number = 0;
const listener = (event: CustomEvent<ChangeDetail>) => {
const now = Date.now();
const timeDiff = now - lastNow;
let delta = 0;
switch (event.detail.ev?.type) {
case 'delete':
case 'insert':
case 'format':
case 'marker': {
delta = timeDiff < 30 ? 10 : timeDiff < 70 ? 5 : timeDiff < 150 ? 2 : timeDiff <= 1000 ? 1 : -1;
break;
}
default: {
delta = timeDiff <= 1000 ? 0 : -1;
break;
}
}
if (delta) value.score.next(delta >= 0 ? value.score.value + delta : 0);
value.scoreDelta.next(delta);
lastNow = now;
};
dom.et.addEventListener('change', listener);
return () => {
dom.et.removeEventListener('change', listener);
};
}, [ctx?.dom, value]);

return (
<context.Provider value={value}>
{children}
</context.Provider>
);
};
20 changes: 20 additions & 0 deletions src/json-crdt-peritext-ui/plugins/cursor/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import type {PeritextSurfaceContextValue} from '../../react';
import type {ValueSyncStore} from '../../../util/events/sync-store';

export interface CursorPluginContextValue {
ctx?: PeritextSurfaceContextValue;

/** Current score. */
score: ValueSyncStore<number>;

/** By how much the score changed. */
scoreDelta: ValueSyncStore<number>;

/** The last score that was shown to the user. */
lastVisScore: ValueSyncStore<number>;
}

export const context = React.createContext<CursorPluginContextValue>(null!);

export const useCursorPlugin = () => React.useContext(context);
21 changes: 21 additions & 0 deletions src/json-crdt-peritext-ui/plugins/cursor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import {RenderCaret} from './RenderCaret';
import {RenderFocus} from './RenderFocus';
import {RenderAnchor} from './RenderAnchor';
import {RenderInline} from './RenderInline';
import {RenderPeritext} from './RenderPeritext';
import type {PeritextPlugin} from '../../react/types';

const h = React.createElement;

/**
* Plugin which renders the main cursor and all other current user local
* cursors.
*/
export const cursorPlugin: PeritextPlugin = {
caret: (props, children) => h(RenderCaret, <any>props, children),
focus: (props, children) => h(RenderFocus, <any>props, children),
anchor: (props, children) => h(RenderAnchor, <any>props, children),
inline: (props, children) => h(RenderInline, props as any, children),
peritext: (props, children, ctx) => h(RenderPeritext, {...props, children, ctx}),
};
36 changes: 1 addition & 35 deletions src/json-crdt-peritext-ui/plugins/default/RenderInline.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
// biome-ignore lint: React is used for JSX
import * as React from 'react';
import {usePeritext} from '../../react';
import {useSyncStore} from '../../react/hooks';
import {DefaultRendererColors} from './constants';
import type {InlineViewProps} from '../../react/InlineView';
import {CommonSliceType} from '../../../json-crdt-extensions';

interface RenderInlineSelectionProps extends RenderInlineProps {
selection: [left: 'anchor' | 'focus' | '', right: 'anchor' | 'focus' | ''];
}

const RenderInlineSelection: React.FC<RenderInlineSelectionProps> = (props) => {
const {children, selection} = props;
const {dom} = usePeritext();
const focus = useSyncStore(dom.cursor.focus);

const [left, right] = selection;
const style: React.CSSProperties = {
backgroundColor: focus ? DefaultRendererColors.ActiveSelection : DefaultRendererColors.InactiveSelection,
borderRadius: left === 'anchor' ? '.25em 1px 1px .25em' : right === 'anchor' ? '1px .25em .25em 1px' : '1px',
};

return <span style={style}>{children}</span>;
};
import type {InlineViewProps} from '../../react/InlineView';

export interface RenderInlineProps extends InlineViewProps {
children: React.ReactNode;
}

export const RenderInline: React.FC<RenderInlineProps> = (props) => {
const {inline, children} = props;

const attr = inline.attr();
const selection = inline.selection();

let element = children;

if (attr[CommonSliceType.code]) element = <code>{element}</code>;
if (attr[CommonSliceType.mark]) element = <mark>{element}</mark>;
if (attr[CommonSliceType.del]) element = <del>{element}</del>;
Expand All @@ -46,14 +21,5 @@ export const RenderInline: React.FC<RenderInlineProps> = (props) => {
if (attr[CommonSliceType.kbd]) element = <kbd>{element}</kbd>;
if (attr[CommonSliceType.hidden])
element = <span style={{color: 'transparent', background: 'black'}}>{element}</span>;

if (selection) {
element = (
<RenderInlineSelection {...props} selection={selection}>
{element}
</RenderInlineSelection>
);
}

return element;
};
43 changes: 1 addition & 42 deletions src/json-crdt-peritext-ui/plugins/default/RenderPeritext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import * as React from 'react';
import {Chrome} from './Chrome';
import {context, type DefaultPluginContextValue} from './context';
import {ValueSyncStore} from '../../../util/events/sync-store';
import type {ChangeDetail} from '../../events/types';
import type {PeritextSurfaceContextValue, PeritextViewProps} from '../../react';

export interface RenderPeritextProps extends PeritextViewProps {
Expand All @@ -11,46 +9,7 @@ export interface RenderPeritextProps extends PeritextViewProps {
}

export const RenderPeritext: React.FC<RenderPeritextProps> = ({ctx, children}) => {
const value: DefaultPluginContextValue = React.useMemo(
() => ({
ctx,
score: new ValueSyncStore(0),
scoreDelta: new ValueSyncStore(0),
lastVisScore: new ValueSyncStore(0),
}),
[ctx],
);

React.useEffect(() => {
const dom = ctx?.dom;
if (!dom || !value) return;
let lastNow: number = 0;
const listener = (event: CustomEvent<ChangeDetail>) => {
const now = Date.now();
const timeDiff = now - lastNow;
let delta = 0;
switch (event.detail.ev?.type) {
case 'delete':
case 'insert':
case 'format':
case 'marker': {
delta = timeDiff < 30 ? 10 : timeDiff < 70 ? 5 : timeDiff < 150 ? 2 : timeDiff <= 1000 ? 1 : -1;
break;
}
default: {
delta = timeDiff <= 1000 ? 0 : -1;
break;
}
}
if (delta) value.score.next(delta >= 0 ? value.score.value + delta : 0);
value.scoreDelta.next(delta);
lastNow = now;
};
dom.et.addEventListener('change', listener);
return () => {
dom.et.removeEventListener('change', listener);
};
}, [ctx?.dom, value]);
const value: DefaultPluginContextValue = React.useMemo(() => ({ctx}), [ctx]);

return (
<context.Provider value={value}>
Expand Down
10 changes: 0 additions & 10 deletions src/json-crdt-peritext-ui/plugins/default/context.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
import * as React from 'react';
import type {PeritextSurfaceContextValue} from '../../react';
import type {ValueSyncStore} from '../../../util/events/sync-store';

export interface DefaultPluginContextValue {
ctx?: PeritextSurfaceContextValue;

/** Current score. */
score: ValueSyncStore<number>;

/** By how much the score changed. */
scoreDelta: ValueSyncStore<number>;

/** The last score that was shown to the user. */
lastVisScore: ValueSyncStore<number>;
}

export const context = React.createContext<DefaultPluginContextValue>(null!);
Expand Down
6 changes: 0 additions & 6 deletions src/json-crdt-peritext-ui/plugins/default/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import * as React from 'react';
import {RenderCaret} from './RenderCaret';
import {RenderFocus} from './RenderFocus';
import {RenderAnchor} from './RenderAnchor';
import {RenderInline} from './RenderInline';
import {RenderPeritext} from './RenderPeritext';
import {text} from '../minimal/text';
Expand All @@ -12,9 +9,6 @@ const h = React.createElement;

export const renderers: PeritextPlugin = {
text,
caret: (props, children) => h(RenderCaret, <any>props, children),
focus: (props, children) => h(RenderFocus, <any>props, children),
anchor: (props, children) => h(RenderAnchor, <any>props, children),
inline: (props, children) => h(RenderInline, props as any, children),
block: (props, children) => h(RenderBlock, props as any, children),
peritext: (props, children, ctx) => h(RenderPeritext, {...props, children, ctx}),
Expand Down

0 comments on commit 43e86c6

Please sign in to comment.