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