UNPKG

@limetech/lime-elements

Version:
674 lines (673 loc) • 28.4 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"; 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" }]; } }