Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Peritext rendering surface #736

Merged
merged 4 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src/json-crdt-peritext-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Peritext UI

CRDT-native UI for JSON CRDT `peritext` extension. Supports block-level and
inline-level collaborative editing, with the ability to nest blocks.
24 changes: 24 additions & 0 deletions src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';
import {Provider, GlobalCss} from 'nano-theme';
import {ModelWithExt, ext} from '../../../json-crdt-extensions';
import {PeritextView} from '../../react';
import {renderers} from '../../renderers/default';
import {renderers as debugRenderers} from '../../renderers/debug';

export const App: React.FC = ({}) => {
const [[model, peritext]] = React.useState(() => {
const model = ModelWithExt.create(ext.peritext.new('Hello world!'));
const peritext = model.s.toExt().txt;
peritext.refresh();
return [model, peritext] as const;
});

return (
<Provider theme={'light'}>
<GlobalCss />
<div style={{maxWidth: '640px', fontSize: '21px', margin: '32px auto'}}>
<PeritextView peritext={peritext} renderers={[debugRenderers({enabled: true}), renderers]} />
</div>
</Provider>
);
};
13 changes: 13 additions & 0 deletions src/json-crdt-peritext-ui/__demos__/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {createRoot} from 'react-dom/client';
import * as React from 'react';
import {App} from './components/App';

const div = document.createElement('div');
document.body.appendChild(div);

const root = createRoot(div);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
5 changes: 5 additions & 0 deletions src/json-crdt-peritext-ui/__demos__/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.json",
"include": ["./"],
"compilerOptions": {}
}
33 changes: 33 additions & 0 deletions src/json-crdt-peritext-ui/__demos__/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
mode: 'development',
devtool: 'inline-source-map',
entry: __dirname + '/main.tsx',
plugins: [
new HtmlWebpackPlugin({
title: 'Development',
}),
],
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'ts-loader',
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve('../../..', 'dist'),
},
devServer: {
port: 9876,
hot: false,
},
};
1 change: 1 addition & 0 deletions src/json-crdt-peritext-ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './react';
38 changes: 38 additions & 0 deletions src/json-crdt-peritext-ui/react/BlockView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from 'react';
import {LeafBlock} from '../../json-crdt-extensions/peritext/block/LeafBlock';
import {Block} from '../../json-crdt-extensions/peritext/block/Block';
import {InlineView} from './InlineView';
import {Char} from '../constants';
import {usePeritext} from './context';

export interface BlockViewProps {
hash: number;
block: Block;
el?: (element: HTMLElement | null) => void;
}

export const BlockView: React.FC<BlockViewProps> = React.memo(
(props) => {
const {block, el} = props;
const {renderers} = usePeritext();

const elements: React.ReactNode[] = [];
if (block instanceof LeafBlock) {
for (const inline of block.texts()) elements.push(<InlineView key={inline.key()} inline={inline} />);
} else {
const children = block.children;
const length = children.length;
for (let i = 0; i < length; i++) {
const child = children[i];
elements.push(<BlockView key={child.key()} hash={child.hash} block={child} />);
}
}

let children: React.ReactNode = (
<div ref={(element) => el?.(element)}>{elements.length ? elements : Char.ZeroLengthSpace}</div>
);
for (const map of renderers) children = map.block?.(props, children) ?? children;
return children;
},
(prev, next) => prev.hash === next.hash,
);
116 changes: 116 additions & 0 deletions src/json-crdt-peritext-ui/react/InlineView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as React from 'react';
import {Inline} from '../../json-crdt-extensions/peritext/block/Inline';
import {CssClass, ElementAttr} from '../constants';
import {TextView} from './TextView';
import {usePeritext} from './context';
import {CaretView} from './selection/CaretView';
import {FocusView} from './selection/FocusView';
import {AnchorView} from './selection/AnchorView';
import {put} from 'nano-theme';

const {createElement: h, Fragment} = React;

put('.' + CssClass.Inline, {
/**
* Font *kerning* is the variable distance between every pair of characters.
* It is adjusted to make the text more readable. This disables it, so that
* there is always the same distance between characters.
*
* Useful because, while moving the cursor the characters can be arbitrarily
* grouped into <span> elements, the distance between them should be
* consistent to avoid layout shifts. Otherwise, there is a text shift when
* moving the cursor. For example, consider:
*
* ```jsx
* <span>Word</span>
* ```
*
* vs.
*
* ```jsx
* <span>W</span><span>ord</span>
* ```
*
* The kerning between letters "W" and "o" changes and results in a shift, if
* this property is not set.
*/
fontKerning: 'none',

/**
* Similar to `fontKerning`, but for ligatures. Ligatures are special glyphs
* that combine two or more characters into a single glyph. We disable them
* so that the text is more visually predictable.
*/
fontVariantLigatures: 'none',
});

export interface InlineViewProps {
inline: Inline;
}

/** @todo Add ability to compute `.hash` for {@link Inline} nodes and use for memoization. */
export const InlineView: React.FC<InlineViewProps> = (props) => {
const {inline} = props;
const {renderers} = usePeritext();
const ref = React.useRef<HTMLSpanElement | null>(null);
const text = inline.text();

const span = ref.current;
if (span) (span as any)[ElementAttr.InlineOffset] = inline;

const attributes: React.HTMLAttributes<HTMLSpanElement> = {
className: CssClass.Inline,
};

let children: React.ReactNode = (
<TextView
ref={(span: HTMLSpanElement | null) => {
ref.current = span as HTMLSpanElement;
if (span) (span as any)[ElementAttr.InlineOffset] = inline;
}}
attr={attributes}
text={text}
/>
);
for (const map of renderers) children = map.inline?.(props, children, attributes) ?? children;

if (inline.hasCursor()) {
const elements: React.ReactNode[] = [];
const attr = inline.attr();
const key = inline.key();
const cursorStart = inline.cursorStart();
if (cursorStart) {
const k = key + 'a';
elements.push(
cursorStart.isStartFocused() ? (
cursorStart.isCollapsed() ? (
<CaretView key={k} italic={!!attr.i} />
) : (
<FocusView key={k} />
)
) : (
<AnchorView key={k} />
),
);
}
elements.push(h(Fragment, {key}, children));
const cursorEnd = inline.cursorEnd();
if (cursorEnd) {
const k = key + 'b';
elements.push(
cursorEnd.isEndFocused() ? (
cursorEnd.isCollapsed() ? (
<CaretView key={k} italic={!!attr.i} />
) : (
<FocusView key={k} left />
)
) : (
<AnchorView key={k} />
),
);
}
children = h(Fragment, null, elements);
}

return children;
};
80 changes: 80 additions & 0 deletions src/json-crdt-peritext-ui/react/PeritextView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from 'react';
import {put} from 'nano-theme';
import {context, type PeritextSurfaceContextValue} from './context';
import {CssClass} from '../constants';
import {BlockView} from './BlockView';
import useIsomorphicLayoutEffect from 'react-use/lib/useIsomorphicLayoutEffect';
import {PeritextDomController} from '../events/PeritextDomController';
import {renderers as defaultRenderers} from '../renderers/default';
import type {Peritext} from '../../json-crdt-extensions/peritext/Peritext';
import type {RendererMap} from './types';

put('.' + CssClass.Editor, {
out: 0,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
'caret-color': 'transparent',
'::selection': {
bgc: 'transparent',
},

/** @todo Move these to the default theme. */
fontVariantNumeric: 'slashed-zero oldstyle-nums',
fontOpticalSizing: 'auto',
});

/**
* @todo The PeritextView should return some imperative API, such as the methods
* for finding line wrappings (soft start and end of line) and positions
* of characters when moving the cursor up/down.
*/
export interface PeritextViewProps {
peritext: Peritext;
renderers?: RendererMap[];
onRender?: () => void;
}

/** @todo Is `React.memo` needed here? */
export const PeritextView: React.FC<PeritextViewProps> = React.memo((props) => {
const {peritext, renderers = [defaultRenderers], onRender} = props;
const [, setTick] = React.useState(0);
const ref = React.useRef<HTMLElement | null>(null);
const controller = React.useRef<PeritextDomController | undefined>(undefined);

const rerender = () => {
peritext.refresh();
setTick((tick) => tick + 1);
if (onRender) onRender();
};

useIsomorphicLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const ctrl = new PeritextDomController({source: el, txt: peritext});
controller.current = ctrl;
ctrl.start();
ctrl.et.addEventListener('change', rerender);
return () => {
ctrl.stop();
ctrl.et.removeEventListener('change', rerender);
};
}, [peritext, ref.current]);

const block = peritext.blocks.root;
if (!block) return null;

const value: PeritextSurfaceContextValue = {
peritext,
dom: controller.current,
renderers,
rerender,
};

let children: React.ReactNode = (
<div ref={(div) => (ref.current = div)} className={CssClass.Editor}>
<BlockView hash={block.hash} block={block} />
</div>
);
for (const map of renderers) children = map.peritext?.(props, children) ?? children;
return <context.Provider value={value}>{children}</context.Provider>;
});
11 changes: 11 additions & 0 deletions src/json-crdt-peritext-ui/react/TextView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as React from 'react';

export const TextView = React.forwardRef<HTMLSpanElement, {text: string; attr: React.HTMLAttributes<HTMLSpanElement>}>(
(props, ref) => {
return (
<span {...props.attr} ref={ref}>
{props.text}
</span>
);
},
);
14 changes: 14 additions & 0 deletions src/json-crdt-peritext-ui/react/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from 'react';
import type {RendererMap} from './types';
import type {Peritext} from '../../json-crdt-extensions/peritext/Peritext';
import type {PeritextDomController} from '../events/PeritextDomController';

export interface PeritextSurfaceContextValue {
peritext: Peritext;
renderers: RendererMap[];
dom?: PeritextDomController;
rerender: () => void;
}

export const context = React.createContext<PeritextSurfaceContextValue | null>(null);
export const usePeritext = () => React.useContext(context)!;
7 changes: 7 additions & 0 deletions src/json-crdt-peritext-ui/react/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

export const useIsoLayoutEffect =
typeof window === 'object' && !!window.document ? React.useLayoutEffect : React.useEffect;

export const useBrowserLayoutEffect =
typeof window === 'object' && !!window.document ? React.useLayoutEffect : () => {};
1 change: 1 addition & 0 deletions src/json-crdt-peritext-ui/react/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './PeritextView';
12 changes: 12 additions & 0 deletions src/json-crdt-peritext-ui/react/selection/AnchorView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from 'react';
import {usePeritext} from '../context';

export interface AnchorViewProps {}

export const AnchorView: React.FC<AnchorViewProps> = (props) => {
const {renderers} = usePeritext();

let children: React.ReactNode = null;
for (const map of renderers) children = map.anchor?.(props, children);
return children;
};
15 changes: 15 additions & 0 deletions src/json-crdt-peritext-ui/react/selection/Caret.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';
import {Char} from '../../constants';
import {usePeritext} from '../context';
import {useCaret} from './hooks';

export const Caret: React.FC = () => {
const {dom} = usePeritext();
const ref = useCaret();

return (
<span id={dom?.selection.caretId} ref={ref}>
{Char.ZeroLengthSpace}
</span>
);
};
Loading
Loading