diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b220382..5d338366f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: 📦 react-codemirror-merge publish to NPM + run: npm publish --access public + working-directory: ./merge/ + continue-on-error: true + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: 📦 @uiw/codemirror-themes publish to NPM run: npm publish --access public @@ -364,6 +370,20 @@ jobs: env: NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: "Modify react-codemirror-merge => @uiwjs/react-codemirror-merge" + uses: jaywcjlove/github-action-package@main + continue-on-error: true + with: + path: merge/package.json + rename: "@uiwjs/react-codemirror-merge" + + - run: npm publish + name: 📦 @uiwjs/react-codemirror-merge publish to NPM + working-directory: merge + continue-on-error: true + env: + NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Modify @uiw/codemirror-themes => @uiwjs/codemirror-themes uses: jaywcjlove/github-action-package@main with: diff --git a/core/README.md b/core/README.md index e5eb640ba..5c49568d6 100644 --- a/core/README.md +++ b/core/README.md @@ -183,6 +183,34 @@ export default function App() { } ``` +## Codemirror Merge + +```jsx +import CodeMirrorMerge from 'react-codemirror-merge'; +import { EditorView } from 'codemirror'; +import { EditorState } from '@codemirror/state'; + +const Original = CodeMirrorMerge.Original; +const Modified = CodeMirrorMerge.Modified; +let doc = `one +two +three +four +five`; + +export const Example = () => { + return ( + + + + + ); +}; +``` + ## Support Hook [![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-blue?logo=codesandbox)](https://codesandbox.io/embed/react-codemirror-example-codemirror-6-hook-yr4vg?fontsize=14&hidenavigation=1&theme=dark) @@ -397,7 +425,7 @@ export interface ReactCodeMirrorProps */ readOnly?: boolean; /** - * Controls whether pressing the `Tab` key inserts a tab character and indents the text (`true`) + * Controls whether pressing the `Tab` key inserts a tab character and indents the text (`true`) * or behaves according to the browser's default behavior (`false`). * @default true */ diff --git a/core/src/getDefaultExtensions.ts b/core/src/getDefaultExtensions.ts new file mode 100644 index 000000000..9248785ac --- /dev/null +++ b/core/src/getDefaultExtensions.ts @@ -0,0 +1,71 @@ +import { Extension } from '@codemirror/state'; +import { indentWithTab } from '@codemirror/commands'; +import { basicSetup, BasicSetupOptions } from '@uiw/codemirror-extensions-basic-setup'; +import { EditorView, keymap, placeholder } from '@codemirror/view'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { EditorState } from '@codemirror/state'; + +export type DefaultExtensionsOptions = { + indentWithTab?: boolean; + basicSetup?: boolean | BasicSetupOptions; + placeholder?: string | HTMLElement; + theme?: 'light' | 'dark' | 'none' | Extension; + readOnly?: boolean; + editable?: boolean; +}; + +export const getDefaultExtensions = (optios: DefaultExtensionsOptions = {}): Extension[] => { + const { + indentWithTab: defaultIndentWithTab = true, + editable = true, + readOnly = false, + theme = 'light', + placeholder: placeholderStr = '', + basicSetup: defaultBasicSetup = true, + } = optios; + const getExtensions: Extension[] = []; + const defaultLightThemeOption = EditorView.theme( + { + '&': { + backgroundColor: '#fff', + }, + }, + { + dark: false, + }, + ); + if (defaultIndentWithTab) { + getExtensions.unshift(keymap.of([indentWithTab])); + } + if (defaultBasicSetup) { + if (typeof defaultBasicSetup === 'boolean') { + getExtensions.unshift(basicSetup()); + } else { + getExtensions.unshift(basicSetup(defaultBasicSetup)); + } + } + if (placeholderStr) { + getExtensions.unshift(placeholder(placeholderStr)); + } + switch (theme) { + case 'light': + getExtensions.push(defaultLightThemeOption); + break; + case 'dark': + getExtensions.push(oneDark); + break; + case 'none': + break; + default: + getExtensions.push(theme); + break; + } + if (editable === false) { + getExtensions.push(EditorView.editable.of(false)); + } + if (readOnly) { + getExtensions.push(EditorState.readOnly.of(true)); + } + + return [...getExtensions]; +}; diff --git a/core/src/index.tsx b/core/src/index.tsx index 62cfd97fb..898e31576 100644 --- a/core/src/index.tsx +++ b/core/src/index.tsx @@ -7,6 +7,7 @@ import { Statistics } from './utils'; export * from '@uiw/codemirror-extensions-basic-setup'; export * from './useCodeMirror'; +export * from './getDefaultExtensions'; export * from './utils'; export interface ReactCodeMirrorProps @@ -45,7 +46,7 @@ export interface ReactCodeMirrorProps */ readOnly?: boolean; /** - * Controls whether pressing the `Tab` key inserts a tab character and indents the text (`true`) + * Controls whether pressing the `Tab` key inserts a tab character and indents the text (`true`) * or behaves according to the browser's default behavior (`false`). * @default true */ diff --git a/core/src/useCodeMirror.ts b/core/src/useCodeMirror.ts index 4b9a84723..a34aaa458 100644 --- a/core/src/useCodeMirror.ts +++ b/core/src/useCodeMirror.ts @@ -1,9 +1,7 @@ import { useEffect, useState } from 'react'; import { Annotation, EditorState, StateEffect } from '@codemirror/state'; -import { indentWithTab } from '@codemirror/commands'; -import { EditorView, keymap, ViewUpdate, placeholder } from '@codemirror/view'; -import { basicSetup } from '@uiw/codemirror-extensions-basic-setup'; -import { oneDark } from '@codemirror/theme-one-dark'; +import { EditorView, ViewUpdate } from '@codemirror/view'; +import { getDefaultExtensions } from './getDefaultExtensions'; import { getStatistics } from './utils'; import { ReactCodeMirrorProps } from '.'; @@ -41,16 +39,6 @@ export function useCodeMirror(props: UseCodeMirror) { const [container, setContainer] = useState(); const [view, setView] = useState(); const [state, setState] = useState(); - const defaultLightThemeOption = EditorView.theme( - { - '&': { - backgroundColor: '#fff', - }, - }, - { - dark: false, - }, - ); const defaultThemeOption = EditorView.theme({ '&': { height, @@ -76,42 +64,16 @@ export function useCodeMirror(props: UseCodeMirror) { onStatistics && onStatistics(getStatistics(vu)); }); - let getExtensions = [updateListener, defaultThemeOption]; - if (defaultIndentWithTab) { - getExtensions.unshift(keymap.of([indentWithTab])); - } - if (defaultBasicSetup) { - if (typeof defaultBasicSetup === 'boolean') { - getExtensions.unshift(basicSetup()); - } else { - getExtensions.unshift(basicSetup(defaultBasicSetup)); - } - } - - if (placeholderStr) { - getExtensions.unshift(placeholder(placeholderStr)); - } - - switch (theme) { - case 'light': - getExtensions.push(defaultLightThemeOption); - break; - case 'dark': - getExtensions.push(oneDark); - break; - case 'none': - break; - default: - getExtensions.push(theme); - break; - } + const defaultExtensions = getDefaultExtensions({ + theme, + editable: true, + readOnly: false, + placeholder: placeholderStr, + indentWithTab: defaultIndentWithTab, + basicSetup: defaultBasicSetup, + }); - if (editable === false) { - getExtensions.push(EditorView.editable.of(false)); - } - if (readOnly) { - getExtensions.push(EditorState.readOnly.of(true)); - } + let getExtensions = [updateListener, defaultThemeOption, ...defaultExtensions]; if (onUpdate && typeof onUpdate === 'function') { getExtensions.push(EditorView.updateListener.of(onUpdate)); diff --git a/lerna.json b/lerna.json index 70c2e60f1..4df64b383 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "version": "4.19.11", - "packages": ["themes/**", "core", "www"], + "packages": ["themes/**", "core", "merge", "www"], "useWorkspaces": true } diff --git a/merge/README.md b/merge/README.md new file mode 100644 index 000000000..c6a9b9ec9 --- /dev/null +++ b/merge/README.md @@ -0,0 +1,57 @@ + + +# react-codemirror-merge + + + +[![npm version](https://img.shields.io/npm/v/react-codemirror-merge.svg)](https://www.npmjs.com/package/react-codemirror-merge) + +CodeMirror merge view for React. + +## Install + +```bash +npm install react-codemirror-merge --save +``` + +## Usage + +```jsx +import CodeMirrorMerge from 'react-codemirror-merge'; +import { EditorView } from 'codemirror'; +import { EditorState } from '@codemirror/state'; + +const Original = CodeMirrorMerge.Original; +const Modified = CodeMirrorMerge.Modified; +let doc = `one +two +three +four +five`; + +export const Example = () => { + return ( + + + + + ); +}; +``` + +## Contributors + +As always, thanks to our amazing contributors! + + + + + +Made with [github-action-contributors](https://github.com/jaywcjlove/github-action-contributors). + +## License + +Licensed under the MIT License. diff --git a/merge/package.json b/merge/package.json new file mode 100644 index 000000000..2348fd795 --- /dev/null +++ b/merge/package.json @@ -0,0 +1,56 @@ +{ + "name": "react-codemirror-merge", + "version": "0.0.1", + "description": "CodeMirror merge view for React.", + "homepage": "https://uiwjs.github.io/react-codemirror", + "author": "kenny wong ", + "license": "MIT", + "main": "./cjs/index.js", + "module": "./esm/index.js", + "scripts": { + "watch": "tsbb watch src/*.tsx --use-babel", + "build": "tsbb build src/*.tsx --use-babel", + "test": "tsbb test --env=jsdom", + "coverage": "tsbb test --env=jsdom --coverage --bail" + }, + "repository": { + "type": "git", + "url": "https://github.com/uiwjs/react-codemirror.git" + }, + "files": [ + "dist", + "src", + "esm", + "cjs" + ], + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "dependencies": { + "@babel/runtime": "^7.18.6", + "@uiw/react-codemirror": "^4.19.11", + "@codemirror/merge": "^6.0.1" + }, + "keywords": [ + "react", + "codemirror", + "codemirror6", + "react-codemirror", + "editor", + "syntax", + "ide", + "code" + ], + "jest": { + "coverageReporters": [ + "lcov", + "json-summary" + ] + } +} diff --git a/merge/src/Internal.tsx b/merge/src/Internal.tsx new file mode 100644 index 000000000..c91b10a4f --- /dev/null +++ b/merge/src/Internal.tsx @@ -0,0 +1,37 @@ +import React, { useEffect, useRef } from 'react'; +import { MergeView } from '@codemirror/merge'; +import { useStore } from './store'; + +export interface ReactCodeMirrorMergeInternalProps extends React.LiHTMLAttributes {} + +export const Internal = React.forwardRef( + (props: ReactCodeMirrorMergeInternalProps, ref?: React.ForwardedRef) => { + const { className, children } = props; + const { modified, original, view, dispatch } = useStore(); + const editor = useRef(null); + + useEffect(() => { + if (!view && editor.current && original && modified) { + const viewDefault = new MergeView({ + a: original, + b: modified, + parent: editor.current, + }); + dispatch && dispatch({ view: viewDefault }); + } + }, [editor.current, original, modified, view]); + + useEffect(() => { + return () => { + view && view.destroy(); + }; + }, []); + + const defaultClassNames = 'cm-merge-theme'; + return ( +
+ {children} +
+ ); + }, +); diff --git a/merge/src/Modified.tsx b/merge/src/Modified.tsx new file mode 100644 index 000000000..d9afbc39e --- /dev/null +++ b/merge/src/Modified.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { EditorStateConfig, Extension } from '@codemirror/state'; +import { getDefaultExtensions } from '@uiw/react-codemirror'; +import { useStore } from './store'; + +export interface ModifiedProps extends EditorStateConfig { + value?: EditorStateConfig['doc']; + extensions?: Extension[]; +} + +export const Modified = (props: ModifiedProps): JSX.Element | null => { + const { extensions = [] } = props; + const { modified, view, dispatch } = useStore(); + const defaultExtensions = getDefaultExtensions(); + useEffect(() => { + const data: EditorStateConfig = { extensions: [...defaultExtensions, ...extensions] }; + if (modified?.doc !== props.value && view) { + data.doc = props.value; + dispatch!({ modified: { ...modified, ...data } }); + const modifiedDoc = view?.b.state.doc.toString(); + if (modifiedDoc !== props.value) { + view.b.dispatch({ + changes: { from: 0, to: (modifiedDoc || '').length, insert: props.value || '' }, + }); + } + } + if (modified?.selection !== props.selection) { + data.selection = props.selection; + dispatch!({ modified: { ...modified, ...data } }); + } + }, [props.value, props.selection, view]); + + return null; +}; diff --git a/merge/src/Original.tsx b/merge/src/Original.tsx new file mode 100644 index 000000000..25b514489 --- /dev/null +++ b/merge/src/Original.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { EditorStateConfig, Extension } from '@codemirror/state'; +import { useStore } from './store'; +import { getDefaultExtensions } from '@uiw/react-codemirror'; + +export interface OriginalProps extends EditorStateConfig { + value?: EditorStateConfig['doc']; + extensions?: Extension[]; +} + +export const Original = (props: OriginalProps): JSX.Element | null => { + const { extensions = [] } = props; + const { original, view, dispatch } = useStore(); + const defaultExtensions = getDefaultExtensions(); + useEffect(() => { + const data: EditorStateConfig = { extensions: [...defaultExtensions, ...extensions] }; + if (original?.doc !== props.value && view) { + data.doc = props.value; + dispatch!({ original: { ...original, ...data } }); + const originalDoc = view?.a.state.doc.toString(); + if (originalDoc !== props.value) { + view?.a.dispatch({ + changes: { from: 0, to: (originalDoc || '').length, insert: props.value || '' }, + }); + } + } + if (original?.selection !== props.selection) { + data.selection = props.selection; + dispatch!({ original: { ...original, ...data } }); + } + }, [props.value, props.selection, view]); + + return null; +}; diff --git a/merge/src/index.tsx b/merge/src/index.tsx new file mode 100644 index 000000000..e5302c4cf --- /dev/null +++ b/merge/src/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Original } from './Original'; +import { Modified } from './Modified'; +import { Internal } from './Internal'; +import { Provider } from './store'; + +export interface ReactCodeMirrorMergeProps extends React.LiHTMLAttributes {} + +const InternalCodeMirror = (props: ReactCodeMirrorMergeProps, ref?: React.ForwardedRef) => { + return ( + + + + ); +}; + +type CodeMirrorComponent = React.FC> & { + Original: typeof Original; + Modified: typeof Modified; +}; + +const ReactCodeMirrorMerge: CodeMirrorComponent = React.forwardRef( + InternalCodeMirror, +) as unknown as CodeMirrorComponent; + +ReactCodeMirrorMerge.Original = Original; +ReactCodeMirrorMerge.Modified = Modified; +ReactCodeMirrorMerge.displayName = 'CodeMirrorMerge'; + +export default ReactCodeMirrorMerge; diff --git a/merge/src/store.tsx b/merge/src/store.tsx new file mode 100644 index 000000000..913838279 --- /dev/null +++ b/merge/src/store.tsx @@ -0,0 +1,38 @@ +import React, { PropsWithChildren, createContext, useContext, useReducer } from 'react'; +import { EditorStateConfig } from '@codemirror/state'; +import { MergeView } from '@codemirror/merge'; + +export interface StoreContextValue extends InitialState { + dispatch?: React.Dispatch; +} + +export interface InitialState { + modified?: EditorStateConfig; + original?: EditorStateConfig; + view?: MergeView; +} + +export const initialState: InitialState = { + modified: { doc: '' }, + original: { doc: '' }, +}; + +export const Context = createContext(initialState); + +export function reducer(state: InitialState, action: InitialState): InitialState { + return { + ...state, + ...action, + modified: { ...state.modified, ...action.modified }, + original: { ...state.original, ...action.original }, + }; +} + +export const useStore = () => { + return useContext(Context); +}; + +export const Provider: React.FC> = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + return {children}; +}; diff --git a/merge/tsconfig.json b/merge/tsconfig.json new file mode 100644 index 000000000..50face246 --- /dev/null +++ b/merge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig", + "include": ["src"], + "compilerOptions": { + "outDir": "cjs", + "baseUrl": "." + } +} diff --git a/package.json b/package.json index 6aaeb10d1..f9da0e174 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "build": "lerna exec --scope @uiw/* --ignore www -- npm run build", + "build": "lerna exec --scope @uiw/* --scope react-codemirror-merge --ignore www -- npm run build", "⬇️⬇️⬇️⬇️⬇️ package ⬇️⬇️⬇️⬇️⬇️": "▼▼▼▼▼ package ▼▼▼▼▼", "watch": "npm run-script watch --workspace @uiw/react-codemirror", "bundle": "npm run-script bundle --workspace @uiw/react-codemirror", @@ -18,6 +18,7 @@ "themes/**", "extensions/**", "core", + "merge", "www" ], "engines": { diff --git a/www/package.json b/www/package.json index a38f2ffac..c52ba770b 100644 --- a/www/package.json +++ b/www/package.json @@ -79,6 +79,7 @@ "code-example": "^3.3.6", "markdown-react-code-preview-loader": "^2.1.2", "react": "~18.2.0", + "react-codemirror-merge": "0.0.1", "react-code-preview-layout": "^2.0.5", "react-dom": "~18.2.0", "react-router-dom": "^6.3.0", diff --git a/www/src/components/NavMenus.tsx b/www/src/components/NavMenus.tsx index ad047f22b..a8ab4e2b4 100644 --- a/www/src/components/NavMenus.tsx +++ b/www/src/components/NavMenus.tsx @@ -36,6 +36,7 @@ export const NavMenus = () => { Home Extensions + Merge Themes Themes Editor Theme Doc diff --git a/www/src/index.tsx b/www/src/index.tsx index 84042b2b3..cfb050f7f 100644 --- a/www/src/index.tsx +++ b/www/src/index.tsx @@ -7,6 +7,7 @@ import { ThemeEditor } from './pages/theme/editor'; import { ThemeLayout } from './pages/theme'; import { ThemesHome } from './pages/theme/home'; import { ThemeDoc } from './pages/theme/docs'; +import { MergeDoc, MergeLayout } from './pages/merge'; import { ThemeOkaidia } from './pages/theme/themes'; import { ExtensionsLayout } from './pages/extensions'; import { EventsDoc } from './pages/extensions/events'; @@ -73,6 +74,9 @@ root.render( } /> } /> + }> + } /> + }> } /> } /> diff --git a/www/src/pages/merge/Example.tsx b/www/src/pages/merge/Example.tsx new file mode 100644 index 000000000..ccd294e80 --- /dev/null +++ b/www/src/pages/merge/Example.tsx @@ -0,0 +1,23 @@ +import CodeMirrorMerge from 'react-codemirror-merge'; +import { EditorView } from 'codemirror'; +import { EditorState } from '@codemirror/state'; + +const Original = CodeMirrorMerge.Original; +const Modified = CodeMirrorMerge.Modified; +let doc = `one +two +three +four +five`; + +export const Example = () => { + return ( + + + + + ); +}; diff --git a/www/src/pages/merge/data.tsx b/www/src/pages/merge/data.tsx new file mode 100644 index 000000000..769d128ba --- /dev/null +++ b/www/src/pages/merge/data.tsx @@ -0,0 +1,5 @@ +import mergeMd from 'react-codemirror-merge/README.md'; + +export const mdSource = { + document: mergeMd.source, +}; diff --git a/www/src/pages/merge/index.tsx b/www/src/pages/merge/index.tsx new file mode 100644 index 000000000..77340e477 --- /dev/null +++ b/www/src/pages/merge/index.tsx @@ -0,0 +1,51 @@ +import { Outlet, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; +import MarkdownPreview from '@uiw/react-markdown-preview'; +import BackToUp from '@uiw/react-back-to-top'; +import { Example } from './Example'; +import { Header, Warpper } from '../../components/Header'; +import { Sider } from '../theme/editor'; +import { Document } from '../extensions/Document'; +import { PageWarpper } from '../extensions'; +import { Content } from '../theme'; +import { MenuItem } from '../theme/themes/SiderMenus'; +import { mdSource } from './data'; + +export const MergeDoc = () => { + return ( + + + + Top + + ); +}; + +export const MergeLayout = () => { + const location = useLocation(); + useEffect(() => { + document.title = `${location.pathname.split('/').join(' ')} for CodeMirror 6`; + }, [location]); + return ( + +
+ + + {Object.keys(mdSource).map((name, key) => { + return ( + + {name.split('-').join(' ')} + + ); + })} + + + + + + + ); +};