├── .npmrc ├── src ├── react-app-env.d.ts ├── setupTests.ts ├── index.tsx ├── demo │ ├── CustomPreview.tsx │ ├── Autosaving.tsx │ ├── CleanupByButton.tsx │ ├── CustomEventsAndCursorPosition.tsx │ ├── UpdateableByHotKeys.tsx │ ├── DynamicallyChangingEvents.tsx │ ├── DynamicallyChangingOptions.tsx │ ├── Demo.tsx │ ├── UpdateUsingButtonWithAutofocus.tsx │ └── GetInstance.tsx ├── index.test.tsx └── SimpleMdeReact.tsx ├── favicon.ico ├── .editorconfig ├── netlify.toml ├── tsup.config.js ├── vite.config.ts ├── .gitignore ├── .github └── workflows │ └── playwright.yml ├── LICENSE ├── tests └── smoke.spec.ts ├── tsconfig.json ├── package.json ├── playwright.config.ts ├── index.html ├── .pnpmfile.cjs ├── README.md └── pnpm-lock.yaml /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RIP21/react-simplemde-editor/HEAD/favicon.ico -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import "vitest-dom/extend-expect"; 2 | // @ts-ignore 3 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | end_of_line = lf 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.md] 9 | max_line_length = 0 10 | trim_trailing_whitespace = false 11 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "16" 3 | NPM_FLAGS = "--version" # prevent Netlify npm install 4 | [build] 5 | publish = "./dist" 6 | command = "npm i pnpm -g && pnpm install && pnpm run build:demo" 7 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup" 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default defineConfig({ 5 | entry: ["src/index.ts"], 6 | splitting: false, 7 | sourcemap: true, 8 | dts: true, 9 | format: ['cjs', "esm"] 10 | }) 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import Demo from "./demo/Demo"; 3 | import React, { StrictMode } from "react"; 4 | 5 | const container = document.getElementById("root"); 6 | const root = createRoot(container!); 7 | 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export */ 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | server: { 6 | port: 3000 7 | }, 8 | test: { 9 | globals: true, 10 | environment: "happy-dom", 11 | setupFiles: ["./src/setupTests.ts"], 12 | include: ["**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | /lib 4 | /typings 5 | # See https://help.github.com/ignore-files/ for more about ignoring files. 6 | 7 | # dependencies 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .idea 26 | /test-results/ 27 | /playwright-report/ 28 | /playwright/.cache/ 29 | dist 30 | -------------------------------------------------------------------------------- /src/demo/CustomPreview.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import SimpleMDE from "easymde"; 3 | import SimpleMdeReact from "../SimpleMdeReact"; 4 | import React from "react"; 5 | 6 | export const CustomPreview = () => { 7 | const customRendererOptions = useMemo(() => { 8 | return { 9 | previewRender() { 10 | return `
Hello from preview renderer
`; 11 | }, 12 | } as SimpleMDE.Options; 13 | }, []); 14 | 15 | return ( 16 |
17 |

Custom preview

18 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/demo/Autosaving.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import SimpleMDEReact from "../SimpleMdeReact"; 3 | import React from "react"; 4 | 5 | export const Autosaving = () => { 6 | const delay = 1000; 7 | const autosavedValue = localStorage.getItem(`smde_demo`) || "Initial value"; 8 | const anOptions = useMemo(() => { 9 | return { 10 | autosave: { 11 | enabled: true, 12 | uniqueId: "demo", 13 | delay, 14 | }, 15 | }; 16 | }, [delay]); 17 | 18 | return ( 19 |
20 |

Autosaving after refresh (wait 1000ms after change)

21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/demo/CleanupByButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { State } from "./UpdateUsingButtonWithAutofocus"; 3 | import SimpleMdeReact from "../SimpleMdeReact"; 4 | import React from "react"; 5 | 6 | export const CleanupByButton = () => { 7 | const [value, setValue] = useState( 8 | "You can clean the input using button above." 9 | ); 10 | const onChange = (value: string) => setValue(value); 11 | 12 | const handleCleanup = () => { 13 | setValue(``); 14 | }; 15 | 16 | return ( 17 |
18 |

Cleanup by button

19 | 25 | 26 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/demo/CustomEventsAndCursorPosition.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import SimpleMdeReact, { SimpleMdeToCodemirrorEvents } from "../SimpleMdeReact"; 3 | import { State } from "./UpdateUsingButtonWithAutofocus"; 4 | import React from "react"; 5 | 6 | export const CustomEventsAndCursorPosition = () => { 7 | const [cursorInfo, setCursorInfo] = useState({}); 8 | 9 | const customEvents = useMemo(() => { 10 | return { 11 | cursorActivity: (instance) => { 12 | setCursorInfo(instance.getCursor()); 13 | }, 14 | } as SimpleMdeToCodemirrorEvents; 15 | }, []); 16 | 17 | return ( 18 |
19 |

Custom events and cursor reading

20 | 21 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16.x' 16 | - name: Install pnpm 17 | run: npm install pnpm -g 18 | 19 | - name: Install dependencies 20 | run: pnpm install 21 | 22 | - name: Install Playwright Browsers 23 | run: npx playwright install --with-deps 24 | 25 | - name: Run unit tests 26 | run: pnpm test 27 | 28 | - name: Run tsc 29 | run: pnpm tsc 30 | 31 | - name: Run Playwright tests 32 | run: pnpm test:e2e 33 | 34 | - uses: actions/upload-artifact@v2 35 | if: always() 36 | with: 37 | name: playwright-report 38 | path: playwright-report/ 39 | retention-days: 5 40 | 41 | -------------------------------------------------------------------------------- /src/demo/UpdateableByHotKeys.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { KeyMap } from "codemirror"; 3 | import { State } from "./UpdateUsingButtonWithAutofocus"; 4 | import SimpleMdeReact from "../SimpleMdeReact"; 5 | import React from "react"; 6 | 7 | export const UpdateableByHotKeys = () => { 8 | const extraKeys = useMemo(() => { 9 | return { 10 | Up: function (cm) { 11 | cm.replaceSelection(" surprise. "); 12 | }, 13 | Down: function (cm) { 14 | cm.replaceSelection(" surprise again! "); 15 | }, 16 | }; 17 | }, []); 18 | 19 | const [value, setValue] = useState( 20 | "Focus this text area and then use the Up and Down arrow keys to see the `extraKeys` prop in action" 21 | ); 22 | 23 | const onChange = (value: string) => setValue(value); 24 | 25 | return ( 26 |
27 |

Update by extra keys. E.g. arrow up and arrow down buttons

28 | 29 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andrii Los 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import { act, render, screen } from "@testing-library/react"; 6 | import React, { useState } from "react"; 7 | import { SimpleMdeReact } from "./SimpleMdeReact"; 8 | import userEvent from "@testing-library/user-event"; 9 | import { expect, describe, it } from "vitest" 10 | 11 | // @ts-ignore 12 | Document.prototype.createRange = function () { 13 | return { 14 | setEnd: function () {}, 15 | setStart: function () {}, 16 | getBoundingClientRect: function () { 17 | return { right: 0 }; 18 | }, 19 | getClientRects: function () { 20 | return { 21 | length: 0, 22 | left: 0, 23 | right: 0, 24 | }; 25 | }, 26 | }; 27 | }; 28 | 29 | const Editor = () => { 30 | const [value, setValue] = useState(""); 31 | return ; 32 | }; 33 | 34 | describe("Renders", () => { 35 | it("successfully", async () => { 36 | act(() => { 37 | render(); 38 | }); 39 | const editor = await screen.findByRole("textbox"); 40 | await userEvent.type(editor, "hello"); 41 | expect(screen.getByText("hello")).toBeDefined(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/demo/DynamicallyChangingEvents.tsx: -------------------------------------------------------------------------------- 1 | import SimpleMdeReact, { SimpleMdeToCodemirrorEvents } from "../SimpleMdeReact"; 2 | import { useState } from "react"; 3 | import React from "react"; 4 | import {State} from "./UpdateUsingButtonWithAutofocus"; 5 | 6 | export const DynamicallyChangingEvents = () => { 7 | const [value, setValue] = useState(`Blur away to see initial event behavior`); 8 | 9 | const [events, setEvents] = useState({ 10 | blur: (_) => { 11 | console.log("blur"); 12 | setValue(`I'm initial event behavior`); 13 | }, 14 | }); 15 | 16 | return ( 17 |
18 |

Dynamically changing event listeners

19 | 35 | 36 | 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /tests/smoke.spec.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from "@playwright/test"; 2 | import { 3 | locatorFixtures as fixtures, 4 | LocatorFixtures as TestingLibraryFixtures, 5 | } from "@playwright-testing-library/test/fixture"; 6 | 7 | const test = base.extend(fixtures); 8 | 9 | const { expect } = test; 10 | 11 | test("Tests UpdateUsingButtonWithAutofocus behavior", async ({ screen, within }) => { 12 | await screen.goto("http://localhost:3000"); 13 | const container = await screen.findByTestId("autofocus-no-spellchecker"); 14 | const editor = await within(container).findByRole("textbox"); 15 | await editor.fill("hello"); 16 | const state = within(container).getByTestId("state") 17 | const editorContainer = within(container).getByTestId("autofocus-no-spellchecker-editor") 18 | expect(within(editorContainer).getByText("hello")).toBeDefined(); 19 | expect(within(state).getByText("hello")).toBeDefined(); 20 | const buttonToChangeValue = within(container).getByText("Click me to update the textValue outside of the editor") 21 | await buttonToChangeValue.click() 22 | expect(within(editorContainer).getByText("Changing text by setting new state.")).toBeDefined(); 23 | expect(within(state).getByText("Changing text by setting new state.")).toBeDefined(); 24 | }); 25 | -------------------------------------------------------------------------------- /src/demo/DynamicallyChangingOptions.tsx: -------------------------------------------------------------------------------- 1 | import SimpleMdeReact from "../SimpleMdeReact"; 2 | import { useState } from "react"; 3 | import { Options } from "easymde"; 4 | import React from "react"; 5 | import {State} from "./UpdateUsingButtonWithAutofocus"; 6 | 7 | export const DynamicallyChangingOptions = () => { 8 | const [value, setValue] = useState(`Blur away to see initial event behavior`); 9 | 10 | const [options, setOptions] = useState({ 11 | maxHeight: "50px", 12 | }); 13 | 14 | return ( 15 |
16 |

Dynamically changing options. Change max height

17 | 28 | 39 | 40 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "vite.config.ts"], 4 | "compilerOptions": { 5 | "module": "ES2020", 6 | "lib": ["dom", "esnext"], 7 | // output .d.ts declaration files for consumers 8 | "declaration": false, 9 | // output .js.map sourcemap files for consumers 10 | "sourceMap": true, 11 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 12 | "baseUrl": "./src", 13 | // stricter type-checking for stronger correctness. Recommended by TS 14 | "strict": true, 15 | // linter checks for common issues 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | // use Node's module resolution algorithm, instead of the legacy TS one 19 | "moduleResolution": "node", 20 | // transpile JSX to React.createElement 21 | "jsx": "react", 22 | // interop between ESM and CJS modules. Recommended by TS 23 | "esModuleInterop": true, 24 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 25 | "skipLibCheck": true, 26 | // error out if import and file system have a casing mismatch. Recommended by TS 27 | "forceConsistentCasingInFileNames": true, 28 | "noEmit": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/demo/Demo.tsx: -------------------------------------------------------------------------------- 1 | import "easymde/dist/easymde.min.css"; 2 | import { UpdateUsingButtonWithAutofocus } from "./UpdateUsingButtonWithAutofocus"; 3 | import { Autosaving } from "./Autosaving"; 4 | import { UpdateableByHotKeys } from "./UpdateableByHotKeys"; 5 | import { CleanupByButton } from "./CleanupByButton"; 6 | import { CustomPreview } from "./CustomPreview"; 7 | import { CustomEventsAndCursorPosition } from "./CustomEventsAndCursorPosition"; 8 | import { GetInstance } from "./GetInstance"; 9 | import { DynamicallyChangingEvents } from "./DynamicallyChangingEvents"; 10 | import { DynamicallyChangingOptions } from "./DynamicallyChangingOptions"; 11 | import React from "react"; 12 | 13 | const Demo = () => { 14 | return ( 15 |
16 |
17 |

18 | 19 | react-simplemde-editor 20 | 21 |

22 |

23 | A React.js wrapper for{" "} 24 | 25 | easy-markdown-editor 26 | 27 | . 28 |

29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | ); 42 | }; 43 | 44 | export default Demo; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simplemde-editor", 3 | "homepage": "https://react-simplemde-edtior.netlify.app/", 4 | "repository": "https://github.com/RIP21/react-simplemde-editor", 5 | "version": "5.2.0", 6 | "author": "Andrii Los", 7 | "contributors": [ 8 | { 9 | "name": "Ben Lodge", 10 | "url": "https://github.com/benrlodge", 11 | "email": "benrlodge@gmail.com" 12 | } 13 | ], 14 | "license": "MIT", 15 | "main": "dist/SimpleMdeReact.js", 16 | "module": "dist/SimpleMdeReact.mjs", 17 | "typings": "dist/SimpleMdeReact.d.ts", 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "scripts": { 23 | "build:lib": "tsup ./src/SimpleMdeReact.tsx", 24 | "prepare": "pnpm build:lib", 25 | "demo": "vite", 26 | "build:demo": "vite build", 27 | "test": "vitest", 28 | "test:e2e": "playwright test", 29 | "test:e2e:debug": "playwright test --debug", 30 | "tsc": "tsc" 31 | }, 32 | "dependencies": { 33 | "@types/codemirror": "~5.60.5" 34 | }, 35 | "peerDependencies": { 36 | "easymde": ">= 2.0.0 < 3.0.0", 37 | "react": ">=16.8.2", 38 | "react-dom": ">=16.8.2" 39 | }, 40 | "devDependencies": { 41 | "@playwright-testing-library/test": "~4.5.0", 42 | "@playwright/test": "~1.26.1", 43 | "@testing-library/react": "~13.4.0", 44 | "@testing-library/user-event": "~14.4.3", 45 | "@types/codemirror": "~5.60.5", 46 | "@types/node": "~16.11.62", 47 | "@types/react": "~18.0.21", 48 | "@types/react-dom": "~18.0.6", 49 | "easymde": "~2.18.0", 50 | "happy-dom": "~6.0.4", 51 | "jsdom": "~20.0.0", 52 | "prettier": "~2.7.1", 53 | "react": "18.2.0", 54 | "react-dom": "18.2.0", 55 | "tsup": "~6.2.3", 56 | "typescript": "~4.8.4", 57 | "vite": "~3.1.4", 58 | "vitest": "0.23.4", 59 | "vitest-dom": "~0.0.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/demo/UpdateUsingButtonWithAutofocus.tsx: -------------------------------------------------------------------------------- 1 | import SimpleMdeReact, { SimpleMdeToCodemirrorEvents } from "../SimpleMdeReact"; 2 | import { useMemo, useState } from "react"; 3 | import SimpleMDE from "easymde"; 4 | import React from "react"; 5 | 6 | let counter = 1; 7 | export const State = (props: any) => { 8 | return ( 9 |
10 | {JSON.stringify(props, null, 2)} 11 |
12 | ); 13 | }; 14 | 15 | const events = { 16 | focus: () => console.log("focus"), 17 | } as SimpleMdeToCodemirrorEvents; 18 | 19 | export const UpdateUsingButtonWithAutofocus = () => { 20 | const [value, setValue] = useState( 21 | "I am the initial value. Erase me, or try the button above." 22 | ); 23 | 24 | const onChange = (value: string) => { 25 | setValue(value); 26 | }; 27 | 28 | const handleTextChangeByButton = () => { 29 | setValue(`Changing text by setting new state. ${counter++}`); 30 | }; 31 | 32 | const autofocusNoSpellcheckerOptions = useMemo(() => { 33 | return { 34 | autofocus: true, 35 | spellChecker: false, 36 | } as SimpleMDE.Options; 37 | }, []); 38 | 39 | return ( 40 |
41 |

Autofocus spellchecker disabled, button updated, controlled

42 | 48 | 49 |

Update by button

50 | 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/demo/GetInstance.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import SimpleMDE from "easymde"; 3 | import SimpleMdeReact from "../SimpleMdeReact"; 4 | import type { Editor, Position } from "codemirror"; 5 | import React from "react"; 6 | 7 | export const GetInstance = () => { 8 | // simple mde 9 | const [simpleMdeInstance, setMdeInstance] = useState(null); 10 | 11 | const getMdeInstanceCallback = useCallback((simpleMde: SimpleMDE) => { 12 | setMdeInstance(simpleMde); 13 | }, []); 14 | 15 | useEffect(() => { 16 | simpleMdeInstance && 17 | console.info("Hey I'm editor instance!", simpleMdeInstance); 18 | }, [simpleMdeInstance]); 19 | 20 | // codemirror 21 | const [codemirrorInstance, setCodemirrorInstance] = useState( 22 | null 23 | ); 24 | const getCmInstanceCallback = useCallback((editor: Editor) => { 25 | setCodemirrorInstance(editor); 26 | }, []); 27 | 28 | useEffect(() => { 29 | codemirrorInstance && 30 | console.info("Hey I'm codemirror instance!", codemirrorInstance); 31 | }, [codemirrorInstance]); 32 | 33 | // line and cursor 34 | const [lineAndCursor, setLineAndCursor] = useState(null); 35 | 36 | const getLineAndCursorCallback = useCallback((position: Position) => { 37 | setLineAndCursor(position); 38 | }, []); 39 | 40 | useEffect(() => { 41 | lineAndCursor && 42 | console.info("Hey I'm line and cursor info!", lineAndCursor); 43 | }, [lineAndCursor]); 44 | 45 | return ( 46 |
47 |

Getting instance of Mde and codemirror and line and cursor info

48 | 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './tests', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'], 51 | }, 52 | }, 53 | 54 | { 55 | name: 'firefox', 56 | use: { 57 | ...devices['Desktop Firefox'], 58 | }, 59 | }, 60 | 61 | { 62 | name: 'webkit', 63 | use: { 64 | ...devices['Desktop Safari'], 65 | }, 66 | }, 67 | ], 68 | 69 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 70 | // outputDir: 'test-results/', 71 | 72 | /* Run your local dev server before starting the tests */ 73 | webServer: { 74 | command: 'pnpm demo', 75 | port: 3000, 76 | }, 77 | }; 78 | 79 | export default config; 80 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | react-simplemde-editor demo 13 | 14 | 15 | 24 | 33 | 34 | 35 | View the code 36 |
37 |
38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /.pnpmfile.cjs: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | /** 4 | * When using the PNPM package manager, you can use pnpmfile.js to workaround 5 | * dependencies that have mistakes in their package.json file. (This feature is 6 | * functionally similar to Yarn's "resolutions".) 7 | * 8 | * For details, see the PNPM documentation: 9 | * https://pnpm.js.org/docs/en/hooks.html 10 | * 11 | * IMPORTANT: SINCE THIS FILE CONTAINS EXECUTABLE CODE, MODIFYING IT IS LIKELY TO INVALIDATE 12 | * ANY CACHED DEPENDENCY ANALYSIS. After any modification to pnpmfile.js, it's recommended to run 13 | * "rush update --full" so that PNPM will recalculate all version selections. 14 | * Or `pnpm install --fix-lockfile` for non Rush projects 15 | */ 16 | module.exports = { 17 | hooks: { 18 | readPackage, 19 | }, 20 | } 21 | 22 | /** 23 | * This hook is invoked during installation before a package's dependencies 24 | * are selected. 25 | * The `packageJson` parameter is the deserialized package.json 26 | * contents for the package that is about to be installed. 27 | * The `context` parameter provides a log() function. 28 | * The return value is the updated object. 29 | */ 30 | 31 | const TYPES = { 32 | PEER: "peerDependencies", 33 | DEPS: "dependencies", 34 | } 35 | 36 | const prettyType = (type) => (type === TYPES.DEPS ? "dependency" : "peerDependency") 37 | 38 | function readPackage(packageJson, context) { 39 | function removeGlobal(type, name, noLog) { 40 | if (packageJson[type] && packageJson[type][name]) { 41 | !noLog && 42 | context.log(`Removed "${name}" ${prettyType(type)} for ${packageJson.name}`) 43 | delete packageJson[type][name] 44 | } 45 | } 46 | 47 | function changeGlobal(type, name, ver, noLog) { 48 | if (packageJson[type] && packageJson[type][name]) { 49 | const originalVersion = packageJson[type][name] 50 | if (originalVersion !== ver) { 51 | !noLog && 52 | context.log( 53 | `Changed "${name}" ${prettyType( 54 | type, 55 | )} from ${originalVersion} to ${ver} for ${packageJson.name}`, 56 | ) 57 | packageJson[type][name] = ver 58 | } 59 | } 60 | } 61 | 62 | function add(type, forPackage, dep, ver, noLog) { 63 | if (packageJson.name === forPackage) { 64 | if (!packageJson[type]) { 65 | packageJson[type] = {} 66 | } 67 | !noLog && context.log(`Added "${dep}" ${prettyType(type)} for ${packageJson.name}`) 68 | packageJson[type][dep] = ver 69 | } 70 | } 71 | 72 | function remove(type, forPackage, dep, noLog) { 73 | if (packageJson.name === forPackage && !packageJson?.[type]?.[dep]) { 74 | context.log( 75 | `No ${type} "${dep}" in the package ${forPackage} to remove it. You sure about it?`, 76 | ) 77 | } else if (packageJson.name === forPackage) { 78 | !noLog && context.log(`Removed "${dep}" dependency for "${packageJson.name}"`) 79 | delete packageJson[type][dep] 80 | } 81 | } 82 | 83 | function change(type, forPackage, dep, ver, noLog) { 84 | if (packageJson.name === forPackage && packageJson[type]) { 85 | if (!packageJson[type][dep]) { 86 | context.log( 87 | `No such ${type} in the package ${forPackage} to change it. You sure about it?`, 88 | ) 89 | } else if (packageJson.name === forPackage) { 90 | const originalVersion = packageJson[type][dep] 91 | if (originalVersion !== ver) { 92 | !noLog && 93 | context.log( 94 | `Changed "${dep}" ${prettyType( 95 | type, 96 | )} from ${originalVersion} to ${ver} for ${packageJson.name}`, 97 | ) 98 | packageJson[type][dep] = ver 99 | } 100 | } 101 | } 102 | } 103 | 104 | change(TYPES.PEER, "vitest-dom", "vitest", "<1", true) 105 | 106 | return packageJson 107 | } 108 | -------------------------------------------------------------------------------- /src/SimpleMdeReact.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useMemo, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import SimpleMDE, { Options } from "easymde"; 9 | 10 | import type { Editor, EditorEventMap, KeyMap, Position } from "codemirror"; 11 | import { EditorChange } from "codemirror"; 12 | 13 | let _id = 0; 14 | 15 | const generateId = () => `simplemde-editor-${++_id}`; 16 | 17 | export type DOMEvent = 18 | | "mousedown" 19 | | "dblclick" 20 | | "touchstart" 21 | | "contextmenu" 22 | | "keydown" 23 | | "keypress" 24 | | "keyup" 25 | | "cut" 26 | | "copy" 27 | | "paste" 28 | | "dragstart" 29 | | "dragenter" 30 | | "dragover" 31 | | "dragleave" 32 | | "drop"; 33 | 34 | export type CopyEvents = { 35 | [TKey in string & 36 | DOMEvent & 37 | keyof DocumentAndElementEventHandlersEventMap as `${TKey}`]?: ( 38 | instance: Editor, 39 | event: DocumentAndElementEventHandlersEventMap[TKey] 40 | ) => void; 41 | }; 42 | 43 | export type GlobalEvents = { 44 | [TKey in string & 45 | DOMEvent & 46 | keyof GlobalEventHandlersEventMap as `${TKey}`]?: ( 47 | instance: Editor, 48 | event: GlobalEventHandlersEventMap[TKey] 49 | ) => void; 50 | }; 51 | 52 | export type DefaultEvent = (instance: Editor, ...args: any[]) => void; 53 | 54 | export type IndexEventsSignature = { 55 | [key: string]: DefaultEvent | undefined; 56 | }; 57 | 58 | export interface SimpleMdeToCodemirrorEvents 59 | extends CopyEvents, 60 | GlobalEvents, 61 | IndexEventsSignature, 62 | Partial {} 63 | 64 | export type GetMdeInstance = (instance: SimpleMDE) => void; 65 | export type GetCodemirrorInstance = (instance: Editor) => void; 66 | export type GetLineAndCursor = (instance: Position) => void; 67 | 68 | export interface SimpleMDEReactProps 69 | extends Omit, "onChange"> { 70 | id?: string; 71 | onChange?: (value: string, changeObject?: EditorChange) => void; 72 | value?: string; 73 | extraKeys?: KeyMap; 74 | options?: SimpleMDE.Options; 75 | events?: SimpleMdeToCodemirrorEvents; 76 | getMdeInstance?: GetMdeInstance; 77 | getCodemirrorInstance?: GetCodemirrorInstance; 78 | getLineAndCursor?: GetLineAndCursor; 79 | placeholder?: string; 80 | textareaProps?: Omit< 81 | React.HTMLAttributes, 82 | "id" | "style" | "placeholder" 83 | >; 84 | } 85 | 86 | const useHandleEditorInstanceLifecycle = ({ 87 | options, 88 | id, 89 | currentValueRef, 90 | textRef, 91 | }: { 92 | options?: Options; 93 | id: string; 94 | currentValueRef: React.MutableRefObject; 95 | textRef: HTMLTextAreaElement | null; 96 | }) => { 97 | const [editor, setEditor] = useState(null); 98 | 99 | const imageUploadCallback = useCallback( 100 | ( 101 | file: File, 102 | onSuccess: (url: string) => void, 103 | onError: (error: string) => void 104 | ) => { 105 | const imageUpload = options?.imageUploadFunction; 106 | if (imageUpload) { 107 | const _onSuccess = (url: string) => { 108 | onSuccess(url); 109 | }; 110 | imageUpload(file, _onSuccess, onError); 111 | } 112 | }, 113 | [options?.imageUploadFunction] 114 | ); 115 | 116 | const editorRef = useRef(editor); 117 | editorRef.current = editor; 118 | 119 | useEffect(() => { 120 | let editor: SimpleMDE; 121 | if (textRef) { 122 | const initialOptions = { 123 | element: textRef, 124 | initialValue: currentValueRef.current, 125 | }; 126 | const imageUploadFunction = options?.imageUploadFunction 127 | ? imageUploadCallback 128 | : undefined; 129 | editor = new SimpleMDE( 130 | Object.assign({}, initialOptions, options, { 131 | imageUploadFunction, 132 | }) 133 | ); 134 | setEditor(editor); 135 | } 136 | return () => { 137 | editor?.toTextArea(); 138 | editor?.cleanup(); 139 | }; 140 | }, [textRef, currentValueRef, id, imageUploadCallback, options]); 141 | 142 | const codemirror = useMemo(() => { 143 | return editor?.codemirror; 144 | }, [editor?.codemirror]) as Editor | undefined; 145 | return { editor, codemirror }; 146 | }; 147 | 148 | export const SimpleMdeReact = React.forwardRef< 149 | HTMLDivElement, 150 | SimpleMDEReactProps 151 | >((props, ref) => { 152 | const { 153 | events, 154 | value, 155 | options, 156 | children, 157 | extraKeys, 158 | getLineAndCursor, 159 | getMdeInstance, 160 | getCodemirrorInstance, 161 | onChange, 162 | id: anId, 163 | placeholder, 164 | textareaProps, 165 | ...rest 166 | } = props; 167 | 168 | const id = useMemo(() => anId ?? generateId(), [anId]); 169 | 170 | const elementWrapperRef = useRef(null); 171 | const nonEventChangeRef = useRef(true); 172 | 173 | // This is to not pass value as a dependency e.g. to keep event handlers referentially 174 | // stable and do not `off` and `on` on each value change 175 | // plus to avoid unnecessary EasyEde editor recreation on each value change while still, if it has to be remounted 176 | // due to options and other deps change, to preserve that last value and not the default one from the first render. 177 | const currentValueRef = useRef(value); 178 | currentValueRef.current = value; 179 | 180 | const [textRef, setTextRef] = useState(null); 181 | const { editor, codemirror } = useHandleEditorInstanceLifecycle({ 182 | options, 183 | id, 184 | currentValueRef, 185 | textRef, 186 | }); 187 | 188 | useEffect(() => { 189 | // If change comes from the event we don't need to update `SimpleMDE` value as it already has it 190 | // Otherwise we shall set it as it comes from `props` set from the outside. E.g. by some reset button and whatnot 191 | if (nonEventChangeRef.current) { 192 | editor?.value(value ?? ""); 193 | } 194 | nonEventChangeRef.current = true; 195 | }, [editor, value]); // _: Editor | Event <===== is to please TS :) 196 | const onCodemirrorChangeHandler = useCallback( 197 | (_: Editor | Event, changeObject?: EditorChange) => { 198 | if (editor?.value() !== currentValueRef.current) { 199 | nonEventChangeRef.current = false; 200 | onChange?.(editor?.value() ?? "", changeObject); 201 | } 202 | }, 203 | [editor, onChange] 204 | ); 205 | 206 | useEffect(() => { 207 | // For some reason it doesn't work out of the box, this makes sure it's working correctly 208 | if (options?.autofocus) { 209 | codemirror?.focus(); 210 | codemirror?.setCursor(codemirror?.lineCount(), 0); 211 | } 212 | }, [codemirror, options?.autofocus]); 213 | 214 | const getCursorCallback = useCallback(() => { 215 | // https://codemirror.net/doc/manual.html#api_selection 216 | codemirror && getLineAndCursor?.(codemirror.getDoc().getCursor()); 217 | }, [codemirror, getLineAndCursor]); 218 | 219 | useEffect(() => { 220 | getCursorCallback(); 221 | }, [getCursorCallback]); 222 | 223 | useEffect(() => { 224 | editor && getMdeInstance?.(editor); 225 | }, [editor, getMdeInstance]); 226 | 227 | useEffect(() => { 228 | codemirror && getCodemirrorInstance?.(codemirror); 229 | }, [codemirror, getCodemirrorInstance, getMdeInstance]); 230 | 231 | useEffect(() => { 232 | // https://codemirror.net/doc/manual.html#option_extraKeys 233 | if (extraKeys && codemirror) { 234 | codemirror.setOption( 235 | "extraKeys", 236 | Object.assign({}, codemirror.getOption("extraKeys"), extraKeys) 237 | ); 238 | } 239 | }, [codemirror, extraKeys]); 240 | 241 | useEffect(() => { 242 | const toolbarNode = 243 | elementWrapperRef.current?.getElementsByClassName( 244 | "editor-toolbarNode" 245 | )[0]; 246 | const handler = codemirror && onCodemirrorChangeHandler; 247 | if (handler) { 248 | toolbarNode?.addEventListener("click", handler); 249 | return () => { 250 | toolbarNode?.removeEventListener("click", handler); 251 | }; 252 | } 253 | return () => {}; 254 | }, [codemirror, onCodemirrorChangeHandler]); 255 | 256 | useEffect(() => { 257 | codemirror?.on("change", onCodemirrorChangeHandler); 258 | codemirror?.on("cursorActivity", getCursorCallback); 259 | return () => { 260 | codemirror?.off("change", onCodemirrorChangeHandler); 261 | codemirror?.off("cursorActivity", getCursorCallback); 262 | }; 263 | }, [codemirror, getCursorCallback, onCodemirrorChangeHandler]); 264 | 265 | const prevEvents = useRef(events); 266 | 267 | useEffect(() => { 268 | const isNotFirstEffectRun = events !== prevEvents.current; 269 | isNotFirstEffectRun && 270 | prevEvents.current && 271 | Object.entries(prevEvents.current).forEach(([event, handler]) => { 272 | handler && codemirror?.off(event as keyof EditorEventMap, handler); 273 | }); 274 | 275 | events && 276 | Object.entries(events).forEach(([event, handler]) => { 277 | handler && codemirror?.on(event as keyof EditorEventMap, handler); 278 | }); 279 | prevEvents.current = events; 280 | return () => { 281 | events && 282 | Object.entries(events).forEach(([event, handler]) => { 283 | handler && codemirror?.off(event as keyof EditorEventMap, handler); 284 | }); 285 | }; 286 | }, [codemirror, events]); 287 | 288 | return ( 289 |
{ 293 | if (typeof ref === "function") { 294 | ref(aRef); 295 | } else if (ref) { 296 | ref.current = aRef; 297 | } 298 | elementWrapperRef.current = aRef; 299 | }} 300 | > 301 |