multiline-editor.mjs (13194B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { html } from "chrome://global/content/vendor/lit.all.mjs"; 6 import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; 7 import { 8 Decoration, 9 DecorationSet, 10 EditorState, 11 EditorView, 12 Plugin as PmPlugin, 13 TextSelection, 14 baseKeymap, 15 basicSchema, 16 history as historyPlugin, 17 keymap, 18 redo as historyRedo, 19 undo as historyUndo, 20 } from "chrome://browser/content/multilineeditor/prosemirror.bundle.mjs"; 21 22 /** 23 * @class MultilineEditor 24 * 25 * A ProseMirror-based multiline editor. 26 * 27 * @property {string} placeholder - Placeholder text for the editor. 28 * @property {boolean} readOnly - Whether the editor is read-only. 29 */ 30 export class MultilineEditor extends MozLitElement { 31 static shadowRootOptions = { 32 ...MozLitElement.shadowRootOptions, 33 delegatesFocus: true, 34 }; 35 36 static properties = { 37 placeholder: { type: String, reflect: true, fluent: true }, 38 readOnly: { type: Boolean, reflect: true, attribute: "readonly" }, 39 }; 40 41 static schema = basicSchema; 42 43 #pendingValue = ""; 44 #placeholderPlugin; 45 #plugins; 46 #suppressInputEvent = false; 47 #view; 48 49 constructor() { 50 super(); 51 52 this.placeholder = ""; 53 this.readOnly = false; 54 this.#placeholderPlugin = this.#createPlaceholderPlugin(); 55 const plugins = [ 56 historyPlugin(), 57 keymap({ 58 Enter: () => true, 59 "Shift-Enter": (state, dispatch) => 60 this.#insertParagraph(state, dispatch), 61 "Mod-z": historyUndo, 62 "Mod-y": historyRedo, 63 "Shift-Mod-z": historyRedo, 64 }), 65 keymap(baseKeymap), 66 this.#placeholderPlugin, 67 ]; 68 69 if (document.contentType === "application/xhtml+xml") { 70 plugins.push(this.#createCleanupOrphanedBreaksPlugin()); 71 } 72 73 this.#plugins = plugins; 74 } 75 76 /** 77 * Whether the editor is composing. 78 * 79 * @type {boolean} 80 */ 81 get composing() { 82 return this.#view?.composing ?? false; 83 } 84 85 /** 86 * The current text content of the editor. 87 * 88 * @type {string} 89 */ 90 get value() { 91 if (!this.#view) { 92 return this.#pendingValue; 93 } 94 return this.#view.state.doc.textBetween( 95 0, 96 this.#view.state.doc.content.size, 97 "\n", 98 "\n" 99 ); 100 } 101 102 /** 103 * Set the text content of the editor. 104 * 105 * @param {string} val 106 */ 107 set value(val) { 108 if (!this.#view) { 109 this.#pendingValue = val; 110 return; 111 } 112 113 if (val === this.value) { 114 return; 115 } 116 117 const state = this.#view.state; 118 const schema = state.schema; 119 const lines = val.split("\n"); 120 const paragraphs = lines.map(line => { 121 const content = line ? [schema.text(line)] : []; 122 return schema.node("paragraph", null, content); 123 }); 124 const doc = schema.node("doc", null, paragraphs); 125 126 const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content); 127 tr.setMeta("addToHistory", false); 128 129 const cursorPos = this.#posFromTextOffset(val.length, tr.doc); 130 // Suppress input events when updating only the text selection. 131 this.#suppressInputEvent = true; 132 try { 133 this.#view.dispatch( 134 tr.setSelection( 135 TextSelection.between( 136 tr.doc.resolve(cursorPos), 137 tr.doc.resolve(cursorPos) 138 ) 139 ) 140 ); 141 } finally { 142 this.#suppressInputEvent = false; 143 } 144 } 145 146 /** 147 * The start offset of the selection. 148 * 149 * @type {number} 150 */ 151 get selectionStart() { 152 if (!this.#view) { 153 return 0; 154 } 155 return this.#textOffsetFromPos(this.#view.state.selection.from); 156 } 157 158 /** 159 * Set the start offset of the selection. 160 * 161 * @param {number} val 162 */ 163 set selectionStart(val) { 164 this.setSelectionRange(val, this.selectionEnd ?? val); 165 } 166 167 /** 168 * The end offset of the selection. 169 * 170 * @type {number} 171 */ 172 get selectionEnd() { 173 if (!this.#view) { 174 return 0; 175 } 176 return this.#textOffsetFromPos(this.#view.state.selection.to); 177 } 178 179 /** 180 * Set the end offset of the selection. 181 * 182 * @param {number} val 183 */ 184 set selectionEnd(val) { 185 this.setSelectionRange(this.selectionStart ?? 0, val); 186 } 187 188 /** 189 * Set the selection range in the editor. 190 * 191 * @param {number} start 192 * @param {number} end 193 */ 194 setSelectionRange(start, end) { 195 if (!this.#view) { 196 return; 197 } 198 199 const doc = this.#view.state.doc; 200 const docSize = doc.content.size; 201 const maxOffset = this.#textLength(doc); 202 const fromOffset = Math.max(0, Math.min(start ?? 0, maxOffset)); 203 const toOffset = Math.max(0, Math.min(end ?? fromOffset, maxOffset)); 204 const from = Math.max( 205 0, 206 Math.min(this.#posFromTextOffset(fromOffset, doc), docSize) 207 ); 208 const to = Math.max( 209 0, 210 Math.min(this.#posFromTextOffset(toOffset, doc), docSize) 211 ); 212 213 if ( 214 this.#view.state.selection.from === from && 215 this.#view.state.selection.to === to 216 ) { 217 return; 218 } 219 220 let selection; 221 try { 222 selection = TextSelection.between(doc.resolve(from), doc.resolve(to)); 223 } catch (_e) { 224 const anchor = Math.max(0, Math.min(to, docSize)); 225 selection = TextSelection.near(doc.resolve(anchor)); 226 } 227 this.#view.dispatch( 228 this.#view.state.tr.setSelection(selection).scrollIntoView() 229 ); 230 this.#dispatchSelectionChange(); 231 } 232 233 /** 234 * Select all text in the editor. 235 */ 236 select() { 237 this.setSelectionRange(0, this.value.length); 238 } 239 240 /** 241 * Focus the editor. 242 */ 243 focus() { 244 this.#view?.focus(); 245 super.focus(); 246 } 247 248 /** 249 * Called when the element is added to the DOM. 250 */ 251 connectedCallback() { 252 super.connectedCallback(); 253 this.setAttribute("role", "presentation"); 254 } 255 256 /** 257 * Called when the element is removed from the DOM. 258 */ 259 disconnectedCallback() { 260 this.#destroyView(); 261 this.#pendingValue = ""; 262 super.disconnectedCallback(); 263 } 264 265 /** 266 * Called after the element’s DOM has been rendered for the first time. 267 */ 268 firstUpdated() { 269 this.#createView(); 270 } 271 272 /** 273 * Called when the element’s properties are updated. 274 * 275 * @param {Map} changedProps 276 */ 277 updated(changedProps) { 278 if (changedProps.has("placeholder") || changedProps.has("readOnly")) { 279 this.#refreshView(); 280 } 281 } 282 283 #createView() { 284 const mount = this.renderRoot.querySelector(".multiline-editor"); 285 if (!mount) { 286 return; 287 } 288 289 const state = EditorState.create({ 290 schema: MultilineEditor.schema, 291 plugins: this.#plugins, 292 }); 293 294 this.#view = new EditorView(mount, { 295 state, 296 attributes: this.#viewAttributes(), 297 editable: () => !this.readOnly, 298 dispatchTransaction: this.#dispatchTransaction, 299 }); 300 301 if (this.#pendingValue) { 302 this.value = this.#pendingValue; 303 this.#pendingValue = ""; 304 } 305 } 306 307 #destroyView() { 308 this.#view?.destroy(); 309 this.#view = null; 310 } 311 312 #dispatchTransaction = tr => { 313 if (!this.#view) { 314 return; 315 } 316 317 const prevText = this.value; 318 const prevSelection = this.#view.state.selection; 319 const nextState = this.#view.state.apply(tr); 320 this.#view.updateState(nextState); 321 322 const selectionChanged = 323 tr.selectionSet && 324 (prevSelection.from !== nextState.selection.from || 325 prevSelection.to !== nextState.selection.to); 326 327 if (selectionChanged) { 328 this.#dispatchSelectionChange(); 329 } 330 331 if (tr.docChanged && !this.#suppressInputEvent) { 332 const nextText = this.value; 333 let insertedText = ""; 334 for (const step of tr.steps) { 335 insertedText += step.slice?.content?.textBetween( 336 0, 337 step.slice.content.size, 338 "", 339 "" 340 ); 341 } 342 this.dispatchEvent( 343 new InputEvent("input", { 344 bubbles: true, 345 composed: true, 346 data: insertedText || null, 347 inputType: 348 insertedText || nextText.length >= prevText.length 349 ? "insertText" 350 : "deleteContentBackward", 351 }) 352 ); 353 } 354 }; 355 356 #dispatchSelectionChange() { 357 this.dispatchEvent( 358 new Event("selectionchange", { bubbles: true, composed: true }) 359 ); 360 } 361 362 #insertParagraph(state, dispatch) { 363 const paragraph = state.schema.nodes.paragraph; 364 if (!paragraph) { 365 return false; 366 } 367 const { $from } = state.selection; 368 let tr = state.tr; 369 if (!state.selection.empty) { 370 tr = tr.deleteSelection(); 371 } 372 tr = tr.split(tr.mapping.map($from.pos)).scrollIntoView(); 373 dispatch(tr); 374 return true; 375 } 376 377 /** 378 * Creates a plugin that shows a placeholder when the editor is empty. 379 * 380 * @returns {PmPlugin} 381 */ 382 #createPlaceholderPlugin() { 383 return new PmPlugin({ 384 props: { 385 decorations: ({ doc }) => { 386 if ( 387 doc.childCount !== 1 || 388 !doc.firstChild.isTextblock || 389 doc.firstChild.content.size !== 0 || 390 !this.placeholder 391 ) { 392 return null; 393 } 394 395 return DecorationSet.create(doc, [ 396 Decoration.node(0, doc.firstChild.nodeSize, { 397 class: "placeholder", 398 "data-placeholder": this.placeholder, 399 }), 400 ]); 401 }, 402 }, 403 }); 404 } 405 406 /** 407 * Creates a plugin that removes orphaned hard breaks from empty paragraphs. 408 * 409 * In XHTML contexts the trailing break element in paragraphs are rendered as 410 * uppercase (<BR> instead of <br>). ProseMirror seems to have issues parsing 411 * these breaks, which leads to orphaned breaks after deleting text content. 412 * 413 * @returns {PmPlugin} 414 */ 415 #createCleanupOrphanedBreaksPlugin() { 416 return new PmPlugin({ 417 appendTransaction(transactions, prevState, nextState) { 418 if (!transactions.some(tr => tr.docChanged)) { 419 return null; 420 } 421 422 const tr = nextState.tr; 423 let modified = false; 424 425 nextState.doc.descendants((nextNode, nextPos) => { 426 if ( 427 nextNode.type.name !== "paragraph" || 428 nextNode.textContent || 429 nextNode.childCount === 0 430 ) { 431 return true; 432 } 433 434 for (let i = 0; i < nextNode.childCount; i++) { 435 if (nextNode.child(i).type.name === "hard_break") { 436 const prevNode = prevState.doc.nodeAt(nextPos); 437 if (prevNode?.type.name === "paragraph" && prevNode.textContent) { 438 tr.replaceWith( 439 nextPos + 1, 440 nextPos + nextNode.content.size + 1, 441 [] 442 ); 443 modified = true; 444 } 445 break; 446 } 447 } 448 449 return true; 450 }); 451 452 return modified ? tr : null; 453 }, 454 }); 455 } 456 457 #refreshView() { 458 if (!this.#view) { 459 return; 460 } 461 462 this.#view.setProps({ 463 attributes: this.#viewAttributes(), 464 editable: () => !this.readOnly, 465 }); 466 this.#view.dispatch(this.#view.state.tr); 467 } 468 469 #textOffsetFromPos(pos, doc = this.#view?.state.doc) { 470 if (!doc) { 471 return 0; 472 } 473 return doc.textBetween(0, pos, "\n", "\n").length; 474 } 475 476 #posFromTextOffset(offset, doc = this.#view?.state.doc) { 477 if (!doc) { 478 return 0; 479 } 480 const target = Math.max(0, Math.min(offset ?? 0, this.#textLength(doc))); 481 let seen = 0; 482 let pos = doc.content.size; 483 let found = false; 484 let paragraphCount = 0; 485 doc.descendants((node, nodePos) => { 486 if (found) { 487 return false; 488 } 489 if (node.type.name === "paragraph") { 490 if (paragraphCount > 0) { 491 if (target <= seen + 1) { 492 pos = nodePos; 493 found = true; 494 return false; 495 } 496 seen += 1; 497 } 498 paragraphCount++; 499 } 500 if (node.isText) { 501 const textNodeLength = node.text.length; 502 const start = nodePos; 503 if (target <= seen + textNodeLength) { 504 pos = start + (target - seen); 505 found = true; 506 return false; 507 } 508 seen += textNodeLength; 509 } else if (node.type.name === "hard_break") { 510 if (target <= seen + 1) { 511 pos = nodePos; 512 found = true; 513 return false; 514 } 515 seen += 1; 516 } 517 return true; 518 }); 519 return pos; 520 } 521 522 #textLength(doc) { 523 if (!doc) { 524 return 0; 525 } 526 return doc.textBetween(0, doc.content.size, "\n", "\n").length; 527 } 528 529 #viewAttributes() { 530 return { 531 "aria-label": this.placeholder, 532 "aria-multiline": "true", 533 "aria-readonly": this.readOnly ? "true" : "false", 534 role: "textbox", 535 }; 536 } 537 538 render() { 539 return html` 540 <link 541 rel="stylesheet" 542 href="chrome://browser/content/multilineeditor/prosemirror.css" 543 /> 544 <link 545 rel="stylesheet" 546 href="chrome://browser/content/multilineeditor/multiline-editor.css" 547 /> 548 <div class="multiline-editor"></div> 549 `; 550 } 551 } 552 553 customElements.define("moz-multiline-editor", MultilineEditor);