@paperbits/prosemirror
Version:
Paperbits HTML editor based on ProseMirror.
583 lines (459 loc) • 20.3 kB
text/typescript
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();
}
}