@limetech/lime-elements
Version:
674 lines (673 loc) • 28.4 kB
JavaScript
import { Host, h, } from "@stencil/core";
import { EditorState, Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema, DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { exampleSetup } from "prosemirror-example-setup";
import { keymap } from "prosemirror-keymap";
import { MenuCommandFactory } from "./menu/menu-commands";
import { menuTranslationIDs, getTextEditorMenuItems } from "./menu/menu-items";
import { MarkdownConverter } from "../utils/markdown-converter";
import { HTMLConverter } from "../utils/html-converter";
import { EditorMenuTypes, editorMenuTypesArray, } from "./menu/types";
import translate from "../../../global/translations";
import { createRandomString } from "../../../util/random-string";
import { isItem } from "../../action-bar/is-item";
import { cloneDeep, debounce } from "lodash-es";
import { strikethrough } from "./menu/menu-schema-extender";
import { createLinkPlugin } from "./plugins/link/link-plugin";
import { linkMarkSpec } from "./plugins/link/link-mark";
import { createImageInserterPlugin } from "./plugins/image/inserter";
import { createImageViewPlugin } from "./plugins/image/view";
import { createMenuStateTrackingPlugin } from "./plugins/menu-state-tracking-plugin";
import { createActionBarInteractionPlugin } from "./plugins/menu-action-interaction-plugin";
import { createNodeSpec } from "../utils/plugin-factory";
import { createTriggerPlugin } from "./plugins/trigger/factory";
import { getTableNodes, getTableEditingPlugins } from "./plugins/table-plugin";
import { getImageNode, imageCache } from "./plugins/image/node";
import { getMetadataFromDoc, hasMetadataChanged, } from "../utils/metadata-utils";
const DEBOUNCE_TIMEOUT = 300;
/**
* The ProseMirror adapter offers a rich text editing experience with markdown support.
* [Read more...](https://prosemirror.net/)
*
* @exampleComponent limel-example-prosemirror-adapter-basic
* @exampleComponent limel-example-prosemirror-adapter-with-custom-menu
* @beta
* @private
*/
export class ProsemirrorAdapter {
constructor() {
/**
* The type of content that the editor should handle and emit, defaults to `markdown`
*
* Assumed to be set only once, so not reactive to changes
*/
this.contentType = 'markdown';
/**
* Set to `true` to disable the field.
* Use `disabled` to indicate that the field can normally be interacted
* with, but is currently disabled. This tells the user that if certain
* requirements are met, the field may become enabled again.
*/
this.disabled = false;
/**
* set to private to avoid usage while under development
*
* @private
* @alpha
*/
this.customElements = [];
/**
* set to private to avoid usage while under development
*
* @private
* @alpha
*/
this.triggerCharacters = [];
/**
* Specifies the visual appearance of the editor.
*/
this.ui = 'standard';
this.actionBarItems = [];
this.link = { href: 'https://' };
/**
* Open state of the dialog
*/
this.isLinkMenuOpen = false;
this.changeWaiting = false;
this.transactionFired = false;
this.lastClickedPos = null;
this.metadata = { images: [], links: [] };
/**
* Used to stop change event emitting as result of getting updated value from consumer
*/
this.suppressChangeEvent = false;
this.getActionBarItems = () => {
this.actionBarItems = getTextEditorMenuItems().map(this.getTranslatedItem);
};
this.getTranslatedItem = (item) => {
const newItem = cloneDeep(item);
if (isItem(item)) {
const translationId = menuTranslationIDs[item.value];
if (translationId) {
newItem.text = translate.get(translationId, this.language);
}
}
return newItem;
};
this.updateActiveActionBarItems = (activeTypes, allowedTypes) => {
const newItems = getTextEditorMenuItems().map((item) => {
if (isItem(item)) {
return Object.assign(Object.assign({}, item), { selected: activeTypes[item.value], allowed: allowedTypes[item.value] });
}
return item;
});
this.actionBarItems = newItems.filter((item) => isItem(item) ? item.allowed : true);
};
this.handleTransaction = (transaction) => {
this.transactionFired = true;
const newState = this.view.state.apply(transaction);
this.view.updateState(newState);
if (this.suppressChangeEvent || transaction.getMeta('pointer')) {
return;
}
const content = this.contentConverter.serialize(this.view, this.schema);
if (content === this.lastEmittedValue) {
return;
}
const metadata = getMetadataFromDoc(newState.doc);
this.metadataEmitter(metadata);
this.lastEmittedValue = content;
this.changeWaiting = true;
this.changeEmitter(content);
};
this.handleActionBarItem = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
const { value } = event.detail;
if (value === EditorMenuTypes.Link) {
this.isLinkMenuOpen = true;
return;
}
const actionBarEvent = new CustomEvent('actionBarItemClick', {
detail: event.detail,
});
this.view.dom.dispatchEvent(actionBarEvent);
};
this.handleCancelLinkMenu = (event) => {
event.preventDefault();
event.stopPropagation();
this.isLinkMenuOpen = false;
this.link = { text: '', href: 'https://' };
};
this.handleSaveLinkMenu = () => {
this.isLinkMenuOpen = false;
const saveLinkEvent = new CustomEvent('saveLinkMenu', {
detail: {
type: EditorMenuTypes.Link,
link: this.link,
},
});
this.view.dom.dispatchEvent(saveLinkEvent);
this.link = { href: 'https://' };
};
this.handleLinkChange = (event) => {
this.link = event.detail;
};
this.handleFocus = () => {
var _a;
if (!this.disabled) {
(_a = this.view) === null || _a === void 0 ? void 0 : _a.focus();
// Workaround: On some focus interactions (especially clicking the first line and the last line),
// ProseMirror does not dispatch a transaction or update the selection. This can cause
// the cursor to fall back to the end of the document instead of placing it where the user clicked.
//
// To detect this, we wait one tick after focus. If no transaction has fired by then,
// we assume the selection is unresolved and manually move the cursor to the last clicked position.
this.transactionFired = false;
setTimeout(() => {
if (!this.transactionFired && this.lastClickedPos) {
const { doc, tr } = this.view.state;
const resolvedPos = doc.resolve(this.lastClickedPos);
const selection = Selection.near(resolvedPos);
tr.setMeta('pointer', true);
this.view.dispatch(tr.setSelection(selection));
}
}, 0);
}
};
this.handleNewLinkSelection = (text, href) => {
this.link.text = text;
this.link.href = href || 'https://';
};
this.handleOpenLinkMenu = (event) => {
event.stopImmediatePropagation();
const { href, text } = event.detail;
this.link = { href: href, text: text };
this.isLinkMenuOpen = true;
};
this.handleMouseDown = (event) => {
var _a;
const coords = { left: event.clientX, top: event.clientY };
const result = this.view.posAtCoords(coords);
this.lastClickedPos = (_a = result === null || result === void 0 ? void 0 : result.pos) !== null && _a !== void 0 ? _a : null;
};
this.changeEmitter = debounce((value) => {
this.change.emit(value);
this.changeWaiting = false;
}, DEBOUNCE_TIMEOUT);
this.handleBlur = () => {
this.changeEmitter.flush();
};
this.portalId = createRandomString();
}
watchValue(newValue) {
if (!this.view) {
return;
}
if (this.changeWaiting) {
// A change is pending; do not update the editor's content
return;
}
const currentContent = this.contentConverter.serialize(this.view, this.schema);
// If the new value is the same as the current content, do nothing
if (newValue === currentContent) {
return;
}
// Update the editor's content with the new value
this.updateView(newValue);
}
componentWillLoad() {
this.getActionBarItems();
this.setupContentConverter();
}
componentDidLoad() {
// Stencil complains loudly about triggering rerenders in
// componentDidLoad, but we have to, so we're using setTimeout to
// suppress the warning. /Ads
setTimeout(() => {
this.initializeTextEditor();
}, 0);
}
connectedCallback() {
if (this.view) {
this.initializeTextEditor();
}
this.host.addEventListener('open-editor-link-menu', this.handleOpenLinkMenu);
}
disconnectedCallback() {
var _a, _b, _c, _d, _e;
imageCache.clear();
this.host.removeEventListener('open-editor-link-menu', this.handleOpenLinkMenu);
(_b = (_a = this.view) === null || _a === void 0 ? void 0 : _a.dom) === null || _b === void 0 ? void 0 : _b.removeEventListener('blur', this.handleBlur);
(_d = (_c = this.view) === null || _c === void 0 ? void 0 : _c.dom) === null || _d === void 0 ? void 0 : _d.removeEventListener('mousedown', this.handleMouseDown);
(_e = this.view) === null || _e === void 0 ? void 0 : _e.destroy();
}
render() {
return (h(Host, { key: '125d6d80cfc94a3121cde5d121d3cb2a26cf1635', onFocus: this.handleFocus }, h("div", { key: 'eb076afa683c0dfcb57b6688bc94ce1b81ce5710', id: "editor" }), this.renderToolbar(), this.renderLinkMenu()));
}
renderToolbar() {
if (this.actionBarItems.length === 0 || this.ui === 'no-toolbar') {
return;
}
return (h("div", { class: "toolbar" }, h("limel-action-bar", { ref: (el) => (this.actionBarElement = el), accessibleLabel: "Toolbar", actions: this.actionBarItems, onItemSelected: this.handleActionBarItem })));
}
renderLinkMenu() {
if (!this.isLinkMenuOpen) {
return;
}
return (h("limel-portal", { containerId: this.portalId, visible: this.isLinkMenuOpen, openDirection: "top", inheritParentWidth: true, anchor: this.actionBarElement }, h("limel-text-editor-link-menu", { link: this.link, isOpen: this.isLinkMenuOpen, onLinkChange: this.handleLinkChange, onCancel: this.handleCancelLinkMenu, onSave: this.handleSaveLinkMenu })));
}
setupContentConverter() {
if (this.contentType === 'markdown') {
this.contentConverter = new MarkdownConverter(this.customElements, this.language);
}
else if (this.contentType === 'html') {
this.contentConverter = new HTMLConverter(this.customElements);
}
else {
throw new Error(`Unsupported content type: ${this.contentType}. Only 'markdown' and 'html' are supported.`);
}
}
async initializeTextEditor() {
this.schema = this.initializeSchema();
const initialDoc = await this.parseInitialContent();
this.menuCommandFactory = new MenuCommandFactory(this.schema);
this.view = new EditorView(this.host.shadowRoot.querySelector('#editor'), {
state: this.createEditorState(initialDoc),
dispatchTransaction: this.handleTransaction,
});
this.view.dom.addEventListener('blur', this.handleBlur);
this.view.dom.addEventListener('mousedown', this.handleMouseDown);
if (this.value) {
this.updateView(this.value);
}
// Initialize lastEmittedValue to prevent false change events
this.lastEmittedValue = this.contentConverter.serialize(this.view, this.schema);
}
initializeSchema() {
let nodes = schema.spec.nodes;
for (const customElement of this.customElements) {
const newNodeSpec = createNodeSpec(customElement);
const nodeName = customElement.tagName;
nodes = nodes.append({ [nodeName]: newNodeSpec });
}
nodes = addListNodes(nodes, 'paragraph block*', 'block');
if (this.contentType === 'html') {
nodes = nodes.append(getTableNodes());
}
nodes = nodes.append(getImageNode(this.language));
return new Schema({
nodes: nodes,
marks: schema.spec.marks.append({
strikethrough: strikethrough,
link: linkMarkSpec,
}),
});
}
async parseInitialContent() {
const initialContentElement = document.createElement('div');
if (this.value) {
initialContentElement.innerHTML =
await this.contentConverter.parseAsHTML(this.value, this.schema);
}
else {
initialContentElement.innerHTML = '<p></p>';
}
return DOMParser.fromSchema(this.schema).parse(initialContentElement);
}
createEditorState(initialDoc) {
return EditorState.create({
doc: initialDoc,
plugins: [
...exampleSetup({ schema: this.schema, menuBar: false }),
keymap(this.menuCommandFactory.buildKeymap()),
createTriggerPlugin(this.triggerCharacters, this.contentConverter),
createLinkPlugin(this.handleNewLinkSelection),
createImageInserterPlugin(this.imagePasted.emit),
createImageViewPlugin(this.language),
createMenuStateTrackingPlugin(editorMenuTypesArray, this.menuCommandFactory, this.updateActiveActionBarItems),
createActionBarInteractionPlugin(this.menuCommandFactory),
...getTableEditingPlugins(this.contentType === 'html'),
],
});
}
async updateView(content) {
this.suppressChangeEvent = true;
const html = await this.contentConverter.parseAsHTML(content, this.schema);
const prosemirrorDOMparser = DOMParser.fromSchema(this.view.state.schema);
const domParser = new window.DOMParser();
const doc = domParser.parseFromString(html, 'text/html');
const prosemirrorDoc = prosemirrorDOMparser.parse(doc.body);
const tr = this.view.state.tr;
tr.replaceWith(0, tr.doc.content.size, prosemirrorDoc.content);
this.view.dispatch(tr);
const metadata = getMetadataFromDoc(this.view.state.doc);
this.metadataEmitter(metadata);
this.suppressChangeEvent = false;
}
metadataEmitter(metadata) {
if (hasMetadataChanged(this.metadata, metadata)) {
this.removeImagesFromCache(this.metadata, metadata);
this.metadata = metadata;
this.metadataChange.emit(metadata);
}
}
removeImagesFromCache(oldMetadata, newMetadata) {
const removedImages = oldMetadata.images.filter((oldImage) => !newMetadata.images.some((newImage) => newImage.fileInfoId === oldImage.fileInfoId));
for (const image of removedImages) {
imageCache.delete(image.fileInfoId);
this.imageRemoved.emit(image);
}
}
static get is() { return "limel-prosemirror-adapter"; }
static get encapsulation() { return "shadow"; }
static get delegatesFocus() { return true; }
static get originalStyleUrls() {
return {
"$": ["prosemirror-adapter.scss"]
};
}
static get styleUrls() {
return {
"$": ["prosemirror-adapter.css"]
};
}
static get properties() {
return {
"contentType": {
"type": "string",
"mutable": false,
"complexType": {
"original": "'markdown' | 'html'",
"resolved": "\"html\" | \"markdown\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The type of content that the editor should handle and emit, defaults to `markdown`\n\nAssumed to be set only once, so not reactive to changes"
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "content-type",
"defaultValue": "'markdown'"
},
"value": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The value of the editor, expected to be markdown"
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "value"
},
"language": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Languages",
"resolved": "\"da\" | \"de\" | \"en\" | \"fi\" | \"fr\" | \"nb\" | \"nl\" | \"no\" | \"sv\"",
"references": {
"Languages": {
"location": "import",
"path": "../../date-picker/date.types",
"id": "src/components/date-picker/date.types.ts::Languages",
"referenceLocation": "Languages"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Defines the language for translations."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "language"
},
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "Set to `true` to disable the field.\nUse `disabled` to indicate that the field can normally be interacted\nwith, but is currently disabled. This tells the user that if certain\nrequirements are met, the field may become enabled again."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "disabled",
"defaultValue": "false"
},
"customElements": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "CustomElementDefinition[]",
"resolved": "CustomElementDefinition[]",
"references": {
"CustomElementDefinition": {
"location": "import",
"path": "../../../global/shared-types/custom-element.types",
"id": "src/global/shared-types/custom-element.types.ts::CustomElementDefinition",
"referenceLocation": "CustomElementDefinition"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "private",
"text": undefined
}, {
"name": "alpha",
"text": undefined
}],
"text": "set to private to avoid usage while under development"
},
"getter": false,
"setter": false,
"defaultValue": "[]"
},
"triggerCharacters": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "TriggerCharacter[]",
"resolved": "TriggerCharacter[]",
"references": {
"TriggerCharacter": {
"location": "import",
"path": "../text-editor.types",
"id": "src/components/text-editor/text-editor.types.ts::TriggerCharacter",
"referenceLocation": "TriggerCharacter"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "private",
"text": undefined
}, {
"name": "alpha",
"text": undefined
}],
"text": "set to private to avoid usage while under development"
},
"getter": false,
"setter": false,
"defaultValue": "[]"
},
"ui": {
"type": "string",
"mutable": false,
"complexType": {
"original": "EditorUiType",
"resolved": "\"minimal\" | \"no-toolbar\" | \"standard\"",
"references": {
"EditorUiType": {
"location": "import",
"path": "../types",
"id": "src/components/text-editor/types.ts::EditorUiType",
"referenceLocation": "EditorUiType"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the visual appearance of the editor."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "ui",
"defaultValue": "'standard'"
}
};
}
static get states() {
return {
"view": {},
"actionBarItems": {},
"link": {},
"isLinkMenuOpen": {}
};
}
static get events() {
return [{
"method": "change",
"name": "change",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Dispatched when a change is made to the editor"
},
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
}
}, {
"method": "imagePasted",
"name": "imagePasted",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [{
"name": "private",
"text": undefined
}, {
"name": "alpha",
"text": undefined
}],
"text": "Dispatched when a image is pasted into the editor"
},
"complexType": {
"original": "ImageInserter",
"resolved": "ImageInserter",
"references": {
"ImageInserter": {
"location": "import",
"path": "../text-editor.types",
"id": "src/components/text-editor/text-editor.types.ts::ImageInserter",
"referenceLocation": "ImageInserter"
}
}
}
}, {
"method": "imageRemoved",
"name": "imageRemoved",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [{
"name": "private",
"text": undefined
}, {
"name": "alpha",
"text": undefined
}],
"text": "Dispatched when a image is removed from the editor"
},
"complexType": {
"original": "EditorImage",
"resolved": "EditorImage",
"references": {
"EditorImage": {
"location": "import",
"path": "../text-editor.types",
"id": "src/components/text-editor/text-editor.types.ts::EditorImage",
"referenceLocation": "EditorImage"
}
}
}
}, {
"method": "metadataChange",
"name": "metadataChange",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [{
"name": "private",
"text": undefined
}, {
"name": "alpha",
"text": undefined
}],
"text": "Dispatched when the metadata of the editor changes (images and links)"
},
"complexType": {
"original": "EditorMetadata",
"resolved": "EditorMetadata",
"references": {
"EditorMetadata": {
"location": "import",
"path": "../text-editor.types",
"id": "src/components/text-editor/text-editor.types.ts::EditorMetadata",
"referenceLocation": "EditorMetadata"
}
}
}
}];
}
static get elementRef() { return "host"; }
static get watchers() {
return [{
"propName": "value",
"methodName": "watchValue"
}];
}
}