UNPKG

@limetech/lime-elements

Version:
618 lines (617 loc) • 21.7 kB
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-spec'; 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() { 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); 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.contentType = 'markdown'; this.value = undefined; this.language = undefined; this.disabled = false; this.customElements = []; this.triggerCharacters = []; this.ui = 'standard'; this.view = undefined; this.actionBarItems = []; this.link = { href: 'https://' }; this.isLinkMenuOpen = false; 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, { onFocus: this.handleFocus }, h("div", { 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); } } 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" }, "attribute": "content-type", "reflect": false, "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" }, "attribute": "value", "reflect": false }, "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" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Defines the language for translations." }, "attribute": "language", "reflect": true }, "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." }, "attribute": "disabled", "reflect": true, "defaultValue": "false" }, "customElements": { "type": "unknown", "mutable": false, "complexType": { "original": "CustomElementDefinition[]", "resolved": "CustomElementDefinition[]", "references": { "CustomElementDefinition": { "location": "import", "path": "../../../global/shared-types/custom-element.types" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "private", "text": undefined }, { "name": "alpha", "text": undefined }], "text": "set to private to avoid usage while under development" }, "defaultValue": "[]" }, "triggerCharacters": { "type": "unknown", "mutable": false, "complexType": { "original": "TriggerCharacter[]", "resolved": "TriggerCharacter[]", "references": { "TriggerCharacter": { "location": "import", "path": "../text-editor.types" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "private", "text": undefined }, { "name": "alpha", "text": undefined }], "text": "set to private to avoid usage while under development" }, "defaultValue": "[]" }, "ui": { "type": "string", "mutable": false, "complexType": { "original": "EditorUiType", "resolved": "\"minimal\" | \"no-toolbar\" | \"standard\"", "references": { "EditorUiType": { "location": "import", "path": "../types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the visual appearance of the editor." }, "attribute": "ui", "reflect": false, "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" } } } }, { "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" } } } }, { "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" } } } }]; } static get elementRef() { return "host"; } static get watchers() { return [{ "propName": "value", "methodName": "watchValue" }]; } } //# sourceMappingURL=prosemirror-adapter.js.map