├── bunfig.toml ├── .prettierrc ├── .gitignore ├── src ├── index.html ├── index.css ├── index.tsx ├── server.ts └── components │ ├── App.tsx │ ├── Basic.tsx │ ├── Focus.tsx │ ├── Keyboard.tsx │ ├── BlockSelection.tsx │ ├── Autocomplete.tsx │ ├── BlockSelectionPlugin.tsx │ ├── Architecture.tsx │ └── Property.tsx ├── README.md ├── tsconfig.json ├── package.json └── bun.lock /bunfig.toml: -------------------------------------------------------------------------------- 1 | [serve.static] 2 | env = "BUN_PUBLIC_*" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "useTabs": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .awcache 2 | .DS_Store 3 | node_modules 4 | *.log 5 | dist 6 | package-lock.json 7 | build -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Prosemirror Examples 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProseMirror Examples [[Demo](https://ccorcos.github.io/prosemirror-examples)] 2 | 3 | A set of prosemirror plugin examples: 4 | 5 | - autocomplete. 6 | - inline tokens with inner content. 7 | 8 | ## TODO 9 | 10 | - Fix @ trigger logic. 11 | - serialize document to list of objects with fractional indexing 12 | - hierarchical document with tab/untab 13 | - infinite loading document 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "jsx": "react-jsx", 7 | "allowJs": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/*": ["./src/*"] 14 | } 15 | }, 16 | "exclude": ["dist", "node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .custom-selection { 2 | position: relative; 3 | } 4 | 5 | img.custom-selection { 6 | position: relative; 7 | outline: 0.4rem solid rgba(89, 87, 214, 0.2); 8 | } 9 | 10 | .custom-selection:after { 11 | content: ""; 12 | position: absolute; 13 | left: -0.4rem; 14 | right: -0.4rem; 15 | top: -0.4rem; 16 | bottom: -0.4rem; 17 | background: rgba(89, 87, 214, 0.2); 18 | pointer-events: none; 19 | } 20 | 21 | li.custom-selection:after { 22 | left: -2rem; 23 | } 24 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the entry point for the React app, it sets up the root 3 | * element and renders the App component to the DOM. 4 | * 5 | * It is included in `src/index.html`. 6 | */ 7 | 8 | import { createRoot } from "react-dom/client" 9 | import { App } from "./components/App" 10 | 11 | const elem = document.getElementById("root")! 12 | const app = 13 | 14 | if (import.meta.hot) { 15 | // With hot module reloading, `import.meta.hot.data` is persisted. 16 | const root = (import.meta.hot.data.root ??= createRoot(elem)) 17 | root.render(app) 18 | } else { 19 | // The hot module reloading API is not available in production. 20 | createRoot(elem).render(app) 21 | } 22 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "bun" 2 | import index from "./index.html" 3 | 4 | const server = serve({ 5 | routes: { 6 | // Serve index.html for all unmatched routes. 7 | "/*": index, 8 | 9 | "/api/hello": { 10 | async GET(req) { 11 | return Response.json({ 12 | message: "Hello, world!", 13 | method: "GET", 14 | }) 15 | }, 16 | async PUT(req) { 17 | return Response.json({ 18 | message: "Hello, world!", 19 | method: "PUT", 20 | }) 21 | }, 22 | }, 23 | 24 | "/api/hello/:name": async (req) => { 25 | const name = req.params.name 26 | return Response.json({ 27 | message: `Hello, ${name}!`, 28 | }) 29 | }, 30 | }, 31 | 32 | development: process.env.NODE_ENV !== "production" && { 33 | // Enable browser hot reloading in development 34 | hmr: true, 35 | 36 | // Echo console logs from the browser to the server 37 | console: true, 38 | }, 39 | }) 40 | 41 | console.log(`🚀 Server running at ${server.url}`) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-examples", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "main": "src/server.ts", 6 | "module": "src/server.ts", 7 | "scripts": { 8 | "dev": "bun --hot src/server.ts", 9 | "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", 10 | "start": "NODE_ENV=production bun src/server.ts" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0", 15 | "glamor": "^2.20.40", 16 | "lodash": "^4.17.21", 17 | "prosemirror-commands": "^1.7.1", 18 | "prosemirror-dropcursor": "^1.8.2", 19 | "prosemirror-example-setup": "^1.2.3", 20 | "prosemirror-gapcursor": "^1.3.2", 21 | "prosemirror-history": "^1.4.1", 22 | "prosemirror-inputrules": "^1.5.0", 23 | "prosemirror-keymap": "^1.2.3", 24 | "prosemirror-mentions": "^1.0.2", 25 | "prosemirror-model": "^1.25.1", 26 | "prosemirror-schema-basic": "^1.2.4", 27 | "prosemirror-schema-list": "^1.5.1", 28 | "prosemirror-state": "^1.4.3", 29 | "prosemirror-transform": "^1.10.4", 30 | "prosemirror-view": "^1.39.3", 31 | "react-router-dom": "^7.6.0" 32 | }, 33 | "devDependencies": { 34 | "@types/prosemirror-commands": "^1.3.0", 35 | "@types/prosemirror-dropcursor": "^1.5.0", 36 | "@types/prosemirror-gapcursor": "^1.3.0", 37 | "@types/prosemirror-history": "^1.3.0", 38 | "@types/prosemirror-inputrules": "^1.2.0", 39 | "@types/prosemirror-keymap": "^1.2.0", 40 | "@types/prosemirror-model": "^1.17.0", 41 | "@types/prosemirror-schema-basic": "^1.2.0", 42 | "@types/prosemirror-state": "^1.4.0", 43 | "@types/prosemirror-transform": "^1.5.0", 44 | "@types/prosemirror-view": "^1.24.0", 45 | "@types/lodash": "^4.17.17", 46 | "@types/bun": "latest", 47 | "@types/react": "^19.1.5", 48 | "@types/react-dom": "^19.1.5", 49 | "typescript": "^5.8.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import "prosemirror-view/style/prosemirror.css" 2 | import { Link, Route, HashRouter as Router, Routes } from "react-router-dom" 3 | import "../index.css" 4 | import { Architecture } from "./Architecture" 5 | import { Editor as Autocomplete } from "./Autocomplete" 6 | import { Basic } from "./Basic" 7 | import { BlockSelection } from "./BlockSelection" 8 | import { BlockSelectionPlugin } from "./BlockSelectionPlugin" 9 | import { Focus } from "./Focus" 10 | import { Editor as Property } from "./Property" 11 | 12 | export function App() { 13 | return ( 14 | 15 |
16 |
17 |
18 | Examples: 19 |
20 |
21 | Autocomplete 22 |
23 |
24 | Property 25 |
26 |
27 | Focus 28 |
29 |
30 | Block Selection 31 |
32 |
33 | Block Selection Plugin 34 |
35 |
36 | Architecture 37 |
38 |
39 | Basic 40 |
41 |
42 | 43 | 44 | } /> 45 | } /> 46 | } /> 47 | } /> 48 | } /> 49 | } 52 | /> 53 | } /> 54 | 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Basic.tsx: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands" 2 | import { keymap } from "prosemirror-keymap" 3 | import { Schema } from "prosemirror-model" 4 | import { EditorState, TextSelection } from "prosemirror-state" 5 | import { EditorView } from "prosemirror-view" 6 | import { useLayoutEffect, useMemo, useRef } from "react" 7 | 8 | const schema = new Schema({ 9 | nodes: { 10 | doc: { content: "block+" }, 11 | paragraph: { 12 | group: "block", 13 | content: "inline*", 14 | parseDOM: [{ tag: "p" }], 15 | toDOM: () => ["p", 0], 16 | }, 17 | text: { group: "inline", inline: true }, 18 | }, 19 | marks: { 20 | bold: { 21 | parseDOM: [{ tag: "strong" }], 22 | toDOM: () => ["strong", 0], 23 | }, 24 | }, 25 | }) 26 | 27 | export type EditorSchema = typeof schema 28 | 29 | type SchemaNodeType = T extends Schema ? N : never 30 | type SchemaMarkType = T extends Schema ? M : never 31 | 32 | export type EditorNodeType = SchemaNodeType 33 | export type EditorMarkType = SchemaMarkType 34 | 35 | const initialDoc = schema.node("doc", null, [ 36 | schema.node("paragraph", null, [ 37 | schema.text("Hello "), 38 | schema.text("world", [schema.marks.bold.create()]), 39 | ]), 40 | schema.node("paragraph", null), 41 | ]) 42 | 43 | export function Basic() { 44 | const initialState = useMemo(() => { 45 | const doc = initialDoc 46 | const state = EditorState.create({ 47 | schema: schema, 48 | doc: doc, 49 | selection: TextSelection.create(doc, 1), 50 | plugins: [keymap({ "Mod-b": toggleMark(schema.marks.bold) })], 51 | }) 52 | return state 53 | }, []) 54 | 55 | const divRef = useRef(null) 56 | const viewRef = useRef(null) 57 | 58 | // Mount the view. 59 | useLayoutEffect(() => { 60 | const node = divRef.current 61 | if (!node) throw new Error("Editor did not render!") 62 | const view = new EditorView( 63 | { mount: node }, 64 | { 65 | state: initialState, 66 | attributes: { style: "-webkit-font-smoothing: auto" }, 67 | dispatchTransaction(transaction) { 68 | const newState = view.state.apply(transaction) 69 | view.updateState(newState) 70 | }, 71 | } 72 | ) 73 | viewRef.current = view 74 | }, []) 75 | 76 | const selectAll = () => { 77 | const view = viewRef.current 78 | if (!view) return 79 | view.dispatch( 80 | view.state.tr.setSelection( 81 | TextSelection.create(view.state.doc, 0, view.state.doc.content.size) 82 | ) 83 | ) 84 | view.focus() 85 | } 86 | 87 | return ( 88 |
89 | 90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/components/Focus.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Autocomplete example. 4 | 5 | Resources: 6 | https://discuss.prosemirror.net/t/how-to-update-plugin-state-from-handlekeydown-props/3420 7 | https://discuss.prosemirror.net/t/how-to-get-a-selection-rect/3430 8 | 9 | */ 10 | 11 | import { toggleMark } from "prosemirror-commands" 12 | import { history, redo, undo } from "prosemirror-history" 13 | import { keymap } from "prosemirror-keymap" 14 | import { MarkSpec, NodeSpec, Schema } from "prosemirror-model" 15 | import { EditorState, TextSelection } from "prosemirror-state" 16 | import { EditorView } from "prosemirror-view" 17 | import { useLayoutEffect, useRef } from "react" 18 | 19 | const doc: NodeSpec = { 20 | content: "block+", 21 | } 22 | 23 | const paragraph: NodeSpec = { 24 | content: "inline*", 25 | group: "block", 26 | parseDOM: [{ tag: "p" }], 27 | toDOM() { 28 | return [ 29 | "p", 30 | { style: "max-width: 40rem; margin: 0px auto; padding: 3px 2px;" }, 31 | 0, 32 | ] 33 | }, 34 | } 35 | 36 | const text: NodeSpec = { 37 | group: "inline", 38 | } 39 | 40 | const bold: MarkSpec = { 41 | parseDOM: [{ tag: "strong" }], 42 | toDOM() { 43 | return ["strong", 0] 44 | }, 45 | } 46 | 47 | const nodes = { 48 | doc, 49 | paragraph, 50 | text, 51 | } 52 | const marks = { bold } 53 | 54 | const schema = new Schema({ nodes, marks }) 55 | export type EditorSchema = typeof schema 56 | export type EditorNodeType = keyof typeof nodes 57 | export type EditorMarkType = keyof typeof marks 58 | 59 | type NodeJSON = { 60 | type: EditorNodeType 61 | content?: Array 62 | attrs?: Record 63 | marks?: Array<{ type: "bold"; attrs?: Record }> 64 | text?: string 65 | } 66 | 67 | const initialDocJson: NodeJSON = { 68 | type: "doc", 69 | content: [ 70 | { 71 | type: "paragraph", 72 | content: [{ type: "text", text: "Initial focus should be here ->" }], 73 | }, 74 | { 75 | type: "paragraph", 76 | content: [], 77 | }, 78 | { 79 | type: "paragraph", 80 | content: [{ type: "text", text: "Third paragraph." }], 81 | }, 82 | ], 83 | } 84 | 85 | export function Focus() { 86 | const ref = useRef(null) 87 | 88 | useLayoutEffect(() => { 89 | const node = ref.current 90 | if (!node) throw new Error("Editor did not render!") 91 | 92 | const doc = schema.nodeFromJSON(initialDocJson) 93 | 94 | console.log(doc.children) 95 | const state = EditorState.create({ 96 | selection: TextSelection.create(doc, doc.child(0).nodeSize - 1), 97 | schema: schema, 98 | doc: doc, 99 | plugins: [ 100 | history(), 101 | keymap({ "Mod-z": undo, "Mod-y": redo }), 102 | keymap({ "Mod-b": toggleMark(schema.marks.bold) }), 103 | ], 104 | }) 105 | 106 | const view = new EditorView( 107 | { mount: node }, 108 | { 109 | state, 110 | attributes: { 111 | style: [ 112 | "outline: 0px solid transparent", 113 | "line-height: 1.5", 114 | "-webkit-font-smoothing: auto", 115 | "padding: 2em", 116 | ].join(";"), 117 | }, 118 | dispatchTransaction(transaction) { 119 | view.updateState(view.state.apply(transaction)) 120 | }, 121 | } 122 | ) 123 | 124 | view.focus() 125 | ;(window as any)["editor"] = { view } 126 | }, []) 127 | 128 | return
129 | } 130 | -------------------------------------------------------------------------------- /src/components/Keyboard.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react" 2 | import { base, keyName } from "w3c-keyname" 3 | 4 | type KeyboardEventHandler = (event: KeyboardEvent) => boolean 5 | type KeyboardBindings = Record 6 | 7 | // This logic is ripped out of prosemirror-keymap 8 | 9 | const mac = 10 | typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false 11 | 12 | function normalizeKeyName(name: string): string { 13 | let parts = name.split(/-(?!$)/), 14 | result = parts[parts.length - 1] 15 | if (result == "Space") result = " " 16 | let alt, ctrl, shift, meta 17 | for (let i = 0; i < parts.length - 1; i++) { 18 | let mod = parts[i] 19 | if (/^(cmd|meta|m)$/i.test(mod)) meta = true 20 | else if (/^a(lt)?$/i.test(mod)) alt = true 21 | else if (/^(c|ctrl|control)$/i.test(mod)) ctrl = true 22 | else if (/^s(hift)?$/i.test(mod)) shift = true 23 | else if (/^mod$/i.test(mod)) { 24 | if (mac) meta = true 25 | else ctrl = true 26 | } else throw new Error("Unrecognized modifier name: " + mod) 27 | } 28 | if (alt) result = "Alt-" + result 29 | if (ctrl) result = "Ctrl-" + result 30 | if (meta) result = "Meta-" + result 31 | if (shift) result = "Shift-" + result 32 | return result 33 | } 34 | 35 | function normalize(map: Record): Record { 36 | let copy = Object.create(null) 37 | for (let prop in map) copy[normalizeKeyName(prop)] = map[prop] 38 | return copy 39 | } 40 | 41 | function modifiers(name: string, event: KeyboardEvent, shift: boolean) { 42 | if (event.altKey) name = "Alt-" + name 43 | if (event.ctrlKey) name = "Ctrl-" + name 44 | if (event.metaKey) name = "Meta-" + name 45 | if (shift !== false && event.shiftKey) name = "Shift-" + name 46 | return name 47 | } 48 | 49 | // Key names may be strings like `"Shift-Ctrl-Enter"`—a key 50 | // identifier prefixed with zero or more modifiers. Key identifiers 51 | // are based on the strings that can appear in 52 | // [`KeyEvent.key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). 53 | // Use lowercase letters to refer to letter keys (or uppercase letters 54 | // if you want shift to be held). You may use `"Space"` as an alias 55 | // for the `" "` name. 56 | // 57 | // Modifiers can be given in any order. `Shift-` (or `s-`), `Alt-` (or 58 | // `a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or 59 | // `Meta-`) are recognized. For characters that are created by holding 60 | // shift, the `Shift-` prefix is implied, and should not be added 61 | // explicitly. 62 | // 63 | // You can use `Mod-` as a shorthand for `Cmd-` on Mac and `Ctrl-` on 64 | // other platforms. 65 | export function keydownHandler(bindings: KeyboardBindings) { 66 | let map = normalize(bindings) 67 | return function (event: KeyboardEvent) { 68 | let name = keyName(event), 69 | isChar = name.length == 1 && name != " ", 70 | baseName 71 | 72 | let direct = map[modifiers(name, event, !isChar)] 73 | if (direct && direct(event)) return true 74 | if ( 75 | isChar && 76 | (event.shiftKey || 77 | event.altKey || 78 | event.metaKey || 79 | name.charCodeAt(0) > 127) && 80 | (baseName = base[event.keyCode]) && 81 | baseName != name 82 | ) { 83 | // Try falling back to the keyCode when there's a modifier 84 | // active or the character produced isn't ASCII, and our table 85 | // produces a different name from the the keyCode. See #668, 86 | // #1060 87 | let fromCode = map[modifiers(baseName, event, true)] 88 | if (fromCode && fromCode(event)) return true 89 | } else if (isChar && event.shiftKey) { 90 | // Otherwise, if shift is active, also try the binding with the 91 | // Shift- prefix enabled. See #997 92 | let withShift = map[modifiers(name, event, true)] 93 | if (withShift && withShift(event)) return true 94 | } 95 | return false 96 | } 97 | } 98 | 99 | // This is the stack of keyboard event listeners. 100 | 101 | class KeyboardStack { 102 | stack: Array = [] 103 | 104 | add = (handler: KeyboardEventHandler) => { 105 | this.stack.push(handler) 106 | return () => this.remove(handler) 107 | } 108 | 109 | remove = (handler: KeyboardEventHandler) => { 110 | const index = this.stack.indexOf(handler) 111 | if (index >= 0) { 112 | this.stack.splice(index, 1) 113 | } 114 | } 115 | 116 | handleKeyDown: KeyboardEventHandler = (event) => { 117 | for (let i = this.stack.length - 1; i >= 0; i--) { 118 | const handler = this.stack[i] 119 | if (handler(event)) { 120 | return true 121 | } 122 | } 123 | return false 124 | } 125 | } 126 | 127 | export const keyboardStack = new KeyboardStack() 128 | 129 | export function useKeyboard(bindings: KeyboardBindings) { 130 | // Hoist bindings so the callback never changes. 131 | // This makes it so you don't have to worry so much about binding the callback functions. 132 | const ref = useRef(bindings) 133 | ref.current = bindings 134 | 135 | const callback = useCallback( 136 | (event: KeyboardEvent) => keydownHandler(ref.current)(event), 137 | [] 138 | ) 139 | 140 | useEffect(() => keyboardStack.add(callback), []) 141 | } 142 | 143 | document.addEventListener("keydown", (event) => { 144 | return keyboardStack.handleKeyDown(event) 145 | }) 146 | -------------------------------------------------------------------------------- /src/components/BlockSelection.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "glamor" 2 | import { exampleSetup } from "prosemirror-example-setup" 3 | import { keymap } from "prosemirror-keymap" 4 | import { Node as ProsemirrorNode, Schema } from "prosemirror-model" 5 | import { schema as basicSchema } from "prosemirror-schema-basic" 6 | import { addListNodes } from "prosemirror-schema-list" 7 | import { 8 | EditorState, 9 | NodeSelection, 10 | Selection, 11 | TextSelection, 12 | Transaction, 13 | } from "prosemirror-state" 14 | import { EditorView } from "prosemirror-view" 15 | import { useLayoutEffect, useRef } from "react" 16 | 17 | // An extension of NodeSelection so that escape/enter toggles between block selection mode. 18 | // Meanwhile, basic node selections can happen by arrowing past an image or an inline token. 19 | type BlockSelection = NodeSelection & { block: true } 20 | 21 | function createBlockSelection(doc: ProsemirrorNode, pos: number) { 22 | const selection = NodeSelection.create(doc, pos) as BlockSelection 23 | selection.block = true 24 | return selection 25 | } 26 | 27 | function isNodeSelection(selection: Selection): NodeSelection | undefined { 28 | if (selection instanceof NodeSelection) { 29 | return selection 30 | } 31 | } 32 | 33 | function isBlockSelection(selection: Selection) { 34 | const nodeSelection = isNodeSelection(selection) as BlockSelection | undefined 35 | if (nodeSelection && nodeSelection.block) { 36 | return nodeSelection 37 | } 38 | } 39 | 40 | // Similar to prosemirror-commands `selectParentNode`. 41 | function selectCurrentBlock(state: EditorState, selection: Selection) { 42 | let { $from, to } = selection 43 | 44 | let same = $from.sharedDepth(to) 45 | if (same == 0) return 46 | let pos = $from.before(same) 47 | 48 | return createBlockSelection(state.doc, pos) 49 | } 50 | 51 | // A set of utility functions for transforming selections around the tree. 52 | type SelectionAction = ( 53 | state: EditorState, 54 | selection: BlockSelection 55 | ) => BlockSelection | undefined 56 | 57 | const selectParent: SelectionAction = (state, selection) => { 58 | const { $from } = selection 59 | // We're at the top-level 60 | if ($from.depth <= 0) return 61 | 62 | const pos = $from.before() 63 | return createBlockSelection(state.doc, pos) 64 | } 65 | 66 | const selectFirstChild: SelectionAction = (state, selection) => { 67 | const { $from, node } = selection 68 | 69 | // We're at a leaf. 70 | if (!node.firstChild?.isBlock) return 71 | 72 | return createBlockSelection(state.doc, $from.pos + 1) 73 | } 74 | 75 | const selectNextSibling: SelectionAction = (state, selection) => { 76 | const { $to } = selection 77 | const nextIndex = $to.indexAfter() 78 | 79 | // We're at the last sibling. 80 | if (nextIndex >= $to.parent.childCount) return 81 | 82 | const pos = $to.posAtIndex(nextIndex) 83 | return createBlockSelection(state.doc, pos) 84 | } 85 | 86 | const selectPrevSibling: SelectionAction = (state, selection) => { 87 | const { $to } = selection 88 | const prevIndex = $to.indexAfter() - 2 89 | 90 | // We're at the first sibling. 91 | if (prevIndex < 0) return 92 | 93 | const pos = $to.posAtIndex(prevIndex) 94 | return createBlockSelection(state.doc, pos) 95 | } 96 | 97 | const selectNext: SelectionAction = (state, selection) => { 98 | let nextSelection: BlockSelection | undefined 99 | if ((nextSelection = selectFirstChild(state, selection))) { 100 | return nextSelection 101 | } 102 | 103 | if ((nextSelection = selectNextSibling(state, selection))) { 104 | return nextSelection 105 | } 106 | 107 | // Traverse parents looking for a sibling. 108 | let parent: BlockSelection | undefined = selection 109 | while ((parent = selectParent(state, parent))) { 110 | if ((nextSelection = selectNextSibling(state, parent))) { 111 | return nextSelection 112 | } 113 | } 114 | } 115 | 116 | const selectLastChild: SelectionAction = (state, selection) => { 117 | const first = selectFirstChild(state, selection) 118 | if (!first) return 119 | 120 | let next: BlockSelection | undefined = first 121 | let lastChild: BlockSelection | undefined = first 122 | while ((next = selectNextSibling(state, next))) { 123 | lastChild = next 124 | } 125 | 126 | return lastChild 127 | } 128 | 129 | const selectPrev: SelectionAction = (state, selection) => { 130 | // Prev sibling -> recursively last child 131 | let prevSelection: BlockSelection | undefined 132 | if ((prevSelection = selectPrevSibling(state, selection))) { 133 | let lastSelection: BlockSelection | undefined 134 | while ((lastSelection = selectLastChild(state, prevSelection))) { 135 | prevSelection = lastSelection 136 | } 137 | return prevSelection 138 | } 139 | 140 | // Traverse to parent. 141 | if ((prevSelection = selectParent(state, selection))) { 142 | return prevSelection 143 | } 144 | 145 | return undefined 146 | } 147 | 148 | // Turn a SelectionAction into a Prosemirror Command. 149 | function selectionCommmand( 150 | action: SelectionAction, 151 | // Capture the keyboard input when we try to arrow past the end rather than 152 | // return to TextSelection. 153 | capture: boolean = false 154 | ) { 155 | return (state: EditorState, dispatch?: (tr: Transaction) => void) => { 156 | const nodeSelection = isBlockSelection(state.selection) 157 | if (!nodeSelection) return false 158 | 159 | const selection = action(state, nodeSelection) 160 | if (!selection) return capture 161 | 162 | if (dispatch) { 163 | dispatch(state.tr.setSelection(selection).scrollIntoView()) 164 | } 165 | return true 166 | } 167 | } 168 | 169 | // Mix the nodes from prosemirror-schema-list into the basic schema to 170 | // create a schema with list support. 171 | const schema = new Schema({ 172 | nodes: addListNodes(basicSchema.spec.nodes, "paragraph block*", "block"), 173 | marks: basicSchema.spec.marks, 174 | }) 175 | 176 | type EditorSchema = typeof schema 177 | 178 | export function BlockSelection() { 179 | const ref = useRef(null) 180 | 181 | useLayoutEffect(() => { 182 | // Hide the menu. 183 | css.global(".ProseMirror-menubar", { display: "none" }) 184 | 185 | const node = ref.current 186 | if (!node) { 187 | throw new Error("Editor did not render!") 188 | } 189 | 190 | const doc = schema.nodeFromJSON(initialDocJson) 191 | 192 | const view = new EditorView( 193 | { mount: node }, 194 | { 195 | state: EditorState.create({ 196 | doc: doc, 197 | schema: schema, 198 | plugins: [ 199 | keymap({ 200 | // Select current block. 201 | Escape: (state, dispatch) => { 202 | if (isBlockSelection(state.selection)) { 203 | return false 204 | } 205 | const nodeSelection = selectCurrentBlock(state, state.selection) 206 | if (!nodeSelection) { 207 | return false 208 | } 209 | if (dispatch) { 210 | dispatch(state.tr.setSelection(nodeSelection)) 211 | } 212 | return true 213 | }, 214 | // Edit current block. 215 | Enter: (state, dispatch) => { 216 | const nodeSelection = isBlockSelection(state.selection) 217 | if (!nodeSelection) { 218 | return false 219 | } 220 | if (dispatch) { 221 | // TODO: what if this is an image? 222 | dispatch( 223 | state.tr.setSelection( 224 | TextSelection.create( 225 | state.tr.doc, 226 | nodeSelection.$to.pos - 1 227 | ) 228 | ) 229 | ) 230 | } 231 | return true 232 | }, 233 | 234 | // Select parent block 235 | ArrowLeft: selectionCommmand(selectParent, true), 236 | 237 | // Select child block 238 | ArrowRight: selectionCommmand(selectFirstChild, true), 239 | 240 | // Select next sibling block 241 | "Ctrl-ArrowDown": selectionCommmand(selectNextSibling, true), 242 | 243 | // Select previous sibling block 244 | "Ctrl-ArrowUp": selectionCommmand(selectPrevSibling, true), 245 | 246 | // Select next block 247 | ArrowDown: selectionCommmand(selectNext, true), 248 | 249 | // Select previous block 250 | ArrowUp: selectionCommmand(selectPrev, true), 251 | }), 252 | ...exampleSetup({ schema }), 253 | ], 254 | }), 255 | attributes: { 256 | style: [ 257 | "outline: 0px solid transparent", 258 | "line-height: 1.5", 259 | "-webkit-font-smoothing: auto", 260 | "padding: 2em", 261 | ].join(";"), 262 | }, 263 | dispatchTransaction(transaction) { 264 | view.updateState(view.state.apply(transaction)) 265 | }, 266 | } 267 | ) 268 | 269 | view.focus() 270 | ;(window as any)["editor"] = { view } 271 | 272 | return () => view.destroy() 273 | }, []) 274 | return
275 | } 276 | 277 | type NodeJSON = { 278 | type: string 279 | content?: Array 280 | attrs?: Record 281 | marks?: Array<{ type: "bold"; attrs?: Record }> 282 | text?: string 283 | } 284 | 285 | const initialDocJson: NodeJSON = { 286 | type: "doc", 287 | content: [ 288 | { 289 | type: "heading", 290 | attrs: { level: 1 }, 291 | content: [{ type: "text", text: "Block Selection" }], 292 | }, 293 | { 294 | type: "paragraph", 295 | content: [{ type: "text", text: "Similar to Notion…" }], 296 | }, 297 | { 298 | type: "paragraph", 299 | content: [ 300 | { type: "text", text: "Press escape, then arrow keys, then enter..." }, 301 | ], 302 | }, 303 | { 304 | type: "paragraph", 305 | content: [ 306 | { type: "text", text: "This uses the internal NodeSelection." }, 307 | ], 308 | }, 309 | { 310 | type: "bullet_list", 311 | content: [ 312 | { 313 | type: "list_item", 314 | content: [ 315 | { 316 | type: "paragraph", 317 | content: [{ type: "text", text: "List items" }], 318 | }, 319 | { 320 | type: "paragraph", 321 | content: [{ type: "text", text: "With children…" }], 322 | }, 323 | { 324 | type: "ordered_list", 325 | attrs: { order: 1 }, 326 | content: [ 327 | { 328 | type: "list_item", 329 | content: [ 330 | { 331 | type: "paragraph", 332 | content: [{ type: "text", text: "Nested" }], 333 | }, 334 | ], 335 | }, 336 | { 337 | type: "list_item", 338 | content: [ 339 | { 340 | type: "paragraph", 341 | content: [{ type: "text", text: "Lists" }], 342 | }, 343 | ], 344 | }, 345 | ], 346 | }, 347 | ], 348 | }, 349 | { 350 | type: "list_item", 351 | content: [ 352 | { type: "paragraph", content: [{ type: "text", text: "As well" }] }, 353 | ], 354 | }, 355 | ], 356 | }, 357 | { type: "paragraph", content: [{ type: "text", text: "And we’re back…" }] }, 358 | ], 359 | } 360 | -------------------------------------------------------------------------------- /src/components/Autocomplete.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Autocomplete example. 4 | 5 | Resources: 6 | https://discuss.prosemirror.net/t/how-to-update-plugin-state-from-handlekeydown-props/3420 7 | https://discuss.prosemirror.net/t/how-to-get-a-selection-rect/3430 8 | 9 | */ 10 | 11 | import { css } from "glamor" 12 | import { toggleMark } from "prosemirror-commands" 13 | import { history, redo, undo } from "prosemirror-history" 14 | import { keymap } from "prosemirror-keymap" 15 | import { MarkSpec, NodeSpec, Schema } from "prosemirror-model" 16 | import { 17 | EditorState, 18 | NodeSelection, 19 | Plugin, 20 | PluginKey, 21 | } from "prosemirror-state" 22 | import { Decoration, DecorationSet, EditorView } from "prosemirror-view" 23 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" 24 | import { createRoot } from "react-dom/client" 25 | import { keyboardStack, useKeyboard } from "./Keyboard" 26 | 27 | // ================================================================== 28 | // Autocomplete Plugin 29 | // ================================================================== 30 | 31 | type AutocompleteTokenPluginState = 32 | | { active: false } 33 | | AutocompleteTokenPluginActiveState 34 | 35 | type AutocompleteTokenPluginActiveState = { 36 | active: true 37 | // The cursor selection where we get text from 38 | range: { from: number; to: number } 39 | // The text we use to search 40 | text: string 41 | // Where to position the popup 42 | rect: { bottom: number; left: number } 43 | } 44 | 45 | type AutocompleteTokenPluginActions = { 46 | onCreate: (nodeAttr: string, range: { from: number; to: number }) => void 47 | onClose: () => void 48 | } 49 | 50 | type AutocompleteTokenPluginAction = 51 | | { type: "open"; pos: number; rect: { bottom: number; left: number } } 52 | | { type: "close" } 53 | 54 | function createAutocompleteTokenPlugin(args: { 55 | nodeName: N 56 | triggerCharacter: string 57 | renderToken: (span: HTMLSpanElement, nodeAttr: string) => void 58 | renderPopup: ( 59 | state: AutocompleteTokenPluginState, 60 | actions: AutocompleteTokenPluginActions 61 | ) => void 62 | }): { plugins: Plugin[]; nodes: { [key in N]: NodeSpec } } { 63 | const { nodeName, triggerCharacter, renderToken, renderPopup } = args 64 | const pluginKey = new PluginKey(nodeName) 65 | const dataAttr = `data-${nodeName}` 66 | 67 | const autocompleteTokenNode: NodeSpec = { 68 | group: "inline", 69 | inline: true, 70 | atom: true, 71 | attrs: { [nodeName]: { default: "" } }, 72 | toDOM: (node) => { 73 | const span = document.createElement("span") 74 | const nodeAttr = node.attrs[nodeName] 75 | span.setAttribute(dataAttr, node.attrs[nodeName]) 76 | renderToken(span, nodeAttr) 77 | return span 78 | }, 79 | parseDOM: [ 80 | { 81 | tag: `span[${dataAttr}]`, 82 | getAttrs: (dom) => { 83 | if (dom instanceof HTMLElement) { 84 | var value = dom.getAttribute(dataAttr) 85 | return { [nodeName]: value } 86 | } 87 | return false 88 | }, 89 | }, 90 | ], 91 | } 92 | 93 | const autocompleteTokenPlugin = new Plugin>({ 94 | key: pluginKey, 95 | state: { 96 | init() { 97 | return { active: false } 98 | }, 99 | apply(tr, state) { 100 | const action: AutocompleteTokenPluginAction | undefined = 101 | tr.getMeta(pluginKey) 102 | if (action) { 103 | if (action.type === "open") { 104 | const { pos, rect } = action 105 | const newState: AutocompleteTokenPluginState = { 106 | active: true, 107 | range: { from: pos, to: pos }, 108 | text: "", 109 | rect: rect, 110 | } 111 | return newState 112 | } else if (action.type === "close") { 113 | return { active: false } 114 | } 115 | } 116 | 117 | // Update the range and compute query. 118 | if (state.active) { 119 | const { range } = state 120 | const from = 121 | range.from === range.to ? range.from : tr.mapping.map(range.from) 122 | const to = tr.mapping.map(range.to) 123 | 124 | const text = tr.doc.textBetween(from, to, "\n", "\0") 125 | if (!text.startsWith(triggerCharacter)) { 126 | // Close when deleting the #. 127 | return { active: false } 128 | } 129 | 130 | const queryText = text.slice(1) // Remove the leading "#" 131 | const newState: AutocompleteTokenPluginState = { 132 | ...state, 133 | range: { from, to }, 134 | text: queryText, 135 | } 136 | return newState 137 | } 138 | 139 | return { active: false } 140 | }, 141 | }, 142 | props: { 143 | handleKeyDown(view, e) { 144 | const state = pluginKey.getState(view.state) 145 | 146 | const dispatch = (action: AutocompleteTokenPluginAction) => { 147 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 148 | } 149 | 150 | // if key is #, check that the previous position is blank and the next position is blank. 151 | if (e.key === triggerCharacter) { 152 | const tr = view.state.tr 153 | var selection = tr.selection 154 | // Collapsed selection 155 | if (selection.from === selection.to) { 156 | const $position = selection.$from 157 | const isStart = $position.pos === $position.start() 158 | const isEnd = $position.pos === $position.end() 159 | const emptyPrev = Boolean( 160 | !isStart && 161 | $position.doc 162 | .textBetween($position.pos - 1, $position.pos, "\n", "\0") 163 | .match(/\s/) 164 | ) 165 | const emptyNext = Boolean( 166 | !isEnd && 167 | $position.doc 168 | .textBetween($position.pos, $position.pos + 1, "\n", "\0") 169 | .match(/\s/) 170 | ) 171 | 172 | if ((isStart || emptyPrev) && (isEnd || emptyNext)) { 173 | const pos = $position.pos 174 | const rect = view.coordsAtPos(pos) 175 | dispatch({ type: "open", pos, rect }) 176 | 177 | // Don't override the actual input. 178 | return false 179 | } 180 | } 181 | } 182 | 183 | return false 184 | }, 185 | decorations(editorState) { 186 | const state: AutocompleteTokenPluginState = 187 | pluginKey.getState(editorState) 188 | if (!state.active) { 189 | return null 190 | } 191 | const { range } = state 192 | return DecorationSet.create(editorState.doc, [ 193 | Decoration.inline(range.from, range.to, { 194 | nodeName: "span", 195 | style: "color:#999;", 196 | }), 197 | ]) 198 | }, 199 | }, 200 | view() { 201 | return { 202 | update(view) { 203 | var state: AutocompleteTokenPluginState = pluginKey.getState( 204 | view.state 205 | ) 206 | 207 | const onCreate = ( 208 | value: string, 209 | range: { from: number; to: number } 210 | ) => { 211 | const node = view.state.schema.nodes[nodeName].create({ 212 | [nodeName]: value, 213 | }) 214 | view.dispatch(view.state.tr.replaceWith(range.from, range.to, node)) 215 | } 216 | 217 | const dispatch = (action: AutocompleteTokenPluginAction) => { 218 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 219 | } 220 | const onClose = () => dispatch({ type: "close" }) 221 | 222 | renderPopup(state, { onCreate, onClose }) 223 | }, 224 | destroy() {}, 225 | } 226 | }, 227 | }) 228 | 229 | return { 230 | nodes: { [nodeName]: autocompleteTokenNode } as any, 231 | plugins: [ 232 | autocompleteTokenPlugin, 233 | // Delete token when it is selected. 234 | keymap({ 235 | Backspace: (state, dispatch) => { 236 | const { node } = state.selection as NodeSelection 237 | if (node) { 238 | node.type === state.schema.nodes[nodeName] 239 | console.log(node) 240 | if (dispatch) { 241 | dispatch(state.tr.deleteSelection()) 242 | } 243 | return true 244 | } 245 | return false 246 | }, 247 | }), 248 | ], 249 | } 250 | } 251 | 252 | // ================================================================== 253 | // Mention Token Autocomplete 254 | // ================================================================== 255 | 256 | const mentionPopupElement = document.createElement("div") 257 | document.body.append(mentionPopupElement) 258 | 259 | const getSuggestions = (queryText: string) => { 260 | return [ 261 | "Max Einhorn", 262 | "Sean O'Rielly", 263 | "Sam Corcos", 264 | "Haris Butt", 265 | "Simon Last", 266 | ].filter((str) => str.toLowerCase().includes(queryText.toLowerCase())) 267 | } 268 | 269 | css.global("[data-mention].ProseMirror-selectednode", { 270 | outline: "1px solid blue", 271 | }) 272 | 273 | const mentionAutocomplete = createAutocompleteTokenPlugin({ 274 | nodeName: "mention", 275 | triggerCharacter: "@", 276 | renderToken: (span, attr) => { 277 | createRoot(span).render() 278 | }, 279 | renderPopup: (state, actions) => { 280 | createRoot(mentionPopupElement).render( 281 | 282 | ) 283 | }, 284 | }) 285 | 286 | function MentionToken(props: { value: string }) { 287 | return @{props.value} 288 | } 289 | 290 | function AutocompletePopup(props: { 291 | state: AutocompleteTokenPluginState 292 | actions: AutocompleteTokenPluginActions 293 | }) { 294 | if (!props.state.active) { 295 | return null 296 | } 297 | 298 | return 299 | } 300 | 301 | function AutocompletePopupInner( 302 | props: AutocompleteTokenPluginActiveState & 303 | AutocompleteTokenPluginActions 304 | ) { 305 | const { rect, text, onClose, range, onCreate } = props 306 | 307 | const misses = useRef(0) 308 | 309 | const suggestions = useMemo(() => { 310 | const list = getSuggestions(text) 311 | if (list.length === 0) { 312 | misses.current++ 313 | } else { 314 | misses.current = 0 315 | } 316 | return list 317 | }, [text]) 318 | 319 | const [index, setIndex] = useState(0) 320 | 321 | useKeyboard({ 322 | ArrowUp: () => { 323 | setIndex(strangle(index - 1, [0, suggestions.length - 1])) 324 | return true 325 | }, 326 | ArrowDown: () => { 327 | console.log("Down") 328 | setIndex(strangle(index + 1, [0, suggestions.length - 1])) 329 | return true 330 | }, 331 | Enter: () => { 332 | if (index < suggestions.length) { 333 | onCreate(suggestions[index], range) 334 | onClose() 335 | } 336 | return true 337 | }, 338 | Escape: () => { 339 | onClose() 340 | return true 341 | }, 342 | }) 343 | 344 | useEffect(() => { 345 | if (misses.current > 5) { 346 | onClose() 347 | } 348 | }, [misses.current > 5]) 349 | 350 | return ( 351 |
365 |
Query: "{text}"
366 | {suggestions.length === 0 &&
No Results
} 367 | {suggestions.map((suggestion, i) => { 368 | return ( 369 |
370 | 371 |
372 | ) 373 | })} 374 |
375 | ) 376 | } 377 | 378 | function strangle(n: number, minMax: [number, number]) { 379 | return Math.max(Math.min(n, minMax[1]), minMax[0]) 380 | } 381 | 382 | // ================================================================== 383 | // ProseMirror Editor 384 | // ================================================================== 385 | 386 | const doc: NodeSpec = { content: "inline*" } 387 | const text: NodeSpec = { group: "inline" } 388 | 389 | const bold: MarkSpec = { 390 | parseDOM: [{ tag: "strong" }], 391 | toDOM() { 392 | return ["strong", 0] 393 | }, 394 | } 395 | 396 | const nodes = { 397 | doc, 398 | text, 399 | ...mentionAutocomplete.nodes, 400 | } 401 | const marks = { bold } 402 | 403 | const schema = new Schema({ nodes, marks }) 404 | type EditorSchema = typeof schema 405 | type EditorNodeType = keyof typeof nodes 406 | type EditorMarkType = keyof typeof marks 407 | 408 | type NodeJSON = { 409 | type: EditorNodeType 410 | content?: Array 411 | attrs?: Record 412 | marks?: Array<{ type: "bold"; attrs?: Record }> 413 | text?: string 414 | } 415 | 416 | const initialDoc: NodeJSON = { 417 | type: "doc", 418 | content: [{ type: "text", text: "Type @ to create a mention." }], 419 | } 420 | 421 | export function Editor() { 422 | const ref = useRef(null) 423 | 424 | useLayoutEffect(() => { 425 | const node = ref.current 426 | 427 | if (!node) throw new Error("Editor did not render!") 428 | 429 | const state = EditorState.create({ 430 | schema: schema, 431 | doc: schema.nodeFromJSON(initialDoc), 432 | plugins: [ 433 | history(), 434 | keymap({ "Mod-z": undo, "Mod-y": redo }), 435 | keymap({ "Mod-b": toggleMark(schema.marks.bold) }), 436 | ...mentionAutocomplete.plugins, 437 | ], 438 | }) 439 | 440 | const view = new EditorView( 441 | { mount: node }, 442 | { 443 | state, 444 | attributes: { 445 | style: [ 446 | "outline: 0px solid transparent", 447 | "line-height: 1.5", 448 | "-webkit-font-smoothing: auto", 449 | "padding: 2em", 450 | ].join(";"), 451 | }, 452 | dispatchTransaction(transaction) { 453 | view.updateState(view.state.apply(transaction)) 454 | }, 455 | handleKeyDown(view, event) { 456 | // Delegate to the global keyboard stack. 457 | if (keyboardStack.handleKeyDown(event)) { 458 | // Don't bubble up so we only handle this event once. 459 | event.stopPropagation() 460 | return true 461 | } 462 | return false 463 | }, 464 | } 465 | ) 466 | 467 | ;(window as any)["editor"] = { view } 468 | }, []) 469 | 470 | return
471 | } 472 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "bun-react-template", 6 | "dependencies": { 7 | "glamor": "^2.20.40", 8 | "lodash": "^4.17.21", 9 | "prosemirror-commands": "^1.7.1", 10 | "prosemirror-dropcursor": "^1.8.2", 11 | "prosemirror-example-setup": "^1.2.3", 12 | "prosemirror-gapcursor": "^1.3.2", 13 | "prosemirror-history": "^1.4.1", 14 | "prosemirror-inputrules": "^1.5.0", 15 | "prosemirror-keymap": "^1.2.3", 16 | "prosemirror-mentions": "^1.0.2", 17 | "prosemirror-model": "^1.25.1", 18 | "prosemirror-schema-basic": "^1.2.4", 19 | "prosemirror-schema-list": "^1.5.1", 20 | "prosemirror-state": "^1.4.3", 21 | "prosemirror-transform": "^1.10.4", 22 | "prosemirror-view": "^1.39.3", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0", 25 | "react-router-dom": "^7.6.0", 26 | }, 27 | "devDependencies": { 28 | "@types/bun": "latest", 29 | "@types/lodash": "^4.17.17", 30 | "@types/prosemirror-commands": "^1.3.0", 31 | "@types/prosemirror-dropcursor": "^1.5.0", 32 | "@types/prosemirror-gapcursor": "^1.3.0", 33 | "@types/prosemirror-history": "^1.3.0", 34 | "@types/prosemirror-inputrules": "^1.2.0", 35 | "@types/prosemirror-keymap": "^1.2.0", 36 | "@types/prosemirror-model": "^1.17.0", 37 | "@types/prosemirror-schema-basic": "^1.2.0", 38 | "@types/prosemirror-state": "^1.4.0", 39 | "@types/prosemirror-transform": "^1.5.0", 40 | "@types/prosemirror-view": "^1.24.0", 41 | "@types/react": "^19.1.5", 42 | "@types/react-dom": "^19.1.5", 43 | "typescript": "^5.8.3", 44 | }, 45 | }, 46 | }, 47 | "packages": { 48 | "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], 49 | 50 | "@types/lodash": ["@types/lodash@4.17.17", "", {}, "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ=="], 51 | 52 | "@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="], 53 | 54 | "@types/prosemirror-commands": ["@types/prosemirror-commands@1.3.0", "", { "dependencies": { "prosemirror-commands": "*" } }, "sha512-3UV4Pk4WRhrU7sGI5q/DAFS0DDIWYdaJwFqgrCblYRSOrJDLU8GIaZK5GmUaZtYF07E29XMKo9D2cDDh5pZBGg=="], 55 | 56 | "@types/prosemirror-dropcursor": ["@types/prosemirror-dropcursor@1.5.0", "", { "dependencies": { "prosemirror-dropcursor": "*" } }, "sha512-Xa13THoY0YkvYP/peH995ahT79w3ErdsmFUIaTY21nshxxnn5mdSgG+RTpkqXwZ85v+n28MvNfLF2gm+c8RZ1A=="], 57 | 58 | "@types/prosemirror-gapcursor": ["@types/prosemirror-gapcursor@1.3.0", "", { "dependencies": { "prosemirror-gapcursor": "*" } }, "sha512-KbZbwrr2i6+AAOtTTQhbgXlAL1ZTY+FE8PsGz4vqRLeS4ow7sppdI3oHGMn0xmCgqXI+ajEDYENKHUQ2WZkXew=="], 59 | 60 | "@types/prosemirror-history": ["@types/prosemirror-history@1.3.0", "", { "dependencies": { "prosemirror-history": "*" } }, "sha512-Cs3jtZvk+9N5ygsry2gEwkgMq11YwSFaChoxIRq75nGbDp8ZVAiYEqF6iAunsrExQC3zh0ojmf+XxP5X3j2Ztw=="], 61 | 62 | "@types/prosemirror-inputrules": ["@types/prosemirror-inputrules@1.2.0", "", { "dependencies": { "prosemirror-inputrules": "*" } }, "sha512-N30wadmd6uVnGR97JvX2mEOEoqsLr/nv96SkTb3JKfTLqtdLW6UHjDf3fiOPPQkj2hMqhS9ENnsIbDKfsYrSdw=="], 63 | 64 | "@types/prosemirror-keymap": ["@types/prosemirror-keymap@1.2.0", "", { "dependencies": { "prosemirror-keymap": "*" } }, "sha512-Vv/hOlNsDBOkqmxWUjgK7Ch5mFNRnvG88mfl2WhLFp4awdg3oQiZeTPN0wosWSO4mpK9aAWtZEhvJ/639HTLTQ=="], 65 | 66 | "@types/prosemirror-model": ["@types/prosemirror-model@1.17.0", "", { "dependencies": { "prosemirror-model": "*" } }, "sha512-lG5xEMkE8r8Soa80KdWPTbCLUaSHBHVHpTIEsQiebfONpvmS5061IMGzHUdb1oWjgrwh8EJq0GgMNwXHUx5mVg=="], 67 | 68 | "@types/prosemirror-schema-basic": ["@types/prosemirror-schema-basic@1.2.0", "", { "dependencies": { "prosemirror-schema-basic": "*" } }, "sha512-WrQLRv3Ss48e2XBnocoZNMLw3xZwiW2cSjPvrZ/Ui6PicAMsabkb6EKTdLU0B52LuQjShNPtq0tFejrbLOMbpQ=="], 69 | 70 | "@types/prosemirror-state": ["@types/prosemirror-state@1.4.0", "", { "dependencies": { "prosemirror-state": "*" } }, "sha512-71epLy1HD2H7Qn6iOoQrFdbdFP32Cg5U7OvlCXMuYO8ygUdz07dfqA1lNj1y+KLf3HkRCXVkfvi3OnNa/tFZ3A=="], 71 | 72 | "@types/prosemirror-transform": ["@types/prosemirror-transform@1.5.0", "", { "dependencies": { "prosemirror-transform": "*" } }, "sha512-++krMS5bt3SxNOqjrftispPLRkvfXXw2BtVq4VPJ8Vpf+Sne1MhxVoj0EFCM+14MFlX0EHYQvX3k9AaQzob9ZQ=="], 73 | 74 | "@types/prosemirror-view": ["@types/prosemirror-view@1.24.0", "", { "dependencies": { "prosemirror-view": "*" } }, "sha512-Swn08/O+QIOKOSfFFa+KKF19eeHetwA+pBMAHZ7wbF0wPrMS3zJ+G9wbOGqSkUv6JOVpuhlOP8Xg5nA3MyIXgQ=="], 75 | 76 | "@types/react": ["@types/react@19.1.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g=="], 77 | 78 | "@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="], 79 | 80 | "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], 81 | 82 | "bowser": ["bowser@1.9.4", "", {}, "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ=="], 83 | 84 | "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], 85 | 86 | "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], 87 | 88 | "core-js": ["core-js@1.2.7", "", {}, "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA=="], 89 | 90 | "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], 91 | 92 | "css-in-js-utils": ["css-in-js-utils@2.0.1", "", { "dependencies": { "hyphenate-style-name": "^1.0.2", "isobject": "^3.0.1" } }, "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA=="], 93 | 94 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 95 | 96 | "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], 97 | 98 | "fbjs": ["fbjs@0.8.18", "", { "dependencies": { "core-js": "^1.0.0", "isomorphic-fetch": "^2.1.1", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^0.7.30" } }, "sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA=="], 99 | 100 | "glamor": ["glamor@2.20.40", "", { "dependencies": { "fbjs": "^0.8.12", "inline-style-prefixer": "^3.0.6", "object-assign": "^4.1.1", "prop-types": "^15.5.10", "through": "^2.3.8" } }, "sha512-DNXCd+c14N9QF8aAKrfl4xakPk5FdcFwmH7sD0qnC0Pr7xoZ5W9yovhUrY/dJc3psfGGXC58vqQyRtuskyUJxA=="], 101 | 102 | "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], 103 | 104 | "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], 105 | 106 | "inline-style-prefixer": ["inline-style-prefixer@3.0.8", "", { "dependencies": { "bowser": "^1.7.3", "css-in-js-utils": "^2.0.0" } }, "sha512-ne8XIyyqkRaNJ1JfL1NYzNdCNxq+MCBQhC8NgOQlzNm2vv3XxlP0VSLQUbSRCF6KPEoveCVEpayHoHzcMyZsMQ=="], 107 | 108 | "is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], 109 | 110 | "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], 111 | 112 | "isomorphic-fetch": ["isomorphic-fetch@2.2.1", "", { "dependencies": { "node-fetch": "^1.0.1", "whatwg-fetch": ">=0.10.0" } }, "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA=="], 113 | 114 | "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 115 | 116 | "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 117 | 118 | "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], 119 | 120 | "node-fetch": ["node-fetch@1.7.3", "", { "dependencies": { "encoding": "^0.1.11", "is-stream": "^1.0.1" } }, "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ=="], 121 | 122 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 123 | 124 | "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], 125 | 126 | "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], 127 | 128 | "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], 129 | 130 | "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], 131 | 132 | "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], 133 | 134 | "prosemirror-example-setup": ["prosemirror-example-setup@1.2.3", "", { "dependencies": { "prosemirror-commands": "^1.0.0", "prosemirror-dropcursor": "^1.0.0", "prosemirror-gapcursor": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-inputrules": "^1.0.0", "prosemirror-keymap": "^1.0.0", "prosemirror-menu": "^1.0.0", "prosemirror-schema-list": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-+hXZi8+xbFvYM465zZH3rdZ9w7EguVKmUYwYLZjIJIjPK+I0nPTwn8j0ByW2avchVczRwZmOJGNvehblyIerSQ=="], 135 | 136 | "prosemirror-gapcursor": ["prosemirror-gapcursor@1.3.2", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ=="], 137 | 138 | "prosemirror-history": ["prosemirror-history@1.4.1", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ=="], 139 | 140 | "prosemirror-inputrules": ["prosemirror-inputrules@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA=="], 141 | 142 | "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], 143 | 144 | "prosemirror-mentions": ["prosemirror-mentions@1.0.2", "", { "peerDependencies": { "prosemirror-state": "^1.2.2", "prosemirror-view": "^1.4.2" } }, "sha512-d9O1IT69NQvASN4K+/ki5FaiAs5yK5qnueZiRHtlnsy80V74hVINIdDp2HQkFM26/TLZ4xbkjXTakjv4X4ZRiQ=="], 145 | 146 | "prosemirror-menu": ["prosemirror-menu@1.2.5", "", { "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ=="], 147 | 148 | "prosemirror-model": ["prosemirror-model@1.25.1", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg=="], 149 | 150 | "prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "^1.25.0" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="], 151 | 152 | "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], 153 | 154 | "prosemirror-state": ["prosemirror-state@1.4.3", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q=="], 155 | 156 | "prosemirror-transform": ["prosemirror-transform@1.10.4", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw=="], 157 | 158 | "prosemirror-view": ["prosemirror-view@1.39.3", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-bY/7kg0LzRE7ytR0zRdSMWX3sknEjw68l836ffLPMh0OG3OYnNuBDUSF3v0vjvnzgYjgY9ZH/RypbARURlcMFA=="], 159 | 160 | "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 161 | 162 | "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], 163 | 164 | "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 165 | 166 | "react-router": ["react-router@7.6.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ=="], 167 | 168 | "react-router-dom": ["react-router-dom@7.6.0", "", { "dependencies": { "react-router": "7.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA=="], 169 | 170 | "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], 171 | 172 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 173 | 174 | "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], 175 | 176 | "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 177 | 178 | "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], 179 | 180 | "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], 181 | 182 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 183 | 184 | "ua-parser-js": ["ua-parser-js@0.7.40", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ=="], 185 | 186 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 187 | 188 | "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], 189 | 190 | "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/components/BlockSelectionPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from "react" 2 | 3 | import { exampleSetup } from "prosemirror-example-setup" 4 | import { Node as ProsemirrorNode, ResolvedPos, Schema } from "prosemirror-model" 5 | import { schema as basicSchema } from "prosemirror-schema-basic" 6 | import { addListNodes } from "prosemirror-schema-list" 7 | import { Decoration, DecorationSet, EditorView } from "prosemirror-view" 8 | 9 | import { css } from "glamor" 10 | import { keydownHandler } from "prosemirror-keymap" 11 | import { 12 | EditorState, 13 | Plugin, 14 | PluginKey, 15 | Selection, 16 | TextSelection, 17 | Transaction, 18 | } from "prosemirror-state" 19 | 20 | // TODO: 21 | // - how can we use this same logic as a 'custom' Selection on state.selection? 22 | // - Allow cmd+shift+clicking to select multiple disjointed blocks. 23 | // - move blocks around 24 | // - drag and drop 25 | // 26 | // LATER: 27 | // - expandPrev and expandNext should call selectNext when shrinking. 28 | 29 | function resolveNode($from: ResolvedPos) { 30 | const node = $from.nodeAfter! 31 | const $to = $from.node(0).resolve($from.pos + node.nodeSize) 32 | return { $from, $to, node } 33 | } 34 | 35 | class BlockPosition { 36 | public $from: ResolvedPos 37 | public $to: ResolvedPos 38 | 39 | constructor($from: ResolvedPos, $to?: ResolvedPos) { 40 | if (!$to) { 41 | $to = resolveNode($from).$to 42 | } 43 | this.$from = $from 44 | this.$to = $to 45 | } 46 | 47 | static create(doc: ProsemirrorNode, from: number) { 48 | return new this(doc.resolve(from)) 49 | } 50 | } 51 | 52 | class BlockSelection { 53 | public $anchor: BlockPosition 54 | public $head: BlockPosition 55 | 56 | constructor($anchor: BlockPosition, $head?: BlockPosition) { 57 | $head = $head || $anchor 58 | 59 | // When the head encapsulated the anchor. 60 | if ( 61 | $head.$from.pos <= $anchor.$from.pos && 62 | $head.$to.pos >= $anchor.$to.pos 63 | ) { 64 | this.$anchor = $head 65 | this.$head = $head 66 | // } else if ( 67 | // $head.$from.pos >= $anchor.$from.pos && 68 | // $head.$to.pos <= $anchor.$to.pos 69 | // ) { 70 | } else { 71 | this.$anchor = $anchor 72 | this.$head = $head 73 | } 74 | } 75 | 76 | static create(doc: ProsemirrorNode, from: number) { 77 | return new this(new BlockPosition(doc.resolve(from))) 78 | } 79 | } 80 | 81 | // Similar to prosemirror-commands `selectParentNode`. 82 | function selectCurrentBlock(state: EditorState, selection: Selection) { 83 | let { $from, to } = selection 84 | 85 | let same = $from.sharedDepth(to) 86 | if (same == 0) return 87 | let pos = $from.before(same) 88 | 89 | return BlockSelection.create(state.doc, pos) 90 | } 91 | 92 | // TODO: SelectionAction should use BlockPos, not BlockSelection. 93 | // A set of utility functions for transforming selections around the tree. 94 | type SelectionAction = ( 95 | state: EditorState, 96 | selection: BlockPosition 97 | ) => BlockPosition | undefined 98 | 99 | const selectParent: SelectionAction = (state, selection) => { 100 | const { $from } = selection 101 | // We're at the top-level 102 | if ($from.depth <= 0) return 103 | 104 | const pos = $from.before() 105 | return BlockPosition.create(state.doc, pos) 106 | } 107 | 108 | const selectFirstChild: SelectionAction = (state, selection) => { 109 | const { $from } = selection 110 | 111 | // We're at a leaf. 112 | // if (!node.firstChild?.isBlock) return 113 | if (!$from.nodeAfter?.firstChild?.isBlock) return 114 | 115 | return BlockPosition.create(state.doc, $from.pos + 1) 116 | } 117 | 118 | const selectNextSibling: SelectionAction = (state, selection) => { 119 | const { $to } = selection 120 | const nextIndex = $to.indexAfter() 121 | 122 | // We're at the last sibling. 123 | if (nextIndex >= $to.parent.childCount) return 124 | 125 | const pos = $to.posAtIndex(nextIndex) 126 | return BlockPosition.create(state.doc, pos) 127 | } 128 | 129 | const selectPrevSibling: SelectionAction = (state, selection) => { 130 | const { $from } = selection 131 | const prevIndex = $from.indexAfter() - 1 132 | 133 | // We're at the first sibling. 134 | if (prevIndex < 0) return 135 | 136 | const pos = $from.posAtIndex(prevIndex) 137 | return BlockPosition.create(state.doc, pos) 138 | } 139 | 140 | const selectNext: SelectionAction = (state, selection) => { 141 | let nextSelection: BlockPosition | undefined 142 | if ((nextSelection = selectFirstChild(state, selection))) { 143 | return nextSelection 144 | } 145 | 146 | if ((nextSelection = selectNextSibling(state, selection))) { 147 | return nextSelection 148 | } 149 | 150 | // Traverse parents looking for a sibling. 151 | return selectNextParentSubling(state, selection) 152 | } 153 | 154 | const selectNextParentSubling: SelectionAction = (state, selection) => { 155 | let nextSelection: BlockPosition | undefined 156 | 157 | // Traverse parents looking for a sibling. 158 | let parent: BlockPosition | undefined = selection 159 | while ((parent = selectParent(state, parent))) { 160 | if ((nextSelection = selectNextSibling(state, parent))) { 161 | return nextSelection 162 | } 163 | } 164 | } 165 | 166 | const selectLastChild: SelectionAction = (state, selection) => { 167 | const first = selectFirstChild(state, selection) 168 | if (!first) return 169 | 170 | let next: BlockPosition | undefined = first 171 | let lastChild: BlockPosition | undefined = first 172 | while ((next = selectNextSibling(state, next))) { 173 | lastChild = next 174 | } 175 | 176 | return lastChild 177 | } 178 | 179 | const selectPrev: SelectionAction = (state, selection) => { 180 | // Prev sibling -> recursively last child 181 | let prevSelection: BlockPosition | undefined 182 | if ((prevSelection = selectPrevSibling(state, selection))) { 183 | let lastSelection: BlockPosition | undefined 184 | while ((lastSelection = selectLastChild(state, prevSelection))) { 185 | prevSelection = lastSelection 186 | } 187 | return prevSelection 188 | } 189 | 190 | // Traverse to parent. 191 | if ((prevSelection = selectParent(state, selection))) { 192 | return prevSelection 193 | } 194 | 195 | return undefined 196 | } 197 | 198 | type ExpandAction = ( 199 | state: EditorState, 200 | selection: BlockSelection 201 | ) => BlockSelection | undefined 202 | 203 | const expandNext: ExpandAction = (state, selection) => { 204 | const nextSibling = selectNextSibling(state, selection.$head) 205 | if (nextSibling) { 206 | return new BlockSelection(selection.$anchor, nextSibling) 207 | } 208 | 209 | const nextAbove = selectNextParentSubling(state, selection.$head) 210 | if (nextAbove) { 211 | return new BlockSelection(selection.$anchor, nextAbove) 212 | } 213 | } 214 | 215 | const expandPrev: ExpandAction = (state, selection) => { 216 | const prevSibling = selectPrevSibling(state, selection.$head) 217 | if (prevSibling) { 218 | return new BlockSelection(selection.$anchor, prevSibling) 219 | } 220 | 221 | const parent = selectParent(state, selection.$head) 222 | if (parent) { 223 | return new BlockSelection(selection.$anchor, parent) 224 | } 225 | } 226 | 227 | // Mix the nodes from prosemirror-schema-list into the basic schema to 228 | // create a schema with list support. 229 | const schema = new Schema({ 230 | nodes: addListNodes(basicSchema.spec.nodes, "paragraph block*", "block"), 231 | marks: basicSchema.spec.marks, 232 | }) 233 | 234 | type EditorSchema = typeof schema 235 | 236 | export function BlockSelectionPlugin() { 237 | const ref = useRef(null) 238 | 239 | useLayoutEffect(() => { 240 | // Hide the menu. 241 | css.global(".ProseMirror-menubar", { display: "none" }) 242 | 243 | const node = ref.current 244 | if (!node) { 245 | throw new Error("Editor did not render!") 246 | } 247 | 248 | const doc = schema.nodeFromJSON(initialDocJson) 249 | 250 | const view = new EditorView( 251 | { mount: node }, 252 | { 253 | state: EditorState.create({ 254 | doc: doc, 255 | schema: schema, 256 | plugins: [selectionPlugin, ...exampleSetup({ schema })], 257 | }), 258 | attributes: { 259 | style: [ 260 | "outline: 0px solid transparent", 261 | "line-height: 1.5", 262 | "-webkit-font-smoothing: auto", 263 | "padding: 2em", 264 | ].join(";"), 265 | }, 266 | dispatchTransaction(transaction) { 267 | view.updateState(view.state.apply(transaction)) 268 | }, 269 | } 270 | ) 271 | 272 | view.focus() 273 | ;(window as any)["editor"] = { view } 274 | 275 | return () => view.destroy() 276 | }, []) 277 | return
278 | } 279 | 280 | type NodeJSON = { 281 | type: string 282 | content?: Array 283 | attrs?: Record 284 | marks?: Array<{ type: "bold"; attrs?: Record }> 285 | text?: string 286 | } 287 | 288 | const initialDocJson: NodeJSON = { 289 | type: "doc", 290 | content: [ 291 | { 292 | type: "heading", 293 | attrs: { level: 1 }, 294 | content: [{ type: "text", text: "Block Selection Plugin" }], 295 | }, 296 | { 297 | type: "paragraph", 298 | content: [{ type: "text", text: "Similar to Notion…" }], 299 | }, 300 | { 301 | type: "paragraph", 302 | content: [ 303 | { 304 | type: "text", 305 | text: "Press escape, then arrow keys, then enter... Try holding to expand the selection.", 306 | }, 307 | ], 308 | }, 309 | { 310 | type: "paragraph", 311 | content: [ 312 | { 313 | type: "text", 314 | text: "Also try shift clicking to expand the selection.", 315 | }, 316 | ], 317 | }, 318 | 319 | { 320 | type: "paragraph", 321 | content: [ 322 | { 323 | type: "text", 324 | text: "This uses a custom BlockSelection implementation.", 325 | }, 326 | ], 327 | }, 328 | { 329 | type: "bullet_list", 330 | content: [ 331 | { 332 | type: "list_item", 333 | content: [ 334 | { 335 | type: "paragraph", 336 | content: [{ type: "text", text: "List items" }], 337 | }, 338 | { 339 | type: "paragraph", 340 | content: [{ type: "text", text: "With children…" }], 341 | }, 342 | { 343 | type: "ordered_list", 344 | attrs: { order: 1 }, 345 | content: [ 346 | { 347 | type: "list_item", 348 | content: [ 349 | { 350 | type: "paragraph", 351 | content: [{ type: "text", text: "Nested" }], 352 | }, 353 | ], 354 | }, 355 | { 356 | type: "list_item", 357 | content: [ 358 | { 359 | type: "paragraph", 360 | content: [{ type: "text", text: "Lists" }], 361 | }, 362 | ], 363 | }, 364 | ], 365 | }, 366 | ], 367 | }, 368 | { 369 | type: "list_item", 370 | content: [ 371 | { type: "paragraph", content: [{ type: "text", text: "As well" }] }, 372 | ], 373 | }, 374 | ], 375 | }, 376 | { type: "paragraph", content: [{ type: "text", text: "And we’re back…" }] }, 377 | ], 378 | } 379 | 380 | type BlockSelectionPluginState = null | BlockSelection 381 | 382 | type BlockSelectionPluginAction = { newState: BlockSelectionPluginState } 383 | 384 | const pluginKey = new PluginKey("block-selection") 385 | 386 | const selectionPlugin = new Plugin({ 387 | key: pluginKey, 388 | state: { 389 | init() { 390 | return null 391 | }, 392 | apply(tr, state) { 393 | const action: BlockSelectionPluginAction | undefined = 394 | tr.getMeta(pluginKey) 395 | if (action) { 396 | return action.newState 397 | } 398 | return state 399 | }, 400 | }, 401 | 402 | props: { 403 | handleKeyDown(view, event) { 404 | const pluginState = pluginKey.getState(view.state) 405 | 406 | const pluginDispatch = (action: BlockSelectionPluginAction) => { 407 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 408 | } 409 | 410 | function selectionCommmand( 411 | action: SelectionAction, 412 | // Capture the keyboard input when we try to arrow past the end rather than 413 | // return to TextSelection. 414 | capture: boolean = false 415 | ) { 416 | return (state: EditorState, dispatch?: (tr: Transaction) => void) => { 417 | if (!pluginState) { 418 | return false 419 | } 420 | 421 | const $head = action(state, pluginState.$head) 422 | if (!$head) return capture 423 | 424 | if (dispatch) { 425 | pluginDispatch({ newState: new BlockSelection($head) }) 426 | } 427 | return true 428 | } 429 | } 430 | 431 | function expandCommmand( 432 | action: ExpandAction, 433 | // Capture the keyboard input when we try to arrow past the end rather than 434 | // return to TextSelection. 435 | capture: boolean = false 436 | ) { 437 | return (state: EditorState, dispatch?: (tr: Transaction) => void) => { 438 | if (!pluginState) { 439 | return false 440 | } 441 | 442 | const selection = action(state, pluginState) 443 | if (!selection) return capture 444 | 445 | if (dispatch) { 446 | pluginDispatch({ newState: selection }) 447 | } 448 | return true 449 | } 450 | } 451 | 452 | const handler = keydownHandler({ 453 | // Select current block. 454 | Escape: (state, dispatch) => { 455 | if (pluginState !== null) { 456 | pluginDispatch({ newState: null }) 457 | return true 458 | } 459 | const nodeSelection = selectCurrentBlock(state, state.selection) 460 | if (!nodeSelection) { 461 | return false 462 | } 463 | if (dispatch) { 464 | // dispatch(view.state.tr.setSelection(nodeSelection)) 465 | pluginDispatch({ newState: nodeSelection }) 466 | } 467 | return true 468 | }, 469 | // Edit current block. 470 | Enter: (state, dispatch) => { 471 | if (!pluginState) { 472 | return false 473 | } 474 | if (dispatch) { 475 | pluginDispatch({ newState: null }) 476 | dispatch( 477 | state.tr.setSelection( 478 | TextSelection.create( 479 | state.tr.doc, 480 | pluginState.$head.$to.pos - 1 481 | ) 482 | ) 483 | ) 484 | } 485 | return true 486 | }, 487 | 488 | // Select parent block 489 | ArrowLeft: selectionCommmand(selectParent, true), 490 | 491 | // Select child block 492 | ArrowRight: selectionCommmand(selectFirstChild, true), 493 | 494 | // Select next sibling block 495 | "Ctrl-ArrowDown": selectionCommmand(selectNextSibling, true), 496 | 497 | // Select previous sibling block 498 | "Ctrl-ArrowUp": selectionCommmand(selectPrevSibling, true), 499 | 500 | // Select next block 501 | ArrowDown: selectionCommmand(selectNext, true), 502 | "Shift-ArrowDown": expandCommmand(expandNext, true), 503 | 504 | // Select previous block 505 | ArrowUp: selectionCommmand(selectPrev, true), 506 | "Shift-ArrowUp": expandCommmand(expandPrev, true), 507 | }) 508 | 509 | return handler(view, event) 510 | }, 511 | 512 | handleDOMEvents: { 513 | mousedown(view, event) { 514 | // Handle shift-click to expand selection. 515 | const pluginState = pluginKey.getState(view.state) 516 | if (!pluginState) { 517 | return false 518 | } 519 | 520 | const pluginDispatch = (action: BlockSelectionPluginAction) => { 521 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 522 | } 523 | 524 | if (!event.shiftKey) { 525 | pluginDispatch({ newState: null }) 526 | return false 527 | } 528 | 529 | const result = view.posAtCoords({ 530 | left: event.clientX, 531 | top: event.clientY, 532 | }) 533 | if (!result) { 534 | return false 535 | } 536 | 537 | // Prevent text selection. 538 | event.preventDefault() 539 | 540 | const $pos = view.state.doc.resolve(result.pos) 541 | 542 | // TODO: sometimes this doesn't work great and there's a runtime error... 543 | const nodePos = $pos.depth === 0 ? $pos.pos : $pos.before() 544 | 545 | const $node = new BlockPosition(view.state.doc.resolve(nodePos)) 546 | const { $anchor, $head } = pluginState 547 | 548 | const $start = $anchor.$from.pos < $head.$from.pos ? $anchor : $head 549 | const $end = $anchor.$from.pos > $head.$from.pos ? $anchor : $head 550 | 551 | // If node is before start 552 | if ($node.$from.pos < $start.$from.pos) { 553 | pluginDispatch({ newState: new BlockSelection($end, $node) }) 554 | return true 555 | } 556 | // If node is after end 557 | if ($node.$to.pos > $end.$to.pos) { 558 | pluginDispatch({ newState: new BlockSelection($start, $node) }) 559 | return true 560 | } 561 | // If node is inside 562 | pluginDispatch({ newState: new BlockSelection($anchor, $node) }) 563 | return true 564 | }, 565 | }, 566 | decorations(editorState) { 567 | const state = pluginKey.getState(editorState) 568 | 569 | if (!state) { 570 | return null 571 | } 572 | console.log( 573 | `BlockSelection(${state.$anchor.$from.pos}, ${state.$head.$to.pos})` 574 | ) 575 | 576 | const ranges: Array<[number, number]> = [] 577 | 578 | // Set to true to show nested highlights. 579 | const showNested = false 580 | 581 | let lastPos = -1 582 | 583 | const { $anchor, $head } = state 584 | const $start = $anchor.$from.pos < $head.$from.pos ? $anchor : $head 585 | const $end = $anchor.$from.pos > $head.$from.pos ? $anchor : $head 586 | 587 | let $node = $start 588 | while ($node.$from.pos < $end.$to.pos) { 589 | if (showNested) { 590 | ranges.push([$node.$from.pos, $node.$to.pos]) 591 | } else if ($node.$from.pos >= lastPos) { 592 | ranges.push([$node.$from.pos, $node.$to.pos]) 593 | lastPos = $node.$to.pos 594 | } 595 | 596 | const $next = selectNext(editorState, $node) 597 | if (!$next) { 598 | break 599 | } 600 | $node = $next 601 | } 602 | 603 | return DecorationSet.create( 604 | editorState.doc, 605 | ranges.map(([from, to]) => 606 | Decoration.node(from, to, { 607 | class: "custom-selection", 608 | }) 609 | ) 610 | ) 611 | }, 612 | }, 613 | }) 614 | -------------------------------------------------------------------------------- /src/components/Architecture.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | deleteSelection, 3 | joinBackward, 4 | splitBlock, 5 | toggleMark, 6 | } from "prosemirror-commands" 7 | import { inputRules, wrappingInputRule } from "prosemirror-inputrules" 8 | import { 9 | DOMParser, 10 | DOMSerializer, 11 | Fragment, 12 | MarkSpec, 13 | NodeSpec, 14 | Schema, 15 | } from "prosemirror-model" 16 | import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state" 17 | import { 18 | Decoration, 19 | DecorationSet, 20 | EditorView, 21 | NodeViewConstructor, 22 | } from "prosemirror-view" 23 | import React, { CSSProperties, useLayoutEffect, useRef, useState } from "react" 24 | import { keydownHandler } from "./Keyboard" 25 | 26 | export function Architecture() { 27 | const [state, setState] = useState( 28 | initEditorState({ ...SimpleEditor, html: `

Hello World

` }) 29 | ) 30 | 31 | const [focused, setFocused] = useState(false) 32 | 33 | // Change editor state from outside Prosemirror. 34 | const removeMarks = () => { 35 | const tr = state.tr 36 | tr.removeMark(0, state.doc.content.size) 37 | const nextState = state.apply(tr) 38 | setState(nextState) 39 | } 40 | 41 | const docFocused = focusKey.getState(state) === null 42 | 43 | return ( 44 |
45 |
Simple Prosemirror Example
46 |
47 | 48 |
49 | setFocused(true)} 58 | onBlur={() => setFocused(false)} 59 | > 60 | {(state, view) => ( 61 | <> 62 | 63 |
Focus: {focusKey.getState(state) || "null"}
64 | 65 | )} 66 |
67 |
68 | ) 69 | } 70 | 71 | type Editor = { 72 | schemaPlugins: SchemaPlugin[] 73 | statePlugins?: StatePlugin[] 74 | viewPlugins?: ViewPlugin[] 75 | commandPlugins?: CommandPlugin[] 76 | nodeViewPlugins?: NodeViewPlugin[] 77 | } 78 | 79 | function initEditorState(args: { 80 | html?: string 81 | schemaPlugins: SchemaPlugin[] 82 | statePlugins?: StatePlugin[] 83 | }) { 84 | const schema = createSchema(args.schemaPlugins) 85 | const plugins = (args.statePlugins || []).flatMap((fn) => fn(schema)) 86 | const doc = args.html ? parseHtmlString(schema, args.html) : undefined 87 | const state = EditorState.create({ plugins, schema, doc }) 88 | return state 89 | } 90 | 91 | function ProsemirrorEditor(props: { 92 | viewPlugins?: ViewPlugin[] 93 | commandPlugins?: CommandPlugin[] 94 | nodeViewPlugins?: NodeViewPlugin[] 95 | state: EditorState 96 | style?: CSSProperties 97 | setState: (nextState: EditorState) => void 98 | // NOTE: This abstraction might change back to a view plugin, because we need to figure out 99 | // a way to plumb React context into node views anyways... 100 | children?: (state: EditorState, view: EditorView) => React.ReactNode 101 | 102 | onFocus?: () => void 103 | onBlur?: () => void 104 | }) { 105 | const { state, setState, style } = props 106 | const nodeRef = useRef(null) 107 | // NOTE: this doesn't ever change 108 | const schema = state.schema 109 | 110 | const [view, setView] = useState() 111 | useLayoutEffect(() => { 112 | const node = nodeRef.current! 113 | 114 | // NOTE: assuming these don't change. 115 | const commands = (props.commandPlugins || []).flatMap((fn) => fn(schema)) 116 | const plugins = (props.viewPlugins || []).flatMap((fn) => fn(schema)) 117 | 118 | const nodeViews = (props.nodeViewPlugins || []).reduce( 119 | (a, b) => Object.assign(a, b), 120 | {} 121 | ) 122 | 123 | const view = new EditorView( 124 | { mount: node }, 125 | { 126 | state, 127 | plugins, 128 | nodeViews, 129 | handleKeyDown: (view, event) => { 130 | // Or register commands with a command prompt or something., 131 | return handleCommandShortcut(view, commands, event) 132 | }, 133 | dispatchTransaction(tr) { 134 | const nextState = view.state.apply(tr) 135 | // Don't want for React to re-render to update the view state. Otherwise 136 | // if there are two transactions in a row, before the next render, then 137 | // the second transaction will not have the result of the first transaction. 138 | view.updateState(nextState) 139 | setState(nextState) 140 | }, 141 | } 142 | ) 143 | setView(view) 144 | // For debugging... 145 | ;(window as any).view = view 146 | 147 | return () => { 148 | view.destroy() 149 | } 150 | }, []) 151 | 152 | useLayoutEffect(() => { 153 | if (!view) return 154 | if (view.state === state) return 155 | 156 | // This will update the view if we edit the state outside of Prosemirror. 157 | view.updateState(state) 158 | }, [view, state]) 159 | 160 | useLayoutEffect(() => { 161 | if (!view) return 162 | if (!props.onFocus) return 163 | const onFocus = props.onFocus 164 | view.dom.addEventListener("focus", onFocus) 165 | return () => { 166 | view.dom.removeEventListener("focus", onFocus) 167 | } 168 | }, [view, props.onFocus]) 169 | 170 | useLayoutEffect(() => { 171 | if (!view) return 172 | if (!props.onBlur) return 173 | const onBlur = props.onBlur 174 | view.dom.addEventListener("blur", onBlur) 175 | return () => { 176 | view.dom.removeEventListener("blur", onBlur) 177 | } 178 | }, [view, props.onBlur]) 179 | 180 | return ( 181 | <> 182 | {props.children && view && props.children(state, view)} 183 |
184 | 185 | ) 186 | } 187 | 188 | // ============================================================================ 189 | // Schema Helpers 190 | // ============================================================================ 191 | 192 | interface SchemaPlugin { 193 | nodes?: { [K in N]?: NodeSpec } 194 | marks?: { [K in M]?: MarkSpec } 195 | } 196 | 197 | function createSchemaPlugin( 198 | plugin: SchemaPlugin 199 | ) { 200 | return plugin 201 | } 202 | 203 | function createSchema>(plugins: T[]) { 204 | const nodes = plugins.reduce( 205 | (acc, plugin) => Object.assign(acc, plugin.nodes), 206 | {} as Record 207 | ) 208 | const marks = plugins.reduce( 209 | (acc, plugin) => Object.assign(acc, plugin.marks), 210 | {} as Record 211 | ) 212 | 213 | const schema = new Schema({ 214 | nodes: { ...nodes }, 215 | marks: { ...marks }, 216 | }) 217 | 218 | // https://stackoverflow.com/questions/49401866/all-possible-keys-of-an-union-type 219 | type KeysOfUnion = T extends T ? keyof T : never 220 | 221 | return schema as Schema, KeysOfUnion> 222 | } 223 | 224 | // ============================================================================ 225 | // Schema Plugins. 226 | // ============================================================================ 227 | 228 | const DocumentSchema = createSchemaPlugin({ 229 | nodes: { 230 | text: { 231 | group: "inline", 232 | }, 233 | 234 | paragraph: { 235 | content: "inline*", 236 | group: "block", 237 | toDOM() { 238 | return ["p", 0] 239 | }, 240 | parseDOM: [{ tag: "p" }], 241 | }, 242 | 243 | doc: { content: "block+" }, 244 | }, 245 | }) 246 | 247 | const QuoteBlockSchema = createSchemaPlugin({ 248 | nodes: { 249 | blockquote: { 250 | content: "block+", 251 | group: "block", 252 | defining: true, 253 | parseDOM: [{ tag: "blockquote" }], 254 | toDOM() { 255 | return ["blockquote", 0] 256 | }, 257 | }, 258 | }, 259 | }) 260 | 261 | const ItalicSchema = createSchemaPlugin({ 262 | marks: { 263 | em: { 264 | parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], 265 | toDOM() { 266 | return ["em", 0] 267 | }, 268 | }, 269 | }, 270 | }) 271 | 272 | // ============================================================================ 273 | // View Plugin. 274 | // ============================================================================ 275 | 276 | type ViewPlugin = (schema: Schema) => Plugin[] 277 | 278 | // ============================================================================ 279 | // State Plugin. 280 | // ============================================================================ 281 | 282 | type StatePlugin = (schema: Schema) => Plugin[] 283 | 284 | // NOTE: schema is not well-typed here. It's a bit annoying, but it takes a lot 285 | // of type mangling to make it work... 286 | const QuoteBlockStatePlugins: StatePlugin = (schema) => [ 287 | inputRules({ 288 | rules: [wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote)], 289 | }), 290 | ] 291 | 292 | // ============================================================================ 293 | // Command Plugin. 294 | // ============================================================================ 295 | 296 | type EditorCommand = { 297 | name: string 298 | shortcut?: string 299 | command: ( 300 | state: EditorState, 301 | dispatch: ((tr: Transaction) => void) | undefined, 302 | view: EditorView 303 | ) => boolean 304 | } 305 | 306 | type CommandPlugin = (schema: Schema) => EditorCommand[] 307 | 308 | const ItalicCommands: CommandPlugin = (schema) => [ 309 | { 310 | name: "Italic", 311 | shortcut: "Meta-i", 312 | command: toggleMark(schema.marks.em), 313 | }, 314 | ] 315 | 316 | const DocumentCommands: CommandPlugin = (schema) => [ 317 | { 318 | name: "Split block", 319 | category: "structure", 320 | shortcut: "Enter", 321 | command: splitBlock, 322 | }, 323 | { 324 | name: "Delete selection", 325 | category: "structure", 326 | shortcut: "Backspace", 327 | command: deleteSelection, 328 | }, 329 | { 330 | name: "Join backward", 331 | category: "structure", 332 | shortcut: "Backspace", 333 | command: joinBackward, 334 | }, 335 | ] 336 | 337 | // NOTE: this thing is hard to test currently -- you need to mangle some things. 338 | // 1. You need to make sure React is batching updates so onKeyDown needs to be 339 | // registered through React. 340 | // 2. You can see that it doesn't work if we delete view.updateState from the 341 | // dispatchTransaction callback. 342 | const DoubleDispatchCommands: CommandPlugin = (schema) => [ 343 | { 344 | name: "Double Dispatch", 345 | shortcut: "Meta-d", 346 | command: (state, dispatch, view) => { 347 | console.log("DISPATCH1") 348 | DELETE_FIRST: { 349 | const tr = view.state.tr 350 | tr.replace(1, 2) 351 | if (dispatch) dispatch(tr) 352 | } 353 | console.log("DISPATCH2") 354 | DELETE_LAST: { 355 | const tr = view.state.tr 356 | tr.replace(tr.doc.content.size - 2, tr.doc.content.size - 1) 357 | if (dispatch) dispatch(tr) 358 | } 359 | return true 360 | }, 361 | }, 362 | ] 363 | 364 | function handleCommandShortcut( 365 | view: EditorView, 366 | commands: EditorCommand[], 367 | event: KeyboardEvent 368 | ): boolean { 369 | for (const command of commands) { 370 | if (!command.shortcut) continue 371 | if ( 372 | keydownHandler({ 373 | [command.shortcut]: () => 374 | command.command(view.state, view.dispatch, view), 375 | })(event) 376 | ) 377 | return true 378 | } 379 | return false 380 | } 381 | 382 | // ============================================================================ 383 | // Node View. 384 | // ============================================================================ 385 | 386 | // ============================================================================ 387 | // Parsing. 388 | // ============================================================================ 389 | 390 | function parseHtmlString(schema: Schema, htmlString: string) { 391 | const doc = document.implementation.createHTMLDocument("New Document") 392 | const div = doc.createElement("div") 393 | div.innerHTML = htmlString 394 | return DOMParser.fromSchema(schema).parse(div) 395 | } 396 | 397 | function formatHtmlString(schema: Schema, content: Fragment) { 398 | const doc = document.implementation.createHTMLDocument("New Document") 399 | const div = doc.createElement("div") 400 | const fragment = DOMSerializer.fromSchema(schema).serializeFragment(content) 401 | div.appendChild(fragment) 402 | return div.innerHTML 403 | } 404 | 405 | // ============================================================================ 406 | // FocusState. 407 | // ============================================================================ 408 | 409 | type FocusPluginState = string | null 410 | 411 | const focusKey = new PluginKey("focus") 412 | 413 | const FocusStatePlugins: StatePlugin = (schema) => [ 414 | new Plugin({ 415 | key: focusKey, 416 | state: { 417 | init: () => null, 418 | apply: (tr, state) => { 419 | const action = tr.getMeta(focusKey) 420 | if (action !== undefined) return action 421 | return state 422 | }, 423 | }, 424 | }), 425 | ] 426 | 427 | function setFocus(tr: Transaction, focusState: FocusPluginState) { 428 | tr.setMeta(focusKey, focusState) 429 | } 430 | 431 | // ============================================================================ 432 | // PopupMenu. 433 | // ============================================================================ 434 | 435 | type PopupPluginOpenState = { open: true; index: number } 436 | type PopupPluginState = { open: false } | PopupPluginOpenState 437 | 438 | const popupMenuKey = new PluginKey("popupMenu") 439 | 440 | const PopupMenuStatePlugins: StatePlugin = (schema) => [ 441 | new Plugin({ 442 | key: popupMenuKey, 443 | state: { 444 | init: () => ({ open: false }), 445 | apply: (tr, state) => { 446 | const action = tr.getMeta(popupMenuKey) 447 | if (action) return action 448 | return state 449 | }, 450 | }, 451 | appendTransaction(trs, oldState, newState) { 452 | if (newState.selection.empty && popupMenuKey.getState(newState)!.open) { 453 | const tr = newState.tr 454 | tr.setMeta(popupMenuKey, { open: false }) 455 | setFocus(tr, null) 456 | return tr 457 | } 458 | return null 459 | }, 460 | }), 461 | ] 462 | 463 | const PopupMenuCommands: CommandPlugin = (schema) => [ 464 | { 465 | name: "Toggle Popup Menu", 466 | shortcut: "Meta-/", 467 | command: (state, dispatch, view) => { 468 | const tr = state.tr 469 | 470 | if (state.selection.empty) return true 471 | 472 | const popupState = popupMenuKey.getState(state)! 473 | if (popupState.open) { 474 | tr.setMeta(popupMenuKey, { open: false }) 475 | setFocus(tr, null) 476 | } else { 477 | tr.setMeta(popupMenuKey, { open: true, index: 0 }) 478 | setFocus(tr, "popup") 479 | } 480 | 481 | if (dispatch) dispatch(tr) 482 | 483 | return true 484 | }, 485 | }, 486 | ] 487 | 488 | const PopupMenuViewPlugins: ViewPlugin = (schema) => [ 489 | new Plugin({ 490 | props: { 491 | decorations: (state) => { 492 | const popupState = popupMenuKey.getState(state)! 493 | if (!popupState.open) return null 494 | const { selection } = state 495 | return DecorationSet.create(state.doc, [ 496 | Decoration.inline(selection.from, selection.to, { 497 | nodeName: "span", 498 | style: "background:#222;color:white;", 499 | }), 500 | ]) 501 | }, 502 | }, 503 | }), 504 | ] 505 | 506 | function PopupMenu(props: { state: EditorState; view: EditorView }) { 507 | const popupState = popupMenuKey.getState(props.state)! 508 | if (!popupState.open) return null 509 | 510 | return 511 | } 512 | 513 | function PopupMenuOpen(props: { 514 | popupState: PopupPluginOpenState 515 | state: EditorState 516 | view: EditorView 517 | }) { 518 | const { view, state, popupState } = props 519 | const [rect, setRect] = useState<{ left: number; bottom: number }>() 520 | 521 | useLayoutEffect(() => { 522 | setRect(view.coordsAtPos(state.selection.from)) 523 | }, [state]) 524 | 525 | if (!rect) return null 526 | 527 | const focused = focusKey.getState(state) === "popup" 528 | 529 | return ( 530 |
543 | Hello 544 |
545 | ) 546 | } 547 | 548 | // ============================================================================ 549 | // ColorSwatch. 550 | // ============================================================================ 551 | 552 | const ColorSwatchSchema = createSchemaPlugin({ 553 | nodes: { 554 | color: { 555 | group: "inline", 556 | inline: true, 557 | atom: true, 558 | }, 559 | toDOM: ["span.color"], 560 | fromDOM: ["span.color"], 561 | }, 562 | }) 563 | 564 | const ColorSwatchCommands: CommandPlugin = (schema) => [ 565 | { 566 | name: "Insert Color Swatch", 567 | shortcut: "Meta-e", 568 | command: (state, dispatch, view) => { 569 | const tr = state.tr 570 | const node = schema.nodes.color.create() 571 | const { from, to } = state.selection 572 | tr.replaceWith(from, to, node) 573 | if (dispatch) dispatch(tr) 574 | return true 575 | }, 576 | }, 577 | ] 578 | 579 | type NodeViewPlugin = { [key: string]: NodeViewConstructor } 580 | 581 | const ColorSwatchNodeViews: NodeViewPlugin = { 582 | color: (node, view, getPos) => { 583 | const div = document.createElement("div") 584 | div.style.display = "inline-block" 585 | div.style.height = "16px" 586 | div.style.width = "16px" 587 | div.style.border = "1px solid #999" 588 | div.style.borderRadius = "2px" 589 | div.style.backgroundColor = "red" 590 | 591 | return { dom: div } 592 | }, 593 | } 594 | 595 | // TODO: 596 | // - controlled focus. 597 | // - can we persist focus across refresh? 598 | // - nodeView 599 | // - how can we plumb react context through? 600 | // - syncing internal/external state 601 | // - how to "pass external props" to a node view 602 | // 1. we can subscribe to some state somewhere. 603 | // this does not work when you're trying to update a plugin state based on external state. 604 | // 2. we can denormalize that state into a plugin state. 605 | // - how to "pass external props" to a state plugin 606 | // 1. we can re-construct the plugin and call state.reconfigure. 607 | // 2. we can denormalize that state into some plugin state. 608 | // 3. we can dispatch a transaction meta so the plugin updates its internal state. 609 | // Either way, we will be denormalizing for this to work. 610 | // - how to "pass external props" to a view plugin 611 | // 1. For decorations, I'm not sure how to "reconfigure" the view... 612 | // 2. For popups and stuff, it seems pretty easy to pass it "around" 613 | // 3. Views can also just subscribe to some data somewhere. 614 | 615 | // TODO: need to think through some more concrete examples. 616 | 617 | // ============================================================================ 618 | // SimpleEditor. 619 | // ============================================================================ 620 | 621 | const SimpleEditor: Editor = { 622 | schemaPlugins: [ 623 | DocumentSchema, 624 | QuoteBlockSchema, 625 | ItalicSchema, 626 | ColorSwatchSchema, 627 | ], 628 | statePlugins: [ 629 | FocusStatePlugins, 630 | QuoteBlockStatePlugins, 631 | PopupMenuStatePlugins, 632 | ], 633 | commandPlugins: [ 634 | DocumentCommands, 635 | ItalicCommands, 636 | PopupMenuCommands, 637 | ColorSwatchCommands, 638 | ], 639 | viewPlugins: [PopupMenuViewPlugins], 640 | nodeViewPlugins: [ColorSwatchNodeViews], 641 | } 642 | -------------------------------------------------------------------------------- /src/components/Property.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Property example. 4 | 5 | Resources: 6 | https://prosemirror.net/examples/footnote/ 7 | 8 | QA Doc: https://www.notion.so/ProseMirror-QA-65c6e1e971084547b6d6778c8e14bc6a 9 | 10 | ProseMirror Asks: 11 | - Set custom state properties so I can have my own "focused" state for the editor view. 12 | 13 | */ 14 | 15 | import { css } from "glamor" 16 | import { toggleMark } from "prosemirror-commands" 17 | import { history, redo, undo } from "prosemirror-history" 18 | import { keymap } from "prosemirror-keymap" 19 | import { 20 | MarkSpec, 21 | NodeSpec, 22 | Node as ProsemirrorNode, 23 | Schema, 24 | } from "prosemirror-model" 25 | import { 26 | EditorState, 27 | NodeSelection, 28 | Plugin, 29 | PluginKey, 30 | TextSelection, 31 | Transaction, 32 | } from "prosemirror-state" 33 | import { StepMap } from "prosemirror-transform" 34 | import { 35 | Decoration, 36 | DecorationSet, 37 | EditorView, 38 | NodeView, 39 | NodeViewConstructor, 40 | } from "prosemirror-view" 41 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" 42 | import { createRoot } from "react-dom/client" 43 | import { keyboardStack, useKeyboard } from "./Keyboard" 44 | 45 | // Non-breaking space. 46 | const nbsp = "\xa0" 47 | 48 | // ================================================================== 49 | // Autocomplete Plugin 50 | // ================================================================== 51 | 52 | type AutocompleteTokenPluginState = 53 | | { active: false } 54 | | AutocompleteTokenPluginActiveState 55 | 56 | type AutocompleteTokenPluginActiveState = { 57 | active: true 58 | // The cursor selection where we get text from 59 | range: { from: number; to: number } 60 | // The text we use to search 61 | text: string 62 | // Where to position the popup 63 | rect: { bottom: number; left: number } 64 | } 65 | 66 | type AutocompleteTokenPluginActions = { 67 | onCreate: (nodeAttr: string, range: { from: number; to: number }) => void 68 | onClose: () => void 69 | } 70 | 71 | type AutocompleteTokenPluginAction = 72 | | { type: "open"; pos: number; rect: { bottom: number; left: number } } 73 | | { type: "close" } 74 | 75 | type Extension = { 76 | plugins: Plugin[] 77 | nodes: { [key in N]: NodeSpec } 78 | nodeViews: { 79 | [key in N]: NodeViewConstructor 80 | // ( 81 | // node: ProsemirrorNode, 82 | // view: EditorView, 83 | // getPos: () => number, 84 | // decorations: readonly Decoration[] 85 | // ) => NodeView 86 | } 87 | } 88 | 89 | function createAutocompleteTokenPlugin(args: { 90 | nodeName: N 91 | triggerCharacter: string 92 | renderPopup: ( 93 | state: AutocompleteTokenPluginState, 94 | actions: AutocompleteTokenPluginActions 95 | ) => void 96 | }): Extension { 97 | const { nodeName, triggerCharacter, renderPopup } = args 98 | const pluginKey = new PluginKey>(nodeName) 99 | const dataAttr = `data-${nodeName}` 100 | 101 | const autocompleteTokenNode: NodeSpec = { 102 | group: "inline", 103 | content: "inline*", 104 | inline: true, 105 | atom: true, 106 | attrs: { [nodeName]: { default: "" } }, 107 | parseDOM: [ 108 | { 109 | // Make sure we set this dataAttr in the NodeView. 110 | tag: `span[${dataAttr}]`, 111 | getAttrs: (dom) => { 112 | if (dom instanceof HTMLElement) { 113 | var value = dom.getAttribute(dataAttr) 114 | return { [nodeName]: value } 115 | } 116 | return false 117 | }, 118 | }, 119 | ], 120 | } 121 | 122 | // This is an inline element with content. 123 | class TokenNodeView implements NodeView { 124 | dom: HTMLElement 125 | getPos: () => number | undefined 126 | 127 | node: ProsemirrorNode 128 | outerView: EditorView 129 | innerView: EditorView 130 | 131 | constructor( 132 | node: ProsemirrorNode, 133 | view: EditorView, 134 | getPos: () => number | undefined, 135 | decorations: readonly Decoration[] 136 | ) { 137 | this.node = node 138 | this.outerView = view 139 | this.getPos = getPos 140 | 141 | // Construct and style the DOM element. 142 | this.dom = document.createElement("span") 143 | 144 | const property = node.attrs[nodeName] 145 | this.dom.setAttribute(dataAttr, property) 146 | 147 | this.dom.style.background = "#ddd" 148 | 149 | const label = document.createElement("span") 150 | label.innerText = "." + property + ":" + nbsp 151 | this.dom.appendChild(label) 152 | 153 | const value = document.createElement("span") 154 | this.dom.appendChild(value) 155 | this.dom.appendChild(document.createTextNode(nbsp)) 156 | 157 | // Create the inner document. 158 | this.innerView = new EditorView( 159 | { mount: value }, 160 | { 161 | // Disable editing when the node is not selected to that the keyboard arrow 162 | // keys can move around this token. 163 | editable: () => { 164 | // This document is editable only when the outerView has this node selected. 165 | // It's possbile for this node to be selected without focus on the innerView, 166 | // but when we press Enter, we want this node to already be editable. 167 | const selection = this.outerView.state.selection as NodeSelection 168 | const editable = selection.node === this.node 169 | return editable 170 | }, 171 | attributes: { 172 | style: [ 173 | "display: inline", 174 | "outline: 0px solid transparent", 175 | "-webkit-font-smoothing: auto", 176 | "min-width:2em", 177 | ].join(";"), 178 | }, 179 | state: EditorState.create({ 180 | doc: this.node, 181 | plugins: [ 182 | keymap({ 183 | "Mod-z": () => 184 | undo(this.outerView.state, this.outerView.dispatch), 185 | "Mod-y": () => 186 | redo(this.outerView.state, this.outerView.dispatch), 187 | "Mod-Shift-z": () => 188 | redo(this.outerView.state, this.outerView.dispatch), 189 | }), 190 | // This plugin uses ProseMirror's decoration feature for placeholders. 191 | new Plugin({ 192 | props: { 193 | decorations(state) { 194 | let doc = state.doc 195 | if (doc.childCount === 0) { 196 | const span = document.createElement("span") 197 | span.innerText = "_" 198 | span.style.color = "#bbb" 199 | return DecorationSet.create(doc, [ 200 | Decoration.widget(0, span), 201 | ]) 202 | } 203 | }, 204 | }, 205 | }), 206 | ], 207 | }), 208 | handleKeyDown: (view, event) => { 209 | // Enter inside the token will move the cursor after the token. 210 | // If the token is empty, keep focus on enter so you aren't confused about focus 211 | // when you first create the token. 212 | if (event.key === "Enter" && view.state.doc.childCount !== 0) { 213 | const { tr, doc, selection } = this.outerView.state 214 | this.outerView.dispatch( 215 | tr.setSelection(TextSelection.create(doc, selection.$head.pos)) 216 | ) 217 | this.focusOuterView() 218 | return true 219 | } 220 | return false 221 | }, 222 | 223 | dispatchTransaction: this.dispatchInner, 224 | handleDOMEvents: { 225 | mousedown: () => { 226 | // Focus the innerView on mousedown do you can make a selection inside. 227 | // Also set the outerView's node selection to feel consistent with using 228 | // just the keyboard. 229 | if (this.outerView.hasFocus()) { 230 | const { 231 | state: { doc, tr }, 232 | } = this.outerView 233 | this.outerView.dispatch( 234 | tr.setSelection(NodeSelection.create(doc, this.getPos()!)) 235 | ) 236 | // Dispatch an empty transaction so that we recompute EditorView.editable() 237 | this.innerView.dispatch(this.innerView.state.tr) 238 | this.innerView.focus() 239 | } 240 | 241 | return false 242 | }, 243 | }, 244 | } 245 | ) 246 | } 247 | 248 | focusOuterView() { 249 | this.outerView.focus() 250 | // Dispatch an empty transaction so that we recompute EditorView.editable() 251 | this.innerView.dispatch(this.innerView.state.tr) 252 | } 253 | 254 | dispatchInner = (tr: Transaction) => { 255 | let { state, transactions } = this.innerView.state.applyTransaction(tr) 256 | this.innerView.updateState(state) 257 | 258 | // This code was taken from https://prosemirror.net/examples/footnote/ 259 | // It looks like this code takes normal editing transactions and passes them 260 | // on to the outerView using `this.getPos()` to offset correctly. 261 | if (!tr.getMeta("fromOutside")) { 262 | let outerTr = this.outerView.state.tr, 263 | offsetMap = StepMap.offset(this.getPos()! + 1) 264 | for (let i = 0; i < transactions.length; i++) { 265 | let steps = transactions[i].steps 266 | for (let j = 0; j < steps.length; j++) 267 | outerTr.step(steps[j].map(offsetMap)!) 268 | } 269 | if (outerTr.docChanged) { 270 | this.outerView.dispatch(outerTr) 271 | } 272 | } 273 | } 274 | 275 | // TODO: ProsemirrorNode doesn't work here. 276 | update(node: ProsemirrorNode) { 277 | if (!node.sameMarkup(this.node)) { 278 | return false 279 | } 280 | 281 | // This code was taken from https://prosemirror.net/examples/footnote/ 282 | // We've wired up undo/redo so that the outerView executes the undo. 283 | // When the outerView changes the state of this node, we need to update 284 | // the innerView state to match. 285 | this.node = node 286 | let state = this.innerView.state 287 | let start = node.content.findDiffStart(state.doc.content) 288 | if (start != null) { 289 | let { a: endA, b: endB } = node.content.findDiffEnd( 290 | state.doc.content 291 | ) as { 292 | a: number 293 | b: number 294 | } 295 | let overlap = start - Math.min(endA, endB) 296 | if (overlap > 0) { 297 | endA += overlap 298 | endB += overlap 299 | } 300 | this.innerView.dispatch( 301 | state.tr 302 | .replace(start, endB, node.slice(start, endA)) 303 | .setMeta("fromOutside", true) 304 | ) 305 | } 306 | return true 307 | } 308 | 309 | // This callback is only called when the node is selected from the outerView. 310 | handleKeyboard = (event: KeyboardEvent) => { 311 | // If the node is selected, focus the innerView on Enter and select all. 312 | if (this.outerView.hasFocus() && event.key === "Enter") { 313 | const { 314 | state: { tr, doc }, 315 | } = this.innerView 316 | 317 | const selection = TextSelection.between( 318 | doc.resolve(0), 319 | doc.resolve(doc.content.size) 320 | ) 321 | 322 | this.innerView.dispatch(tr.setSelection(selection)) 323 | this.innerView.focus() 324 | return true 325 | } 326 | 327 | // Unfocus the innerView on Escape unless the value is empty. 328 | // When the innerView is empty and the node is selected, we want the user to type 329 | // into the innerView instead of overwrite the token. 330 | if ( 331 | this.innerView.hasFocus() && 332 | event.key === "Escape" && 333 | this.innerView.state.doc.childCount !== 0 334 | ) { 335 | this.focusOuterView() 336 | return true 337 | } 338 | 339 | return false 340 | } 341 | 342 | selectNode() { 343 | this.dom.classList.add("ProseMirror-selectednode") 344 | 345 | // A good example of the gymnastics this keyboardStack helps with. 346 | keyboardStack.add(this.handleKeyboard) 347 | 348 | // Dispatch an empty transaction so that we recompute EditorView.editable() 349 | this.innerView.dispatch(this.innerView.state.tr) 350 | 351 | // Automatically focus the innerView if it's empty. This allows us to focus 352 | // immediately after creation and means that you cannot overwrite when the 353 | // node is selected the innerView is empty. 354 | if (this.innerView.state.doc.childCount === 0) { 355 | this.innerView.focus() 356 | } 357 | } 358 | 359 | deselectNode() { 360 | this.dom.classList.remove("ProseMirror-selectednode") 361 | keyboardStack.remove(this.handleKeyboard) 362 | 363 | // Dispatch an empty transaction so that we recompute EditorView.editable() 364 | this.innerView.dispatch(this.innerView.state.tr) 365 | } 366 | 367 | destroy() { 368 | // When you create a token, type in the middle, then undo the creation of the entire 369 | // token with focus inside the innerView, then we want to re-focus the outerView so 370 | // we can keep typing. 371 | if (this.innerView.hasFocus()) { 372 | this.focusOuterView() 373 | } 374 | 375 | this.innerView.destroy() 376 | keyboardStack.remove(this.handleKeyboard) 377 | } 378 | 379 | stopEvent(e: Event) { 380 | if (e.type === "keydown") { 381 | const event = e as KeyboardEvent 382 | 383 | // Delete from the beginning will allow bubbling up to delete the node. 384 | // We don't have to focus the outerView because that will happen in destroy() 385 | const selection = this.innerView.state.selection 386 | if ( 387 | selection.$anchor.pos === 0 && 388 | selection.$head.pos === 0 && 389 | event.key === "Backspace" 390 | ) { 391 | return false 392 | } 393 | } 394 | 395 | return this.innerView.dom.contains(e.target as HTMLElement) 396 | } 397 | 398 | ignoreMutation() { 399 | return true 400 | } 401 | } 402 | 403 | const autocompleteTokenPlugin = new Plugin>({ 404 | key: pluginKey, 405 | state: { 406 | init() { 407 | return { active: false } 408 | }, 409 | apply(tr, state) { 410 | const action: AutocompleteTokenPluginAction | undefined = 411 | tr.getMeta(pluginKey) 412 | if (action) { 413 | if (action.type === "open") { 414 | const { pos, rect } = action 415 | const newState: AutocompleteTokenPluginState = { 416 | active: true, 417 | range: { from: pos, to: pos }, 418 | text: "", 419 | rect: rect, 420 | } 421 | return newState 422 | } else if (action.type === "close") { 423 | return { active: false } 424 | } 425 | } 426 | 427 | // Update the range and compute query. 428 | if (state.active) { 429 | const { range } = state 430 | const from = 431 | range.from === range.to ? range.from : tr.mapping.map(range.from) 432 | const to = tr.mapping.map(range.to) 433 | 434 | const text = tr.doc.textBetween(from, to, "\n", "\0") 435 | if (!text.startsWith(triggerCharacter)) { 436 | // Close when deleting the #. 437 | return { active: false } 438 | } 439 | 440 | const queryText = text.slice(1) // Remove the leading "#" 441 | const newState: AutocompleteTokenPluginState = { 442 | ...state, 443 | range: { from, to }, 444 | text: queryText, 445 | } 446 | return newState 447 | } 448 | 449 | return { active: false } 450 | }, 451 | }, 452 | props: { 453 | handleKeyDown(view, e) { 454 | const state = pluginKey.getState(view.state) 455 | 456 | const dispatch = (action: AutocompleteTokenPluginAction) => { 457 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 458 | } 459 | 460 | // if key is #, check that the previous position is blank and the next position is blank. 461 | if (e.key === triggerCharacter) { 462 | const tr = view.state.tr 463 | var selection = tr.selection 464 | // Collapsed selection 465 | if (selection.from === selection.to) { 466 | const $position = selection.$from 467 | const isStart = $position.pos === $position.start() 468 | const isEnd = $position.pos === $position.end() 469 | const emptyPrev = Boolean( 470 | !isStart && 471 | $position.doc 472 | .textBetween($position.pos - 1, $position.pos, "\n", "\0") 473 | .match(/\s/) 474 | ) 475 | const emptyNext = Boolean( 476 | !isEnd && 477 | $position.doc 478 | .textBetween($position.pos, $position.pos + 1, "\n", "\0") 479 | .match(/\s/) 480 | ) 481 | 482 | if ((isStart || emptyPrev) && (isEnd || emptyNext)) { 483 | const pos = $position.pos 484 | const rect = view.coordsAtPos(pos) 485 | dispatch({ type: "open", pos, rect }) 486 | 487 | // Don't override the actual input. 488 | return false 489 | } 490 | } 491 | } 492 | 493 | return false 494 | }, 495 | decorations(editorState) { 496 | const state = pluginKey.getState(editorState) 497 | if (!state) return null 498 | if (!state.active) return null 499 | 500 | const { range } = state 501 | return DecorationSet.create(editorState.doc, [ 502 | Decoration.inline(range.from, range.to, { 503 | nodeName: "span", 504 | style: "color:#999;", 505 | }), 506 | ]) 507 | }, 508 | }, 509 | view() { 510 | return { 511 | update(view) { 512 | var state = pluginKey.getState(view.state)! 513 | 514 | const onCreate = ( 515 | value: string, 516 | range: { from: number; to: number } 517 | ) => { 518 | const node = view.state.schema.nodes[nodeName].create({ 519 | [nodeName]: value, 520 | }) 521 | 522 | const tr = view.state.tr.replaceWith(range.from, range.to, node) 523 | 524 | // Select the node to enter data inside. 525 | view.dispatch( 526 | tr.setSelection(NodeSelection.create(tr.doc, range.from)) 527 | ) 528 | } 529 | 530 | const dispatch = (action: AutocompleteTokenPluginAction) => { 531 | view.dispatch(view.state.tr.setMeta(pluginKey, action)) 532 | } 533 | const onClose = () => dispatch({ type: "close" }) 534 | 535 | renderPopup(state, { onCreate, onClose }) 536 | }, 537 | destroy() {}, 538 | } 539 | }, 540 | }) 541 | 542 | const nodeView: Extension["nodeViews"][N] = ( 543 | node, 544 | view, 545 | getPos, 546 | decorations 547 | ) => new TokenNodeView(node, view, getPos, decorations) 548 | 549 | const extension: Extension = { 550 | nodes: { [nodeName]: autocompleteTokenNode } as Extension["nodes"], 551 | nodeViews: { 552 | [nodeName]: nodeView, 553 | } as Extension["nodeViews"], 554 | plugins: [ 555 | autocompleteTokenPlugin, 556 | // Delete token when it is selected (and allowed to bubble up from stopEvent). 557 | keymap({ 558 | Backspace: (state, dispatch) => { 559 | const { node } = state.selection as NodeSelection 560 | if (node) { 561 | node.type === state.schema.nodes[nodeName] 562 | if (dispatch) { 563 | dispatch(state.tr.deleteSelection()) 564 | } 565 | return true 566 | } 567 | return false 568 | }, 569 | }), 570 | ], 571 | } 572 | 573 | return extension 574 | } 575 | 576 | // ================================================================== 577 | // Property Token Autocomplete 578 | // ================================================================== 579 | 580 | const propertyPopupElement = document.createElement("div") 581 | document.body.append(propertyPopupElement) 582 | 583 | const getSuggestions = (queryText: string) => { 584 | return ["Phone Number", "Email"].filter((str) => 585 | str.toLowerCase().includes(queryText.toLowerCase()) 586 | ) 587 | } 588 | 589 | css.global("[data-property].ProseMirror-selectednode", { 590 | outline: "1px solid blue", 591 | }) 592 | 593 | const propertyAutocomplete = createAutocompleteTokenPlugin({ 594 | nodeName: "property", 595 | triggerCharacter: ".", 596 | renderPopup: (state, actions) => { 597 | createRoot(propertyPopupElement).render( 598 | 599 | ) 600 | }, 601 | }) 602 | 603 | function AutocompletePopup(props: { 604 | state: AutocompleteTokenPluginState 605 | actions: AutocompleteTokenPluginActions 606 | }) { 607 | if (!props.state.active) { 608 | return null 609 | } 610 | 611 | return 612 | } 613 | 614 | function AutocompletePopupInner( 615 | props: AutocompleteTokenPluginActiveState & 616 | AutocompleteTokenPluginActions 617 | ) { 618 | const { rect, text, onClose, range, onCreate } = props 619 | 620 | const misses = useRef(0) 621 | 622 | const suggestions = useMemo(() => { 623 | const list = getSuggestions(text) 624 | if (list.length === 0) { 625 | misses.current++ 626 | } else { 627 | misses.current = 0 628 | } 629 | return list 630 | }, [text]) 631 | 632 | const [index, setIndex] = useState(0) 633 | 634 | useKeyboard({ 635 | ArrowUp: () => { 636 | setIndex(strangle(index - 1, [0, suggestions.length - 1])) 637 | return true 638 | }, 639 | ArrowDown: () => { 640 | setIndex(strangle(index + 1, [0, suggestions.length - 1])) 641 | return true 642 | }, 643 | Enter: () => { 644 | if (index < suggestions.length) { 645 | onCreate(suggestions[index], range) 646 | onClose() 647 | } 648 | return true 649 | }, 650 | Escape: () => { 651 | onClose() 652 | return true 653 | }, 654 | }) 655 | 656 | useEffect(() => { 657 | if (misses.current > 5) { 658 | onClose() 659 | } 660 | }, [misses.current > 5]) 661 | 662 | return ( 663 |
677 |
Query: "{text}"
678 | {suggestions.length === 0 &&
No Results
} 679 | {suggestions.map((suggestion, i) => { 680 | return ( 681 |
682 | 683 | .{suggestion}:{nbsp}{" "} 684 | 685 |
686 | ) 687 | })} 688 |
689 | ) 690 | } 691 | 692 | function strangle(n: number, minMax: [number, number]) { 693 | return Math.max(Math.min(n, minMax[1]), minMax[0]) 694 | } 695 | 696 | // ================================================================== 697 | // ProseMirror Editor 698 | // ================================================================== 699 | 700 | const doc: NodeSpec = { content: "inline*" } 701 | const text: NodeSpec = { group: "inline" } 702 | 703 | const bold: MarkSpec = { 704 | parseDOM: [{ tag: "strong" }], 705 | toDOM() { 706 | return ["strong", 0] 707 | }, 708 | } 709 | 710 | const nodes = { 711 | doc, 712 | text, 713 | ...propertyAutocomplete.nodes, 714 | } 715 | 716 | const marks = { bold } 717 | 718 | const schema = new Schema({ nodes, marks }) 719 | type EditorSchema = typeof schema 720 | type EditorNodeType = keyof typeof nodes 721 | type EditorMarkType = keyof typeof marks 722 | 723 | type NodeJSON = { 724 | type: EditorNodeType 725 | content?: Array 726 | attrs?: Record 727 | marks?: Array<{ type: "bold"; attrs?: Record }> 728 | text?: string 729 | } 730 | 731 | const initialDoc: NodeJSON = { 732 | type: "doc", 733 | content: [{ type: "text", text: "Type . to create a property-value." }], 734 | } 735 | 736 | export function Editor() { 737 | const ref = useRef(null) 738 | 739 | useLayoutEffect(() => { 740 | const node = ref.current 741 | if (!node) { 742 | throw new Error("Editor did not render!") 743 | } 744 | 745 | const state = EditorState.create({ 746 | schema: schema, 747 | doc: schema.nodeFromJSON(initialDoc), 748 | plugins: [ 749 | history(), 750 | keymap({ "Mod-z": undo, "Mod-y": redo, "Mod-Shift-z": redo }), 751 | keymap({ "Mod-b": toggleMark(schema.marks.bold) }), 752 | ...propertyAutocomplete.plugins, 753 | ], 754 | }) 755 | 756 | const view = new EditorView( 757 | { mount: node }, 758 | { 759 | state, 760 | attributes: { 761 | style: [ 762 | "outline: 0px solid transparent", 763 | "line-height: 1.5", 764 | "-webkit-font-smoothing: auto", 765 | "padding: 2em", 766 | ].join(";"), 767 | }, 768 | dispatchTransaction(transaction) { 769 | view.updateState(view.state.apply(transaction)) 770 | }, 771 | nodeViews: { 772 | ...propertyAutocomplete.nodeViews, 773 | }, 774 | } 775 | ) 776 | 777 | ;(window as any)["editor"] = { view } 778 | }, []) 779 | return
780 | } 781 | --------------------------------------------------------------------------------