() { return this.#view?.composing ?? false; } /** * The current text content of the editor. * * @type {string} */ get value() { if (!this.#view) { return this.#pendingValue; } return this.#view.state.doc.textBetween( 0, this.#view.state.doc.content.size, "\n", "\n" ); } /** * Set the text content of the editor. * * @param {string} val */ set value(val) { if (!this.#view) { this.#pendingValue = val; return; } if (val === this.value) { return; } const state = this.#view.state; const schema = state.schema; const lines = val.split("\n"); const paragraphs = lines.map(line => { const content = line ? [schema.text(line)] : []; return schema.node("paragraph", null, content); }); const doc = schema.node("doc", null, paragraphs); const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content); tr.setMeta("addToHistory", false); const cursorPos = this.#posFromTextOffset(val.length, tr.doc); // Suppress input events when updating only the text selection. this.#suppressInputEvent = true; try { this.#view.dispatch( tr.setSelection( TextSelection.between( tr.doc.resolve(cursorPos), tr.doc.resolve(cursorPos) ) ) ); } finally { this.#suppressInputEvent = false; } } /** * The start offset of the selection. * * @type {number} */ get selectionStart() { if (!this.#view) { return 0; } return this.#textOffsetFromPos(this.#view.state.selection.from); } /** * Set the start offset of the selection. * * @param {number} val */ set selectionStart(val) { this.setSelectionRange(val, this.selectionEnd ?? val); } /** * The end offset of the selection. * * @type {number} */ get selectionEnd() { if (!this.#view) { return 0; } return this.#textOffsetFromPos(this.#view.state.selection.to); } /** * Set the end offset of the selection. * * @param {number} val */ set selectionEnd(val) { this.setSelectionRange(this.selectionStart ?? 0, val); } /** * Set the selection range in the editor. * * @param {number} start * @param {number} end */ setSelectionRange(start, end) { if (!this.#view) { return; } const doc = this.#view.state.doc; const docSize = doc.content.size; const maxOffset = this.#textLength(doc); const fromOffset = Math.max(0, Math.min(start ?? 0, maxOffset)); const toOffset = Math.max(0, Math.min(end ?? fromOffset, maxOffset)); const from = Math.max( 0, Math.min(this.#posFromTextOffset(fromOffset, doc), docSize) ); const to = Math.max( 0, Math.min(this.#posFromTextOffset(toOffset, doc), docSize) ); if ( this.#view.state.selection.from === from && this.#view.state.selection.to === to ) { return; } let selection; try { selection = TextSelection.between(doc.resolve(from), doc.resolve(to)); } catch (_e) { const anchor = Math.max(0, Math.min(to, docSize)); selection = TextSelection.near(doc.resolve(anchor)); } this.#view.dispatch( this.#view.state.tr.setSelection(selection).scrollIntoView() ); this.#dispatchSelectionChange(); } /** * Select all text in the editor. */ select() { this.setSelectionRange(0, this.value.length); } /** * Focus the editor. */ focus() { this.#view?.focus(); super.focus(); } /** * Called when the element is added to the DOM. */ connectedCallback() { super.connectedCallback(); this.setAttribute("role", "presentation"); } /** * Called when the element is removed from the DOM. */ disconnectedCallback() { this.#destroyView(); this.#pendingValue = ""; super.disconnectedCallback(); } /** * Called after the element’s DOM has been rendered for the first time. */ firstUpdated() { this.#createView(); } /** * Called when the element’s properties are updated. * * @param {Map} changedProps */ updated(changedProps) { if (changedProps.has("placeholder") || changedProps.has("readOnly")) { this.#refreshView(); } } #createView() { const mount = this.renderRoot.querySelector(".multiline-editor"); if (!mount) { return; } const state = EditorState.create({ schema: MultilineEditor.schema, plugins: this.#plugins, }); this.#view = new EditorView(mount, { state, attributes: this.#viewAttributes(), editable: () => !this.readOnly, dispatchTransaction: this.#dispatchTransaction, }); if (this.#pendingValue) { this.value = this.#pendingValue; this.#pendingValue = ""; } } #destroyView() { this.#view?.destroy(); this.#view = null; } #dispatchTransaction = tr => { if (!this.#view) { return; } const prevText = this.value; const prevSelection = this.#view.state.selection; const nextState = this.#view.state.apply(tr); this.#view.updateState(nextState); const selectionChanged = tr.selectionSet && (prevSelection.from !== nextState.selection.from || prevSelection.to !== nextState.selection.to); if (selectionChanged) { this.#dispatchSelectionChange(); } if (tr.docChanged && !this.#suppressInputEvent) { const nextText = this.value; let insertedText = ""; for (const step of tr.steps) { insertedText += step.slice?.content?.textBetween( 0, step.slice.content.size, "", "" ); } this.dispatchEvent( new InputEvent("input", { bubbles: true, composed: true, data: insertedText || null, inputType: insertedText || nextText.length >= prevText.length ? "insertText" : "deleteContentBackward", }) ); } }; #dispatchSelectionChange() { this.dispatchEvent( new Event("selectionchange", { bubbles: true, composed: true }) ); } #insertParagraph(state, dispatch) { const paragraph = state.schema.nodes.paragraph; if (!paragraph) { return false; } const { $from } = state.selection; let tr = state.tr; if (!state.selection.empty) { tr = tr.deleteSelection(); } tr = tr.split(tr.mapping.map($from.pos)).scrollIntoView(); dispatch(tr); return true; } /** * Creates a plugin that shows a placeholder when the editor is empty. * * @returns {PmPlugin} */ #createPlaceholderPlugin() { return new PmPlugin({ props: { decorations: ({ doc }) => { if ( doc.childCount !== 1 || !doc.firstChild.isTextblock || doc.firstChild.content.size !== 0 || !this.placeholder ) { return null; } return DecorationSet.create(doc, [ Decoration.node(0, doc.firstChild.nodeSize, { class: "placeholder", "data-placeholder": this.placeholder, }), ]); }, }, }); } /** * Creates a plugin that removes orphaned hard breaks from empty paragraphs. * * In XHTML contexts the trailing break element in paragraphs are rendered as * uppercase (
instead of
). ProseMirror seems to have issues parsing * these breaks, which leads to orphaned breaks after deleting text content. * * @returns {PmPlugin} */ #createCleanupOrphanedBreaksPlugin() { return new PmPlugin({ appendTransaction(transactions, prevState, nextState) { if (!transactions.some(tr => tr.docChanged)) { return null; } const tr = nextState.tr; let modified = false; nextState.doc.descendants((nextNode, nextPos) => { if ( nextNode.type.name !== "paragraph" || nextNode.textContent || nextNode.childCount === 0 ) { return true; } for (let i = 0; i < nextNode.childCount; i++) { if (nextNode.child(i).type.name === "hard_break") { const prevNode = prevState.doc.nodeAt(nextPos); if (prevNode?.type.name === "paragraph" && prevNode.textContent) { tr.replaceWith( nextPos + 1, nextPos + nextNode.content.size + 1, [] ); modified = true; } break; } } return true; }); return modified ? tr : null; }, }); } #refreshView() { if (!this.#view) { return; } this.#view.setProps({ attributes: this.#viewAttributes(), editable: () => !this.readOnly, }); this.#view.dispatch(this.#view.state.tr); } #textOffsetFromPos(pos, doc = this.#view?.state.doc) { if (!doc) { return 0; } return doc.textBetween(0, pos, "\n", "\n").length; } #posFromTextOffset(offset, doc = this.#view?.state.doc) { if (!doc) { return 0; } const target = Math.max(0, Math.min(offset ?? 0, this.#textLength(doc))); let seen = 0; let pos = doc.content.size; let found = false; let paragraphCount = 0; doc.descendants((node, nodePos) => { if (found) { return false; } if (node.type.name === "paragraph") { if (paragraphCount > 0) { if (target <= seen + 1) { pos = nodePos; found = true; return false; } seen += 1; } paragraphCount++; } if (node.isText) { const textNodeLength = node.text.length; const start = nodePos; if (target <= seen + textNodeLength) { pos = start + (target - seen); found = true; return false; } seen += textNodeLength; } else if (node.type.name === "hard_break") { if (target <= seen + 1) { pos = nodePos; found = true; return false; } seen += 1; } return true; }); return pos; } #textLength(doc) { if (!doc) { return 0; } return doc.textBetween(0, doc.content.size, "\n", "\n").length; } #viewAttributes() { return { "aria-label": this.placeholder, "aria-multiline": "true", "aria-readonly": this.readOnly ? "true" : "false", role: "textbox", }; } render() { return html`
`; } } customElements.define("moz-multiline-editor", MultilineEditor); PK