Skip to content

Commit

Permalink
feat(CodeMirrorMerge): add orientation, revertControls, `highligh…
Browse files Browse the repository at this point in the history
…tChanges`, `gutter` props.
  • Loading branch information
jaywcjlove committed Apr 8, 2023
1 parent d7ca858 commit 3198a3c
Show file tree
Hide file tree
Showing 9 changed files with 324 additions and 48 deletions.
105 changes: 104 additions & 1 deletion merge/README.md
Expand Up @@ -35,7 +35,7 @@ five`;

export const Example = () => {
return (
<CodeMirrorMerge>
<CodeMirrorMerge orientation="b-a">
<Original value={doc} />
<Modified
value={doc.replace(/t/g, 'T') + 'Six'}
Expand All @@ -46,6 +46,109 @@ export const Example = () => {
};
```

## Props

```ts
export interface CodeMirrorMergeProps extends React.HTMLAttributes<HTMLDivElement>, MergeConfig {}

interface MergeConfig {
/**
Controls whether editor A or editor B is shown first. Defaults
to `"a-b"`.
*/
orientation?: 'a-b' | 'b-a';
/**
Controls whether revert controls are shown between changed
chunks.
*/
revertControls?: 'a-to-b' | 'b-to-a';
/**
When given, this function is called to render the button to
revert a chunk.
*/
renderRevertControl?: () => HTMLElement;
/**
By default, the merge view will mark inserted and deleted text
in changed chunks. Set this to false to turn that off.
*/
highlightChanges?: boolean;
/**
Controls whether a gutter marker is shown next to changed lines.
*/
gutter?: boolean;
/**
When given, long stretches of unchanged text are collapsed.
`margin` gives the number of lines to leave visible after/before
a change (default is 3), and `minSize` gives the minimum amount
of collapsible lines that need to be present (defaults to 4).
*/
collapseUnchanged?: {
margin?: number;
minSize?: number;
};
}
```

## Modified Props

```ts
interface ModifiedProps {
/**
The initial document. Defaults to an empty document. Can be
provided either as a plain string (which will be split into
lines according to the value of the [`lineSeparator`
facet](https://codemirror.net/6/docs/ref/#state.EditorState^lineSeparator)), or an instance of
the [`Text`](https://codemirror.net/6/docs/ref/#state.Text) class (which is what the state will use
to represent the document).
*/
value?: string | Text;
/**
The starting selection. Defaults to a cursor at the very start
of the document.
*/
selection?:
| EditorSelection
| {
anchor: number;
head?: number;
};
/**
[Extension(s)](https://codemirror.net/6/docs/ref/#state.Extension) to associate with this state.
*/
extensions?: Extension;
}
```

## Original Props

```ts
interface OriginalProps {
/**
The initial document. Defaults to an empty document. Can be
provided either as a plain string (which will be split into
lines according to the value of the [`lineSeparator`
facet](https://codemirror.net/6/docs/ref/#state.EditorState^lineSeparator)), or an instance of
the [`Text`](https://codemirror.net/6/docs/ref/#state.Text) class (which is what the state will use
to represent the document).
*/
value?: string | Text;
/**
The starting selection. Defaults to a cursor at the very start
of the document.
*/
selection?:
| EditorSelection
| {
anchor: number;
head?: number;
};
/**
[Extension(s)](https://codemirror.net/6/docs/ref/#state.Extension) to associate with this state.
*/
extensions?: Extension;
}
```

## Contributors

As always, thanks to our amazing contributors!
Expand Down
71 changes: 62 additions & 9 deletions merge/src/Internal.tsx
@@ -1,27 +1,38 @@
import React, { useEffect, useImperativeHandle, useRef } from 'react';
import { MergeView } from '@codemirror/merge';
import React, { useEffect, useImperativeHandle, useMemo, useRef, memo } from 'react';
import { MergeView, MergeConfig } from '@codemirror/merge';
import { useStore } from './store';
import { CodeMirrorMergeProps } from './';

export interface InternalRef {
container?: HTMLDivElement | null;
view?: MergeView;
}

export interface InternalProps extends React.LiHTMLAttributes<HTMLDivElement> {}

export const Internal = React.forwardRef((props: InternalProps, ref?: React.ForwardedRef<InternalRef>) => {
const { className, children } = props;
const { modified, original, view, dispatch } = useStore();
export const Internal = React.forwardRef((props: CodeMirrorMergeProps, ref?: React.ForwardedRef<InternalRef>) => {
const {
className,
children,
orientation,
revertControls,
highlightChanges,
gutter,
collapseUnchanged,
renderRevertControl,
...elmProps
} = props;
const { modified, original, view, dispatch, ...otherStore } = useStore();
const editor = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({ container: editor.current, view }), [editor, view]);
useEffect(() => {
if (!view && editor.current && original && modified) {
const opts = { orientation, revertControls, highlightChanges, gutter, collapseUnchanged, renderRevertControl };
const viewDefault = new MergeView({
a: original,
b: modified,
parent: editor.current,
...opts,
});
dispatch && dispatch({ view: viewDefault });
dispatch && dispatch({ view: viewDefault, ...opts });
}
}, [editor.current, original, modified, view]);

Expand All @@ -31,9 +42,51 @@ export const Internal = React.forwardRef((props: InternalProps, ref?: React.Forw
};
}, []);

useEffect(() => {
if (view) {
const opts: MergeConfig = {};
if (otherStore.orientation !== orientation) {
opts.orientation = orientation;
}
if (otherStore.revertControls !== revertControls) {
opts.revertControls = revertControls;
}
if (otherStore.highlightChanges !== highlightChanges) {
opts.highlightChanges = highlightChanges;
}
if (otherStore.gutter !== gutter) {
opts.gutter = gutter;
}
if (otherStore.collapseUnchanged !== collapseUnchanged) {
opts.collapseUnchanged = collapseUnchanged;
}
if (Object.keys(opts).length && dispatch && original && modified && editor.current) {
view.destroy();
const viewDefault = new MergeView({
a: original,
b: modified,
parent: editor.current,
...opts,
});
dispatch({ ...opts, renderRevertControl, view: viewDefault });
}
}
}, [
view,
original,
modified,
editor,
orientation,
revertControls,
highlightChanges,
gutter,
collapseUnchanged,
renderRevertControl,
]);

const defaultClassNames = 'cm-merge-theme';
return (
<div ref={editor} className={`${defaultClassNames}${className ? ` ${className}` : ''}`} {...props}>
<div ref={editor} className={`${defaultClassNames}${className ? ` ${className}` : ''}`} {...elmProps}>
{children}
</div>
);
Expand Down
11 changes: 3 additions & 8 deletions merge/src/Modified.tsx
Expand Up @@ -3,7 +3,7 @@ import { EditorStateConfig, Extension, StateEffect } from '@codemirror/state';
import { getDefaultExtensions } from '@uiw/react-codemirror';
import { useStore } from './store';

export interface ModifiedProps extends EditorStateConfig {
export interface ModifiedProps extends Omit<EditorStateConfig, 'doc'> {
value?: EditorStateConfig['doc'];
extensions?: Extension[];
}
Expand All @@ -21,20 +21,15 @@ export const Modified = (props: ModifiedProps): JSX.Element | null => {
if (modifiedDoc !== props.value) {
view.b.dispatch({
changes: { from: 0, to: (modifiedDoc || '').length, insert: props.value || '' },
effects: StateEffect.appendConfig.of([...defaultExtensions, ...extensions]),
});
}
}
if (modified?.selection !== props.selection) {
data.selection = props.selection;
dispatch!({ modified: { ...modified, ...data } });
}
}, [props.value, props.selection, view]);

useEffect(() => {
if (view) {
view.b.dispatch({ effects: StateEffect.appendConfig.of([...defaultExtensions, ...extensions]) });
}
}, [extensions, view]);
}, [props.value, extensions, props.selection, view]);

return null;
};
Expand Down
9 changes: 2 additions & 7 deletions merge/src/Original.tsx
Expand Up @@ -3,7 +3,7 @@ import { EditorStateConfig, Extension, StateEffect } from '@codemirror/state';
import { useStore } from './store';
import { getDefaultExtensions } from '@uiw/react-codemirror';

export interface OriginalProps extends EditorStateConfig {
export interface OriginalProps extends Omit<EditorStateConfig, 'doc'> {
value?: EditorStateConfig['doc'];
extensions?: Extension[];
}
Expand All @@ -21,6 +21,7 @@ export const Original = (props: OriginalProps): JSX.Element | null => {
if (originalDoc !== props.value) {
view?.a.dispatch({
changes: { from: 0, to: (originalDoc || '').length, insert: props.value || '' },
effects: StateEffect.appendConfig.of([...defaultExtensions, ...extensions]),
});
}
}
Expand All @@ -30,12 +31,6 @@ export const Original = (props: OriginalProps): JSX.Element | null => {
}
}, [props.value, props.selection, view]);

useEffect(() => {
if (view) {
view.a.dispatch({ effects: StateEffect.appendConfig.of([...defaultExtensions, ...extensions]) });
}
}, [extensions, view]);

return null;
};

Expand Down
9 changes: 5 additions & 4 deletions merge/src/index.tsx
@@ -1,21 +1,22 @@
import React from 'react';
import { MergeConfig } from '@codemirror/merge';
import { Original } from './Original';
import { Modified } from './Modified';
import { Internal, InternalRef } from './Internal';
import { Provider } from './store';

export interface ReactCodeMirrorMergeRef extends InternalRef {}
export interface ReactCodeMirrorMergeProps extends React.LiHTMLAttributes<HTMLDivElement> {}
export interface CodeMirrorMergeRef extends InternalRef {}
export interface CodeMirrorMergeProps extends React.HTMLAttributes<HTMLDivElement>, MergeConfig {}

const InternalCodeMirror = (props: ReactCodeMirrorMergeProps, ref?: React.ForwardedRef<InternalRef>) => {
const InternalCodeMirror = (props: CodeMirrorMergeProps, ref?: React.ForwardedRef<InternalRef>) => {
return (
<Provider>
<Internal {...props} ref={ref} />
</Provider>
);
};

type CodeMirrorComponent = React.FC<React.PropsWithRef<ReactCodeMirrorMergeProps>> & {
type CodeMirrorComponent = React.FC<React.PropsWithRef<CodeMirrorMergeProps>> & {
Original: typeof Original;
Modified: typeof Modified;
};
Expand Down
4 changes: 2 additions & 2 deletions merge/src/store.tsx
@@ -1,12 +1,12 @@
import React, { PropsWithChildren, createContext, useContext, useReducer } from 'react';
import { EditorStateConfig } from '@codemirror/state';
import { MergeView } from '@codemirror/merge';
import { MergeView, MergeConfig } from '@codemirror/merge';

export interface StoreContextValue extends InitialState {
dispatch?: React.Dispatch<InitialState>;
}

export interface InitialState {
export interface InitialState extends MergeConfig {
modified?: EditorStateConfig;
original?: EditorStateConfig;
view?: MergeView;
Expand Down

1 comment on commit 3198a3c

@jaywcjlove
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.