├── .gitignore ├── src ├── prosemirror.publish.module.ts ├── bindingHandlers.textblock.ts ├── prosemirror.design.module.ts ├── modelConverter.ts ├── prosemirrorRenderer.ts ├── keymap.ts ├── prosemirrorSchemaBuilder.ts ├── lists.ts └── prosemirrorHtmlEditor.ts ├── tsconfig.json ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vscode/ -------------------------------------------------------------------------------- /src/prosemirror.publish.module.ts: -------------------------------------------------------------------------------- 1 | import { IInjector, IInjectorModule } from "@paperbits/common/injection"; 2 | import "./bindingHandlers.textblock"; 3 | 4 | export class ProseMirrorPublishModule implements IInjectorModule { 5 | public register(injector: IInjector): void { } 6 | } -------------------------------------------------------------------------------- /src/bindingHandlers.textblock.ts: -------------------------------------------------------------------------------- 1 | import * as ko from "knockout"; 2 | import { BlockModel } from "@paperbits/common/text/models"; 3 | import { ProseMirrorRenderer } from "./prosemirrorRenderer"; 4 | 5 | 6 | const renderer = new ProseMirrorRenderer(); 7 | 8 | ko.bindingHandlers["textblock"] = { 9 | init(element: HTMLElement, valueAccessor: () => BlockModel[]): void { 10 | const blockModels = valueAccessor(); 11 | renderer.renderBlock(element, ko.unwrap(blockModels)) 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/prosemirror.design.module.ts: -------------------------------------------------------------------------------- 1 | import { IInjector, IInjectorModule } from "@paperbits/common/injection"; 2 | import { ProseMirrorHtmlEditor } from "./prosemirrorHtmlEditor"; 3 | 4 | 5 | 6 | export class ProseMirrorDesignModule implements IInjectorModule { 7 | public register(injector: IInjector): void { 8 | injector.bind("htmlEditor", ProseMirrorHtmlEditor); 9 | 10 | const factory = function () { 11 | return { 12 | createHtmlEditor: () => { 13 | return injector.resolve("htmlEditor"); 14 | } 15 | }; 16 | }; 17 | 18 | injector.bind("htmlEditorFactory", factory); 19 | } 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "es2018" 7 | ], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "declaration": false, 11 | "noImplicitAny": false, 12 | "removeComments": true, 13 | "noLib": false, 14 | "skipLibCheck": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "allowUnreachableCode": true, 18 | "listFiles": false, 19 | "sourceMap": false, 20 | "allowSyntheticDefaultImports": true 21 | }, 22 | "exclude": [ 23 | "./node_modules" 24 | ] 25 | } -------------------------------------------------------------------------------- /src/modelConverter.ts: -------------------------------------------------------------------------------- 1 | import { BlockModel } from "@paperbits/common/text/models"; 2 | 3 | export class ModelConverter { 4 | public static modelToProseMirrorModel(source: BlockModel[]): any { 5 | let result = JSON.stringify(source); 6 | 7 | result = result 8 | .replaceAll(`ordered-list`, `ordered_list`) 9 | .replaceAll(`bulleted-list`, `bulleted_list`) 10 | .replaceAll(`list-item`, `list_item`) 11 | .replaceAll(`"nodes":`, `"content":`); 12 | 13 | return JSON.parse(result); 14 | } 15 | 16 | public static proseMirrorModelToModel(source: any): BlockModel[] { 17 | let result = JSON.stringify(source); 18 | 19 | result = result 20 | .replaceAll(`ordered_list`, `ordered-list`) 21 | .replaceAll(`bulleted_list`, `bulleted-list`) 22 | .replaceAll(`list_item`, `list-item`) 23 | .replaceAll(`"content":`, `"nodes":`); 24 | 25 | return JSON.parse(result); 26 | } 27 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@paperbits/prosemirror", 3 | "version": "0.1.661", 4 | "description": "Paperbits HTML editor based on ProseMirror.", 5 | "author": "Paperbits", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/paperbits/paperbits-prosemirror.git" 10 | }, 11 | "keywords": [ 12 | "paperbits", 13 | "prosemirror" 14 | ], 15 | "bugs": { 16 | "url": "https://github.com/paperbits/paperbits-prosemirror/issues" 17 | }, 18 | "dependencies": { 19 | "@paperbits/common": "0.1.661", 20 | "prosemirror-commands": "^1.5.2", 21 | "prosemirror-history": "^1.3.2", 22 | "prosemirror-inputrules": "^1.2.1", 23 | "prosemirror-keymap": "^1.2.2", 24 | "prosemirror-model": "^1.19.1", 25 | "prosemirror-schema-list": "^1.2.3", 26 | "prosemirror-state": "^1.4.3", 27 | "prosemirror-transform": "^1.7.2", 28 | "prosemirror-view": "^1.31.3" 29 | }, 30 | "devDependencies": {} 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Paperbits. 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /src/prosemirrorRenderer.ts: -------------------------------------------------------------------------------- 1 | import { BlockModel } from "@paperbits/common/text/models"; 2 | import { ProsemirrorSchemaBuilder } from "./prosemirrorSchemaBuilder"; 3 | import { DOMSerializer, Schema } from "prosemirror-model"; 4 | import { ModelConverter } from "./modelConverter"; 5 | 6 | 7 | export class ProseMirrorRenderer { 8 | private readonly schema: Schema; 9 | private readonly serializer: DOMSerializer; 10 | 11 | constructor() { 12 | const builder = new ProsemirrorSchemaBuilder(); 13 | this.schema = builder.build(); 14 | this.serializer = DOMSerializer.fromSchema(this.schema); 15 | } 16 | 17 | public renderBlock(element: HTMLElement, blockContent: BlockModel[]): void { 18 | try { 19 | const prosemirrorContent = ModelConverter.modelToProseMirrorModel(blockContent); 20 | 21 | const content: any = { 22 | type: "doc", 23 | content: prosemirrorContent 24 | }; 25 | 26 | const node: any = this.schema.nodeFromJSON(content); 27 | const fragment = this.serializer.serializeFragment(node); 28 | 29 | element.appendChild(fragment); 30 | } 31 | catch (error) { 32 | console.error(error.stack); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/keymap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | wrapIn, setBlockType, chainCommands, toggleMark, exitCode, 3 | joinUp, joinDown, lift, selectParentNode 4 | } from "prosemirror-commands"; 5 | import { wrapInList, splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; 6 | import { undo, redo } from "prosemirror-history"; 7 | import { undoInputRule } from "prosemirror-inputrules"; 8 | 9 | const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; 10 | 11 | // :: (Schema, ?Object) → Object 12 | // inspect the given schema looking for marks and nodes from the 13 | // basic schema, and if found, add key bindings related to them. 14 | // this will add: 15 | // 16 | // * **Mod-b** for toggling [strong](#schema-basic.StrongMark) 17 | // * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) 18 | // * **Mod-`** for toggling [code font](#schema-basic.CodeMark) 19 | // * **Ctrl-Shift-0** for making the current textblock a paragraph 20 | // * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current 21 | // textblock a heading of the corresponding level 22 | // * **Ctrl-Shift-Backslash** to make the current textblock a code block 23 | // * **Ctrl-Shift-8** to wrap the selection in an ordered list 24 | // * **Ctrl-Shift-9** to wrap the selection in a bullet list 25 | // * **Ctrl->** to wrap the selection in a block quote 26 | // * **Enter** to split a non-empty textblock in a list item while at 27 | // the same time splitting the list item 28 | // * **Mod-Enter** to insert a hard break 29 | // * **Mod-_** to insert a horizontal rule 30 | // * **Backspace** to undo an input rule 31 | // * **Alt-ArrowUp** to `joinUp` 32 | // * **Alt-ArrowDown** to `joinDown` 33 | // * **Mod-BracketLeft** to `lift` 34 | // * **Escape** to `selectParentNode` 35 | // 36 | // you can suppress or map these bindings by passing a `mapKeys` 37 | // argument, which maps key names (say `"Mod-B"` to either `false`, to 38 | // remove the binding, or a new key name string. 39 | export function buildKeymap(schema, mapKeys) { 40 | let keys = {}, type; 41 | function bind(key, cmd) { 42 | if (mapKeys) { 43 | const mapped = mapKeys[key]; 44 | if (mapped === false) { return; } 45 | if (mapped) { key = mapped; } 46 | } 47 | keys[key] = cmd; 48 | } 49 | 50 | 51 | bind("Mod-z", undo); 52 | bind("Shift-Mod-z", redo); 53 | bind("Backspace", undoInputRule); 54 | if (!mac) { bind("Mod-y", redo); } 55 | 56 | bind("Alt-ArrowUp", joinUp); 57 | bind("Alt-ArrowDown", joinDown); 58 | bind("Mod-BracketLeft", lift); 59 | bind("Escape", selectParentNode); 60 | 61 | if (type = schema.marks.bold) { 62 | bind("Mod-b", toggleMark(type)); 63 | } 64 | 65 | if (type = schema.marks.italic) { 66 | bind("Mod-i", toggleMark(type)); 67 | } 68 | 69 | if (type = schema.marks.code) { 70 | bind("Mod-`", toggleMark(type)); 71 | } 72 | 73 | if (type = schema.nodes.bullet_list) { 74 | bind("Shift-Ctrl-8", wrapInList(type)); 75 | } 76 | 77 | if (type = schema.nodes.ordered_list) { 78 | bind("Shift-Ctrl-9", wrapInList(type)); 79 | } 80 | 81 | if (type = schema.nodes.blockquote) { 82 | bind("Ctrl->", wrapIn(type)); 83 | } 84 | 85 | if (type = schema.nodes.break) { 86 | const br = type, cmd = chainCommands(exitCode, (state, dispatch) => { 87 | const $anchor = state.selection.$anchor; 88 | 89 | if (!$anchor) { 90 | return; 91 | } 92 | 93 | if ($anchor.nodeBefore && $anchor.nodeBefore.type.name === "break") { 94 | dispatch(state.tr.delete($anchor.pos - 1, $anchor.pos) 95 | .replaceSelectionWith(schema.nodes.paragraph.create()).scrollIntoView()); 96 | } 97 | else { 98 | dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()); 99 | } 100 | 101 | return true; 102 | }); 103 | bind("Mod-Enter", cmd); 104 | bind("Shift-Enter", cmd); 105 | bind("Ctrl-Enter", cmd); 106 | } 107 | 108 | if (type = schema.nodes.list_item) { 109 | bind("Enter", splitListItem(type)); 110 | bind("Mod-[", liftListItem(type)); 111 | bind("Mod-]", sinkListItem(type)); 112 | } 113 | 114 | if (type = schema.nodes.paragraph) { 115 | bind("Shift-Ctrl-0", setBlockType(type)); 116 | } 117 | 118 | if (type = schema.nodes.code_block) { 119 | bind("Shift-Ctrl-\\", setBlockType(type)); 120 | } 121 | 122 | if (type = schema.nodes.heading) { 123 | for (let i = 1; i <= 6; i++) { bind("Shift-Ctrl-" + i, setBlockType(type, { level: i })); } 124 | } 125 | 126 | if (type = schema.nodes.horizontal_rule) { 127 | const hr = type; 128 | 129 | bind("Mod-_", (state, dispatch) => { 130 | dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); 131 | return true; 132 | }); 133 | } 134 | 135 | return keys; 136 | } -------------------------------------------------------------------------------- /src/prosemirrorSchemaBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Attributes, DataAttributes, HyperlinkRels } from "@paperbits/common/html"; 2 | import { HyperlinkTarget } from "@paperbits/common/permalinks"; 3 | import { Schema } from "prosemirror-model"; 4 | 5 | export class ProsemirrorSchemaBuilder { 6 | private setupBlock(tag: string): any { 7 | return { 8 | group: "block", 9 | content: "inline*", 10 | attrs: { 11 | id: { default: null }, 12 | className: { default: null }, 13 | styles: { default: null } 14 | }, 15 | toDOM: (node) => { 16 | const properties: any = {}; 17 | 18 | if (node.attrs?.id) { 19 | properties.id = node.attrs.id; 20 | } 21 | 22 | if (node.attrs?.className) { 23 | properties.class = node.attrs.className; 24 | } 25 | 26 | return [tag, properties, 0]; 27 | }, 28 | parseDOM: [{ tag: tag }] 29 | }; 30 | } 31 | 32 | private setupHeading(tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"): any { 33 | const block = this.setupBlock(tag); 34 | 35 | block.parseDOM = [{ 36 | tag: tag, 37 | getAttrs: (dom) => { 38 | return { 39 | id: dom.hasAttribute("id") ? dom.getAttribute("id") : null 40 | }; 41 | } 42 | }]; 43 | 44 | return block; 45 | } 46 | 47 | public build(): Schema { 48 | const nodes: Object = { 49 | text: { 50 | group: "inline", 51 | }, 52 | paragraph: this.setupBlock("p"), 53 | formatted: this.setupBlock("pre"), 54 | ordered_list: { 55 | content: "list_item+", 56 | group: "block", 57 | attrs: { order: { default: 1 } }, 58 | parseDOM: [{ 59 | tag: "ol", 60 | getAttrs: (dom) => { 61 | return { order: dom.hasAttribute("start") ? +dom.getAttribute("start") : 1 }; 62 | } 63 | }], 64 | toDOM: (node) => { 65 | return node.attrs.order === 1 66 | ? ["ol", 0] 67 | : ["ol", { start: node.attrs.order }, 0]; 68 | } 69 | }, 70 | bulleted_list: { 71 | content: "list_item+", 72 | group: "block", 73 | attrs: { 74 | className: { default: null }, 75 | styles: { default: null } 76 | }, 77 | parseDOM: [{ tag: "ul" }], 78 | toDOM: (node) => { 79 | const tag = "ul"; 80 | const properties: any = {}; 81 | 82 | if (node.attrs.className) { 83 | properties.class = node.attrs.className; 84 | } 85 | 86 | return [tag, properties, 0]; 87 | } 88 | }, 89 | list_item: { 90 | content: "paragraph block*", 91 | parseDOM: [{ 92 | tag: "li" 93 | }], 94 | toDOM: () => { 95 | return ["li", 0]; 96 | }, 97 | defining: true 98 | }, 99 | heading1: this.setupHeading("h1"), 100 | heading2: this.setupHeading("h2"), 101 | heading3: this.setupHeading("h3"), 102 | heading4: this.setupHeading("h4"), 103 | heading5: this.setupHeading("h5"), 104 | heading6: this.setupHeading("h6"), 105 | quote: this.setupBlock("blockquote"), 106 | break: { 107 | inline: true, 108 | group: "inline", 109 | selectable: false, 110 | parseDOM: [{ tag: "br" }], 111 | toDOM: () => ["br"] 112 | }, 113 | property: { 114 | inline: true, 115 | group: "inline", 116 | selectable: false, 117 | attrs: { 118 | name: { default: null }, 119 | placeholder: { default: null } 120 | }, 121 | toDOM: (node) => { 122 | return ["property", { 123 | name: node.attrs.name, 124 | placeholder: node.attrs.placeholder 125 | }]; 126 | } 127 | }, 128 | doc: { 129 | content: "block+" 130 | } 131 | }; 132 | 133 | const marks: Object = { 134 | bold: { 135 | toDOM: () => { return ["b"]; }, 136 | parseDOM: [{ tag: "b" }] 137 | }, 138 | italic: { 139 | toDOM: () => { return ["i"]; }, 140 | parseDOM: [{ tag: "i" }] 141 | }, 142 | underlined: { 143 | toDOM: () => { return ["u"]; }, 144 | parseDOM: [{ tag: "u" }] 145 | }, 146 | highlighted: { 147 | toDOM: () => { return ["mark"]; }, 148 | parseDOM: [{ tag: "mark" }] 149 | }, 150 | striked: { 151 | toDOM: () => { return ["strike"]; }, 152 | parseDOM: [{ tag: "strike" }] 153 | }, 154 | code: { 155 | toDOM: () => { return ["code"]; }, 156 | parseDOM: [{ tag: "code" }] 157 | }, 158 | color: { 159 | attrs: { 160 | colorKey: {}, 161 | colorClass: {}, 162 | }, 163 | toDOM: (node) => { 164 | return ["span", { class: node.attrs.colorClass }]; 165 | } 166 | }, 167 | hyperlink: { 168 | attrs: { 169 | [Attributes.Href]: { default: undefined }, 170 | "anchor": { default: undefined }, 171 | "anchorName": { default: undefined }, 172 | "targetKey": { default: undefined }, 173 | [Attributes.Target]: { default: undefined }, 174 | [Attributes.Download]: { default: undefined }, 175 | [Attributes.Rel]: { default: undefined }, 176 | [DataAttributes.Toggle]: { default: undefined }, 177 | [DataAttributes.Target]: { default: undefined }, 178 | [DataAttributes.TriggerEvent]: { default: undefined } 179 | }, 180 | toDOM: (node) => { 181 | const hyperlink = node.attrs; 182 | 183 | let hyperlinkObj; 184 | let rels = null; 185 | 186 | switch (hyperlink.target) { 187 | case HyperlinkTarget.popup: 188 | hyperlinkObj = { 189 | [DataAttributes.Toggle]: "popup", 190 | [DataAttributes.Target]: `#${hyperlink.targetKey.replace("popups/", "popups")}`, 191 | [DataAttributes.TriggerEvent]: hyperlink.triggerEvent, 192 | [Attributes.Href]: "javascript:void(0)" 193 | }; 194 | 195 | break; 196 | 197 | case HyperlinkTarget.download: 198 | hyperlinkObj = { 199 | [Attributes.Href]: hyperlink.href, 200 | [Attributes.Download]: "" // Leave empty unless file name gets specified. 201 | }; 202 | break; 203 | 204 | default: 205 | if (hyperlink.targetKey?.startsWith("urls/")) { 206 | rels = [HyperlinkRels.NoOpener, HyperlinkRels.NoReferrer].join(" "); 207 | } 208 | 209 | hyperlinkObj = { 210 | [Attributes.Href]: `${hyperlink.href}${hyperlink.anchor ? "#" + hyperlink.anchor : ""}`, 211 | [Attributes.Target]: hyperlink.target, 212 | [Attributes.Rel]: rels 213 | }; 214 | } 215 | 216 | return ["a", hyperlinkObj]; 217 | }, 218 | parseDOM: [{ 219 | tag: "a", 220 | getAttrs: (dom) => { 221 | return { 222 | href: dom.href, 223 | target: dom.target 224 | }; 225 | } 226 | }], 227 | inclusive: false 228 | } 229 | }; 230 | 231 | const schema = new Schema({ 232 | nodes: nodes, 233 | marks: marks 234 | }); 235 | 236 | return schema; 237 | } 238 | } -------------------------------------------------------------------------------- /src/lists.ts: -------------------------------------------------------------------------------- 1 | import { findWrapping, liftTarget, canSplit, ReplaceAroundStep } from "prosemirror-transform"; 2 | import { Slice, Fragment, NodeRange } from "prosemirror-model"; 3 | 4 | // :: (NodeType, ?Object) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool 5 | // returns a command function that wraps the selection in a list with 6 | // the given type an attributes. If `dispatch` is null, only return a 7 | // value to indicate whether this is possible, but don't actually 8 | // perform the change. 9 | export function wrapInList(listType, attrs?) { 10 | return (state, dispatch) => { 11 | const { $from, $to } = state.selection; 12 | let range = $from.blockRange($to), doJoin = false, outerRange = range; 13 | 14 | if (!range) { 15 | return false; 16 | } 17 | 18 | // this is at the top of an existing list item 19 | if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex === 0) { 20 | 21 | // don't do anything if this is the top of the list 22 | if ($from.index(range.depth - 1) === 0) { 23 | return false; 24 | } 25 | 26 | const $insert = state.doc.resolve(range.start - 2); 27 | outerRange = new NodeRange($insert, $insert, range.depth); 28 | 29 | if (range.endIndex < range.parent.childCount) { 30 | range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth); 31 | } 32 | doJoin = true; 33 | } 34 | 35 | const wrap = findWrapping(outerRange, listType, attrs, range); 36 | 37 | if (!wrap) { 38 | return false; 39 | } 40 | 41 | if (dispatch) { 42 | dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView()); 43 | } 44 | 45 | return true; 46 | }; 47 | } 48 | 49 | function doWrapInList(tr, range, wrappers, joinBefore, listType) { 50 | let content = Fragment.empty; 51 | for (let i = wrappers.length - 1; i >= 0; i--) { 52 | content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)); 53 | } 54 | 55 | tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, 56 | new Slice(content, 0, 0), wrappers.length, true)); 57 | 58 | let found = 0; 59 | for (let i = 0; i < wrappers.length; i++) { if (wrappers[i].type === listType) { found = i + 1; } } 60 | const splitDepth = wrappers.length - found; 61 | 62 | let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0), parent = range.parent; 63 | 64 | for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++ , first = false) { 65 | if (!first && canSplit(tr.doc, splitPos, splitDepth)) { 66 | tr.split(splitPos, splitDepth); 67 | splitPos += 2 * splitDepth; 68 | } 69 | splitPos += parent.child(i).nodeSize; 70 | } 71 | return tr; 72 | } 73 | 74 | // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool 75 | // build a command that splits a non-empty textblock at the top level 76 | // of a list item by also splitting that list item. 77 | export function splitListItem(itemType) { 78 | return (state, dispatch) => { 79 | const { $from, $to, node } = state.selection; 80 | if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; } 81 | const grandParent = $from.node(-1); 82 | if (grandParent.type !== itemType) { return false; } 83 | if ($from.parent.content.size === 0) { 84 | // in an empty block. If this is a nested list, the wrapping 85 | // list item should be split. Otherwise, bail out and let next 86 | // command handle lifting. 87 | if ($from.depth === 2 || $from.node(-3).type !== itemType || 88 | $from.index(-2) !== $from.node(-2).childCount - 1) { return false; } 89 | if (dispatch) { 90 | let wrap = Fragment.empty, keepItem = $from.index(-1) > 0; 91 | // build a fragment containing empty versions of the structure 92 | // from the outer list item to the parent node of the cursor 93 | for (let d = $from.depth - (keepItem ? 1 : 2); d >= $from.depth - 3; d--) { 94 | wrap = Fragment.from($from.node(d).copy(wrap)); 95 | } 96 | // add a second list item with an empty default start node 97 | wrap = wrap.append(Fragment.from(itemType.createAndFill())); 98 | const tr = state.tr.replace($from.before(keepItem ? null : -1), $from.after(-3), new Slice(wrap, keepItem ? 3 : 2, 2)); 99 | tr.setSelection(state.selection.constructor.near(tr.doc.resolve($from.pos + (keepItem ? 3 : 2)))); 100 | dispatch(tr.scrollIntoView()); 101 | } 102 | return true; 103 | } 104 | const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt($from.indexAfter(-1)).defaultType : null; 105 | const tr = state.tr.delete($from.pos, $to.pos); 106 | const types = nextType && [null, { type: nextType }]; 107 | if (!canSplit(tr.doc, $from.pos, 2, types)) { return false; } 108 | if (dispatch) { dispatch(tr.split($from.pos, 2, types).scrollIntoView()); } 109 | return true; 110 | }; 111 | } 112 | 113 | // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool 114 | // create a command to lift the list item around the selection up into 115 | // a wrapping list. 116 | export function liftListItem(itemType) { 117 | return (state, dispatch) => { 118 | const { $from, $to } = state.selection; 119 | const range = $from.blockRange($to, node => node.childCount && node.firstChild.type === itemType); 120 | if (!range) { return false; } 121 | if (!dispatch) { return true; } 122 | if ($from.node(range.depth - 1).type === itemType) { // inside a parent list 123 | return liftToOuterList(state, dispatch, itemType, range); 124 | } else { // outer list node 125 | return liftOutOfList(state, dispatch, range); 126 | } 127 | }; 128 | } 129 | 130 | function liftToOuterList(state, dispatch, itemType, range) { 131 | const tr = state.tr, end = range.end, endOfList = range.$to.end(range.depth); 132 | if (end < endOfList) { 133 | // there are siblings after the lifted items, which must become 134 | // children of the last item 135 | tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList, 136 | new Slice(Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)); 137 | range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); 138 | } 139 | dispatch(tr.lift(range, liftTarget(range)).scrollIntoView()); 140 | return true; 141 | } 142 | 143 | function liftOutOfList(state, dispatch, range) { 144 | const tr = state.tr, list = range.parent; 145 | // merge the list items into a single big item 146 | for (let pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { 147 | pos -= list.child(i).nodeSize; 148 | tr.delete(pos - 1, pos + 1); 149 | } 150 | const $start = tr.doc.resolve(range.start), item = $start.nodeAfter; 151 | const atStart = range.startIndex === 0, atEnd = range.endIndex === list.childCount; 152 | const parent = $start.node(-1), indexBefore = $start.index(-1); 153 | if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, 154 | item.content.append(atEnd ? Fragment.empty : Fragment.from(list)))) { 155 | return false; 156 | } 157 | const start = $start.pos, end = start + item.nodeSize; 158 | // strip off the surrounding list. At the sides where we're not at 159 | // the end of the list, the existing list is closed. At sides where 160 | // this is the end, it is overwritten to its end. 161 | tr.step(new ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, 162 | new Slice((atStart ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))) 163 | .append(atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))), 164 | atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)); 165 | dispatch(tr.scrollIntoView()); 166 | return true; 167 | } 168 | 169 | // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool 170 | // create a command to sink the list item around the selection down 171 | // into an inner list. 172 | export function sinkListItem(itemType) { 173 | return function (state, dispatch) { 174 | const { $from, $to } = state.selection; 175 | const range = $from.blockRange($to, node => node.childCount && node.firstChild.type === itemType); 176 | if (!range) { return false; } 177 | const startIndex = range.startIndex; 178 | if (startIndex === 0) { return false; } 179 | const parent = range.parent, nodeBefore = parent.child(startIndex - 1); 180 | if (nodeBefore.type !== itemType) { return false; } 181 | 182 | if (dispatch) { 183 | const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; 184 | const inner = Fragment.from(nestedBefore ? itemType.create() : null); 185 | const slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.copy(inner)))), 186 | nestedBefore ? 3 : 1, 0); 187 | const before = range.start, after = range.end; 188 | dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, 189 | before, after, slice, 1, true)) 190 | .scrollIntoView()); 191 | } 192 | return true; 193 | }; 194 | } -------------------------------------------------------------------------------- /src/prosemirrorHtmlEditor.ts: -------------------------------------------------------------------------------- 1 | import { BlockModel } from "@paperbits/common/text/models"; 2 | import { EventManager } from "@paperbits/common/events"; 3 | import { StyleCompiler, LocalStyles } from "@paperbits/common/styles"; 4 | import { HyperlinkModel } from "@paperbits/common/permalinks"; 5 | import { IHtmlEditor, SelectionState, alignmentStyleKeys, HtmlEditorEvents } from "@paperbits/common/editing"; 6 | import { DOMSerializer, Mark } from "prosemirror-model"; 7 | import { EditorState, Plugin } from "prosemirror-state"; 8 | import { EditorView } from "prosemirror-view"; 9 | import { baseKeymap, toggleMark, setBlockType } from "prosemirror-commands"; 10 | import { splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; 11 | import { TextSelection } from "prosemirror-state"; 12 | import { history } from "prosemirror-history"; 13 | import { keymap } from "prosemirror-keymap"; 14 | import { wrapInList } from "./lists"; 15 | import { buildKeymap } from "./keymap"; 16 | import { ProsemirrorSchemaBuilder } from "./prosemirrorSchemaBuilder"; 17 | import { Attributes } from "@paperbits/common/html"; 18 | import { ViewManager } from "@paperbits/common/ui"; 19 | import { ModelConverter } from "./modelConverter"; 20 | 21 | const builder = new ProsemirrorSchemaBuilder(); 22 | const schema = builder.build(); 23 | 24 | export class ProseMirrorHtmlEditor implements IHtmlEditor { 25 | private element: HTMLElement; 26 | private editorView: EditorView; 27 | private content: any; 28 | 29 | constructor( 30 | readonly eventManager: EventManager, 31 | readonly styleCompiler: StyleCompiler, 32 | readonly viewManager: ViewManager 33 | ) { } 34 | 35 | public onStateChange: (state: BlockModel[]) => void; 36 | 37 | public getState(): BlockModel[] { 38 | let content; 39 | 40 | if (this.editorView) { 41 | content = this.editorView.state.toJSON()["doc"]["content"]; 42 | } 43 | else { 44 | content = this.content.content; 45 | } 46 | 47 | return ModelConverter.proseMirrorModelToModel(content); 48 | } 49 | 50 | public setState(content: BlockModel[]): void { 51 | try { 52 | const prosemirrorContent = ModelConverter.modelToProseMirrorModel(content); 53 | 54 | this.content = { 55 | type: "doc", 56 | content: prosemirrorContent 57 | }; 58 | 59 | const node: any = schema.nodeFromJSON(this.content); 60 | 61 | const fragment = DOMSerializer 62 | .fromSchema(schema) 63 | .serializeFragment(node); 64 | 65 | this.element.appendChild(fragment); 66 | } 67 | catch (error) { 68 | console.error(error.stack); 69 | } 70 | } 71 | 72 | public getSelectionState(): SelectionState { 73 | const state = this.editorView.state; 74 | 75 | let from = state.selection.from; 76 | const to = state.selection.to; 77 | 78 | if (from === to) { 79 | from -= 1; 80 | } 81 | 82 | const selectionState = new SelectionState(); 83 | const $anchor = state.selection.$anchor; 84 | 85 | if ($anchor) { 86 | const currentBlock = $anchor.node(); 87 | const blockType = currentBlock.type; 88 | const typeName = blockType.name; 89 | 90 | selectionState.block = blockType.name; 91 | selectionState.orderedList = typeName.includes("ordered_list"); 92 | selectionState.bulletedList = typeName.includes("bulleted_list"); 93 | selectionState.italic = state.doc.rangeHasMark(from, to, schema.marks.italic); 94 | selectionState.bold = state.doc.rangeHasMark(from, to, schema.marks.bold); 95 | selectionState.underlined = state.doc.rangeHasMark(from, to, schema.marks.underlined); 96 | selectionState.highlighted = state.doc.rangeHasMark(from, to, schema.marks.highlighted); 97 | selectionState.striked = state.doc.rangeHasMark(from, to, schema.marks.striked); 98 | selectionState.code = state.doc.rangeHasMark(from, to, schema.marks.code); 99 | selectionState.colorKey = this.getColor(); 100 | 101 | if (currentBlock.attrs && currentBlock.attrs.styles) { 102 | if (currentBlock.attrs.styles.alignment) { 103 | selectionState.alignment = currentBlock.attrs.styles.alignment; 104 | } 105 | if (currentBlock.attrs.styles.appearance) { 106 | selectionState.appearance = currentBlock.attrs.styles.appearance; 107 | } 108 | } 109 | } 110 | 111 | return selectionState; 112 | } 113 | 114 | public clearFormatting(): void { 115 | throw new Error("Not implemented"); 116 | } 117 | 118 | public insertText(text: string): void { 119 | throw new Error("Not implemented"); 120 | } 121 | 122 | public insertProperty(name: string, placeholder: string): void { 123 | const state = this.editorView.state; 124 | const dispatch = this.editorView.dispatch; 125 | const from = state.selection.$from; 126 | const index = from.index(); 127 | const propertyType = schema.nodes.property; 128 | 129 | if (!from.parent.canReplaceWith(index, index, propertyType)) { 130 | return; 131 | } 132 | 133 | dispatch(state.tr.replaceSelectionWith(propertyType.create({ name: name, placeholder: placeholder }))); 134 | } 135 | 136 | public toggleBold(): void { 137 | toggleMark(schema.marks.bold)(this.editorView.state, this.editorView.dispatch); 138 | this.editorView.focus(); 139 | this.eventManager.dispatchEvent("onSelectionChange", this); 140 | } 141 | 142 | public toggleItalic(): void { 143 | toggleMark(schema.marks.italic)(this.editorView.state, this.editorView.dispatch); 144 | this.editorView.focus(); 145 | this.eventManager.dispatchEvent("onSelectionChange", this); 146 | } 147 | 148 | public toggleUnderlined(): void { 149 | toggleMark(schema.marks.underlined)(this.editorView.state, this.editorView.dispatch); 150 | this.editorView.focus(); 151 | this.eventManager.dispatchEvent("onSelectionChange", this); 152 | } 153 | 154 | public toggleHighlighted(): void { 155 | toggleMark(schema.marks.highlighted)(this.editorView.state, this.editorView.dispatch); 156 | this.editorView.focus(); 157 | this.eventManager.dispatchEvent("onSelectionChange", this); 158 | } 159 | 160 | public toggleStriked(): void { 161 | toggleMark(schema.marks.striked)(this.editorView.state, this.editorView.dispatch); 162 | this.editorView.focus(); 163 | this.eventManager.dispatchEvent("onSelectionChange", this); 164 | } 165 | 166 | public toggleCode(): void { 167 | toggleMark(schema.marks.code)(this.editorView.state, this.editorView.dispatch); 168 | this.editorView.focus(); 169 | this.eventManager.dispatchEvent("onSelectionChange", this); 170 | } 171 | 172 | public toggleOrderedList(): void { 173 | wrapInList(schema.nodes.ordered_list)(this.editorView.state, this.editorView.dispatch); 174 | this.editorView.focus(); 175 | this.eventManager.dispatchEvent("onSelectionChange", this); 176 | } 177 | 178 | public async toggleUnorderedList(styleKey: string = "globals/ul/default"): Promise { 179 | let attrs = {}; 180 | 181 | if (styleKey) { 182 | const className = await this.styleCompiler.getClassNameByStyleKeyAsync(styleKey); 183 | 184 | if (className) { 185 | attrs = { className: className, styles: { appearance: styleKey } }; 186 | } 187 | } 188 | 189 | wrapInList(schema.nodes.bulleted_list, attrs)(this.editorView.state, this.editorView.dispatch); 190 | this.editorView.focus(); 191 | this.eventManager.dispatchEvent("onSelectionChange", this); 192 | } 193 | 194 | public toggleParagraph(): void { 195 | this.setBlockTypeAndNotify(schema.nodes.paragraph); 196 | } 197 | 198 | public toggleH1(): void { 199 | this.setBlockTypeAndNotify(schema.nodes.heading1); 200 | } 201 | 202 | public toggleH2(): void { 203 | this.setBlockTypeAndNotify(schema.nodes.heading2); 204 | } 205 | 206 | public toggleH3(): void { 207 | this.setBlockTypeAndNotify(schema.nodes.heading3); 208 | } 209 | 210 | public toggleH4(): void { 211 | this.setBlockTypeAndNotify(schema.nodes.heading4); 212 | } 213 | 214 | public toggleH5(): void { 215 | this.setBlockTypeAndNotify(schema.nodes.heading5); 216 | } 217 | 218 | public toggleH6(): void { 219 | this.setBlockTypeAndNotify(schema.nodes.heading6); 220 | } 221 | 222 | public toggleQuote(): void { 223 | this.setBlockTypeAndNotify(schema.nodes.quote); 224 | } 225 | 226 | public toggleFormatted(): void { 227 | this.setBlockTypeAndNotify(schema.nodes.formatted); 228 | } 229 | 230 | public toggleSize(): void { 231 | // const blockNode = this.getClosestNode(this.blockNodes); 232 | // Bindings.applyTypography(blockNode, { size: "smaller" }); 233 | } 234 | 235 | private updateMark(markType: any, markAttrs: Object): void { 236 | if (!markAttrs) { 237 | return; 238 | } 239 | const state = this.editorView.state; 240 | const tr = state.tr; 241 | const doc = tr.doc; 242 | 243 | const markLocation = (!state.selection.empty && state.selection) || 244 | (state.selection.$anchor && this.getMarkLocation(doc, state.selection.$anchor.pos, markType)); 245 | 246 | if (!markLocation) { 247 | return; 248 | } 249 | 250 | if (state.selection.empty) { 251 | if (doc.rangeHasMark(markLocation.from, markLocation.to, markType)) { 252 | tr.removeMark(markLocation.from, markLocation.to, markType); 253 | } else { 254 | return; 255 | } 256 | } 257 | const markItem = markType.create(markAttrs); 258 | this.editorView.dispatch(tr.addMark(markLocation.from, markLocation.to, markItem)); 259 | } 260 | 261 | private removeMark(markType: any): void { 262 | const state = this.editorView.state; 263 | const markLocation = (!state.selection.empty && state.selection) || this.getMarkLocation(state.tr.doc, state.selection.$anchor.pos, markType); 264 | 265 | if (!markLocation) { 266 | return; 267 | } 268 | 269 | this.editorView.dispatch(state.tr.removeMark(markLocation.from, markLocation.to, markType)); 270 | } 271 | 272 | public setColor(colorKey: string): void { 273 | const className = this.styleCompiler.getClassNameByColorKey(colorKey); 274 | this.updateMark(schema.marks.color, { colorKey: colorKey, colorClass: className }); 275 | } 276 | 277 | public getColor(): string { 278 | const mark = this.editorView.state.selection.$from.marks().find(x => x.type.name === "color"); 279 | 280 | if (!mark) { 281 | return null; 282 | } 283 | return mark.attrs.colorKey; 284 | } 285 | 286 | public removeColor(): void { 287 | this.removeMark(schema.marks.color); 288 | } 289 | 290 | public removeHyperlink(): void { 291 | this.removeMark(schema.marks.hyperlink); 292 | } 293 | 294 | private getMarkLocation(doc, pos, markType): { from: number, to: number } { 295 | const $pos = doc.resolve(pos); 296 | 297 | const start = $pos.parent.childAfter($pos.parentOffset); 298 | if (!start.node) { 299 | return null; 300 | } 301 | 302 | const mark = start.node.marks.find((mark) => mark.type === markType); 303 | if (!mark) { 304 | return null; 305 | } 306 | 307 | let startIndex = $pos.index(); 308 | let startPos = $pos.start() + start.offset; 309 | while (startIndex > 0 && mark.isInSet($pos.parent.child(startIndex - 1).marks)) { 310 | startIndex -= 1; 311 | startPos -= $pos.parent.child(startIndex).nodeSize; 312 | } 313 | 314 | let endIndex = $pos.indexAfter(); 315 | let endPos = startPos + start.node.nodeSize; 316 | while (endIndex < $pos.parent.childCount && mark.isInSet($pos.parent.child(endIndex).marks)) { 317 | endPos += $pos.parent.child(endIndex).nodeSize; 318 | endIndex += 1; 319 | } 320 | 321 | return { from: startPos, to: endPos }; 322 | } 323 | 324 | public setHyperlink(hyperlink: HyperlinkModel): void { 325 | if (!hyperlink.href && !hyperlink.targetKey) { 326 | return; 327 | } 328 | 329 | this.updateMark(schema.marks.hyperlink, hyperlink); 330 | } 331 | 332 | public getMarksOfTypeInRange(doc, selection, type): Mark[] { 333 | const from = selection.from; 334 | const to = selection.to; 335 | const marks = new Set(); 336 | 337 | doc.nodesBetween(from, to, (node) => { 338 | node.marks.forEach(mark => { 339 | if (mark.type === type) { 340 | marks.add(mark) 341 | } 342 | }); 343 | }); 344 | 345 | return Array.from(marks); 346 | } 347 | 348 | public getHyperlink(): HyperlinkModel { // TODO: Move to Selection state 349 | const doc = this.editorView.state.tr.doc; 350 | const selection = this.editorView.state.selection; 351 | const hyperlinkMarks = this.getMarksOfTypeInRange(doc, selection, schema.marks.hyperlink); 352 | 353 | return hyperlinkMarks.length > 0 354 | ? (hyperlinkMarks[0]).attrs 355 | : null; 356 | } 357 | 358 | public setAnchor(hash: string, anchorKey: string): void { 359 | // const node = this.getClosestNode(this.blockNodes); 360 | // Bindings.applyAnchor(node, hash, anchorKey); 361 | } 362 | 363 | public removeAnchor(): void { 364 | // const node = this.getClosestNode(this.blockNodes); 365 | // Bindings.removeAnchor(node); 366 | } 367 | 368 | public getSelectionText(): string { 369 | throw new Error("Not implemented"); 370 | } 371 | 372 | public resetToNormal(): void { 373 | // Ui.command(commands.p)({ selection: Api.editor.selection }); 374 | } 375 | 376 | public increaseIndent(): void { 377 | sinkListItem(schema.nodes.list_item); 378 | } 379 | 380 | public decreaseIndent(): void { 381 | liftListItem(schema.nodes.list_item); 382 | } 383 | 384 | public expandSelection(to?: string): void { 385 | throw new Error("Not implemented"); 386 | } 387 | 388 | public setTextStyle(textStyleKey: string, viewport?: string): void { 389 | this.updateTextStyle(textStyleKey, viewport); 390 | } 391 | 392 | private async updateTextStyle(textStyleKey: string, viewport: string = "xs"): Promise { 393 | const $anchor = this.editorView.state.selection.$anchor; 394 | 395 | if (!$anchor) { 396 | return; 397 | } 398 | 399 | const currentBlock = $anchor.node(); 400 | const blockType = currentBlock.type; 401 | const blockStyle: LocalStyles = currentBlock.attrs.styles || {}; 402 | 403 | blockStyle.appearance = blockStyle.appearance || {}; 404 | 405 | if (textStyleKey) { 406 | blockStyle.appearance = textStyleKey; 407 | } 408 | else { 409 | if (blockStyle.appearance) { 410 | delete blockStyle.appearance; 411 | } 412 | } 413 | 414 | setBlockType(schema.nodes.paragraph)(this.editorView.state, this.editorView.dispatch); 415 | 416 | if (Object.keys(blockStyle).length > 0) { 417 | const className = await this.styleCompiler.getClassNamesForLocalStylesAsync(blockStyle); 418 | setBlockType(blockType, { styles: blockStyle, className: className })(this.editorView.state, this.editorView.dispatch); 419 | } 420 | else { 421 | setBlockType(blockType)(this.editorView.state, this.editorView.dispatch); 422 | } 423 | this.editorView.focus(); 424 | this.eventManager.dispatchEvent("onSelectionChange", this); 425 | } 426 | 427 | private async setAlignment(styleKey: string, viewport: string = "xs"): Promise { 428 | const $anchor = this.editorView.state.selection.$anchor; 429 | 430 | if (!$anchor) { 431 | return; 432 | } 433 | 434 | const currentBlock = $anchor.node(); 435 | const blockType = currentBlock.type; 436 | const blockStyle = currentBlock.attrs.styles || {}; 437 | 438 | blockStyle.alignment = blockStyle.alignment || {}; 439 | 440 | Object.assign(blockStyle.alignment, { [viewport]: styleKey }); 441 | 442 | const className = await this.styleCompiler.getClassNamesForLocalStylesAsync(blockStyle); 443 | 444 | setBlockType(schema.nodes.paragraph)(this.editorView.state, this.editorView.dispatch); 445 | setBlockType(blockType, { styles: blockStyle, className: className })(this.editorView.state, this.editorView.dispatch); 446 | 447 | this.editorView.focus(); 448 | this.eventManager.dispatchEvent("onSelectionChange", this); 449 | } 450 | 451 | public alignLeft(viewport: string = "xs"): void { 452 | this.setAlignment(alignmentStyleKeys.left, viewport); 453 | } 454 | 455 | public alignCenter(viewport: string = "xs"): void { 456 | this.setAlignment(alignmentStyleKeys.center, viewport); 457 | } 458 | 459 | public alignRight(viewport: string = "xs"): void { 460 | this.setAlignment(alignmentStyleKeys.right, viewport); 461 | } 462 | 463 | public justify(viewport: string = "xs"): void { 464 | this.setAlignment(alignmentStyleKeys.justify, viewport); 465 | } 466 | 467 | public setCaretAtEndOf(node: Node): void { 468 | // const boundary = Boundaries.fromEndOfNode(node); 469 | // Api.editor.selection = Selections.select(Api.editor.selection, boundary, boundary); 470 | } 471 | 472 | public setCaretAt(clientX: number, clientY: number): void { 473 | // const boundary = Boundaries.fromPosition( 474 | // clientX + Dom.scrollLeft(document), 475 | // clientY + Dom.scrollTop(document), 476 | // document 477 | // ); 478 | // Api.editor.selection = Selections.select(Api.editor.selection, boundary, boundary); 479 | // this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); 480 | } 481 | 482 | private setBlockTypeAndNotify(blockType: any, attrs?: any): void { 483 | setBlockType(blockType, attrs)(this.editorView.state, this.editorView.dispatch); 484 | this.eventManager.dispatchEvent("onSelectionChange", this); 485 | } 486 | 487 | private handleUpdates(view: any, prevState: any): void { 488 | this.eventManager.dispatchEvent("htmlEditorChanged", this); 489 | 490 | const state = view.state; 491 | 492 | if (this.onStateChange && prevState && !prevState.doc.eq(state.doc)) { 493 | const newState = this.getState(); 494 | this.onStateChange(newState); 495 | } 496 | 497 | if (prevState && !prevState.selection.eq(state.selection)) { 498 | this.eventManager.dispatchEvent("onSelectionChange", this); 499 | } 500 | } 501 | 502 | private placeCaretUnderPointer(hostElement: HTMLElement): void { 503 | if (!hostElement) { 504 | return; 505 | } 506 | 507 | setTimeout(() => { 508 | hostElement.focus(); 509 | 510 | const pointerPosition = this.viewManager.getPointerPosition(); 511 | const coords = { left: pointerPosition.x, top: pointerPosition.y }; 512 | const cursorPosition = this.editorView.posAtCoords(coords); 513 | 514 | if (!cursorPosition) { 515 | return; 516 | } 517 | 518 | const state = this.editorView.state; 519 | const transaction = state.tr.setSelection(TextSelection.create(state.doc, cursorPosition.pos, cursorPosition.pos)); 520 | 521 | this.editorView.dispatch(transaction); 522 | }, 100); 523 | } 524 | 525 | public enable(activeElement: HTMLElement): void { 526 | if (this.editorView) { 527 | this.editorView.dom.setAttribute(Attributes.ContentEditable, "true"); 528 | this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); 529 | 530 | if (activeElement) { 531 | this.placeCaretUnderPointer(activeElement); 532 | } 533 | 534 | return; 535 | } 536 | 537 | const doc: any = schema.nodeFromJSON(this.content); 538 | const handleUpdates = this.handleUpdates.bind(this); 539 | 540 | const detectChangesPlugin = new Plugin({ 541 | view(view) { 542 | return { 543 | update: handleUpdates 544 | }; 545 | } 546 | }); 547 | 548 | const plugins = [detectChangesPlugin]; 549 | 550 | this.editorView = new EditorView({ mount: this.element }, { 551 | state: EditorState.create({ 552 | doc, 553 | plugins: plugins.concat([ 554 | keymap(buildKeymap(schema, null)), 555 | keymap(baseKeymap), 556 | history()]) 557 | }) 558 | }); 559 | 560 | this.eventManager.dispatchEvent("htmlEditorChanged", this); 561 | this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); 562 | 563 | if (activeElement) { 564 | this.placeCaretUnderPointer(activeElement); 565 | } 566 | } 567 | 568 | public disable(): void { 569 | if (!this.editorView) { 570 | return; 571 | } 572 | 573 | this.editorView.dom.removeAttribute(Attributes.ContentEditable); 574 | } 575 | 576 | public attachToElement(element: HTMLElement): void { 577 | this.element = element; 578 | } 579 | 580 | public detachFromElement(): void { 581 | this.disable(); 582 | } 583 | } --------------------------------------------------------------------------------