1/* 2 * editor.ts 3 * 4 * Copyright (C) 2021 by RStudio, PBC 5 * 6 * Unless you have received this program directly from RStudio pursuant 7 * to the terms of a commercial license agreement with RStudio, then 8 * this program is licensed to you under the terms of version 3 of the 9 * GNU Affero General Public License. This program is distributed WITHOUT 10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, 11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the 12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. 13 * 14 */ 15 16import { inputRules } from 'prosemirror-inputrules'; 17import { keydownHandler } from 'prosemirror-keymap'; 18import { Node as ProsemirrorNode, Schema, DOMParser, ParseOptions } from 'prosemirror-model'; 19import { EditorState, Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state'; 20import { EditorView } from 'prosemirror-view'; 21import 'prosemirror-view/style/prosemirror.css'; 22 23import { setTextSelection } from 'prosemirror-utils'; 24 25import { citeUI } from '../api/cite'; 26import { EditorOptions } from '../api/options'; 27import { ProsemirrorCommand, CommandFn, EditorCommand } from '../api/command'; 28import { EditorUI } from '../api/ui'; 29import { 30 attrPropsToInput, 31 attrInputToProps, 32 AttrProps, 33 AttrEditInput, 34 InsertCiteProps, 35 InsertCiteUI, 36} from '../api/ui-dialogs'; 37 38import { Extension } from '../api/extension'; 39import { PandocWriterOptions } from '../api/pandoc'; 40import { PandocCapabilities, getPandocCapabilities } from '../api/pandoc_capabilities'; 41import { fragmentToHTML } from '../api/html'; 42import { DOMEditorEvents, EventType, EventHandler } from '../api/events'; 43import { 44 ScrollEvent, 45 UpdateEvent, 46 OutlineChangeEvent, 47 StateChangeEvent, 48 ResizeEvent, 49 LayoutEvent, 50 FocusEvent, 51 DispatchEvent, 52 NavigateEvent, 53 BlurEvent, 54} from '../api/event-types'; 55import { 56 PandocFormat, 57 resolvePandocFormat, 58 PandocFormatConfig, 59 pandocFormatConfigFromCode, 60 pandocFormatConfigFromDoc, 61} from '../api/pandoc_format'; 62import { baseKeysPlugin } from '../api/basekeys'; 63import { 64 appendTransactionsPlugin, 65 appendMarkTransactionsPlugin, 66 kFixupTransaction, 67 kAddToHistoryTransaction, 68 kSetMarkdownTransaction, 69} from '../api/transaction'; 70import { EditorOutline, getOutlineNodes, EditingOutlineLocation, getEditingOutlineLocation } from '../api/outline'; 71import { EditingLocation, getEditingLocation, setEditingLocation } from '../api/location'; 72import { navigateTo, NavigationType } from '../api/navigation'; 73import { FixupContext } from '../api/fixup'; 74import { unitToPixels, pixelsToUnit, roundUnit, kValidUnits } from '../api/image'; 75import { kPercentUnit } from '../api/css'; 76import { EditorFormat } from '../api/format'; 77import { diffChars, EditorChange } from '../api/change'; 78import { markInputRuleFilter } from '../api/input_rule'; 79import { editorMath } from '../api/math'; 80import { EditorEvents } from '../api/events'; 81import { insertRmdChunk } from '../api/rmd'; 82import { EditorServer } from '../api/server'; 83import { pandocAutoIdentifier } from '../api/pandoc_id'; 84import { wrapSentences } from '../api/wrap'; 85import { yamlFrontMatter, applyYamlFrontMatter } from '../api/yaml'; 86import { EditorSpellingDoc } from '../api/spelling'; 87 88import { getTitle, setTitle } from '../nodes/yaml_metadata/yaml_metadata-title'; 89import { getOutline } from '../behaviors/outline'; 90import { 91 FindOptions, 92 find, 93 matchCount, 94 selectFirst, 95 selectNext, 96 selectPrevious, 97 replace, 98 replaceAll, 99 clear, 100 selectCurrent, 101} from '../behaviors/find'; 102 103import { omniInsertExtension } from '../behaviors/omni_insert/omni_insert'; 104import { completionExtension } from '../behaviors/completion/completion'; 105 106import { getSpellingDoc } from '../behaviors/spelling/spelling-interactive'; 107import { realtimeSpellingPlugin, invalidateAllWords, invalidateWord } from '../behaviors/spelling/spelling-realtime'; 108 109import { PandocConverter, PandocLineWrapping } from '../pandoc/pandoc_converter'; 110 111import { ExtensionManager, initExtensions } from './editor-extensions'; 112import { defaultTheme, EditorTheme, applyTheme, applyPadding } from './editor-theme'; 113import { defaultEditorUIImages } from './editor-images'; 114import { editorMenus, EditorMenus } from './editor-menus'; 115import { editorSchema } from './editor-schema'; 116 117// import styles before extensions so they can be overriden by extensions 118import './styles/frame.css'; 119import './styles/styles.css'; 120 121export interface EditorCode { 122 code: string; 123 selection_only: boolean; 124 location: EditingOutlineLocation; 125} 126 127export interface EditorSetMarkdownResult { 128 // editor view of markdown (as it will be persisted) 129 canonical: string; 130 131 // line wrapping 132 line_wrapping: PandocLineWrapping; 133 134 // unrecoginized pandoc tokens 135 unrecognized: string[]; 136 137 // example lists? 138 example_lists: boolean; 139 140 // unparsed meta 141 unparsed_meta: { [key: string]: any }; 142 143 // updated outline 144 location: EditingOutlineLocation; 145} 146 147export interface EditorContext { 148 readonly server: EditorServer; 149 readonly ui: EditorUI; 150 readonly hooks?: EditorHooks; 151 readonly extensions?: readonly Extension[]; 152} 153 154export interface EditorHooks { 155 isEditable?: () => boolean; 156} 157 158export interface EditorKeybindings { 159 [id: string]: string[]; 160} 161 162export interface EditorSelection { 163 from: number; 164 to: number; 165 navigation_id: string | null; 166} 167 168export interface EditorFindReplace { 169 find: (term: string, options: FindOptions) => boolean; 170 matches: () => number; 171 selectFirst: () => boolean; 172 selectCurrent: () => boolean; 173 selectNext: () => boolean; 174 selectPrevious: () => boolean; 175 replace: (text: string) => boolean; 176 replaceAll: (text: string) => number; 177 clear: () => boolean; 178} 179 180export { EditorCommandId as EditorCommands } from '../api/command'; 181 182export interface UIToolsAttr { 183 propsToInput(attr: AttrProps): AttrEditInput; 184 inputToProps(input: AttrEditInput): AttrProps; 185 pandocAutoIdentifier(text: string): string; 186} 187 188export interface UIToolsImage { 189 validUnits(): string[]; 190 percentUnit(): string; 191 unitToPixels(value: number, unit: string, containerWidth: number): number; 192 pixelsToUnit(pixels: number, unit: string, containerWidth: number): number; 193 roundUnit(value: number, unit: string): string; 194} 195 196export interface UIToolsFormat { 197 parseFormatConfig(markdown: string, isRmd: boolean): PandocFormatConfig; 198} 199 200export interface UIToolsSource { 201 diffChars(from: string, to: string, timeout: number): EditorChange[]; 202} 203 204export interface UIToolsCitation { 205 citeUI(citeProps: InsertCiteProps): InsertCiteUI; 206} 207 208export class UITools { 209 public readonly attr: UIToolsAttr; 210 public readonly image: UIToolsImage; 211 public readonly format: UIToolsFormat; 212 public readonly source: UIToolsSource; 213 public readonly citation: UIToolsCitation; 214 215 constructor() { 216 this.attr = { 217 propsToInput: attrPropsToInput, 218 inputToProps: attrInputToProps, 219 pandocAutoIdentifier: (text: string) => pandocAutoIdentifier(text, false), 220 }; 221 222 this.image = { 223 validUnits: () => kValidUnits, 224 percentUnit: () => kPercentUnit, 225 unitToPixels, 226 pixelsToUnit, 227 roundUnit, 228 }; 229 230 this.format = { 231 parseFormatConfig: pandocFormatConfigFromCode, 232 }; 233 234 this.source = { 235 diffChars, 236 }; 237 238 this.citation = { 239 citeUI, 240 }; 241 } 242} 243 244const keybindingsPlugin = new PluginKey('keybindings'); 245 246export class Editor { 247 // core context passed from client 248 private readonly parent: HTMLElement; 249 private readonly context: EditorContext; 250 private readonly events: EditorEvents; 251 252 // options (derived from defaults + config) 253 private readonly options: EditorOptions; 254 255 // format (pandocFormat includes additional diagnostics based on the validity of 256 // provided mode + extensions) 257 private readonly format: EditorFormat; 258 private readonly pandocFormat: PandocFormat; 259 260 // pandoc capabilities 261 private pandocCapabilities: PandocCapabilities; 262 263 // core prosemirror state/behaviors 264 private readonly extensions: ExtensionManager; 265 private readonly schema: Schema; 266 private state: EditorState; 267 private readonly view: EditorView; 268 private readonly pandocConverter: PandocConverter; 269 270 // setting via setKeybindings forces reconfiguration of EditorState 271 // with plugins recreated 272 private keybindings: EditorKeybindings; 273 274 // content width constraints (if unset uses default editor CSS) 275 private maxContentWidth = 0; 276 private minContentPadding = 0; 277 278 // keep track of whether the last transaction was selection-only 279 private lastTrSelectionOnly = false; 280 281 // create the editor -- note that the markdown argument does not substitute for calling 282 // setMarkdown, rather it's used to read the format comment to determine how to 283 // initialize the various editor features 284 public static async create( 285 parent: HTMLElement, 286 context: EditorContext, 287 format: EditorFormat, 288 options: EditorOptions, 289 ): Promise<Editor> { 290 // provide option defaults 291 options = { 292 autoFocus: false, 293 spellCheck: false, 294 codeEditor: 'codemirror', 295 rmdImagePreview: false, 296 hideFormatComment: false, 297 className: '', 298 ...options, 299 }; 300 301 // provide format defaults 302 format = { 303 pandocMode: format.pandocMode || 'markdown', 304 pandocExtensions: format.pandocExtensions || '', 305 rmdExtensions: { 306 codeChunks: false, 307 bookdownXRef: false, 308 bookdownXRefUI: false, 309 bookdownPart: false, 310 blogdownMathInCode: false, 311 ...format.rmdExtensions, 312 }, 313 hugoExtensions: { 314 shortcodes: false, 315 ...format.hugoExtensions, 316 }, 317 docTypes: format.docTypes || [], 318 }; 319 320 // provide context defaults 321 const defaultImages = defaultEditorUIImages(); 322 context = { 323 ...context, 324 ui: { 325 ...context.ui, 326 images: { 327 ...defaultImages, 328 ...context.ui.images, 329 omni_insert: { 330 ...defaultImages.omni_insert, 331 ...context.ui.images, 332 }, 333 citations: { 334 ...defaultImages.citations, 335 ...context.ui.images, 336 }, 337 }, 338 }, 339 }; 340 341 // resolve the format 342 const pandocFmt = await resolvePandocFormat(context.server.pandoc, format); 343 344 // get pandoc capabilities 345 const pandocCapabilities = await getPandocCapabilities(context.server.pandoc); 346 347 // create editor 348 const editor = new Editor(parent, context, options, format, pandocFmt, pandocCapabilities); 349 350 // return editor 351 return Promise.resolve(editor); 352 } 353 354 private constructor( 355 parent: HTMLElement, 356 context: EditorContext, 357 options: EditorOptions, 358 format: EditorFormat, 359 pandocFormat: PandocFormat, 360 pandocCapabilities: PandocCapabilities, 361 ) { 362 // initialize references 363 this.parent = parent; 364 this.events = new DOMEditorEvents(parent); 365 this.context = context; 366 this.options = options; 367 this.format = format; 368 this.keybindings = {}; 369 this.pandocFormat = pandocFormat; 370 this.pandocCapabilities = pandocCapabilities; 371 372 // create core extensions 373 this.extensions = this.initExtensions(); 374 375 // create schema 376 this.schema = editorSchema(this.extensions); 377 378 // register completion handlers (done in a separate step b/c omni insert 379 // completion handlers require access to the initializezd commands that 380 // carry omni insert info) 381 this.registerCompletionExtension(); 382 383 // register realtime spellchecking (done in a separate step b/c it 384 // requires access to PandocMark definitions to determine which 385 // marks to exclude from spellchecking) 386 this.registerRealtimeSpelling(); 387 388 // create state 389 this.state = EditorState.create({ 390 schema: this.schema, 391 doc: this.initialDoc(), 392 plugins: this.createPlugins(), 393 }); 394 395 // additional dom attributes for editor node 396 const attributes: { [name: string]: string } = {}; 397 if (options.className) { 398 attributes.class = options.className; 399 } 400 401 // create view 402 this.view = new EditorView(this.parent, { 403 state: this.state, 404 dispatchTransaction: this.dispatchTransaction.bind(this), 405 domParser: new EditorDOMParser(this.schema), 406 attributes, 407 }); 408 409 // add custom restoreFocus handler to the view -- this provides a custom 410 // handler for RStudio's FocusContext, necessary because the default 411 // ProseMirror dom mutation handler picks up the focus and changes the 412 // selection. 413 Object.defineProperty(this.view.dom, 'restoreFocus', { 414 value: () => { 415 this.focus(); 416 }, 417 }); 418 419 // add proportinal font class to parent 420 this.parent.classList.add('pm-proportional-font'); 421 422 // apply default theme 423 this.applyTheme(defaultTheme()); 424 425 // create pandoc translator 426 this.pandocConverter = new PandocConverter( 427 this.schema, 428 this.extensions, 429 context.server.pandoc, 430 this.pandocCapabilities, 431 ); 432 433 // focus editor immediately if requested 434 if (this.options.autoFocus) { 435 setTimeout(() => { 436 this.focus(); 437 }, 10); 438 } 439 440 // disale spellcheck if requested 441 if (!this.options.spellCheck) { 442 this.parent.setAttribute('spellcheck', 'false'); 443 } 444 445 { 446 // scroll event optimization, as recommended by 447 // https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event 448 let ticking = false; 449 this.parent.addEventListener( 450 'scroll', 451 () => { 452 if (!ticking) { 453 window.requestAnimationFrame(() => { 454 this.emitEvent(ScrollEvent); 455 ticking = false; 456 }); 457 ticking = true; 458 } 459 }, 460 { capture: true }, 461 ); 462 } 463 } 464 465 public destroy() { 466 this.view.destroy(); 467 } 468 469 public subscribe<TDetail>(event: EventType<TDetail> | string, handler: EventHandler<TDetail>): VoidFunction { 470 if (typeof event === 'string') { 471 return this.events.subscribe({ eventName: event }, handler); 472 } else { 473 return this.events.subscribe(event, handler); 474 } 475 } 476 477 public setTitle(title: string) { 478 const tr = setTitle(this.state, title); 479 if (tr) { 480 this.view.dispatch(tr); 481 } 482 } 483 484 public async setMarkdown( 485 markdown: string, 486 options: PandocWriterOptions, 487 emitUpdate: boolean, 488 ): Promise<EditorSetMarkdownResult> { 489 // get the result 490 const result = await this.pandocConverter.toProsemirror(markdown, this.pandocFormat); 491 const { doc, line_wrapping, unrecognized, example_lists, unparsed_meta } = result; 492 493 // if we are preserving history but the existing doc is empty then create a new state 494 // (resets the undo stack so that the initial setting of the document can't be undone) 495 if (this.isInitialDoc()) { 496 this.state = EditorState.create({ 497 schema: this.state.schema, 498 doc, 499 plugins: this.state.plugins, 500 }); 501 this.view.updateState(this.state); 502 } else { 503 // note current editing location 504 const loc = this.getEditingLocation(); 505 506 // replace the top level nodes in the doc 507 const tr = this.state.tr; 508 tr.setMeta(kSetMarkdownTransaction, true); 509 let i = 0; 510 tr.doc.descendants((node, pos) => { 511 const mappedPos = tr.mapping.map(pos); 512 tr.replaceRangeWith(mappedPos, mappedPos + node.nodeSize, doc.child(i)); 513 i++; 514 return false; 515 }); 516 // set selection to previous location if it's still valid 517 if (loc.pos < tr.doc.nodeSize) { 518 // eat exceptions that might result from an invalid position 519 try { 520 setTextSelection(loc.pos)(tr); 521 } catch (e) { 522 // do-nothing, this error can happen and shouldn't result in 523 // a failure to setMarkdown 524 } 525 } 526 // dispatch 527 this.view.dispatch(tr); 528 } 529 530 // apply fixups 531 this.applyFixups(FixupContext.Load); 532 533 // notify listeners if requested 534 if (emitUpdate) { 535 this.emitEvent(UpdateEvent); 536 this.emitEvent(OutlineChangeEvent); 537 this.emitEvent(StateChangeEvent); 538 } 539 540 // return our current markdown representation (so the caller know what our 541 // current 'view' of the doc as markdown looks like 542 const getMarkdownTr = this.state.tr; 543 const canonical = await this.getMarkdownCode(getMarkdownTr, options); 544 const location = getEditingOutlineLocation(this.state); 545 546 // return 547 return { 548 canonical, 549 line_wrapping, 550 unrecognized, 551 example_lists, 552 unparsed_meta, 553 location 554 }; 555 } 556 557 // flag indicating whether we've ever had setMarkdown (currently we need this 558 // because getMarkdown can only be called after setMarkdown b/c it needs 559 // the API version retreived in setMarkdown -- we should remedy this) 560 public isInitialDoc() { 561 return this.state.doc.attrs.initial; 562 } 563 564 public async getMarkdown(options: PandocWriterOptions): Promise<EditorCode> { 565 // get the code 566 const tr = this.state.tr; 567 const code = await this.getMarkdownCode(tr, options); 568 569 // return code + perhaps outline location 570 return { 571 code, 572 selection_only: this.lastTrSelectionOnly, 573 location: getEditingOutlineLocation(this.state), 574 }; 575 } 576 577 public getEditingOutlineLocation(): EditingOutlineLocation { 578 return getEditingOutlineLocation(this.state); 579 } 580 581 public getHTML(): string { 582 return fragmentToHTML(this.state.schema, this.state.doc.content); 583 } 584 585 public getTitle() { 586 return getTitle(this.state); 587 } 588 589 public getSelection(): EditorSelection { 590 const { from, to } = this.state.selection; 591 return { 592 from, 593 to, 594 navigation_id: navigationIdForSelection(this.state), 595 }; 596 } 597 598 public getEditingLocation(): EditingLocation { 599 return getEditingLocation(this.view); 600 } 601 602 public setEditingLocation(outlineLocation?: EditingOutlineLocation, previousLocation?: EditingLocation) { 603 setEditingLocation(this.view, outlineLocation, previousLocation); 604 } 605 606 public getOutline(): EditorOutline { 607 return getOutline(this.state) || []; 608 } 609 610 public getFindReplace(): EditorFindReplace { 611 return { 612 find: (term: string, options: FindOptions) => find(this.view, term, options), 613 matches: () => matchCount(this.view), 614 selectCurrent: () => selectCurrent(this.view), 615 selectFirst: () => selectFirst(this.view), 616 selectNext: () => selectNext(this.view), 617 selectPrevious: () => selectPrevious(this.view), 618 replace: (text: string) => replace(this.view, text), 619 replaceAll: (text: string) => replaceAll(this.view, text), 620 clear: () => clear(this.view), 621 }; 622 } 623 624 public getSpellingDoc(): EditorSpellingDoc { 625 return getSpellingDoc(this.view, this.extensions.pandocMarks(), this.context.ui.spelling); 626 } 627 628 public spellingInvalidateAllWords() { 629 invalidateAllWords(this.view); 630 } 631 632 public spellingInvalidateWord(word: string) { 633 invalidateWord(this.view, word); 634 } 635 636 // get a canonical version of the passed markdown. this method doesn't mutate the 637 // visual editor's state/view (it's provided as a performance optimiation for when 638 // source mode is configured to save a canonical version of markdown) 639 public async getCanonical(markdown: string, options: PandocWriterOptions): Promise<string> { 640 // convert to prosemirror doc 641 const result = await this.pandocConverter.toProsemirror(markdown, this.pandocFormat); 642 643 // create a state for this doc 644 const state = EditorState.create({ 645 schema: this.schema, 646 doc: result.doc, 647 plugins: this.state.plugins, 648 }); 649 650 // apply load fixups (eumlating what a full round trip will do) 651 const tr = state.tr; 652 this.extensionFixups(tr, FixupContext.Load); 653 654 // return markdown (will apply save fixups) 655 return this.getMarkdownCode(tr, options); 656 } 657 658 public getSelectedText(): string { 659 return this.state.doc.textBetween(this.state.selection.from, this.state.selection.to); 660 } 661 662 public replaceSelection(value: string): void { 663 // retrieve properties we need from selection 664 const { from, empty } = this.view.state.selection; 665 666 // retrieve selection marks 667 const marks = this.view.state.selection.$from.marks(); 668 669 // insert text 670 const tr = this.view.state.tr.replaceSelectionWith(this.view.state.schema.text(value, marks), false); 671 this.view.dispatch(tr); 672 673 // update selection if necessary 674 if (!empty) { 675 const sel = TextSelection.create(this.view.state.doc, from, from + value.length); 676 const trSetSel = this.view.state.tr.setSelection(sel); 677 this.view.dispatch(trSetSel); 678 } 679 } 680 681 public getYamlFrontMatter() { 682 if (this.schema.nodes.yaml_metadata) { 683 return yamlFrontMatter(this.view.state.doc); 684 } else { 685 return ''; 686 } 687 } 688 689 public applyYamlFrontMatter(yaml: string) { 690 if (this.schema.nodes.yaml_metadata) { 691 applyYamlFrontMatter(this.view, yaml); 692 } 693 } 694 695 public focus() { 696 this.view.focus(); 697 } 698 699 public blur() { 700 (this.view.dom as HTMLElement).blur(); 701 } 702 703 public insertChunk(chunkPlaceholder: string, rowOffset: number, colOffset: number) { 704 const insertCmd = insertRmdChunk(chunkPlaceholder, rowOffset, colOffset); 705 insertCmd(this.view.state, this.view.dispatch, this.view); 706 this.focus(); 707 } 708 709 public navigate(type: NavigationType, location: string, recordCurrent = true, animate = false) { 710 // perform navigation 711 const nav = navigateTo(this.view, this.format, type, location, animate); 712 713 // emit event 714 if (nav !== null) { 715 if (!recordCurrent) { 716 nav.prevPos = -1; 717 } 718 this.emitEvent(NavigateEvent, nav); 719 } 720 } 721 722 public resize() { 723 this.syncContentWidth(); 724 this.applyFixupsOnResize(); 725 this.emitEvent(ResizeEvent); 726 } 727 728 public enableDevTools(initFn: (view: EditorView, stateClass: any) => void) { 729 initFn(this.view, { EditorState }); 730 } 731 732 public getMenus(): EditorMenus { 733 return editorMenus(this.context.ui, this.commands()); 734 } 735 736 public commands(): EditorCommand[] { 737 // get keybindings (merge user + default) 738 const commandKeys = this.commandKeys(); 739 740 return this.extensions.commands(this.schema).map((command: ProsemirrorCommand) => { 741 return { 742 id: command.id, 743 keymap: commandKeys[command.id], 744 isActive: () => command.isActive(this.state), 745 isEnabled: () => command.isEnabled(this.state), 746 plural: () => command.plural(this.state), 747 execute: () => { 748 command.execute(this.state, this.view.dispatch, this.view); 749 if (command.keepFocus) { 750 this.focus(); 751 } 752 }, 753 }; 754 }); 755 } 756 757 public applyTheme(theme: EditorTheme) { 758 // set global mode classes 759 this.parent.classList.toggle('pm-dark-mode', !!theme.darkMode); 760 this.parent.classList.toggle('pm-solarized-mode', !!theme.solarizedMode); 761 // apply the rest of the theme 762 applyTheme(theme); 763 } 764 765 public setMaxContentWidth(maxWidth: number, minPadding = 10) { 766 this.maxContentWidth = maxWidth; 767 this.minContentPadding = minPadding; 768 this.syncContentWidth(); 769 } 770 771 public setKeybindings(keyBindings: EditorKeybindings) { 772 // validate that all of these keys can be rebound 773 774 this.keybindings = keyBindings; 775 this.state = this.state.reconfigure({ 776 schema: this.state.schema, 777 plugins: this.createPlugins(), 778 }); 779 } 780 781 public getEditorFormat() { 782 return this.format; 783 } 784 785 public getPandocFormat() { 786 return this.pandocFormat; 787 } 788 789 public getPandocFormatConfig(isRmd: boolean): PandocFormatConfig { 790 return pandocFormatConfigFromDoc(this.state.doc, isRmd); 791 } 792 793 private dispatchTransaction(tr: Transaction) { 794 // track previous outline 795 const previousOutline = getOutline(this.state); 796 797 // track whether this was a selection-only transaction 798 this.lastTrSelectionOnly = tr.selectionSet && !tr.docChanged; 799 800 // apply the transaction 801 this.state = this.state.apply(tr); 802 this.view.updateState(this.state); 803 804 // notify listeners of state change 805 this.emitEvent(StateChangeEvent); 806 807 // notify listeners of updates 808 if (tr.docChanged || tr.storedMarksSet) { 809 // fire updated (unless this was a fixup) 810 if (!tr.getMeta(kFixupTransaction)) { 811 this.emitEvent(UpdateEvent); 812 } 813 814 // fire outline changed if necessary 815 if (getOutline(this.state) !== previousOutline) { 816 this.emitEvent(OutlineChangeEvent); 817 } 818 } 819 820 this.emitEvent(DispatchEvent, tr); 821 822 this.emitEvent(LayoutEvent); 823 } 824 825 private emitEvent<TDetail>(eventType: EventType<TDetail>, detail?: TDetail) { 826 this.events.emit(eventType, detail); 827 } 828 829 private initExtensions() { 830 return initExtensions( 831 { 832 format: this.format, 833 options: this.options, 834 ui: this.context.ui, 835 math: this.context.ui.math.typeset ? editorMath(this.context.ui) : undefined, 836 events: { 837 subscribe: this.subscribe.bind(this), 838 emit: this.emitEvent.bind(this), 839 }, 840 pandocExtensions: this.pandocFormat.extensions, 841 pandocCapabilities: this.pandocCapabilities, 842 server: this.context.server, 843 navigation: { 844 navigate: this.navigate.bind(this), 845 }, 846 }, 847 this.context.extensions, 848 ); 849 } 850 851 private registerCompletionExtension() { 852 // mark filter used to screen completions from noInputRules marks 853 const markFilter = markInputRuleFilter(this.schema, this.extensions.pandocMarks()); 854 855 // register omni insert extension 856 this.extensions.register([ 857 omniInsertExtension(this.extensions.omniInserters(this.schema), markFilter, this.context.ui), 858 ]); 859 860 // register completion extension 861 this.extensions.register([ 862 completionExtension(this.extensions.completionHandlers(), markFilter, this.context.ui, this.events), 863 ]); 864 } 865 866 private registerRealtimeSpelling() { 867 this.extensions.registerPlugins( 868 [realtimeSpellingPlugin(this.schema, this.extensions.pandocMarks(), this.context.ui, this.events)], 869 true, 870 ); 871 } 872 873 private createPlugins(): Plugin[] { 874 return [ 875 baseKeysPlugin(this.extensions.baseKeys(this.schema)), 876 this.keybindingsPlugin(), 877 appendTransactionsPlugin(this.extensions.appendTransactions(this.schema)), 878 appendMarkTransactionsPlugin(this.extensions.appendMarkTransactions(this.schema)), 879 ...this.extensions.plugins(this.schema), 880 this.inputRulesPlugin(), 881 this.editablePlugin(), 882 this.domEventsPlugin(), 883 ]; 884 } 885 886 private editablePlugin() { 887 const hooks = this.context.hooks || {}; 888 return new Plugin({ 889 key: new PluginKey('editable'), 890 props: { 891 editable: hooks.isEditable || (() => true), 892 }, 893 }); 894 } 895 896 private inputRulesPlugin() { 897 // filter for disabling input rules for selected marks 898 const markFilter = markInputRuleFilter(this.schema, this.extensions.pandocMarks()); 899 900 // create the defautl inputRules plugin 901 const plugin = inputRules({ rules: this.extensions.inputRules(this.schema) }); 902 const handleTextInput = plugin.props.handleTextInput!; 903 904 // override to disable input rules as requested 905 // https://github.com/ProseMirror/prosemirror-inputrules/commit/b4bf67623aa4c4c1e096c20aa649c0e63751f337 906 plugin.props.handleTextInput = (view: EditorView, from: number, to: number, text: string) => { 907 if (!markFilter(view.state)) { 908 return false; 909 } 910 return handleTextInput(view, from, to, text); 911 }; 912 return plugin; 913 } 914 915 private domEventsPlugin(): Plugin { 916 return new Plugin({ 917 key: new PluginKey('domevents'), 918 props: { 919 handleDOMEvents: { 920 blur: (view: EditorView, event: Event) => { 921 this.emitEvent(BlurEvent); 922 return false; 923 }, 924 focus: (view: EditorView, event: Event) => { 925 this.emitEvent(FocusEvent, view.state.doc); 926 return false; 927 }, 928 keydown: (view: EditorView, event: Event) => { 929 const kbEvent = event as KeyboardEvent; 930 if (kbEvent.key === 'Tab' && this.context.ui.prefs.tabKeyMoveFocus()) { 931 return true; 932 } else { 933 return false; 934 } 935 }, 936 }, 937 }, 938 }); 939 } 940 941 private keybindingsPlugin(): Plugin { 942 // get keybindings (merge user + default) 943 const commandKeys = this.commandKeys(); 944 945 // command keys from extensions 946 const pluginKeys: { [key: string]: CommandFn } = {}; 947 const commands = this.extensions.commands(this.schema); 948 commands.forEach((command: ProsemirrorCommand) => { 949 const keys = commandKeys[command.id]; 950 if (keys) { 951 keys.forEach((key: string) => { 952 pluginKeys[key] = command.execute; 953 }); 954 } 955 }); 956 957 // for windows desktop, build a list of control key handlers b/c qtwebengine 958 // ends up corrupting ctrl+ keys so they don't hit the ace keybinding 959 // (see: https://github.com/rstudio/rstudio/issues/7142) 960 const ctrlKeyCodes: { [key: string]: CommandFn } = {}; 961 Object.keys(pluginKeys).forEach(keyCombo => { 962 const match = keyCombo.match(/^Mod-([a-z\\])$/); 963 if (match) { 964 const key = match[1]; 965 const keyCode = key === '\\' ? 'Backslash' : `Key${key.toUpperCase()}`; 966 ctrlKeyCodes[keyCode] = pluginKeys[keyCombo]; 967 } 968 }); 969 970 // create default prosemirror handler 971 const prosemirrorKeydownHandler = keydownHandler(pluginKeys); 972 973 // return plugin 974 return new Plugin({ 975 key: keybindingsPlugin, 976 props: { 977 handleKeyDown: (view: EditorView, event: KeyboardEvent) => { 978 // workaround for Ctrl+ keys on windows desktop 979 if (this.context.ui.context.isWindowsDesktop()) { 980 const keyEvent = event as KeyboardEvent; 981 if (keyEvent.ctrlKey) { 982 const keyCommand = ctrlKeyCodes[keyEvent.code]; 983 if (keyCommand && keyCommand(this.view.state)) { 984 keyCommand(this.view.state, this.view.dispatch, this.view); 985 return true; 986 } 987 } 988 } 989 // default handling 990 return prosemirrorKeydownHandler(view, event); 991 }, 992 }, 993 }); 994 } 995 996 private commandKeys(): { [key: string]: readonly string[] } { 997 // start with keys provided within command definitions 998 const commands = this.extensions.commands(this.schema); 999 const defaultKeys = commands.reduce((keys: { [key: string]: readonly string[] }, command: ProsemirrorCommand) => { 1000 keys[command.id] = command.keymap; 1001 return keys; 1002 }, {}); 1003 1004 // merge with user keybindings 1005 return { 1006 ...defaultKeys, 1007 ...this.keybindings, 1008 }; 1009 } 1010 1011 // update parent padding based on content width settings (if specified) 1012 private syncContentWidth() { 1013 if (this.maxContentWidth && this.parent.clientWidth) { 1014 const minContentPadding = this.minContentPadding || 10; 1015 const parentWidth = this.parent.clientWidth; 1016 if (parentWidth > this.maxContentWidth + 2 * minContentPadding) { 1017 applyPadding(`calc((100% - ${this.maxContentWidth}px)/2)`); 1018 } else { 1019 applyPadding(this.minContentPadding + 'px'); 1020 } 1021 } 1022 } 1023 1024 private applyFixupsOnResize() { 1025 const docChanged = this.applyFixups(FixupContext.Resize); 1026 if (!docChanged) { 1027 // If applyFixupsOnResize returns true, then layout has already 1028 // been fired; if it hasn't, we must do so now 1029 this.emitEvent(LayoutEvent); 1030 } 1031 } 1032 1033 private applyFixups(context: FixupContext) { 1034 let tr = this.state.tr; 1035 tr = this.extensionFixups(tr, context); 1036 if (tr.docChanged) { 1037 tr.setMeta(kAddToHistoryTransaction, false); 1038 tr.setMeta(kFixupTransaction, true); 1039 this.view.dispatch(tr); 1040 return true; 1041 } 1042 return false; 1043 } 1044 1045 private extensionFixups(tr: Transaction, context: FixupContext) { 1046 this.extensions.fixups(this.schema, this.view).forEach(fixup => { 1047 tr = fixup(tr, context); 1048 }); 1049 return tr; 1050 } 1051 1052 private initialDoc(): ProsemirrorNode { 1053 return this.schema.nodeFromJSON({ 1054 type: 'doc', 1055 attrs: { 1056 initial: true, 1057 }, 1058 content: [ 1059 { type: 'body', content: [{ type: 'paragraph' }] }, 1060 { type: 'notes', content: [] }, 1061 ], 1062 }); 1063 } 1064 1065 private async getMarkdownCode(tr: Transaction, options: PandocWriterOptions) { 1066 // apply save fixups 1067 this.extensionFixups(tr, FixupContext.Save); 1068 1069 // apply sentence wrapping if requested 1070 if (options.wrap === 'sentence') { 1071 wrapSentences(tr); 1072 } 1073 1074 // get code 1075 return this.pandocConverter.fromProsemirror(tr.doc, this.pandocFormat, options); 1076 } 1077} 1078 1079function navigationIdForSelection(state: EditorState): string | null { 1080 const outline = getOutlineNodes(state.doc); 1081 const outlineNode = outline.reverse().find(node => node.pos < state.selection.from); 1082 if (outlineNode) { 1083 return outlineNode.node.attrs.navigation_id; 1084 } else { 1085 return null; 1086 } 1087} 1088 1089// custom DOMParser that preserves all whitespace (required by display math marks) 1090class EditorDOMParser extends DOMParser { 1091 constructor(schema: Schema) { 1092 super(schema, DOMParser.fromSchema(schema).rules); 1093 } 1094 public parse(dom: Node, options?: ParseOptions) { 1095 return super.parse(dom, { ...options, preserveWhitespace: 'full' }); 1096 } 1097 1098 public parseSlice(dom: Node, options?: ParseOptions) { 1099 return super.parseSlice(dom, { ...options, preserveWhitespace: 'full' }); 1100 } 1101} 1102