1/*
2 * editor-extensions.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 { InputRule } from 'prosemirror-inputrules';
17import { Schema } from 'prosemirror-model';
18import { Plugin } from 'prosemirror-state';
19import { EditorView } from 'prosemirror-view';
20import { ProsemirrorCommand } from '../api/command';
21import { PandocMark } from '../api/mark';
22import { PandocNode, CodeViewOptions } from '../api/node';
23import { Extension, ExtensionFn, ExtensionContext } from '../api/extension';
24import { BaseKeyBinding } from '../api/basekeys';
25import { OmniInserter } from '../api/omni_insert';
26import { AppendTransactionHandler, AppendMarkTransactionHandler } from '../api/transaction';
27import { FixupFn } from '../api/fixup';
28import {
29  PandocTokenReader,
30  PandocMarkWriter,
31  PandocNodeWriter,
32  PandocPreprocessorFn,
33  PandocPostprocessorFn,
34  PandocBlockReaderFn,
35  PandocInlineHTMLReaderFn,
36  PandocTokensFilterFn,
37} from '../api/pandoc';
38import { PandocBlockCapsuleFilter } from '../api/pandoc_capsule';
39import { markInputRuleFilter } from '../api/input_rule';
40import { CompletionHandler } from '../api/completion';
41
42// required extensions (base non-customiziable pandoc nodes/marks + core behaviors)
43import nodeText from '../nodes/text';
44import nodeParagraph from '../nodes/paragraph';
45import nodeHeading from '../nodes/heading';
46import nodeBlockquote from '../nodes/blockquote';
47import nodeCodeBlock from '../nodes/code_block';
48import nodeLists from '../nodes/list/list';
49import nodeImage from '../nodes/image/image';
50import nodeFigure from '../nodes/image/figure';
51import nodeHr from '../nodes/hr';
52import nodeHardBreak from '../nodes/hard_break';
53import nodeNull from '../nodes/null';
54import markEm from '../marks/em';
55import markStrong from '../marks/strong';
56import markCode from '../marks/code';
57import markLink from '../marks/link/link';
58import behaviorHistory from '../behaviors/history';
59import behaviorSelectAll from '../behaviors/select_all';
60import behaviorCursor from '../behaviors/cursor';
61import behaviorFind from '../behaviors/find';
62import behaviorSpellingInteractive from '../behaviors/spelling/spelling-interactive';
63import behaviorClearFormatting from '../behaviors/clear_formatting';
64
65// behaviors
66import behaviorSmarty from '../behaviors/smarty';
67import behaviorAttrDuplicateId from '../behaviors/attr_duplicate_id';
68import behaviorTrailingP from '../behaviors/trailing_p';
69import behaviorEmptyMark from '../behaviors/empty_mark';
70import behaviorEscapeMark from '../behaviors/escape_mark';
71import behaviorOutline from '../behaviors/outline';
72import beahviorCodeBlockInput from '../behaviors/code_block_input';
73import behaviorPasteText from '../behaviors/paste_text';
74import behaviorBottomPadding from '../behaviors/bottom_padding';
75import behaviorInsertSymbol from '../behaviors/insert_symbol/insert_symbol-plugin-symbol';
76import behaviorInsertSymbolEmoji from '../behaviors/insert_symbol/insert_symbol-plugin-emoji';
77import beahviorInsertSpecialCharacters from '../behaviors/insert_symbol/insert_special_characters';
78import behaviorNbsp from '../behaviors/nbsp';
79import behaviorRemoveSection from '../behaviors/remove_section';
80
81// marks
82import markStrikeout from '../marks/strikeout';
83import markSuperscript from '../marks/superscript';
84import markSubscript from '../marks/subscript';
85import markSmallcaps from '../marks/smallcaps';
86import markUnderline from '../marks/underline';
87import markRawInline from '../marks/raw_inline/raw_inline';
88import markRawTex from '../marks/raw_inline/raw_tex';
89import markRawHTML from '../marks/raw_inline/raw_html';
90import markMath from '../marks/math/math';
91import markCite from '../marks/cite/cite';
92import markSpan from '../marks/span';
93import markXRef from '../marks/xref/xref';
94import markHTMLComment from '../marks/raw_inline/raw_html_comment';
95import markShortcode from '../marks/shortcode';
96import markEmoji from '../marks/emoji/emoji';
97import { markOmniInsert } from '../behaviors/omni_insert/omni_insert';
98
99// nodes
100import nodeFootnote from '../nodes/footnote/footnote';
101import nodeRawBlock from '../nodes/raw_block';
102import nodeYamlMetadata from '../nodes/yaml_metadata/yaml_metadata';
103import nodeRmdCodeChunk from '../nodes/rmd_chunk/rmd_chunk';
104import nodeDiv from '../nodes/div';
105import nodeLineBlock from '../nodes/line_block';
106import nodeTable from '../nodes/table/table';
107import nodeDefinitionList from '../nodes/definition_list/definition_list';
108import nodeShortcodeBlock from '../nodes/shortcode_block';
109import nodeHtmlPreserve from '../nodes/html_preserve';
110
111// extension/plugin factories
112import { acePlugins } from '../optional/ace/ace';
113import { attrEditExtension } from '../behaviors/attr_edit/attr_edit';
114import { codeViewClipboardPlugin } from '../api/code';
115
116export function initExtensions(context: ExtensionContext, extensions?: readonly Extension[]): ExtensionManager {
117  // create extension manager
118  const manager = new ExtensionManager(context);
119
120  // required extensions
121  manager.register([
122    nodeText,
123    nodeParagraph,
124    nodeHeading,
125    nodeBlockquote,
126    nodeLists,
127    nodeCodeBlock,
128    nodeImage,
129    nodeFigure,
130    nodeHr,
131    nodeHardBreak,
132    nodeNull,
133    markEm,
134    markStrong,
135    markCode,
136    markLink,
137    behaviorHistory,
138    behaviorSelectAll,
139    behaviorCursor,
140    behaviorFind,
141    behaviorSpellingInteractive,
142    behaviorClearFormatting,
143  ]);
144
145  // optional extensions
146  manager.register([
147    // behaviors
148    behaviorSmarty,
149    behaviorAttrDuplicateId,
150    behaviorTrailingP,
151    behaviorEmptyMark,
152    behaviorEscapeMark,
153    behaviorOutline,
154    beahviorCodeBlockInput,
155    behaviorPasteText,
156    behaviorBottomPadding,
157    behaviorInsertSymbol,
158    behaviorInsertSymbolEmoji,
159    beahviorInsertSpecialCharacters,
160    behaviorNbsp,
161    behaviorRemoveSection,
162
163    // nodes
164    nodeDiv,
165    nodeFootnote,
166    nodeYamlMetadata,
167    nodeRmdCodeChunk,
168    nodeTable,
169    nodeDefinitionList,
170    nodeLineBlock,
171    nodeRawBlock,
172    nodeShortcodeBlock,
173    nodeHtmlPreserve,
174
175    // marks
176    markUnderline,
177    markStrikeout,
178    markSuperscript,
179    markSubscript,
180    markSmallcaps,
181    markHTMLComment,
182    markRawTex,
183    markRawHTML,
184    markRawInline,
185    markMath,
186    markCite,
187    markSpan,
188    markXRef,
189    markShortcode,
190    markEmoji,
191    markOmniInsert,
192  ]);
193
194  // register external extensions
195  if (extensions) {
196    manager.register(extensions);
197  }
198
199  // additional extensions dervied from other extensions (e.g. extensions that have registered attr editors)
200  // note that all of these take a callback to access the manager -- this is so that if an extension earlier
201  // in the chain registers something the later extensions are able to see it
202  manager.register([
203    // bindings to 'Edit Attribute' command and UI adornment
204    attrEditExtension(context.pandocExtensions, context.ui, manager.attrEditors()),
205  ]);
206
207  // additional plugins derived from extensions
208  const codeViews = manager.codeViews();
209  const plugins: Plugin[] = [];
210  if (context.options.codeEditor === 'ace') {
211    plugins.push(...acePlugins(codeViews, context));
212  }
213  plugins.push(codeViewClipboardPlugin(codeViews));
214
215  // register plugins
216  manager.registerPlugins(plugins);
217
218  // return manager
219  return manager;
220}
221
222export class ExtensionManager {
223  private context: ExtensionContext;
224  private extensions: Extension[];
225
226  public constructor(context: ExtensionContext) {
227    this.context = context;
228    this.extensions = [];
229  }
230
231  public register(extensions: ReadonlyArray<Extension | ExtensionFn>, priority = false): void {
232    extensions.forEach(extension => {
233      if (typeof extension === 'function') {
234        const ext = extension(this.context);
235        if (ext) {
236          if (priority) {
237            this.extensions.unshift(ext);
238          } else {
239            this.extensions.push(ext);
240          }
241        }
242      } else {
243        if (priority) {
244          this.extensions.unshift(extension);
245        } else {
246          this.extensions.push(extension);
247        }
248      }
249    });
250  }
251
252  public registerPlugins(plugins: Plugin[], priority = false) {
253    this.register([{ plugins: () => plugins }], priority);
254  }
255
256  public pandocMarks(): readonly PandocMark[] {
257    return this.collect(extension => extension.marks);
258  }
259
260  public pandocNodes(): readonly PandocNode[] {
261    return this.collect(extension => extension.nodes);
262  }
263
264  public pandocPreprocessors(): readonly PandocPreprocessorFn[] {
265    return this.collectFrom({
266      node: node => [node.pandoc.preprocessor],
267    });
268  }
269
270  public pandocPostprocessors(): readonly PandocPostprocessorFn[] {
271    return this.pandocReaders().flatMap(reader => (reader.postprocessor ? [reader.postprocessor] : []));
272  }
273
274  public pandocTokensFilters(): readonly PandocTokensFilterFn[] {
275    return this.collectFrom({
276      node: node => [node.pandoc.tokensFilter],
277    });
278  }
279
280  public pandocBlockReaders(): readonly PandocBlockReaderFn[] {
281    return this.collectFrom({
282      node: node => [node.pandoc.blockReader],
283    });
284  }
285
286  public pandocInlineHTMLReaders(): readonly PandocInlineHTMLReaderFn[] {
287    return this.collectFrom({
288      mark: mark => [mark.pandoc.inlineHTMLReader],
289      node: node => [node.pandoc.inlineHTMLReader],
290    });
291  }
292
293  public pandocBlockCapsuleFilters(): readonly PandocBlockCapsuleFilter[] {
294    return this.collectFrom({
295      node: node => [node.pandoc.blockCapsuleFilter],
296    });
297  }
298
299  public pandocReaders(): readonly PandocTokenReader[] {
300    return this.collectFrom({
301      mark: mark => mark.pandoc.readers,
302      node: node => node.pandoc.readers ?? [],
303    });
304  }
305
306  public pandocMarkWriters(): readonly PandocMarkWriter[] {
307    return this.collectFrom({
308      mark: mark => [{ name: mark.name, ...mark.pandoc.writer }],
309    });
310  }
311
312  public pandocNodeWriters(): readonly PandocNodeWriter[] {
313    return this.collectFrom({
314      node: node => {
315        return node.pandoc.writer ? [{ name: node.name, write: node.pandoc.writer! }] : [];
316      },
317    });
318  }
319
320  public commands(schema: Schema): readonly ProsemirrorCommand[] {
321    return this.collect<ProsemirrorCommand>(extension => extension.commands?.(schema));
322  }
323
324  public omniInserters(schema: Schema): OmniInserter[] {
325    const omniInserters: OmniInserter[] = [];
326    const commands = this.commands(schema);
327    commands.forEach(command => {
328      if (command.omniInsert) {
329        omniInserters.push({
330          ...command.omniInsert,
331          id: command.id,
332          command: command.execute,
333        });
334      }
335    });
336    return omniInserters;
337  }
338
339  public codeViews() {
340    const views: { [key: string]: CodeViewOptions } = {};
341    this.pandocNodes().forEach((node: PandocNode) => {
342      if (node.code_view) {
343        views[node.name] = node.code_view;
344      }
345    });
346    return views;
347  }
348
349  public attrEditors() {
350    return this.collectFrom({
351      node: node => [node.attr_edit?.()],
352    });
353  }
354
355  public baseKeys(schema: Schema): readonly BaseKeyBinding[] {
356    return this.collect(extension => extension.baseKeys?.(schema));
357  }
358
359  public appendTransactions(schema: Schema): readonly AppendTransactionHandler[] {
360    return this.collect(extension => extension.appendTransaction?.(schema));
361  }
362
363  public appendMarkTransactions(schema: Schema): readonly AppendMarkTransactionHandler[] {
364    return this.collect(extension => extension.appendMarkTransaction?.(schema));
365  }
366
367  public plugins(schema: Schema): readonly Plugin[] {
368    return this.collect(extension => extension.plugins?.(schema));
369  }
370
371  public fixups(schema: Schema, view: EditorView): readonly FixupFn[] {
372    return this.collect(extension => extension.fixups?.(schema, view));
373  }
374
375  public completionHandlers(): readonly CompletionHandler[] {
376    return this.collect(extension => extension.completionHandlers?.());
377  }
378
379  // NOTE: return value not readonly b/c it will be fed directly to a
380  // Prosemirror interface that doesn't take readonly
381  public inputRules(schema: Schema): InputRule[] {
382    const markFilter = markInputRuleFilter(schema, this.pandocMarks());
383    return this.collect<InputRule>(extension => extension.inputRules?.(schema, markFilter));
384  }
385
386  private collect<T>(collector: (extension: Extension) => readonly T[] | undefined) {
387    return this.collectFrom({
388      extension: extension => collector(extension) ?? [],
389    });
390  }
391
392  /**
393   * Visits extensions in order of registration, providing optional callbacks
394   * for extension, mark, and node. The return value of callbacks should be
395   * arrays of (T | undefined | null); these will all be concatenated together,
396   * with the undefined and nulls filtered out.
397   *
398   * @param visitor Object containing callback methods for the different
399   * extension parts.
400   */
401  private collectFrom<T>(visitor: {
402    extension?: (extension: Extension) => ReadonlyArray<T | undefined | null>;
403    mark?: (mark: PandocMark) => ReadonlyArray<T | undefined | null>;
404    node?: (node: PandocNode) => ReadonlyArray<T | undefined | null>;
405  }): T[] {
406    const results: Array<T | undefined | null> = [];
407
408    this.extensions.forEach(extension => {
409      if (visitor.extension) {
410        results.push(...visitor.extension(extension));
411      }
412      if (visitor.mark && extension.marks) {
413        results.push(...extension.marks.flatMap(visitor.mark));
414      }
415      if (visitor.node && extension.nodes) {
416        results.push(...extension.nodes.flatMap(visitor.node));
417      }
418    });
419
420    return results.filter(value => typeof value !== 'undefined' && value !== null) as T[];
421  }
422}
423