├── .prettierignore ├── example ├── chalk.js ├── empty-plugin.ts ├── index.tsx └── editor.css ├── json-diff.worker.js ├── docs └── assets │ ├── logo.png │ ├── schema-tab.png │ ├── state-tab.png │ ├── history-tab.png │ ├── plugins-tab.png │ ├── snapshots-tab.png │ └── structure-tab.png ├── .npmignore ├── src ├── types │ ├── window.ts │ └── prosemirror.ts ├── state │ ├── expand-path.ts │ ├── get-editor-state.ts │ ├── editor-view.ts │ ├── editor-state.ts │ ├── global.ts │ ├── schema.ts │ ├── idle-scheduler.ts │ ├── json-diff-main.ts │ ├── active-marks.ts │ ├── snapshots.ts │ ├── json-diff-worker.ts │ ├── node-colors.ts │ ├── node-picker.ts │ └── history.ts ├── components │ ├── node-picker │ │ ├── picker-icon.png │ │ └── index.tsx │ ├── css-reset.tsx │ ├── info-panel.tsx │ ├── button.tsx │ ├── highlighter.tsx │ ├── json-tree.tsx │ ├── split-view.tsx │ ├── heading.tsx │ ├── search-bar.tsx │ ├── save-snapshot-button.tsx │ ├── tabs.tsx │ ├── json-diff.tsx │ └── list.tsx ├── hooks │ ├── use-resize-document.ts │ ├── use-subscribe-to-editor-view.ts │ └── use-rollback-history.ts ├── utils │ ├── log-node-from-json.ts │ ├── subscribe-on-updates.ts │ ├── format-selection-object.ts │ └── find-node.ts ├── json-diff.worker.ts ├── index.tsx ├── dev-tools.tsx ├── theme.ts ├── tabs │ ├── schema.tsx │ ├── plugins.tsx │ ├── snapshots.tsx │ ├── history.tsx │ ├── state.tsx │ └── structure.tsx ├── dev-tools-collapsed.tsx └── dev-tools-expanded.tsx ├── .editorconfig ├── .gitignore ├── index.html ├── tests ├── open-close.spec.ts ├── tabs.spec.ts ├── state-tab.spec.ts ├── snapshots.spec.ts ├── history-tab.spec.ts └── page-object.ts ├── vite.config.ts ├── CONTRIBUTING.md ├── .eslintrc.js ├── .babelrc ├── .github └── workflows │ ├── playwright.yml │ └── ci.yml ├── playwright.config.ts ├── package.json ├── README.md └── tsconfig.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /example/chalk.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /json-diff.worker.js: -------------------------------------------------------------------------------- 1 | require("./dist/cjs/json-diff.worker.js"); 2 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/logo.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /example/ 2 | 3 | /.babelrc 4 | /.editorconfig 5 | /.gitignore 6 | /.travis.yml 7 | /.build 8 | /docs 9 | -------------------------------------------------------------------------------- /docs/assets/schema-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/schema-tab.png -------------------------------------------------------------------------------- /docs/assets/state-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/state-tab.png -------------------------------------------------------------------------------- /docs/assets/history-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/history-tab.png -------------------------------------------------------------------------------- /docs/assets/plugins-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/plugins-tab.png -------------------------------------------------------------------------------- /src/types/window.ts: -------------------------------------------------------------------------------- 1 | export type ExtendedWindow = Window & { 2 | cancelIdleCallack: (handler: unknown) => void; 3 | }; 4 | -------------------------------------------------------------------------------- /docs/assets/snapshots-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/snapshots-tab.png -------------------------------------------------------------------------------- /docs/assets/structure-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/docs/assets/structure-tab.png -------------------------------------------------------------------------------- /src/state/expand-path.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const expandPathAtom = atom>([]); 4 | -------------------------------------------------------------------------------- /src/components/node-picker/picker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rkr00t/prosemirror-dev-tools/HEAD/src/components/node-picker/picker-icon.png -------------------------------------------------------------------------------- /src/state/get-editor-state.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | 3 | export default function getEditorStateClass() { 4 | return EditorState; 5 | } 6 | -------------------------------------------------------------------------------- /src/state/editor-view.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { EditorView } from "prosemirror-view"; 3 | 4 | export const editorViewAtom = atom(null); 5 | -------------------------------------------------------------------------------- /src/state/editor-state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { EditorState } from "prosemirror-state"; 3 | 4 | export const editorStateAtom = atom(null); 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/state/global.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const devToolsOpenedAtom = atom(false); 4 | export const devToolsSizeAtom = atom(0.5); 5 | export const devToolTabIndexAtom = atom("state"); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | out 4 | .cache 5 | .parcel-cache 6 | yarn.lock 7 | pnpm-lock.yaml 8 | package-lock.json 9 | /test-results/ 10 | /playwright-report/ 11 | /playwright/.cache/ 12 | .npmrc 13 | -------------------------------------------------------------------------------- /src/state/schema.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { editorStateAtom } from "./editor-state"; 3 | 4 | export const schemaAtom = atom((get) => { 5 | const editorState = get(editorStateAtom); 6 | if (!editorState) return null; 7 | return editorState.schema; 8 | }); 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/css-reset.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@compiled/react"; 3 | 4 | const cssResetStyles = css({ 5 | fontSize: "100%", 6 | lineHeight: 1, 7 | 8 | "& li + li": { 9 | margin: 0, 10 | }, 11 | }); 12 | 13 | const CSSReset: React.FC = ({ children }) => { 14 | return
{children}
; 15 | }; 16 | 17 | export default CSSReset; 18 | -------------------------------------------------------------------------------- /src/types/prosemirror.ts: -------------------------------------------------------------------------------- 1 | import type { Fragment, Node } from "prosemirror-model"; 2 | 3 | export type JSONNode = { 4 | type: string; 5 | attrs?: Record; 6 | content?: Array; 7 | text?: string; 8 | }; 9 | 10 | export type JSONMark = { 11 | type: string; 12 | attrs?: Record; 13 | }; 14 | 15 | export type ExtendedFragment = Fragment & { 16 | content?: Array; 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/use-resize-document.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function useResizeDocument(isOpen: boolean, defaultSize: number) { 4 | React.useEffect(() => { 5 | if (!isOpen) { 6 | document.querySelector("html")!.style.marginBottom = ""; 7 | } else { 8 | const size = defaultSize * window.innerHeight; 9 | document.querySelector("html")!.style.marginBottom = `${size}px`; 10 | } 11 | }, [defaultSize, isOpen]); 12 | } 13 | -------------------------------------------------------------------------------- /tests/open-close.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { PlaywrightDevPage } from "./page-object"; 3 | 4 | test("open and close dev tools", async ({ page }) => { 5 | const po = new PlaywrightDevPage(page); 6 | await po.goto(); 7 | 8 | await po.collapsedButton.click(); 9 | await expect(po.devToolsContainer).toBeVisible(); 10 | 11 | await po.closeButton.click(); 12 | await expect(po.devToolsContainer).not.toBeVisible(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/info-panel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | const infoPanelStyles = css({ 6 | position: "relative", 7 | top: "50%", 8 | transform: "translateY(-50%)", 9 | textAlign: "center", 10 | color: theme.main, 11 | fontSize: "14px", 12 | }); 13 | const InfoPanel: React.FC = ({ children }) => ( 14 |
{children}
15 | ); 16 | 17 | export { InfoPanel }; 18 | -------------------------------------------------------------------------------- /src/state/idle-scheduler.ts: -------------------------------------------------------------------------------- 1 | import type { ExtendedWindow } from "../types/window"; 2 | 3 | export class IdleScheduler { 4 | task = undefined; 5 | 6 | request() { 7 | this.cancel(); 8 | const request = window.requestIdleCallback || window.requestAnimationFrame; 9 | return new Promise((resolve) => request(resolve)); 10 | } 11 | 12 | cancel() { 13 | const cancel = 14 | (window as unknown as ExtendedWindow).cancelIdleCallack || 15 | window.cancelAnimationFrame; 16 | if (this.task) { 17 | cancel(this.task); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | optimizeDeps: { 8 | exclude: ["@compiled/react/jsx-dev-runtime"], 9 | }, 10 | plugins: [ 11 | react({ 12 | jsxRuntime: "classic", 13 | babel: { 14 | plugins: ["@compiled/babel-plugin"], 15 | }, 16 | }), 17 | ], 18 | resolve: { 19 | alias: { 20 | chalk: path.join(__dirname, "example", "chalk.js"), 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/state/json-diff-main.ts: -------------------------------------------------------------------------------- 1 | import { DiffPatcher } from "jsondiffpatch"; 2 | import { IdleScheduler } from "./idle-scheduler"; 3 | 4 | export class JsonDiffMain { 5 | diffPatcher = new DiffPatcher({ 6 | arrays: { detectMove: false, includeValueOnMove: false }, 7 | textDiff: { minLength: 1 }, 8 | }); 9 | 10 | scheduler = new IdleScheduler(); 11 | 12 | async diff(input: { id: string; a: unknown; b: unknown }) { 13 | await this.scheduler.request(); 14 | 15 | return { 16 | id: input.id, 17 | delta: this.diffPatcher.diff(input.a, input.b), 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/empty-plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from "prosemirror-state"; 2 | 3 | function createPluginState() { 4 | return { 5 | doSomething() {}, 6 | apple: "apple", 7 | cherry: "cherry", 8 | dog: "dog", 9 | elephant: "elephant", 10 | frog: "frog", 11 | banana: "banana", 12 | }; 13 | } 14 | 15 | const key = new PluginKey("empty-plugin"); 16 | const plugin = new Plugin({ 17 | key, 18 | state: { 19 | init() { 20 | return createPluginState(); 21 | }, 22 | apply(tr, pluginState) { 23 | return pluginState; 24 | }, 25 | }, 26 | }); 27 | 28 | export default plugin; 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | ## Project setup 6 | 7 | 1. Fork and clone the repo 8 | 2. `$ npm install` to install dependencies 9 | 3. `$ npm start` to start dev server 10 | 4. Create a branch for your PR 11 | 12 | This project uses [commitizen](http://commitizen.github.io/cz-cli/) – please read about it here. 13 | 14 | ## Committing and Pushing changes 15 | 16 | Once you are ready to commit the changes, please use the below commands 17 | 18 | 1. `git add ` 19 | 2. `$ npm run commit` 20 | 21 | ... and follow the instruction of the interactive prompt. 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | overrides: [], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | sourceType: "module", 17 | }, 18 | plugins: ["react", "@typescript-eslint"], 19 | rules: { 20 | "react/prop-types": "off", 21 | "react/no-unknown-property": ["error", { ignore: ["css"] }], 22 | "@typescript-eslint/no-explicit-any": "off", 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/log-node-from-json.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState } from "prosemirror-state"; 2 | import { findNodeJSON } from "./find-node"; 3 | import { JSONNode } from "../types/prosemirror"; 4 | 5 | export const logNodeFromJSON = 6 | (state: EditorState) => 7 | ({ doc, node }: { doc: JSONNode; node: JSONNode }) => { 8 | const fullDoc = state.doc; 9 | const path = findNodeJSON([], doc, node); 10 | if (path) { 11 | console.log( 12 | path.reduce( 13 | (node, pathItem) => (node as any)[pathItem], 14 | fullDoc.toJSON() as JSONNode 15 | ) 16 | ); 17 | } else { 18 | console.log(node); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import applyDevTools from "../src"; 2 | import "./editor.css"; 3 | 4 | import { EditorState } from "prosemirror-state"; 5 | import { EditorView } from "prosemirror-view"; 6 | import { schema } from "prosemirror-schema-basic"; 7 | import { exampleSetup } from "prosemirror-example-setup"; 8 | 9 | import plugin from "./empty-plugin"; 10 | 11 | const plugins = exampleSetup({ schema }); 12 | plugins.push(plugin); 13 | 14 | const view = new EditorView(document.querySelector("#app"), { 15 | state: EditorState.create({ schema, plugins }), 16 | }); 17 | 18 | const worker = new Worker( 19 | new URL("../src/json-diff.worker.ts", import.meta.url), 20 | { 21 | type: "module", 22 | } 23 | ); 24 | 25 | applyDevTools(view, { diffWorker: worker }); 26 | -------------------------------------------------------------------------------- /src/json-diff.worker.ts: -------------------------------------------------------------------------------- 1 | import { DiffPatcher } from "jsondiffpatch"; 2 | 3 | const diffPatcher = new DiffPatcher({ 4 | arrays: { detectMove: false, includeValueOnMove: false }, 5 | textDiff: { minLength: 1 }, 6 | }); 7 | 8 | self.addEventListener("message", (e) => { 9 | if (!e.data.id || !e.data.method || !e.data.args) { 10 | return; 11 | } 12 | 13 | switch (e.data.method) { 14 | case "diff": { 15 | const [{ a, b, id }] = e.data.args; 16 | 17 | self.postMessage({ 18 | id: e.data.id, 19 | returns: { 20 | id, 21 | delta: diffPatcher.diff(a, b), 22 | }, 23 | }); 24 | break; 25 | } 26 | default: 27 | throw new Error("unknown method: " + e.data.method); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, MouseEventHandler } from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | type ButtonProps = { 6 | onClick: MouseEventHandler; 7 | children: ReactNode; 8 | }; 9 | 10 | const buttonStyles = css({ 11 | color: theme.softerMain, 12 | marginTop: "4px", 13 | marginBottom: "4px", 14 | fontWeight: 400, 15 | fontSize: "12px", 16 | background: "transparent", 17 | border: "none", 18 | "&:hover": { 19 | background: theme.white10, 20 | }, 21 | }); 22 | 23 | const Button = ({ onClick, children }: ButtonProps) => { 24 | return ( 25 | 28 | ); 29 | }; 30 | 31 | export default Button; 32 | -------------------------------------------------------------------------------- /tests/tabs.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { PlaywrightDevPage } from "./page-object"; 3 | 4 | test("switch tabs", async ({ page }) => { 5 | const po = new PlaywrightDevPage(page); 6 | await po.goto(); 7 | await po.collapsedButton.click(); 8 | await expect(po.devToolsContainer).toBeVisible(); 9 | 10 | await po.tabHistoryButton.click(); 11 | await expect(po.locateTab("history")).toBeVisible(); 12 | 13 | await po.tabPluginsButton.click(); 14 | await expect(po.locateTab("plugins")).toBeVisible(); 15 | 16 | await po.tabSchemaButton.click(); 17 | await expect(po.locateTab("schema")).toBeVisible(); 18 | 19 | await po.tabSnapshotsButton.click(); 20 | await expect(po.locateTab("snapshots")).toBeVisible(); 21 | 22 | await po.tabStructureButton.click(); 23 | await expect(po.locateTab("structure")).toBeVisible(); 24 | 25 | await po.tabStateButton.click(); 26 | await expect(po.locateTab("state")).toBeVisible(); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/state-tab.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { PlaywrightDevPage } from "./page-object"; 3 | 4 | test("log nodes from the current doc tree", async ({ page }) => { 5 | const po = new PlaywrightDevPage(page); 6 | await po.goto(); 7 | await po.collapsedButton.click(); 8 | await expect(po.devToolsContainer).toBeVisible(); 9 | 10 | await po.tabStateButton.click(); 11 | await expect(po.locateTab("state")).toBeVisible(); 12 | 13 | await po.proseMirror.type("Hello"); 14 | await po.proseMirror.press("Enter"); 15 | await po.proseMirror.type("World"); 16 | await expect(po.proseMirror).toHaveText("HelloWorld"); 17 | 18 | await po.page.locator("label").getByText("content").click(); 19 | 20 | const messages: Array = []; 21 | page.on("pageerror", (error) => { 22 | messages.push(`[${error.name}] ${error.message}`); 23 | }); 24 | 25 | await po.stateLogNodeButtons.nth(2).click(); 26 | expect(messages).toStrictEqual([]); 27 | }); 28 | -------------------------------------------------------------------------------- /src/state/active-marks.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { Mark } from "prosemirror-model"; 3 | import type { EditorState } from "prosemirror-state"; 4 | import { editorStateAtom } from "./editor-state"; 5 | 6 | export const activeMarksAtom = atom((get) => { 7 | const editorState = get(editorStateAtom); 8 | if (!editorState) return []; 9 | return getActiveMarks(editorState); 10 | }); 11 | 12 | function getActiveMarks(editorState: EditorState): Array { 13 | const selection = editorState.selection; 14 | let marks: readonly Mark[] = []; 15 | 16 | if (selection.empty) { 17 | marks = selection.$from.marks(); 18 | } else { 19 | editorState.doc.nodesBetween(selection.from, selection.to, (node) => { 20 | marks = marks.concat(node.marks); 21 | }); 22 | } 23 | 24 | return marks 25 | .reduce>((acc, mark) => { 26 | if (acc.indexOf(mark) === -1) { 27 | acc.push(mark); 28 | } 29 | return acc; 30 | }, []) 31 | .map((m) => m.toJSON()); 32 | } 33 | -------------------------------------------------------------------------------- /src/state/snapshots.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage, useReducerAtom } from "jotai/utils"; 2 | 3 | const SNAPSHOTS_KEY = "prosemirror-dev-tools-snapshots"; 4 | const snapshotsAtom = atomWithStorage>(SNAPSHOTS_KEY, []); 5 | 6 | type SnapshotReducerAction = 7 | | { type: "save"; payload: { snapshot: Snapshot } } 8 | | { type: "delete"; payload: { snapshot: Snapshot } }; 9 | 10 | export type Snapshot = { 11 | name: string; 12 | timestamp: number; 13 | snapshot: any; 14 | }; 15 | 16 | function snapshotReducer(prev: Array, action: SnapshotReducerAction) { 17 | if (action.type === "save") { 18 | const snapshots = [action.payload.snapshot].concat(prev); 19 | return snapshots; 20 | } else if (action.type === "delete") { 21 | return prev.filter((item) => item !== action.payload.snapshot); 22 | } 23 | return prev; 24 | } 25 | 26 | export function useSnapshots() { 27 | return useReducerAtom, SnapshotReducerAction>( 28 | snapshotsAtom, 29 | snapshotReducer 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/highlighter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | const customPreStyles = css({ 6 | padding: "9px 0 18px 0 !important", 7 | margin: 0, 8 | color: theme.white80, 9 | "& .prosemirror-dev-tools-highlighter-tag": { 10 | color: theme.main, 11 | }, 12 | }); 13 | const CustomPre: React.FC<{ 14 | __html: string; 15 | children?: React.ReactNode; 16 | }> = ({ children, __html }) => ( 17 |
18 |     {children}
19 |   
20 | ); 21 | 22 | const regexp = /(<\/?[\w\d\s="']+>)/gim; 23 | const highlight = (str: string) => 24 | str 25 | .replace(//g, ">") 27 | .replace( 28 | regexp, 29 | "$&" 30 | ); 31 | 32 | type HighlighterFC = React.FC<{ children: string }>; 33 | export const Highlighter: HighlighterFC = (props) => { 34 | if (!props.children) return null; 35 | return ; 36 | }; 37 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { EditorView } from "prosemirror-view"; 3 | import DevTools from "./dev-tools"; 4 | import { createRoot, Root } from "react-dom/client"; 5 | 6 | const DEVTOOLS_CLASS_NAME = "__prosemirror-dev-tools__"; 7 | let root: Root; 8 | 9 | function createPlace() { 10 | let place = document.querySelector(`.${DEVTOOLS_CLASS_NAME}`); 11 | 12 | if (!place) { 13 | place = document.createElement("div"); 14 | place.className = DEVTOOLS_CLASS_NAME; 15 | document.body.appendChild(place); 16 | } else { 17 | // eslint-disable-next-line react/no-deprecated 18 | root.unmount(); 19 | place.innerHTML = ""; 20 | } 21 | 22 | return place; 23 | } 24 | 25 | type DevToolsProps = { diffWorker?: Worker }; 26 | function applyDevTools(editorView: EditorView, props: DevToolsProps) { 27 | const place = createPlace(); 28 | root = createRoot(place); 29 | root.render( 30 | 31 | ); 32 | 33 | return () => { 34 | root.unmount(); 35 | }; 36 | } 37 | 38 | export default applyDevTools; 39 | export { applyDevTools }; 40 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "modules": false }], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | [ 10 | "@babel/plugin-transform-runtime", 11 | { "helpers": false, "regenerator": true } 12 | ], 13 | "@compiled/babel-plugin" 14 | ], 15 | "env": { 16 | "cjs": { 17 | "presets": ["@babel/preset-env", "@babel/preset-react"], 18 | "plugins": [ 19 | "@babel/plugin-proposal-class-properties", 20 | [ 21 | "@babel/plugin-transform-runtime", 22 | { "helpers": false, "regenerator": true } 23 | ], 24 | "@compiled/babel-plugin" 25 | ] 26 | }, 27 | "esm": { 28 | "presets": [ 29 | ["@babel/preset-env", { "modules": false }], 30 | "@babel/preset-react" 31 | ], 32 | "plugins": [ 33 | "@babel/plugin-proposal-class-properties", 34 | [ 35 | "@babel/plugin-transform-runtime", 36 | { "helpers": false, "regenerator": true } 37 | ], 38 | "@compiled/babel-plugin" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/subscribe-on-updates.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState, Transaction } from "prosemirror-state"; 2 | import type { EditorView } from "prosemirror-view"; 3 | 4 | type subsctibeCallback = ( 5 | tr: Transaction, 6 | oldState: EditorState, 7 | newState: EditorState 8 | ) => void; 9 | export default function subscribeOnUpdates( 10 | editorView: EditorView, 11 | callback: subsctibeCallback 12 | ) { 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | // @ts-expect-error 15 | const maybeDispatchTransaction = editorView._props.dispatchTransaction; 16 | const dispatch = (maybeDispatchTransaction || editorView.dispatch).bind( 17 | editorView 18 | ); 19 | 20 | const handler = function (tr: Transaction) { 21 | const oldState = editorView.state; 22 | dispatch(tr); 23 | callback(tr, oldState, editorView.state); 24 | }; 25 | 26 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 27 | // @ts-expect-error 28 | if (editorView._props.dispatchTransaction) { 29 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 30 | // @ts-expect-error 31 | editorView._props.dispatchTransaction = handler; 32 | } else { 33 | editorView.dispatch = handler; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/state/json-diff-worker.ts: -------------------------------------------------------------------------------- 1 | import type { Delta } from "jsondiffpatch"; 2 | import { nanoid } from "nanoid"; 3 | import { IdleScheduler } from "./idle-scheduler"; 4 | 5 | export class JsonDiffWorker { 6 | queue = new Map(); 7 | scheduler = new IdleScheduler(); 8 | worker: Worker; 9 | 10 | constructor(worker: Worker) { 11 | this.worker = worker; 12 | 13 | this.worker.addEventListener("message", (e) => { 14 | const deferred = this.queue.get(e.data.id); 15 | if (deferred) { 16 | this.queue.delete(e.data.id); 17 | deferred.resolve(e.data.returns); 18 | } 19 | }); 20 | } 21 | 22 | async diff(input: unknown): Promise<{ id: string; delta?: Delta }> { 23 | await this.scheduler.request(); 24 | 25 | const id = nanoid(); 26 | const deferred = createDeferrable(); 27 | this.queue.set(id, deferred); 28 | 29 | this.worker.postMessage({ 30 | method: "diff", 31 | id, 32 | args: [input], 33 | }); 34 | 35 | return deferred as any; 36 | } 37 | } 38 | 39 | function createDeferrable() { 40 | let r: (...args: any) => void; 41 | 42 | const p = new Promise((resolve) => { 43 | r = resolve; 44 | }); 45 | 46 | (p as any).resolve = (...args: any) => r(...args); 47 | return p; 48 | } 49 | -------------------------------------------------------------------------------- /src/dev-tools.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAtom, useAtomValue } from "jotai"; 3 | import type { EditorView } from "prosemirror-view"; 4 | import { devToolsOpenedAtom, devToolsSizeAtom } from "./state/global"; 5 | import DevToolsCollapsed from "./dev-tools-collapsed"; 6 | import DevToolsExpanded from "./dev-tools-expanded"; 7 | import { useResizeDocument } from "./hooks/use-resize-document"; 8 | import { useSubscribeToEditorView } from "./hooks/use-subscribe-to-editor-view"; 9 | import { useRollbackHistory } from "./hooks/use-rollback-history"; 10 | 11 | export default function DevTools(props: DevToolsProps) { 12 | const [isOpen, setIsOpen] = useAtom(devToolsOpenedAtom); 13 | const defaultSize = useAtomValue(devToolsSizeAtom); 14 | const editorView = props.editorView; 15 | const toggleOpen = React.useCallback(() => setIsOpen(!isOpen), [isOpen]); 16 | 17 | useResizeDocument(isOpen, defaultSize); 18 | useSubscribeToEditorView(editorView, props.diffWorker); 19 | 20 | const rollbackHistory = useRollbackHistory(editorView); 21 | 22 | if (isOpen) { 23 | return ; 24 | } 25 | 26 | return ; 27 | } 28 | 29 | type DevToolsProps = { 30 | editorView: EditorView; 31 | diffWorker?: Worker; 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/json-tree.tsx: -------------------------------------------------------------------------------- 1 | import type { Mark, MarkType, NodeType } from "prosemirror-model"; 2 | import React from "react"; 3 | import { JSONTree } from "react-json-tree"; 4 | import { jsonTreeTheme } from "../theme"; 5 | import { JSONNode } from "../types/prosemirror"; 6 | 7 | type GetItemString = ( 8 | nodeType: string, 9 | data: any, 10 | itemType: React.ReactNode, 11 | itemString: string, 12 | keyPath: (string | number)[] 13 | ) => React.ReactNode | string; 14 | type JSONTreeProps = { 15 | data: 16 | | JSONNode 17 | | Record 18 | | Record 19 | | Array; 20 | hideRoot?: boolean; 21 | getItemString?: GetItemString; 22 | shouldExpandNode?: (nodePath: Array) => boolean; 23 | postprocessValue?: (data: Record) => Record; 24 | valueRenderer?: (value: any) => string | React.ReactNode; 25 | labelRenderer?: ([label]: (string | number)[]) => React.ReactNode; 26 | isCustomNode?: (node: any) => boolean; 27 | sortObjectKeys?: boolean | ((...args: any[]) => any); 28 | }; 29 | export default function JSONTreeWrapper(props: JSONTreeProps) { 30 | return ( 31 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | const jsonTreeTheme = { 2 | scheme: "monokai", 3 | base00: "#1E1E2E", 4 | base01: "#45475A", 5 | base02: "#313244", 6 | base03: "#B4BEFE", 7 | base04: "#F2CDCD", 8 | base05: "#CDD6F4", 9 | base06: "#CDD6F4", 10 | base07: "#CDD6F4", 11 | base08: "#F38BA8", 12 | base09: "#FAB387", 13 | base0A: "#F9E2AF", 14 | base0B: "#A6E3A1", 15 | base0C: "#94E2D5", 16 | base0D: "#89DCEB", 17 | base0E: "#CBA6F7", 18 | base0F: "#FAB387", 19 | }; 20 | 21 | const mainTheme = { 22 | main: "#CBA6F7", 23 | main20: "rgba(203, 166, 247, .2)", 24 | main40: "rgba(203, 166, 247, .4)", 25 | main60: "rgba(203, 166, 247, .6)", 26 | main80: "rgba(203, 166, 247, .8)", 27 | main90: "rgba(203, 166, 247, .9)", 28 | mainBg: "#1E1E2E", 29 | softerMain: "#B4BEFE", 30 | 31 | white: "#fff", 32 | 33 | text: "#CDD6F4", 34 | white05: "rgba(205, 214, 244, .05)", 35 | white10: "rgba(205, 214, 244, .1)", 36 | white20: "rgba(205, 214, 244, .2)", 37 | white60: "rgba(205, 214, 244, .6)", 38 | white80: "rgba(205, 214, 244, .8)", 39 | 40 | black30: "#11111B", 41 | 42 | // For diffs and structure 43 | lightYellow: "rgba(205, 214, 244, .2)", 44 | lightPink: "#F38BA8", 45 | darkGreen: "#A6E3A1", 46 | 47 | syntax: jsonTreeTheme, 48 | }; 49 | 50 | export default mainTheme; 51 | export { jsonTreeTheme }; 52 | -------------------------------------------------------------------------------- /src/state/node-colors.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { Schema } from "prosemirror-model"; 3 | import { editorStateAtom } from "./editor-state"; 4 | 5 | export const nodeColorsAtom = atom((get) => { 6 | const editorState = get(editorStateAtom); 7 | if (!editorState) return {}; 8 | return buildColors(editorState.schema); 9 | }); 10 | 11 | const nodesColors = [ 12 | "#F38BA8", // red 13 | "#74C7EC", // cyan 400 14 | "#A6E3A1", // green 15 | "#CA9EDB", // deep purple 16 | "#DCDC5D", // lime 17 | "#B9CC7C", // light green 18 | "#FAB387", // orange 19 | "#89B4FA", // light blue 20 | "#F36E98", // pink 21 | "#E45F44", // deep orange 22 | "#DD97D8", // purple 23 | "#A6A4AE", // blue grey 24 | "#F9E2AF", // yellow 25 | "#FFC129", // amber 26 | "#EBA0AC", // can can 27 | "#89DCEB", // cyan 28 | "#B4BEFE", // indigo 29 | ]; 30 | 31 | function calculateSafeIndex(index: number, total: number) { 32 | const quotient = index / total; 33 | return Math.round(total * (quotient - Math.floor(quotient))); 34 | } 35 | 36 | function buildColors(schema: Schema) { 37 | return Object.keys(schema.nodes).reduce>( 38 | (acc, node, index) => { 39 | const safeIndex = 40 | index >= nodesColors.length 41 | ? calculateSafeIndex(index, nodesColors.length) 42 | : index; 43 | 44 | acc[node] = nodesColors[safeIndex]; 45 | return acc; 46 | }, 47 | {} 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | 17 | - uses: pnpm/action-setup@v2 18 | name: Install pnpm 19 | id: pnpm-install 20 | with: 21 | version: 7 22 | run_install: false 23 | 24 | - name: Get pnpm store directory 25 | id: pnpm-cache 26 | shell: bash 27 | run: | 28 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 29 | 30 | - uses: actions/cache@v3 31 | name: Setup pnpm cache 32 | with: 33 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 34 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pnpm-store- 37 | 38 | - name: Install dependencies 39 | run: pnpm install 40 | - name: Install Playwright Browsers 41 | run: pnpm dlx playwright install --with-deps 42 | - name: Run Playwright tests 43 | run: pnpm run test 44 | - uses: actions/upload-artifact@v3 45 | if: always() 46 | with: 47 | name: playwright-report 48 | path: playwright-report/ 49 | retention-days: 30 50 | -------------------------------------------------------------------------------- /src/tabs/schema.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SplitView, SplitViewCol } from "../components/split-view"; 3 | import JSONTree from "../components/json-tree"; 4 | import { Heading } from "../components/heading"; 5 | import { useAtomValue } from "jotai"; 6 | import { schemaAtom } from "../state/schema"; 7 | 8 | const ignoreFields = ["schema", "contentExpr", "schema", "parseDOM", "toDOM"]; 9 | 10 | export function postprocessValue( 11 | ignore: Array, 12 | data: Record 13 | ) { 14 | if (!data || Object.prototype.toString.call(data) !== "[object Object]") { 15 | return data; 16 | } 17 | 18 | return Object.keys(data) 19 | .filter((key) => ignore.indexOf(key) === -1) 20 | .reduce((res, key) => { 21 | res[key] = data[key]; 22 | return res; 23 | }, {} as Record); 24 | } 25 | 26 | export default function SchemaTab() { 27 | const schema = useAtomValue(schemaAtom); 28 | if (!schema) return null; 29 | 30 | return ( 31 | 32 | 33 | Nodes 34 | 38 | 39 | 40 | Marks 41 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/hooks/use-subscribe-to-editor-view.ts: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from "jotai"; 2 | import type { EditorView } from "prosemirror-view"; 3 | import React from "react"; 4 | import { editorStateAtom } from "../state/editor-state"; 5 | import { editorViewAtom } from "../state/editor-view"; 6 | import { historyWriteAtom } from "../state/history"; 7 | import subscribeOnUpdates from "../utils/subscribe-on-updates"; 8 | 9 | export function useSubscribeToEditorView( 10 | editorView: EditorView, 11 | diffWorkerInstance?: Worker 12 | ) { 13 | const setEditorView = useSetAtom(editorViewAtom); 14 | const historyDispatcher = useSetAtom(historyWriteAtom); 15 | const setEditorState = useSetAtom(editorStateAtom); 16 | const diffWorker = diffWorkerInstance 17 | ? import("../state/json-diff-worker").then( 18 | ({ JsonDiffWorker }) => new JsonDiffWorker(diffWorkerInstance) 19 | ) 20 | : import("../state/json-diff-main").then( 21 | ({ JsonDiffMain }) => new JsonDiffMain() 22 | ); 23 | 24 | React.useEffect(() => { 25 | // set initial editor state 26 | setEditorState(editorView.state); 27 | 28 | // store editor view reference 29 | setEditorView(editorView); 30 | 31 | historyDispatcher({ type: "reset", payload: { state: editorView.state } }); 32 | 33 | subscribeOnUpdates(editorView, (tr, oldState, newState) => { 34 | setEditorState(newState); 35 | 36 | historyDispatcher({ 37 | type: "update", 38 | payload: { 39 | oldState, 40 | newState, 41 | tr, 42 | diffWorker, 43 | }, 44 | }); 45 | }); 46 | }, [editorView, diffWorker]); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/split-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | export const SplitView: React.FC<{ 6 | testId?: string; 7 | children: React.ReactNode; 8 | }> = ({ children, testId }) => ( 9 |
16 | {children} 17 |
18 | ); 19 | 20 | type SplitViewColProps = { 21 | grow?: boolean; 22 | maxWidth?: number; 23 | minWidth?: number; 24 | noPaddings?: boolean; 25 | sep?: boolean; 26 | children: React.ReactNode; 27 | }; 28 | 29 | const splitViewColStyles = css({ 30 | boxSizing: "border-box", 31 | height: "100%", 32 | overflow: "scroll", 33 | borderLeft: "none", 34 | padding: "16px 18px 18px", 35 | }); 36 | const splitViewColGrowStyles = css({ 37 | flexGrow: 1, 38 | }); 39 | const splitViewColSepStyles = css({ 40 | borderLeft: "1px solid " + theme.main20, 41 | }); 42 | const splitViewColNoPaddingStyles = css({ 43 | padding: "0", 44 | }); 45 | export const SplitViewCol: React.FC = ({ 46 | children, 47 | sep, 48 | grow, 49 | noPaddings, 50 | minWidth, 51 | maxWidth, 52 | }) => { 53 | return ( 54 |
66 | {children} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/heading.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | const headingStyles = css({ 6 | color: theme.softerMain, 7 | padding: 0, 8 | margin: 0, 9 | fontWeight: 400, 10 | letterSpacing: "1px", 11 | fontSize: "13px", 12 | textTransform: "uppercase", 13 | flexGrow: 1, 14 | }); 15 | const Heading: React.FC = ({ children }) => ( 16 |

{children}

17 | ); 18 | 19 | const headingWithButtonStyles = css({ display: "flex" }); 20 | const HeadingWithButton: React.FC = ({ children }) => ( 21 |
{children}
22 | ); 23 | 24 | const headingButtonStyles = css({ 25 | padding: "6px 10px", 26 | margin: "-6px -10px 0 8px", 27 | fontWeight: 400, 28 | letterSpacing: "1px", 29 | fontSize: "11px", 30 | color: theme.white80, 31 | textTransform: "uppercase", 32 | transition: "background 0.3s, color 0.3s", 33 | borderRadius: "2px", 34 | border: "none", 35 | background: "transparent", 36 | 37 | "&:hover": { 38 | background: theme.main40, 39 | color: theme.text, 40 | cursor: "pointer", 41 | }, 42 | 43 | "&:focus": { 44 | outline: "none", 45 | }, 46 | 47 | "&:active": { 48 | background: theme.main60, 49 | }, 50 | }); 51 | const HeadingButton: React.FC<{ 52 | onClick: MouseEventHandler; 53 | children: React.ReactNode; 54 | }> = ({ children, onClick }) => ( 55 | 58 | ); 59 | 60 | export { Heading, HeadingWithButton, HeadingButton }; 61 | -------------------------------------------------------------------------------- /src/utils/format-selection-object.ts: -------------------------------------------------------------------------------- 1 | import type { Selection } from "prosemirror-state"; 2 | 3 | const copyProps = [ 4 | "jsonID", 5 | "empty", 6 | "anchor", 7 | "from", 8 | "head", 9 | "to", 10 | "$anchor", 11 | "$head", 12 | "$cursor", 13 | "$to", 14 | "$from", 15 | ]; 16 | 17 | const copySubProps = { 18 | $from: ["nodeAfter", "nodeBefore", "parent", "textOffset", "depth", "pos"], 19 | $to: ["nodeAfter", "nodeBefore", "parent", "textOffset", "depth", "pos"], 20 | }; 21 | 22 | const isNode = ["nodeAfter", "nodeBefore", "parent"]; 23 | 24 | function filterProps( 25 | selection: Selection, 26 | props: Array, 27 | subProps?: Record> 28 | ) { 29 | return props.reduce>((acc, prop) => { 30 | if (subProps && subProps[prop]) { 31 | acc[prop] = subProps[prop].reduce>( 32 | (subAcc, subProp) => { 33 | subAcc[subProp] = 34 | isNode.indexOf(subProp) === -1 || !(selection as any)[prop][subProp] 35 | ? (selection as any)[prop][subProp] 36 | : (selection[prop as keyof Selection] as any)[subProp].toJSON(); 37 | return subAcc; 38 | }, 39 | {} 40 | ); 41 | } else { 42 | acc[prop === "jsonID" ? "type" : prop] = 43 | selection[prop as keyof Selection]; 44 | } 45 | 46 | return acc; 47 | }, {}); 48 | } 49 | 50 | export function expandedStateFormatSelection(selection: Selection) { 51 | return filterProps(selection, copyProps, copySubProps); 52 | } 53 | 54 | export function collapsedStateFormatSelection(selection: Selection) { 55 | return filterProps(selection, copyProps.slice(0, 6)); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import theme from "../theme"; 3 | import { css } from "@compiled/react"; 4 | 5 | interface SearchBarProps { 6 | onSearch: (query: string) => void; 7 | } 8 | 9 | const buttonStyles = css({ 10 | color: theme.softerMain, 11 | padding: 6, 12 | fontWeight: 400, 13 | fontSize: "12px", 14 | background: "transparent", 15 | border: "none", 16 | "&:hover": { 17 | background: theme.white10, 18 | }, 19 | }); 20 | 21 | const inputStyles = css({ 22 | background: "transparent", 23 | border: "1px solid " + theme.softerMain, 24 | outline: "none", 25 | color: theme.softerMain, 26 | "&::placeholder": { color: theme.white20 }, 27 | }); 28 | 29 | const searchBarWrapperStyles = css({ 30 | display: "flex", 31 | gap: "4px", 32 | margin: 4, 33 | }); 34 | 35 | const SearchBar: React.FC = ({ onSearch }) => { 36 | const [query, setQuery] = useState(""); 37 | 38 | const handleInputChange = useCallback( 39 | (event: React.ChangeEvent) => { 40 | setQuery(event.target.value); 41 | onSearch(event.target.value); 42 | }, 43 | [onSearch] 44 | ); 45 | 46 | const handleSearch = useCallback(() => { 47 | onSearch(query); 48 | }, [query, onSearch]); 49 | 50 | return ( 51 |
52 | 59 | 62 |
63 | ); 64 | }; 65 | 66 | export default SearchBar; 67 | -------------------------------------------------------------------------------- /src/hooks/use-rollback-history.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSetAtom } from "jotai"; 3 | import { Selection } from "prosemirror-state"; 4 | import type { EditorView } from "prosemirror-view"; 5 | import { editorStateAtom } from "../state/editor-state"; 6 | import { type HistoryItem, historyRolledBackToAtom } from "../state/history"; 7 | import getEditorStateClass from "../state/get-editor-state"; 8 | 9 | export type rollbackHistoryFn = ( 10 | historyItem: HistoryItem, 11 | historyItemIndex: number 12 | ) => void; 13 | export function useRollbackHistory(editorView: EditorView): rollbackHistoryFn { 14 | const setHistoryRolledBackTo = useSetAtom(historyRolledBackToAtom); 15 | const setEditorState = useSetAtom(editorStateAtom); 16 | const rollbackHistory = React.useCallback( 17 | (historyItem: HistoryItem, historyItemIndex: number) => { 18 | const EditorState = getEditorStateClass(); 19 | const { state } = historyItem; 20 | const newState = EditorState.create({ 21 | schema: state.schema, 22 | plugins: state.plugins, 23 | doc: state.schema.nodeFromJSON(state.doc.toJSON()), 24 | }); 25 | editorView.updateState(newState); 26 | editorView.dom.focus(); 27 | const selection = Selection.fromJSON( 28 | editorView.state.doc, 29 | state.selection.toJSON() 30 | ); 31 | const tr = editorView.state.tr 32 | .setSelection(selection) 33 | .setMeta("addToHistory", false) 34 | .setMeta("_skip-dev-tools-history_", true); 35 | 36 | editorView.dispatch(tr); 37 | 38 | setEditorState(editorView.state); 39 | setHistoryRolledBackTo(historyItemIndex); 40 | }, 41 | [editorView] 42 | ); 43 | 44 | return rollbackHistory; 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Install Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 16 29 | 30 | - uses: pnpm/action-setup@v2 31 | name: Install pnpm 32 | id: pnpm-install 33 | with: 34 | version: 7 35 | run_install: false 36 | 37 | - name: Get pnpm store directory 38 | id: pnpm-cache 39 | shell: bash 40 | run: | 41 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 42 | 43 | - uses: actions/cache@v3 44 | name: Setup pnpm cache 45 | with: 46 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 47 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 48 | restore-keys: | 49 | ${{ runner.os }}-pnpm-store- 50 | 51 | - name: Install dependencies 52 | run: pnpm install 53 | 54 | - run: pnpm run typecheck 55 | - run: pnpm run lint 56 | - run: pnpm run prettier:check 57 | - run: pnpm run build 58 | -------------------------------------------------------------------------------- /src/components/save-snapshot-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import { useAtomValue } from "jotai"; 3 | import theme from "../theme"; 4 | import { useSnapshots } from "../state/snapshots"; 5 | import { editorStateAtom } from "../state/editor-state"; 6 | import { css } from "@compiled/react"; 7 | 8 | const saveSnapshotButtonStyles = css({ 9 | appearance: "none", 10 | position: "absolute", 11 | right: "32px", 12 | top: "-28px", 13 | color: theme.white, 14 | background: theme.main60, 15 | fontSize: "12px", 16 | lineHeight: "25px", 17 | padding: "0 6px", 18 | height: "24px", 19 | backgroundSize: "20px 20px", 20 | backgroundRepeat: "none", 21 | backgroundPosition: "50% 50%", 22 | borderRadius: "3px", 23 | border: "none", 24 | 25 | "&:hover": { 26 | backgroundColor: theme.main80, 27 | cursor: "pointer", 28 | }, 29 | }); 30 | const SaveSnapshotButton: React.FC<{ 31 | onClick: MouseEventHandler; 32 | children: React.ReactNode; 33 | }> = ({ children, onClick }) => ( 34 | 37 | ); 38 | 39 | export default function SaveSnapshot() { 40 | const [, snapshotsDispatch] = useSnapshots(); 41 | const editorState = useAtomValue(editorStateAtom); 42 | const handleClick = React.useCallback(() => { 43 | const snapshotName = prompt("Enter snapshot name", "" + Date.now()); 44 | if (!snapshotName || !editorState) return; 45 | const snapshot = { 46 | name: snapshotName, 47 | timestamp: Date.now(), 48 | snapshot: editorState.doc.toJSON(), 49 | }; 50 | snapshotsDispatch({ type: "save", payload: { snapshot } }); 51 | }, [editorState]); 52 | 53 | return ( 54 | 55 | Save snapshots 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /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 | baseURL: "http://localhost:3000/", 44 | }, 45 | 46 | webServer: { 47 | command: "pnpm run start", 48 | url: "http://localhost:3000/", 49 | timeout: 120 * 1000, 50 | reuseExistingServer: !process.env.CI, 51 | }, 52 | 53 | /* Configure projects for major browsers */ 54 | projects: [ 55 | { 56 | name: "chromium", 57 | use: { 58 | ...devices["Desktop Chrome"], 59 | }, 60 | }, 61 | ], 62 | }; 63 | 64 | export default config; 65 | -------------------------------------------------------------------------------- /src/state/node-picker.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; 2 | import findNodeIn, { findPMNode } from "../utils/find-node"; 3 | import { editorStateAtom } from "./editor-state"; 4 | import { expandPathAtom } from "./expand-path"; 5 | 6 | const NODE_PICKER_DEFAULT = { 7 | top: 0, 8 | left: 0, 9 | width: 0, 10 | height: 0, 11 | active: false, 12 | }; 13 | 14 | export type NodePickerState = { 15 | top: number; 16 | left: number; 17 | width: number; 18 | height: number; 19 | active: boolean; 20 | }; 21 | const nodePickerAtom = atom(NODE_PICKER_DEFAULT); 22 | 23 | // TODO: rewrite as read and write atom 24 | export function useNodePicker() { 25 | const [nodePickerState, setNodePickerState] = useAtom(nodePickerAtom); 26 | const editorState = useAtomValue(editorStateAtom); 27 | const setExpandPath = useSetAtom(expandPathAtom); 28 | 29 | const api = { 30 | activate: () => { 31 | setNodePickerState({ ...NODE_PICKER_DEFAULT, active: true }); 32 | }, 33 | 34 | deactivate: () => { 35 | setNodePickerState(NODE_PICKER_DEFAULT); 36 | }, 37 | 38 | select: (target: HTMLElement) => { 39 | if (!editorState) return; 40 | const node = findPMNode(target); 41 | 42 | if (node) { 43 | const path = findNodeIn( 44 | editorState.doc, 45 | editorState.doc.nodeAt(node.pmViewDesc!.posAtStart)! 46 | ); 47 | 48 | if (!path) return; 49 | 50 | setExpandPath([]); 51 | setExpandPath(path); 52 | api.deactivate(); 53 | } 54 | }, 55 | 56 | updatePosition: (target: HTMLElement) => { 57 | const node = findPMNode(target); 58 | 59 | if ( 60 | node && 61 | ((node.pmViewDesc!.node && node.pmViewDesc!.node.type.name !== "doc") || 62 | (node.pmViewDesc! as any).mark) 63 | ) { 64 | const { top, left, width, height } = node.getBoundingClientRect(); 65 | setNodePickerState({ 66 | top: top + window.scrollY, 67 | left, 68 | width, 69 | height, 70 | active: true, 71 | }); 72 | } else { 73 | api.activate(); 74 | } 75 | }, 76 | }; 77 | 78 | return [nodePickerState, api] as const; 79 | } 80 | -------------------------------------------------------------------------------- /tests/snapshots.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { PlaywrightDevPage } from "./page-object"; 3 | 4 | test("save and restore snaphsots", async ({ page }) => { 5 | const po = new PlaywrightDevPage(page); 6 | await po.goto(); 7 | await po.collapsedButton.click(); 8 | await expect(po.devToolsContainer).toBeVisible(); 9 | 10 | await page.evaluate(() => window.localStorage.clear()); 11 | page.on("dialog", async (dialog) => { 12 | await dialog.accept("" + Math.ceil(Math.random() * 10000)); 13 | }); 14 | 15 | await po.tabSnapshotsButton.click(); 16 | await expect(po.locateTab("snapshots")).toBeVisible(); 17 | 18 | await po.saveSnapshotButton.click(); 19 | 20 | await po.proseMirror.type("Hello"); 21 | await po.proseMirror.press("Enter"); 22 | await po.proseMirror.type("World"); 23 | 24 | await po.saveSnapshotButton.click(); 25 | 26 | await po.page.getByText("restore", { exact: true }).nth(1).click(); 27 | await expect(po.proseMirror).toHaveText(""); 28 | 29 | await po.proseMirror.type("Some other"); 30 | await po.proseMirror.press("Enter"); 31 | await po.proseMirror.type("Text"); 32 | 33 | await po.page.getByText("restore", { exact: true }).nth(0).click(); 34 | await expect(po.proseMirror).toHaveText("HelloWorld"); 35 | }); 36 | 37 | test("delete snaphsots", async ({ page }) => { 38 | const po = new PlaywrightDevPage(page); 39 | await po.goto(); 40 | await po.collapsedButton.click(); 41 | await expect(po.devToolsContainer).toBeVisible(); 42 | 43 | await page.evaluate(() => window.localStorage.clear()); 44 | page.on("dialog", async (dialog) => { 45 | await dialog.accept("" + Math.ceil(Math.random() * 10000)); 46 | }); 47 | 48 | await po.tabSnapshotsButton.click(); 49 | await expect(po.locateTab("snapshots")).toBeVisible(); 50 | 51 | await po.saveSnapshotButton.click(); 52 | 53 | await po.proseMirror.type("Hello"); 54 | await po.proseMirror.press("Enter"); 55 | await po.proseMirror.type("World"); 56 | 57 | await po.saveSnapshotButton.click(); 58 | expect(await po.page.getByText("delete", { exact: true }).count()).toBe(2); 59 | 60 | await po.page.getByText("delete", { exact: true }).nth(1).click(); 61 | expect(await po.page.getByText("delete", { exact: true }).count()).toBe(1); 62 | }); 63 | -------------------------------------------------------------------------------- /src/utils/find-node.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "prosemirror-model"; 2 | import type { ExtendedFragment, JSONNode } from "../types/prosemirror"; 3 | 4 | function findNode( 5 | fullPath: Array, 6 | currentNode: Node, 7 | nodeToFind: Node 8 | ): Array | null { 9 | if (nodeToFind === currentNode) { 10 | return fullPath; 11 | } 12 | 13 | const fragment = currentNode.content as unknown as ExtendedFragment; 14 | 15 | if (!fragment || !fragment.content) return null; 16 | 17 | const res = fragment.content 18 | .map((currentNode: Node, i: number) => 19 | findNode([...fullPath, "content", i], currentNode, nodeToFind) 20 | ) 21 | .filter((res) => Array.isArray(res) && res.length)[0]; 22 | 23 | return res; 24 | } 25 | 26 | export default function findNodeIn(doc: Node, node: Node) { 27 | const path = findNode([], doc, node); 28 | 29 | if (path) { 30 | return path.reduce>((newPath, item) => { 31 | // [0, content, content, 0] => [0, content, 0] 32 | // Because JSON representation a bit different from actual DOC. 33 | if (item === "content" && newPath[newPath.length - 1] === "content") { 34 | return newPath; 35 | } 36 | 37 | newPath.push(item); 38 | return newPath; 39 | }, []); 40 | } 41 | } 42 | 43 | export function findNodeJSON( 44 | fullPath: Array, 45 | currentNode: JSONNode, 46 | nodeToFind: JSONNode | Array 47 | ): Array { 48 | if (nodeToFind === currentNode) { 49 | return fullPath; 50 | } 51 | 52 | if (!currentNode.content) return []; 53 | 54 | if (currentNode.content === nodeToFind) { 55 | return fullPath.concat("content"); 56 | } 57 | 58 | const res = currentNode.content 59 | .map((currentNode, i) => 60 | findNodeJSON([...fullPath, "content", i], currentNode, nodeToFind) 61 | ) 62 | .filter((res) => Array.isArray(res) && res.length)[0]; 63 | 64 | return res; 65 | } 66 | 67 | export function findPMNode(domNode: HTMLElement) { 68 | let node; 69 | let target: HTMLElement | null = domNode; 70 | 71 | while (!node && target) { 72 | if ((target as any).pmViewDesc) { 73 | node = target; 74 | } 75 | target = target.parentNode as HTMLElement; 76 | } 77 | 78 | return node; 79 | } 80 | -------------------------------------------------------------------------------- /tests/history-tab.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { PlaywrightDevPage } from "./page-object"; 3 | 4 | test("create and revert history items", async ({ page }) => { 5 | const po = new PlaywrightDevPage(page); 6 | await po.goto(); 7 | await po.collapsedButton.click(); 8 | await expect(po.devToolsContainer).toBeVisible(); 9 | const messages: Array = []; 10 | page.on("pageerror", (error) => { 11 | messages.push(`[${error.name}] ${error.message}`); 12 | }); 13 | 14 | await po.tabHistoryButton.click(); 15 | await expect(po.locateTab("history")).toBeVisible(); 16 | 17 | expect(await po.listItemActive.count()).toBe(1); 18 | await po.proseMirror.type("He"); 19 | await po.proseMirror.type("ll"); 20 | await po.proseMirror.type("o"); 21 | expect(await po.listItemActive.count()).toBeGreaterThan(5); 22 | await expect(po.proseMirror).toHaveText("Hello"); 23 | 24 | // Reset to N=2 = check that not equal to Hello 25 | let count = await po.listItemActive.count(); 26 | await po.listItemActive.nth(count - 2).dblclick(); 27 | await expect(po.proseMirror).not.toHaveText("Hello"); 28 | expect(await po.listItemInactive.count()).toBeGreaterThan(0); 29 | 30 | // Reset to N=0 = check that equal to Hello 31 | await po.listItemInactive.nth(0).dblclick(); 32 | await expect(po.proseMirror).toHaveText("Hello"); 33 | expect(await po.listItemInactive.count()).toBe(0); 34 | 35 | // Type more text check that number of items is more than 5 and text Hello World 36 | await po.proseMirror.type("W"); 37 | expect(await po.listItemActive.count()).toBeGreaterThan(6); 38 | await expect(po.proseMirror).toHaveText("HelloW"); 39 | 40 | // Reset to N=2 check that number of items is less than 5 and text not Hello World 41 | count = await po.listItemActive.count(); 42 | await po.listItemActive.nth(count - 2).dblclick(); 43 | await expect(po.proseMirror).not.toHaveText("HelloW"); 44 | expect(await po.listItemInactive.count()).toBeGreaterThan(0); 45 | 46 | // Type and check if number of active items is less than 5 47 | await po.proseMirror.type("o"); 48 | expect(await po.listItemInactive.count()).toBe(0); 49 | expect(await po.listItemActive.count()).toBeLessThan(5); 50 | 51 | // Reset to N=0 check that text is empty 52 | count = await po.listItemActive.count(); 53 | await po.listItemActive.nth(count - 1).dblclick(); 54 | await expect(po.proseMirror).toHaveText(""); 55 | 56 | expect(messages).toStrictEqual([]); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/page-object.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test"; 2 | 3 | export class PlaywrightDevPage { 4 | readonly page: Page; 5 | readonly collapsedButton: Locator; 6 | readonly devToolsContainer: Locator; 7 | readonly closeButton: Locator; 8 | 9 | readonly tabStateButton: Locator; 10 | readonly tabButtonsConatiner: Locator; 11 | readonly tabHistoryButton: Locator; 12 | readonly tabPluginsButton: Locator; 13 | readonly tabSchemaButton: Locator; 14 | readonly tabStructureButton: Locator; 15 | readonly tabSnapshotsButton: Locator; 16 | 17 | readonly proseMirror: Locator; 18 | 19 | readonly stateLogNodeButtons: Locator; 20 | 21 | readonly saveSnapshotButton: Locator; 22 | readonly listItemActive: Locator; 23 | readonly listItemInactive: Locator; 24 | 25 | constructor(page: Page) { 26 | this.page = page; 27 | this.collapsedButton = page.locator( 28 | "[data-test-id=__prosemirror_devtools_collapsed_button__]" 29 | ); 30 | this.devToolsContainer = page.locator( 31 | "[data-test-id=__prosemirror_devtools_container__]" 32 | ); 33 | this.closeButton = page.locator( 34 | "[data-test-id=__prosemirror_devtools_close_button__]" 35 | ); 36 | 37 | // tabs 38 | this.tabButtonsConatiner = page.locator( 39 | "[data-test-id=__prosemirror_devtools_tabs_buttons_container__]" 40 | ); 41 | this.tabStateButton = this.tabButtonsConatiner.getByText("State", { 42 | exact: true, 43 | }); 44 | this.tabHistoryButton = this.tabButtonsConatiner.getByText("History"); 45 | this.tabPluginsButton = this.tabButtonsConatiner.getByText("Plugins"); 46 | this.tabSchemaButton = this.tabButtonsConatiner.getByText("Schema"); 47 | this.tabStructureButton = 48 | this.tabButtonsConatiner.locator("text=Structure"); 49 | this.tabSnapshotsButton = 50 | this.tabButtonsConatiner.locator("text=Snapshots"); 51 | 52 | // ProseMirror 53 | this.proseMirror = page.locator(".ProseMirror"); 54 | 55 | // log buttons 56 | this.stateLogNodeButtons = page.locator( 57 | "[data-test-id=__prosemirror_devtools_log_node_button__]" 58 | ); 59 | 60 | this.saveSnapshotButton = page.getByText("Save snapshots", { exact: true }); 61 | 62 | this.listItemActive = page.locator( 63 | "[data-test-id=__prosemirror_devtools_list_item__]" 64 | ); 65 | this.listItemInactive = page.locator( 66 | "[data-test-id=__prosemirror_devtools_list_item_inactive__]" 67 | ); 68 | } 69 | 70 | async goto() { 71 | await this.page.goto("http://localhost:3000/"); 72 | } 73 | 74 | locateTab( 75 | id: "history" | "state" | "plugins" | "schema" | "snapshots" | "structure" 76 | ) { 77 | return this.page.locator( 78 | `[data-test-id=__prosemirror_devtools_tabs_${id}__]` 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler, useContext } from "react"; 2 | import "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | const TabsContextProvider = React.createContext({ 6 | selectedIndex: "state", 7 | // eslint-disable-next-line 8 | onSelect: (_index: string) => {}, 9 | }); 10 | 11 | export const TabList: React.FC = ({ children }) => ( 12 |
20 | {children} 21 |
22 | ); 23 | 24 | const TabsStyled: React.FC = ({ children }) => ( 25 |
32 | {children} 33 |
34 | ); 35 | 36 | type TabStyledProps = { 37 | isSelected: boolean; 38 | onClick: MouseEventHandler; 39 | children: React.ReactNode; 40 | }; 41 | const TabStyled: React.FC = ({ 42 | children, 43 | isSelected, 44 | onClick, 45 | }) => ( 46 |
67 | {children} 68 |
69 | ); 70 | 71 | export function Tab({ 72 | index, 73 | children, 74 | }: { 75 | index: string; 76 | children: React.ReactNode; 77 | }) { 78 | const tabs = useContext(TabsContextProvider); 79 | return ( 80 | tabs.onSelect(index)} 83 | > 84 | {children} 85 | 86 | ); 87 | } 88 | 89 | export function TabPanel(props: { 90 | children: (prop: { index: string }) => React.ReactNode; 91 | }) { 92 | const tabs = useContext(TabsContextProvider); 93 | return ( 94 |
101 | {props.children({ index: tabs.selectedIndex })} 102 |
103 | ); 104 | } 105 | 106 | export function Tabs(props: { 107 | onSelect: (index: string) => void; 108 | selectedIndex: string; 109 | children: React.ReactNode; 110 | }) { 111 | return ( 112 | 118 | {props.children} 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-dev-tools", 3 | "version": "4.2.0", 4 | "description": "Dev Tools for ProseMirror", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "engines": { 8 | "node": ">=10" 9 | }, 10 | "scripts": { 11 | "build": "npm run build:cjs && npm run build:esm && npm run build:types", 12 | "build:cjs": "cross-env BABEL_ENV=cjs babel src --out-dir dist/cjs --extensions '.ts,.tsx,.js,.jsx'", 13 | "build:esm": "cross-env BABEL_ENV=esm babel src --out-dir dist/esm --extensions '.ts,.tsx,.js,.jsx'", 14 | "build:types": "tsc --emitDeclarationOnly --declaration --isolatedModules false --outDir dist/types", 15 | "commit": "git-cz", 16 | "lint": "eslint ./src", 17 | "lint:staged": "lint-staged", 18 | "pmm:prepare": "npm run build", 19 | "prebuild": "rimraf ./dist", 20 | "prettier:check": "prettier --check .", 21 | "release:major": "pmm major", 22 | "release:minor": "pmm minor", 23 | "release:patch": "pmm patch", 24 | "start": "vite ./ --port 3000 --host localhost", 25 | "test": "playwright test", 26 | "typecheck": "tsc --noEmit" 27 | }, 28 | "keywords": [], 29 | "author": "Stanislav Sysoev <@d4rkr00t>", 30 | "license": "MIT", 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/d4rkr00t/prosemirror-dev-tools" 34 | }, 35 | "dependencies": { 36 | "@babel/runtime": "^7.18.6", 37 | "@compiled/react": "^0.11.1", 38 | "html": "^1.0.0", 39 | "jotai": "^1.10.0", 40 | "jsondiffpatch": "^0.4.1", 41 | "nanoid": "^3.3.8", 42 | "prosemirror-model": ">=1.0.0", 43 | "prosemirror-state": ">=1.0.0", 44 | "react-dock": "^0.6.0", 45 | "react-json-tree": "^0.17.0" 46 | }, 47 | "peerDependencies": { 48 | "react": "^17.0.0 || ^18.0.0 || ^19.0.0", 49 | "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" 50 | }, 51 | "devDependencies": { 52 | "@babel/cli": "^7.19.3", 53 | "@babel/core": "^7.20.2", 54 | "@babel/plugin-proposal-class-properties": "^7.18.6", 55 | "@babel/plugin-transform-runtime": "^7.19.6", 56 | "@babel/preset-env": "^7.19.3", 57 | "@babel/preset-react": "^7.18.6", 58 | "@babel/preset-typescript": "^7.18.6", 59 | "@compiled/babel-plugin": "^0.17.1", 60 | "@playwright/test": "^1.28.1", 61 | "@types/html": "^1.0.1", 62 | "@types/react": "^18.0.0", 63 | "@types/react-dom": "^18.0.0", 64 | "@typescript-eslint/eslint-plugin": "^5.44.0", 65 | "@typescript-eslint/parser": "^5.44.0", 66 | "@vitejs/plugin-react": "^3.0.0", 67 | "cross-env": "^7.0.3", 68 | "eslint": "^8.28.0", 69 | "eslint-config-prettier": "^8.5.0", 70 | "eslint-plugin-react": "^7.31.11", 71 | "lint-staged": "^13.0.4", 72 | "pmm": "^2.0.0", 73 | "pre-commit": "^1.2.2", 74 | "prettier": "^2.8.0", 75 | "prosemirror-example-setup": "*", 76 | "prosemirror-schema-basic": "*", 77 | "prosemirror-view": "*", 78 | "react": "^18.0.0", 79 | "react-dom": "^18.0.0", 80 | "rimraf": "^3.0.2", 81 | "typescript": "^4.9.3", 82 | "vite": "^4.0.3" 83 | }, 84 | "pre-commit": [ 85 | "lint:staged" 86 | ], 87 | "lint-staged": { 88 | "*.js": [ 89 | "prettier --write", 90 | "git add" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![prosemirror-dev-tools](/docs/assets/logo.png) 2 | 3 |

4 | 5 | NPM Version 6 | 7 | 8 | 9 | License 10 | 11 | 12 | 13 | Github Issues 14 | 15 | 16 | 17 | Travis Status 18 | 19 | 20 | 21 | Commitizen Friendly 22 | 23 |

24 | 25 | ## Table of Content 26 | 27 | - [Table of Content](#table-of-content) 28 | - [Quick Start](#quick-start) 29 | - [NPM Way](#npm-way) 30 | - [Features](#features) 31 | - [State](#state) 32 | - [History](#history) 33 | - [Plugins](#plugins) 34 | - [Schema](#schema) 35 | - [Structure](#structure) 36 | - [Snapshots](#snapshots) 37 | - [Demo](#demo) 38 | - [Contributing](#contributing) 39 | - [License](#license) 40 | 41 | ## Quick Start 42 | 43 | ### NPM Way 44 | 45 | Install `prosemirror-dev-tools` package from npm: 46 | 47 | ```sh 48 | npm install --save-dev prosemirror-dev-tools 49 | ``` 50 | 51 | Wrap `EditorView` instance in applyDevTools method: 52 | 53 | ```js 54 | import applyDevTools from "prosemirror-dev-tools"; 55 | 56 | const view = new EditorView /*...*/(); 57 | 58 | applyDevTools(view); 59 | ``` 60 | 61 | ## Features 62 | 63 | ### State 64 | 65 | - Inspect document – all nodes and marks 66 | - Inspect selection – position, head, anchor and etc. 67 | - Inspect active marks 68 | - See document stats – size, child count 69 | 70 | ![prosemirror-dev-tools state tab](/docs/assets/state-tab.png) 71 | 72 | ### History 73 | 74 | - Inspect document changes over time 75 | - Time travel between states 76 | - See selection content for particular state in time 77 | - See selection diff 78 | 79 | ![prosemirror-dev-tools history tab](/docs/assets/history-tab.png) 80 | 81 | ### Plugins 82 | 83 | Inspect state of each plugin inside prosemirror. 84 | 85 | ![prosemirror-dev-tools plugins tab](/docs/assets/plugins-tab.png) 86 | 87 | ### Schema 88 | 89 | Inspect current document schema with nodes and marks. 90 | 91 | ![prosemirror-dev-tools schema tab](/docs/assets/schema-tab.png) 92 | 93 | ### Structure 94 | 95 | Visual representation of current document tree with positions at the beginning 96 | and the end of every node. 97 | 98 | ![prosemirror-dev-tools structure tab](/docs/assets/structure-tab.png) 99 | 100 | ### Snapshots 101 | 102 | Snapshots allow you to save current editor state and restore it later. State is 103 | stored in local storage. 104 | 105 | ![prosemirror-dev-tools snapshots tab](/docs/assets/snapshots-tab.png) 106 | 107 | ## Demo 108 | 109 | - [Demo & Example Setup](https://codesandbox.io/s/l9n6667ooz) 110 | 111 | ## Contributing 112 | 113 | Contributions are highly welcome! This repo is commitizen friendly — please read 114 | about it [here](http://commitizen.github.io/cz-cli/). 115 | 116 | ## License 117 | 118 | - **MIT** : http://opensource.org/licenses/MIT 119 | -------------------------------------------------------------------------------- /src/components/node-picker/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import { useSetAtom } from "jotai"; 4 | import theme from "../../theme"; 5 | import { useNodePicker } from "../../state/node-picker"; 6 | import { devToolTabIndexAtom } from "../../state/global"; 7 | 8 | const icon = 9 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAmCAQAAAACNCElAAAAxklEQVRIx+2Vuw3DMAxEXWUD9VrKvTYJRzAygWpPkSVcBlDtJS6Fg8AQqQ+lAEECXU08iid+pmnoTwWDKzbU6IEbLnkYQaMlD9uA6iqAUArQwDBgX4T1Z+uF4Q4PB/sZmH/1e1BCRZiLhqgWKsJsYjJLUPkDEJKjvmPWwnwCtcKoW4O5VnpTFmaVb8o3LXONOiZAcI3aYe5UIFXiUmv77doOc7oUpDoozLU5iiPFqYtcW4W01LJP3FEiwzXBLG9SUBNq6Ef0BJ8IApq+rItIAAAAAElFTkSuQmCC"; 10 | 11 | const NodePickerHighlight: React.FC<{ 12 | visible: boolean; 13 | width: number; 14 | height: number; 15 | left: number; 16 | top: number; 17 | }> = ({ visible, width, height, left, top }) => ( 18 |
33 | ); 34 | 35 | function NodePicker() { 36 | const setTabIndex = useSetAtom(devToolTabIndexAtom); 37 | const [nodePicker, nodePickerApi] = useNodePicker(); 38 | const handleMouseMove = React.useCallback( 39 | (e: MouseEvent) => { 40 | nodePickerApi.updatePosition(e.target as HTMLElement); 41 | }, 42 | [nodePickerApi] 43 | ); 44 | const handleNodeClick = React.useCallback( 45 | (e: MouseEvent) => { 46 | e.preventDefault(); 47 | nodePickerApi.select(e.target as HTMLElement); 48 | setTabIndex("state"); 49 | }, 50 | [nodePickerApi] 51 | ); 52 | 53 | React.useEffect(() => { 54 | const active = nodePicker.active; 55 | if (!active) return; 56 | 57 | document.addEventListener("mousemove", handleMouseMove); 58 | document.addEventListener("click", handleNodeClick); 59 | document.addEventListener("keydown", nodePickerApi.deactivate); 60 | 61 | return () => { 62 | document.removeEventListener("mousemove", handleMouseMove); 63 | document.removeEventListener("click", handleNodeClick); 64 | document.removeEventListener("keydown", nodePickerApi.deactivate); 65 | }; 66 | }, [handleMouseMove, handleNodeClick, nodePickerApi, nodePicker.active]); 67 | 68 | return ( 69 | 76 | ); 77 | } 78 | 79 | const NodePickerTrigger: React.FC<{ 80 | onClick: MouseEventHandler; 81 | isActive: boolean; 82 | children?: React.ReactNode; 83 | }> = ({ children, isActive, onClick }) => ( 84 | 108 | ); 109 | 110 | export { NodePicker, NodePickerTrigger }; 111 | -------------------------------------------------------------------------------- /src/tabs/plugins.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import type { Plugin } from "prosemirror-state"; 3 | import { InfoPanel } from "../components/info-panel"; 4 | import { Heading } from "../components/heading"; 5 | import JSONTree from "../components/json-tree"; 6 | import { List } from "../components/list"; 7 | import { SplitView, SplitViewCol } from "../components/split-view"; 8 | import { useAtomValue } from "jotai"; 9 | import { editorStateAtom } from "../state/editor-state"; 10 | import SearchBar from "../components/search-bar"; 11 | import Button from "../components/button"; 12 | 13 | export function valueRenderer(raw: string, ...rest: Array) { 14 | if (typeof rest[0] === "function") { 15 | return "func"; 16 | } 17 | return raw; 18 | } 19 | 20 | export function PluginState(props: { pluginState: any }) { 21 | return ( 22 |
23 | Plugin State 24 | 29 |
30 | ); 31 | } 32 | 33 | // TODO: replace isDimmed with useCallback once EditorStateContainer is decomposed 34 | export default function PluginsTab() { 35 | const state = useAtomValue(editorStateAtom); 36 | if (!state) return null; 37 | 38 | const [selectedPlugin, setSelectedPlugin] = useState(state.plugins[0]); 39 | const [pluginsLocal, setPluginsLocal] = useState(state.plugins); 40 | const [sortAsc, setSortOrder] = useState(true); 41 | 42 | const handleOnListItemClick = React.useCallback( 43 | (_plugin: Plugin) => setSelectedPlugin(_plugin), 44 | [] 45 | ); 46 | 47 | const selectedPluginState = selectedPlugin.getState(state); 48 | 49 | const handleSearch = useCallback( 50 | (input: string) => { 51 | const filteredPlugins = (state.plugins as any as Plugin[]).filter( 52 | (plugin) => { 53 | return (plugin as any).key 54 | .toLowerCase() 55 | .includes(input.toLowerCase()); 56 | } 57 | ); 58 | setPluginsLocal(filteredPlugins); 59 | }, 60 | [state.plugins] 61 | ); 62 | 63 | const handleClickSort = () => { 64 | setSortOrder(!sortAsc); 65 | }; 66 | 67 | const handleSortAsc = (plugins: any) => { 68 | return [...plugins].sort((a, b) => { 69 | if ((a as any).key < (b as any).key) { 70 | return -1; 71 | } 72 | if ((a as any).key > (b as any).key) { 73 | return 1; 74 | } 75 | return 0; 76 | }); 77 | }; 78 | const handleSortDes = (plugins: any) => { 79 | return [...plugins].sort((a, b) => { 80 | if ((a as any).key < (b as any).key) { 81 | return 1; 82 | } 83 | if ((a as any).key > (b as any).key) { 84 | return -1; 85 | } 86 | return 0; 87 | }); 88 | }; 89 | 90 | return ( 91 | 92 | 93 |
102 | 103 | 106 |
107 | (plugin as any).key} 112 | title={(plugin: Plugin) => (plugin as any).key} 113 | isDimmed={(plugin: Plugin) => !plugin.getState(state)} 114 | onListItemClick={handleOnListItemClick} 115 | /> 116 |
117 | 118 | {selectedPluginState ? ( 119 | 120 | ) : ( 121 | Plugin doesn't have any state 122 | )} 123 | 124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/dev-tools-collapsed.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import { css } from "@compiled/react"; 3 | import theme from "./theme"; 4 | 5 | const floatingButtonStyles = css({ 6 | appearance: "none", 7 | position: "fixed", 8 | bottom: "16px", 9 | right: "16px", 10 | background: theme.mainBg, 11 | boxShadow: `0 0 30px ${theme.black30}`, 12 | borderRadius: "50%", 13 | padding: "4px 6px", 14 | transition: "opacity 0.3s", 15 | border: "none", 16 | zIndex: 99999, 17 | 18 | "&:hover": { 19 | opacity: 0.7, 20 | cursor: "pointer", 21 | }, 22 | 23 | "& svg": { 24 | width: "34px", 25 | height: "34px", 26 | position: "relative", 27 | bottom: "-2px", 28 | }, 29 | }); 30 | 31 | type DevToolsCollapsedProps = { 32 | onClick: MouseEventHandler; 33 | }; 34 | export default function DevToolsCollapsed(props: DevToolsCollapsedProps) { 35 | return ( 36 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/tabs/snapshots.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import { SplitView, SplitViewCol } from "../components/split-view"; 4 | import { List } from "../components/list"; 5 | import { InfoPanel } from "../components/info-panel"; 6 | import theme from "../theme"; 7 | import { Snapshot, useSnapshots } from "../state/snapshots"; 8 | import getEditorStateClass from "../state/get-editor-state"; 9 | import { useAtom, useAtomValue, useSetAtom } from "jotai"; 10 | import { editorViewAtom } from "../state/editor-view"; 11 | import { editorStateAtom } from "../state/editor-state"; 12 | import { historyWriteAtom } from "../state/history"; 13 | 14 | const ActionButton: React.FC<{ 15 | onClick?: MouseEventHandler; 16 | children: React.ReactNode; 17 | }> = ({ children, onClick }) => ( 18 | 53 | ); 54 | 55 | const ListItem: React.FC = ({ children }) => ( 56 |
64 | {children} 65 |
66 | ); 67 | 68 | const ListItemTitle: React.FC = ({ children }) => ( 69 |
74 | {children} 75 |
76 | ); 77 | 78 | type SnapshotsListProps = { 79 | snapshots: Array; 80 | deleteSnapshot: (snapshot: Snapshot) => void; 81 | loadSnapshot: (snapshot: Snapshot) => void; 82 | }; 83 | export function SnapshotsList({ 84 | snapshots, 85 | deleteSnapshot, 86 | loadSnapshot, 87 | }: SnapshotsListProps) { 88 | return ( 89 | item.name + item.timestamp} 91 | items={snapshots} 92 | title={(item) => ( 93 | 94 | {item.name} 95 |
96 | deleteSnapshot(item)}> 97 | delete 98 | 99 | loadSnapshot(item)}> 100 | restore 101 | 102 |
103 |
104 | )} 105 | /> 106 | ); 107 | } 108 | 109 | export default function SnapshotTab() { 110 | const [snapshots, snapshotsDispatch] = useSnapshots(); 111 | const editorView = useAtomValue(editorViewAtom); 112 | const [editorState, setEditorState] = useAtom(editorStateAtom); 113 | const historyDispatcher = useSetAtom(historyWriteAtom); 114 | const loadSnapshot = React.useCallback( 115 | (snapshot: Snapshot) => { 116 | const EditorState = getEditorStateClass(); 117 | 118 | if (!editorState) return; 119 | if (!editorView) return; 120 | 121 | const newState = EditorState.create({ 122 | schema: editorState.schema, 123 | plugins: editorState.plugins, 124 | doc: editorState.schema.nodeFromJSON(snapshot.snapshot), 125 | }); 126 | 127 | editorView.updateState(newState); 128 | setEditorState(newState); 129 | historyDispatcher({ 130 | type: "reset", 131 | payload: { state: editorView.state }, 132 | }); 133 | }, 134 | [editorView, editorState] 135 | ); 136 | const deleteSnapshot = React.useCallback( 137 | (snapshot: Snapshot) => { 138 | snapshotsDispatch({ type: "delete", payload: { snapshot } }); 139 | }, 140 | [snapshotsDispatch] 141 | ); 142 | 143 | return ( 144 | 145 | 146 | {snapshots.length ? ( 147 | 152 | ) : ( 153 | 154 | No saved snapshots yet. Press "Save Snapshot" button to 155 | add one. 156 | 157 | )} 158 | 159 | 160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /src/dev-tools-expanded.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import { Dock } from "react-dock"; 4 | import { Tab, Tabs, TabList, TabPanel } from "./components/tabs"; 5 | import { 6 | devToolsOpenedAtom, 7 | devToolsSizeAtom, 8 | devToolTabIndexAtom, 9 | } from "./state/global"; 10 | import StateTab from "./tabs/state"; 11 | import HistoryTab from "./tabs/history"; 12 | import SchemaTab from "./tabs/schema"; 13 | import PluginsTab from "./tabs/plugins"; 14 | import StructureTab from "./tabs/structure"; 15 | import SnapshotsTab from "./tabs/snapshots"; 16 | import CSSReset from "./components/css-reset"; 17 | import { NodePicker, NodePickerTrigger } from "./components/node-picker"; 18 | import SaveSnapshotButton from "./components/save-snapshot-button"; 19 | import theme from "./theme"; 20 | import { useAtom, useAtomValue } from "jotai"; 21 | import { useNodePicker } from "./state/node-picker"; 22 | import type { rollbackHistoryFn } from "./hooks/use-rollback-history"; 23 | 24 | const CloseButton: React.FC<{ 25 | onClick: MouseEventHandler; 26 | children: React.ReactNode; 27 | }> = ({ children, onClick }) => ( 28 | 52 | ); 53 | 54 | type DevToolsExpandedProps = { 55 | rollbackHistory: rollbackHistoryFn; 56 | }; 57 | export default function DevToolsExpanded({ 58 | rollbackHistory, 59 | }: DevToolsExpandedProps) { 60 | const [isOpen, setIsOpen] = useAtom(devToolsOpenedAtom); 61 | const defaultSize = useAtomValue(devToolsSizeAtom); 62 | const [tabIndex, setTabIndex] = useAtom(devToolTabIndexAtom); 63 | const updateBodyMargin = React.useCallback((devToolsSize: number) => { 64 | const size = devToolsSize * window.innerHeight; 65 | document.querySelector("html")!.style.marginBottom = `${size}px`; 66 | }, []); 67 | const [nodePicker, nodePickerAPI] = useNodePicker(); 68 | const toggleOpen = React.useCallback(() => { 69 | setIsOpen(!isOpen); 70 | }, [isOpen]); 71 | 72 | const renderTab = React.useCallback( 73 | ({ index }: { index: string }) => { 74 | switch (index) { 75 | case "state": 76 | return ; 77 | case "history": 78 | return ; 79 | case "plugins": 80 | return ; 81 | case "schema": 82 | return ; 83 | case "structure": 84 | return ; 85 | case "snapshots": 86 | return ; 87 | default: 88 | return ; 89 | } 90 | }, 91 | [rollbackHistory] 92 | ); 93 | 94 | const renderDockContent = React.useCallback(() => { 95 | return ( 96 |
107 | × 108 | 112 | 113 | 114 | 115 | 116 | State 117 | History 118 | Plugins 119 | Schema 120 | Structure 121 | Snapshots 122 | 123 | 124 | {renderTab} 125 | 126 |
127 | ); 128 | }, [nodePicker, nodePickerAPI, tabIndex, isOpen, renderTab]); 129 | 130 | return ( 131 | 132 | 133 | 143 | {renderDockContent} 144 | 145 | 146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /src/components/json-diff.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "@compiled/react"; 3 | import JSONTree from "./json-tree"; 4 | import theme from "../theme"; 5 | 6 | const updatedStyles = css({ 7 | color: theme.main, 8 | }); 9 | const Updated: React.FC = ({ children }) => ( 10 | {children} 11 | ); 12 | 13 | const whiteStyles = css({ 14 | color: theme.text, 15 | }); 16 | const White: React.FC = ({ children }) => ( 17 | {children} 18 | ); 19 | 20 | const deletedStyles = css({ 21 | display: "inline-block", 22 | background: theme.lightYellow, 23 | color: theme.lightPink, 24 | padding: "1px 3px 2px", 25 | textIndent: 0, 26 | textDecoration: "line-through", 27 | minHeight: "1ex", 28 | }); 29 | const Deleted: React.FC = ({ children }) => ( 30 | {children} 31 | ); 32 | 33 | const addedStyles = css({ 34 | display: "inline-block", 35 | background: theme.lightYellow, 36 | color: theme.darkGreen, 37 | padding: "1px 3px 2px", 38 | textIndent: 0, 39 | minHeight: "1ex", 40 | }); 41 | const Added: React.FC = ({ children }) => ( 42 | {children} 43 | ); 44 | 45 | function postprocessValue(value: Record) { 46 | if (value && value._t === "a") { 47 | const res: Record = {}; 48 | for (const key in value) { 49 | if (key !== "_t") { 50 | if (key[0] === "_" && !value[key.substring(1)]) { 51 | res[key.substring(1)] = value[key]; 52 | } else if (value["_" + key]) { 53 | res[key] = [value["_" + key][0], value[key][0]]; 54 | } else if (!value["_" + key] && key[0] !== "_") { 55 | res[key] = value[key]; 56 | } 57 | } 58 | } 59 | 60 | return res; 61 | } 62 | return value; 63 | } 64 | 65 | function labelRenderer(raw: Array) { 66 | return raw[0]; 67 | } 68 | 69 | function stringifyAndShrink(val: null | object) { 70 | if (val === null) { 71 | return "null"; 72 | } 73 | 74 | const str = JSON.stringify(val); 75 | if (typeof str === "undefined") { 76 | return "undefined"; 77 | } 78 | 79 | return str.length > 22 ? `${str.substr(0, 15)}…${str.substr(-5)}` : str; 80 | } 81 | 82 | function getValueString(raw: string | object | null) { 83 | if (typeof raw === "string") { 84 | return raw; 85 | } 86 | return stringifyAndShrink(raw); 87 | } 88 | 89 | function replaceSpacesWithNonBreakingSpace(value: string) { 90 | return value.replace(/\s/gm, " "); 91 | } 92 | 93 | function parseTextDiff(textDiff: string) { 94 | const diffByLines = textDiff.split(/\n/gm).slice(1); 95 | 96 | return diffByLines.map((line) => { 97 | const type = line.startsWith("-") 98 | ? "delete" 99 | : line.startsWith("+") 100 | ? "add" 101 | : "raw"; 102 | 103 | return { [type]: replaceSpacesWithNonBreakingSpace(line.substr(1)) }; 104 | }); 105 | } 106 | 107 | function valueRenderer(raw: Array | string) { 108 | if (Array.isArray(raw)) { 109 | if (raw.length === 1) { 110 | return {getValueString(raw[0])}; 111 | } 112 | 113 | if (raw.length === 2) { 114 | return ( 115 | 116 | {getValueString(raw[0])} =>{" "} 117 | {getValueString(raw[1])} 118 | 119 | ); 120 | } 121 | 122 | if (raw.length === 3 && raw[1] === 0 && raw[2] === 0) { 123 | return {getValueString(raw[0])}; 124 | } 125 | 126 | if (raw.length === 3 && raw[2] === 2) { 127 | return ( 128 | 129 | " 130 | {parseTextDiff(raw[0]).map((item) => { 131 | if (item.delete) { 132 | return ( 133 | {item.delete} 134 | ); 135 | } 136 | 137 | if (item.add) { 138 | return {item.add}; 139 | } 140 | 141 | return {item.raw}; 142 | })} 143 | " 144 | 145 | ); 146 | } 147 | } 148 | 149 | return "" + raw; 150 | } 151 | 152 | export function itemsCountString(count: number) { 153 | return `${count}`; 154 | } 155 | 156 | export function getItemString( 157 | type: string, 158 | _value: unknown, 159 | defaultView: unknown, 160 | keysCount: string 161 | ) { 162 | switch (type) { 163 | case "Object": 164 | return {"{…}"}; 165 | default: 166 | return ( 167 | 168 | <> 169 | {defaultView} {keysCount} 170 | 171 | 172 | ); 173 | } 174 | } 175 | 176 | export default function JSONDiff(props: { delta: any }) { 177 | if (!props.delta) return null; 178 | 179 | return ( 180 | true} 189 | /> 190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /src/state/history.ts: -------------------------------------------------------------------------------- 1 | import { prettyPrint } from "html"; 2 | import { atom } from "jotai"; 3 | import { nanoid } from "nanoid"; 4 | import { DOMSerializer } from "prosemirror-model"; 5 | import type { EditorState, Selection, Transaction } from "prosemirror-state"; 6 | import type { JsonDiffMain } from "./json-diff-main"; 7 | import type { JsonDiffWorker } from "./json-diff-worker"; 8 | 9 | const HISTORY_SIZE = 200; 10 | 11 | export const historyAtom = atom>([]); 12 | export const historyRolledBackToAtom = atom(null); 13 | export const historyDiffsAtom = atom< 14 | Record 15 | >({}); 16 | 17 | type HistoryAction = 18 | | { type: "reset"; payload: { state: EditorState } } 19 | | { 20 | type: "update"; 21 | payload: { 22 | newState: EditorState; 23 | tr: Transaction; 24 | oldState: EditorState; 25 | diffWorker: Promise; 26 | }; 27 | }; 28 | export const historyWriteAtom = atom( 29 | null, 30 | async (get, set, action: HistoryAction) => { 31 | if (action.type === "reset") { 32 | set(historyAtom, [ 33 | { 34 | id: nanoid(), 35 | state: action.payload.state, 36 | timestamp: Date.now(), 37 | diffPending: false, 38 | diff: null, 39 | selectionContent: [], 40 | }, 41 | ]); 42 | set(historyRolledBackToAtom, null); 43 | set(historyDiffsAtom, {}); 44 | return; 45 | } 46 | 47 | if (action.type === "update") { 48 | const rolledBackTo = get(historyRolledBackToAtom); 49 | const history = get(historyAtom); 50 | 51 | // TODO: figure out why this is called 2 times 52 | if (history[0].state === action.payload.newState) { 53 | return; 54 | } 55 | 56 | const { oldState, newState, tr } = action.payload; 57 | const updatedHistory = updateEditorHistory( 58 | [...history], 59 | rolledBackTo, 60 | tr, 61 | newState 62 | ); 63 | if (!updatedHistory) { 64 | return; 65 | } 66 | set(historyAtom, updatedHistory); 67 | 68 | if (rolledBackTo !== null) { 69 | const historyDiff = get(historyDiffsAtom); 70 | set(historyRolledBackToAtom, null); 71 | const newDiffs = updatedHistory.reduce< 72 | Record 73 | >((acc, item) => { 74 | acc[item.id] = historyDiff[item.id]; 75 | return acc; 76 | }, {}); 77 | // TODO: cleanup diffs 78 | set(historyDiffsAtom, newDiffs); 79 | } 80 | 81 | const historyDiff = get(historyDiffsAtom); 82 | const [{ id }] = updatedHistory; 83 | const diffWorker = await action.payload.diffWorker; 84 | const [{ delta: diff }, { delta: selection }] = await Promise.all([ 85 | diffWorker.diff({ 86 | a: oldState.doc.toJSON(), 87 | b: newState.doc.toJSON(), 88 | id, 89 | }), 90 | diffWorker.diff({ 91 | a: buildSelection(oldState.selection), 92 | b: buildSelection(newState.selection), 93 | id, 94 | }), 95 | ]); 96 | set(historyDiffsAtom, { ...historyDiff, [id]: { diff, selection } }); 97 | } 98 | } 99 | ); 100 | 101 | export function buildSelection(selection: Selection) { 102 | return { 103 | empty: selection.empty, 104 | anchor: selection.anchor, 105 | head: selection.head, 106 | from: selection.from, 107 | to: selection.to, 108 | }; 109 | } 110 | 111 | export function createHistoryEntry(editorState: EditorState): HistoryItem { 112 | const serializer = DOMSerializer.fromSchema(editorState.schema); 113 | const selection = editorState.selection; 114 | const domFragment = serializer.serializeFragment(selection.content().content); 115 | 116 | const selectionContent: Array = []; 117 | if (domFragment) { 118 | let child = domFragment.firstChild; 119 | while (child) { 120 | selectionContent.push((child as HTMLElement).outerHTML); 121 | child = child.nextSibling; 122 | } 123 | } 124 | 125 | return { 126 | id: nanoid(), 127 | state: editorState, 128 | timestamp: Date.now(), 129 | diffPending: true, 130 | diff: undefined, 131 | selection: undefined, 132 | selectionContent: prettyPrint(selectionContent.join("\n"), { 133 | max_char: 60, 134 | indent_size: 2, 135 | }), 136 | }; 137 | } 138 | 139 | export function shrinkEditorHistory( 140 | history: Array, 141 | historyRolledBackTo: number | null 142 | ) { 143 | const startIndex = historyRolledBackTo !== null ? historyRolledBackTo : 0; 144 | return history.slice(startIndex, HISTORY_SIZE); 145 | } 146 | 147 | export function updateEditorHistory( 148 | history: Array, 149 | historyRolledBackTo: null | number, 150 | tr: Transaction, 151 | newState: EditorState 152 | ) { 153 | const skipHistory = tr.getMeta("_skip-dev-tools-history_"); 154 | 155 | if (skipHistory) return; 156 | 157 | const newHistory = shrinkEditorHistory(history, historyRolledBackTo); 158 | newHistory.unshift(createHistoryEntry(newState)); 159 | return newHistory; 160 | } 161 | 162 | export type HistoryItem = { 163 | id: string; 164 | index?: number; 165 | state: EditorState; 166 | timestamp: number; 167 | diffPending: boolean; 168 | diff: unknown; 169 | selection?: Selection; 170 | selectionContent: string | Array; 171 | }; 172 | -------------------------------------------------------------------------------- /src/components/list.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import theme from "../theme"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-function 6 | const noop = () => undefined; 7 | 8 | type ListItemProps = { 9 | isDimmed?: boolean; 10 | nested?: boolean; 11 | background?: (props: { 12 | isSelected?: boolean; 13 | isPrevious?: boolean; 14 | }) => string | undefined; 15 | isSelected?: boolean; 16 | isPrevious?: boolean; 17 | onClick?: MouseEventHandler; 18 | onDoubleClick?: MouseEventHandler; 19 | children: React.ReactNode; 20 | }; 21 | const ListItem: React.FC = (props) => { 22 | const background = props.background 23 | ? props.background(props) 24 | : props.isSelected 25 | ? theme.main40 26 | : "transparent"; 27 | return ( 28 |
73 | {props.children} 74 |
75 | ); 76 | }; 77 | 78 | type ListProps = { 79 | items: Array; 80 | isSelected?: IsSelectedHandler; 81 | isPrevious?: IsPreviousHandler; 82 | isDimmed?: isDimmedHandler; 83 | onListItemClick?: OnListItemClickHandler; 84 | onListItemDoubleClick?: OnListItemDoubleClickHandler; 85 | getKey: GetKey; 86 | title: GetTitle; 87 | groupTitle?: GetGroupTitle; 88 | customItemBackground?: (props: { 89 | isSelected?: boolean; 90 | isPrevious?: boolean; 91 | }) => string | undefined; 92 | }; 93 | 94 | function ListItemGroup( 95 | props: ListProps & { children: React.ReactNode } 96 | ) { 97 | const [collapsed, setCollapsed] = React.useState(false); 98 | const { 99 | items, 100 | groupTitle = noop, 101 | title, 102 | isSelected = noop, 103 | isPrevious = noop, 104 | isDimmed = noop, 105 | getKey = noop, 106 | onListItemClick = noop, 107 | onListItemDoubleClick = noop, 108 | customItemBackground, 109 | } = props; 110 | return ( 111 |
112 | setCollapsed(!collapsed)} 115 | isSelected={items.some(isSelected) && collapsed} 116 | isPrevious={isPrevious(items[0], 0) && collapsed} 117 | isDimmed={items.every(isDimmed)} 118 | background={customItemBackground} 119 | > 120 |
{groupTitle(items as any, 0)}
121 |
{collapsed ? "▶" : "▼"}
122 |
123 |
128 | {(items || []).map((item, index) => { 129 | return ( 130 | onListItemClick(item, index)} 138 | onDoubleClick={() => onListItemDoubleClick(item, index)} 139 | > 140 | {title(item, index)} 141 | 142 | ); 143 | })} 144 |
145 |
146 | ); 147 | } 148 | 149 | export function List(props: ListProps) { 150 | const { 151 | isSelected = noop, 152 | isPrevious = noop, 153 | isDimmed = noop, 154 | getKey = noop, 155 | onListItemClick = noop, 156 | onListItemDoubleClick = noop, 157 | } = props; 158 | return ( 159 |
160 | {(props.items || []).map((item, index) => { 161 | if (Array.isArray(item)) { 162 | return ( 163 | 164 | {(props.groupTitle || noop)(item, index)} 165 | 166 | ); 167 | } 168 | 169 | return ( 170 | onListItemClick(item, index)} 177 | onDoubleClick={() => onListItemDoubleClick(item, index)} 178 | > 179 | {props.title(item, index)} 180 | 181 | ); 182 | })} 183 |
184 | ); 185 | } 186 | 187 | type IsSelectedHandler = (item: T, index: number) => boolean | undefined; 188 | type IsPreviousHandler = (item: T, index: number) => boolean | undefined; 189 | type isDimmedHandler = (item: T, index: number) => boolean | undefined; 190 | type OnListItemClickHandler = (item: T, index: number) => void; 191 | type OnListItemDoubleClickHandler = (item: T, index: number) => void; 192 | type GetKey = (item: T) => string; 193 | type GetTitle = ( 194 | item: T, 195 | index: number 196 | ) => string | undefined | React.ReactNode; 197 | type GetGroupTitle = (item: T, index: number) => string | undefined; 198 | -------------------------------------------------------------------------------- /src/tabs/history.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@compiled/react"; 3 | import { InfoPanel } from "../components/info-panel"; 4 | import { Heading } from "../components/heading"; 5 | import { List } from "../components/list"; 6 | import JSONDiff from "../components/json-diff"; 7 | import { SplitView, SplitViewCol } from "../components/split-view"; 8 | import { Highlighter } from "../components/highlighter"; 9 | import theme from "../theme"; 10 | import { atom, useAtom, useAtomValue } from "jotai"; 11 | import { 12 | historyAtom, 13 | historyDiffsAtom, 14 | HistoryItem, 15 | historyRolledBackToAtom, 16 | } from "../state/history"; 17 | import type { Selection } from "prosemirror-state"; 18 | 19 | const Section: React.FC = ({ children }) => ( 20 |
30 | {children} 31 |
32 | ); 33 | 34 | function pad(num: number) { 35 | return ("00" + num).slice(-2); 36 | } 37 | 38 | function pad3(num: number) { 39 | return ("000" + num).slice(-3); 40 | } 41 | 42 | const formatTimestamp = (timestamp: number) => { 43 | const date = new Date(timestamp); 44 | return [ 45 | pad(date.getHours()), 46 | pad(date.getMinutes()), 47 | pad(date.getSeconds()), 48 | pad3(date.getMilliseconds()), 49 | ].join(":"); 50 | }; 51 | 52 | export function SelectionContentSection(props: { 53 | selectionContent?: string[] | string; 54 | }) { 55 | if (!props.selectionContent) return null; 56 | 57 | const content = Array.isArray(props.selectionContent) 58 | ? props.selectionContent.join("\n") 59 | : props.selectionContent; 60 | 61 | return ( 62 |
63 | Selection Content 64 | {content} 65 |
66 | ); 67 | } 68 | 69 | export function DocDiffSection(props: { diff?: unknown }) { 70 | if (!props.diff) return null; 71 | 72 | return ( 73 |
74 | Doc diff 75 | 76 |
77 | ); 78 | } 79 | 80 | export function SelectionSection(props: { selection?: Selection }) { 81 | if (!props.selection) return null; 82 | 83 | return ( 84 |
85 | Selection diff 86 | 87 |
88 | ); 89 | } 90 | 91 | const selectedHistoryItemAtom = atom(0); 92 | export default function HistoryView({ 93 | rollbackHistory, 94 | }: { 95 | rollbackHistory: (item: HistoryItem, index: number) => void; 96 | }) { 97 | const [selectedHistoryItem, setSelectedHistoryItem] = useAtom( 98 | selectedHistoryItemAtom 99 | ); 100 | const historyRolledBackTo = useAtomValue(historyRolledBackToAtom); 101 | const history = useAtomValue(historyAtom); 102 | const historyDiffs = useAtomValue(historyDiffsAtom); 103 | const prevItem = history[selectedHistoryItem + 1]; 104 | const selectedItem = history[selectedHistoryItem] ?? history[0]; 105 | const selectedDiff = historyDiffs[selectedItem.id]; 106 | const historyRolledBackToItem = 107 | historyRolledBackTo !== null ? history[historyRolledBackTo] : null; 108 | const historyList = history 109 | .reduce>>((h, item, index) => { 110 | const prev = h[h.length - 1]; 111 | 112 | item.index = index; 113 | 114 | if (!historyDiffs[item.id]?.diff) { 115 | if (!prev || !Array.isArray(prev)) { 116 | h.push([item]); 117 | } else { 118 | prev.push(item); 119 | } 120 | } else { 121 | h.push(item); 122 | } 123 | 124 | return h; 125 | }, []) 126 | .reduce>>((h, item) => { 127 | if (Array.isArray(item) && item.length === 1) { 128 | h.push(item[0]); 129 | } else { 130 | h.push(item); 131 | } 132 | return h; 133 | }, []); 134 | 135 | const isSelected = (item: HistoryItem | HistoryItem[]) => { 136 | if (Array.isArray(item)) return false; 137 | return item.timestamp === selectedItem.timestamp; 138 | }; 139 | const isPrevious = (item: HistoryItem | HistoryItem[]) => { 140 | if (Array.isArray(item)) return false; 141 | return prevItem && item.timestamp === prevItem.timestamp; 142 | }; 143 | const isDimmed = (item: HistoryItem | HistoryItem[]) => { 144 | if (Array.isArray(item)) return false; 145 | return historyRolledBackToItem 146 | ? item.timestamp > historyRolledBackToItem.timestamp 147 | : false; 148 | }; 149 | 150 | return ( 151 | 152 | 153 | > 154 | items={historyList} 155 | getKey={(item) => { 156 | if (Array.isArray(item)) { 157 | return "" + item[0].timestamp; 158 | } 159 | return "" + item.timestamp; 160 | }} 161 | title={(item) => { 162 | if (Array.isArray(item)) { 163 | return formatTimestamp(item[0].timestamp); 164 | } 165 | return formatTimestamp(item.timestamp); 166 | }} 167 | groupTitle={(item) => { 168 | if (Array.isArray(item)) { 169 | return formatTimestamp(item[0].timestamp) + ` [${item.length}]`; 170 | } 171 | return formatTimestamp(item.timestamp); 172 | }} 173 | isSelected={isSelected} 174 | isPrevious={isPrevious} 175 | isDimmed={isDimmed} 176 | customItemBackground={(props) => 177 | props.isSelected 178 | ? theme.main40 179 | : props.isPrevious 180 | ? theme.main20 181 | : "transparent" 182 | } 183 | onListItemClick={(item) => { 184 | if (Array.isArray(item)) return; 185 | setSelectedHistoryItem(item.index!); 186 | }} 187 | onListItemDoubleClick={(item) => { 188 | if (Array.isArray(item)) return; 189 | rollbackHistory(item, item.index!); 190 | }} 191 | /> 192 | 193 | 194 | 195 | 196 | 199 | {!selectedDiff && !selectedItem.selectionContent && ( 200 | Docs are equal. 201 | )} 202 | 203 | 204 | ); 205 | } 206 | -------------------------------------------------------------------------------- /example/editor.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | position: relative; 3 | } 4 | 5 | .ProseMirror { 6 | padding: 6px 0; 7 | 8 | white-space: pre-wrap; 9 | word-wrap: break-word; 10 | 11 | border-bottom: 1px solid silver; 12 | } 13 | 14 | .ProseMirror:focus { 15 | outline: none; 16 | } 17 | 18 | .ProseMirror p { 19 | margin: 0; 20 | } 21 | .ProseMirror p + .ProseMirror p { 22 | margin: 0 0 1em; 23 | } 24 | 25 | .ProseMirror ul, 26 | .ProseMirror ol { 27 | padding-left: 30px; 28 | 29 | cursor: default; 30 | } 31 | 32 | .ProseMirror blockquote { 33 | margin-right: 0; 34 | margin-left: 0; 35 | padding-left: 1em; 36 | 37 | border-left: 3px solid #eee; 38 | } 39 | 40 | .ProseMirror pre { 41 | white-space: pre-wrap; 42 | } 43 | 44 | .ProseMirror li { 45 | position: relative; 46 | 47 | pointer-events: none; /* Don't do weird stuff with marker clicks */ 48 | } 49 | .ProseMirror li > * { 50 | pointer-events: auto; 51 | } 52 | 53 | .ProseMirror-hideselection *::selection { 54 | background: transparent; 55 | } 56 | .ProseMirror-hideselection *::-moz-selection { 57 | background: transparent; 58 | } 59 | 60 | .ProseMirror-selectednode { 61 | outline: 2px solid #8cf; 62 | } 63 | 64 | /* Make sure li selections wrap around markers */ 65 | 66 | li.ProseMirror-selectednode { 67 | outline: none; 68 | } 69 | 70 | li.ProseMirror-selectednode:after { 71 | position: absolute; 72 | top: -2px; 73 | right: -2px; 74 | bottom: -2px; 75 | left: -32px; 76 | 77 | content: ""; 78 | pointer-events: none; 79 | 80 | border: 2px solid #8cf; 81 | } 82 | .ProseMirror-textblock-dropdown { 83 | min-width: 3em; 84 | } 85 | 86 | .ProseMirror-menu { 87 | line-height: 1; 88 | 89 | margin: 0 -4px; 90 | } 91 | 92 | .ProseMirror-tooltip .ProseMirror-menu { 93 | width: -webkit-fit-content; 94 | width: fit-content; 95 | 96 | white-space: pre; 97 | } 98 | 99 | .ProseMirror-menuitem { 100 | display: inline-block; 101 | 102 | margin-right: 3px; 103 | } 104 | 105 | .ProseMirror-menuseparator { 106 | margin-right: 3px; 107 | 108 | border-right: 1px solid #ddd; 109 | } 110 | 111 | .ProseMirror-menu-dropdown, 112 | .ProseMirror-menu-dropdown-menu { 113 | font-size: 90%; 114 | 115 | white-space: nowrap; 116 | } 117 | 118 | .ProseMirror-menu-dropdown { 119 | position: relative; 120 | 121 | padding-right: 15px; 122 | 123 | cursor: pointer; 124 | vertical-align: 1px; 125 | } 126 | 127 | .ProseMirror-menu-dropdown-wrap { 128 | position: relative; 129 | 130 | display: inline-block; 131 | 132 | padding: 1px 0 1px 4px; 133 | } 134 | 135 | .ProseMirror-menu-dropdown:after { 136 | position: absolute; 137 | top: calc(50% - 2px); 138 | right: 4px; 139 | 140 | content: ""; 141 | 142 | opacity: 0.6; 143 | border-top: 4px solid currentColor; 144 | border-right: 4px solid transparent; 145 | border-left: 4px solid transparent; 146 | } 147 | 148 | .ProseMirror-menu-dropdown-menu, 149 | .ProseMirror-menu-submenu { 150 | position: absolute; 151 | 152 | padding: 2px; 153 | 154 | color: #666; 155 | border: 1px solid #aaa; 156 | background: white; 157 | } 158 | 159 | .ProseMirror-menu-dropdown-menu { 160 | z-index: 15; 161 | 162 | min-width: 6em; 163 | } 164 | 165 | .ProseMirror-menu-dropdown-item { 166 | padding: 2px 8px 2px 4px; 167 | 168 | cursor: pointer; 169 | } 170 | 171 | .ProseMirror-menu-dropdown-item:hover { 172 | background: #f2f2f2; 173 | } 174 | 175 | .ProseMirror-menu-submenu-wrap { 176 | position: relative; 177 | 178 | margin-right: -4px; 179 | } 180 | 181 | .ProseMirror-menu-submenu-label:after { 182 | position: absolute; 183 | top: calc(50% - 4px); 184 | right: 4px; 185 | 186 | content: ""; 187 | 188 | opacity: 0.6; 189 | border-top: 4px solid transparent; 190 | border-bottom: 4px solid transparent; 191 | border-left: 4px solid currentColor; 192 | } 193 | 194 | .ProseMirror-menu-submenu { 195 | top: -3px; 196 | left: 100%; 197 | 198 | display: none; 199 | 200 | min-width: 4em; 201 | } 202 | 203 | .ProseMirror-menu-active { 204 | border-radius: 4px; 205 | background: #eee; 206 | } 207 | 208 | .ProseMirror-menu-active { 209 | border-radius: 4px; 210 | background: #eee; 211 | } 212 | 213 | .ProseMirror-menu-disabled { 214 | opacity: 0.3; 215 | } 216 | 217 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, 218 | .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { 219 | display: block; 220 | } 221 | 222 | .ProseMirror-menubar { 223 | position: relative; 224 | z-index: 10; 225 | top: 0; 226 | right: 0; 227 | left: 0; 228 | 229 | overflow: visible; 230 | 231 | -moz-box-sizing: border-box; 232 | box-sizing: border-box; 233 | min-height: 1em !important; 234 | padding: 1px 6px; 235 | 236 | color: #666; 237 | border-bottom: 1px solid silver; 238 | border-top-left-radius: inherit; 239 | border-top-right-radius: inherit; 240 | background: white; 241 | } 242 | 243 | .ProseMirror-icon { 244 | line-height: 0.8; 245 | 246 | display: inline-block; 247 | 248 | padding: 2px 8px; 249 | 250 | cursor: pointer; 251 | vertical-align: -2px; /* Compensate for padding */ 252 | } 253 | 254 | .ProseMirror-icon svg { 255 | height: 1em; 256 | 257 | fill: currentColor; 258 | } 259 | 260 | .ProseMirror-icon span { 261 | vertical-align: text-top; 262 | } 263 | /* Add space around the hr to make clicking it easier */ 264 | 265 | .ProseMirror-example-setup-style hr { 266 | position: relative; 267 | 268 | height: 6px; 269 | 270 | border: none; 271 | } 272 | 273 | .ProseMirror-example-setup-style hr:after { 274 | position: absolute; 275 | top: 2px; 276 | right: 10px; 277 | left: 10px; 278 | 279 | content: ""; 280 | 281 | border-top: 2px solid silver; 282 | } 283 | 284 | .ProseMirror-example-setup-style img { 285 | cursor: default; 286 | } 287 | 288 | .ProseMirror-example-setup-style table { 289 | border-collapse: collapse; 290 | } 291 | 292 | .ProseMirror-example-setup-style td { 293 | padding: 3px 5px; 294 | 295 | vertical-align: top; 296 | 297 | border: 1px solid #ddd; 298 | } 299 | 300 | .ProseMirror-prompt { 301 | position: fixed; 302 | z-index: 11; 303 | 304 | padding: 5px 10px 5px 15px; 305 | 306 | border: 1px solid silver; 307 | border-radius: 3px; 308 | background: white; 309 | box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2); 310 | } 311 | 312 | .ProseMirror-prompt h5 { 313 | font-size: 100%; 314 | font-weight: normal; 315 | 316 | margin: 0; 317 | 318 | color: #444; 319 | } 320 | 321 | .ProseMirror-prompt input[type="text"], 322 | .ProseMirror-prompt textarea { 323 | border: none; 324 | outline: none; 325 | background: #eee; 326 | } 327 | 328 | .ProseMirror-prompt input[type="text"] { 329 | padding: 0 4px; 330 | } 331 | 332 | .ProseMirror-prompt-close { 333 | position: absolute; 334 | top: 1px; 335 | left: 2px; 336 | padding: 0; 337 | 338 | color: #666; 339 | border: none; 340 | background: transparent; 341 | } 342 | 343 | .ProseMirror-prompt-close:after { 344 | font-size: 12px; 345 | 346 | content: "✕"; 347 | } 348 | 349 | .ProseMirror-invalid { 350 | position: absolute; 351 | 352 | min-width: 10em; 353 | padding: 5px 10px; 354 | 355 | border: 1px solid #cc7; 356 | border-radius: 4px; 357 | background: #ffc; 358 | } 359 | 360 | .ProseMirror-prompt-buttons { 361 | display: none; 362 | 363 | margin-top: 5px; 364 | } 365 | -------------------------------------------------------------------------------- /src/tabs/state.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import { atom, useAtom, useAtomValue } from "jotai"; 4 | import { 5 | expandedStateFormatSelection, 6 | collapsedStateFormatSelection, 7 | } from "../utils/format-selection-object"; 8 | import { SplitView, SplitViewCol } from "../components/split-view"; 9 | import JSONTree from "../components/json-tree"; 10 | import { 11 | Heading, 12 | HeadingWithButton, 13 | HeadingButton, 14 | } from "../components/heading"; 15 | import theme from "../theme"; 16 | import { activeMarksAtom } from "../state/active-marks"; 17 | import { expandPathAtom } from "../state/expand-path"; 18 | import { editorStateAtom } from "../state/editor-state"; 19 | import { logNodeFromJSON } from "../utils/log-node-from-json"; 20 | import type { JSONMark, JSONNode } from "../types/prosemirror"; 21 | 22 | const JSONTreeWrapper: React.FC = ({ children }) => ( 23 |
29 | {children} 30 |
31 | ); 32 | 33 | const Section: React.FC = ({ children }) => ( 34 |
44 | {children} 45 |
46 | ); 47 | 48 | const Group: React.FC = ({ children }) => ( 49 |
{children}
50 | ); 51 | 52 | const GroupRow: React.FC = ({ children }) => ( 53 |
58 | {children} 59 |
60 | ); 61 | 62 | const Key: React.FC = ({ children }) => ( 63 |
70 | {children} 71 |
72 | ); 73 | 74 | const ValueNum: React.FC = ({ children }) => ( 75 |
80 | {children} 81 |
82 | ); 83 | 84 | const LogNodeButton: React.FC<{ 85 | onClick: MouseEventHandler; 86 | children: React.ReactNode; 87 | }> = ({ children, onClick }) => ( 88 | 111 | ); 112 | 113 | export function getItemString( 114 | doc: JSONNode, 115 | action: (args: { doc: JSONNode; node: JSONNode }) => void 116 | ) { 117 | return function getItemStringWithBindedDoc( 118 | type: string, 119 | value: JSONNode, 120 | defaultView: unknown, 121 | keysCount: string 122 | ) { 123 | const logButton = ( 124 | { 126 | e.preventDefault(); 127 | e.stopPropagation(); 128 | action({ doc, node: value }); 129 | }} 130 | > 131 | log 132 | 133 | ); 134 | 135 | if (type === "Object" && value.type) { 136 | return ( 137 | 138 | {"{} "} 139 | {value.type} {logButton} 140 | 141 | ); 142 | } 143 | 144 | return ( 145 | 146 | <> 147 | {defaultView} {keysCount} {logButton} 148 | 149 | 150 | ); 151 | }; 152 | } 153 | 154 | function getItemStringForMark( 155 | type: string, 156 | value: JSONMark, 157 | defaultView: unknown, 158 | keysCount: string 159 | ) { 160 | if (type === "Object" && value.type) { 161 | return ( 162 | 163 | {"{} "} 164 | {value.type} 165 | 166 | ); 167 | } 168 | 169 | return ( 170 | 171 | <> 172 | {defaultView} {keysCount} 173 | 174 | 175 | ); 176 | } 177 | 178 | export function shouldExpandNode( 179 | expandPath: Array, 180 | nodePath: Array 181 | ) { 182 | const path = ([] as Array).concat(nodePath).reverse(); 183 | 184 | if (!expandPath) return false; 185 | 186 | // Expand attrs if node has them. 187 | // expandPath.push("attrs"); 188 | 189 | if (path.length > expandPath.length) return false; 190 | if (path.join(".") === expandPath.join(".")) return true; 191 | if (path.every((el, idx) => el === expandPath[idx])) return true; 192 | return false; 193 | } 194 | 195 | const selectionAtom = atom(false); 196 | 197 | export default function StateTab() { 198 | const [selectionExpanded, setExpanded] = useAtom(selectionAtom); 199 | const activeMarks = useAtomValue(activeMarksAtom); 200 | const expandPath = useAtomValue(expandPathAtom); 201 | const state = useAtomValue(editorStateAtom); 202 | const doc = state?.doc.toJSON(); 203 | 204 | if (!state) return null; 205 | 206 | const logNode = logNodeFromJSON(state); 207 | 208 | return ( 209 | 210 | 211 | 212 | Current Doc 213 | console.log(state)}> 214 | Log State 215 | 216 | 217 | 222 | shouldExpandNode(expandPath, nodePath) 223 | } 224 | /> 225 | 226 | 227 |
228 | 229 | Selection 230 | setExpanded(!selectionExpanded)}> 231 | {selectionExpanded ? "▼" : "▶"} 232 | 233 | 234 | 235 | 243 | 244 |
245 |
246 | Active Marks 247 | 248 | {activeMarks.length ? ( 249 | 254 | ) : ( 255 | 256 | 257 | no active marks 258 | 259 | 260 | )} 261 | 262 |
263 |
264 | Document Stats 265 | 266 | 267 | nodeSize: 268 | {state.doc.nodeSize} 269 | 270 | 271 | childCount: 272 | {state.doc.childCount} 273 | 274 | 275 |
276 |
277 |
278 | ); 279 | } 280 | -------------------------------------------------------------------------------- /src/tabs/structure.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import "@compiled/react"; 3 | import { atom, useAtom, useAtomValue } from "jotai"; 4 | import type { Node } from "prosemirror-model"; 5 | import theme from "../theme"; 6 | import { SplitView, SplitViewCol } from "../components/split-view"; 7 | import JSONTree from "../components/json-tree"; 8 | import { 9 | Heading, 10 | HeadingWithButton, 11 | HeadingButton, 12 | } from "../components/heading"; 13 | import { nodeColorsAtom } from "../state/node-colors"; 14 | import { editorStateAtom } from "../state/editor-state"; 15 | import { ExtendedFragment } from "../types/prosemirror"; 16 | 17 | const GraphWrapper: React.FC = ({ children }) => ( 18 |
23 | {children} 24 |
25 | ); 26 | 27 | const BlockNodeContentView: React.FC = ({ 28 | children, 29 | }) => ( 30 |
38 | {children} 39 |
40 | ); 41 | 42 | const BlockNodeContentViewWithInline: React.FC = ({ 43 | children, 44 | }) => ( 45 |
56 | {children} 57 |
58 | ); 59 | 60 | const BlockNodeView: React.FC<{ 61 | bg: string; 62 | onClick?: MouseEventHandler; 63 | children: React.ReactNode; 64 | }> = ({ children, bg, onClick }) => ( 65 |
79 | {children} 80 |
81 | ); 82 | 83 | const Side: React.FC<{ tooltip: string; children: React.ReactNode }> = ({ 84 | tooltip, 85 | children, 86 | }) => ( 87 |
94 | {children} 95 |
96 | ); 97 | 98 | const StartSide: React.FC = ({ children }) => ( 99 |
105 | {children} 106 |
107 | ); 108 | 109 | const Bar: React.FC = ({ children }) => ( 110 |
115 | {children} 116 |
117 | ); 118 | 119 | const Center: React.FC = ({ children }) => ( 120 |
127 | {children} 128 |
129 | ); 130 | 131 | const InlineNodeView: React.FC<{ 132 | bg: string; 133 | onClick?: MouseEventHandler; 134 | children: React.ReactNode; 135 | }> = ({ children, bg, onClick }) => ( 136 |
150 | {children} 151 |
152 | ); 153 | 154 | export function BlockNodeContent(props: { 155 | content: Node["content"]; 156 | startPos: number; 157 | colors: Record; 158 | onNodeSelected: (data: { node: Node }) => void; 159 | }) { 160 | const fragment = props.content as unknown as ExtendedFragment; 161 | if (!fragment || !fragment.content || !fragment.content.length) { 162 | return null; 163 | } 164 | 165 | const content = fragment.content; 166 | 167 | if (content[0].isBlock) { 168 | let startPos = props.startPos + 1; 169 | return ( 170 | 171 | {content.map((childNode: Node, index: number) => { 172 | const pos = startPos; 173 | startPos += childNode.nodeSize; 174 | return ( 175 | 182 | ); 183 | })} 184 | 185 | ); 186 | } 187 | 188 | let startPos = props.startPos; 189 | return ( 190 | 191 | {content.map((childNode: Node, index: number) => { 192 | const pos = startPos; 193 | startPos += childNode.nodeSize; 194 | return ( 195 | 203 | ); 204 | })} 205 | 206 | ); 207 | } 208 | 209 | export function BlockNode(props: { 210 | colors: Record; 211 | node: Node; 212 | startPos: number; 213 | onNodeSelected: (data: { node: Node }) => void; 214 | }) { 215 | const { colors, node, startPos } = props; 216 | const color = colors[node.type.name]; 217 | const marks = getMarksText(node); 218 | return ( 219 |
220 | props.onNodeSelected({ node })}> 221 | {startPos > 0 && ( 222 | 227 | {startPos - 1} 228 | 229 | )} 230 | 231 | {node.type.name} {marks} 232 | 233 | 236 | {startPos} 237 | 238 | 239 | 244 | {startPos + node.nodeSize - 1} 245 | 246 | 247 | 253 |
254 | ); 255 | } 256 | 257 | export function InlineNode(props: { 258 | node: Node; 259 | bg: string; 260 | startPos: number; 261 | index: number; 262 | onNodeSelected: (data: { node: Node }) => void; 263 | }) { 264 | const { node, bg, startPos, index } = props; 265 | const marks = getMarksText(node); 266 | return ( 267 | props.onNodeSelected({ node })} bg={bg}> 268 | {index === 0 ? ( 269 | 272 | {startPos} 273 | 274 | ) : null} 275 |
276 | {node.type.name} {marks} 277 |
278 | 279 | 284 | {startPos + node.nodeSize} 285 | 286 |
287 | ); 288 | } 289 | 290 | const structureTabSelectedNode = atom(null); 291 | 292 | export default function GraphTab() { 293 | const [selectedNode, setSelectedNode] = useAtom(structureTabSelectedNode); 294 | const handleNodeSelect = React.useCallback( 295 | ({ node }: { node: Node }) => setSelectedNode(node), 296 | [] 297 | ); 298 | const nodeColors = useAtomValue(nodeColorsAtom); 299 | const state = useAtomValue(editorStateAtom); 300 | if (!state) return null; 301 | 302 | const selected = selectedNode ? selectedNode : state.doc; 303 | 304 | return ( 305 | 306 | 307 | Current Doc 308 | 309 | 315 | 316 | 317 | 318 | 319 | Node Info 320 | console.log(selected)}> 321 | Log Node 322 | 323 | 324 | (selected.type.name !== "doc" ? true : false)} 328 | /> 329 | 330 | 331 | ); 332 | } 333 | 334 | function getMarksText(node: Node) { 335 | return node.marks.length === 1 336 | ? ` - [${node.marks[0].type.name}]` 337 | : node.marks.length > 1 338 | ? ` - [${node.marks.length} marks]` 339 | : ""; 340 | } 341 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "preserve" /* Specify what JSX code is generated. */, 17 | // "jsx": "react-jsx", 18 | "jsxImportSource": "@compiled/react", 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "commonjs" /* Specify what module code is generated. */, 31 | // "rootDir": "./", /* Specify the root folder within your source files. */ 32 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "resolveJsonModule": true, /* Enable importing .json files. */ 41 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 45 | "checkJs": true /* Enable error reporting in type-checked JavaScript files. */, 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 47 | 48 | /* Emit */ 49 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 50 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 54 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 55 | // "removeComments": true, /* Disable emitting comments. */ 56 | // "noEmit": true, /* Disable emitting files from a compilation. */ 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 68 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 77 | "preserveSymlinks": true /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */, 78 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 79 | 80 | /* Type Checking */ 81 | "strict": true /* Enable all strict type-checking options. */, 82 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 83 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 84 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 86 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 87 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 88 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 89 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 91 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 92 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 96 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 98 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true /* Skip type checking .d.ts files that are included with TypeScript. */, 103 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 104 | }, 105 | "include": [ 106 | "./src/**/*.ts", 107 | "./src/**/*.tsx", 108 | "./src/**/*.js", 109 | "./src/**/*.jsx" 110 | ], 111 | "exclude": ["./node_modules/**/*"] 112 | } 113 | --------------------------------------------------------------------------------