├── .nvmrc ├── .gitignore ├── src ├── ts │ ├── modules.d.ts │ ├── invisibles │ │ ├── space.ts │ │ ├── nbSpace.ts │ │ ├── soft-hyphen.ts │ │ ├── hard-break.ts │ │ ├── heading.ts │ │ ├── paragraph.ts │ │ ├── character.ts │ │ └── node.ts │ ├── utils │ │ ├── text-between.ts │ │ ├── get-inserted-ranges.ts │ │ ├── invisible.ts │ │ └── create-deco.ts │ ├── __tests__ │ │ ├── helpers.ts │ │ ├── plugin.spec.ts │ │ └── state.spec.ts │ ├── state.ts │ └── index.ts └── css │ └── invisibles.css ├── CHANGELOG.md ├── .eslintrc.json ├── tsconfig.json ├── .changeset ├── config.json └── README.md ├── vite.config.pages.ts ├── vite.config.ts ├── LICENSE.md ├── .github └── workflows │ └── ci.yaml ├── pages ├── index.ts └── dist │ ├── index.html │ └── style.css ├── index.html ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.18.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | /dist/ 4 | *.log 5 | .vs 6 | .idea/ -------------------------------------------------------------------------------- /src/ts/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "prosemirror-example-setup"; 2 | declare module "prosemirror-schema-list"; 3 | declare module "prosemirror-schema-basic"; 4 | -------------------------------------------------------------------------------- /src/ts/invisibles/space.ts: -------------------------------------------------------------------------------- 1 | import { createInvisibleDecosForCharacter } from "./character"; 2 | 3 | const isSpace = (char: string) => char === " "; 4 | export default createInvisibleDecosForCharacter("space", isSpace); 5 | -------------------------------------------------------------------------------- /src/ts/invisibles/nbSpace.ts: -------------------------------------------------------------------------------- 1 | import { createInvisibleDecosForCharacter } from "./character"; 2 | 3 | const isNbSpace = (char: string) => char === " "; 4 | export default createInvisibleDecosForCharacter("nb-space", isNbSpace); 5 | -------------------------------------------------------------------------------- /src/ts/invisibles/soft-hyphen.ts: -------------------------------------------------------------------------------- 1 | import { createInvisibleDecosForCharacter } from "./character"; 2 | 3 | const isSoftHyphen = (char: string) => char === "\u00ad"; 4 | export default createInvisibleDecosForCharacter("soft-hyphen", isSoftHyphen); 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @guardian/prosemirror-invisibles 2 | 3 | ## 3.1.1 4 | 5 | ### Patch Changes 6 | 7 | - ed1f8f0: Change package access to public 8 | 9 | ## 3.1.0 10 | 11 | ### Minor Changes 12 | 13 | - 7fcb4a8: Add support for displaying soft hyphens 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | "prettier/@typescript-eslint" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/ts"], 3 | "compilerOptions": { 4 | "lib": ["dom", "esnext"], 5 | "target": "es5", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/ts/invisibles/hard-break.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { createInvisibleDecosForNode } from "./node"; 3 | 4 | const isHardbreak = (node: Node): boolean => 5 | node.type === node.type.schema.nodes.hard_break; 6 | export default createInvisibleDecosForNode( 7 | "break", 8 | (_, pos) => pos, 9 | isHardbreak, 10 | ); 11 | -------------------------------------------------------------------------------- /src/ts/invisibles/heading.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { createInvisibleDecosForNode } from "./node"; 3 | 4 | const isHeading = (node: Node): boolean => 5 | node.type === node.type.schema.nodes.heading; 6 | export default createInvisibleDecosForNode( 7 | "heading", 8 | (node, pos) => pos + node.nodeSize - 1, 9 | isHeading, 10 | ); 11 | -------------------------------------------------------------------------------- /src/ts/invisibles/paragraph.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { createInvisibleDecosForNode } from "./node"; 3 | 4 | const isParagraph = (node: Node): boolean => 5 | node.type === node.type.schema.nodes.paragraph; 6 | export default createInvisibleDecosForNode( 7 | "par", 8 | (node, pos) => pos + node.nodeSize - 1, 9 | isParagraph, 10 | ); 11 | -------------------------------------------------------------------------------- /vite.config.pages.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import packageJson from "./package.json"; 3 | import react from "@vitejs/plugin-react"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | build: { 9 | outDir: "pages/dist", 10 | emptyOutDir: false, 11 | lib: { 12 | name: "prosemirror-invisibles", 13 | entry: "pages/index.ts", 14 | formats: ["es"], 15 | fileName: "index", 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/ts/utils/text-between.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | 3 | interface Position { 4 | pos: number; 5 | text: string; 6 | } 7 | 8 | export default (from: number, to: number, doc: Node): Position[] => { 9 | const positions: Position[] = []; 10 | doc.nodesBetween(from, to, (node, pos) => { 11 | if (node.isText) { 12 | const offset = Math.max(from, pos) - pos; 13 | positions.push({ 14 | pos: pos + offset, 15 | text: node.text?.slice(offset, to - pos) || "", 16 | }); 17 | } 18 | }); 19 | return positions; 20 | }; 21 | -------------------------------------------------------------------------------- /src/ts/utils/get-inserted-ranges.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "prosemirror-state"; 2 | 3 | export type Range = [from: number, to: number]; 4 | 5 | export default ({ mapping }: Transaction): Range[] => { 6 | // We must invalidate the ranges touched by old and new selections. 7 | const ranges: Range[] = []; 8 | 9 | mapping.maps.forEach((stepMap, i) => { 10 | stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => { 11 | ranges.push([ 12 | mapping.slice(i + 1).map(newStart), 13 | mapping.slice(i + 1).map(newEnd), 14 | ]); 15 | }); 16 | }); 17 | 18 | return ranges; 19 | }; 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import packageJson from "./package.json"; 3 | import react from "@vitejs/plugin-react"; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | server: { 9 | port: 5001, 10 | }, 11 | build: { 12 | outDir: "dist/", 13 | lib: { 14 | entry: "src/ts/index.ts", 15 | formats: ["cjs", "es"], 16 | fileName: "index", 17 | }, 18 | rollupOptions: { 19 | // We do not bundle any peer dependencies specified by node_modules – they 20 | // should be bundled by the application using this module. 21 | external: Object.keys(packageJson.peerDependencies) 22 | }, 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /src/ts/utils/invisible.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { Selection } from "prosemirror-state"; 3 | import { DecorationSet } from "prosemirror-view"; 4 | 5 | export const BuilderTypes = { 6 | NODE: "NODE", 7 | CHAR: "CHAR", 8 | } as const; 9 | 10 | type BuilderTypes = keyof typeof BuilderTypes; 11 | 12 | /** 13 | * Append a set of decorations for an invisible character to the given DecorationSet. 14 | */ 15 | type AddDecorationsForInvisible = { 16 | type: BuilderTypes; 17 | createDecorations: ( 18 | from: number, 19 | to: number, 20 | doc: Node, 21 | decos: DecorationSet, 22 | selection?: Selection, 23 | displayLineEndSelection?: boolean, 24 | ) => DecorationSet; 25 | }; 26 | 27 | export default AddDecorationsForInvisible; 28 | -------------------------------------------------------------------------------- /src/ts/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createInvisiblesPlugin, 3 | space, 4 | nbSpace, 5 | paragraph, 6 | hardBreak, 7 | softHyphen, 8 | } from "../index"; 9 | import { doc, schema } from "prosemirror-test-builder"; 10 | import { EditorState } from "prosemirror-state"; 11 | import { EditorView } from "prosemirror-view"; 12 | 13 | export const createEditor = ( 14 | docNode = doc(), 15 | shouldShowInvisibles = true, 16 | displayLineEndSelection = true, 17 | ): EditorView => { 18 | const contentElement = document.createElement("content"); 19 | 20 | return new EditorView(contentElement, { 21 | state: EditorState.create({ 22 | doc: docNode, 23 | schema, 24 | plugins: [ 25 | createInvisiblesPlugin( 26 | [hardBreak, paragraph, space, nbSpace, softHyphen], 27 | { 28 | shouldShowInvisibles: shouldShowInvisibles, 29 | displayLineEndSelection, 30 | }, 31 | ), 32 | ], 33 | }), 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/ts/invisibles/character.ts: -------------------------------------------------------------------------------- 1 | import textBetween from "../utils/text-between"; 2 | import createDeco from "../utils/create-deco"; 3 | import AddDecorationsForInvisible, { BuilderTypes } from "../utils/invisible"; 4 | 5 | export const createInvisibleDecosForCharacter = ( 6 | type: string, 7 | predicate: (text: string) => boolean, 8 | ): AddDecorationsForInvisible => ({ 9 | type: BuilderTypes.CHAR, 10 | createDecorations: (from, to, doc, decos) => 11 | textBetween(from, to, doc).reduce( 12 | (decos1, { pos, text }) => 13 | text 14 | .split("") 15 | .reduce( 16 | (decos2, char, i) => 17 | predicate(char) 18 | ? decos2 19 | .remove( 20 | decos.find(pos + i, pos + i, (_) => _.type === type), 21 | ) 22 | .add(doc, [createDeco(pos + i, type)]) 23 | : decos2, 24 | decos1, 25 | ), 26 | decos, 27 | ), 28 | }); 29 | -------------------------------------------------------------------------------- /src/ts/utils/create-deco.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from "prosemirror-view"; 2 | 3 | /** 4 | * Create a decoration for an invisible char of the given type. 5 | */ 6 | export default ( 7 | pos: number, 8 | type: string, 9 | // Mark the decoration as selected. 10 | markAsSelected = false, 11 | ): Decoration => { 12 | const createElement = () => { 13 | const span = document.createElement("span"); 14 | span.classList.add("invisible"); 15 | span.classList.add(`invisible--${type}`); 16 | if (markAsSelected) { 17 | span.classList.add("invisible--is-selected"); 18 | const selectedMarker = document.createElement("span"); 19 | selectedMarker.classList.add("invisible__selected-marker"); 20 | span.appendChild(selectedMarker); 21 | } 22 | return span; 23 | }; 24 | 25 | return Decoration.widget(pos, createElement, { 26 | marks: [], 27 | key: `${type}${markAsSelected ? "-selected" : ""}`, 28 | type, 29 | side: 1000, // always render last 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 The Guardian 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # Find full documentation here https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | 7 | # Manual invocation. 8 | workflow_dispatch: 9 | 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | CI: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version-file: '.nvmrc' 22 | - run: yarn install --frozen-lockfile 23 | - run: yarn test 24 | 25 | release: 26 | runs-on: ubuntu-latest 27 | needs: [CI] 28 | permissions: 29 | contents: write 30 | issues: write 31 | pull-requests: write 32 | if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' 33 | steps: 34 | - uses: actions/checkout@v3 35 | 36 | - name: Use Node.js ${{ steps.nvm.outputs.NODE_VERSION }} 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version-file: .nvmrc 40 | cache: "yarn" 41 | 42 | - name: Install dependencies 43 | run: yarn install --frozen-lockfile 44 | 45 | - name: Build 46 | run: yarn build 47 | 48 | - name: Create Release Pull Request or Publish to npm 49 | id: changesets 50 | uses: changesets/action@v1 51 | with: 52 | publish: yarn changeset publish 53 | title: "🦋 Release package updates" 54 | commit: "Bump package versions" 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /src/ts/invisibles/node.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import createDeco from "../utils/create-deco"; 3 | import AddDecorationsForInvisible, { BuilderTypes } from "../utils/invisible"; 4 | 5 | export const createInvisibleDecosForNode = ( 6 | type: string, 7 | toPosition: (node: Node, pos: number) => number, 8 | predicate: (node: Node) => boolean, 9 | ): AddDecorationsForInvisible => ({ 10 | type: BuilderTypes.NODE, 11 | createDecorations: ( 12 | from, 13 | to, 14 | doc, 15 | decos, 16 | selection, 17 | shouldMarkAsSelected, 18 | ) => { 19 | let newDecos = decos; 20 | 21 | doc.nodesBetween(from, to, (node, pos) => { 22 | if (predicate(node)) { 23 | const decoPos = toPosition(node, pos); 24 | const oldDecos = newDecos.find( 25 | pos, 26 | pos + node.nodeSize - 1, 27 | (deco) => deco.type === type, 28 | ); 29 | 30 | // When we render invisibles that appear at the end of lines, mark 31 | // them as selected where appropriate. 32 | const selectionIsCollapsed = selection?.from === selection?.to; 33 | const isWithinCurrentSelection = 34 | selection && decoPos >= selection.from && decoPos <= selection.to; 35 | const selectionIsLongerThanNode = 36 | !!isWithinCurrentSelection && selection.to >= pos + node.nodeSize; 37 | const markAsSelected = 38 | shouldMarkAsSelected && 39 | selectionIsLongerThanNode && 40 | !selectionIsCollapsed; 41 | 42 | newDecos = newDecos 43 | .remove(oldDecos) 44 | .add(doc, [createDeco(decoPos, type, markAsSelected)]); 45 | 46 | return false; 47 | } 48 | }); 49 | return newDecos; 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /pages/index.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | import { EditorView } from "prosemirror-view"; 3 | import { Schema, DOMParser } from "prosemirror-model"; 4 | import { schema } from "prosemirror-schema-basic"; 5 | import { addListNodes } from "prosemirror-schema-list"; 6 | import { exampleSetup } from "prosemirror-example-setup"; 7 | import applyDevTools from "prosemirror-dev-tools"; 8 | import { 9 | createInvisiblesPlugin, 10 | softHyphen, 11 | hardBreak, 12 | paragraph, 13 | space, 14 | nbSpace, 15 | commands, 16 | heading, 17 | } from "../src/ts"; 18 | 19 | import "prosemirror-view/style/prosemirror.css"; 20 | import "prosemirror-menu/style/menu.css"; 21 | import "prosemirror-example-setup/style/style.css"; 22 | import "../src/css/invisibles.css"; 23 | 24 | const mySchema = new Schema({ 25 | nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"), 26 | marks: schema.spec.marks, 27 | }); 28 | 29 | const view = new EditorView(document.querySelector("#editor") as Element, { 30 | state: EditorState.create({ 31 | doc: DOMParser.fromSchema(mySchema).parse( 32 | document.querySelector("#content") as Element 33 | ), 34 | plugins: [ 35 | ...exampleSetup({ schema: mySchema }), 36 | createInvisiblesPlugin([hardBreak, paragraph, space, nbSpace, heading, softHyphen], { 37 | displayLineEndSelection: true, 38 | }), 39 | ], 40 | }), 41 | }); 42 | 43 | const toggle = document.getElementById("show-invisibles"); 44 | toggle && 45 | toggle.addEventListener("change", (event) => { 46 | const value = (event.currentTarget as HTMLInputElement).checked; 47 | commands.setActiveState(value)(view.state, view.dispatch); 48 | }); 49 | 50 | (window as any).process = {}; 51 | applyDevTools(view); 52 | -------------------------------------------------------------------------------- /src/css/invisibles.css: -------------------------------------------------------------------------------- 1 | .invisible { 2 | /* Chrome in particular dislikes doing the right thing 3 | * with carets and inline elements when contenteditable 4 | * is 'false'. See e.g. https://github.com/ProseMirror/prosemirror/issues/1061 5 | */ 6 | display: inline; 7 | position: relative; 8 | pointer-events: none; 9 | } 10 | 11 | .invisible:before { 12 | position: relative; 13 | caret-color: inherit; 14 | color: gray; 15 | display: inline-block; 16 | font-weight: 400; 17 | font-style: normal; 18 | line-height: initial; 19 | width: 0; 20 | top: 0; 21 | left: 0; 22 | z-index: 1; 23 | } 24 | 25 | .invisible__selected-marker { 26 | position: absolute; 27 | caret-color: inherit; 28 | background-color: #dcdcdc; 29 | display: inline-block; 30 | font-weight: 400; 31 | font-style: normal; 32 | line-height: initial; 33 | top: 0; 34 | left: 0; 35 | width: 10px; 36 | height: 100%; 37 | z-index: 0; 38 | } 39 | 40 | .ProseMirror-focused .invisible__selected-marker { 41 | background-color: #b4d9ff; 42 | } 43 | 44 | .ProseMirror-focused .invisible--is-selected::before { 45 | background-color: #b4d8ff; 46 | } 47 | 48 | .invisible--is-selected::before { 49 | background-color: #dcdcdc; 50 | } 51 | 52 | .invisible--soft-hyphen:before { 53 | content: "-"; 54 | } 55 | 56 | .invisible--space:before { 57 | content: "·"; 58 | } 59 | 60 | .invisible--break:before { 61 | content: "¬"; 62 | } 63 | 64 | .invisible--par:before { 65 | content: "¶"; 66 | } 67 | 68 | .invisible--heading:before { 69 | content: "¶"; 70 | } 71 | 72 | .invisible--nb-space { 73 | vertical-align: text-bottom; 74 | } 75 | 76 | .invisible--nb-space:before { 77 | font-size: 15px; 78 | content: "^"; 79 | position: absolute; 80 | top: 9px; 81 | left: -1px; 82 | } 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | prosemirror-invisibles 9 | 30 | 31 | 32 | 33 |

ProseMirror Invisibles

34 |

A plugin that uses decorations to show invisible characters in ProseMirror

35 | 36 | 37 |
38 | 39 |
40 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Cupiditate magni harum labore explicabo maxime laborum quod 41 | maiores iusto, aperiam, eaque voluptatibus ipsa repellat consequatur nulla iste hic ipsum nemo ullam.

42 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit.

Consequuntur ab suscipit assumenda harum nesciunt, 43 | quas nisi neque aut veritatis tempora perspiciatis animi atque reiciendis dolorem cumque iure laborum, obcaecati accusantium? 44 |

45 |

46 |

47 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Repudiandae veritatis perspiciatis, quidem nulla veniam iure 48 | dolorum illo ratione quod tenetur tempore, alias unde dignissimos molestias eos. Laborum expedita natus saepe.

49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /pages/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 30 | 31 | 32 | 33 |

ProseMirror Invisibles

34 |

A plugin that uses decorations to show invisible characters in ProseMirror

35 | 36 | 37 |
38 | 39 |
40 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Cupiditate magni harum labore explicabo maxime laborum quod 41 | maiores iusto, aperiam, eaque voluptatibus ipsa repellat consequatur nulla iste hic ipsum nemo ullam.

42 |

Lorem, ipsum dolor sit amet consectetur adipisicing elit.

Consequuntur ab suscipit assumenda harum nesciunt, 43 | quas nisi neque aut veritatis tempora perspiciatis animi atque reiciendis dolorem cumque iure laborum, obcaecati accusantium? 44 |

45 |

46 |

47 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Repudiandae veritatis perspiciatis, quidem nulla veniam iure 48 | dolorum illo ratione quod tenetur tempore, alias unde dignissimos molestias eos. Laborum expedita natus saepe.

49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@guardian/prosemirror-invisibles", 3 | "version": "3.1.1", 4 | "description": "A plugin to show invisibles in prosemirror", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.es.js", 7 | "types": "dist/index.d.ts", 8 | "license": "MIT", 9 | "author": { 10 | "name": "Richard Beddington", 11 | "email": "richard.beddington@guardian.co.uk" 12 | }, 13 | "scripts": { 14 | "watch": "run-p watch:*", 15 | "watch:serve": "vite", 16 | "watch:typecheck": "tsc --noEmit --watch", 17 | "build": "vite build && tsc --declaration --emitDeclarationOnly --outDir dist/", 18 | "test": "jest", 19 | "pages": "npm run build-pages && http-server ./pages/dist", 20 | "prepublishOnly": "yarn build", 21 | "format": "prettier --write './src/**/*.ts'", 22 | "build-pages": "vite build -c vite.config.pages.ts", 23 | "publish-pages": "git subtree push --prefix pages/dist origin gh-pages" 24 | }, 25 | "devDependencies": { 26 | "@changesets/cli": "^2.27.7", 27 | "@types/jest": "^29.2.3", 28 | "@types/prosemirror-dev-tools": "^3.0.2", 29 | "@typescript-eslint/eslint-plugin": "8.3.0", 30 | "@typescript-eslint/parser": "8.3.0", 31 | "@vitejs/plugin-react": "1.3.2", 32 | "eslint": "8.57.0", 33 | "eslint-config-prettier": "9.1.0", 34 | "eslint-plugin-import": "2.29.1", 35 | "eslint-plugin-prettier": "5.2.1", 36 | "http-server": "^14.1.1", 37 | "identity-obj-proxy": "^3.0.0", 38 | "jest": "29.7.0", 39 | "jest-environment-jsdom": "29.7.0", 40 | "postcss": "^8.4.16", 41 | "prettier": "3.3.3", 42 | "prosemirror-dev-tools": "^3.1.0", 43 | "prosemirror-example-setup": "1.2.1", 44 | "prosemirror-history": "1.3.0", 45 | "prosemirror-keymap": "1.2.0", 46 | "prosemirror-menu": "1.2.1", 47 | "prosemirror-schema-basic": "1.2.0", 48 | "prosemirror-test-builder": "^1.1.0", 49 | "react": "17", 50 | "react-dom": "17", 51 | "ts-jest": "^29.0.3", 52 | "typescript": "4.8.3", 53 | "vite": "5.4.2", 54 | "vite-tsconfig-paths": "^3.5.0" 55 | }, 56 | "jest": { 57 | "modulePaths": [ 58 | "/src/ts" 59 | ], 60 | "moduleNameMapper": { 61 | "\\.(css|less)$": "identity-obj-proxy" 62 | }, 63 | "testMatch": [ 64 | "**/?(*.)+(spec|test).[jt]s?(x)" 65 | ], 66 | "preset": "ts-jest", 67 | "testEnvironment": "jest-environment-jsdom" 68 | }, 69 | "peerDependencies": { 70 | "prosemirror-model": "^1.18.2", 71 | "prosemirror-state": "^1.4.2", 72 | "prosemirror-view": "^1.29.1" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-invisibles 2 | A simple implementation of invisible characters in [ProseMirror](https://prosemirror.net/). 3 | 4 | Demo [here.](https://guardian.github.io/prosemirror-invisibles/) 5 | 6 | --- 7 | 8 | ## Install 9 | `npm install @guardian/prosemirror-invisibles` 10 | 11 | ## Usage 12 | Add the plugin to the state and pass the invisibles you would like to display in the document. 13 | ```javascript 14 | import invisibles, { space, hardBreak, paragraph } from 'prosemirror-invisibles'; 15 | 16 | new EditorView(document.querySelector("#editor"), { 17 | state: EditorState.create({ 18 | doc: DOMParser.fromSchema(mySchema).parse( 19 | document.querySelector("#content") 20 | ), 21 | plugins: [ 22 | invisibles([space(), hardBreak(), paragraph()]) 23 | ] 24 | }) 25 | }); 26 | ``` 27 | 28 | And import the css from `prosemirror-invisibles/dist/invisibles.css`. 29 | 30 | ## A note on line-break invisibles and selections 31 | 32 | At the time of writing, Chrome, Firefox and Safari all provide limited feedback when rendering a selection that includes a line break – the selection and cursor does not change until content after the break is highlighted. Below, you can see there is a selection position that includes the linebreak which is not visible to users: 33 | 34 | ![No clear indication when we've selected across the paragraph boundary. Taken in Firefox](https://user-images.githubusercontent.com/7767575/196673262-27f8bd5f-ea43-4139-805e-59576d06aaab.gif) 35 | 36 | This is problematic for invisible characters that represent line breaks (paragraphs, soft returns, etc.) – we must include line break invisibles when they're part of a selection, or we risk misleading the user by implying that line breaks are not part of a selection when they are. 37 | 38 | Including content in the decorations representing invisible characters introduces problems with layout and cursor behaviour, so at present we fake this by adding an additional span within these decorations when they're part of a selection. We can then add a width to the span, and give it a background colour that's identical to the text selection. This kludge gives us the behaviour we expect: 39 | 40 | ![Selecting invisibles provides a clear indication when we've selected across the paragraph boundary. Taken in Firefox](https://user-images.githubusercontent.com/7767575/196673269-9f8794a6-91c0-432b-a4c6-ec54a9a12e40.gif) 41 | 42 | Adapting this styling may be necessary in your own editor to ensure that the impostor span's colour and size are correct. We recommend removing this styling if this behaviour isn't desirable. Finally, we'd love a better solution to this problem – any suggestions most welcome! 43 | 44 | ## Running locally 45 | 46 | To run the sandbox locally run: 47 | 48 | ```yarn watch``` 49 | -------------------------------------------------------------------------------- /src/ts/state.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, PluginKey, Transaction } from "prosemirror-state"; 2 | import { DecorationSet } from "prosemirror-view"; 3 | 4 | /** 5 | * State 6 | */ 7 | 8 | export interface PluginState { 9 | decorations: DecorationSet; 10 | shouldShowInvisibles: boolean; 11 | // Should we alter invisible decorations to emulate the selection of line end 12 | // characters? 13 | shouldShowLineEndSelectionDecorations: boolean; 14 | } 15 | 16 | export const pluginKey = new PluginKey( 17 | "PROSEMIRROR_INVISIBLES_PLUGIN", 18 | ); 19 | 20 | /** 21 | * Selectors 22 | */ 23 | export const selectActiveState = (state: EditorState): boolean => 24 | !!pluginKey.getState(state)?.shouldShowInvisibles; 25 | 26 | /** 27 | * Actions 28 | */ 29 | 30 | const PROSEMIRROR_INVISIBLES_ACTION = "PM_INVISIBLES_ACTION"; 31 | export const getActionFromTransaction = ( 32 | tr: Transaction, 33 | ): Actions | undefined => tr.getMeta(PROSEMIRROR_INVISIBLES_ACTION); 34 | 35 | const SET_SHOW_INVISIBLES_STATE = "SET_SHOW_INVISIBLES_STATE" as const; 36 | const SET_FOCUS_STATE = "BLUR_DOCUMENT" as const; 37 | 38 | const setShowInvisiblesStateAction = (shouldShowInvisibles: boolean) => ({ 39 | type: SET_SHOW_INVISIBLES_STATE, 40 | payload: { shouldShowInvisibles }, 41 | }); 42 | 43 | const setFocusedStateAction = (isFocused: boolean) => ({ 44 | type: SET_FOCUS_STATE, 45 | payload: { isFocused }, 46 | }); 47 | 48 | export type Actions = 49 | | ReturnType 50 | | ReturnType; 51 | 52 | /** 53 | * Reducer 54 | */ 55 | 56 | export const reducer = ( 57 | state: PluginState, 58 | action: Actions | undefined, 59 | ): PluginState => { 60 | if (!action) { 61 | return state; 62 | } 63 | switch (action.type) { 64 | case SET_SHOW_INVISIBLES_STATE: 65 | return { 66 | ...state, 67 | shouldShowInvisibles: action.payload.shouldShowInvisibles, 68 | }; 69 | case SET_FOCUS_STATE: { 70 | return { 71 | ...state, 72 | shouldShowLineEndSelectionDecorations: action.payload.isFocused, 73 | }; 74 | } 75 | default: 76 | return state; 77 | } 78 | }; 79 | 80 | /** 81 | * Commands 82 | */ 83 | 84 | type Command = ( 85 | state: EditorState, 86 | dispatch?: (tr: Transaction) => void, 87 | ) => boolean; 88 | 89 | const toggleActiveState = (): Command => (state, dispatch) => { 90 | dispatch && 91 | dispatch( 92 | state.tr.setMeta( 93 | PROSEMIRROR_INVISIBLES_ACTION, 94 | setShowInvisiblesStateAction( 95 | !pluginKey.getState(state)?.shouldShowInvisibles, 96 | ), 97 | ), 98 | ); 99 | return true; 100 | }; 101 | 102 | const setActiveState = 103 | (shouldShowInvisibles: boolean): Command => 104 | (state, dispatch) => { 105 | dispatch && 106 | dispatch( 107 | state.tr.setMeta( 108 | PROSEMIRROR_INVISIBLES_ACTION, 109 | setShowInvisiblesStateAction(shouldShowInvisibles), 110 | ), 111 | ); 112 | return true; 113 | }; 114 | 115 | const setFocusedState = 116 | (isFocused: boolean): Command => 117 | (state, dispatch) => { 118 | dispatch && 119 | dispatch( 120 | state.tr.setMeta( 121 | PROSEMIRROR_INVISIBLES_ACTION, 122 | setFocusedStateAction(isFocused), 123 | ), 124 | ); 125 | return true; 126 | }; 127 | 128 | export const commands = { setActiveState, toggleActiveState, setFocusedState }; 129 | -------------------------------------------------------------------------------- /pages/dist/style.css: -------------------------------------------------------------------------------- 1 | .ProseMirror{position:relative}.ProseMirror{word-wrap:break-word;white-space:pre-wrap;white-space:break-spaces;-webkit-font-variant-ligatures:none;font-variant-ligatures:none;font-feature-settings:"liga" 0}.ProseMirror pre{white-space:pre-wrap}.ProseMirror li{position:relative}.ProseMirror-hideselection *::selection{background:transparent}.ProseMirror-hideselection *::-moz-selection{background:transparent}.ProseMirror-hideselection{caret-color:transparent}.ProseMirror-selectednode{outline:2px solid #8cf}li.ProseMirror-selectednode{outline:none}li.ProseMirror-selectednode:after{content:"";position:absolute;left:-32px;right:-2px;top:-2px;bottom:-2px;border:2px solid #8cf;pointer-events:none}img.ProseMirror-separator{display:inline!important;border:none!important;margin:0!important}.ProseMirror-textblock-dropdown{min-width:3em}.ProseMirror-menu{margin:0 -4px;line-height:1}.ProseMirror-tooltip .ProseMirror-menu{width:-webkit-fit-content;width:fit-content;white-space:pre}.ProseMirror-menuitem{margin-right:3px;display:inline-block}.ProseMirror-menuseparator{border-right:1px solid #ddd;margin-right:3px}.ProseMirror-menu-dropdown,.ProseMirror-menu-dropdown-menu{font-size:90%;white-space:nowrap}.ProseMirror-menu-dropdown{vertical-align:1px;cursor:pointer;position:relative;padding-right:15px}.ProseMirror-menu-dropdown-wrap{padding:1px 0 1px 4px;display:inline-block;position:relative}.ProseMirror-menu-dropdown:after{content:"";border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid currentColor;opacity:.6;position:absolute;right:4px;top:calc(50% - 2px)}.ProseMirror-menu-dropdown-menu,.ProseMirror-menu-submenu{position:absolute;background:white;color:#666;border:1px solid #aaa;padding:2px}.ProseMirror-menu-dropdown-menu{z-index:15;min-width:6em}.ProseMirror-menu-dropdown-item{cursor:pointer;padding:2px 8px 2px 4px}.ProseMirror-menu-dropdown-item:hover{background:#f2f2f2}.ProseMirror-menu-submenu-wrap{position:relative;margin-right:-4px}.ProseMirror-menu-submenu-label:after{content:"";border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:4px solid currentColor;opacity:.6;position:absolute;right:4px;top:calc(50% - 4px)}.ProseMirror-menu-submenu{display:none;min-width:4em;left:100%;top:-3px}.ProseMirror-menu-active{background:#eee;border-radius:4px}.ProseMirror-menu-disabled{opacity:.3}.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu,.ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu{display:block}.ProseMirror-menubar{border-top-left-radius:inherit;border-top-right-radius:inherit;position:relative;min-height:1em;color:#666;padding:1px 6px;top:0;left:0;right:0;border-bottom:1px solid silver;background:white;z-index:10;-moz-box-sizing:border-box;box-sizing:border-box;overflow:visible}.ProseMirror-icon{display:inline-block;line-height:.8;vertical-align:-2px;padding:2px 8px;cursor:pointer}.ProseMirror-menu-disabled.ProseMirror-icon{cursor:default}.ProseMirror-icon svg{fill:currentColor;height:1em}.ProseMirror-icon span{vertical-align:text-top}.ProseMirror-example-setup-style hr{padding:2px 10px;border:none;margin:1em 0}.ProseMirror-example-setup-style hr:after{content:"";display:block;height:1px;background-color:silver;line-height:2px}.ProseMirror ul,.ProseMirror ol{padding-left:30px}.ProseMirror blockquote{padding-left:1em;border-left:3px solid #eee;margin-left:0;margin-right:0}.ProseMirror-example-setup-style img{cursor:default}.ProseMirror-prompt{background:white;padding:5px 10px 5px 15px;border:1px solid silver;position:fixed;border-radius:3px;z-index:11;box-shadow:-.5px 2px 5px #0003}.ProseMirror-prompt h5{margin:0;font-weight:400;font-size:100%;color:#444}.ProseMirror-prompt input[type=text],.ProseMirror-prompt textarea{background:#eee;border:none;outline:none}.ProseMirror-prompt input[type=text]{padding:0 4px}.ProseMirror-prompt-close{position:absolute;left:2px;top:1px;color:#666;border:none;background:transparent;padding:0}.ProseMirror-prompt-close:after{content:"\2715";font-size:12px}.ProseMirror-invalid{background:#ffc;border:1px solid #cc7;border-radius:4px;padding:5px 10px;position:absolute;min-width:10em}.ProseMirror-prompt-buttons{margin-top:5px;display:none}.invisible{pointer-events:none;user-select:none;display:inline-block}.invisible:before{caret-color:inherit;color:gray;display:inline-block;font-weight:400;font-style:normal;line-height:1em;width:0}.invisible--space:before{content:"\b7"}.invisible--break:before{content:"\ac"}.invisible--par:before{content:"\b6"} 2 | -------------------------------------------------------------------------------- /src/ts/__tests__/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { AllSelection, TextSelection } from "prosemirror-state"; 2 | import { br, doc, p } from "prosemirror-test-builder"; 3 | import { commands } from "../state"; 4 | 5 | import { createEditor } from "./helpers"; 6 | 7 | describe("Invisibles plugin", () => { 8 | it("should not render invisibles when the plugin is not active", () => { 9 | const view = createEditor(doc(p("1 2 3 4")), false); 10 | 11 | const elements = view.dom.querySelectorAll(".invisible--space"); 12 | 13 | expect(elements.length).toEqual(0); 14 | }); 15 | 16 | it("should render character invisibles", () => { 17 | const view = createEditor(doc(p("1 2 3 4"))); 18 | 19 | const elements = view.dom.querySelectorAll(".invisible--space"); 20 | 21 | expect(elements.length).toEqual(3); 22 | }); 23 | 24 | it("should render node invisibles", () => { 25 | const view = createEditor(doc(p("1"), p("2"))); 26 | 27 | const elements = view.dom.querySelectorAll(".invisible--par"); 28 | 29 | expect(elements.length).toEqual(2); 30 | }); 31 | 32 | it("should not double render invisibles when the selection changes", () => { 33 | const view = createEditor(doc(p("1 2 3 4"))); 34 | 35 | view.dispatch( 36 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 9)), 37 | ); 38 | 39 | const elements = view.dom.querySelectorAll(".invisible--space"); 40 | 41 | expect(elements.length).toEqual(3); 42 | }); 43 | 44 | it("should add node and character decorations when content is added", () => { 45 | const docNode = doc(p("1 2 3 4")); 46 | const view = createEditor(docNode); 47 | 48 | view.dispatch(view.state.tr.insert(docNode.nodeSize - 2, p("5, 6, 7, 8"))); 49 | 50 | const charElements = view.dom.querySelectorAll(".invisible--space"); 51 | expect(charElements.length).toEqual(6); 52 | 53 | const nodeElements = view.dom.querySelectorAll(".invisible--par"); 54 | expect(nodeElements.length).toEqual(2); 55 | }); 56 | 57 | describe("Selection emulation for line end invisibles", () => { 58 | it("should add content to node invisibles to help emulate selection when the selection includes the invisible and the next node", () => { 59 | const view = createEditor(doc(p("1 2 3 4"), p("5 6 7 8"))); 60 | view.dispatch( 61 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 13)), 62 | ); 63 | 64 | const elements = view.dom.querySelectorAll(".invisible--par"); 65 | const firstParaInvisible = elements.item(0); 66 | const secondParaInvisible = elements.item(1); 67 | 68 | expect(firstParaInvisible.children.length).toEqual(1); 69 | expect(secondParaInvisible.children.length).toEqual(0); 70 | }); 71 | 72 | it("should remove content from node to help emulate selection invisibles when they are no longer included in the selection", () => { 73 | const view = createEditor(doc(p("1 2 3 4"), p("5 6 7 8"))); 74 | view.dispatch( 75 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 13)), 76 | ); 77 | view.dispatch( 78 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 2)), 79 | ); 80 | 81 | const elements = view.dom.querySelectorAll(".invisible--par"); 82 | const firstParaInvisible = elements.item(0); 83 | const secondParaInvisible = elements.item(1); 84 | 85 | expect(firstParaInvisible.children.length).toEqual(0); 86 | expect(secondParaInvisible.children.length).toEqual(0); 87 | }); 88 | 89 | it("should continue to render decorations as selection passes over them", () => { 90 | const docNode = doc(p("1 2 3 4", br(), br(), p("5 6 7 8"))); 91 | const view = createEditor(docNode); 92 | 93 | // This collapsed selection shifts left each iteration, to 94 | // catch a bug where selections caused node decos to disappear. 95 | for (let curPos = docNode.nodeSize - 2; curPos > 0; curPos--) { 96 | view.dispatch( 97 | view.state.tr.setSelection( 98 | TextSelection.create(view.state.doc, curPos, curPos), 99 | ), 100 | ); 101 | 102 | const elements = view.dom.querySelectorAll(".invisible--break"); 103 | expect(elements.length).toBe(2); 104 | } 105 | }); 106 | 107 | it("should correctly handle selections which reduce the document size", () => { 108 | const docNode = doc(p("1 2 3 4", br(), br(), p("5 6 7 8"))); 109 | const view = createEditor(docNode); 110 | 111 | const removeDoc = () => 112 | view.dispatch( 113 | view.state.tr 114 | .setSelection(new AllSelection(view.state.doc)) 115 | .deleteSelection(), 116 | ); 117 | 118 | expect(removeDoc).not.toThrow(); 119 | }); 120 | 121 | it("should not render selection emulation decos when the document is blurred", () => { 122 | const view = createEditor(doc(p("1 2 3 4"), p("5 6 7 8"))); 123 | view.dispatch( 124 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 13)), 125 | ); 126 | 127 | commands.setFocusedState(false)(view.state, view.dispatch); 128 | 129 | const elements = view.dom.querySelectorAll(".invisible--par"); 130 | const firstParaInvisible = elements.item(0); 131 | const secondParaInvisible = elements.item(1); 132 | 133 | expect(firstParaInvisible.children.length).toEqual(0); 134 | expect(secondParaInvisible.children.length).toEqual(0); 135 | }); 136 | 137 | it("should render selection emulation decos when the document is refocused", () => { 138 | const view = createEditor(doc(p("1 2 3 4"), p("5 6 7 8"))); 139 | view.dispatch( 140 | view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 13)), 141 | ); 142 | 143 | commands.setFocusedState(false)(view.state, view.dispatch); 144 | commands.setFocusedState(true)(view.state, view.dispatch); 145 | 146 | const elements = view.dom.querySelectorAll(".invisible--par"); 147 | const firstParaInvisible = elements.item(0); 148 | const secondParaInvisible = elements.item(1); 149 | 150 | expect(firstParaInvisible.children.length).toEqual(1); 151 | expect(secondParaInvisible.children.length).toEqual(0); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/ts/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, AllSelection, EditorState } from "prosemirror-state"; 2 | import { DecorationSet, EditorView } from "prosemirror-view"; 3 | import AddDecorationsForInvisible from "./utils/invisible"; 4 | import getInsertedRanges, { Range } from "./utils/get-inserted-ranges"; 5 | import { 6 | commands, 7 | getActionFromTransaction, 8 | pluginKey, 9 | PluginState, 10 | reducer, 11 | } from "./state"; 12 | import "../css/invisibles.css"; 13 | 14 | interface InvisiblesOptions { 15 | shouldShowInvisibles?: boolean; 16 | // Add styling to emulate the selection of line end characters with CSS. 17 | displayLineEndSelection?: boolean; 18 | } 19 | 20 | /** 21 | * Create a plugin to render invisible characters. Accepts a list of 22 | * creator functions, examples of which are defined in './invisibles'. 23 | * 24 | * Example usage: ``` 25 | * import hardBreak from 'invisibles/hard-break'; 26 | * import paragraph from 'invisibles/paragraph'; 27 | * const plugin = createInvisiblesPlugin([hardBreak(), paragraph()]) 28 | * ``` 29 | */ 30 | const createInvisiblesPlugin = ( 31 | builders: AddDecorationsForInvisible[], 32 | { 33 | shouldShowInvisibles = true, 34 | displayLineEndSelection = false, 35 | }: InvisiblesOptions = {}, 36 | ): Plugin => 37 | new Plugin({ 38 | key: pluginKey, 39 | state: { 40 | init: (_, state) => { 41 | const { from, to } = new AllSelection(state.doc); 42 | const decorations = builders.reduce( 43 | (newDecos, { createDecorations }) => 44 | createDecorations( 45 | from, 46 | to, 47 | state.doc, 48 | newDecos, 49 | state.selection, 50 | displayLineEndSelection, 51 | ), 52 | DecorationSet.empty, 53 | ); 54 | 55 | return { 56 | shouldShowInvisibles: shouldShowInvisibles, 57 | shouldShowLineEndSelectionDecorations: true, 58 | decorations, 59 | } as PluginState; 60 | }, 61 | apply: (tr, pluginState, oldState, newState) => { 62 | const newPluginState = reducer( 63 | pluginState, 64 | getActionFromTransaction(tr), 65 | ); 66 | 67 | const documentBlurStateHasNotChanged = 68 | pluginState.shouldShowLineEndSelectionDecorations === 69 | newPluginState.shouldShowLineEndSelectionDecorations; 70 | const docAndSelectionHaveNotChanged = 71 | !tr.docChanged && oldState.selection === newState.selection; 72 | 73 | if (documentBlurStateHasNotChanged && docAndSelectionHaveNotChanged) { 74 | return newPluginState; 75 | } 76 | 77 | const insertedRanges = getInsertedRanges(tr); 78 | const selectedRanges: Range[] = [ 79 | [tr.selection.from, tr.selection.to], 80 | // We must include the old selection to ensure that any decorations that 81 | // are no longer selected are correctly amended. 82 | [ 83 | oldState.selection.from, 84 | // We are operating on selections based on the old document that may no 85 | // longer exist, so we cap the selection to the size of the document. 86 | Math.min(oldState.selection.to, tr.doc.nodeSize - 2), 87 | ], 88 | ]; 89 | const allRanges = insertedRanges.concat(selectedRanges); 90 | const shouldDisplayLineEndDecorations = 91 | displayLineEndSelection && 92 | newPluginState.shouldShowLineEndSelectionDecorations; 93 | 94 | const decorations = builders.reduce( 95 | (newDecos, { createDecorations, type }) => { 96 | const rangesToApply = type === "NODE" ? allRanges : insertedRanges; 97 | return rangesToApply.reduce( 98 | (nextDecos, [from, to]) => 99 | createDecorations( 100 | from, 101 | to, 102 | tr.doc, 103 | nextDecos, 104 | tr?.selection, 105 | shouldDisplayLineEndDecorations, 106 | ), 107 | newDecos, 108 | ); 109 | }, 110 | newPluginState.decorations.map(tr.mapping, newState.doc), 111 | ); 112 | 113 | return { ...newPluginState, decorations }; 114 | }, 115 | }, 116 | props: { 117 | decorations: function (state: EditorState) { 118 | const { shouldShowInvisibles, decorations } = 119 | this.getState(state) || {}; 120 | return shouldShowInvisibles ? decorations : DecorationSet.empty; 121 | }, 122 | handleDOMEvents: { 123 | blur: (view: EditorView, event: FocusEvent) => { 124 | // When we blur the editor but remain focused on the page, the DOM 125 | // will lose its selection but Prosemirror will not. This will cause 126 | // prosemirror-elements' selection emulation decorations to remain on 127 | // the page. We store state to manually turn off the selection 128 | // emulation in this case. 129 | const selectionFallsOutsideOfPage = 130 | document.activeElement === event.target; 131 | if (!selectionFallsOutsideOfPage) { 132 | commands.setFocusedState(false)(view.state, view.dispatch); 133 | } 134 | 135 | return false; 136 | }, 137 | focus: (view: EditorView) => { 138 | commands.setFocusedState(true)(view.state, view.dispatch); 139 | 140 | return false; 141 | }, 142 | }, 143 | }, 144 | }); 145 | 146 | export { createInvisiblesPlugin }; 147 | 148 | export { createInvisibleDecosForCharacter } from "./invisibles/character"; 149 | export { createInvisibleDecosForNode } from "./invisibles/node"; 150 | 151 | export { default as space } from "./invisibles/space"; 152 | export { default as hardBreak } from "./invisibles/hard-break"; 153 | export { default as softHyphen } from "./invisibles/soft-hyphen"; 154 | export { default as paragraph } from "./invisibles/paragraph"; 155 | export { default as nbSpace } from "./invisibles/nbSpace"; 156 | export { default as heading } from "./invisibles/heading"; 157 | 158 | export { default as createDeco } from "./utils/create-deco"; 159 | export { default as textBetween } from "./utils/text-between"; 160 | 161 | export { selectActiveState } from "./state"; 162 | export { commands } from "./state"; 163 | -------------------------------------------------------------------------------- /src/ts/__tests__/state.spec.ts: -------------------------------------------------------------------------------- 1 | import { createInvisiblesPlugin } from "../"; 2 | import space from "../invisibles/space"; 3 | import { addListNodes } from "prosemirror-schema-list"; 4 | import { schema } from "prosemirror-schema-basic"; 5 | import { Schema, DOMParser } from "prosemirror-model"; 6 | import { EditorState } from "prosemirror-state"; 7 | import { DecorationSet, EditorView } from "prosemirror-view"; 8 | import hardBreak from "../invisibles/hard-break"; 9 | import paragraph from "../invisibles/paragraph"; 10 | import nbSpace from "../invisibles/nbSpace"; 11 | import heading from "../invisibles/heading"; 12 | import { commands, pluginKey } from "../state"; 13 | import AddDecorationsForInvisible from "../utils/invisible"; 14 | 15 | const testSchema = new Schema({ 16 | nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"), 17 | marks: schema.spec.marks, 18 | }); 19 | 20 | const createEditor = ( 21 | invisibleType: AddDecorationsForInvisible[], 22 | htmlDoc: string, 23 | shouldShowInvisibles: boolean, 24 | ) => { 25 | const contentElement = document.createElement("content"); 26 | contentElement.innerHTML = htmlDoc; 27 | return new EditorView(contentElement, { 28 | state: EditorState.create({ 29 | doc: DOMParser.fromSchema(testSchema).parse(contentElement), 30 | plugins: [ 31 | createInvisiblesPlugin(invisibleType, { 32 | shouldShowInvisibles: shouldShowInvisibles, 33 | }), 34 | ], 35 | }), 36 | }); 37 | }; 38 | 39 | const getDocDecorations = (editor: EditorView) => 40 | (editor.someProp("decorations")?.(editor.state) as DecorationSet).find(); 41 | 42 | describe("State management", () => { 43 | describe("Active state", () => { 44 | it("should set the active state initially", () => { 45 | const editor = createEditor([space], "

Example doc

", false); 46 | 47 | const pluginState = pluginKey.getState(editor.state); 48 | expect(pluginState?.shouldShowInvisibles).toBe(false); 49 | }); 50 | it("should provide a command to set the active state of the plugin", () => { 51 | const editor = createEditor([space], "

Example doc

", true); 52 | commands.setActiveState(false)(editor.state, editor.dispatch); 53 | 54 | const pluginState = pluginKey.getState(editor.state); 55 | expect(pluginState?.shouldShowInvisibles).toBe(false); 56 | }); 57 | it("should show decorations when the active state is true", () => { 58 | const editor = createEditor( 59 | [space], 60 | "

Example doc with four spaces

", 61 | true, 62 | ); 63 | 64 | const currentDecorations = getDocDecorations(editor); 65 | expect(currentDecorations?.length).toBe(4); 66 | }); 67 | it("should hide decorations when the active state is false", () => { 68 | const editor = createEditor( 69 | [space], 70 | "

Example doc with four spaces

", 71 | false, 72 | ); 73 | 74 | const currentDecorations = getDocDecorations(editor); 75 | expect(currentDecorations?.length).toBe(0); 76 | }); 77 | it("should show decorations when the active state is true, with 2 hard breaks", () => { 78 | const editor = createEditor( 79 | [hardBreak], 80 | "

Example doc with four spaces
Some more text

", 81 | true, 82 | ); 83 | 84 | const currentDecorations = getDocDecorations(editor); 85 | expect(currentDecorations?.length).toBe(2); 86 | }); 87 | it("should hide decorations when the active state is false, with 2 hard breaks", () => { 88 | const editor = createEditor( 89 | [hardBreak], 90 | "

Example doc with four spaces
Some more text

", 91 | false, 92 | ); 93 | 94 | const currentDecorations = getDocDecorations(editor); 95 | expect(currentDecorations?.length).toBe(0); 96 | }); 97 | it("should show decorations when the active state is true, with 2 paragraphs", () => { 98 | const editor = createEditor( 99 | [paragraph], 100 | "

Example doc with two paragraphs

Some more test text

", 101 | true, 102 | ); 103 | 104 | const currentDecorations = getDocDecorations(editor); 105 | expect(currentDecorations?.length).toBe(2); 106 | }); 107 | it("should hide decorations when the active state is false, with 2 paragraphs", () => { 108 | const editor = createEditor( 109 | [paragraph], 110 | "

Example doc with two paragraphs

Some more test text

", 111 | false, 112 | ); 113 | 114 | const currentDecorations = getDocDecorations(editor); 115 | expect(currentDecorations?.length).toBe(0); 116 | }); 117 | it("should hide decorations when the active state is true, with 2 headings", () => { 118 | const editor = createEditor( 119 | [heading], 120 | "

Example

Heading

with another

heading

Some more text

", 121 | true, 122 | ); 123 | 124 | const currentDecorations = getDocDecorations(editor); 125 | expect(currentDecorations?.length).toBe(2); 126 | }); 127 | it("should hide decorations when the active state is true, with 2 headings", () => { 128 | const editor = createEditor( 129 | [heading], 130 | "

Example

Heading

with another

heading

Some more text

", 131 | false, 132 | ); 133 | 134 | const currentDecorations = getDocDecorations(editor); 135 | expect(currentDecorations?.length).toBe(0); 136 | }); 137 | it("should hide decorations when the active state is true, with 2 nbspace", () => { 138 | const editor = createEditor( 139 | [nbSpace], 140 | "

Example

Heading

with   another  space

heading

Some more text

", 141 | true, 142 | ); 143 | 144 | const currentDecorations = getDocDecorations(editor); 145 | expect(currentDecorations?.length).toBe(2); 146 | }); 147 | it("should hide decorations when the active state is false, with 2 nbspace", () => { 148 | const editor = createEditor( 149 | [nbSpace], 150 | "

Example

Heading

with   another  space

heading

Some more text

", 151 | false, 152 | ); 153 | 154 | const currentDecorations = getDocDecorations(editor); 155 | expect(currentDecorations?.length).toBe(0); 156 | }); 157 | }); 158 | }); 159 | --------------------------------------------------------------------------------