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.
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 | 
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 | 
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 | "