├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── RELEASE.md ├── biome.json ├── package.json ├── packages ├── banger-editor │ ├── package.json │ ├── src │ │ ├── active-node.ts │ │ ├── base.ts │ │ ├── blockquote.ts │ │ ├── bold.ts │ │ ├── code-block.ts │ │ ├── code.ts │ │ ├── common │ │ │ ├── __tests__ │ │ │ │ └── collection.spec.ts │ │ │ ├── collection.ts │ │ │ ├── global-config.ts │ │ │ ├── index.ts │ │ │ ├── keybinding.ts │ │ │ ├── misc.ts │ │ │ └── types.ts │ │ ├── drag │ │ │ ├── drag-handle-ui.ts │ │ │ ├── drag-handle-view.ts │ │ │ ├── drag-handle.ts │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ ├── drop-gap-cursor.ts │ │ ├── hard-break.ts │ │ ├── heading.ts │ │ ├── history.ts │ │ ├── horizontal-rule.ts │ │ ├── hover.ts │ │ ├── image.ts │ │ ├── index.ts │ │ ├── italic.ts │ │ ├── link │ │ │ ├── index.ts │ │ │ ├── link.ts │ │ │ └── url-regex.ts │ │ ├── list.ts │ │ ├── paragraph.ts │ │ ├── placeholder.ts │ │ ├── pm-utils │ │ │ ├── README.md │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── helpers.spec.ts.snap │ │ │ │ └── helpers.spec.ts │ │ │ ├── commands.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ ├── node-utils.ts │ │ │ ├── selection.ts │ │ │ ├── transforms.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── pm │ │ │ └── index.ts │ │ ├── store │ │ │ ├── index.ts │ │ │ └── store.ts │ │ ├── strike.ts │ │ ├── suggestions │ │ │ ├── index.ts │ │ │ ├── input-rule.ts │ │ │ ├── keymap.ts │ │ │ ├── plugin-suggestion.ts │ │ │ └── suggestions-mark.ts │ │ ├── test-helpers │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── underline.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pm-markdown │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── list-helpers.spec.ts │ │ │ └── pm-markdown.spec.ts │ │ ├── index.ts │ │ ├── list-helpers.ts │ │ ├── list-markdown.ts │ │ ├── markdown.ts │ │ ├── pm.ts │ │ └── tokenizer.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── prosemirror-all │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts └── tooling │ ├── packager │ ├── build-dist-export-map.ts │ ├── build-src-export-map.ts │ ├── common.ts │ ├── copy-readme.ts │ ├── current-publishing-pkg.ts │ ├── execa.ts │ ├── find-root.ts │ ├── format-package-json.ts │ ├── index.ts │ ├── package.json │ ├── packager.ts │ └── set-version.ts │ ├── scripts │ ├── package.json │ ├── postpublish-run.ts │ ├── prepublish-run.ts │ ├── release-package-publish.ts │ ├── release-package-set-version.ts │ └── set-version.ts │ ├── tsconfig │ ├── base.json │ ├── library.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json │ └── tsup-config │ ├── index.d.mts │ ├── index.mjs │ ├── package.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json ├── vitest-global-setup.js └── vitest.config.ts /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main, dev, staging, production, v2, v3] 6 | pull_request: 7 | branches: [main, dev, staging, production, v2, v3] 8 | 9 | concurrency: 10 | group: bangle-banger-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | SENTRY_NO_PROGRESS_BAR: 1 15 | GITHUB_OWNER: ${{ secrets.GH_OWNER }} 16 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Install pnpm 23 | id: pnpm-install 24 | run: | 25 | corepack enable 26 | corepack prepare --activate 27 | - name: Use Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'pnpm' 32 | - name: Install bun 33 | uses: oven-sh/setup-bun@v2 34 | with: 35 | bun-version: latest 36 | - name: Install dependencies 37 | run: pnpm install 38 | - name: Setup Biome 39 | uses: biomejs/setup-biome@v2 40 | - name: Lint 41 | run: pnpm run lint:ci 42 | test: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Install pnpm 47 | id: pnpm-install 48 | run: | 49 | corepack enable 50 | corepack prepare --activate 51 | - name: Use Node.js 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: 20 55 | cache: 'pnpm' 56 | - name: Install dependencies 57 | run: pnpm install 58 | - name: test 59 | run: pnpm run test:ci 60 | # e2e-tests: 61 | # timeout-minutes: 10 62 | # runs-on: ubuntu-latest 63 | # container: mcr.microsoft.com/playwright:v1.48.2-focal 64 | # steps: 65 | # - uses: actions/checkout@v4 66 | # - name: Install pnpm 67 | # id: pnpm-install 68 | # run: | 69 | # corepack enable 70 | # corepack prepare --activate 71 | # - name: Use Node.js 72 | # uses: actions/setup-node@v4 73 | # with: 74 | # node-version: 20 75 | # cache: 'pnpm' 76 | # - name: Cache Playwright browsers 77 | # uses: actions/cache@v4 78 | # with: 79 | # path: ~/.cache/ms-playwright 80 | # key: ${{ runner.os }}-playwright-browsers 81 | # restore-keys: | 82 | # ${{ runner.os }}-playwright-browsers 83 | # - name: Install dependencies 84 | # run: pnpm install 85 | # - name: Install Playwright Browsers 86 | # run: pnpm -w run e2e-install 87 | # - name: Run Playwright tests 88 | # run: pnpm -w run e2e:ci 89 | # - uses: actions/upload-artifact@v4 90 | # if: ${{ !cancelled() }} 91 | # with: 92 | # name: playwright-report 93 | # path: playwright-report/ 94 | # retention-days: 30 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .cache 4 | .DS_Store 5 | coverage 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | .env 11 | .env.* 12 | .aider* 13 | 14 | dist 15 | 16 | *.tsbuildinfo 17 | .log/ 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "[javascript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 bangle-io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Banger Editor: ProseMirror Made Easy 2 | 3 | Banger Editor aims to provide missing pieces of ProseMirror components so you don't have to build them from scratch. And trust me, you don't want to build them from scratch. 4 | 5 | **Why Banger Editor?** 6 | 7 | * :battery: **Batteries Included:** Everything you need to get started with ProseMirror. 8 | * :spider_web: **Framework Agnostic:** Vanilla JS at its core, but works great with React. Vue support coming soon! 9 | * :+1: **Pure ProseMirror:** No extra abstractions, just pure ProseMirror. Compatible with libraries like `tiptap`, `milkdown`, and `novel`. 10 | * :hammer_and_wrench: **Headless & Customizable:** Use our `shadcn/ui`-like components or build your own. 11 | 12 | **Getting Started** 13 | 14 | ```bash 15 | npm install banger-editor 16 | ``` 17 | 18 | > [!NOTE] 19 | > Example Repo: [banger-vite-react-starter](https://github.com/kepta/banger-vite-react-starter) 20 | 21 | **Peer Dependencies** 22 | 23 | Banger Editor uses ProseMirror packages directly. You'll need to install the ones you need. 24 | 25 | **New to ProseMirror? Install these:** 26 | 27 | ```bash 28 | npm install orderedmap prosemirror-commands prosemirror-dropcursor prosemirror-flat-list prosemirror-gapcursor prosemirror-history prosemirror-inputrules prosemirror-keymap prosemirror-model prosemirror-schema-basic prosemirror-state prosemirror-transform prosemirror-view 29 | ``` 30 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Package Publishing & Release Guide 2 | 3 | This document outlines the process for publishing new package versions and creating releases. 4 | 5 | ## Prerequisites 6 | 7 | - Ensure you have npm account with appropriate permissions 8 | - Have `pnpm` installed globally 9 | - Have access to the GitHub repository with write permissions 10 | - Ensure you have 2FA enabled for npm (required for publishing) 11 | 12 | 13 | 14 | 1. ensure you are in `dev` branch and upto date with dev (`git pull origin dev`). 15 | 16 | 1. Figure out older version and what version you wanna release. 17 | 18 | 1. Run `pnpm tsx packages/tooling/scripts/set-version.ts --vv X.Y.Z` to bump the version. 19 | 20 | ``` 21 | # alpha 22 | pnpm tsx packages/tooling/scripts/set-version.ts --vv 2.0.0-alpha.11 23 | 24 | # latest 25 | pnpm tsx packages/tooling/scripts/set-version.ts --vv 2.1.0 26 | ``` 27 | 28 | 1. Go to github (link will be in the terminal) and create a new release with the tag that was created in the previous step. 29 | 30 | 1. Run `pnpm publish-alpha --otp=123456` or `publish-latest` to publish the packages to npm. 31 | 32 | 33 | ## PNPM Commands 34 | 35 | ``` 36 | # build all packages 37 | pnpm -r build 38 | 39 | # single concurrency - better output 40 | pnpm -r run --workspace-concurrency=1 "build" 41 | ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "files": { 4 | "ignore": [ 5 | "missing-test-types.d.ts", 6 | "packages/tooling/tsup-config", 7 | "packages/tooling/tsconfig", 8 | ".vscode/settings.json" 9 | ] 10 | }, 11 | "vcs": { 12 | "enabled": true, 13 | "clientKind": "git", 14 | "useIgnoreFile": true 15 | }, 16 | "formatter": { 17 | "enabled": true, 18 | "formatWithErrors": false, 19 | "indentStyle": "space", 20 | "indentWidth": 2, 21 | "lineEnding": "lf", 22 | "lineWidth": 80, 23 | "attributePosition": "auto", 24 | "ignore": [ 25 | "**/.cache", 26 | "**/.DS_Store", 27 | "**/.idea", 28 | "**/.vscode", 29 | "**/.yarnrc.yml", 30 | "**/*.hbs", 31 | "**/*.md", 32 | "**/build", 33 | "**/CHANGELOG.md", 34 | "**/coverage", 35 | "**/dist", 36 | "**/jsconfig-base.json", 37 | "**/jsconfig.json", 38 | "**/node_modules", 39 | "**/npm-debug.log", 40 | "**/tsconfig.json" 41 | ] 42 | }, 43 | "organizeImports": { 44 | "enabled": true 45 | }, 46 | "linter": { 47 | "enabled": true, 48 | "rules": { 49 | "recommended": true, 50 | "style": { 51 | "useTemplate": "info", 52 | "useImportType": "error" 53 | }, 54 | "suspicious": { 55 | "noExplicitAny": "warn" 56 | }, 57 | "nursery": { 58 | "useSortedClasses": "error" 59 | }, 60 | "correctness": { 61 | "noUnusedVariables": "warn", 62 | "noUnusedImports": "error" 63 | }, 64 | "complexity": { 65 | "noForEach": "off", 66 | "useLiteralKeys": "off" 67 | } 68 | } 69 | }, 70 | "javascript": { 71 | "jsxRuntime": "reactClassic", 72 | "formatter": { 73 | "jsxQuoteStyle": "double", 74 | "quoteProperties": "asNeeded", 75 | "trailingCommas": "all", 76 | "semicolons": "always", 77 | "arrowParentheses": "always", 78 | "bracketSpacing": true, 79 | "bracketSameLine": false, 80 | "quoteStyle": "single", 81 | "attributePosition": "auto" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangle.dev/banger-editor-root", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "@biomejs/biome": "1.9.4", 6 | "@manypkg/cli": "^0.23.0", 7 | "@types/node": "^22.13.14", 8 | "tsx": "^4.19.3", 9 | "typescript": "^5.8.2", 10 | "vitest": "^3.0.9" 11 | }, 12 | "manypkg": { 13 | "defaultBranch": "main", 14 | "workspaceProtocol": "allow" 15 | }, 16 | "packageManager": "pnpm@9.15.0", 17 | "private": true, 18 | "scripts": { 19 | "lint": "pnpm biome check", 20 | "lint:ci": "pnpm run typecheck && pnpm biome ci . --diagnostic-level=error", 21 | "lint:fix": "pnpm biome check --fix", 22 | "test:ci": "pnpm vitest run", 23 | "typecheck": "tsc -b", 24 | "publish-alpha": "pnpm -r --filter \"./packages/**\" publish --tag alpha --otp ${npm_config_otp} --access public" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/banger-editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "banger-editor", 3 | "version": "2.0.0-alpha.18", 4 | "author": { 5 | "name": "Kushan Joshi", 6 | "email": "0o3ko0@gmail.com", 7 | "url": "http://github.com/kepta" 8 | }, 9 | "description": "A modern collection of ProseMirror packages for building powerful editing experiences", 10 | "keywords": [ 11 | "prosemirror", 12 | "rich text editor", 13 | "editor", 14 | "typescript" 15 | ], 16 | "homepage": "https://bangle.io", 17 | "bugs": { 18 | "url": "https://github.com/bangle-io/banger-editor/issues" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/bangle-io/banger-editor.git", 24 | "directory": "packages/prosemirror-all" 25 | }, 26 | "type": "module", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "main": "./src/index.ts", 31 | "files": [ 32 | "dist", 33 | "src" 34 | ], 35 | "scripts": { 36 | "prepublishOnly": "tsx ../../packages/tooling/scripts/prepublish-run.ts", 37 | "postpublish": "tsx ../../packages/tooling/scripts/postpublish-run.ts", 38 | "build:tsup": "tsup --config tsup.config.ts" 39 | }, 40 | "dependencies": { 41 | "jotai": "^2.12.2" 42 | }, 43 | "devDependencies": { 44 | "@bangle.dev/packager": "workspace:*", 45 | "@types/orderedmap": "^2.0.0", 46 | "orderedmap": "^2.1.1", 47 | "prosemirror-commands": "^1.7.0", 48 | "prosemirror-dropcursor": "^1.8.1", 49 | "prosemirror-flat-list": "^0.5.4", 50 | "prosemirror-gapcursor": "^1.3.2", 51 | "prosemirror-history": "^1.4.1", 52 | "prosemirror-inputrules": "^1.5.0", 53 | "prosemirror-keymap": "^1.2.2", 54 | "prosemirror-markdown": "^1.13.2", 55 | "prosemirror-model": "^1.25.0", 56 | "prosemirror-schema-basic": "^1.2.4", 57 | "prosemirror-state": "^1.4.3", 58 | "prosemirror-test-builder": "^1.1.1", 59 | "prosemirror-transform": "^1.10.3", 60 | "prosemirror-view": "^1.38.1", 61 | "tsconfig": "workspace:*", 62 | "tsup": "^8.4.0", 63 | "tsup-config": "workspace:*" 64 | }, 65 | "peerDependencies": { 66 | "orderedmap": "*", 67 | "prosemirror-commands": "*", 68 | "prosemirror-dropcursor": "*", 69 | "prosemirror-flat-list": "*", 70 | "prosemirror-gapcursor": "*", 71 | "prosemirror-history": "*", 72 | "prosemirror-inputrules": "*", 73 | "prosemirror-keymap": "*", 74 | "prosemirror-model": "*", 75 | "prosemirror-schema-basic": "*", 76 | "prosemirror-state": "*", 77 | "prosemirror-test-builder": "*", 78 | "prosemirror-transform": "*", 79 | "prosemirror-view": "*" 80 | }, 81 | "exports": { 82 | ".": "./src/index.ts", 83 | "./active-node": "./src/active-node.ts", 84 | "./base": "./src/base.ts", 85 | "./blockquote": "./src/blockquote.ts", 86 | "./bold": "./src/bold.ts", 87 | "./code": "./src/code.ts", 88 | "./code-block": "./src/code-block.ts", 89 | "./common": "./src/common/index.ts", 90 | "./drag": "./src/drag/index.ts", 91 | "./drop-gap-cursor": "./src/drop-gap-cursor.ts", 92 | "./hard-break": "./src/hard-break.ts", 93 | "./heading": "./src/heading.ts", 94 | "./history": "./src/history.ts", 95 | "./horizontal-rule": "./src/horizontal-rule.ts", 96 | "./hover": "./src/hover.ts", 97 | "./image": "./src/image.ts", 98 | "./italic": "./src/italic.ts", 99 | "./link": "./src/link/index.ts", 100 | "./list": "./src/list.ts", 101 | "./package.json": "./package.json", 102 | "./paragraph": "./src/paragraph.ts", 103 | "./placeholder": "./src/placeholder.ts", 104 | "./pm": "./src/pm/index.ts", 105 | "./pm-utils": "./src/pm-utils/index.ts", 106 | "./store": "./src/store/index.ts", 107 | "./strike": "./src/strike.ts", 108 | "./suggestions": "./src/suggestions/index.ts", 109 | "./test-helpers": "./src/test-helpers/index.ts", 110 | "./underline": "./src/underline.ts" 111 | }, 112 | "peerDependenciesMeta": { 113 | "orderedmap": { 114 | "optional": true 115 | }, 116 | "prosemirror-dropcursor": { 117 | "optional": true 118 | }, 119 | "prosemirror-flat-list": { 120 | "optional": true 121 | }, 122 | "prosemirror-gapcursor": { 123 | "optional": true 124 | }, 125 | "prosemirror-history": { 126 | "optional": true 127 | }, 128 | "prosemirror-inputrules": { 129 | "optional": true 130 | }, 131 | "prosemirror-keymap": { 132 | "optional": true 133 | }, 134 | "prosemirror-model": { 135 | "optional": false 136 | }, 137 | "prosemirror-schema-basic": { 138 | "optional": true 139 | }, 140 | "prosemirror-state": { 141 | "optional": false 142 | }, 143 | "prosemirror-test-builder": { 144 | "optional": true 145 | }, 146 | "prosemirror-transform": { 147 | "optional": false 148 | }, 149 | "prosemirror-view": { 150 | "optional": false 151 | } 152 | }, 153 | "sideEffects": false, 154 | "bangleConfig": { 155 | "tsupEntry": { 156 | "index": "src/index.ts", 157 | "active-node": "src/active-node.ts", 158 | "base": "src/base.ts", 159 | "blockquote": "src/blockquote.ts", 160 | "bold": "src/bold.ts", 161 | "code": "src/code.ts", 162 | "code-block": "src/code-block.ts", 163 | "common": "src/common/index.ts", 164 | "drag": "src/drag/index.ts", 165 | "drop-gap-cursor": "src/drop-gap-cursor.ts", 166 | "hard-break": "src/hard-break.ts", 167 | "heading": "src/heading.ts", 168 | "history": "src/history.ts", 169 | "horizontal-rule": "src/horizontal-rule.ts", 170 | "hover": "src/hover.ts", 171 | "image": "src/image.ts", 172 | "italic": "src/italic.ts", 173 | "link": "src/link/index.ts", 174 | "list": "src/list.ts", 175 | "paragraph": "src/paragraph.ts", 176 | "placeholder": "src/placeholder.ts", 177 | "pm": "src/pm/index.ts", 178 | "pm-utils": "src/pm-utils/index.ts", 179 | "store": "src/store/index.ts", 180 | "strike": "src/strike.ts", 181 | "suggestions": "src/suggestions/index.ts", 182 | "test-helpers": "src/test-helpers/index.ts", 183 | "underline": "src/underline.ts" 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /packages/banger-editor/src/active-node.ts: -------------------------------------------------------------------------------- 1 | import { collection } from './common'; 2 | import { type EditorState, Plugin, PluginKey } from './pm'; 3 | import { Decoration, DecorationSet } from './pm'; 4 | import { findParentNode } from './pm-utils'; 5 | 6 | export interface ActiveNodeConfig { 7 | excludedNodes?: string[]; 8 | } 9 | 10 | export function setupActiveNode(config: ActiveNodeConfig = {}) { 11 | const plugin = { 12 | activeNode: pluginActiveNode(config), 13 | }; 14 | 15 | return collection({ 16 | id: 'active-node', 17 | plugin, 18 | }); 19 | } 20 | 21 | function pluginActiveNode(config: ActiveNodeConfig) { 22 | const key = new PluginKey('active-node'); 23 | 24 | return new Plugin({ 25 | key, 26 | state: { 27 | init: (_, state) => { 28 | return buildDeco(state, config); 29 | }, 30 | apply: (tr, old, _oldState, newState) => { 31 | if (!tr.selectionSet) { 32 | return old; 33 | } 34 | return buildDeco(newState, config); 35 | }, 36 | }, 37 | props: { 38 | decorations(state) { 39 | return key.getState(state); 40 | }, 41 | }, 42 | }); 43 | } 44 | 45 | function buildDeco( 46 | state: EditorState, 47 | config: ActiveNodeConfig, 48 | ): DecorationSet { 49 | const { selection } = state; 50 | const { $from } = selection; 51 | 52 | // Only decorate for an empty (cursor) selection 53 | if (!selection.empty) { 54 | return DecorationSet.empty; 55 | } 56 | 57 | // Get a valid block range for the cursor's parent block 58 | const range = $from.blockRange(); 59 | if (!range) { 60 | // If `blockRange()` is null/undefined, no valid block to decorate 61 | return DecorationSet.empty; 62 | } 63 | 64 | // If that parent is not actually a block node, skip 65 | const parentNode = $from.node(range.depth); 66 | if (!parentNode || !parentNode.isBlock) { 67 | return DecorationSet.empty; 68 | } 69 | 70 | if ( 71 | // the previous parentNode is different and is one level above the current parentNode 72 | // for example when in blockquote -> the `parentNode` is the blockquote node 73 | // where as the code below will scan for all parent nodes, in case of blockquote, it will be paragraph node -> blockquote node 74 | findParentNode((node) => { 75 | return !!config.excludedNodes?.includes(node.type.name); 76 | })(state.selection) 77 | ) { 78 | return DecorationSet.empty; 79 | } 80 | 81 | // Grab the start/end from the block range 82 | const { start, end } = range; 83 | 84 | // Now we can safely create a node decoration that spans the entire block node 85 | const deco = Decoration.node(start, end, { 86 | class: 'rounded-sm animate-editor-selected-node', 87 | }); 88 | 89 | // Build the set with one node decoration 90 | return DecorationSet.create(state.doc, [deco]); 91 | } 92 | -------------------------------------------------------------------------------- /packages/banger-editor/src/base.ts: -------------------------------------------------------------------------------- 1 | import { nodes as schemaBasicNodes } from 'prosemirror-schema-basic'; 2 | import { keybinding } from './common'; 3 | import { type CollectionType, collection, setPriority } from './common'; 4 | import { PRIORITY } from './common'; 5 | import type { Command, NodeSpec } from './pm'; 6 | import { baseKeymap } from './pm'; 7 | import { undoInputRule } from './pm'; 8 | import { safeInsert } from './pm-utils'; 9 | 10 | export type BaseConfig = { 11 | nameDoc?: string; 12 | nameText?: string; 13 | /** 14 | * Let user undo input rule by pressing backspace. 15 | * @default true 16 | */ 17 | backspaceToUndoInputRule?: boolean; 18 | /** 19 | * Let user undo input rule by pressing this key. 20 | * @default 'Mod-z' 21 | */ 22 | keyUndoInputRule?: string | false; 23 | }; 24 | 25 | type RequiredConfig = Required; 26 | 27 | const DEFAULT_CONFIG: RequiredConfig = { 28 | nameDoc: 'doc', 29 | nameText: 'text', 30 | backspaceToUndoInputRule: true, 31 | keyUndoInputRule: 'Mod-z', 32 | }; 33 | 34 | export function setupBase(userConfig?: BaseConfig) { 35 | const config = { 36 | ...DEFAULT_CONFIG, 37 | ...userConfig, 38 | }; 39 | 40 | const { nameDoc, nameText } = config; 41 | 42 | const nodes = { 43 | [nameDoc]: setPriority(schemaBasicNodes.doc, PRIORITY.baseSpec), 44 | [nameText]: setPriority(schemaBasicNodes.text, PRIORITY.baseSpec), 45 | } satisfies Record; 46 | 47 | const plugin = { 48 | baseKeymap: keybinding(baseKeymap, 'baseKeymap', PRIORITY.baseKeymap), 49 | undoInputRule: () => 50 | keybinding( 51 | [ 52 | [ 53 | config.backspaceToUndoInputRule ? 'Backspace' : false, 54 | undoInputRule, 55 | ], 56 | [config.keyUndoInputRule, undoInputRule], 57 | ], 58 | 'backspaceToUndoInputRule', 59 | PRIORITY.baseUndoInputRuleKey, 60 | ), 61 | }; 62 | 63 | const command = { 64 | insertText: insertText(), 65 | }; 66 | 67 | return collection({ 68 | id: 'base', 69 | nodes, 70 | plugin, 71 | command, 72 | markdown: markdown(config), 73 | }); 74 | } 75 | 76 | // COMMANDS 77 | export function insertText() { 78 | return ({ text }: { text?: string } = {}): Command => 79 | (state, dispatch) => { 80 | if (text) { 81 | const node = state.schema.text(text); 82 | dispatch?.(safeInsert(node)(state.tr)); 83 | } 84 | return true; 85 | }; 86 | } 87 | 88 | // MARKDOWN 89 | function markdown(_config: RequiredConfig): CollectionType['markdown'] { 90 | return { 91 | nodes: { 92 | text: { 93 | toMarkdown(state, node) { 94 | state.text(node.text ?? ''); 95 | }, 96 | }, 97 | }, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /packages/banger-editor/src/blockquote.ts: -------------------------------------------------------------------------------- 1 | import { nodes as schemaBasicNodes } from 'prosemirror-schema-basic'; 2 | import { type CollectionType, collection, keybinding } from './common'; 3 | import type { Command, NodeSpec, NodeType, Schema } from './pm'; 4 | import { lift, wrapIn } from './pm'; 5 | import { inputRules, wrappingInputRule } from './pm'; 6 | import { findParentNodeOfType } from './pm-utils'; 7 | import { 8 | type KeyCode, 9 | type PluginContext, 10 | defaultGetParagraphNodeType, 11 | getNodeType, 12 | insertEmptyParagraphAboveNode, 13 | insertEmptyParagraphBelowNode, 14 | moveNode, 15 | } from './pm-utils'; 16 | 17 | export type BlockquoteConfig = { 18 | name?: string; 19 | getParagraphNodeType?: (arg: Schema) => NodeType; 20 | // keys 21 | keyWrap?: KeyCode; 22 | keyToggle?: KeyCode; 23 | keyMoveUp?: KeyCode; 24 | keyMoveDown?: KeyCode; 25 | keyInsertEmptyParaAbove?: KeyCode; 26 | keyInsertEmptyParaBelow?: KeyCode; 27 | }; 28 | 29 | type RequiredConfig = Required; 30 | 31 | const DEFAULT_CONFIG: RequiredConfig = { 32 | name: 'blockquote', 33 | getParagraphNodeType: defaultGetParagraphNodeType, 34 | keyWrap: false, 35 | keyToggle: false, 36 | keyMoveUp: 'Alt-ArrowUp', 37 | keyMoveDown: 'Alt-ArrowDown', 38 | keyInsertEmptyParaAbove: 'Mod-Shift-Enter', 39 | keyInsertEmptyParaBelow: 'Mod-Enter', 40 | }; 41 | 42 | export function setupBlockquote(userConfig?: BlockquoteConfig) { 43 | const config = { 44 | ...DEFAULT_CONFIG, 45 | ...userConfig, 46 | }; 47 | const { name } = config; 48 | 49 | const nodes = { 50 | [name]: schemaBasicNodes.blockquote, 51 | } satisfies Record; 52 | 53 | const plugin = { 54 | inputRules: pluginInputRules(config), 55 | keybindings: pluginKeybindings(config), 56 | }; 57 | 58 | const command = { 59 | wrapBlockquote: wrapBlockquote(config), 60 | insertBlockquote: insertBlockquote(config), 61 | toggleBlockquote: toggleBlockquote(config), 62 | moveBlockquoteUp: moveBlockquoteUp(config), 63 | moveBlockquoteDown: moveBlockquoteDown(config), 64 | insertEmptyParaAbove: insertEmptyParaAboveBlockquote(config), 65 | insertEmptyParaBelow: insertEmptyParaBelowBlockquote(config), 66 | }; 67 | 68 | return collection({ 69 | id: 'blockquote', 70 | nodes, 71 | plugin, 72 | command, 73 | query: { 74 | isBlockquoteActive: isBlockquoteActive(config), 75 | }, 76 | markdown: markdown(config), 77 | }); 78 | } 79 | 80 | // PLUGINS 81 | function pluginInputRules(config: RequiredConfig) { 82 | const { name } = config; 83 | return ({ schema }: PluginContext) => { 84 | const node = schema.nodes[name]; 85 | if (!node) { 86 | throw new Error(`Node ${name} not found in schema`); 87 | } 88 | return inputRules({ 89 | rules: [wrappingInputRule(/^\s*>\s$/, node)], 90 | }); 91 | }; 92 | } 93 | 94 | function pluginKeybindings(config: RequiredConfig) { 95 | return () => { 96 | return keybinding( 97 | [ 98 | [config.keyWrap, wrapBlockquote(config)], 99 | [config.keyToggle, toggleBlockquote(config)], 100 | [config.keyMoveUp, moveBlockquoteUp(config)], 101 | [config.keyMoveDown, moveBlockquoteDown(config)], 102 | [ 103 | config.keyInsertEmptyParaAbove, 104 | insertEmptyParaAboveBlockquote(config), 105 | ], 106 | [ 107 | config.keyInsertEmptyParaBelow, 108 | insertEmptyParaBelowBlockquote(config), 109 | ], 110 | ], 111 | 'blockquote', 112 | ); 113 | }; 114 | } 115 | 116 | // COMMANDS 117 | function wrapBlockquote(config: RequiredConfig): Command { 118 | const { name } = config; 119 | return (state, dispatch) => { 120 | const type = getNodeType(state.schema, name); 121 | return wrapIn(type)(state, dispatch); 122 | }; 123 | } 124 | 125 | function insertBlockquote(config: RequiredConfig): Command { 126 | const { name } = config; 127 | return (state, dispatch) => { 128 | const type = getNodeType(state.schema, name); 129 | const node = type.createAndFill(); 130 | if (!node) { 131 | return false; 132 | } 133 | 134 | dispatch?.(state.tr.replaceSelectionWith(node)); 135 | return true; 136 | }; 137 | } 138 | 139 | function toggleBlockquote(config: RequiredConfig): Command { 140 | return (state, dispatch) => { 141 | if (isBlockquoteActive(config)(state)) { 142 | return lift(state, dispatch); 143 | } 144 | return wrapBlockquote(config)(state, dispatch); 145 | }; 146 | } 147 | 148 | function moveBlockquoteUp(config: RequiredConfig): Command { 149 | const { name } = config; 150 | return (state, dispatch) => { 151 | const type = getNodeType(state.schema, name); 152 | return moveNode(type, 'UP')(state, dispatch); 153 | }; 154 | } 155 | 156 | function moveBlockquoteDown(config: RequiredConfig): Command { 157 | const { name } = config; 158 | return (state, dispatch) => { 159 | const type = getNodeType(state.schema, name); 160 | return moveNode(type, 'DOWN')(state, dispatch); 161 | }; 162 | } 163 | 164 | function insertEmptyParaAboveBlockquote(config: RequiredConfig): Command { 165 | const { name } = config; 166 | return (state, dispatch) => { 167 | const type = getNodeType(state.schema, name); 168 | return insertEmptyParagraphAboveNode(type, config.getParagraphNodeType)( 169 | state, 170 | dispatch, 171 | ); 172 | }; 173 | } 174 | 175 | function insertEmptyParaBelowBlockquote(config: RequiredConfig): Command { 176 | const { name } = config; 177 | return (state, dispatch) => { 178 | const type = getNodeType(state.schema, name); 179 | return insertEmptyParagraphBelowNode(type, config.getParagraphNodeType)( 180 | state, 181 | dispatch, 182 | ); 183 | }; 184 | } 185 | 186 | // QUERIES 187 | export function isBlockquoteActive(config: RequiredConfig): Command { 188 | const { name } = config; 189 | return (state) => { 190 | const type = getNodeType(state.schema, name); 191 | return Boolean(findParentNodeOfType(type)(state.selection)); 192 | }; 193 | } 194 | 195 | // MARKDOWN 196 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 197 | const { name } = config; 198 | return { 199 | nodes: { 200 | [name]: { 201 | toMarkdown: (state, node) => { 202 | state.wrapBlock('> ', null, node, () => state.renderContent(node)); 203 | }, 204 | parseMarkdown: { 205 | blockquote: { 206 | block: name, 207 | }, 208 | }, 209 | }, 210 | }, 211 | }; 212 | } 213 | -------------------------------------------------------------------------------- /packages/banger-editor/src/bold.ts: -------------------------------------------------------------------------------- 1 | import { keybinding } from './common'; 2 | import { type CollectionType, collection } from './common'; 3 | import { toggleMark } from './pm'; 4 | import { inputRules } from './pm'; 5 | import type { MarkSpec } from './pm'; 6 | import type { Command, EditorState } from './pm'; 7 | import { 8 | type PluginContext, 9 | getMarkType, 10 | isMarkActiveInSelection, 11 | markInputRule, 12 | markPastePlugin, 13 | } from './pm-utils'; 14 | 15 | export type BoldConfig = { 16 | name?: string; 17 | keyToggle?: string | false; 18 | }; 19 | 20 | type RequiredConfig = Required; 21 | 22 | const DEFAULT_CONFIG: RequiredConfig = { 23 | name: 'bold', 24 | keyToggle: 'Mod-b', 25 | }; 26 | 27 | export function setupBold(userConfig: BoldConfig = {}) { 28 | const config = { 29 | ...DEFAULT_CONFIG, 30 | ...userConfig, 31 | }; 32 | const { name } = config; 33 | 34 | const marks: Record = { 35 | [name]: { 36 | parseDOM: [ 37 | { tag: 'strong' }, 38 | { 39 | tag: 'b', 40 | getAttrs: (node) => node.style.fontWeight !== 'normal' && null, 41 | }, 42 | { 43 | style: 'font-weight', 44 | getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null, 45 | }, 46 | ], 47 | toDOM: () => ['strong', 0] as const, 48 | }, 49 | }; 50 | 51 | const plugin = { 52 | inputRules: pluginInputRules(config), 53 | pasteRules1: pluginPasteRules1(config), 54 | pasteRules2: pluginPasteRules2(config), 55 | keybindings: pluginKeybindings(config), 56 | }; 57 | 58 | return collection({ 59 | id: 'bold', 60 | marks, 61 | plugin, 62 | command: { 63 | toggleBold: toggleBold(config), 64 | }, 65 | query: { 66 | isBoldActive: isBoldActive(config), 67 | }, 68 | markdown: markdown(config), 69 | }); 70 | } 71 | 72 | // PLUGINS 73 | function pluginInputRules(config: RequiredConfig) { 74 | return ({ schema }: PluginContext) => { 75 | const type = getMarkType(schema, config.name); 76 | 77 | return inputRules({ 78 | rules: [ 79 | markInputRule(/(?:^|\s)((?:\*\*)((?:[^*]+))(?:\*\*))$/, type), 80 | markInputRule(/(?:^|\s)((?:__)((?:[^__]+))(?:__))$/, type), 81 | ], 82 | }); 83 | }; 84 | } 85 | 86 | function pluginPasteRules1(config: RequiredConfig) { 87 | return ({ schema }: PluginContext) => { 88 | const type = getMarkType(schema, config.name); 89 | return markPastePlugin(/(?:^|\s)((?:\*\*)((?:[^*]+))(?:\*\*))/g, type); 90 | }; 91 | } 92 | 93 | function pluginPasteRules2(config: RequiredConfig) { 94 | return ({ schema }: PluginContext) => { 95 | const type = getMarkType(schema, config.name); 96 | return markPastePlugin(/(?:^|\s)((?:__)((?:[^__]+))(?:__))/g, type); 97 | }; 98 | } 99 | 100 | function pluginKeybindings(config: RequiredConfig) { 101 | return ({ schema }: PluginContext) => { 102 | const type = getMarkType(schema, config.name); 103 | return keybinding([[config.keyToggle, toggleMark(type)]], 'bold'); 104 | }; 105 | } 106 | 107 | // COMMAND 108 | function toggleBold(config: RequiredConfig): Command { 109 | return (state, dispatch) => { 110 | const markType = getMarkType(state.schema, config.name); 111 | 112 | return toggleMark(markType)(state, dispatch); 113 | }; 114 | } 115 | 116 | // QUERY 117 | function isBoldActive(config: RequiredConfig) { 118 | return (state: EditorState) => { 119 | const markType = getMarkType(state.schema, config.name); 120 | 121 | return isMarkActiveInSelection(markType, state); 122 | }; 123 | } 124 | 125 | // MARKDOWN 126 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 127 | const { name } = config; 128 | return { 129 | marks: { 130 | [name]: { 131 | toMarkdown: { 132 | open: '**', 133 | close: '**', 134 | mixable: true, 135 | expelEnclosingWhitespace: true, 136 | }, 137 | parseMarkdown: { strong: { mark: name } }, 138 | }, 139 | }, 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /packages/banger-editor/src/code-block.ts: -------------------------------------------------------------------------------- 1 | import { type CollectionType, collection, keybinding } from './common'; 2 | import type { Command, EditorState, NodeSpec, NodeType, Schema } from './pm'; 3 | import { setBlockType } from './pm'; 4 | import { inputRules, textblockTypeInputRule } from './pm'; 5 | import { findParentNodeOfType } from './pm-utils'; 6 | import { insertEmptyParagraphBelowNode } from './pm-utils'; 7 | import { 8 | type PluginContext, 9 | defaultGetParagraphNodeType, 10 | getNodeType, 11 | } from './pm-utils'; 12 | 13 | export type CodeBlockConfig = { 14 | name?: string; 15 | getParagraphNodeType?: (arg: Schema) => NodeType; 16 | // keys 17 | keyToCodeBlock?: string | false; 18 | keyExit?: string | false; 19 | }; 20 | 21 | type RequiredConfig = Required; 22 | 23 | const DEFAULT_CONFIG: RequiredConfig = { 24 | name: 'code_block', 25 | getParagraphNodeType: defaultGetParagraphNodeType, 26 | keyToCodeBlock: 'Mod-\\\\', 27 | keyExit: 'Enter', 28 | }; 29 | 30 | export function setupCodeBlock(userConfig?: CodeBlockConfig) { 31 | const config = { 32 | ...DEFAULT_CONFIG, 33 | ...userConfig, 34 | }; 35 | 36 | const { name } = config; 37 | 38 | const nodes: Record = { 39 | [name]: { 40 | attrs: { 41 | language: { default: '' }, 42 | }, 43 | content: 'text*', 44 | marks: '', 45 | group: 'block', 46 | code: true, 47 | defining: true, 48 | draggable: false, 49 | parseDOM: [ 50 | { 51 | tag: 'pre', 52 | preserveWhitespace: 'full', 53 | getAttrs: (dom: HTMLElement) => ({ 54 | language: dom.getAttribute('data-language') || '', 55 | }), 56 | }, 57 | ], 58 | toDOM: (node) => [ 59 | 'pre', 60 | { 'data-language': node.attrs.language }, 61 | ['code', 0], 62 | ], 63 | }, 64 | }; 65 | 66 | const plugin = { 67 | inputRules: pluginInputRules(config), 68 | keybindings: pluginKeybindings(config), 69 | }; 70 | 71 | return collection({ 72 | id: 'code-block', 73 | nodes, 74 | plugin, 75 | command: { 76 | toggleCodeBlock: toggleCodeBlock(config), 77 | }, 78 | query: { 79 | isCodeBlockActive: isCodeBlockActive(config), 80 | }, 81 | markdown: markdown(config), 82 | }); 83 | } 84 | 85 | // PLUGINS 86 | function pluginInputRules(config: RequiredConfig) { 87 | return ({ schema }: PluginContext) => { 88 | const { name } = config; 89 | const type = getNodeType(schema, name); 90 | return inputRules({ 91 | rules: [textblockTypeInputRule(/^```$/, type)], 92 | }); 93 | }; 94 | } 95 | 96 | function pluginKeybindings(config: RequiredConfig) { 97 | return keybinding([[config.keyExit, exitCodeBlock(config)]], 'code-block'); 98 | } 99 | 100 | // COMMANDS 101 | function exitCodeBlock(config: RequiredConfig): Command { 102 | return (state, dispatch) => { 103 | const { selection } = state; 104 | const { name, getParagraphNodeType } = config; 105 | const codeBlockType = getNodeType(state.schema, name); 106 | const { $from, from, empty } = selection; 107 | const node = findParentNodeOfType(codeBlockType)(state.selection); 108 | 109 | // Must have empty selection inside a code block 110 | if (!empty || !node) { 111 | return false; 112 | } 113 | 114 | if (dispatch) { 115 | const isAtEnd = from === $from.end(node.depth); 116 | const isAtStart = from === node.start; 117 | const lastNode = 118 | isAtEnd && !isAtStart 119 | ? $from.doc.nodeAt( 120 | // gives the last position inside node 121 | $from.end(node.depth) - 1, 122 | ) 123 | : null; 124 | 125 | const breakNode = 126 | lastNode?.isText && 127 | lastNode.textContent[lastNode.textContent.length - 1] === '\n'; 128 | 129 | if (isAtEnd && !isAtStart && breakNode) { 130 | // Case 1: User presses enter and the previous inline node is a hard break, and the line is empty 131 | insertEmptyParagraphBelowNode(codeBlockType, getParagraphNodeType)( 132 | state, 133 | dispatch, 134 | ); 135 | return true; 136 | } 137 | if (isAtStart && isAtEnd) { 138 | // Case 2: User presses enter and the entire text in the code block is empty 139 | insertEmptyParagraphBelowNode(codeBlockType, getParagraphNodeType)( 140 | state, 141 | dispatch, 142 | ); 143 | return true; 144 | } 145 | } 146 | 147 | return false; 148 | }; 149 | } 150 | 151 | function toggleCodeBlock(config: RequiredConfig): Command { 152 | return (state, dispatch) => { 153 | const { name } = config; 154 | const codeBlockType = getNodeType(state.schema, name); 155 | const paraType = config.getParagraphNodeType(state.schema); 156 | 157 | if (isCodeBlockActive(config)(state)) { 158 | return setBlockType(paraType)(state, dispatch); 159 | } 160 | return setBlockType(codeBlockType)(state, dispatch); 161 | }; 162 | } 163 | 164 | // QUERY 165 | function isCodeBlockActive(config: RequiredConfig) { 166 | return (state: EditorState) => { 167 | const { name } = config; 168 | const type = getNodeType(state.schema, name); 169 | return Boolean(findParentNodeOfType(type)(state.selection)); 170 | }; 171 | } 172 | 173 | // MARKDOWN 174 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 175 | const { name } = config; 176 | return { 177 | nodes: { 178 | [name]: { 179 | toMarkdown(state, node) { 180 | state.write(`\`\`\`${node.attrs.language || ''}\n`); 181 | state.text(node.textContent, false); 182 | state.ensureNewLine(); 183 | state.write('```'); 184 | state.closeBlock(node); 185 | }, 186 | parseMarkdown: { 187 | code_block: { block: name, noCloseToken: true }, 188 | fence: { 189 | block: name, 190 | getAttrs: (tok) => ({ language: tok.info || '' }), 191 | noCloseToken: true, 192 | }, 193 | }, 194 | }, 195 | }, 196 | }; 197 | } 198 | -------------------------------------------------------------------------------- /packages/banger-editor/src/common/global-config.ts: -------------------------------------------------------------------------------- 1 | const globalConfig: { debug: boolean } = { debug: false }; 2 | 3 | export function setGlobalConfig(config: { debug?: boolean } = {}) { 4 | globalConfig.debug = config.debug ?? false; 5 | } 6 | 7 | export function getGlobalConfig() { 8 | return globalConfig; 9 | } 10 | -------------------------------------------------------------------------------- /packages/banger-editor/src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collection'; 2 | export * from './global-config'; 3 | export * from './keybinding'; 4 | export * from './types'; 5 | export * from './misc'; 6 | -------------------------------------------------------------------------------- /packages/banger-editor/src/common/keybinding.ts: -------------------------------------------------------------------------------- 1 | import { type Command, type Plugin, keymap } from '../pm'; 2 | import { setPluginPriority } from './collection'; 3 | import { getGlobalConfig } from './global-config'; 4 | 5 | // use false to disable a keybinding 6 | export function keybinding( 7 | keys: Array<[string | false, Command]> | Record, 8 | name: string, 9 | priority?: number, 10 | debug = getGlobalConfig().debug, 11 | ): Plugin { 12 | const normalizedKeys = Array.isArray(keys) ? keys : Object.entries(keys); 13 | 14 | const object = Object.fromEntries( 15 | normalizedKeys 16 | .filter((param): param is [string, Command] => !!param[0]) 17 | .map(([key, command]): [string, Command] => [ 18 | key, 19 | !debug 20 | ? command 21 | : (...args) => { 22 | const result = command(...args); 23 | 24 | // to avoid logging non shortcut keys 25 | if (key.length > 1) { 26 | if (result !== false) { 27 | console.log(`✅ "${name}" handled keypress "${key}"`); 28 | } else { 29 | console.log(`❌ "${name}" did not handle keypress "${key}"`); 30 | } 31 | } 32 | return result; 33 | }, 34 | ]), 35 | ); 36 | 37 | const plugin = keymap(object); 38 | 39 | if (typeof priority === 'number') { 40 | setPluginPriority(plugin, priority, name); 41 | } 42 | 43 | return plugin; 44 | } 45 | -------------------------------------------------------------------------------- /packages/banger-editor/src/common/misc.ts: -------------------------------------------------------------------------------- 1 | // Check if the platform is macOS or iOS 2 | 3 | export const isMac = 4 | typeof navigator !== 'undefined' 5 | ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) 6 | : false; 7 | 8 | export function assertIsDefined( 9 | arg: T | null | undefined, 10 | hint?: string, 11 | ): asserts arg is T { 12 | if (typeof arg === 'undefined' || arg === null || arg === undefined) { 13 | throw new Error( 14 | `Assertion Failed: argument is undefined or null. ${hint ?? ''}`, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/banger-editor/src/common/types.ts: -------------------------------------------------------------------------------- 1 | export type Logger = { 2 | error: (...args: unknown[]) => void; 3 | warn: (...args: unknown[]) => void; 4 | info: (...args: unknown[]) => void; 5 | debug: (...args: unknown[]) => void; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drag/drag-handle-ui.ts: -------------------------------------------------------------------------------- 1 | export function createDragHandle(): HTMLElement { 2 | const dragHandle = document.createElement('button'); 3 | dragHandle.type = 'button'; 4 | 5 | dragHandle.className = [ 6 | 'bg-center', 7 | 'bg-no-repeat', 8 | 'cursor-grab', 9 | 'duration-200', 10 | 'ease-linear', 11 | 'fixed', 12 | 'focus-visible:outline-none', 13 | 'focus-visible:ring-2', 14 | 'focus-visible:ring-ring', 15 | 'h-6', 16 | 'items-center', 17 | 'justify-center', 18 | 'rounded-md', 19 | 'flex', 20 | 'transition-[background-color,opacity]', 21 | 'w-5', 22 | 'z-50', 23 | 'text-foreground/70', 24 | 'dark:text-foreground/70', 25 | 'hover:bg-muted', 26 | 'hover:text-muted-foreground', 27 | 'dark:hover:bg-muted', 28 | 'dark:hover:text-muted-foreground', 29 | 'active:bg-muted', 30 | 'active:cursor-grabbing', 31 | 'active:text-muted-foreground', 32 | ].join(' '); 33 | // Create grip icons 34 | const iconWrapper = document.createElement('div'); 35 | iconWrapper.classList.add('flex', 'pointer-events-none', 'text-current'); 36 | 37 | // Using GripVertical icon from lucide-react 38 | const iconSvg = ``; 39 | iconWrapper.innerHTML = iconSvg; 40 | 41 | dragHandle.appendChild(iconWrapper); 42 | 43 | return dragHandle; 44 | } 45 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drag/drag-handle-view.ts: -------------------------------------------------------------------------------- 1 | import { NodeSelection, Plugin, PluginKey, TextSelection } from '../pm'; 2 | import type { EditorView } from '../pm'; 3 | import { isNodeSelection } from '../pm-utils'; 4 | import { createDragHandle } from './drag-handle-ui'; 5 | import { 6 | type GlobalDragHandlePluginOptions, 7 | calcNodePos, 8 | nodeDOMAtCoords, 9 | nodePosAtDOM, 10 | } from './helpers'; 11 | 12 | let dragHandleElement: HTMLElement | null = null; 13 | let listType = ''; 14 | 15 | export const dragHandleViewPluginKey = new PluginKey('drag-handle-view'); 16 | 17 | function handleDragStart( 18 | event: DragEvent, 19 | view: EditorView, 20 | options: Required, 21 | ) { 22 | view.focus(); 23 | if (!event.dataTransfer) return; 24 | 25 | const node = nodeDOMAtCoords( 26 | { 27 | x: event.clientX + options.horizontalNodeOffset + options.dragHandleWidth, 28 | y: event.clientY, 29 | }, 30 | options, 31 | ); 32 | if (!(node instanceof Element)) return; 33 | 34 | let draggedNodePos = nodePosAtDOM(node, view, options); 35 | if (draggedNodePos == null || draggedNodePos < 0) { 36 | return; 37 | } 38 | draggedNodePos = calcNodePos(draggedNodePos, view.state); 39 | 40 | const { from, to } = view.state.selection; 41 | const diff = from - to; 42 | 43 | const fromSelectionPos = calcNodePos(from, view.state); 44 | let differentNodeSelected = false; 45 | const nodePos = view.state.doc.resolve(fromSelectionPos); 46 | 47 | // Updated isDoc reference to pass EditorState 48 | if (options.isDoc(view.state, nodePos.node())) { 49 | differentNodeSelected = true; 50 | } else { 51 | const nodeSelection = NodeSelection.create( 52 | view.state.doc, 53 | nodePos.before(), 54 | ); 55 | differentNodeSelected = !( 56 | draggedNodePos + 1 >= nodeSelection.$from.pos && 57 | draggedNodePos <= nodeSelection.$to.pos 58 | ); 59 | } 60 | 61 | // Adjust the selection to ensure we are selecting the correct node 62 | let selection = view.state.selection; 63 | if (!differentNodeSelected && diff !== 0 && !isNodeSelection(selection)) { 64 | // If a text selection is partially on the node, we refine it: 65 | const endSelection = NodeSelection.create(view.state.doc, to - 1); 66 | selection = TextSelection.create( 67 | view.state.doc, 68 | draggedNodePos, 69 | endSelection.$to.pos, 70 | ); 71 | } else { 72 | // Otherwise, select the entire node 73 | selection = NodeSelection.create(view.state.doc, draggedNodePos); 74 | if (!isNodeSelection(selection)) { 75 | throw new Error('Selection is not a NodeSelection'); 76 | } 77 | const selectedNode = selection.node; 78 | // Updated isTableRow reference 79 | if ( 80 | selectedNode.type.isInline || 81 | options.isTableRow(view.state, selectedNode) 82 | ) { 83 | const $pos = view.state.doc.resolve(selection.from); 84 | selection = NodeSelection.create(view.state.doc, $pos.before()); 85 | } 86 | } 87 | view.dispatch(view.state.tr.setSelection(selection)); 88 | 89 | // Check if we are dragging a list item (pass EditorState) 90 | if ( 91 | view.state.selection instanceof NodeSelection && 92 | options.isListItem(view.state, view.state.selection.node) && 93 | node.parentElement 94 | ) { 95 | listType = node.parentElement.tagName; 96 | } 97 | 98 | // Copy the snippet to the dataTransfer 99 | const slice = view.state.selection.content(); 100 | const { dom, text } = view.serializeForClipboard(slice); 101 | event.dataTransfer.clearData(); 102 | event.dataTransfer.setData('text/html', dom.innerHTML); 103 | event.dataTransfer.setData('text/plain', text); 104 | event.dataTransfer.effectAllowed = 'copyMove'; 105 | event.dataTransfer.setDragImage(node, 0, 0); 106 | 107 | view.dragging = { slice, move: event.ctrlKey }; 108 | } 109 | 110 | export function createDragHandleViewPlugin( 111 | options: Required, 112 | ) { 113 | return new Plugin({ 114 | key: dragHandleViewPluginKey, 115 | 116 | view: (view) => { 117 | // Attempt to find an existing drag-handle from user’s custom selector, else create one 118 | const handleBySelector = options.dragHandleSelector 119 | ? document.querySelector(options.dragHandleSelector) 120 | : null; 121 | 122 | dragHandleElement = handleBySelector ?? createDragHandle(); 123 | dragHandleElement.draggable = true; 124 | dragHandleElement.dataset.dragHandle = ''; 125 | dragHandleElement.classList.add(options.dragHandleClassName); 126 | 127 | // Attach DOM listeners 128 | const onDragHandleDragStart = (e: DragEvent) => { 129 | handleDragStart(e, view, options); 130 | }; 131 | dragHandleElement.addEventListener('dragstart', onDragHandleDragStart); 132 | 133 | const onDragHandleDrag = (e: DragEvent) => { 134 | hideDragHandle(options); 135 | const scrollY = window.scrollY; 136 | if (e.clientY < options.scrollTreshold) { 137 | window.scrollTo({ top: scrollY - 30, behavior: 'smooth' }); 138 | } else if (window.innerHeight - e.clientY < options.scrollTreshold) { 139 | window.scrollTo({ top: scrollY + 30, behavior: 'smooth' }); 140 | } 141 | }; 142 | dragHandleElement.addEventListener('drag', onDragHandleDrag); 143 | 144 | hideDragHandle(options); 145 | 146 | // Insert the handle if user’s selector didn’t return an existing handle 147 | if (!handleBySelector) { 148 | view.dom.parentElement?.appendChild(dragHandleElement); 149 | } 150 | 151 | // Hide handle when mouse leaves the editor area 152 | const hideHandleOnEditorOut = (event: MouseEvent) => { 153 | if (event.target instanceof Element) { 154 | const relatedTarget = event.relatedTarget as HTMLElement; 155 | const isInsideEditor = 156 | relatedTarget?.classList.contains( 157 | options.editorContainerClassName, 158 | ) || relatedTarget?.classList.contains(options.dragHandleClassName); 159 | 160 | if (isInsideEditor) return; 161 | } 162 | hideDragHandle(options); 163 | }; 164 | view.dom.parentElement?.addEventListener( 165 | 'mouseout', 166 | hideHandleOnEditorOut, 167 | ); 168 | 169 | return { 170 | destroy: () => { 171 | if (!handleBySelector) { 172 | dragHandleElement?.remove?.(); 173 | } 174 | dragHandleElement?.removeEventListener('drag', onDragHandleDrag); 175 | dragHandleElement?.removeEventListener( 176 | 'dragstart', 177 | onDragHandleDragStart, 178 | ); 179 | dragHandleElement = null; 180 | view.dom.parentElement?.removeEventListener( 181 | 'mouseout', 182 | hideHandleOnEditorOut, 183 | ); 184 | }, 185 | }; 186 | }, 187 | }); 188 | } 189 | 190 | // Simple helpers to show/hide the drag handle 191 | export function hideDragHandle( 192 | options: Required, 193 | ) { 194 | dragHandleElement?.classList.add(options.dragHandleHideClassName); 195 | } 196 | export function showDragHandle( 197 | options: Required, 198 | ) { 199 | dragHandleElement?.classList.remove(options.dragHandleHideClassName); 200 | } 201 | 202 | // So that other plugins can see which type of list we started with (ordered vs. un-ordered) 203 | export function getCurrentListType() { 204 | return listType; 205 | } 206 | export function resetListType() { 207 | listType = ''; 208 | } 209 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drag/drag-handle.ts: -------------------------------------------------------------------------------- 1 | import type { PMNode } from '../pm'; 2 | import { Plugin, PluginKey } from '../pm'; 3 | 4 | import { Fragment, Slice } from '../pm'; 5 | import { isNodeSelection } from '../pm-utils'; 6 | import { 7 | getCurrentListType, 8 | hideDragHandle, 9 | resetListType, 10 | showDragHandle, 11 | } from './drag-handle-view'; 12 | import { 13 | type GlobalDragHandlePluginOptions, 14 | ORDERED_LIST_TAG, 15 | absoluteRect, 16 | nodeDOMAtCoords, 17 | } from './helpers'; 18 | 19 | export const dragHandleEventsPluginKey = new PluginKey('drag-handle-events'); 20 | 21 | export function createDragHandleEventsPlugin( 22 | options: Required, 23 | ) { 24 | return new Plugin({ 25 | key: dragHandleEventsPluginKey, 26 | props: { 27 | handleDOMEvents: { 28 | mousemove: (view, event) => { 29 | if (!view.editable) { 30 | return false; 31 | } 32 | const node = nodeDOMAtCoords( 33 | { 34 | x: 35 | event.clientX + 36 | options.horizontalNodeOffset + 37 | options.dragHandleWidth, 38 | y: event.clientY, 39 | }, 40 | options, 41 | ); 42 | const notDragging = node?.closest( 43 | `.${options.notDraggableClassName}`, 44 | ); 45 | const excludedTagList = options.excludedTags 46 | .concat(['ol', 'ul']) 47 | .join(', '); 48 | 49 | if ( 50 | !(node instanceof Element) || 51 | node.matches(excludedTagList) || 52 | notDragging 53 | ) { 54 | hideDragHandle(options); 55 | return false; 56 | } 57 | 58 | const compStyle = window.getComputedStyle(node); 59 | const parsedLineHeight = Number.parseInt(compStyle.lineHeight, 10); 60 | const lineHeight = Number.isNaN(parsedLineHeight) 61 | ? Number.parseInt(compStyle.fontSize, 10) * 1.2 62 | : parsedLineHeight; 63 | const paddingTop = Number.parseInt(compStyle.paddingTop, 10); 64 | 65 | const result = options.calculateNodeOffset({ 66 | node, 67 | rect: absoluteRect(node), 68 | lineHeight, 69 | paddingTop, 70 | view, 71 | event: event as MouseEvent, 72 | state: view.state, 73 | }); 74 | 75 | // Position the handle 76 | const handleEl = 77 | document.querySelector('[data-drag-handle]'); 78 | if (!handleEl) return false; 79 | 80 | handleEl.style.left = `${result.left - result.width}px`; 81 | handleEl.style.top = `${result.top}px`; 82 | 83 | showDragHandle(options); 84 | return false; // Do not prevent PM’s default 85 | }, 86 | 87 | keydown: () => { 88 | hideDragHandle(options); 89 | return false; 90 | }, 91 | 92 | mousewheel: () => { 93 | hideDragHandle(options); 94 | return false; 95 | }, 96 | 97 | dragenter: (view) => { 98 | view.dom.classList.add(options.editorDraggingClassName); 99 | return false; 100 | }, 101 | 102 | drop: (view, event) => { 103 | view.dom.classList.remove(options.editorDraggingClassName); 104 | hideDragHandle(options); 105 | 106 | let droppedNode: PMNode | null = null; 107 | const dropPos = view.posAtCoords({ 108 | left: event.clientX, 109 | top: event.clientY, 110 | }); 111 | 112 | if (!dropPos) { 113 | resetListType(); 114 | return false; 115 | } 116 | 117 | // If we dropped an entire node selection 118 | if (isNodeSelection(view.state.selection)) { 119 | droppedNode = view.state.selection.node; 120 | } 121 | 122 | if (!droppedNode) { 123 | resetListType(); 124 | return false; 125 | } 126 | 127 | const resolvedPos = view.state.doc.resolve(dropPos.pos); 128 | const isDroppedInsideList = options.isListItem( 129 | view.state, 130 | resolvedPos.parent, 131 | ); 132 | 133 | // If dropping a list item outside a list but it was originally an ordered list 134 | if ( 135 | isNodeSelection(view.state.selection) && 136 | options.isListItem(view.state, droppedNode) && 137 | !isDroppedInsideList && 138 | getCurrentListType() === ORDERED_LIST_TAG 139 | ) { 140 | const newList = options.createOrderedListWithNode( 141 | view.state.schema, 142 | droppedNode, 143 | ); 144 | if (newList) { 145 | const slice = new Slice(Fragment.from(newList), 0, 0); 146 | view.dragging = { 147 | slice, 148 | move: event.ctrlKey, 149 | }; 150 | } 151 | } 152 | 153 | resetListType(); 154 | return false; 155 | }, 156 | 157 | dragend: (view) => { 158 | view.dom.classList.remove(options.editorDraggingClassName); 159 | resetListType(); 160 | return false; 161 | }, 162 | }, 163 | }, 164 | }); 165 | } 166 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drag/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { PMNode, Schema } from '../pm'; 2 | import type { EditorState } from '../pm'; 3 | import type { EditorView } from '../pm'; 4 | 5 | export const ORDERED_LIST_TAG = 'OL'; 6 | 7 | export type ListType = 'ordered' | 'unordered' | 'todo' | null; 8 | 9 | export interface NodeOffsetCalculationArgs { 10 | node: Element; 11 | rect: { top: number; left: number; width: number }; 12 | lineHeight: number; 13 | paddingTop: number; 14 | view: EditorView; 15 | event: MouseEvent; 16 | state: EditorState; 17 | } 18 | 19 | export interface GlobalDragHandlePluginOptions { 20 | dragHandleWidth: number; 21 | scrollTreshold: number; 22 | dragHandleSelector?: string; 23 | excludedTags: string[]; 24 | customNodes: string[]; 25 | // Updated type checks to receive EditorState for more context 26 | isTableRow?: (state: EditorState, node: PMNode) => boolean; 27 | isListItem?: (state: EditorState, node: PMNode) => boolean; 28 | isDoc?: (state: EditorState, node: PMNode) => boolean; 29 | createOrderedListWithNode?: ( 30 | schema: Schema, 31 | droppedNode: PMNode, 32 | ) => PMNode | null; 33 | dragHandleClassName?: string; 34 | dragHandleHideClassName?: string; 35 | editorContentClassName?: string; 36 | editorContainerClassName?: string; 37 | editorDraggingClassName?: string; 38 | notDraggableClassName?: string; 39 | movableNodeSelectors?: string[]; 40 | horizontalNodeOffset?: number; 41 | // New callback to calculate node offset dynamically 42 | calculateNodeOffset?: (args: NodeOffsetCalculationArgs) => { 43 | top: number; 44 | left: number; 45 | width: number; 46 | }; 47 | } 48 | 49 | export function absoluteRect(node: Element) { 50 | const data = node.getBoundingClientRect(); 51 | const modal = node.closest('[role="dialog"]'); 52 | if (modal && window.getComputedStyle(modal).transform !== 'none') { 53 | const modalRect = modal.getBoundingClientRect(); 54 | return { 55 | top: data.top - modalRect.top, 56 | left: data.left - modalRect.left, 57 | width: data.width, 58 | }; 59 | } 60 | return { 61 | top: data.top, 62 | left: data.left, 63 | width: data.width, 64 | }; 65 | } 66 | 67 | export function nodeDOMAtCoords( 68 | coords: { x: number; y: number }, 69 | options: Required, 70 | ) { 71 | const selectors = [ 72 | 'li', 73 | 'p:not(:first-child)', 74 | 'pre', 75 | 'blockquote', 76 | 'h1', 77 | 'h2', 78 | 'h3', 79 | 'h4', 80 | 'h5', 81 | 'h6', 82 | ...(options.movableNodeSelectors || []), 83 | ...options.customNodes.map((node) => `[data-type=${node}]`), 84 | ].join(', '); 85 | 86 | return document 87 | .elementsFromPoint(coords.x, coords.y) 88 | .find( 89 | (elem: Element) => 90 | elem.parentElement?.matches?.(`.${options.editorContentClassName}`) || 91 | elem.matches(selectors), 92 | ); 93 | } 94 | 95 | export function nodePosAtDOM( 96 | node: Element, 97 | view: EditorView, 98 | options: Required, 99 | ) { 100 | const boundingRect = node.getBoundingClientRect(); 101 | return view.posAtCoords({ 102 | left: 103 | boundingRect.left + 104 | options.horizontalNodeOffset + 105 | options.dragHandleWidth, 106 | top: boundingRect.top + 1, 107 | })?.inside; 108 | } 109 | 110 | export function calcNodePos(pos: number, state: EditorState) { 111 | const $pos = state.doc.resolve(pos); 112 | if ($pos.depth > 1) { 113 | return $pos.before($pos.depth); 114 | } 115 | return pos; 116 | } 117 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drag/index.ts: -------------------------------------------------------------------------------- 1 | import { collection } from '../common'; 2 | import type { PMNode } from '../pm'; 3 | import type { EditorState } from '../pm'; 4 | import { createDragHandleEventsPlugin } from './drag-handle'; 5 | import { createDragHandleViewPlugin } from './drag-handle-view'; 6 | import type { 7 | GlobalDragHandlePluginOptions, 8 | NodeOffsetCalculationArgs, 9 | } from './helpers'; 10 | 11 | type DragConfig = { 12 | pluginOptions?: Partial | undefined; 13 | }; 14 | 15 | function defaultCalculateNodeOffset(args: NodeOffsetCalculationArgs) { 16 | const { node, rect, lineHeight, paddingTop } = args; 17 | const newRect = { ...rect }; 18 | 19 | newRect.top += (lineHeight - 24) / 2; 20 | newRect.top += paddingTop; 21 | 22 | // For UL/OL, shift handle to the left 23 | if (node.matches('ul:not([data-type=taskList]) li, ol li')) { 24 | newRect.left -= 20; 25 | } 26 | newRect.width = 20; 27 | return newRect; 28 | } 29 | 30 | function defaultIsDoc(_state: EditorState, node: PMNode) { 31 | return node.type.name === 'doc'; 32 | } 33 | function defaultIsListItem(_state: EditorState, node: PMNode) { 34 | return node.type.name === 'list'; 35 | } 36 | function defaultIsTableRow(_state: EditorState, node: PMNode) { 37 | return node.type.name === 'tableRow'; 38 | } 39 | 40 | export function setupDragNode(config: DragConfig) { 41 | const mergedConfig = { 42 | dragHandleWidth: 20, 43 | scrollTreshold: 100, 44 | excludedTags: [], 45 | customNodes: [], 46 | dragHandleSelector: '', 47 | isTableRow: defaultIsTableRow, 48 | isListItem: defaultIsListItem, 49 | isDoc: defaultIsDoc, 50 | createOrderedListWithNode: (schema, droppedNode) => 51 | schema.nodes.list?.createAndFill(null, droppedNode) || null, 52 | dragHandleClassName: 'drag-handle', 53 | dragHandleHideClassName: 'hidden', 54 | editorContentClassName: 'ProseMirror', 55 | editorContainerClassName: 'ProseMirror', 56 | editorDraggingClassName: 'dragging', 57 | notDraggableClassName: 'not-draggable', 58 | movableNodeSelectors: [], 59 | horizontalNodeOffset: 50, 60 | calculateNodeOffset: defaultCalculateNodeOffset, 61 | ...(config.pluginOptions || {}), 62 | } satisfies Required; 63 | 64 | const plugin = { 65 | dragNode: createDragHandleViewPlugin(mergedConfig), 66 | dragNodeEvents: createDragHandleEventsPlugin(mergedConfig), 67 | }; 68 | 69 | return collection({ 70 | id: 'drag-node', 71 | plugin, 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /packages/banger-editor/src/drop-gap-cursor.ts: -------------------------------------------------------------------------------- 1 | import { dropCursor } from './pm'; 2 | import { gapCursor } from './pm'; 3 | 4 | import { collection } from './common'; 5 | 6 | export interface DropCursorOptions { 7 | /** 8 | The color of the cursor. Defaults to `black`. Use `null` to apply no color and rely only on class. 9 | */ 10 | color?: string | null; 11 | /** 12 | The precise width of the cursor in pixels. Defaults to 1. 13 | */ 14 | width?: number; 15 | /** 16 | A CSS class name to add to the cursor element. 17 | */ 18 | class?: string; 19 | } 20 | 21 | type RequiredDropCursorOptions = Required; 22 | 23 | const DEFAULT_DROP_CURSOR_OPTIONS: RequiredDropCursorOptions = { 24 | color: 'black', 25 | width: 1, 26 | class: '', 27 | }; 28 | 29 | export type DropGapCursorConfig = { 30 | dropCursorOptions?: DropCursorOptions; 31 | }; 32 | 33 | type RequiredConfig = Required; 34 | 35 | const DEFAULT_CONFIG: RequiredConfig = { 36 | dropCursorOptions: DEFAULT_DROP_CURSOR_OPTIONS, 37 | }; 38 | 39 | // combining pm drop cursor and gap cursor 40 | export function setupDropGapCursor(userConfig?: DropGapCursorConfig) { 41 | const config = { 42 | ...DEFAULT_CONFIG, 43 | ...userConfig, 44 | }; 45 | 46 | const plugin = { 47 | dropCursor: pluginDropCursor(config), 48 | gapCursor: pluginGapCursor(), 49 | }; 50 | 51 | return collection({ 52 | id: 'drop-gap-cursor', 53 | plugin, 54 | }); 55 | } 56 | 57 | // PLUGINS 58 | function pluginDropCursor(config: RequiredConfig) { 59 | return () => { 60 | const { color, width, class: className } = config.dropCursorOptions; 61 | return dropCursor({ 62 | color: !color ? undefined : color, 63 | width, 64 | class: className ? className : undefined, 65 | }); 66 | }; 67 | } 68 | 69 | function pluginGapCursor() { 70 | return () => { 71 | return gapCursor(); 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /packages/banger-editor/src/hard-break.ts: -------------------------------------------------------------------------------- 1 | import { type CollectionType, collection, keybinding } from './common'; 2 | import type { Command } from './pm'; 3 | import { exitCode } from './pm'; 4 | import { chainCommands } from './pm'; 5 | import type { NodeSpec } from './pm'; 6 | import type { DOMOutputSpec } from './pm'; 7 | import { getNodeType } from './pm-utils'; 8 | 9 | export type HardBreakConfig = { 10 | name?: string; 11 | // keys 12 | keyInsert?: string | false; 13 | }; 14 | 15 | type RequiredConfig = Required; 16 | 17 | const DEFAULT_CONFIG: RequiredConfig = { 18 | name: 'hard_break', 19 | keyInsert: 'Shift-Enter', 20 | }; 21 | 22 | export function setupHardBreak(userConfig?: HardBreakConfig) { 23 | const config = { 24 | ...DEFAULT_CONFIG, 25 | ...userConfig, 26 | }; 27 | 28 | const { name } = config; 29 | 30 | const nodes = { 31 | [name]: { 32 | inline: true, 33 | group: 'inline', 34 | selectable: false, 35 | parseDOM: [{ tag: 'br' }], 36 | toDOM: (): DOMOutputSpec => ['br'], 37 | } satisfies NodeSpec, 38 | }; 39 | 40 | const plugin = { 41 | keybindings: pluginKeybindings(config), 42 | }; 43 | 44 | return collection({ 45 | id: 'hard-break', 46 | nodes, 47 | plugin, 48 | command: { 49 | insertHardBreak: insertHardBreak(config), 50 | }, 51 | markdown: markdown(config), 52 | }); 53 | } 54 | 55 | // PLUGINS 56 | function pluginKeybindings(config: RequiredConfig) { 57 | return keybinding( 58 | [[config.keyInsert, insertHardBreak(config)]], 59 | 'hard-break', 60 | ); 61 | } 62 | 63 | // COMMANDS 64 | function insertHardBreak(config: RequiredConfig): Command { 65 | // This command tries exitCode first, then fallback to inserting a hardBreak. 66 | const { name } = config; 67 | return (state, dispatch) => { 68 | const type = getNodeType(state.schema, name); 69 | return chainCommands(exitCode, (state, dispatch) => { 70 | if (dispatch) { 71 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 72 | } 73 | return true; 74 | })(state, dispatch); 75 | }; 76 | } 77 | 78 | // MARKDOWN 79 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 80 | const { name } = config; 81 | return { 82 | nodes: { 83 | [name]: { 84 | toMarkdown(state, node, parent, index) { 85 | for (let i = index + 1; i < parent.childCount; i++) { 86 | if (parent.child(i).type !== node.type) { 87 | state.write('\\\n'); 88 | return; 89 | } 90 | } 91 | }, 92 | parseMarkdown: { 93 | // this is different from the `name` of the node, and dictated by markdown parser 94 | hardbreak: { 95 | node: config.name, 96 | }, 97 | }, 98 | }, 99 | }, 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /packages/banger-editor/src/heading.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Command, 3 | type EditorState, 4 | type NodeSpec, 5 | type NodeType, 6 | type PMNode, 7 | type Schema, 8 | setBlockType, 9 | } from './pm'; 10 | 11 | import { type CollectionType, collection, isMac, keybinding } from './common'; 12 | import { inputRules, textblockTypeInputRule } from './pm'; 13 | import { findParentNodeOfType } from './pm-utils'; 14 | import { 15 | type KeyCode, 16 | type PluginContext, 17 | defaultGetParagraphNodeType, 18 | getNodeType, 19 | insertEmptyParagraphAboveNode, 20 | insertEmptyParagraphBelowNode, 21 | jumpToEndOfNode, 22 | jumpToStartOfNode, 23 | moveNode, 24 | } from './pm-utils'; 25 | 26 | export type HeadingConfig = { 27 | name?: string; 28 | levels?: number[]; 29 | getParagraphNodeType?: (arg: Schema) => NodeType; 30 | // keys 31 | keyToH1?: KeyCode; 32 | keyToH2?: KeyCode; 33 | keyToH3?: KeyCode; 34 | keyToH4?: KeyCode; 35 | keyToH5?: KeyCode; 36 | keyToH6?: KeyCode; 37 | keyMoveDown?: KeyCode; 38 | keyMoveUp?: KeyCode; 39 | keyEmptyCopy?: KeyCode; 40 | keyEmptyCut?: KeyCode; 41 | keyInsertEmptyParaAbove?: KeyCode; 42 | keyInsertEmptyParaBelow?: KeyCode; 43 | keyToggleCollapse?: KeyCode; 44 | keyJumpToStartOfHeading?: KeyCode; 45 | keyJumpToEndOfHeading?: KeyCode; 46 | }; 47 | 48 | type RequiredConfig = Required & { 49 | levels: NonNullable['levels']>; 50 | }; 51 | 52 | const DEFAULT_CONFIG: RequiredConfig = { 53 | name: 'heading', 54 | levels: [1, 2, 3, 4, 5, 6], 55 | getParagraphNodeType: defaultGetParagraphNodeType, 56 | keyToH1: isMac ? 'Mod-Alt-1' : 'Shift-Ctrl-1', 57 | keyToH2: isMac ? 'Mod-Alt-2' : 'Shift-Ctrl-2', 58 | keyToH3: isMac ? 'Mod-Alt-3' : 'Shift-Ctrl-3', 59 | keyToH4: false, 60 | keyToH5: false, 61 | keyToH6: false, 62 | keyMoveDown: 'Alt-ArrowDown', 63 | keyMoveUp: 'Alt-ArrowUp', 64 | keyEmptyCopy: false, 65 | keyEmptyCut: false, 66 | keyInsertEmptyParaAbove: 'Mod-Shift-Enter', 67 | keyInsertEmptyParaBelow: 'Mod-Enter', 68 | keyToggleCollapse: false, 69 | // TODO base keymap already handles these, it possible doesnt handle inline nodes (need to check) 70 | keyJumpToStartOfHeading: false, 71 | keyJumpToEndOfHeading: false, 72 | }; 73 | 74 | export function setupHeading(userConfig?: HeadingConfig) { 75 | const config = { 76 | ...DEFAULT_CONFIG, 77 | ...userConfig, 78 | } as RequiredConfig; 79 | 80 | const { name } = config; 81 | 82 | const nodes: Record = { 83 | [name]: { 84 | attrs: { 85 | level: { 86 | default: 1, 87 | }, 88 | collapseContent: { 89 | default: null, 90 | }, 91 | }, 92 | content: 'inline*', 93 | group: 'block', 94 | defining: true, 95 | draggable: false, 96 | parseDOM: config.levels.map((level) => { 97 | return { 98 | tag: `h${level}`, 99 | getAttrs: (dom: HTMLElement) => { 100 | const result = { level: parseLevel(level) }; 101 | const attrs = dom.getAttribute('data-bangle-attrs'); 102 | 103 | if (!attrs) { 104 | return result; 105 | } 106 | 107 | const obj = JSON.parse(attrs); 108 | 109 | return Object.assign({}, result, obj); 110 | }, 111 | }; 112 | }), 113 | toDOM: (node: PMNode) => { 114 | const result: any = [`h${node.attrs.level}`, {}, 0]; 115 | 116 | if (node.attrs.collapseContent) { 117 | result[1]['data-bangle-attrs'] = JSON.stringify({ 118 | collapseContent: node.attrs.collapseContent, 119 | }); 120 | result[1].class = 'bangle-heading-collapsed'; 121 | } 122 | 123 | return result; 124 | }, 125 | }, 126 | }; 127 | 128 | const plugin = { 129 | inputRules: pluginInputRules(config), 130 | keybindings: pluginKeybindings(config), 131 | }; 132 | 133 | return collection({ 134 | id: 'heading', 135 | nodes, 136 | plugin, 137 | command: { 138 | toggleHeading: toggleHeading(config), 139 | insertEmptyParaAbove: insertEmptyParaAboveHeading(config), 140 | insertEmptyParaBelow: insertEmptyParaBelowHeading(config), 141 | }, 142 | query: { 143 | isHeadingActive: isHeadingActive(config), 144 | isInsideHeading: isInsideHeading(config), 145 | }, 146 | markdown: markdown(config), 147 | }); 148 | } 149 | 150 | // PLUGINS 151 | function pluginInputRules(config: RequiredConfig) { 152 | return ({ schema }: PluginContext) => { 153 | const { name, levels } = config; 154 | const type = getNodeType(schema, name); 155 | return inputRules({ 156 | rules: levels.map((level: number) => 157 | textblockTypeInputRule( 158 | new RegExp(`^(#{1,${level}})\\s$`), 159 | type, 160 | () => ({ 161 | level, 162 | }), 163 | ), 164 | ), 165 | }); 166 | }; 167 | } 168 | 169 | function pluginKeybindings(config: RequiredConfig) { 170 | return ({ schema }: PluginContext) => { 171 | const { name, levels } = config; 172 | const type = getNodeType(schema, name); 173 | 174 | const levelBindings = levels.map( 175 | (level: number): [string | false, Command] => [ 176 | (config as any)[`keyToH${level}`] ?? false, 177 | setBlockType(type, { level }), 178 | ], 179 | ); 180 | 181 | return keybinding( 182 | [ 183 | ...levelBindings, 184 | [config.keyMoveUp, moveNode(type, 'UP')], 185 | [config.keyMoveDown, moveNode(type, 'DOWN')], 186 | [config.keyJumpToStartOfHeading, jumpToStartOfNode(type)], 187 | [config.keyJumpToEndOfHeading, jumpToEndOfNode(type)], 188 | [config.keyInsertEmptyParaAbove, insertEmptyParaAboveHeading(config)], 189 | [config.keyInsertEmptyParaBelow, insertEmptyParaBelowHeading(config)], 190 | ], 191 | 'heading', 192 | ); 193 | }; 194 | } 195 | 196 | const parseLevel = (levelStr: string | number) => { 197 | const level = Number.parseInt(levelStr as string, 10); 198 | return Number.isNaN(level) ? undefined : level; 199 | }; 200 | 201 | // COMMANDS 202 | function toggleHeading(config: RequiredConfig) { 203 | return (level?: number): Command => { 204 | return (state, dispatch) => { 205 | const { name } = config; 206 | if (isHeadingActive(config)(state, level)) { 207 | const para = config.getParagraphNodeType(state.schema); 208 | return setBlockType(para)(state, dispatch); 209 | } 210 | return setBlockType(getNodeType(state.schema, name), { level })( 211 | state, 212 | dispatch, 213 | ); 214 | }; 215 | }; 216 | } 217 | 218 | function insertEmptyParaAboveHeading(config: RequiredConfig): Command { 219 | const { name } = config; 220 | return (state, dispatch) => { 221 | const type = getNodeType(state.schema, name); 222 | return insertEmptyParagraphAboveNode(type, config.getParagraphNodeType)( 223 | state, 224 | dispatch, 225 | ); 226 | }; 227 | } 228 | 229 | function insertEmptyParaBelowHeading(config: RequiredConfig): Command { 230 | const { name } = config; 231 | return (state, dispatch) => { 232 | const type = getNodeType(state.schema, name); 233 | return insertEmptyParagraphBelowNode(type, config.getParagraphNodeType)( 234 | state, 235 | dispatch, 236 | ); 237 | }; 238 | } 239 | 240 | // QUERIES 241 | function isInsideHeading(config: RequiredConfig) { 242 | return (state: EditorState) => { 243 | const { name } = config; 244 | const type = getNodeType(state.schema, name); 245 | return findParentNodeOfType(type)(state.selection); 246 | }; 247 | } 248 | 249 | function isHeadingActive(config: RequiredConfig) { 250 | return (state: EditorState, level?: number) => { 251 | const { name } = config; 252 | const match = findParentNodeOfType(getNodeType(state.schema, name))( 253 | state.selection, 254 | ); 255 | if (!match) { 256 | return false; 257 | } 258 | const { node } = match; 259 | if (level == null) { 260 | return true; 261 | } 262 | return node.attrs.level === level; 263 | }; 264 | } 265 | 266 | // MARKDOWN 267 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 268 | return { 269 | nodes: { 270 | [config.name]: { 271 | toMarkdown(state, node: PMNode) { 272 | state.write(`${state.repeat('#', node.attrs.level)} `); 273 | state.renderInline(node); 274 | state.closeBlock(node); 275 | }, 276 | parseMarkdown: { 277 | heading: { 278 | block: config.name, 279 | getAttrs: (tok) => { 280 | return { level: parseLevel(tok.tag.slice(1)) }; 281 | }, 282 | }, 283 | }, 284 | }, 285 | }, 286 | }; 287 | } 288 | -------------------------------------------------------------------------------- /packages/banger-editor/src/history.ts: -------------------------------------------------------------------------------- 1 | import { collection, isMac, keybinding } from './common'; 2 | import { history, redo, undo } from './pm'; 3 | 4 | export type HistoryConfig = { 5 | depth?: number; 6 | newGroupDelay?: number; 7 | // keys 8 | keyUndo?: string | false; 9 | keyRedo?: string | false; 10 | }; 11 | 12 | type RequiredConfig = Required; 13 | 14 | const DEFAULT_CONFIG: RequiredConfig = { 15 | depth: 100, 16 | newGroupDelay: 500, 17 | keyUndo: 'Mod-z', 18 | keyRedo: isMac ? 'Mod-Shift-z' : 'Mod-y', 19 | }; 20 | 21 | export function setupHistory(userConfig?: HistoryConfig) { 22 | const config = { 23 | ...DEFAULT_CONFIG, 24 | ...userConfig, 25 | }; 26 | 27 | const plugin = { 28 | history: pluginHistory(config), 29 | keybindings: pluginKeybindings(config), 30 | }; 31 | 32 | return collection({ 33 | id: 'history', 34 | plugin, 35 | }); 36 | } 37 | 38 | // PLUGINS 39 | function pluginHistory(config: RequiredConfig) { 40 | return () => { 41 | const { depth, newGroupDelay } = config; 42 | return history({ 43 | depth, 44 | newGroupDelay, 45 | }); 46 | }; 47 | } 48 | 49 | function pluginKeybindings(config: RequiredConfig) { 50 | return () => { 51 | const { keyUndo, keyRedo } = config; 52 | return keybinding( 53 | [ 54 | [keyUndo, undo], 55 | [keyRedo, redo], 56 | ], 57 | 'history', 58 | ); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /packages/banger-editor/src/horizontal-rule.ts: -------------------------------------------------------------------------------- 1 | import { type CollectionType, collection, keybinding } from './common'; 2 | import type { Command, NodeType } from './pm'; 3 | import { InputRule, inputRules } from './pm'; 4 | import type { NodeSpec, Schema } from './pm'; 5 | import { safeInsert } from './pm-utils'; 6 | import { 7 | type PluginContext, 8 | defaultGetParagraphNodeType, 9 | getNodeType, 10 | } from './pm-utils'; 11 | 12 | export type HorizontalRuleConfig = { 13 | name?: string; 14 | markdownShortcut?: boolean; 15 | getParagraphNodeType: (schema: Schema) => NodeType; 16 | keyInsert?: string | false; 17 | }; 18 | 19 | type RequiredConfig = Required; 20 | 21 | const DEFAULT_CONFIG: RequiredConfig = { 22 | name: 'horizontalRule', 23 | markdownShortcut: true, 24 | getParagraphNodeType: defaultGetParagraphNodeType, 25 | keyInsert: 'Mod-Shift-h', 26 | }; 27 | 28 | export function setupHorizontalRule(userConfig?: HorizontalRuleConfig) { 29 | const config = { 30 | ...DEFAULT_CONFIG, 31 | ...userConfig, 32 | }; 33 | 34 | const { name } = config; 35 | 36 | const nodes = { 37 | [name]: { 38 | group: 'block', 39 | parseDOM: [{ tag: 'hr' }], 40 | toDOM: () => ['hr'], 41 | } satisfies NodeSpec, 42 | }; 43 | 44 | const plugin = { 45 | inputRules: pluginInputRules(config), 46 | keybindings: pluginKeybindings(config), 47 | }; 48 | 49 | return collection({ 50 | id: 'horizontal_rule', 51 | nodes, 52 | plugin, 53 | command: { 54 | insertHorizontalRule: insertHorizontalRule(config), 55 | }, 56 | markdown: markdown(config), 57 | }); 58 | } 59 | 60 | // PLUGINS 61 | function pluginInputRules(config: RequiredConfig) { 62 | return ({ schema }: PluginContext) => { 63 | const { name, markdownShortcut } = config; 64 | if (!markdownShortcut) { 65 | return null; 66 | } 67 | 68 | const type = getNodeType(schema, name); 69 | 70 | return inputRules({ 71 | rules: [ 72 | new InputRule( 73 | /^(?:---|___\s|\*\*\*\s)$/, 74 | (state, match, start, end) => { 75 | if (!match[0]) { 76 | return null; 77 | } 78 | const tr = state.tr.replaceWith( 79 | start - 1, 80 | end, 81 | type.createChecked(), 82 | ); 83 | // Find the paragraph that contains the "---" shortcut text, we need 84 | // it below for deciding whether to insert a new paragraph after the 85 | // hr. 86 | const $para = state.doc.resolve(start); 87 | 88 | let insertParaAfter = false; 89 | if ($para.end() !== end) { 90 | // if the paragraph has more characters, e.g. "---abc", then no 91 | // need to insert a new paragraph 92 | insertParaAfter = false; 93 | } else if ($para.after() === $para.end(-1)) { 94 | // if the paragraph is the last child of its parent, then insert a 95 | // new paragraph 96 | insertParaAfter = true; 97 | } else { 98 | // biome-ignore lint/style/noNonNullAssertion: 99 | const nextNode = state.doc.resolve($para.after()).nodeAfter!; 100 | // if the next node is a hr, then insert a new paragraph 101 | insertParaAfter = nextNode.type === type; 102 | } 103 | return insertParaAfter 104 | ? safeInsert( 105 | config.getParagraphNodeType(state.schema).createChecked(), 106 | tr.mapping.map($para.after()), 107 | )(tr) 108 | : tr; 109 | }, 110 | ), 111 | ], 112 | }); 113 | }; 114 | } 115 | 116 | function pluginKeybindings(config: RequiredConfig) { 117 | return keybinding( 118 | [[config.keyInsert, insertHorizontalRule(config)]], 119 | 'horizontal-rule', 120 | ); 121 | } 122 | 123 | // COMMANDS 124 | function insertHorizontalRule(config: RequiredConfig): Command { 125 | const { name } = config; 126 | return (state, dispatch) => { 127 | const type = getNodeType(state.schema, name); 128 | const { $from } = state.selection; 129 | const pos = $from.end(); 130 | 131 | // biome-ignore lint/style/noNonNullAssertion: 132 | const tr = safeInsert(type.createAndFill()!, pos)(state.tr); 133 | if (tr) { 134 | dispatch?.(tr); 135 | return true; 136 | } 137 | 138 | return false; 139 | }; 140 | } 141 | 142 | // MARKDOWN 143 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 144 | const { name } = config; 145 | return { 146 | nodes: { 147 | [name]: { 148 | toMarkdown: (state, node) => { 149 | state.write(node.attrs.markup || '---'); 150 | state.closeBlock(node); 151 | }, 152 | parseMarkdown: { 153 | hr: { 154 | node: name, 155 | }, 156 | }, 157 | }, 158 | }, 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /packages/banger-editor/src/hover.ts: -------------------------------------------------------------------------------- 1 | import { collection } from './common'; 2 | import { Plugin, PluginKey } from './pm'; 3 | import { Decoration, DecorationSet, type EditorView } from './pm'; 4 | 5 | export type HoverOptions = { 6 | /** 7 | * The CSS class to apply when a node is hovered. 8 | */ 9 | className: string; 10 | }; 11 | 12 | type RequiredHoverOptions = Required; 13 | 14 | const DEFAULT_HOVER_OPTIONS: RequiredHoverOptions = { 15 | className: 'hover', 16 | }; 17 | 18 | export function setupHover(options: HoverOptions = DEFAULT_HOVER_OPTIONS) { 19 | const plugin = { 20 | hover: hoverPlugin(options), 21 | }; 22 | 23 | return collection({ 24 | id: 'hover', 25 | plugin, 26 | }); 27 | } 28 | 29 | function hoverPlugin(options: RequiredHoverOptions) { 30 | const { className } = options; 31 | const pluginKey = new PluginKey('hover'); 32 | 33 | return new Plugin({ 34 | key: pluginKey, 35 | 36 | state: { 37 | init() { 38 | return DecorationSet.empty; 39 | }, 40 | apply(tr, inDecorationSet) { 41 | let decorationSet = inDecorationSet.map(tr.mapping, tr.doc); 42 | const meta = tr.getMeta(pluginKey); 43 | if (meta?.hoverDecoration) { 44 | decorationSet = DecorationSet.create(tr.doc, meta.hoverDecoration); 45 | } 46 | return decorationSet; 47 | }, 48 | }, 49 | 50 | props: { 51 | decorations(state) { 52 | return pluginKey.getState(state); 53 | }, 54 | 55 | handleDOMEvents: { 56 | mouseover(view: EditorView, event: MouseEvent) { 57 | const target = event.target as HTMLElement; 58 | if (!target) return false; 59 | 60 | const nodeElement = target.closest('[data-node-type]'); 61 | if (!nodeElement) return false; 62 | 63 | const pos = view.posAtDOM(nodeElement, 0); 64 | if (pos == null) return false; 65 | 66 | const { state, dispatch } = view; 67 | 68 | const currentDecoration = pluginKey.getState(state).find(); 69 | if (currentDecoration.length > 0) { 70 | const existing = currentDecoration.find()[0]; 71 | if (existing.from === pos) return false; 72 | } 73 | 74 | const nodeDecoration = Decoration.node(pos, pos + 1, { 75 | class: className, 76 | }); 77 | 78 | const decorations = DecorationSet.create(state.doc, [nodeDecoration]); 79 | 80 | dispatch( 81 | state.tr.setMeta(pluginKey, { hoverDecoration: decorations }), 82 | ); 83 | 84 | return false; 85 | }, 86 | 87 | mouseout(view: EditorView, _event: MouseEvent) { 88 | const { state, dispatch } = view; 89 | const currentDecoration = pluginKey.getState(state); 90 | 91 | if (currentDecoration.find().length > 0) { 92 | dispatch( 93 | state.tr.setMeta(pluginKey, { 94 | hoverDecoration: DecorationSet.empty, 95 | }), 96 | ); 97 | } 98 | 99 | return false; 100 | }, 101 | }, 102 | }, 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /packages/banger-editor/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | setGlobalConfig, 3 | resolve, 4 | collection, 5 | type CollectionType, 6 | type MarkdownConfig, 7 | type MarkdownNodeConfig, 8 | type MarkdownMarkConfig, 9 | } from './common'; 10 | 11 | export * from './active-node'; 12 | export * from './base'; 13 | export * from './blockquote'; 14 | export * from './bold'; 15 | export * from './code-block'; 16 | export * from './code'; 17 | export * from './drag'; 18 | export * from './drop-gap-cursor'; 19 | export * from './hard-break'; 20 | export * from './heading'; 21 | export * from './history'; 22 | export * from './horizontal-rule'; 23 | export * from './image'; 24 | export * from './italic'; 25 | export * from './link'; 26 | export * from './list'; 27 | export * from './paragraph'; 28 | export * from './placeholder'; 29 | export * from './pm-utils'; 30 | export * from './store'; 31 | export * from './strike'; 32 | export * from './suggestions'; 33 | export * from './underline'; 34 | -------------------------------------------------------------------------------- /packages/banger-editor/src/italic.ts: -------------------------------------------------------------------------------- 1 | import { keybinding } from './common'; 2 | import { type CollectionType, collection } from './common'; 3 | import type { MarkSpec } from './pm'; 4 | import { toggleMark } from './pm'; 5 | import { inputRules } from './pm'; 6 | import type { Command, EditorState } from './pm'; 7 | import { 8 | type PluginContext, 9 | getMarkType, 10 | isMarkActiveInSelection, 11 | markInputRule, 12 | markPastePlugin, 13 | } from './pm-utils'; 14 | 15 | export type ItalicConfig = { 16 | name?: string; 17 | // keys 18 | keyToggle?: string | false; 19 | // Controls whether pasting text with *text* or _text_ patterns will be converted to italic. 20 | enablePasteRules?: boolean; 21 | }; 22 | 23 | type RequiredConfig = Required; 24 | 25 | const DEFAULT_CONFIG: RequiredConfig = { 26 | name: 'italic', 27 | keyToggle: 'Mod-i', 28 | enablePasteRules: true, 29 | }; 30 | 31 | export function setupItalic(userConfig?: ItalicConfig) { 32 | const config = { 33 | ...DEFAULT_CONFIG, 34 | ...userConfig, 35 | }; 36 | 37 | const { name } = config; 38 | 39 | const marks = { 40 | [name]: { 41 | parseDOM: [ 42 | { 43 | tag: 'i', 44 | getAttrs: (node) => node.style.fontStyle !== 'normal' && null, 45 | }, 46 | { 47 | tag: 'em', 48 | }, 49 | { style: 'font-style=italic' }, 50 | { 51 | style: 'font-style=normal', 52 | clearMark: (m) => m.type.name === name, 53 | }, 54 | ], 55 | toDOM: (): ['em', 0] => ['em', 0], 56 | } satisfies MarkSpec, 57 | }; 58 | 59 | const plugin = { 60 | keybindings: pluginKeybindings(config), 61 | inputRules: pluginInputRules(config), 62 | pluginPasteRules1: pluginPasteRules1(config), 63 | pluginPasteRules2: pluginPasteRules2(config), 64 | }; 65 | 66 | return collection({ 67 | id: 'italic', 68 | marks, 69 | plugin, 70 | command: { 71 | toggleItalic: toggleItalic(config), 72 | }, 73 | query: { 74 | isItalicActive: isItalicActive(config), 75 | }, 76 | markdown: markdown(config), 77 | }); 78 | } 79 | 80 | // PLUGINS 81 | function pluginKeybindings(config: RequiredConfig) { 82 | return () => { 83 | return keybinding([[config.keyToggle, toggleItalic(config)]], 'italic'); 84 | }; 85 | } 86 | 87 | function pluginInputRules(config: RequiredConfig) { 88 | return ({ schema }: PluginContext) => { 89 | const type = getMarkType(schema, config.name); 90 | return inputRules({ 91 | rules: [ 92 | markInputRule(/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, type), 93 | markInputRule(/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, type), 94 | ], 95 | }); 96 | }; 97 | } 98 | 99 | function pluginPasteRules1(config: RequiredConfig) { 100 | return ({ schema }: PluginContext) => { 101 | if (!config.enablePasteRules) { 102 | return null; 103 | } 104 | const type = getMarkType(schema, config.name); 105 | return markPastePlugin(/_([^_]+)_/g, type); 106 | }; 107 | } 108 | 109 | function pluginPasteRules2(config: RequiredConfig) { 110 | return ({ schema }: PluginContext) => { 111 | if (!config.enablePasteRules) { 112 | return null; 113 | } 114 | const type = getMarkType(schema, config.name); 115 | return markPastePlugin(/\*([^*]+)\*/g, type); 116 | }; 117 | } 118 | 119 | // COMMANDS 120 | function toggleItalic(config: RequiredConfig): Command { 121 | const { name } = config; 122 | return (state, dispatch, _view) => { 123 | const markType = state.schema.marks[name]; 124 | if (!markType) { 125 | return false; 126 | } 127 | 128 | return toggleMark(markType)(state, dispatch); 129 | }; 130 | } 131 | 132 | // QUERY 133 | function isItalicActive(config: RequiredConfig) { 134 | return (state: EditorState) => { 135 | const { name } = config; 136 | const markType = state.schema.marks[name]; 137 | if (!markType) { 138 | return false; 139 | } 140 | 141 | return isMarkActiveInSelection(markType, state); 142 | }; 143 | } 144 | 145 | // MARKDOWN 146 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 147 | const { name } = config; 148 | return { 149 | marks: { 150 | [name]: { 151 | toMarkdown: { 152 | open: '_', 153 | close: '_', 154 | mixable: true, 155 | expelEnclosingWhitespace: true, 156 | }, 157 | parseMarkdown: { 158 | em: { mark: 'italic' }, 159 | }, 160 | }, 161 | }, 162 | }; 163 | } 164 | -------------------------------------------------------------------------------- /packages/banger-editor/src/link/index.ts: -------------------------------------------------------------------------------- 1 | export * from './link'; 2 | -------------------------------------------------------------------------------- /packages/banger-editor/src/paragraph.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Command, 3 | type DOMOutputSpec, 4 | type EditorState, 5 | type Schema, 6 | setBlockType, 7 | } from './pm'; 8 | import { 9 | copyEmptyCommand, 10 | cutEmptyCommand, 11 | filterCommand, 12 | insertEmptyParagraphAboveNode, 13 | insertEmptyParagraphBelowNode, 14 | jumpToEndOfNode, 15 | jumpToStartOfNode, 16 | moveNode, 17 | parentHasDirectParentOfType, 18 | } from './pm-utils'; 19 | 20 | import { 21 | type CollectionType, 22 | collection, 23 | isMac, 24 | keybinding, 25 | setPriority, 26 | } from './common'; 27 | import { PRIORITY } from './common'; 28 | import type { NodeSpec } from './pm'; 29 | import { findParentNodeOfType } from './pm-utils'; 30 | import { type KeyCode, getNodeType } from './pm-utils'; 31 | 32 | export type ParagraphConfig = { 33 | name?: string; 34 | // keys 35 | keyMoveUp?: KeyCode; 36 | keyMoveDown?: KeyCode; 37 | keyEmptyCopy?: KeyCode; 38 | keyEmptyCut?: KeyCode; 39 | keyInsertEmptyParaAbove?: KeyCode; 40 | keyInsertEmptyParaBelow?: KeyCode; 41 | keyJumpToStartOfParagraph?: KeyCode; 42 | keyJumpToEndOfParagraph?: KeyCode; 43 | keyConvertToParagraph?: KeyCode; 44 | }; 45 | 46 | type RequiredConfig = Required; 47 | 48 | const DEFAULT_CONFIG: RequiredConfig = { 49 | name: 'paragraph', 50 | keyMoveUp: 'Alt-ArrowUp', 51 | keyMoveDown: 'Alt-ArrowDown', 52 | keyEmptyCopy: 'Mod-c', 53 | keyEmptyCut: 'Mod-x', 54 | keyInsertEmptyParaAbove: 'Mod-Shift-Enter', 55 | keyInsertEmptyParaBelow: 'Mod-Enter', 56 | keyJumpToStartOfParagraph: isMac ? 'Ctrl-a' : 'Ctrl-Home', 57 | keyJumpToEndOfParagraph: isMac ? 'Ctrl-e' : 'Ctrl-End', 58 | keyConvertToParagraph: isMac ? 'Mod-Alt-0' : 'Ctrl-Shift-0', 59 | }; 60 | 61 | export function setupParagraph(userConfig?: ParagraphConfig) { 62 | const config = { 63 | ...DEFAULT_CONFIG, 64 | ...userConfig, 65 | } as RequiredConfig; 66 | 67 | const { name } = config; 68 | 69 | const nodes: Record = { 70 | [name]: setPriority( 71 | { 72 | content: 'inline*', 73 | group: 'block', 74 | draggable: false, 75 | parseDOM: [ 76 | { 77 | tag: 'p', 78 | }, 79 | ], 80 | toDOM: (): DOMOutputSpec => ['p', 0], 81 | }, 82 | PRIORITY.paragraphSpec, 83 | ), 84 | }; 85 | 86 | const plugin = { 87 | keybindings: pluginKeybindings(config), 88 | }; 89 | 90 | return collection({ 91 | id: 'paragraph', 92 | nodes, 93 | plugin, 94 | command: { 95 | convertToParagraph: convertToParagraph(config), 96 | insertEmptyParagraphAbove: cmdInsertEmptyParagraphAbove(config), 97 | insertEmptyParagraphBelow: cmdInsertEmptyParagraphBelow(config), 98 | jumpToStartOfParagraph: jumpToStartOfParagraph(config), 99 | jumpToEndOfParagraph: jumpToEndOfParagraph(config), 100 | }, 101 | query: { 102 | isParagraph: isParagraph(config), 103 | isTopLevelParagraph: isTopLevelParagraph(config), 104 | }, 105 | markdown: markdown(config), 106 | }); 107 | } 108 | 109 | // PLUGINS 110 | function pluginKeybindings(config: RequiredConfig) { 111 | return ({ schema }: { schema: Schema }) => { 112 | const { name } = config; 113 | const type = getNodeType(schema, name); 114 | const isTopLevel = parentHasDirectParentOfType(type, schema.topNodeType); 115 | 116 | return keybinding( 117 | [ 118 | [config.keyConvertToParagraph, convertToParagraph(config)], 119 | [config.keyMoveUp, filterCommand(isTopLevel, moveNode(type, 'UP'))], 120 | [config.keyMoveDown, filterCommand(isTopLevel, moveNode(type, 'DOWN'))], 121 | [config.keyJumpToStartOfParagraph, jumpToStartOfNode(type)], 122 | [config.keyJumpToEndOfParagraph, jumpToEndOfNode(type)], 123 | [ 124 | config.keyEmptyCopy, 125 | filterCommand(isTopLevel, copyEmptyCommand(type)), 126 | ], 127 | [config.keyEmptyCut, filterCommand(isTopLevel, cutEmptyCommand(type))], 128 | [ 129 | config.keyInsertEmptyParaAbove, 130 | filterCommand(isTopLevel, cmdInsertEmptyParagraphAbove(config)), 131 | ], 132 | [ 133 | config.keyInsertEmptyParaBelow, 134 | filterCommand(isTopLevel, cmdInsertEmptyParagraphBelow(config)), 135 | ], 136 | ], 137 | 'paragraph', 138 | ); 139 | }; 140 | } 141 | 142 | // COMMANDS 143 | function convertToParagraph(config: RequiredConfig): Command { 144 | const { name } = config; 145 | return (state, dispatch) => { 146 | if (isParagraph(config)(state)) { 147 | return false; 148 | } 149 | return setBlockType(getNodeType(state.schema, name))(state, dispatch); 150 | }; 151 | } 152 | 153 | function cmdInsertEmptyParagraphAbove(config: RequiredConfig): Command { 154 | const { name } = config; 155 | return (state, dispatch, view) => { 156 | const type = getNodeType(state.schema, name); 157 | return filterCommand( 158 | parentHasDirectParentOfType(type, state.schema.topNodeType), 159 | insertEmptyParagraphAboveNode(type, () => 160 | getNodeType(state.schema, name), 161 | ), 162 | )(state, dispatch, view); 163 | }; 164 | } 165 | 166 | function cmdInsertEmptyParagraphBelow(config: RequiredConfig): Command { 167 | const { name } = config; 168 | return (state, dispatch, view) => { 169 | const type = getNodeType(state.schema, name); 170 | return filterCommand( 171 | parentHasDirectParentOfType(type, state.schema.topNodeType), 172 | insertEmptyParagraphBelowNode(type, () => 173 | getNodeType(state.schema, name), 174 | ), 175 | )(state, dispatch, view); 176 | }; 177 | } 178 | 179 | function jumpToStartOfParagraph(config: RequiredConfig): Command { 180 | const { name } = config; 181 | return (state, dispatch) => { 182 | const type = getNodeType(state.schema, name); 183 | return jumpToStartOfNode(type)(state, dispatch); 184 | }; 185 | } 186 | 187 | function jumpToEndOfParagraph(config: RequiredConfig): Command { 188 | const { name } = config; 189 | return (state, dispatch) => { 190 | const type = getNodeType(state.schema, name); 191 | return jumpToEndOfNode(type)(state, dispatch); 192 | }; 193 | } 194 | 195 | // QUERY 196 | function isParagraph(config: RequiredConfig) { 197 | return (state: EditorState) => { 198 | const { name } = config; 199 | const type = getNodeType(state.schema, name); 200 | return Boolean(findParentNodeOfType(type)(state.selection)); 201 | }; 202 | } 203 | 204 | function isTopLevelParagraph(config: RequiredConfig) { 205 | return (state: EditorState) => { 206 | const { name } = config; 207 | const type = getNodeType(state.schema, name); 208 | return parentHasDirectParentOfType(type, state.schema.topNodeType)(state); 209 | }; 210 | } 211 | 212 | // MARKDOWN 213 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 214 | // Potential improvement: Might want to handle paragraphs that are empty vs. paragraphs with content differently in markdown. 215 | const { name } = config; 216 | return { 217 | nodes: { 218 | [name]: { 219 | toMarkdown(state, node) { 220 | state.renderInline(node); 221 | state.closeBlock(node); 222 | }, 223 | parseMarkdown: { 224 | paragraph: { 225 | block: name, 226 | }, 227 | }, 228 | }, 229 | }, 230 | }; 231 | } 232 | -------------------------------------------------------------------------------- /packages/banger-editor/src/placeholder.ts: -------------------------------------------------------------------------------- 1 | import { collection } from './common'; 2 | import type { EditorState } from './pm'; 3 | import { Plugin, PluginKey } from './pm'; 4 | import { Decoration, DecorationSet } from './pm'; 5 | import { isDocEmpty } from './pm-utils'; 6 | 7 | const key = new PluginKey('placeholder'); 8 | 9 | export type PlaceholderConfig = { 10 | placeholder?: string | ((state: EditorState) => string); 11 | }; 12 | 13 | type RequiredConfig = Required; 14 | 15 | const DEFAULT_CONFIG: RequiredConfig = { 16 | placeholder: 'Type something...', 17 | }; 18 | 19 | export function setupPlaceholder(config: PlaceholderConfig) { 20 | const finalConfig = { 21 | ...DEFAULT_CONFIG, 22 | ...config, 23 | }; 24 | 25 | const plugin = { 26 | placeholder: pluginPlaceholder(finalConfig), 27 | }; 28 | 29 | return collection({ 30 | id: 'placeholder', 31 | plugin, 32 | }); 33 | } 34 | 35 | function pluginPlaceholder(config: RequiredConfig) { 36 | return new Plugin({ 37 | key, 38 | props: { 39 | decorations(state) { 40 | const placeholderText = 41 | typeof config.placeholder === 'function' 42 | ? config.placeholder(state) 43 | : config.placeholder; 44 | 45 | if (!isDocEmpty(state.doc)) { 46 | return null; 47 | } 48 | 49 | const deco = createPlaceholderDecoration(state, placeholderText); 50 | if (!deco) { 51 | return null; 52 | } 53 | 54 | return DecorationSet.create(state.doc, [deco]); 55 | }, 56 | }, 57 | }); 58 | } 59 | 60 | function createPlaceholderDecoration( 61 | state: EditorState, 62 | placeholderText: string, 63 | ): Decoration | null { 64 | if (!placeholderText) return null; 65 | 66 | const { selection } = state; 67 | if (!selection.empty) return null; 68 | 69 | const $pos = selection.$anchor; 70 | const node = $pos.parent; 71 | if (node.content.size > 0) return null; 72 | 73 | const before = $pos.before(); 74 | return Decoration.node(before, before + node.nodeSize, { 75 | class: 76 | 'before:absolute before:opacity-30 before:pointer-events-none before:h-0 before:content-[attr(data-placeholder)]', 77 | 'data-placeholder': placeholderText, 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/README.md: -------------------------------------------------------------------------------- 1 | # ProseMirror Utilities 2 | 3 | Consolidated utilities for ProseMirror, including helpers, selections, transforms, etc. 4 | 5 | > **NOTE:** Some parts of this code were adapted from https://github.com/atlassian/prosemirror-utils, and have been modified. 6 | 7 | See [LICENSE](https://github.com/atlassian/prosemirror-utils/blob/master/LICENSE) for more details. -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/__tests__ /__snapshots__/helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`matchAllPlus > works when direct match 2`] = ` 4 | [ 5 | { 6 | "_sourceString": "foozball", 7 | "end": 8, 8 | "match": true, 9 | "start": 0, 10 | }, 11 | ] 12 | `; 13 | 14 | exports[`matchAllPlus > works when match 2`] = ` 15 | [ 16 | { 17 | "_sourceString": "baseball foozball", 18 | "end": 10, 19 | "match": false, 20 | "start": 0, 21 | }, 22 | { 23 | "_sourceString": "baseball foozball", 24 | "end": 18, 25 | "match": true, 26 | "start": 10, 27 | }, 28 | ] 29 | `; 30 | 31 | exports[`matchAllPlus > works when no match 2`] = ` 32 | [ 33 | { 34 | "_sourceString": "baseball boozball", 35 | "end": 18, 36 | "match": false, 37 | "start": 0, 38 | }, 39 | ] 40 | `; 41 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/__tests__ /helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { matchAllPlus } from '../helpers'; 3 | 4 | describe('matchAllPlus', () => { 5 | const mergeStartEnd = (str: string, result: any[]) => 6 | result.reduce((prev, cur) => prev + str.slice(cur.start, cur.end), ''); 7 | 8 | const mergeSubstrings = (_str: string, result: any[]) => 9 | result.reduce((prev, cur) => prev + cur.subString, ''); 10 | 11 | test('works when match', () => { 12 | const result = matchAllPlus(/foo[a-z]*/g, 'baseball foozball'); 13 | expect(result.map((r) => r.subString)).toMatchInlineSnapshot(` 14 | [ 15 | "baseball ", 16 | "foozball", 17 | ] 18 | `); 19 | expect(result.map((r) => ({ ...r }))).toMatchSnapshot(); 20 | }); 21 | 22 | test('works when direct match', () => { 23 | const result = matchAllPlus(/foo[a-z]*/g, 'foozball'); 24 | expect(result.map((r) => r.subString)).toMatchInlineSnapshot(` 25 | [ 26 | "foozball", 27 | ] 28 | `); 29 | expect(result.map((r) => ({ ...r }))).toMatchSnapshot(); 30 | }); 31 | 32 | test('works when no match', () => { 33 | const result = matchAllPlus(/foo[a-z]*/g, 'baseball boozball'); 34 | expect(result.map((r) => r.subString)).toMatchInlineSnapshot(` 35 | [ 36 | "baseball boozball", 37 | ] 38 | `); 39 | expect(result.every((r) => r.match === false)).toBe(true); 40 | expect(result.map((r) => ({ ...r }))).toMatchSnapshot(); 41 | }); 42 | 43 | test('works with multiple matches 1', () => { 44 | const result = matchAllPlus( 45 | /foo[a-z]*/g, 46 | 'baseball football foosball gobhi', 47 | ); 48 | expect(result.map((r) => r.subString)).toMatchInlineSnapshot(` 49 | [ 50 | "baseball ", 51 | "football", 52 | " ", 53 | "foosball", 54 | " gobhi", 55 | ] 56 | `); 57 | }); 58 | 59 | test('works with multiple matches 2', () => { 60 | const result = matchAllPlus( 61 | /foo[a-z]*/g, 62 | 'baseball football gobhi tamatar foosball', 63 | ); 64 | expect(result.map((r) => r.subString)).toMatchInlineSnapshot(` 65 | [ 66 | "baseball ", 67 | "football", 68 | " gobhi tamatar ", 69 | "foosball", 70 | ] 71 | `); 72 | expect(result.map((r) => r.match)).toMatchInlineSnapshot(` 73 | [ 74 | false, 75 | true, 76 | false, 77 | true, 78 | ] 79 | `); 80 | }); 81 | 82 | test.each([ 83 | ['hello https://google.com two https://bangle.io', 2], 84 | ['hello https://google.com https://bangle.io', 2], 85 | ['https://google.com https://bangle.io', 2], 86 | ['https://google.com t https://bangle.io ', 2], 87 | ['https://google.com 🙆‍♀️ https://bangle.io 👯‍♀️', 2], 88 | ['hello https://google.com two s', 1], 89 | ["hello https://google.com'", 1], 90 | ["hello https://google.com' two", 1], 91 | ])( 92 | '%# string start and end positions should be correct', 93 | (string, matchCount) => { 94 | const result = matchAllPlus( 95 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-zA-Z]{2,}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, 96 | string, 97 | ); 98 | 99 | expect(result.filter((r) => r.match)).toHaveLength(matchCount); 100 | 101 | expect(mergeStartEnd(string, result)).toBe(string); 102 | }, 103 | ); 104 | 105 | test('1 misc cases', () => { 106 | const regex = /t(e)(st(\d?))/g; 107 | const string = 'test1test2'; 108 | const result = matchAllPlus(regex, string); 109 | 110 | expect(mergeStartEnd(string, result)).toBe(string); 111 | expect(mergeSubstrings(string, result)).toBe(string); 112 | 113 | expect(result).toMatchInlineSnapshot(` 114 | [ 115 | MatchType { 116 | "_sourceString": "test1test2", 117 | "end": 5, 118 | "match": true, 119 | "start": 0, 120 | }, 121 | MatchType { 122 | "_sourceString": "test1test2", 123 | "end": 10, 124 | "match": true, 125 | "start": 5, 126 | }, 127 | ] 128 | `); 129 | }); 130 | 131 | test('2 misc cases', () => { 132 | const regex = /(#\w+)/g; 133 | const string = 'Hello #world #planet!'; 134 | const result = matchAllPlus(regex, string); 135 | expect(mergeStartEnd(string, result)).toBe(string); 136 | 137 | expect(result).toMatchInlineSnapshot(` 138 | [ 139 | MatchType { 140 | "_sourceString": "Hello #world #planet!", 141 | "end": 6, 142 | "match": false, 143 | "start": 0, 144 | }, 145 | MatchType { 146 | "_sourceString": "Hello #world #planet!", 147 | "end": 12, 148 | "match": true, 149 | "start": 6, 150 | }, 151 | MatchType { 152 | "_sourceString": "Hello #world #planet!", 153 | "end": 13, 154 | "match": false, 155 | "start": 12, 156 | }, 157 | MatchType { 158 | "_sourceString": "Hello #world #planet!", 159 | "end": 20, 160 | "match": true, 161 | "start": 13, 162 | }, 163 | MatchType { 164 | "_sourceString": "Hello #world #planet!", 165 | "end": 21, 166 | "match": false, 167 | "start": 20, 168 | }, 169 | ] 170 | `); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './commands'; 2 | export * from './helpers'; 3 | export * from './node-utils'; 4 | export * from './selection'; 5 | export * from './transforms'; 6 | export * from './types'; 7 | export * from './utils'; 8 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/node-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Attrs, MarkType, NodeType, PMNode } from '../pm'; 2 | 3 | type FindChildrenAttrsPredicate = (attrs: Attrs) => boolean; 4 | type FindNodesResult = Array<{ node: PMNode; pos: number }>; 5 | type FindChildrenPredicate = (node: PMNode) => boolean; 6 | 7 | // Flattens descendants of a given `node`. 8 | export const flatten = (node: PMNode, descend = true): FindNodesResult => { 9 | if (!node) { 10 | throw new Error('Invalid "node" parameter'); 11 | } 12 | const result: FindNodesResult = []; 13 | node.descendants((child, pos) => { 14 | result.push({ node: child, pos }); 15 | if (!descend) { 16 | return false; 17 | } 18 | return; 19 | }); 20 | return result; 21 | }; 22 | 23 | // Iterates over descendants of a given `node`, returning child nodes predicate returns truthy for. 24 | export const findChildren = ( 25 | node: PMNode, 26 | predicate: FindChildrenPredicate, 27 | descend = true, 28 | ): FindNodesResult => { 29 | if (!node) { 30 | throw new Error('Invalid "node" parameter'); 31 | } 32 | if (!predicate) { 33 | throw new Error('Invalid "predicate" parameter'); 34 | } 35 | return flatten(node, descend).filter((child) => predicate(child.node)); 36 | }; 37 | 38 | // Returns text nodes of a given `node`. 39 | export const findTextNodes = ( 40 | node: PMNode, 41 | descend = true, 42 | ): FindNodesResult => { 43 | return findChildren(node, (child) => child.isText, descend); 44 | }; 45 | 46 | // Returns inline nodes of a given `node`. 47 | export const findInlineNodes = ( 48 | node: PMNode, 49 | descend = true, 50 | ): FindNodesResult => { 51 | return findChildren(node, (child) => child.isInline, descend); 52 | }; 53 | 54 | // Returns block descendants of a given `node`. 55 | export const findBlockNodes = ( 56 | node: PMNode, 57 | descend = true, 58 | ): FindNodesResult => { 59 | return findChildren(node, (child) => child.isBlock, descend); 60 | }; 61 | 62 | // Iterates over descendants of a given `node`, returning child nodes predicate returns truthy for. 63 | export const findChildrenByAttr = ( 64 | node: PMNode, 65 | predicate: FindChildrenAttrsPredicate, 66 | descend = true, 67 | ): FindNodesResult => { 68 | return findChildren(node, (child) => !!predicate(child.attrs), descend); 69 | }; 70 | 71 | // Iterates over descendants of a given `node`, returning child nodes of a given nodeType. 72 | export const findChildrenByType = ( 73 | node: PMNode, 74 | nodeType: NodeType, 75 | descend = true, 76 | ): FindNodesResult => { 77 | return findChildren(node, (child) => child.type === nodeType, descend); 78 | }; 79 | 80 | // Iterates over descendants of a given `node`, returning child nodes that have a mark of a given markType. 81 | export const findChildrenByMark = ( 82 | node: PMNode, 83 | markType: MarkType, 84 | descend = true, 85 | ): FindNodesResult => { 86 | return findChildren( 87 | node, 88 | (child) => Boolean(markType.isInSet(child.marks)), 89 | descend, 90 | ); 91 | }; 92 | 93 | // Returns `true` if a given node contains nodes of a given `nodeType` 94 | export const contains = (node: PMNode, nodeType: NodeType): boolean => { 95 | return !!findChildrenByType(node, nodeType).length; 96 | }; 97 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/selection.ts: -------------------------------------------------------------------------------- 1 | import { type PMNode, type ResolvedPos, TextSelection } from '../pm'; 2 | import { PMSelection } from '../pm'; 3 | 4 | import { equalNodeType, isNodeSelection } from './helpers'; 5 | import type { 6 | DomAtPos, 7 | FindPredicate, 8 | FindResult, 9 | NodeTypeParam, 10 | } from './types'; 11 | 12 | // Iterates over parent nodes, returning the closest node `predicate` returns truthy for. 13 | export const findParentNode = 14 | (predicate: FindPredicate) => 15 | ({ $from, $to }: PMSelection, validateSameParent = false): FindResult => { 16 | if (validateSameParent && !$from.sameParent($to)) { 17 | let depth = Math.min($from.depth, $to.depth); 18 | while (depth >= 0) { 19 | const fromNode = $from.node(depth); 20 | const toNode = $to.node(depth); 21 | if (toNode === fromNode) { 22 | if (predicate(fromNode)) { 23 | return { 24 | pos: depth > 0 ? $from.before(depth) : 0, 25 | start: $from.start(depth), 26 | depth: depth, 27 | node: fromNode, 28 | }; 29 | } 30 | } 31 | depth = depth - 1; 32 | } 33 | return; 34 | } 35 | return findParentNodeClosestToPos($from, predicate); 36 | }; 37 | 38 | // Iterates over parent nodes starting from the given `$pos`. 39 | export const findParentNodeClosestToPos = ( 40 | $pos: ResolvedPos, 41 | predicate: FindPredicate, 42 | ): FindResult => { 43 | for (let i = $pos.depth; i > 0; i--) { 44 | const node = $pos.node(i); 45 | if (predicate(node)) { 46 | return { 47 | pos: i > 0 ? $pos.before(i) : 0, 48 | start: $pos.start(i), 49 | depth: i, 50 | node, 51 | }; 52 | } 53 | } 54 | return; 55 | }; 56 | 57 | // Iterates over parent nodes, returning DOM reference of the closest node `predicate` returns truthy for. 58 | export const findParentDomRef = 59 | (predicate: FindPredicate, domAtPos: DomAtPos) => 60 | (selection: PMSelection): Node | undefined => { 61 | const parent = findParentNode(predicate)(selection); 62 | if (parent) { 63 | return findDomRefAtPos(parent.pos, domAtPos); 64 | } 65 | return; 66 | }; 67 | 68 | // Checks if there's a parent node `predicate` returns truthy for. 69 | export const hasParentNode = 70 | (predicate: FindPredicate) => 71 | (selection: PMSelection): boolean => { 72 | return !!findParentNode(predicate)(selection); 73 | }; 74 | 75 | // Iterates over parent nodes, returning closest node of a given `nodeType`. 76 | export const findParentNodeOfType = 77 | (nodeType: NodeTypeParam) => 78 | (selection: PMSelection): FindResult => { 79 | return findParentNode((node) => equalNodeType(nodeType, node))(selection); 80 | }; 81 | 82 | // Iterates over parent nodes starting from the given `$pos`, returning closest node of a given `nodeType`. 83 | export const findParentNodeOfTypeClosestToPos = ( 84 | $pos: ResolvedPos, 85 | nodeType: NodeTypeParam, 86 | ): FindResult => { 87 | return findParentNodeClosestToPos($pos, (node: PMNode) => 88 | equalNodeType(nodeType, node), 89 | ); 90 | }; 91 | 92 | // Checks if there's a parent node of a given `nodeType`. 93 | export const hasParentNodeOfType = 94 | (nodeType: NodeTypeParam) => 95 | (selection: PMSelection): boolean => { 96 | return hasParentNode((node) => equalNodeType(nodeType, node))(selection); 97 | }; 98 | 99 | // Iterates over parent nodes, returning DOM reference of the closest node of a given `nodeType`. 100 | export const findParentDomRefOfType = 101 | (nodeType: NodeTypeParam, domAtPos: DomAtPos) => 102 | (selection: PMSelection): Node | undefined => { 103 | return findParentDomRef( 104 | (node) => equalNodeType(nodeType, node), 105 | domAtPos, 106 | )(selection); 107 | }; 108 | 109 | // Returns a node of a given `nodeType` if it is selected. 110 | export const findSelectedNodeOfType = 111 | (nodeType: NodeTypeParam) => 112 | (selection: PMSelection): FindResult | undefined => { 113 | if (isNodeSelection(selection)) { 114 | const { node, $from } = selection; 115 | if (equalNodeType(nodeType, node)) { 116 | return { 117 | node, 118 | start: $from.start(), 119 | pos: $from.pos, 120 | depth: $from.depth, 121 | }; 122 | } 123 | } 124 | return; 125 | }; 126 | 127 | // Returns position of the previous node. 128 | export const findPositionOfNodeBefore = ( 129 | selection: PMSelection, 130 | ): number | undefined => { 131 | const { nodeBefore } = selection.$from; 132 | const maybeSelection = PMSelection.findFrom(selection.$from, -1); 133 | if (maybeSelection && nodeBefore) { 134 | const parent = findParentNodeOfType(nodeBefore.type)(maybeSelection); 135 | if (parent) { 136 | return parent.pos; 137 | } 138 | return maybeSelection.$from.pos; 139 | } 140 | return; 141 | }; 142 | 143 | // Returns DOM reference of a node at a given `position`. 144 | export const findDomRefAtPos = (position: number, domAtPos: DomAtPos): Node => { 145 | const dom = domAtPos(position); 146 | const node = dom.node.childNodes[dom.offset]; 147 | 148 | if (dom.node.nodeType === Node.TEXT_NODE && dom.node.parentNode) { 149 | return dom.node.parentNode; 150 | } 151 | 152 | if (!node || node.nodeType === Node.TEXT_NODE) { 153 | return dom.node; 154 | } 155 | 156 | return node; 157 | }; 158 | 159 | export function isTextSelection( 160 | selection: PMSelection, 161 | ): selection is TextSelection { 162 | return selection instanceof TextSelection; 163 | } 164 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/transforms.ts: -------------------------------------------------------------------------------- 1 | import type { Attrs, Mark, NodeType } from '../pm'; 2 | import { Fragment, PMNode } from '../pm'; 3 | import { NodeSelection, PMSelection, type Transaction } from '../pm'; 4 | 5 | import { 6 | canInsert, 7 | cloneTr, 8 | isEmptyParagraph, 9 | isNodeSelection, 10 | removeNodeAtPos, 11 | replaceNodeAtPos, 12 | } from './helpers'; 13 | import { findParentNodeOfType, findPositionOfNodeBefore } from './selection'; 14 | import type { Content, NodeTypeParam } from './types'; 15 | 16 | // Removes a node of a given `nodeType`. 17 | export const removeParentNodeOfType = 18 | (nodeType: NodeTypeParam) => 19 | (tr: Transaction): Transaction => { 20 | const parent = findParentNodeOfType(nodeType)(tr.selection); 21 | if (parent) { 22 | return removeNodeAtPos(parent.pos)(tr); 23 | } 24 | return tr; 25 | }; 26 | 27 | // Replaces parent node of a given `nodeType` with the given `content`. 28 | export const replaceParentNodeOfType = 29 | (nodeType: NodeTypeParam, content: Content) => 30 | (tr: Transaction): Transaction => { 31 | if (!Array.isArray(nodeType)) { 32 | // biome-ignore lint/style/noParameterAssign: 33 | nodeType = [nodeType]; 34 | } 35 | for (let i = 0, count = nodeType.length; i < count; i++) { 36 | const childNodeType = nodeType[i]; 37 | if (!childNodeType) { 38 | continue; 39 | } 40 | 41 | const parent = findParentNodeOfType(childNodeType)(tr.selection); 42 | if (parent) { 43 | const newTr = replaceNodeAtPos(parent.pos, content)(tr); 44 | if (newTr !== tr) { 45 | return newTr; 46 | } 47 | } 48 | } 49 | return tr; 50 | }; 51 | 52 | // Removes selected node. 53 | export const removeSelectedNode = (tr: Transaction): Transaction => { 54 | if (isNodeSelection(tr.selection)) { 55 | const from = tr.selection.$from.pos; 56 | const to = tr.selection.$to.pos; 57 | return cloneTr(tr.delete(from, to)); 58 | } 59 | return tr; 60 | }; 61 | 62 | // Replaces selected node with a given `content`. 63 | export const replaceSelectedNode = 64 | (content: Content) => 65 | (tr: Transaction): Transaction => { 66 | if (isNodeSelection(tr.selection)) { 67 | const { $from, $to } = tr.selection; 68 | if ( 69 | (content instanceof Fragment && 70 | $from.parent.canReplace( 71 | $from.index(), 72 | $from.indexAfter(), 73 | content, 74 | )) || 75 | (content instanceof PMNode && 76 | $from.parent.canReplaceWith( 77 | $from.index(), 78 | $from.indexAfter(), 79 | content.type, 80 | )) 81 | ) { 82 | return cloneTr( 83 | tr 84 | .replaceWith($from.pos, $to.pos, content) 85 | .setSelection(new NodeSelection(tr.doc.resolve($from.pos))), 86 | ); 87 | } 88 | } 89 | return tr; 90 | }; 91 | 92 | // Sets a text selection from the given position, searching in the specified direction. 93 | export const setTextSelection = 94 | (position: number, dir = 1) => 95 | (tr: Transaction): Transaction => { 96 | const nextSelection = PMSelection.findFrom( 97 | tr.doc.resolve(position), 98 | dir, 99 | true, 100 | ); 101 | if (nextSelection) { 102 | return tr.setSelection(nextSelection); 103 | } 104 | return tr; 105 | }; 106 | 107 | const isSelectableNode = (node: Content): node is PMNode => 108 | Boolean(node instanceof PMNode && node.type && node.type.spec.selectable); 109 | const shouldSelectNode = (node: Content): boolean => 110 | isSelectableNode(node) && node.type.isLeaf; 111 | 112 | const setSelection = ( 113 | node: Content, 114 | pos: number, 115 | tr: Transaction, 116 | ): Transaction => { 117 | if (shouldSelectNode(node)) { 118 | return tr.setSelection(new NodeSelection(tr.doc.resolve(pos))); 119 | } 120 | return setTextSelection(pos)(tr); 121 | }; 122 | 123 | // Inserts a given `content` at the current cursor position, or at a given `position`. 124 | export const safeInsert = 125 | (content: Content, position?: number, tryToReplace?: boolean) => 126 | (tr: Transaction): Transaction => { 127 | const hasPosition = typeof position === 'number'; 128 | const { $from } = tr.selection; 129 | const $insertPos = hasPosition 130 | ? tr.doc.resolve(position) 131 | : isNodeSelection(tr.selection) 132 | ? tr.doc.resolve($from.pos + 1) 133 | : $from; 134 | const { parent } = $insertPos; 135 | 136 | if (isNodeSelection(tr.selection) && tryToReplace) { 137 | const oldTr = tr; 138 | // biome-ignore lint/style/noParameterAssign: 139 | tr = replaceSelectedNode(content)(tr); 140 | if (oldTr !== tr) { 141 | return tr; 142 | } 143 | } 144 | 145 | if (isEmptyParagraph(parent)) { 146 | const oldTr = tr; 147 | // biome-ignore lint/style/noParameterAssign: 148 | tr = replaceParentNodeOfType(parent.type, content)(tr); 149 | if (oldTr !== tr) { 150 | const pos = isSelectableNode(content) 151 | ? $insertPos.before($insertPos.depth) 152 | : $insertPos.pos; 153 | return setSelection(content, pos, tr); 154 | } 155 | } 156 | 157 | if (canInsert($insertPos, content)) { 158 | tr.insert($insertPos.pos, content); 159 | const pos = hasPosition 160 | ? $insertPos.pos 161 | : isSelectableNode(content) 162 | ? tr.selection.$anchor.pos - 1 163 | : tr.selection.$anchor.pos; 164 | return cloneTr(setSelection(content, pos, tr)); 165 | } 166 | 167 | for (let i = $insertPos.depth; i > 0; i--) { 168 | const pos = $insertPos.after(i); 169 | const $pos = tr.doc.resolve(pos); 170 | if (canInsert($pos, content)) { 171 | tr.insert(pos, content); 172 | return cloneTr(setSelection(content, pos, tr)); 173 | } 174 | } 175 | return tr; 176 | }; 177 | 178 | // Changes the type, attributes, and/or marks of the parent node of a given `nodeType`. 179 | export const setParentNodeMarkup = 180 | ( 181 | nodeType: NodeTypeParam, 182 | type: NodeType | null, 183 | attrs?: Attrs | null, 184 | marks?: Array | ReadonlyArray, 185 | ) => 186 | (tr: Transaction): Transaction => { 187 | const parent = findParentNodeOfType(nodeType)(tr.selection); 188 | if (parent) { 189 | return cloneTr( 190 | tr.setNodeMarkup( 191 | parent.pos, 192 | type, 193 | Object.assign({}, parent.node.attrs, attrs), 194 | marks, 195 | ), 196 | ); 197 | } 198 | return tr; 199 | }; 200 | 201 | // Sets a `NodeSelection` on a parent node of a `given nodeType`. 202 | export const selectParentNodeOfType = 203 | (nodeType: NodeTypeParam) => 204 | (tr: Transaction): Transaction => { 205 | if (!isNodeSelection(tr.selection)) { 206 | const parent = findParentNodeOfType(nodeType)(tr.selection); 207 | if (parent) { 208 | return cloneTr( 209 | tr.setSelection(NodeSelection.create(tr.doc, parent.pos)), 210 | ); 211 | } 212 | } 213 | return tr; 214 | }; 215 | 216 | // Deletes previous node. 217 | export const removeNodeBefore = (tr: Transaction): Transaction => { 218 | const position = findPositionOfNodeBefore(tr.selection); 219 | if (typeof position === 'number') { 220 | return removeNodeAtPos(position)(tr); 221 | } 222 | return tr; 223 | }; 224 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { Fragment, NodeType, PMNode, PMPlugin, Schema } from '../pm'; 2 | 3 | export type NodeWithPos = { 4 | pos: number; 5 | node: PMNode; 6 | }; 7 | 8 | export type ContentNodeWithPos = { 9 | start: number; 10 | depth: number; 11 | } & NodeWithPos; 12 | 13 | export type DomAtPos = (pos: number) => { node: Node; offset: number }; 14 | export type FindPredicate = (node: PMNode) => boolean; 15 | 16 | export type Predicate = FindPredicate; 17 | export type FindResult = ContentNodeWithPos | undefined; 18 | 19 | export type NodeTypeParam = NodeType | Array; 20 | export type Content = PMNode | Fragment; 21 | 22 | export type KeyCode = string | false; 23 | 24 | export type PluginContext = { 25 | schema: Schema; 26 | }; 27 | 28 | // Factory type for ProseMirror plugins 29 | export type PluginFactory = (options: { 30 | schema: Schema; 31 | }) => PMPlugin | PMPlugin[] | null; 32 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm-utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type { MarkType, PMNode } from '../pm'; 2 | import type { EditorState } from '../pm'; 3 | 4 | export function clampRange( 5 | start: number, 6 | end: number, 7 | docSize: number, 8 | ): [number, number] { 9 | return [Math.max(0, start), Math.min(end, docSize)]; 10 | } 11 | export interface MarkScanResult { 12 | start: number; 13 | end: number; 14 | text?: string; // the text content within the mark 15 | } 16 | 17 | export function isStoredMark(state: EditorState, markType: MarkType): boolean { 18 | // Quick check for stored marks. Typically used to decide if a user is "in" a mark during typing. 19 | return !!(state.storedMarks && markType.isInSet(state.storedMarks)); 20 | } 21 | 22 | export function findFirstMarkPosition( 23 | markType: MarkType, 24 | doc: PMNode, 25 | from: number, 26 | to: number, 27 | ): MarkScanResult | null { 28 | // Potential improvement: If multiple marks exist, you might want to track them all, not just the first. 29 | const [startPos, endPos] = clampRange(from, to, doc.content.size); 30 | let result: MarkScanResult | null = null; 31 | let accumulatedText = ''; 32 | let withinMark = false; 33 | let markStart = -1; 34 | let markEnd = -1; 35 | 36 | doc.nodesBetween(startPos, endPos, (node, pos) => { 37 | if (result) { 38 | // We found our first mark, so we stop further scanning. 39 | return false; 40 | } 41 | 42 | if (markType.isInSet(node.marks)) { 43 | if (!withinMark) { 44 | withinMark = true; 45 | markStart = pos; 46 | } 47 | markEnd = pos + node.nodeSize; 48 | if (node.isText) { 49 | accumulatedText += node.textContent; 50 | } 51 | return false; 52 | } 53 | 54 | return undefined; 55 | }); 56 | 57 | if (withinMark) { 58 | result = { start: markStart, end: markEnd }; 59 | if (accumulatedText) { 60 | result.text = accumulatedText; 61 | } 62 | } 63 | 64 | return result; 65 | } 66 | -------------------------------------------------------------------------------- /packages/banger-editor/src/pm/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | AttributeSpec, 3 | DOMOutputSpec, 4 | MarkSpec, 5 | NodeSpec, 6 | ParseOptions, 7 | ParseRule, 8 | SchemaSpec, 9 | Attrs, 10 | } from 'prosemirror-model'; 11 | 12 | export type { 13 | MapResult, 14 | Mapping, 15 | StepMap as TransformStepMap, 16 | } from 'prosemirror-transform'; 17 | 18 | export type { 19 | DirectEditorProps, 20 | EditorProps, 21 | NodeView, 22 | NodeViewConstructor, 23 | DecorationAttrs, 24 | } from 'prosemirror-view'; 25 | 26 | export type { 27 | ListAttributes, 28 | ListKind, 29 | } from 'prosemirror-flat-list'; 30 | 31 | export type { Command } from 'prosemirror-state'; 32 | 33 | export { 34 | autoJoin, 35 | baseKeymap, 36 | chainCommands, 37 | createParagraphNear, 38 | deleteSelection, 39 | exitCode, 40 | joinBackward, 41 | joinDown, 42 | joinForward, 43 | joinUp, 44 | lift, 45 | liftEmptyBlock, 46 | macBaseKeymap, 47 | newlineInCode, 48 | pcBaseKeymap, 49 | selectAll, 50 | selectNodeBackward, 51 | selectNodeForward, 52 | selectParentNode, 53 | setBlockType, 54 | splitBlock, 55 | splitBlockKeepMarks, 56 | toggleMark, 57 | wrapIn, 58 | joinTextblockBackward, 59 | joinTextblockForward, 60 | selectTextblockEnd, 61 | selectTextblockStart, 62 | splitBlockAs, 63 | } from 'prosemirror-commands'; 64 | 65 | export { dropCursor } from 'prosemirror-dropcursor'; 66 | 67 | export { 68 | backspaceCommand, 69 | createDedentListCommand, 70 | createIndentListCommand, 71 | createListPlugins, 72 | createListSpec, 73 | createMoveListCommand, 74 | createToggleListCommand, 75 | createUnwrapListCommand, 76 | deleteCommand, 77 | enterCommand, 78 | isListNode, 79 | isListType, 80 | wrappingListInputRule, 81 | } from 'prosemirror-flat-list'; 82 | 83 | export { GapCursor, gapCursor } from 'prosemirror-gapcursor'; 84 | 85 | export { 86 | history, 87 | undo, 88 | redo, 89 | closeHistory, 90 | redoDepth, 91 | redoNoScroll, 92 | undoDepth, 93 | undoNoScroll, 94 | } from 'prosemirror-history'; 95 | 96 | export { 97 | InputRule, 98 | closeDoubleQuote, 99 | closeSingleQuote, 100 | ellipsis, 101 | emDash, 102 | inputRules, 103 | smartQuotes, 104 | openDoubleQuote, 105 | openSingleQuote, 106 | textblockTypeInputRule, 107 | undoInputRule, 108 | wrappingInputRule, 109 | } from 'prosemirror-inputrules'; 110 | 111 | export { keydownHandler, keymap } from 'prosemirror-keymap'; 112 | 113 | export { 114 | ContentMatch, 115 | DOMParser, 116 | DOMSerializer, 117 | Fragment, 118 | Mark, 119 | MarkType, 120 | Node, 121 | Node as PMNode, 122 | NodeRange, 123 | NodeType, 124 | ReplaceError, 125 | ResolvedPos, 126 | Schema, 127 | Slice, 128 | } from 'prosemirror-model'; 129 | 130 | export { 131 | AllSelection, 132 | EditorState, 133 | NodeSelection, 134 | Plugin, 135 | PluginKey, 136 | Selection, 137 | Selection as PMSelection, 138 | SelectionRange, 139 | TextSelection, 140 | Transaction, 141 | Plugin as PMPlugin, 142 | } from 'prosemirror-state'; 143 | 144 | export { default as OrderedMap } from 'orderedmap'; 145 | 146 | export { 147 | AddMarkStep, 148 | RemoveMarkStep, 149 | ReplaceAroundStep, 150 | ReplaceStep, 151 | Step, 152 | StepMap, 153 | StepResult, 154 | Transform, 155 | canJoin, 156 | canSplit, 157 | dropPoint, 158 | findWrapping, 159 | insertPoint, 160 | joinPoint, 161 | liftTarget, 162 | replaceStep, 163 | } from 'prosemirror-transform'; 164 | 165 | export { 166 | Decoration, 167 | DecorationSet, 168 | EditorView, 169 | } from 'prosemirror-view'; 170 | 171 | export { builders } from 'prosemirror-test-builder'; 172 | 173 | export { 174 | schema, 175 | nodes, 176 | marks, 177 | } from 'prosemirror-schema-basic'; 178 | -------------------------------------------------------------------------------- /packages/banger-editor/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * as store from './store'; 2 | -------------------------------------------------------------------------------- /packages/banger-editor/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { type Atom, type WritableAtom, createStore } from 'jotai'; 2 | import { type EditorState, Plugin, PluginKey } from '../pm'; 3 | export { atom } from 'jotai'; 4 | 5 | export type Store = ReturnType; 6 | 7 | const atomStoreKey = new PluginKey('atomStore'); 8 | 9 | export function storePlugin(store?: Store) { 10 | return new Plugin({ 11 | key: atomStoreKey, 12 | state: { 13 | init() { 14 | return store || createStore(); 15 | }, 16 | apply(_, v) { 17 | return v; 18 | }, 19 | }, 20 | }); 21 | } 22 | 23 | export function resolveStore(editorState: EditorState): Store { 24 | const store = atomStoreKey.getState(editorState); 25 | if (!store) { 26 | throw new Error('Store not found'); 27 | } 28 | 29 | return store as Store; 30 | } 31 | 32 | export function get(editorState: EditorState, atom: Atom): Value { 33 | const store = resolveStore(editorState); 34 | return store.get(atom); 35 | } 36 | 37 | export function set( 38 | editorState: EditorState, 39 | atom: WritableAtom, 40 | ...args: Args 41 | ) { 42 | const store = resolveStore(editorState); 43 | store.set(atom, ...args); 44 | } 45 | 46 | export function sub( 47 | editorState: EditorState, 48 | atom: Atom, 49 | listener: () => void, 50 | ): () => void { 51 | const store = resolveStore(editorState); 52 | return store.sub(atom, listener); 53 | } 54 | -------------------------------------------------------------------------------- /packages/banger-editor/src/strike.ts: -------------------------------------------------------------------------------- 1 | import { type CollectionType, collection, keybinding } from './common'; 2 | import { toggleMark } from './pm'; 3 | import { inputRules } from './pm'; 4 | import type { MarkSpec, Schema } from './pm'; 5 | import type { Command, EditorState } from './pm'; 6 | import { 7 | getMarkType, 8 | isMarkActiveInSelection, 9 | markInputRule, 10 | markPastePlugin, 11 | } from './pm-utils'; 12 | 13 | export type StrikeConfig = { 14 | name?: string; 15 | // keys 16 | keyToggle?: string | false; 17 | }; 18 | 19 | type RequiredConfig = Required; 20 | 21 | const DEFAULT_CONFIG: RequiredConfig = { 22 | name: 'strike', 23 | keyToggle: 'Mod-d', 24 | }; 25 | 26 | export function setupStrike(userConfig?: StrikeConfig) { 27 | const config = { 28 | ...DEFAULT_CONFIG, 29 | ...userConfig, 30 | }; 31 | 32 | const { name } = config; 33 | 34 | const marks = { 35 | [name]: { 36 | parseDOM: [ 37 | { 38 | tag: 's', 39 | }, 40 | { 41 | tag: 'del', 42 | }, 43 | { 44 | tag: 'strike', 45 | }, 46 | { 47 | style: 'text-decoration', 48 | getAttrs: (node) => (node === 'line-through' ? {} : false), 49 | }, 50 | ], 51 | toDOM: (): ['s', 0] => ['s', 0], 52 | } satisfies MarkSpec, 53 | }; 54 | 55 | const plugin = { 56 | keybindings: pluginKeybindings(config), 57 | inputRules: pluginInputRules(config), 58 | pasteRules: pluginPasteRules(config), 59 | }; 60 | 61 | return collection({ 62 | id: 'strike', 63 | marks, 64 | plugin, 65 | command: { 66 | toggleStrike: toggleStrike(config), 67 | }, 68 | query: { 69 | isStrikeActive: isStrikeActive(config), 70 | }, 71 | markdown: markdown(config), 72 | }); 73 | } 74 | 75 | // PLUGINS 76 | function pluginKeybindings(config: RequiredConfig) { 77 | return keybinding([[config.keyToggle, toggleStrike(config)]], 'strike'); 78 | } 79 | 80 | function pluginInputRules(config: RequiredConfig) { 81 | return ({ schema }: { schema: Schema }) => { 82 | const type = getMarkType(schema, config.name); 83 | return inputRules({ 84 | rules: [markInputRule(/(?:^|\s)((?:~~)((?:[^~]+))(?:~~))$/, type)], 85 | }); 86 | }; 87 | } 88 | 89 | function pluginPasteRules(config: RequiredConfig) { 90 | return ({ schema }: { schema: Schema }) => { 91 | const type = getMarkType(schema, config.name); 92 | return markPastePlugin(/(?:^|\s)((?:~~)((?:[^~]+))(?:~~))/g, type); 93 | }; 94 | } 95 | 96 | // COMMANDS 97 | function toggleStrike(config: RequiredConfig): Command { 98 | const { name } = config; 99 | return (state, dispatch, _view) => { 100 | const markType = state.schema.marks[name]; 101 | if (!markType) { 102 | return false; 103 | } 104 | 105 | return toggleMark(markType)(state, dispatch); 106 | }; 107 | } 108 | 109 | // QUERY 110 | function isStrikeActive(config: RequiredConfig) { 111 | return (state: EditorState) => { 112 | const { name } = config; 113 | const markType = state.schema.marks[name]; 114 | if (!markType) { 115 | return false; 116 | } 117 | 118 | return isMarkActiveInSelection(markType, state); 119 | }; 120 | } 121 | 122 | // MARKDOWN 123 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 124 | const { name } = config; 125 | return { 126 | marks: { 127 | [name]: { 128 | toMarkdown: { 129 | open: '~~', 130 | close: '~~', 131 | mixable: true, 132 | expelEnclosingWhitespace: true, 133 | }, 134 | parseMarkdown: { 135 | s: { mark: 'strike' }, 136 | }, 137 | }, 138 | }, 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /packages/banger-editor/src/suggestions/index.ts: -------------------------------------------------------------------------------- 1 | import { collection } from '../common'; 2 | import type { Logger } from '../common'; 3 | import type { Selection } from '../pm'; 4 | import { inputRules } from '../pm'; 5 | import { triggerInputRule } from './input-rule'; 6 | import { suggestionKeymap } from './keymap'; 7 | import { 8 | type ReplacementContent, 9 | pluginSuggestion, 10 | removeSuggestMark, 11 | replaceSuggestMarkWith, 12 | } from './plugin-suggestion'; 13 | import { suggestionsMark } from './suggestions-mark'; 14 | 15 | export * from './plugin-suggestion'; 16 | export * from './suggestions-mark'; 17 | export * from './keymap'; 18 | export * from './input-rule'; 19 | 20 | export type SuggestionConfig = { 21 | markName: string; 22 | trigger: string; 23 | markClassName: string; 24 | logger?: Logger; 25 | }; 26 | 27 | export function setupSuggestions(config: SuggestionConfig) { 28 | const marks = { 29 | [config.markName]: suggestionsMark({ 30 | markName: config.markName, 31 | className: config.markClassName, 32 | trigger: config.trigger, 33 | }), 34 | }; 35 | 36 | const plugin = { 37 | inputRules: inputRules({ rules: [triggerInputRule(config)] }), 38 | keybindings: suggestionKeymap(), 39 | suggestion: pluginSuggestion(config), 40 | }; 41 | 42 | return collection({ 43 | id: 'suggestions', 44 | marks, 45 | plugin, 46 | command: { 47 | replaceSuggestMarkWith: ({ 48 | content, 49 | focus, 50 | }: { 51 | content?: ReplacementContent; 52 | focus?: boolean; 53 | }) => { 54 | return replaceSuggestMarkWith({ 55 | markName: config.markName, 56 | content, 57 | focus, 58 | }); 59 | }, 60 | removeSuggestMark: (selection: Selection) => { 61 | return removeSuggestMark({ 62 | markName: config.markName, 63 | selection, 64 | }); 65 | }, 66 | }, 67 | markdown: { 68 | marks: { 69 | [config.markName]: { 70 | toMarkdown: { 71 | open: () => '', 72 | close: () => '', 73 | }, 74 | }, 75 | }, 76 | }, 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /packages/banger-editor/src/suggestions/input-rule.ts: -------------------------------------------------------------------------------- 1 | import { InputRule } from '../pm'; 2 | 3 | import { type EditorState, TextSelection } from '../pm'; 4 | 5 | // ProseMirror uses the Unicode Character 'OBJECT REPLACEMENT CHARACTER' (U+FFFC) as text representation for 6 | // leaf nodes, i.e. nodes that don't have any content or text property (e.g. hardBreak, emoji) 7 | const leafNodeReplacementCharacter = '\ufffc'; 8 | 9 | export function triggerInputRule({ 10 | trigger, 11 | markName, 12 | }: { 13 | trigger: string; 14 | markName: string; 15 | }) { 16 | const regexStart = new RegExp( 17 | `(^|[.!?\\s${leafNodeReplacementCharacter}])(${escapeRegExp(trigger)})$`, 18 | ); 19 | 20 | const startRule = new InputRule( 21 | regexStart, 22 | (editorState: EditorState, match: string[]) => { 23 | const trigger = match[3] || match[2]; 24 | if (!trigger) { 25 | return null; 26 | } 27 | const schema = editorState.schema; 28 | const mark = schema.mark(markName, { trigger }); 29 | const { tr, selection } = editorState; 30 | if (trigger.length > 1) { 31 | const textSelection = TextSelection.create( 32 | tr.doc, 33 | selection.from, 34 | selection.from - trigger.length + 1, 35 | ); 36 | tr.setSelection(textSelection); 37 | } 38 | const marks = selection.$from.marks(); 39 | return tr.replaceSelectionWith( 40 | schema.text(trigger, [mark, ...marks]), 41 | false, 42 | ); 43 | }, 44 | ); 45 | 46 | return startRule; 47 | } 48 | 49 | const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; 50 | const reHasRegExpChar = RegExp(reRegExpChar.source); 51 | 52 | function escapeRegExp(string: string) { 53 | return string && reHasRegExpChar.test(string) 54 | ? string.replace(reRegExpChar, '\\$&') 55 | : string || ''; 56 | } 57 | -------------------------------------------------------------------------------- /packages/banger-editor/src/suggestions/keymap.ts: -------------------------------------------------------------------------------- 1 | import { keybinding } from '../common'; 2 | import { PRIORITY } from '../common'; 3 | import { store } from '../store'; 4 | import { 5 | $suggestion, 6 | $suggestionUi, 7 | removeSuggestMark, 8 | } from './plugin-suggestion'; 9 | 10 | export const suggestionKeymap = () => 11 | keybinding( 12 | [ 13 | [ 14 | 'Escape', 15 | (state, dispatch, view) => { 16 | const suggestion = store.get(state, $suggestion); 17 | if (suggestion) { 18 | return removeSuggestMark({ 19 | markName: suggestion.markName, 20 | selection: state.selection, 21 | })(state, dispatch, view); 22 | } 23 | return false; 24 | }, 25 | ], 26 | [ 27 | 'ArrowDown', 28 | (state) => { 29 | const suggestion = store.get(state, $suggestion); 30 | if (suggestion) { 31 | store.set(state, $suggestion, { 32 | ...suggestion, 33 | selectedIndex: suggestion.selectedIndex + 1, 34 | }); 35 | return true; 36 | } 37 | return false; 38 | }, 39 | ], 40 | [ 41 | 'ArrowUp', 42 | (state) => { 43 | const suggestion = store.get(state, $suggestion); 44 | if (suggestion) { 45 | store.set(state, $suggestion, { 46 | ...suggestion, 47 | selectedIndex: suggestion.selectedIndex - 1, 48 | }); 49 | return true; 50 | } 51 | return false; 52 | }, 53 | ], 54 | [ 55 | 'Enter', 56 | (state) => { 57 | const suggestion = store.get(state, $suggestion); 58 | if (suggestion) { 59 | const ui = store.get(state, $suggestionUi); 60 | const onSelect = ui[suggestion.markName]?.onSelect; 61 | if (onSelect) { 62 | onSelect(suggestion); 63 | return true; 64 | } 65 | } 66 | return false; 67 | }, 68 | ], 69 | ], 70 | 'suggestion', 71 | PRIORITY.suggestionKey, 72 | ); 73 | -------------------------------------------------------------------------------- /packages/banger-editor/src/suggestions/suggestions-mark.ts: -------------------------------------------------------------------------------- 1 | import type { Mark, MarkSpec, Schema } from '../pm'; 2 | import { getMarkType } from '../pm-utils'; 3 | 4 | type SuggestionsMarkAttrs = { 5 | trigger: string; 6 | }; 7 | 8 | export function suggestionsMark({ 9 | markName, 10 | className, 11 | trigger, 12 | }: { 13 | markName: TMarkName; 14 | className: string; 15 | trigger: string; 16 | }): MarkSpec { 17 | return { 18 | name: markName, 19 | inclusive: true, 20 | excludes: '_', 21 | group: 'suggestTriggerMarks', 22 | parseDOM: [{ tag: `span[data-mark-name="${markName}"]` }], 23 | toDOM: () => { 24 | return [ 25 | 'span', 26 | { 27 | 'data-mark-name': markName, 28 | 'data-suggest-trigger': trigger, 29 | class: className, 30 | }, 31 | ]; 32 | }, 33 | attrs: { 34 | trigger: { default: trigger }, 35 | }, 36 | }; 37 | } 38 | 39 | export function createSuggestionsMark( 40 | schema: Schema, 41 | markName: string, 42 | attrs?: SuggestionsMarkAttrs, 43 | ): Mark & { attrs: SuggestionsMarkAttrs } { 44 | const mark = getMarkType(schema, markName).create(attrs); 45 | 46 | return mark as Mark & { attrs: SuggestionsMarkAttrs }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/banger-editor/src/test-helpers/index.ts: -------------------------------------------------------------------------------- 1 | import * as PMTestBuilder from 'prosemirror-test-builder'; 2 | import type { PMNode } from '../pm'; 3 | import { EditorView } from '../pm'; 4 | import { EditorState, NodeSelection, TextSelection } from '../pm'; 5 | import { buildTestSchema } from './schema'; 6 | export * from './schema'; 7 | const { builders } = PMTestBuilder; 8 | 9 | type Tag = { 10 | cursor?: number; 11 | node?: number; 12 | start?: number; 13 | end?: number; 14 | }; 15 | type Ref = { 16 | tag: Tag; 17 | }; 18 | type DocumentTest = PMNode & Ref; 19 | const initSelection = ( 20 | doc: DocumentTest, 21 | ): TextSelection | NodeSelection | undefined => { 22 | const { cursor, node, start, end } = doc.tag; 23 | 24 | if (typeof node === 'number') { 25 | return new NodeSelection(doc.resolve(node)); 26 | } 27 | if (typeof cursor === 'number') { 28 | return new TextSelection(doc.resolve(cursor)); 29 | } 30 | if (typeof start === 'number' && typeof end === 'number') { 31 | return new TextSelection(doc.resolve(start), doc.resolve(end)); 32 | } 33 | return undefined; 34 | }; 35 | 36 | const testHelpers = builders(buildTestSchema(), { 37 | doc: { nodeType: 'doc' }, 38 | p: { nodeType: 'paragraph' }, 39 | text: { nodeType: 'text' }, 40 | atomInline: { nodeType: 'atomInline' }, 41 | atomBlock: { nodeType: 'atomBlock' }, 42 | atomContainer: { nodeType: 'atomContainer' }, 43 | heading: { nodeType: 'heading' }, 44 | blockquote: { nodeType: 'blockquote' }, 45 | a: { markType: 'link', href: 'foo' }, 46 | strong: { markType: 'strong' }, 47 | em: { markType: 'em' }, 48 | code: { markType: 'code' }, 49 | code_block: { nodeType: 'code_block' }, 50 | hr: { markType: 'rule' }, 51 | }); 52 | 53 | type EditorHelper = { 54 | state: EditorState; 55 | view: EditorView; 56 | } & Tag; 57 | 58 | let view: EditorView; 59 | 60 | // afterEach(() => { 61 | // if (!view) { 62 | // return; 63 | // } 64 | 65 | // view.destroy(); 66 | // const editorMount = document.querySelector('#editor-mount'); 67 | // editorMount?.parentNode?.removeChild(editorMount); 68 | // }); 69 | 70 | const createEditor = (doc: DocumentTest): EditorHelper => { 71 | const editorMount = document.createElement('div'); 72 | editorMount.setAttribute('id', 'editor-mount'); 73 | 74 | document.body.appendChild(editorMount); 75 | const state = EditorState.create({ 76 | doc, 77 | schema: buildTestSchema(), 78 | selection: initSelection(doc), 79 | }); 80 | view = new EditorView(editorMount, { state }); 81 | 82 | return { state, view, ...doc.tag }; 83 | }; 84 | 85 | export { createEditor, testHelpers }; 86 | -------------------------------------------------------------------------------- /packages/banger-editor/src/test-helpers/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | marks as schemaBasicMarks, 3 | nodes as schemaBasicNodes, 4 | } from 'prosemirror-schema-basic'; 5 | import { type DOMOutputSpec, type PMNode, Schema } from '../pm'; 6 | 7 | const { 8 | doc, 9 | paragraph, 10 | text, 11 | horizontal_rule: rule, 12 | blockquote, 13 | heading, 14 | code_block, 15 | } = schemaBasicNodes; 16 | 17 | type Attrs = { 18 | [key: string]: unknown; 19 | }; 20 | const atomInline = { 21 | inline: true, 22 | group: 'inline', 23 | atom: true, 24 | attrs: { 25 | color: { default: null }, 26 | }, 27 | selectable: true, 28 | parseDOM: [ 29 | { 30 | tag: 'span[data-node-type="atomInline"]', 31 | getAttrs: (dom: HTMLElement | string): Attrs => { 32 | return { 33 | color: (dom as HTMLElement).getAttribute('data-color'), 34 | }; 35 | }, 36 | }, 37 | ], 38 | toDOM(node: PMNode): DOMOutputSpec { 39 | const { color } = node.attrs; 40 | const attrs = { 41 | 'data-node-type': 'atomInline', 42 | 'data-color': color, 43 | }; 44 | return ['span', attrs]; 45 | }, 46 | }; 47 | 48 | const atomBlock = { 49 | inline: false, 50 | group: 'block', 51 | atom: true, 52 | attrs: { 53 | color: { default: null }, 54 | }, 55 | selectable: true, 56 | parseDOM: [ 57 | { 58 | tag: 'div[data-node-type="atomBlock"]', 59 | getAttrs: (dom: HTMLElement | string): Attrs => { 60 | return { 61 | color: (dom as HTMLElement).getAttribute('data-color'), 62 | }; 63 | }, 64 | }, 65 | ], 66 | toDOM(node: PMNode): DOMOutputSpec { 67 | const { color } = node.attrs; 68 | const attrs = { 69 | 'data-node-type': 'atomBlock', 70 | 'data-color': color, 71 | }; 72 | return ['div', attrs]; 73 | }, 74 | }; 75 | 76 | const atomContainer = { 77 | inline: false, 78 | group: 'block', 79 | content: 'atomBlock', 80 | parseDOM: [ 81 | { 82 | tag: 'div[data-node-type="atomBlockContainer"]', 83 | }, 84 | ], 85 | toDOM(): DOMOutputSpec { 86 | return ['div', { 'data-node-type': 'atomBlockContainer' }]; 87 | }, 88 | }; 89 | 90 | const containerWithRestrictedContent = { 91 | inline: false, 92 | group: 'block', 93 | content: 'paragraph+', 94 | parseDOM: [ 95 | { 96 | tag: 'div[data-node-type="containerWithRestrictedContent"]', 97 | }, 98 | ], 99 | toDOM(): DOMOutputSpec { 100 | return ['div', { 'data-node-type': 'containerWithRestrictedContent' }]; 101 | }, 102 | }; 103 | 104 | const article = { 105 | inline: false, 106 | group: 'block', 107 | content: 'section*', 108 | parseDOM: [ 109 | { 110 | tag: 'article', 111 | }, 112 | ], 113 | toDOM(): DOMOutputSpec { 114 | return ['article', 0]; 115 | }, 116 | }; 117 | 118 | const section = { 119 | inline: false, 120 | group: 'block', 121 | content: 'paragraph*', 122 | parseDOM: [ 123 | { 124 | tag: 'section', 125 | }, 126 | ], 127 | toDOM(): DOMOutputSpec { 128 | return ['section']; 129 | }, 130 | }; 131 | 132 | export const buildTestSchema = () => { 133 | return new Schema({ 134 | nodes: { 135 | doc, 136 | heading, 137 | paragraph, 138 | text, 139 | atomInline, 140 | atomBlock, 141 | atomContainer, 142 | containerWithRestrictedContent, 143 | blockquote, 144 | rule, 145 | code_block, 146 | article, 147 | section, 148 | }, 149 | marks: schemaBasicMarks, 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /packages/banger-editor/src/underline.ts: -------------------------------------------------------------------------------- 1 | import { type CollectionType, collection, keybinding } from './common'; 2 | import { toggleMark } from './pm'; 3 | import type { Command, EditorState } from './pm'; 4 | import { isMarkActiveInSelection } from './pm-utils'; 5 | 6 | export type UnderlineConfig = { 7 | name?: string; 8 | // keys 9 | keyToggle?: string | false; 10 | }; 11 | 12 | type RequiredConfig = Required; 13 | 14 | const DEFAULT_CONFIG: RequiredConfig = { 15 | name: 'underline', 16 | keyToggle: 'Mod-u', 17 | }; 18 | 19 | export function setupUnderline(userConfig?: UnderlineConfig) { 20 | const config = { 21 | ...DEFAULT_CONFIG, 22 | ...userConfig, 23 | }; 24 | 25 | const { name } = config; 26 | 27 | const plugin = { 28 | keybindings: pluginKeybindings(config), 29 | }; 30 | 31 | return collection({ 32 | id: 'underline', 33 | marks: { 34 | [name]: { 35 | // TODO: Not sure how to handle parseDOM and toDOM 36 | parseDOM: [ 37 | { 38 | tag: 'u', 39 | }, 40 | { 41 | style: 'text-decoration', 42 | getAttrs: (node) => (node === 'underline' ? {} : false), 43 | }, 44 | ], 45 | toDOM: (): ['u', 0] => ['u', 0], 46 | }, 47 | }, 48 | plugin, 49 | command: { 50 | toggleUnderline: toggleUnderline(config), 51 | }, 52 | query: { 53 | isUnderlineActive: isUnderlineActive(config), 54 | }, 55 | markdown: markdown(config), 56 | }); 57 | } 58 | 59 | // PLUGINS 60 | function pluginKeybindings(config: RequiredConfig) { 61 | return keybinding([[config.keyToggle, toggleUnderline(config)]], 'underline'); 62 | } 63 | 64 | // COMMANDS 65 | function toggleUnderline(config: RequiredConfig): Command { 66 | const { name } = config; 67 | return (state, dispatch, _view) => { 68 | const markType = state.schema.marks[name]; 69 | if (!markType) { 70 | return false; 71 | } 72 | 73 | return toggleMark(markType)(state, dispatch); 74 | }; 75 | } 76 | 77 | function isUnderlineActive(config: RequiredConfig) { 78 | const { name } = config; 79 | return (state: EditorState) => { 80 | const markType = state.schema.marks[name]; 81 | if (!markType) { 82 | return false; 83 | } 84 | 85 | return isMarkActiveInSelection(markType, state); 86 | }; 87 | } 88 | 89 | // MARKDOWN 90 | function markdown(config: RequiredConfig): CollectionType['markdown'] { 91 | const { name } = config; 92 | return { 93 | // TODO underline is not a real thing in markdown, what is the best option here? 94 | // I know this is cheating, but underlines are confusing 95 | // this moves them italic 96 | marks: { 97 | [name]: { 98 | toMarkdown: { 99 | open: '_', 100 | close: '_', 101 | mixable: true, 102 | expelEnclosingWhitespace: true, 103 | }, 104 | }, 105 | }, 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /packages/banger-editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/library.json", 3 | "include": [ 4 | "${configDir}/src/**/*" 5 | ], 6 | "exclude": [ 7 | "dist", 8 | "build", 9 | "node_modules" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/banger-editor/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import { baseConfig, getPackager } from 'tsup-config'; 3 | 4 | export default defineConfig(async () => { 5 | const pkgJson = await import('./package.json'); 6 | const name = pkgJson.default.name; 7 | const { Packager } = await getPackager(); 8 | const packager = await new Packager({}).init(); 9 | const entry = await packager.generateTsupEntry(name); 10 | 11 | return { 12 | ...baseConfig, 13 | // prevents adding `import prosemiror-xyz` to the bundle 14 | treeshake: 'smallest', 15 | entry: entry, 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /packages/pm-markdown/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangle.dev/pm-markdown", 3 | "version": "2.0.0-alpha.18", 4 | "author": { 5 | "name": "Kushan Joshi", 6 | "email": "0o3ko0@gmail.com", 7 | "url": "http://github.com/kepta" 8 | }, 9 | "description": "A modern collection of ProseMirror packages for building powerful editing experiences", 10 | "keywords": [ 11 | "prosemirror", 12 | "rich text editor", 13 | "editor", 14 | "typescript" 15 | ], 16 | "homepage": "https://bangle.io", 17 | "bugs": { 18 | "url": "https://github.com/bangle-io/banger-editor/issues" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/bangle-io/banger-editor.git", 24 | "directory": "packages/pm-markdown" 25 | }, 26 | "type": "module", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "main": "./src/index.ts", 31 | "files": [ 32 | "dist", 33 | "src" 34 | ], 35 | "scripts": { 36 | "prepublishOnly": "tsx ../../packages/tooling/scripts/prepublish-run.ts", 37 | "postpublish": "tsx ../../packages/tooling/scripts/postpublish-run.ts", 38 | "build:tsup": "tsup --config tsup.config.ts" 39 | }, 40 | "dependencies": { 41 | "@types/markdown-it": "^14.1.2", 42 | "markdown-it": "^14.1.0" 43 | }, 44 | "devDependencies": { 45 | "@bangle.dev/packager": "workspace:*", 46 | "@types/markdown-it": "^14.1.2", 47 | "banger-editor": "workspace:*", 48 | "prosemirror-markdown": "^1.13.2", 49 | "prosemirror-model": "^1.25.0", 50 | "prosemirror-test-builder": "^1.1.1", 51 | "tsconfig": "workspace:*", 52 | "tsup": "^8.4.0", 53 | "tsup-config": "workspace:*" 54 | }, 55 | "peerDependencies": { 56 | "prosemirror-markdown": "*" 57 | }, 58 | "exports": { 59 | ".": "./src/index.ts", 60 | "./list-helpers": "./src/list-helpers.ts", 61 | "./list-markdown": "./src/list-markdown.ts", 62 | "./markdown": "./src/markdown.ts", 63 | "./package.json": "./package.json", 64 | "./pm": "./src/pm.ts", 65 | "./tokenizer": "./src/tokenizer.ts" 66 | }, 67 | "sideEffects": false, 68 | "bangleConfig": { 69 | "tsupEntry": { 70 | "index": "src/index.ts", 71 | "list-markdown": "src/list-markdown.ts", 72 | "markdown": "src/markdown.ts", 73 | "pm": "src/pm.ts", 74 | "tokenizer": "src/tokenizer.ts" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/__tests__/list-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | // getListItemSpacing.test.ts 2 | 3 | import MarkdownIt from 'markdown-it'; 4 | import type Token from 'markdown-it/lib/token.mjs'; 5 | import { describe, expect, test } from 'vitest'; 6 | import { getListItemSpacing } from '../list-helpers'; 7 | 8 | /** 9 | * Helper function that takes a markdown string, parses it with MarkdownIt, 10 | * and returns an array of objects containing the list item content and spacing. 11 | */ 12 | function getListItemDetails( 13 | markdown: string, 14 | ): Array<{ content: string; spacing: string }> { 15 | const md = new MarkdownIt(); 16 | const tokens: Token[] = md.parse(markdown, {}); 17 | 18 | // Debug: Log the tokens to understand their structure (for test development) 19 | console.log( 20 | 'Tokens:', 21 | tokens.map((t) => ({ 22 | type: t.type, 23 | content: t.content, 24 | map: t.map, 25 | // @ts-ignore 26 | tight: t.tight, 27 | level: t.level, 28 | })), 29 | ); 30 | 31 | const items: Array<{ content: string; spacing: string }> = []; 32 | for (let i = 0; i < tokens.length; i++) { 33 | if (tokens[i]?.type === 'list_item_open') { 34 | // Find the inline content token for this list item 35 | let j = i + 1; 36 | while (j < tokens.length && tokens[j]?.type !== 'inline') { 37 | j++; 38 | } 39 | const content = j < tokens.length ? tokens[j]?.content || '' : ''; 40 | items.push({ 41 | content, 42 | spacing: getListItemSpacing(i, tokens), 43 | }); 44 | } 45 | } 46 | return items; 47 | } 48 | 49 | describe('getListItemSpacing', () => { 50 | test('tight list items (no blank lines)', () => { 51 | const markdown = `- item one 52 | - item two 53 | - item three`; 54 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 55 | [ 56 | { 57 | "content": "item one", 58 | "spacing": "thin", 59 | }, 60 | { 61 | "content": "item two", 62 | "spacing": "thin", 63 | }, 64 | { 65 | "content": "item three", 66 | "spacing": "thin", 67 | }, 68 | ] 69 | `); 70 | }); 71 | 72 | test('loose list items (blank lines between items)', () => { 73 | const markdown = `- item one 74 | 75 | - item two 76 | 77 | - item three`; 78 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 79 | [ 80 | { 81 | "content": "item one", 82 | "spacing": "thick", 83 | }, 84 | { 85 | "content": "item two", 86 | "spacing": "thick", 87 | }, 88 | { 89 | "content": "item three", 90 | "spacing": "thin", 91 | }, 92 | ] 93 | `); 94 | }); 95 | 96 | test('list item with multiple paragraphs', () => { 97 | const markdown = `- paragraph one 98 | 99 | paragraph two`; 100 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 101 | [ 102 | { 103 | "content": "paragraph one", 104 | "spacing": "thick", 105 | }, 106 | ] 107 | `); 108 | }); 109 | 110 | test('nested list items - tight', () => { 111 | const markdown = `- item 1 112 | - nested item 1 113 | - nested item 2 114 | - item 2`; 115 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 116 | [ 117 | { 118 | "content": "item 1", 119 | "spacing": "thick", 120 | }, 121 | { 122 | "content": "nested item 1", 123 | "spacing": "thin", 124 | }, 125 | { 126 | "content": "nested item 2", 127 | "spacing": "thin", 128 | }, 129 | { 130 | "content": "item 2", 131 | "spacing": "thin", 132 | }, 133 | ] 134 | `); 135 | }); 136 | 137 | test('nested list items - loose parent, tight child', () => { 138 | const markdown = `- item 1 139 | 140 | - nested item 1 141 | - nested item 2 142 | 143 | - item 2`; 144 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 145 | [ 146 | { 147 | "content": "item 1", 148 | "spacing": "thick", 149 | }, 150 | { 151 | "content": "nested item 1", 152 | "spacing": "thin", 153 | }, 154 | { 155 | "content": "nested item 2", 156 | "spacing": "thin", 157 | }, 158 | { 159 | "content": "item 2", 160 | "spacing": "thin", 161 | }, 162 | ] 163 | `); 164 | }); 165 | 166 | test('nested list items - tight parent, loose child', () => { 167 | const markdown = `- item 1 168 | - nested item 1 169 | 170 | - deeply nested item 1 171 | - nested item 2 172 | - item 2`; 173 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 174 | [ 175 | { 176 | "content": "item 1", 177 | "spacing": "thick", 178 | }, 179 | { 180 | "content": "nested item 1", 181 | "spacing": "thick", 182 | }, 183 | { 184 | "content": "deeply nested item 1", 185 | "spacing": "thin", 186 | }, 187 | { 188 | "content": "nested item 2", 189 | "spacing": "thin", 190 | }, 191 | { 192 | "content": "item 2", 193 | "spacing": "thin", 194 | }, 195 | ] 196 | `); 197 | }); 198 | 199 | test('nested list items - loose parent and loose child', () => { 200 | const markdown = `- item 1 201 | 202 | - nested item 1 203 | 204 | - deeply nested item 1 205 | 206 | - nested item 2 207 | 208 | - item 2`; 209 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 210 | [ 211 | { 212 | "content": "item 1", 213 | "spacing": "thick", 214 | }, 215 | { 216 | "content": "nested item 1", 217 | "spacing": "thick", 218 | }, 219 | { 220 | "content": "deeply nested item 1", 221 | "spacing": "thin", 222 | }, 223 | { 224 | "content": "nested item 2", 225 | "spacing": "thin", 226 | }, 227 | { 228 | "content": "item 2", 229 | "spacing": "thin", 230 | }, 231 | ] 232 | `); 233 | }); 234 | 235 | test('single item list - tight', () => { 236 | const markdown = '- item one'; 237 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 238 | [ 239 | { 240 | "content": "item one", 241 | "spacing": "thin", 242 | }, 243 | ] 244 | `); 245 | }); 246 | 247 | test('single item list - loose (with blank line after)', () => { 248 | const markdown = `- item one 249 | 250 | `; 251 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 252 | [ 253 | { 254 | "content": "item one", 255 | "spacing": "thin", 256 | }, 257 | ] 258 | `); 259 | }); 260 | 261 | test('ordered list - tight', () => { 262 | const markdown = `1. item one 263 | 2. item two`; 264 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 265 | [ 266 | { 267 | "content": "item one", 268 | "spacing": "thin", 269 | }, 270 | { 271 | "content": "item two", 272 | "spacing": "thin", 273 | }, 274 | ] 275 | `); 276 | }); 277 | 278 | test('ordered list - loose', () => { 279 | const markdown = `1. item one 280 | 281 | 2. item two`; 282 | expect(getListItemDetails(markdown)).toMatchInlineSnapshot(` 283 | [ 284 | { 285 | "content": "item one", 286 | "spacing": "thick", 287 | }, 288 | { 289 | "content": "item two", 290 | "spacing": "thin", 291 | }, 292 | ] 293 | `); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './markdown'; 2 | export * from './pm'; 3 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/list-helpers.ts: -------------------------------------------------------------------------------- 1 | import type Token from 'markdown-it/lib/token.mjs'; 2 | 3 | // NOTE this is broken and doesnt really work 4 | export function getListItemSpacing( 5 | tokenIndex: number, 6 | tokens: Token[], 7 | ): 'thick' | 'thin' { 8 | const token = tokens[tokenIndex]; 9 | if (!token || token.type !== 'list_item_open') { 10 | return 'thin'; 11 | } 12 | 13 | // Determine the boundaries of this list item by finding its matching "list_item_close". 14 | let closeIndex = tokenIndex; 15 | const baseLevel = token.level; 16 | for (let i = tokenIndex + 1; i < tokens.length; i++) { 17 | if ( 18 | tokens[i] && 19 | tokens[i]?.type === 'list_item_close' && 20 | tokens[i]?.level === baseLevel 21 | ) { 22 | closeIndex = i; 23 | break; 24 | } 25 | } 26 | 27 | // Gather the direct child block tokens (opening tokens) that have a .map. 28 | // (We consider only tokens with level === baseLevel+1 and a type ending in "_open".) 29 | const children: Token[] = []; 30 | for (let i = tokenIndex + 1; i < closeIndex; i++) { 31 | if ( 32 | tokens[i] && 33 | tokens[i]?.level === baseLevel + 1 && 34 | tokens[i]?.map && 35 | tokens[i]?.type.endsWith('_open') 36 | ) { 37 | // biome-ignore lint/style/noNonNullAssertion: 38 | children.push(tokens[i]!); 39 | } 40 | } 41 | 42 | // If more than one direct block exists, mark the item as "thick". 43 | if (children.length > 1) { 44 | return 'thick'; 45 | } 46 | 47 | // If there is exactly one block-level child and we have map info for the list item, 48 | // check for extra blank lines (using the .map values) in a way that depends on whether 49 | // the list item is the first or last among its siblings. 50 | if (children.length === 1 && token.map) { 51 | const child = children[0]; 52 | if (!child) { 53 | return 'thin'; 54 | } 55 | if (!child.map) { 56 | return 'thin'; 57 | } 58 | const itemStart = token.map[0]; 59 | const itemEnd = token.map[1]; 60 | const childStart = child.map[0]; 61 | const childEnd = child.map[1]; 62 | 63 | // Determine whether this list item has previous or next siblings. 64 | let isFirst = true; 65 | let isLast = true; 66 | 67 | // Check backwards for a sibling "list_item_open" at the same level. 68 | for (let i = tokenIndex - 1; i >= 0; i--) { 69 | const currentToken = tokens[i]; 70 | if ( 71 | currentToken && 72 | currentToken.type === 'list_item_open' && 73 | currentToken.level === baseLevel 74 | ) { 75 | isFirst = false; 76 | break; 77 | } 78 | if (currentToken && currentToken.level < baseLevel) break; 79 | } 80 | 81 | // Check forwards for a sibling "list_item_open" at the same level. 82 | for (let i = closeIndex + 1; i < tokens.length; i++) { 83 | const currentToken = tokens[i]; 84 | if ( 85 | currentToken && 86 | currentToken.type === 'list_item_open' && 87 | currentToken.level === baseLevel 88 | ) { 89 | isLast = false; 90 | break; 91 | } 92 | if (currentToken && currentToken.level < baseLevel) break; 93 | } 94 | 95 | // For the first item (with no previous sibling), check only the gap after the child block. 96 | // For the last item (with no next sibling), check only the gap before the child block. 97 | // For middle items, check both gaps. 98 | if (!isFirst && isLast) { 99 | if (childStart - itemStart >= 1) { 100 | return 'thick'; 101 | } 102 | } else if (isFirst && !isLast) { 103 | if (itemEnd - childEnd >= 1) { 104 | return 'thick'; 105 | } 106 | } else if (!isFirst && !isLast) { 107 | if (childStart - itemStart >= 1 || itemEnd - childEnd >= 1) { 108 | return 'thick'; 109 | } 110 | } 111 | // If the list item is the only one (both first and last), ignore the gaps. 112 | } 113 | 114 | return 'thin'; 115 | } 116 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/markdown.ts: -------------------------------------------------------------------------------- 1 | import type { Schema } from 'prosemirror-model'; 2 | import { MarkdownParser, MarkdownSerializer } from './pm'; 3 | import { defaultTokenizers } from './tokenizer'; 4 | 5 | type UnnestObjValue = T extends { [k: string]: infer U } ? U : never; 6 | 7 | export type MarkdownNodeConfig = { 8 | toMarkdown: UnnestObjValue; 9 | parseMarkdown?: MarkdownParser['tokens']; 10 | }; 11 | 12 | export type MarkdownMarkConfig = { 13 | toMarkdown: UnnestObjValue; 14 | parseMarkdown?: MarkdownParser['tokens']; 15 | }; 16 | 17 | type MarkdownSpec = { 18 | id: string; 19 | markdown?: { 20 | nodes?: Record; 21 | marks?: Record; 22 | }; 23 | }; 24 | 25 | type ParseSpec = ConstructorParameters[2][string]; 26 | 27 | type TokenCollection = 28 | | { 29 | type: 'node'; 30 | parsing: Record; 31 | toMarkdown: Record; 32 | } 33 | | { 34 | type: 'mark'; 35 | parsing: Record; 36 | toMarkdown: Record; 37 | }; 38 | 39 | function createTokenCollection( 40 | type: T, 41 | ): Extract { 42 | return { 43 | type, 44 | parsing: {}, 45 | toMarkdown: {}, 46 | } as any; 47 | } 48 | 49 | function processTokens( 50 | collection: TokenCollection, 51 | key: string, 52 | value: MarkdownNodeConfig | MarkdownMarkConfig, 53 | type: 'node' | 'mark', 54 | ) { 55 | const parse = value.parseMarkdown; 56 | if (parse) { 57 | for (const [key, value] of Object.entries(parse)) { 58 | if (collection.parsing[key]) { 59 | throw new Error(`Duplicate ${type} parsing token found: ${key}`); 60 | } 61 | collection.parsing[key] = value; 62 | } 63 | } 64 | 65 | const toMarkdown = value.toMarkdown; 66 | if (toMarkdown) { 67 | if (collection.toMarkdown[key]) { 68 | throw new Error(`Duplicate ${type} toMarkdown token found: ${key}`); 69 | } 70 | collection.toMarkdown[key] = toMarkdown; 71 | } 72 | } 73 | 74 | export function markdownLoader( 75 | items: Array, 76 | schema: Schema, 77 | // TODO 78 | tokenizers: ConstructorParameters< 79 | typeof MarkdownParser 80 | >[1] = defaultTokenizers, 81 | serializerOptions?: ConstructorParameters[2], 82 | ) { 83 | const nodeTokens = createTokenCollection('node'); 84 | const markTokens = createTokenCollection('mark'); 85 | 86 | for (const item of items) { 87 | if (item.markdown?.nodes) { 88 | for (const [key, value] of Object.entries(item.markdown.nodes)) { 89 | processTokens(nodeTokens, key, value, 'node'); 90 | } 91 | } 92 | 93 | if (item.markdown?.marks) { 94 | for (const [key, value] of Object.entries(item.markdown.marks)) { 95 | processTokens(markTokens, key, value, 'mark'); 96 | } 97 | } 98 | } 99 | 100 | for (const key of Object.keys(nodeTokens.parsing)) { 101 | if (markTokens.parsing[key]) { 102 | throw new Error( 103 | `Token key "${key}" exists in both nodes and marks parsing tokens`, 104 | ); 105 | } 106 | } 107 | 108 | return { 109 | parser: new MarkdownParser(schema, tokenizers, { 110 | ...nodeTokens.parsing, 111 | ...markTokens.parsing, 112 | }), 113 | serializer: new MarkdownSerializer( 114 | nodeTokens.toMarkdown, 115 | markTokens.toMarkdown, 116 | serializerOptions, 117 | ), 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/pm.ts: -------------------------------------------------------------------------------- 1 | export { 2 | MarkdownSerializer, 3 | MarkdownParser, 4 | MarkdownSerializerState, 5 | defaultMarkdownParser, 6 | defaultMarkdownSerializer, 7 | } from 'prosemirror-markdown'; 8 | 9 | export type { ParseSpec } from 'prosemirror-markdown'; 10 | -------------------------------------------------------------------------------- /packages/pm-markdown/src/tokenizer.ts: -------------------------------------------------------------------------------- 1 | import markdownIt from 'markdown-it'; 2 | import { listMarkdownPlugin } from './list-markdown'; 3 | export const defaultTokenizers = markdownIt('commonmark', { 4 | html: false, 5 | breaks: false, 6 | }) 7 | // .enable('table') 8 | .enable('strikethrough') 9 | .use(listMarkdownPlugin); 10 | -------------------------------------------------------------------------------- /packages/pm-markdown/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/library.json", 3 | "include": [ 4 | "." 5 | ], 6 | "exclude": [ 7 | "dist", 8 | "build", 9 | "node_modules" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/pm-markdown/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import { baseConfig, getPackager } from 'tsup-config'; 3 | 4 | export default defineConfig(async () => { 5 | const pkgJson = await import('./package.json'); 6 | const name = pkgJson.default.name; 7 | const { Packager } = await getPackager(); 8 | const packager = await new Packager({}).init(); 9 | const entry = await packager.generateTsupEntry(name); 10 | return { 11 | ...baseConfig, 12 | entry: entry, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /packages/prosemirror-all/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangle.dev/prosemirror-all", 3 | "version": "2.0.0-alpha.18", 4 | "author": { 5 | "name": "Kushan Joshi", 6 | "email": "0o3ko0@gmail.com", 7 | "url": "http://github.com/kepta" 8 | }, 9 | "description": "A modern collection of ProseMirror packages for building powerful editing experiences", 10 | "keywords": [ 11 | "prosemirror", 12 | "rich text editor", 13 | "editor", 14 | "typescript" 15 | ], 16 | "homepage": "https://bangle.io", 17 | "bugs": { 18 | "url": "https://github.com/bangle-io/banger-editor/issues" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/bangle-io/banger-editor.git", 24 | "directory": "packages/prosemirror-all" 25 | }, 26 | "type": "module", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "main": "./src/index.ts", 31 | "files": [ 32 | "dist", 33 | "src" 34 | ], 35 | "scripts": { 36 | "prepublishOnly": "tsx ../../packages/tooling/scripts/prepublish-run.ts", 37 | "postpublish": "tsx ../../packages/tooling/scripts/postpublish-run.ts", 38 | "build:tsup": "tsup --config tsup.config.ts" 39 | }, 40 | "dependencies": { 41 | "@types/orderedmap": "^2.0.0", 42 | "orderedmap": "^2.1.1", 43 | "prosemirror-commands": "^1.7.0", 44 | "prosemirror-dropcursor": "^1.8.1", 45 | "prosemirror-flat-list": "^0.5.4", 46 | "prosemirror-gapcursor": "^1.3.2", 47 | "prosemirror-history": "^1.4.1", 48 | "prosemirror-inputrules": "^1.5.0", 49 | "prosemirror-keymap": "^1.2.2", 50 | "prosemirror-model": "^1.25.0", 51 | "prosemirror-schema-basic": "^1.2.4", 52 | "prosemirror-state": "^1.4.3", 53 | "prosemirror-test-builder": "^1.1.1", 54 | "prosemirror-transform": "^1.10.3", 55 | "prosemirror-view": "^1.38.1" 56 | }, 57 | "devDependencies": { 58 | "@bangle.dev/packager": "workspace:*", 59 | "tsconfig": "workspace:*", 60 | "tsup": "^8.4.0", 61 | "tsup-config": "workspace:*" 62 | }, 63 | "exports": { 64 | ".": "./src/index.ts", 65 | "./package.json": "./package.json" 66 | }, 67 | "sideEffects": false, 68 | "bangleConfig": { 69 | "tsupEntry": { 70 | "index": "src/index.ts" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/prosemirror-all/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'prosemirror-commands'; 2 | export * from 'prosemirror-dropcursor'; 3 | export * from 'prosemirror-flat-list'; 4 | export * from 'prosemirror-gapcursor'; 5 | export * from 'prosemirror-history'; 6 | export * from 'prosemirror-inputrules'; 7 | export * from 'prosemirror-keymap'; 8 | export * from 'prosemirror-model'; 9 | export * from 'prosemirror-state'; 10 | export * from 'prosemirror-transform'; 11 | export * from 'prosemirror-view'; 12 | export { default as OrderedMap } from 'orderedmap'; 13 | export { 14 | nodes as schemaBasicNodes, 15 | marks as schemaBasicMarks, 16 | schema as schemaBasic, 17 | } from 'prosemirror-schema-basic'; 18 | export { Plugin as PMPlugin } from 'prosemirror-state'; 19 | export { Selection as PMSelection } from 'prosemirror-state'; 20 | export { Node as PMNode } from 'prosemirror-model'; 21 | export * as PMTestBuilder from 'prosemirror-test-builder'; 22 | -------------------------------------------------------------------------------- /packages/prosemirror-all/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/library.json", 3 | "include": [ 4 | "." 5 | ], 6 | "exclude": [ 7 | "dist", 8 | "build", 9 | "node_modules" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/prosemirror-all/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import { baseConfig, getPackager } from 'tsup-config'; 3 | 4 | export default defineConfig(async () => { 5 | const pkgJson = await import('./package.json'); 6 | const name = pkgJson.default.name; 7 | const { Packager } = await getPackager(); 8 | const packager = await new Packager({}).init(); 9 | const entry = await packager.generateTsupEntry(name); 10 | return { 11 | ...baseConfig, 12 | entry: entry, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /packages/tooling/packager/build-dist-export-map.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { Package } from '@manypkg/tools'; 3 | import fs from 'fs-extra'; 4 | import { globby } from 'globby'; 5 | import set from 'lodash/set'; 6 | import { 7 | type ExportMapResult, 8 | removeUndefinedValues, 9 | sortObject, 10 | } from './common'; 11 | import { formatPackageJson } from './format-package-json'; 12 | 13 | /** 14 | * Builds an export map by reading the actual contents of the dist directory. 15 | * This is useful when you want to generate exports based on what's actually built 16 | * rather than source files. 17 | */ 18 | export async function buildDistExportMap( 19 | distDir: string, 20 | pkg: Package, 21 | writeToPackageJson = false, 22 | ): Promise { 23 | if (!fs.existsSync(distDir)) { 24 | throw new Error(`Dist directory ${distDir} does not exist`); 25 | } 26 | 27 | const isTypes = (file: string) => file.endsWith('.d.ts'); 28 | const isImport = (file: string) => file.endsWith('.js'); 29 | const isRequire = (file: string) => file.endsWith('.cjs'); 30 | const shouldIgnore = (file: string) => { 31 | const basename = path.basename(file); 32 | return basename.startsWith('.') || basename.startsWith('chunk-'); 33 | }; 34 | 35 | const files = await globby(['**/*.{js,mjs,cjs,d.ts}'], { 36 | cwd: distDir, 37 | absolute: false, 38 | }); 39 | 40 | const filteredFiles = files.filter((file) => !shouldIgnore(file)); 41 | 42 | if (!filteredFiles.some((file) => file === 'index.js')) { 43 | throw new Error(`No top level index file found in ${distDir}`); 44 | } 45 | 46 | if (!filteredFiles.some((file) => file.includes('index.d.ts'))) { 47 | throw new Error( 48 | `No top level index.d.ts file found in ${distDir} did you forget to build?`, 49 | ); 50 | } 51 | 52 | let exportMap: Record = { 53 | './package.json': './package.json', 54 | }; 55 | 56 | const dirName = path.basename(distDir); 57 | const typesVersions: Record> = { 58 | '*': {}, 59 | }; 60 | 61 | // Process each file and map it to the appropriate export path 62 | filteredFiles.forEach((filePath) => { 63 | const parsed = path.parse(filePath); 64 | const dir = parsed.dir; 65 | const nameWithoutExt = (parsed.name + parsed.ext).replace( 66 | /\.(d\.)?(ts|js|mjs|cjs)$/, 67 | '', 68 | ); 69 | const entryPoint = dir ? path.join(dir, nameWithoutExt) : nameWithoutExt; 70 | 71 | const exportKey = entryPoint === 'index' ? '.' : `./${entryPoint}`; 72 | 73 | if (isTypes(filePath)) { 74 | set(exportMap, [exportKey, 'types'], `./${dirName}/${filePath}`); 75 | // Add to typesVersions 76 | const versionKey = exportKey === '.' ? '.' : entryPoint; 77 | set(typesVersions, ['*', versionKey], [`./${dirName}/${filePath}`]); 78 | } else if (isImport(filePath)) { 79 | set(exportMap, [exportKey, 'import'], `./${dirName}/${filePath}`); 80 | set(exportMap, [exportKey, 'default'], `./${dirName}/${filePath}`); 81 | } else if (isRequire(filePath)) { 82 | set(exportMap, [exportKey, 'require'], `./${dirName}/${filePath}`); 83 | } 84 | }); 85 | 86 | exportMap = sortObject(exportMap, (a, b) => { 87 | if (a === '.') return -1; 88 | if (b === '.') return 1; 89 | return a.localeCompare(b); 90 | }); 91 | 92 | exportMap = Object.fromEntries( 93 | Object.entries(exportMap).map(([key, value]) => { 94 | if (typeof value === 'object') { 95 | return [ 96 | key, 97 | removeUndefinedValues({ 98 | // The order of these is important https://publint.dev/rules#exports_types_should_be_first 99 | types: value.types, 100 | import: value.import, 101 | require: value.require, 102 | default: value.default, 103 | }), 104 | ]; 105 | } 106 | return [key, value]; 107 | }), 108 | ); 109 | // Sort typesVersions 110 | typesVersions['*'] = sortObject( 111 | typesVersions['*'] as Record, 112 | (a, b) => { 113 | if (a === '.') return -1; 114 | if (b === '.') return 1; 115 | return a.localeCompare(b); 116 | }, 117 | ); 118 | 119 | const result = { 120 | main: `./${dirName}/index.cjs`, 121 | module: `./${dirName}/index.js`, 122 | types: `./${dirName}/index.d.ts`, 123 | typesVersions, 124 | exports: exportMap, 125 | }; 126 | 127 | if (writeToPackageJson) { 128 | await fs.writeJSON( 129 | path.join(pkg.dir, 'package.json'), 130 | formatPackageJson({ 131 | ...pkg.packageJson, 132 | ...result, 133 | }), 134 | { 135 | spaces: 2, 136 | }, 137 | ); 138 | } 139 | 140 | return result; 141 | } 142 | -------------------------------------------------------------------------------- /packages/tooling/packager/build-src-export-map.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { Package } from '@manypkg/tools'; 3 | import fs from 'fs-extra'; 4 | import { globby } from 'globby'; 5 | import { type ExportMapResult, sortObject } from './common'; 6 | import { formatPackageJson } from './format-package-json'; 7 | 8 | /** 9 | * Builds an export map by reading the actual contents of the src directory. 10 | * This is useful when you want to generate exports based on source files 11 | * rather than built files. 12 | */ 13 | export async function buildSrcExportMap( 14 | srcDir: string, 15 | pkg: Package, 16 | writeToPackageJson = false, 17 | ): Promise { 18 | if (!fs.existsSync(srcDir)) { 19 | throw new Error(`Source directory ${srcDir} does not exist`); 20 | } 21 | 22 | const isTopLevelItem = (filePath: string): boolean => { 23 | return ( 24 | !filePath.includes('/') || 25 | (filePath.split('/').length === 2 && 26 | (filePath.endsWith('/index.ts') || filePath.endsWith('/index.tsx'))) 27 | ); 28 | }; 29 | 30 | const files = await globby(['**/*.{ts,tsx}'], { 31 | cwd: srcDir, 32 | absolute: false, 33 | gitignore: true, 34 | ignore: [ 35 | '.*', 36 | '**/__tests__/**', 37 | '**/*.test.{ts,tsx}', 38 | '**/*.spec.{ts,tsx}', 39 | ], 40 | }); 41 | 42 | const filteredFiles = files 43 | .filter(isTopLevelItem) 44 | .sort((a, b) => a.localeCompare(b)); 45 | 46 | if ( 47 | !filteredFiles.some((file) => file === 'index.ts' || file === 'index.tsx') 48 | ) { 49 | throw new Error(`No top level index file found in ${srcDir}`); 50 | } 51 | 52 | const hasTopLevelIndex = filteredFiles.some( 53 | (file) => file === 'index.ts' || file === 'index.tsx', 54 | ); 55 | 56 | let exportMap: Record = { 57 | './package.json': './package.json', 58 | }; 59 | 60 | // Process each file and map it to the appropriate export path 61 | filteredFiles.forEach((filePath) => { 62 | const parsedPath = path.parse(filePath); 63 | const key = parsedPath.dir || parsedPath.name; 64 | const exportKey = key === 'index' ? '.' : `./${key}`; 65 | exportMap[exportKey] = `./${path.join('src', filePath)}`; 66 | }); 67 | 68 | exportMap = sortObject(exportMap, (a, b) => { 69 | if (a === '.') return -1; 70 | if (b === '.') return 1; 71 | return a.localeCompare(b); 72 | }); 73 | 74 | const result = { 75 | main: hasTopLevelIndex ? './src/index.ts' : '', 76 | exports: exportMap, 77 | }; 78 | 79 | if (writeToPackageJson) { 80 | const finalPackageJson = formatPackageJson({ 81 | ...pkg.packageJson, 82 | ...result, 83 | }); 84 | // biome-ignore lint/performance/noDelete: 85 | delete (finalPackageJson as any).module; 86 | // biome-ignore lint/performance/noDelete: 87 | delete (finalPackageJson as any).types; 88 | // biome-ignore lint/performance/noDelete: 89 | delete (finalPackageJson as any).typesVersions; 90 | 91 | await fs.writeJSON(path.join(pkg.dir, 'package.json'), finalPackageJson, { 92 | spaces: 2, 93 | }); 94 | } 95 | 96 | return result; 97 | } 98 | -------------------------------------------------------------------------------- /packages/tooling/packager/common.ts: -------------------------------------------------------------------------------- 1 | export interface ExportMapResult { 2 | main?: string; 3 | module?: string; 4 | types?: string; 5 | /** The export map object */ 6 | exports: Record< 7 | string, 8 | string | { import?: string; require?: string; types?: string } 9 | >; 10 | } 11 | /** 12 | * Sorts an object's keys based on a provided comparison function. 13 | * If no comparison function is provided, uses localeCompare. 14 | * @param obj The object to sort 15 | * @param compareFunction Optional comparison function for custom sorting 16 | * @returns A new object with sorted keys 17 | */ 18 | export function sortObject( 19 | obj: T, 20 | compareFunction: (a: string, b: string) => number = (a, b) => 21 | a.localeCompare(b), 22 | ): T { 23 | return Object.fromEntries( 24 | Object.entries(obj).sort(([keyA], [keyB]) => compareFunction(keyA, keyB)), 25 | ) as T; 26 | } 27 | 28 | export function removeUndefinedValues(obj: Record) { 29 | return Object.fromEntries( 30 | Object.entries(obj).filter(([_, value]) => value !== undefined), 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /packages/tooling/packager/copy-readme.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { MonorepoRoot, Package } from '@manypkg/tools'; 3 | import fs from 'fs-extra'; 4 | 5 | export async function copyReadMe(pkg: Package, root: MonorepoRoot) { 6 | const packagePath = pkg.dir; 7 | 8 | return { 9 | prepublish: async () => { 10 | await fs.copyFile( 11 | path.join(root.rootDir, 'README.md'), 12 | path.join(packagePath, 'README.md'), 13 | ); 14 | 15 | console.log('Copied README.md to', packagePath); 16 | }, 17 | postpublish: async () => { 18 | await fs.remove(path.join(packagePath, 'README.md')); 19 | 20 | console.log('Unlinked README.md from', packagePath); 21 | }, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /packages/tooling/packager/current-publishing-pkg.ts: -------------------------------------------------------------------------------- 1 | // These values are auto set by npm when publishing 2 | export const currentPublishingPkgName: string = 3 | (process.env as any).npm_package_name || 'unknown'; 4 | export const currentPublishingPkgVersion: string = 5 | (process.env as any).npm_package_version || 'unknown'; 6 | -------------------------------------------------------------------------------- /packages/tooling/packager/execa.ts: -------------------------------------------------------------------------------- 1 | export { execa } from 'execa'; 2 | -------------------------------------------------------------------------------- /packages/tooling/packager/find-root.ts: -------------------------------------------------------------------------------- 1 | export { findRoot } from '@manypkg/find-root'; 2 | -------------------------------------------------------------------------------- /packages/tooling/packager/format-package-json.ts: -------------------------------------------------------------------------------- 1 | import type { PackageJSON } from '@manypkg/tools'; 2 | 3 | export function formatPackageJson(pkg: Record): PackageJSON { 4 | const STANDARD_FIELD_ORDER = [ 5 | 'name', 6 | 'version', 7 | 'authors', 8 | 'author', 9 | 'private', 10 | 'description', 11 | 'keywords', 12 | 'homepage', 13 | 'bugs', 14 | 'license', 15 | 'contributors', 16 | 'repository', 17 | 'type', 18 | 'publishConfig', 19 | 'main', 20 | 'module', 21 | 'types', 22 | 'bin', 23 | 'files', 24 | 'scripts', 25 | 'dependencies', 26 | 'devDependencies', 27 | 'peerDependencies', 28 | 'optionalDependencies', 29 | 'engines', 30 | 'exports', 31 | 'packageManager', 32 | ]; 33 | 34 | const formatted: Record = {}; 35 | 36 | for (const field of STANDARD_FIELD_ORDER) { 37 | if (field in pkg) { 38 | formatted[field] = pkg[field]; 39 | } 40 | } 41 | 42 | for (const field in pkg) { 43 | if (!STANDARD_FIELD_ORDER.includes(field)) { 44 | formatted[field] = pkg[field]; 45 | } 46 | } 47 | 48 | return formatted as PackageJSON; 49 | } 50 | -------------------------------------------------------------------------------- /packages/tooling/packager/index.ts: -------------------------------------------------------------------------------- 1 | export { buildDistExportMap } from './build-dist-export-map'; 2 | export { Packager } from './packager'; 3 | export { formatPackageJson } from './format-package-json'; 4 | export * from './current-publishing-pkg'; 5 | export * from './copy-readme'; 6 | export * from './find-root'; 7 | export * from './execa'; 8 | export * from './build-src-export-map'; 9 | export * from './set-version'; 10 | -------------------------------------------------------------------------------- /packages/tooling/packager/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangle.dev/packager", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@jest/globals": "^29.7.0", 6 | "@manypkg/find-root": "^2.2.3", 7 | "@manypkg/get-packages": "^2.2.2", 8 | "@manypkg/tools": "^1.1.2", 9 | "@tsconfig/node18": "^18.2.4", 10 | "@types/fs-extra": "^11.0.4", 11 | "@types/lodash": "^4.17.14", 12 | "colord": "^2.9.3", 13 | "execa": "9.5.2", 14 | "fs-extra": "^11.2.0", 15 | "globby": "^14.0.2", 16 | "lodash": "^4.17.21", 17 | "p-map": "^7.0.3", 18 | "postcss": "^8.4.49", 19 | "prettier": "3.4.2", 20 | "syncpack": "^13.0.3", 21 | "ts-node": "^10.9.2", 22 | "type-fest": "^4.38.0", 23 | "zod": "^3.24.2" 24 | }, 25 | "main": "index.ts", 26 | "module": "index.ts", 27 | "private": true, 28 | "type": "module" 29 | } 30 | -------------------------------------------------------------------------------- /packages/tooling/packager/set-version.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { MonorepoRoot, Package } from '@manypkg/tools'; 3 | import fs from 'fs-extra'; 4 | import { execa } from './execa'; 5 | 6 | interface SetVersionConfig { 7 | /** 8 | * Whether to skip git checks and operations 9 | */ 10 | skipGitChecks?: boolean; 11 | /** 12 | * Git branch to use for version bumps 13 | */ 14 | gitBranch: string; 15 | /** 16 | * GitHub repository URL for release notes 17 | */ 18 | githubRepoUrl: string; 19 | /** 20 | * When true, no changes are actually written to the filesystem or run as shell commands 21 | */ 22 | dry?: boolean; 23 | } 24 | 25 | interface SetVersionContext { 26 | root: MonorepoRoot; 27 | config: Required; 28 | } 29 | 30 | /** 31 | * Validates version string format (e.g., "1.0.0" or "1.0.0-beta.1") 32 | */ 33 | function validateVersion(version: string): boolean { 34 | const regex = /^\d+\.\d+\.\d+(-[\w.]+)?$/; 35 | return regex.test(version); 36 | } 37 | 38 | /** 39 | * Checks if git working directory has uncommitted changes 40 | */ 41 | async function isGitDirty(cwd: string): Promise { 42 | const { stdout } = await execa('git', ['status', '--porcelain'], { cwd }); 43 | return !!stdout.trim(); 44 | } 45 | 46 | /** 47 | * Updates package.json version for a single package 48 | */ 49 | async function updatePackageVersion( 50 | pkg: Package, 51 | version: string, 52 | dry: boolean, 53 | ): Promise { 54 | if (pkg.packageJson.private) { 55 | console.log(`Skipping private package: ${pkg.packageJson.name}`); 56 | return; 57 | } 58 | 59 | const packageJsonPath = path.join(pkg.dir, 'package.json'); 60 | const packageJson = await fs.readJson(packageJsonPath); 61 | const oldVersion = packageJson.version; 62 | 63 | packageJson.version = version; 64 | 65 | if (!dry) { 66 | if (oldVersion !== version) { 67 | await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); 68 | } else { 69 | console.log( 70 | `Skipping ${packageJson.name} as version is already ${version}`, 71 | ); 72 | } 73 | } else { 74 | console.log( 75 | `[Dry Run] Would update ${packageJson.name} to version ${version}`, 76 | ); 77 | } 78 | } 79 | 80 | /** 81 | * Handles git operations for version update 82 | */ 83 | async function handleGitOperations( 84 | ctx: SetVersionContext, 85 | version: string, 86 | ): Promise { 87 | const { root, config } = ctx; 88 | const { gitBranch, dry, githubRepoUrl } = config; 89 | 90 | if (dry) { 91 | console.log( 92 | `[Dry Run] Would perform git operations for version ${version}`, 93 | ); 94 | return; 95 | } 96 | 97 | try { 98 | // Checkout and pull latest 99 | await execa('git', ['checkout', gitBranch], { cwd: root.rootDir }); 100 | await execa('git', ['pull', 'origin', gitBranch], { cwd: root.rootDir }); 101 | 102 | // Commit changes 103 | await execa('git', ['add', '-A'], { cwd: root.rootDir }); 104 | await execa('git', ['commit', '-m', `Bump version to ${version}`], { 105 | cwd: root.rootDir, 106 | }); 107 | 108 | // Create and push tag 109 | await execa( 110 | 'git', 111 | ['tag', '-a', `v${version}`, '-m', `release v${version}`], 112 | { 113 | cwd: root.rootDir, 114 | }, 115 | ); 116 | await execa('git', ['push', 'origin', `v${version}`], { 117 | cwd: root.rootDir, 118 | }); 119 | await execa('git', ['push', 'origin', 'HEAD', '--tags'], { 120 | cwd: root.rootDir, 121 | }); 122 | 123 | console.log(`Committed and tagged version ${version}.`); 124 | console.log( 125 | `Visit ${githubRepoUrl}/releases/new?tag=v${version} to add release notes.`, 126 | ); 127 | } catch (error) { 128 | if (error instanceof Error) { 129 | console.error('Error performing git operations:', error.message); 130 | } 131 | throw error; 132 | } 133 | } 134 | 135 | /** 136 | * Sets the version across all packages in a monorepo 137 | */ 138 | export async function setVersion( 139 | root: MonorepoRoot, 140 | version: string, 141 | config: SetVersionConfig, 142 | ): Promise { 143 | // Validate inputs and setup 144 | if (!validateVersion(version)) { 145 | throw new Error( 146 | 'Invalid version format. Expected format: X.Y.Z or X.Y.Z-tag.N', 147 | ); 148 | } 149 | 150 | const resolvedConfig = { 151 | skipGitChecks: false, 152 | dry: false, 153 | ...config, 154 | }; 155 | 156 | const ctx: SetVersionContext = { 157 | root, 158 | config: resolvedConfig, 159 | }; 160 | 161 | // Check git status if needed 162 | if (!resolvedConfig.skipGitChecks && (await isGitDirty(root.rootDir))) { 163 | throw new Error( 164 | 'Git working directory has uncommitted changes. Please commit or stash them before proceeding.', 165 | ); 166 | } 167 | 168 | // Update versions in all packages 169 | await Promise.all( 170 | (await root.tool.getPackages(root.rootDir)).packages.map((pkg) => 171 | updatePackageVersion(pkg, version, resolvedConfig.dry), 172 | ), 173 | ); 174 | 175 | // Handle git operations if not skipped 176 | if (!resolvedConfig.skipGitChecks) { 177 | await handleGitOperations(ctx, version); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /packages/tooling/scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangle.dev/scripts", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@bangle.dev/packager": "workspace:*", 6 | "@jest/globals": "^29.7.0", 7 | "@tsconfig/node18": "^18.2.4", 8 | "@types/fs-extra": "^11.0.4", 9 | "@types/yargs": "^17.0.33", 10 | "colord": "^2.9.3", 11 | "execa": "9.5.2", 12 | "fs-extra": "^11.2.0", 13 | "globby": "^14.0.2", 14 | "p-map": "^7.0.3", 15 | "postcss": "^8.4.49", 16 | "prettier": "3.4.2", 17 | "syncpack": "^13.0.3", 18 | "ts-node": "^10.9.2", 19 | "yargs": "^17.7.2", 20 | "zod": "^3.24.2" 21 | }, 22 | "main": "src/index.ts", 23 | "module": "src/index.ts", 24 | "private": true, 25 | "type": "module", 26 | "bin": { 27 | "release-package-set-version": "./release-package-set-version.ts", 28 | "release-package-publish": "./release-package-publish.ts" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/tooling/scripts/postpublish-run.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildSrcExportMap, 3 | copyReadMe, 4 | currentPublishingPkgName, 5 | findRoot, 6 | } from '@bangle.dev/packager'; 7 | import fs from 'fs-extra'; 8 | 9 | async function main() { 10 | const root = await findRoot(process.cwd()); 11 | const pkg = (await root.tool.getPackages(root.rootDir)).packages.find( 12 | (pkg) => pkg.packageJson.name === currentPublishingPkgName, 13 | ); 14 | if (!pkg) { 15 | throw new Error( 16 | `Package ${currentPublishingPkgName} not found in ${root.rootDir}`, 17 | ); 18 | } 19 | 20 | fs.ensureDirSync(`${pkg.dir}/dist`); 21 | const readMe = await copyReadMe(pkg, root); 22 | await readMe.postpublish(); 23 | 24 | await buildSrcExportMap(`${pkg.dir}/src`, pkg, true); 25 | 26 | console.log('Done publishing package name', currentPublishingPkgName); 27 | } 28 | 29 | main(); 30 | -------------------------------------------------------------------------------- /packages/tooling/scripts/prepublish-run.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildDistExportMap, 3 | copyReadMe, 4 | currentPublishingPkgName, 5 | execa, 6 | findRoot, 7 | } from '@bangle.dev/packager'; 8 | import fs from 'fs-extra'; 9 | 10 | async function main() { 11 | const root = await findRoot(process.cwd()); 12 | const pkg = (await root.tool.getPackages(root.rootDir)).packages.find( 13 | (pkg) => pkg.packageJson.name === currentPublishingPkgName, 14 | ); 15 | if (!pkg) { 16 | throw new Error( 17 | `Package ${currentPublishingPkgName} not found in ${root.rootDir}`, 18 | ); 19 | } 20 | 21 | fs.ensureDirSync(`${pkg.dir}/dist`); 22 | 23 | const readMe = await copyReadMe(pkg, root); 24 | 25 | await readMe.prepublish(); 26 | 27 | await execa('tsup', ['--config', 'tsup.config.ts'], { 28 | cwd: pkg.dir, 29 | }); 30 | 31 | await buildDistExportMap(`${pkg.dir}/dist`, pkg, true); 32 | 33 | console.log('Done publishing package name', currentPublishingPkgName); 34 | } 35 | 36 | main(); 37 | -------------------------------------------------------------------------------- /packages/tooling/scripts/release-package-publish.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file A script to publish packages to npm. 3 | * 4 | * ## Usage 5 | * ```bash 6 | * pnpm release-package-publish [options] 7 | * ``` 8 | * 9 | * ### Options 10 | * - `--alpha`: Publish with an `alpha` dist-tag 11 | * - `--otp `: Provide an npm one-time password (2FA code) for the publish 12 | * - `--dry`: Perform a dry run without actually making changes 13 | * 14 | * ### Examples 15 | * ```bash 16 | * # Publish packages: 17 | * pnpm release-package-publish --otp=123456 18 | * 19 | * # Publish under the `alpha` dist-tag: 20 | * pnpm release-package-publish --alpha --otp=123456 21 | * ``` 22 | */ 23 | 24 | import { hideBin } from 'yargs/helpers'; 25 | import yargs from 'yargs/yargs'; 26 | import { Packager } from '../packager'; 27 | 28 | interface CliOptions { 29 | alpha: boolean; 30 | otp: string; 31 | dry: boolean; 32 | } 33 | 34 | function parseCli(): CliOptions { 35 | const argv = yargs(hideBin(process.argv)) 36 | .usage('Usage: pnpm release-package-publish [options]') 37 | .option('alpha', { 38 | type: 'boolean', 39 | default: false, 40 | describe: 'Release an alpha version with --tag alpha', 41 | }) 42 | .option('otp', { 43 | type: 'string', 44 | default: '', 45 | describe: 'One-time password (2FA) for npm publish', 46 | }) 47 | .option('dry', { 48 | type: 'boolean', 49 | default: false, 50 | describe: 'Perform a dry run without making changes', 51 | }) 52 | .strict() 53 | .help() 54 | .parseSync(); 55 | 56 | return { 57 | alpha: argv.alpha, 58 | otp: argv.otp, 59 | dry: argv.dry, 60 | }; 61 | } 62 | 63 | async function main() { 64 | const { alpha, otp, dry } = parseCli(); 65 | console.log( 66 | `\nStarting publish process${alpha ? ' (alpha)' : ''}${dry ? ' [dry mode]' : ''}\n`, 67 | ); 68 | 69 | // Initialize the packager 70 | const packager = new Packager({ dry }); 71 | await packager.init(); 72 | 73 | // Publish all non-private packages 74 | const tagFlag = alpha ? '--tag alpha' : '--tag latest'; 75 | console.log( 76 | `> Publishing packages to npm with ${alpha ? 'alpha' : 'latest'} tag...\n`, 77 | ); 78 | 79 | for (const pkg of packager.packages) { 80 | if (pkg.packageJson.private) { 81 | console.log(`Skipping private package: ${pkg.packageJson.name}`); 82 | continue; 83 | } 84 | 85 | const cmd = `npm publish ${tagFlag}${otp ? ` --otp=${otp}` : ''}`; 86 | console.log(`\n--- Publishing ${pkg.packageJson.name} ---`); 87 | await packager.publishPackage(pkg.packageJson.name, { 88 | publishCommand: cmd, 89 | cleanup: true, 90 | }); 91 | } 92 | 93 | console.log('\n> Publish process complete!\n'); 94 | console.log('Visit GitHub to finalize release notes.\n'); 95 | } 96 | 97 | main().catch((err) => { 98 | console.error('Publish process failed:', err); 99 | process.exit(1); 100 | }); 101 | -------------------------------------------------------------------------------- /packages/tooling/scripts/release-package-set-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file A script to bump the version of all packages in the monorepo. 3 | * 4 | * ## Usage 5 | * ```bash 6 | * pnpm release-package-set-version --vv 1.2.3 [options] 7 | * ``` 8 | * 9 | * ### Options 10 | * - `-v, --version`: Version to set (required) 11 | * - `--dry`: Perform a dry run without actually making changes 12 | * 13 | * ### Examples 14 | * ```bash 15 | * # Set version to 1.2.3: 16 | * pnpm release-package-set-version -v 1.2.3 17 | * 18 | * # Dry run for version 1.2.3: 19 | * pnpm release-package-set-version -v 1.2.3 --dry 20 | * ``` 21 | */ 22 | 23 | import { hideBin } from 'yargs/helpers'; 24 | import yargs from 'yargs/yargs'; 25 | import { Packager } from '../packager'; 26 | 27 | interface CliOptions { 28 | version: string; 29 | dry: boolean; 30 | } 31 | 32 | function parseCli(): CliOptions { 33 | const argv = yargs(hideBin(process.argv)) 34 | .usage('Usage: pnpm release-package-set-version --vv [options]') 35 | .option('vv', { 36 | alias: 'v', 37 | type: 'string', 38 | demandOption: true, 39 | describe: 'Version to set', 40 | }) 41 | .option('dry', { 42 | type: 'boolean', 43 | default: false, 44 | describe: 'Perform a dry run without making changes', 45 | }) 46 | .check((args) => { 47 | // Simple semver-like check 48 | if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(args.vv)) { 49 | throw new Error(`Invalid version format: "${args.vv}"`); 50 | } 51 | return true; 52 | }) 53 | .strict() 54 | .help() 55 | .parseSync(); 56 | 57 | return { 58 | version: argv.vv, 59 | dry: argv.dry, 60 | }; 61 | } 62 | 63 | async function main() { 64 | const { version, dry } = parseCli(); 65 | console.log( 66 | `\nStarting version update process for version ${version}${dry ? ' [dry mode]' : ''}\n`, 67 | ); 68 | 69 | // Initialize the packager 70 | const packager = new Packager({ dry }); 71 | await packager.init(); 72 | 73 | // Set the version across all packages and create a tag 74 | console.log('> Updating version in all packages...\n'); 75 | await packager.setVersion(version); 76 | console.log('\n> Version update complete.\n'); 77 | 78 | console.log(`Next steps: 79 | 1. Create a release on GitHub for tag v${version}. 80 | 2. To publish, run: pnpm release-package-publish${' --otp='}\n`); 81 | } 82 | 83 | main().catch((err) => { 84 | console.error('Version update process failed:', err); 85 | process.exit(1); 86 | }); 87 | -------------------------------------------------------------------------------- /packages/tooling/scripts/set-version.ts: -------------------------------------------------------------------------------- 1 | import { findRoot, setVersion } from '@bangle.dev/packager'; 2 | import { hideBin } from 'yargs/helpers'; 3 | import yargs from 'yargs/yargs'; 4 | 5 | interface CliOptions { 6 | version: string; 7 | skipGitChecks?: boolean; 8 | dry?: boolean; 9 | } 10 | 11 | function parseCli(): CliOptions { 12 | const argv = yargs(hideBin(process.argv)) 13 | .usage('Usage: $0 --vv [options]') 14 | .option('vv', { 15 | alias: 'v', 16 | type: 'string', 17 | demandOption: true, 18 | describe: 'Version to set', 19 | }) 20 | .option('skip-git-checks', { 21 | type: 'boolean', 22 | default: false, 23 | describe: 'Skip git checks and operations', 24 | }) 25 | .option('dry', { 26 | type: 'boolean', 27 | default: false, 28 | describe: 'Perform a dry run without making changes', 29 | }) 30 | .check((args) => { 31 | // Simple semver-like check 32 | if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(args.vv)) { 33 | throw new Error(`Invalid version format: "${args.vv}"`); 34 | } 35 | return true; 36 | }) 37 | .strict() 38 | .help() 39 | .parseSync(); 40 | 41 | return { 42 | version: argv.vv, 43 | skipGitChecks: argv['skip-git-checks'], 44 | dry: argv.dry, 45 | }; 46 | } 47 | 48 | async function main() { 49 | const options = parseCli(); 50 | console.log( 51 | `\nStarting version update process for version ${options.version}${options.dry ? ' [dry mode]' : ''}\n`, 52 | ); 53 | 54 | const root = await findRoot(process.cwd()); 55 | 56 | // Set the version across all packages and create a tag 57 | console.log('> Updating version in all packages...\n'); 58 | await setVersion(root, options.version, { 59 | skipGitChecks: options.skipGitChecks ?? false, 60 | gitBranch: 'dev', 61 | githubRepoUrl: 'https://github.com/bangle-io/banger-editor', 62 | dry: options.dry ?? false, 63 | }); 64 | 65 | console.log('\n> Version update complete.\n'); 66 | } 67 | 68 | main().catch((err) => { 69 | console.error('Version update process failed:', err); 70 | process.exit(1); 71 | }); 72 | -------------------------------------------------------------------------------- /packages/tooling/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "declarationDir": "./dist", 8 | "esModuleInterop": true, 9 | "exactOptionalPropertyTypes": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react-jsx", 12 | "module": "esnext", 13 | "lib": [ 14 | "esnext", 15 | "DOM" 16 | ], 17 | "isolatedModules": true, 18 | "moduleResolution": "bundler", 19 | "noEmit": true, 20 | "noEmitOnError": false, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": true, 23 | "noImplicitOverride": true, 24 | "emitDeclarationOnly": true, 25 | "noImplicitReturns": true, 26 | "moduleDetection": "force", 27 | "noPropertyAccessFromIndexSignature": false, 28 | "noUncheckedIndexedAccess": true, 29 | "verbatimModuleSyntax": true, 30 | "noUnusedLocals": false, 31 | "declarationMap": true, 32 | "noUnusedParameters": false, 33 | "pretty": true, 34 | "skipLibCheck": true, 35 | "strict": true, 36 | "strictNullChecks": true, 37 | "preserveWatchOutput": true, 38 | "stripInternal": true, 39 | "target": "ES2020" 40 | }, 41 | "exclude": [ 42 | "node_modules", 43 | "dist" 44 | ] 45 | } -------------------------------------------------------------------------------- /packages/tooling/tsconfig/library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "target": "ESNext" 7 | } 8 | } -------------------------------------------------------------------------------- /packages/tooling/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/tooling/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "npm-run-all": "^4.1.5", 6 | "tsup": "^8.4.0", 7 | "tsx": "^4.19.3", 8 | "typescript": "^5.8.2" 9 | }, 10 | "license": "MIT", 11 | "private": true, 12 | "publishConfig": { 13 | "access": "public" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/tooling/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": [ 8 | "ES2015", 9 | "DOM" 10 | ], 11 | "module": "ESNext", 12 | "target": "es6" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/tooling/tsup-config/index.d.mts: -------------------------------------------------------------------------------- 1 | import { Options } from 'tsup'; 2 | 3 | export const baseConfig: Options; 4 | 5 | export const getPackager: () => Promise; -------------------------------------------------------------------------------- /packages/tooling/tsup-config/index.mjs: -------------------------------------------------------------------------------- 1 | import { tsImport } from 'tsx/esm/api'; 2 | 3 | /** 4 | * @type {import('tsup').Options} 5 | */ 6 | export const baseConfig = { 7 | format: ['esm', 'cjs'], 8 | splitting: true, 9 | dts: true, 10 | clean: true, 11 | shims: false, 12 | }; 13 | 14 | export const getPackager = async () => { 15 | return (await tsImport('@bangle.dev/packager', import.meta.url)) 16 | } -------------------------------------------------------------------------------- /packages/tooling/tsup-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsup-config", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@bangle.dev/packager": "workspace:*", 6 | "tsup": "^8.4.0", 7 | "tsx": "^4.19.3", 8 | "typescript": "^5.8.2" 9 | }, 10 | "license": "MIT", 11 | "main": "index.mjs", 12 | "private": true, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "types": "index.d.mts" 17 | } -------------------------------------------------------------------------------- /packages/tooling/tsup-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/library.json", 3 | "include": ["."], 4 | "compilerOptions": { 5 | "checkJs": true 6 | }, 7 | "exclude": ["dist", "build", "node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'packages/tooling/*' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "allowImportingTsExtensions": true, 6 | "allowJs": true, 7 | "checkJs": true, 8 | "composite": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "resolvePackageJsonImports": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "importHelpers": true, 15 | "isolatedModules": true, 16 | "jsx": "react-jsx", 17 | "module": "ES2020", 18 | "moduleResolution": "bundler", 19 | "noEmit": true, 20 | "noEmitOnError": false, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": true, 23 | "noImplicitReturns": true, 24 | "noPropertyAccessFromIndexSignature": false, 25 | "noUncheckedIndexedAccess": true, 26 | "noUnusedLocals": false, 27 | "noUnusedParameters": true, 28 | "pretty": true, 29 | "removeComments": false, 30 | "resolveJsonModule": true, 31 | "skipLibCheck": true, 32 | "strict": true, 33 | "stripInternal": true, 34 | // Emit Options 35 | // General Options 36 | // JavaScript Support 37 | // Module Resolution 38 | // Strict Type-Checking Options 39 | // JSX 40 | // Target and Libraries 41 | "target": "ES2020", 42 | "lib": [ 43 | "dom", 44 | "ES2020", 45 | "dom.iterable", 46 | "webworker" 47 | ], 48 | // Miscellaneous 49 | "newLine": "lf", 50 | "useDefineForClassFields": true 51 | }, 52 | "exclude": [ 53 | "node_modules", 54 | "**/dist/**", 55 | "**/lib/**", 56 | ] 57 | } -------------------------------------------------------------------------------- /vitest-global-setup.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangle-io/banger-editor/d126a88f741f554b9ef49849655b0189b4d87c86/vitest-global-setup.js -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig((_env) => { 4 | return { 5 | test: { 6 | globals: true, 7 | setupFiles: 'vitest-global-setup.js', 8 | include: ['**/*.{vitest,spec}.?(c|m)[jt]s?(x)'], 9 | clearMocks: true, 10 | restoreMocks: true, 11 | }, 12 | define: {}, 13 | }; 14 | }); 15 | --------------------------------------------------------------------------------