1/*
2 * mark.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 { Mark, MarkSpec, MarkType, ResolvedPos, Node as ProsemirrorNode } from 'prosemirror-model';
17import { EditorState, Selection, Transaction } from 'prosemirror-state';
18
19import { PandocTokenReader, PandocMarkWriterFn, PandocInlineHTMLReaderFn } from './pandoc';
20import { mergedTextNodes } from './text';
21import { findChildrenByMark } from 'prosemirror-utils';
22import { MarkTransaction } from './transaction';
23
24export interface PandocMark {
25  readonly name: string;
26  readonly spec: MarkSpec;
27  readonly noInputRules?: boolean;
28  readonly noSpelling?: boolean;
29  readonly pandoc: {
30    readonly readers: readonly PandocTokenReader[];
31    readonly inlineHTMLReader?: PandocInlineHTMLReaderFn;
32    readonly writer: {
33      priority: number;
34      write: PandocMarkWriterFn;
35    };
36  };
37}
38
39export function markIsActive(context: EditorState | Transaction, type: MarkType) {
40  const { from, $from, to, empty } = context.selection;
41
42  if (empty) {
43    return type && !!type.isInSet(context.storedMarks || $from.marks());
44  }
45
46  return !!context.doc.rangeHasMark(from, to, type);
47}
48
49export function getMarkAttrs(doc: ProsemirrorNode, range: { from: number; to: number }, type: MarkType) {
50  const { from, to } = range;
51  let marks: Mark[] = [];
52
53  doc.nodesBetween(from, to, node => {
54    marks = [...marks, ...node.marks];
55  });
56
57  const mark = marks.find(markItem => markItem.type.name === type.name);
58
59  if (mark) {
60    return mark.attrs;
61  }
62
63  return {};
64}
65
66export function getMarkRange($pos?: ResolvedPos, type?: MarkType) {
67  if (!$pos || !type) {
68    return false;
69  }
70
71  const start = $pos.parent.childAfter($pos.parentOffset);
72
73  if (!start.node) {
74    return false;
75  }
76
77  const link = start.node.marks.find((mark: Mark) => mark.type === type);
78  if (!link) {
79    return false;
80  }
81
82  let startIndex = $pos.index();
83  let startPos = $pos.start() + start.offset;
84  let endIndex = startIndex + 1;
85  let endPos = startPos + start.node.nodeSize;
86
87  while (startIndex > 0 && link.isInSet($pos.parent.child(startIndex - 1).marks)) {
88    startIndex -= 1;
89    startPos -= $pos.parent.child(startIndex).nodeSize;
90  }
91
92  while (endIndex < $pos.parent.childCount && link.isInSet($pos.parent.child(endIndex).marks)) {
93    endPos += $pos.parent.child(endIndex).nodeSize;
94    endIndex += 1;
95  }
96
97  return { from: startPos, to: endPos };
98}
99
100export function getSelectionMarkRange(selection: Selection, markType: MarkType): { from: number; to: number } {
101  let range: { from: number; to: number };
102  if (selection.empty) {
103    range = getMarkRange(selection.$head, markType) as { from: number; to: number };
104  } else {
105    range = { from: selection.from, to: selection.to };
106  }
107  return range;
108}
109
110export function removeInvalidatedMarks(
111  tr: MarkTransaction,
112  node: ProsemirrorNode,
113  pos: number,
114  re: RegExp,
115  markType: MarkType,
116) {
117  re.lastIndex = 0;
118  const markedNodes = findChildrenByMark(node, markType, true);
119  markedNodes.forEach(markedNode => {
120    const from = pos + 1 + markedNode.pos;
121    const markedRange = getMarkRange(tr.doc.resolve(from), markType);
122    if (markedRange) {
123      const text = tr.doc.textBetween(markedRange.from, markedRange.to);
124      if (!text.match(re)) {
125        tr.removeMark(markedRange.from, markedRange.to, markType);
126        tr.removeStoredMark(markType);
127      }
128    }
129  });
130  re.lastIndex = 0;
131}
132
133export function splitInvalidatedMarks(
134  tr: MarkTransaction,
135  node: ProsemirrorNode,
136  pos: number,
137  validLength: (text: string) => number,
138  markType: MarkType,
139  removeMark?: (from: number, to: number) => void,
140) {
141  const hasMarkType = (nd: ProsemirrorNode) => markType.isInSet(nd.marks);
142  const markedNodes = findChildrenByMark(node, markType, true);
143  markedNodes.forEach(markedNode => {
144    const mark = hasMarkType(markedNode.node);
145    if (mark) {
146      const from = pos + 1 + markedNode.pos;
147      const markRange = getMarkRange(tr.doc.resolve(from), markType);
148      if (markRange) {
149        const text = tr.doc.textBetween(markRange.from, markRange.to);
150        const length = validLength(text);
151        if (length > -1 && length !== text.length) {
152          if (removeMark) {
153            removeMark(markRange.from + length, markRange.to);
154          } else {
155            tr.removeMark(markRange.from + length, markRange.to, markType);
156          }
157        }
158      }
159    }
160  });
161}
162
163export function detectAndApplyMarks(
164  tr: MarkTransaction,
165  node: ProsemirrorNode,
166  pos: number,
167  re: RegExp,
168  markType: MarkType,
169  attrs: (match: RegExpMatchArray) => {},
170  filter?: (from: number, to: number) => boolean,
171  text?: (match: RegExpMatchArray) => string,
172) {
173  re.lastIndex = 0;
174  const textNodes = mergedTextNodes(node, (_node: ProsemirrorNode, _pos: number, parentNode: ProsemirrorNode) =>
175    parentNode.type.allowsMarkType(markType),
176  );
177  textNodes.forEach(textNode => {
178    re.lastIndex = 0;
179    let match = re.exec(textNode.text);
180    while (match !== null) {
181      const refText = text ? text(match) : match[0];
182      const from = pos + 1 + textNode.pos + match.index + (match[0].length - refText.length);
183      const to = from + refText.length;
184      const range = getMarkRange(tr.doc.resolve(to), markType);
185      if (
186        (!range || range.from !== from || range.to !== to) &&
187        !tr.doc.rangeHasMark(from, to, markType.schema.marks.code)
188      ) {
189        if (!filter || filter(from, to)) {
190          const mark = markType.create(attrs instanceof Function ? attrs(match) : attrs);
191          tr.addMark(from, to, mark);
192          if (tr.selection.anchor === to) {
193            tr.removeStoredMark(mark.type);
194          }
195        }
196      }
197      match = re.lastIndex !== 0 ? re.exec(textNode.text) : null;
198    }
199  });
200  re.lastIndex = 0;
201}
202