UNPKG

@paperbits/prosemirror

Version:
583 lines (459 loc) 20.3 kB
import { BlockModel } from "@paperbits/common/text/models"; import { EventManager } from "@paperbits/common/events"; import { StyleCompiler, LocalStyles } from "@paperbits/common/styles"; import { HyperlinkModel } from "@paperbits/common/permalinks"; import { IHtmlEditor, SelectionState, alignmentStyleKeys, HtmlEditorEvents } from "@paperbits/common/editing"; import { DOMSerializer, Mark } from "prosemirror-model"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { baseKeymap, toggleMark, setBlockType } from "prosemirror-commands"; import { splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; import { TextSelection } from "prosemirror-state"; import { history } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { wrapInList } from "./lists"; import { buildKeymap } from "./keymap"; import { ProsemirrorSchemaBuilder } from "./prosemirrorSchemaBuilder"; import { Attributes } from "@paperbits/common/html"; import { ViewManager } from "@paperbits/common/ui"; import { ModelConverter } from "./modelConverter"; const builder = new ProsemirrorSchemaBuilder(); const schema = builder.build(); export class ProseMirrorHtmlEditor implements IHtmlEditor { private element: HTMLElement; private editorView: EditorView; private content: any; constructor( readonly eventManager: EventManager, readonly styleCompiler: StyleCompiler, readonly viewManager: ViewManager ) { } public onStateChange: (state: BlockModel[]) => void; public getState(): BlockModel[] { let content; if (this.editorView) { content = this.editorView.state.toJSON()["doc"]["content"]; } else { content = this.content.content; } return ModelConverter.proseMirrorModelToModel(content); } public setState(content: BlockModel[]): void { try { const prosemirrorContent = ModelConverter.modelToProseMirrorModel(content); this.content = { type: "doc", content: prosemirrorContent }; const node: any = schema.nodeFromJSON(this.content); const fragment = DOMSerializer .fromSchema(schema) .serializeFragment(node); this.element.appendChild(fragment); } catch (error) { console.error(error.stack); } } public getSelectionState(): SelectionState { const state = this.editorView.state; let from = state.selection.from; const to = state.selection.to; if (from === to) { from -= 1; } const selectionState = new SelectionState(); const $anchor = state.selection.$anchor; if ($anchor) { const currentBlock = $anchor.node(); const blockType = currentBlock.type; const typeName = blockType.name; selectionState.block = blockType.name; selectionState.orderedList = typeName.includes("ordered_list"); selectionState.bulletedList = typeName.includes("bulleted_list"); selectionState.italic = state.doc.rangeHasMark(from, to, schema.marks.italic); selectionState.bold = state.doc.rangeHasMark(from, to, schema.marks.bold); selectionState.underlined = state.doc.rangeHasMark(from, to, schema.marks.underlined); selectionState.highlighted = state.doc.rangeHasMark(from, to, schema.marks.highlighted); selectionState.striked = state.doc.rangeHasMark(from, to, schema.marks.striked); selectionState.code = state.doc.rangeHasMark(from, to, schema.marks.code); selectionState.colorKey = this.getColor(); if (currentBlock.attrs && currentBlock.attrs.styles) { if (currentBlock.attrs.styles.alignment) { selectionState.alignment = currentBlock.attrs.styles.alignment; } if (currentBlock.attrs.styles.appearance) { selectionState.appearance = currentBlock.attrs.styles.appearance; } } } return selectionState; } public clearFormatting(): void { throw new Error("Not implemented"); } public insertText(text: string): void { throw new Error("Not implemented"); } public insertProperty(name: string, placeholder: string): void { const state = this.editorView.state; const dispatch = this.editorView.dispatch; const from = state.selection.$from; const index = from.index(); const propertyType = schema.nodes.property; if (!from.parent.canReplaceWith(index, index, propertyType)) { return; } dispatch(state.tr.replaceSelectionWith(propertyType.create({ name: name, placeholder: placeholder }))); } public toggleBold(): void { toggleMark(schema.marks.bold)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleItalic(): void { toggleMark(schema.marks.italic)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleUnderlined(): void { toggleMark(schema.marks.underlined)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleHighlighted(): void { toggleMark(schema.marks.highlighted)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleStriked(): void { toggleMark(schema.marks.striked)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleCode(): void { toggleMark(schema.marks.code)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleOrderedList(): void { wrapInList(schema.nodes.ordered_list)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public async toggleUnorderedList(styleKey: string = "globals/ul/default"): Promise<void> { let attrs = {}; if (styleKey) { const className = await this.styleCompiler.getClassNameByStyleKeyAsync(styleKey); if (className) { attrs = { className: className, styles: { appearance: styleKey } }; } } wrapInList(schema.nodes.bulleted_list, attrs)(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public toggleParagraph(): void { this.setBlockTypeAndNotify(schema.nodes.paragraph); } public toggleH1(): void { this.setBlockTypeAndNotify(schema.nodes.heading1); } public toggleH2(): void { this.setBlockTypeAndNotify(schema.nodes.heading2); } public toggleH3(): void { this.setBlockTypeAndNotify(schema.nodes.heading3); } public toggleH4(): void { this.setBlockTypeAndNotify(schema.nodes.heading4); } public toggleH5(): void { this.setBlockTypeAndNotify(schema.nodes.heading5); } public toggleH6(): void { this.setBlockTypeAndNotify(schema.nodes.heading6); } public toggleQuote(): void { this.setBlockTypeAndNotify(schema.nodes.quote); } public toggleFormatted(): void { this.setBlockTypeAndNotify(schema.nodes.formatted); } public toggleSize(): void { // const blockNode = <HTMLElement>this.getClosestNode(this.blockNodes); // Bindings.applyTypography(blockNode, { size: "smaller" }); } private updateMark(markType: any, markAttrs: Object): void { if (!markAttrs) { return; } const state = this.editorView.state; const tr = state.tr; const doc = tr.doc; const markLocation = (!state.selection.empty && state.selection) || (state.selection.$anchor && this.getMarkLocation(doc, state.selection.$anchor.pos, markType)); if (!markLocation) { return; } if (state.selection.empty) { if (doc.rangeHasMark(markLocation.from, markLocation.to, markType)) { tr.removeMark(markLocation.from, markLocation.to, markType); } else { return; } } const markItem = markType.create(markAttrs); this.editorView.dispatch(tr.addMark(markLocation.from, markLocation.to, markItem)); } private removeMark(markType: any): void { const state = this.editorView.state; const markLocation = (!state.selection.empty && state.selection) || this.getMarkLocation(state.tr.doc, state.selection.$anchor.pos, markType); if (!markLocation) { return; } this.editorView.dispatch(state.tr.removeMark(markLocation.from, markLocation.to, markType)); } public setColor(colorKey: string): void { const className = this.styleCompiler.getClassNameByColorKey(colorKey); this.updateMark(schema.marks.color, { colorKey: colorKey, colorClass: className }); } public getColor(): string { const mark = this.editorView.state.selection.$from.marks().find(x => x.type.name === "color"); if (!mark) { return null; } return mark.attrs.colorKey; } public removeColor(): void { this.removeMark(schema.marks.color); } public removeHyperlink(): void { this.removeMark(schema.marks.hyperlink); } private getMarkLocation(doc, pos, markType): { from: number, to: number } { const $pos = doc.resolve(pos); const start = $pos.parent.childAfter($pos.parentOffset); if (!start.node) { return null; } const mark = start.node.marks.find((mark) => mark.type === markType); if (!mark) { return null; } let startIndex = $pos.index(); let startPos = $pos.start() + start.offset; while (startIndex > 0 && mark.isInSet($pos.parent.child(startIndex - 1).marks)) { startIndex -= 1; startPos -= $pos.parent.child(startIndex).nodeSize; } let endIndex = $pos.indexAfter(); let endPos = startPos + start.node.nodeSize; while (endIndex < $pos.parent.childCount && mark.isInSet($pos.parent.child(endIndex).marks)) { endPos += $pos.parent.child(endIndex).nodeSize; endIndex += 1; } return { from: startPos, to: endPos }; } public setHyperlink(hyperlink: HyperlinkModel): void { if (!hyperlink.href && !hyperlink.targetKey) { return; } this.updateMark(schema.marks.hyperlink, hyperlink); } public getMarksOfTypeInRange(doc, selection, type): Mark[] { const from = selection.from; const to = selection.to; const marks = new Set<Mark>(); doc.nodesBetween(from, to, (node) => { node.marks.forEach(mark => { if (mark.type === type) { marks.add(mark) } }); }); return Array.from(marks); } public getHyperlink(): HyperlinkModel { // TODO: Move to Selection state const doc = this.editorView.state.tr.doc; const selection = this.editorView.state.selection; const hyperlinkMarks = this.getMarksOfTypeInRange(doc, selection, schema.marks.hyperlink); return hyperlinkMarks.length > 0 ? (<any>hyperlinkMarks[0]).attrs : null; } public setAnchor(hash: string, anchorKey: string): void { // const node = <HTMLElement>this.getClosestNode(this.blockNodes); // Bindings.applyAnchor(node, hash, anchorKey); } public removeAnchor(): void { // const node = <HTMLElement>this.getClosestNode(this.blockNodes); // Bindings.removeAnchor(node); } public getSelectionText(): string { throw new Error("Not implemented"); } public resetToNormal(): void { // Ui.command(commands.p)({ selection: Api.editor.selection }); } public increaseIndent(): void { sinkListItem(schema.nodes.list_item); } public decreaseIndent(): void { liftListItem(schema.nodes.list_item); } public expandSelection(to?: string): void { throw new Error("Not implemented"); } public setTextStyle(textStyleKey: string, viewport?: string): void { this.updateTextStyle(textStyleKey, viewport); } private async updateTextStyle(textStyleKey: string, viewport: string = "xs"): Promise<void> { const $anchor = this.editorView.state.selection.$anchor; if (!$anchor) { return; } const currentBlock = $anchor.node(); const blockType = currentBlock.type; const blockStyle: LocalStyles = currentBlock.attrs.styles || {}; blockStyle.appearance = blockStyle.appearance || {}; if (textStyleKey) { blockStyle.appearance = textStyleKey; } else { if (blockStyle.appearance) { delete blockStyle.appearance; } } setBlockType(schema.nodes.paragraph)(this.editorView.state, this.editorView.dispatch); if (Object.keys(blockStyle).length > 0) { const className = await this.styleCompiler.getClassNamesForLocalStylesAsync(blockStyle); setBlockType(blockType, { styles: blockStyle, className: className })(this.editorView.state, this.editorView.dispatch); } else { setBlockType(blockType)(this.editorView.state, this.editorView.dispatch); } this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } private async setAlignment(styleKey: string, viewport: string = "xs"): Promise<void> { const $anchor = this.editorView.state.selection.$anchor; if (!$anchor) { return; } const currentBlock = $anchor.node(); const blockType = currentBlock.type; const blockStyle = currentBlock.attrs.styles || {}; blockStyle.alignment = blockStyle.alignment || {}; Object.assign(blockStyle.alignment, { [viewport]: styleKey }); const className = await this.styleCompiler.getClassNamesForLocalStylesAsync(blockStyle); setBlockType(schema.nodes.paragraph)(this.editorView.state, this.editorView.dispatch); setBlockType(blockType, { styles: blockStyle, className: className })(this.editorView.state, this.editorView.dispatch); this.editorView.focus(); this.eventManager.dispatchEvent("onSelectionChange", this); } public alignLeft(viewport: string = "xs"): void { this.setAlignment(alignmentStyleKeys.left, viewport); } public alignCenter(viewport: string = "xs"): void { this.setAlignment(alignmentStyleKeys.center, viewport); } public alignRight(viewport: string = "xs"): void { this.setAlignment(alignmentStyleKeys.right, viewport); } public justify(viewport: string = "xs"): void { this.setAlignment(alignmentStyleKeys.justify, viewport); } public setCaretAtEndOf(node: Node): void { // const boundary = Boundaries.fromEndOfNode(node); // Api.editor.selection = Selections.select(Api.editor.selection, boundary, boundary); } public setCaretAt(clientX: number, clientY: number): void { // const boundary = Boundaries.fromPosition( // clientX + Dom.scrollLeft(document), // clientY + Dom.scrollTop(document), // document // ); // Api.editor.selection = Selections.select(Api.editor.selection, boundary, boundary); // this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); } private setBlockTypeAndNotify(blockType: any, attrs?: any): void { setBlockType(blockType, attrs)(this.editorView.state, this.editorView.dispatch); this.eventManager.dispatchEvent("onSelectionChange", this); } private handleUpdates(view: any, prevState: any): void { this.eventManager.dispatchEvent("htmlEditorChanged", this); const state = view.state; if (this.onStateChange && prevState && !prevState.doc.eq(state.doc)) { const newState = this.getState(); this.onStateChange(newState); } if (prevState && !prevState.selection.eq(state.selection)) { this.eventManager.dispatchEvent("onSelectionChange", this); } } private placeCaretUnderPointer(hostElement: HTMLElement): void { if (!hostElement) { return; } setTimeout(() => { hostElement.focus(); const pointerPosition = this.viewManager.getPointerPosition(); const coords = { left: pointerPosition.x, top: pointerPosition.y }; const cursorPosition = this.editorView.posAtCoords(coords); if (!cursorPosition) { return; } const state = this.editorView.state; const transaction = state.tr.setSelection(TextSelection.create(state.doc, cursorPosition.pos, cursorPosition.pos)); this.editorView.dispatch(transaction); }, 100); } public enable(activeElement: HTMLElement): void { if (this.editorView) { this.editorView.dom.setAttribute(Attributes.ContentEditable, "true"); this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); if (activeElement) { this.placeCaretUnderPointer(activeElement); } return; } const doc: any = schema.nodeFromJSON(this.content); const handleUpdates = this.handleUpdates.bind(this); const detectChangesPlugin = new Plugin({ view(view) { return { update: handleUpdates }; } }); const plugins = [detectChangesPlugin]; this.editorView = new EditorView({ mount: this.element }, { state: EditorState.create({ doc, plugins: plugins.concat([ keymap(buildKeymap(schema, null)), keymap(baseKeymap), history()]) }) }); this.eventManager.dispatchEvent("htmlEditorChanged", this); this.eventManager.dispatchEvent(HtmlEditorEvents.onSelectionChange); if (activeElement) { this.placeCaretUnderPointer(activeElement); } } public disable(): void { if (!this.editorView) { return; } this.editorView.dom.removeAttribute(Attributes.ContentEditable); } public attachToElement(element: HTMLElement): void { this.element = element; } public detachFromElement(): void { this.disable(); } }