punkweb-bb 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. punkweb_bb/__pycache__/admin.cpython-311.pyc +0 -0
  2. punkweb_bb/__pycache__/admin_forms.cpython-311.pyc +0 -0
  3. punkweb_bb/__pycache__/forms.cpython-311.pyc +0 -0
  4. punkweb_bb/__pycache__/models.cpython-311.pyc +0 -0
  5. punkweb_bb/__pycache__/settings.cpython-311.pyc +0 -0
  6. punkweb_bb/__pycache__/sitemap.cpython-311.pyc +0 -0
  7. punkweb_bb/__pycache__/tests.cpython-311.pyc +0 -0
  8. punkweb_bb/__pycache__/urls.cpython-311.pyc +0 -0
  9. punkweb_bb/__pycache__/utils.cpython-311.pyc +0 -0
  10. punkweb_bb/__pycache__/views.cpython-311.pyc +0 -0
  11. punkweb_bb/__pycache__/widgets.cpython-311.pyc +0 -0
  12. punkweb_bb/admin.py +0 -4
  13. punkweb_bb/admin_forms.py +6 -5
  14. punkweb_bb/forms.py +13 -5
  15. punkweb_bb/migrations/0005_alter_thread_options.py +24 -0
  16. punkweb_bb/migrations/0006_remove_boardprofile__signature_rendered_and_more.py +60 -0
  17. punkweb_bb/migrations/__pycache__/0005_alter_thread_options.cpython-311.pyc +0 -0
  18. punkweb_bb/migrations/__pycache__/0006_remove_boardprofile__signature_rendered_and_more.cpython-311.pyc +0 -0
  19. punkweb_bb/models.py +12 -9
  20. punkweb_bb/settings.py +1 -0
  21. punkweb_bb/static/punkweb_bb/css/thread.css +45 -28
  22. punkweb_bb/static/punkweb_bb/editor/markdown-editor.js +23 -0
  23. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.eslintrc.json +15 -0
  24. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.gitignore +108 -0
  25. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.prettierrc.json +1 -0
  26. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/LICENSE +21 -0
  27. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/README.md +240 -0
  28. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/babel.config.json +14 -0
  29. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/blank.html +18 -0
  30. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/demo.html +126 -0
  31. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.css +231 -0
  32. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.js +3086 -0
  33. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.min.css +1 -0
  34. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.min.js +1 -0
  35. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.tiny.js +1 -0
  36. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/_config.yml +1 -0
  37. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/_layouts/default.html +50 -0
  38. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/index.md +174 -0
  39. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/globals.d.ts +172 -0
  40. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/gulpfile.mjs +226 -0
  41. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/block.test.js +696 -0
  42. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/commandbar.test.js +84 -0
  43. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/inline.test.js +486 -0
  44. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/interaction.test.js +31 -0
  45. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/setup.test.js +164 -0
  46. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/config.js +2 -0
  47. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/server.js +9 -0
  48. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/setup.js +1 -0
  49. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/test-helpers.js +98 -0
  50. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest-puppeteer.config.js +8 -0
  51. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest.config.js +13 -0
  52. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/package-lock.json +16295 -0
  53. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/package.json +72 -0
  54. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/TinyMDE.js +1926 -0
  55. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/TinyMDECommandBar.js +256 -0
  56. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/commandbar.css +72 -0
  57. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/editor.css +157 -0
  58. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/index.css +3 -0
  59. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/grammar.js +300 -0
  60. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/html/blank.html +18 -0
  61. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/html/demo.html +126 -0
  62. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/index.js +4 -0
  63. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/blockquote.svg +1 -0
  64. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/bold.svg +1 -0
  65. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/clear_formatting.svg +1 -0
  66. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/code.svg +1 -0
  67. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/h1.svg +1 -0
  68. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/h2.svg +1 -0
  69. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/hr.svg +1 -0
  70. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/image.svg +1 -0
  71. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/italic.svg +1 -0
  72. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/link.svg +1 -0
  73. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/ol.svg +1 -0
  74. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/strikethrough.svg +1 -0
  75. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/svg.js +17 -0
  76. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/ul.svg +1 -0
  77. punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/tiny.js +3 -0
  78. punkweb_bb/templates/punkweb_bb/base_delete_modal.html +13 -0
  79. punkweb_bb/templates/punkweb_bb/index.html +2 -2
  80. punkweb_bb/templates/punkweb_bb/members.html +3 -1
  81. punkweb_bb/templates/punkweb_bb/partials/category_delete.html +4 -8
  82. punkweb_bb/templates/punkweb_bb/partials/post_delete.html +4 -8
  83. punkweb_bb/templates/punkweb_bb/partials/shout_delete.html +4 -8
  84. punkweb_bb/templates/punkweb_bb/partials/subcategory_delete.html +4 -8
  85. punkweb_bb/templates/punkweb_bb/partials/thread_delete.html +4 -8
  86. punkweb_bb/templates/punkweb_bb/partials/thread_move.html +24 -0
  87. punkweb_bb/templates/punkweb_bb/profile.html +8 -4
  88. punkweb_bb/templates/punkweb_bb/shoutbox/shout_list.html +3 -3
  89. punkweb_bb/templates/punkweb_bb/subcategory.html +1 -1
  90. punkweb_bb/templates/punkweb_bb/thread.html +89 -71
  91. punkweb_bb/templates/punkweb_bb/widgets/markdown-editor.html +3 -0
  92. punkweb_bb/templatetags/__pycache__/markdown.cpython-311.pyc +0 -0
  93. punkweb_bb/templatetags/__pycache__/render.cpython-311.pyc +0 -0
  94. punkweb_bb/templatetags/__pycache__/shoutbox_bbcode.cpython-311.pyc +0 -0
  95. punkweb_bb/templatetags/__pycache__/shoutbox_render.cpython-311.pyc +0 -0
  96. punkweb_bb/templatetags/__pycache__/styled_group_name.cpython-311.pyc +0 -0
  97. punkweb_bb/templatetags/render.py +48 -0
  98. punkweb_bb/templatetags/shoutbox_render.py +22 -0
  99. punkweb_bb/templatetags/styled_group_name.py +2 -2
  100. punkweb_bb/tests.py +3 -3
  101. punkweb_bb/urls.py +1 -0
  102. punkweb_bb/utils.py +23 -7
  103. punkweb_bb/views.py +36 -7
  104. punkweb_bb/widgets.py +20 -0
  105. {punkweb_bb-0.2.2.dist-info → punkweb_bb-0.3.0.dist-info}/METADATA +56 -41
  106. {punkweb_bb-0.2.2.dist-info → punkweb_bb-0.3.0.dist-info}/RECORD +109 -41
  107. punkweb_bb/templatetags/shoutbox_bbcode.py +0 -14
  108. {punkweb_bb-0.2.2.dist-info → punkweb_bb-0.3.0.dist-info}/LICENSE +0 -0
  109. {punkweb_bb-0.2.2.dist-info → punkweb_bb-0.3.0.dist-info}/WHEEL +0 -0
  110. {punkweb_bb-0.2.2.dist-info → punkweb_bb-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1926 @@
1
+ import {
2
+ inlineGrammar,
3
+ lineGrammar,
4
+ punctuationLeading,
5
+ punctuationTrailing,
6
+ htmlescape,
7
+ htmlBlockGrammar,
8
+ commands,
9
+ } from "./grammar";
10
+
11
+ class Editor {
12
+ constructor(props = {}) {
13
+ this.e = null;
14
+ this.textarea = null;
15
+ this.lines = [];
16
+ this.lineElements = [];
17
+ this.lineTypes = [];
18
+ this.lineCaptures = [];
19
+ this.lineReplacements = [];
20
+ this.linkLabels = [];
21
+ this.lineDirty = [];
22
+ this.lastCommandState = null;
23
+
24
+ this.listeners = {
25
+ change: [],
26
+ selection: [],
27
+ };
28
+
29
+ let element = props.element;
30
+ this.textarea = props.textarea;
31
+
32
+ if (this.textarea) {
33
+ if (!this.textarea.tagName) {
34
+ this.textarea = document.getElementById(this.textarea);
35
+ }
36
+ if (!element) element = this.textarea;
37
+ }
38
+
39
+ if (element && !element.tagName) {
40
+ element = document.getElementById(props.element);
41
+ }
42
+ if (!element) {
43
+ element = document.getElementsByTagName("body")[0];
44
+ }
45
+ if (element.tagName == "TEXTAREA") {
46
+ this.textarea = element;
47
+ element = this.textarea.parentNode;
48
+ }
49
+
50
+ if (this.textarea) {
51
+ this.textarea.style.display = "none";
52
+ }
53
+
54
+ this.createEditorElement(element);
55
+ // TODO Placeholder for empty content
56
+ this.setContent(
57
+ props.content ||
58
+ (this.textarea
59
+ ? this.textarea.value
60
+ : "# Hello TinyMDE!\nEdit **here**")
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Creates the editor element inside the target element of the DOM tree
66
+ * @param element The target element of the DOM tree
67
+ */
68
+ createEditorElement(element) {
69
+ this.e = document.createElement("div");
70
+ this.e.className = "TinyMDE";
71
+ this.e.contentEditable = true;
72
+ // The following is important for formatting purposes, but also since otherwise the browser replaces subsequent spaces with    
73
+ // That breaks a lot of stuff, so we do this here and not in CSS—therefore, you don't have to remember to put this in the CSS file
74
+ this.e.style.whiteSpace = "pre-wrap";
75
+ // Avoid formatting (B / I / U) popping up on iOS
76
+ this.e.style.webkitUserModify = "read-write-plaintext-only";
77
+ if (
78
+ this.textarea &&
79
+ this.textarea.parentNode == element &&
80
+ this.textarea.nextSibling
81
+ ) {
82
+ element.insertBefore(this.e, this.textarea.nextSibling);
83
+ } else {
84
+ element.appendChild(this.e);
85
+ }
86
+
87
+ this.e.addEventListener("input", (e) => this.handleInputEvent(e));
88
+ this.e.addEventListener("compositionend", (e) => this.handleInputEvent(e));
89
+ document.addEventListener("selectionchange", (e) =>
90
+ this.handleSelectionChangeEvent(e)
91
+ );
92
+ this.e.addEventListener("paste", (e) => this.handlePaste(e));
93
+ this.lineElements = this.e.childNodes; // this will automatically update
94
+ }
95
+
96
+ /**
97
+ * Sets the editor content.
98
+ * @param {string} content The new Markdown content
99
+ */
100
+ setContent(content) {
101
+ // Delete any existing content
102
+ while (this.e.firstChild) {
103
+ this.e.removeChild(this.e.firstChild);
104
+ }
105
+ this.lines = content.split(/(?:\r\n|\r|\n)/);
106
+ this.lineDirty = [];
107
+ for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
108
+ let le = document.createElement("div");
109
+ this.e.appendChild(le);
110
+ this.lineDirty.push(true);
111
+ }
112
+ this.lineTypes = new Array(this.lines.length);
113
+ this.updateFormatting();
114
+ this.fireChange();
115
+ }
116
+
117
+ /**
118
+ * Gets the editor content as a Markdown string.
119
+ * @returns {string} The editor content as a markdown string
120
+ */
121
+ getContent() {
122
+ return this.lines.join("\n");
123
+ }
124
+
125
+ /**
126
+ * This is the main method to update the formatting (from this.lines to HTML output)
127
+ */
128
+ updateFormatting() {
129
+ // First, parse line types. This will update this.lineTypes, this.lineReplacements, and this.lineCaptures
130
+ // We don't apply the formatting yet
131
+ this.updateLineTypes();
132
+ // Collect any valid link labels from link reference definitions—we need that for formatting to determine what's a valid link
133
+ this.updateLinkLabels();
134
+ // Now, apply the formatting
135
+ this.applyLineTypes();
136
+ }
137
+
138
+ /**
139
+ * Updates this.linkLabels: For every link reference definition (line type TMLinkReferenceDefinition), we collect the label
140
+ */
141
+ updateLinkLabels() {
142
+ this.linkLabels = [];
143
+ for (let l = 0; l < this.lines.length; l++) {
144
+ if (this.lineTypes[l] == "TMLinkReferenceDefinition") {
145
+ this.linkLabels.push(
146
+ this.lineCaptures[l][
147
+ lineGrammar.TMLinkReferenceDefinition.labelPlaceholder
148
+ ]
149
+ );
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Helper function to replace placeholders from a RegExp capture. The replacement string can contain regular dollar placeholders (e.g., $1),
156
+ * which are interpreted like in String.replace(), but also double dollar placeholders ($$1). In the case of double dollar placeholders,
157
+ * Markdown inline grammar is applied on the content of the captured subgroup, i.e., $$1 processes inline Markdown grammar in the content of the
158
+ * first captured subgroup, and replaces `$$1` with the result.
159
+ *
160
+ * @param {string} replacement The replacement string, including placeholders.
161
+ * @param capture The result of a RegExp.exec() call
162
+ * @returns The replacement string, with placeholders replaced from the capture result.
163
+ */
164
+ replace(replacement, capture) {
165
+ return replacement.replace(/(\${1,2})([0-9])/g, (str, p1, p2) => {
166
+ if (p1 == "$") return htmlescape(capture[p2]);
167
+ else
168
+ return `<span class="TMInlineFormatted">${this.processInlineStyles(
169
+ capture[p2]
170
+ )}</span>`;
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Applies the line types (from this.lineTypes as well as the capture result in this.lineReplacements and this.lineCaptures)
176
+ * and processes inline formatting for all lines.
177
+ */
178
+ applyLineTypes() {
179
+ for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
180
+ if (this.lineDirty[lineNum]) {
181
+ let contentHTML = this.replace(
182
+ this.lineReplacements[lineNum],
183
+ this.lineCaptures[lineNum]
184
+ );
185
+ // this.lineHTML[lineNum] = (contentHTML == '' ? '<br />' : contentHTML); // Prevent empty elements which can't be selected etc.
186
+ this.lineElements[lineNum].className = this.lineTypes[lineNum];
187
+ this.lineElements[lineNum].removeAttribute("style");
188
+ this.lineElements[lineNum].innerHTML =
189
+ contentHTML == "" ? "<br />" : contentHTML; // Prevent empty elements which can't be selected etc.
190
+ }
191
+ this.lineElements[lineNum].dataset.lineNum = lineNum;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Determines line types for all lines based on the line / block grammar. Captures the results of the respective line
197
+ * grammar regular expressions.
198
+ * Updates this.lineTypes, this.lineCaptures, and this.lineReplacements.
199
+ */
200
+ updateLineTypes() {
201
+ let codeBlockType = false;
202
+ let codeBlockSeqLength = 0;
203
+ let htmlBlock = false;
204
+
205
+ for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
206
+ let lineType = "TMPara";
207
+ let lineCapture = [this.lines[lineNum]];
208
+ let lineReplacement = "$$0"; // Default replacement for paragraph: Inline format the entire line
209
+
210
+ // Check ongoing code blocks
211
+ // if (lineNum > 0 && (this.lineTypes[lineNum - 1] == 'TMCodeFenceBacktickOpen' || this.lineTypes[lineNum - 1] == 'TMFencedCodeBacktick')) {
212
+ if (codeBlockType == "TMCodeFenceBacktickOpen") {
213
+ // We're in a backtick-fenced code block, check if the current line closes it
214
+ let capture = lineGrammar.TMCodeFenceBacktickClose.regexp.exec(
215
+ this.lines[lineNum]
216
+ );
217
+ if (capture && capture.groups["seq"].length >= codeBlockSeqLength) {
218
+ lineType = "TMCodeFenceBacktickClose";
219
+ lineReplacement = lineGrammar.TMCodeFenceBacktickClose.replacement;
220
+ lineCapture = capture;
221
+ codeBlockType = false;
222
+ } else {
223
+ lineType = "TMFencedCodeBacktick";
224
+ lineReplacement = '<span class="TMFencedCode">$0<br /></span>';
225
+ lineCapture = [this.lines[lineNum]];
226
+ }
227
+ }
228
+ // if (lineNum > 0 && (this.lineTypes[lineNum - 1] == 'TMCodeFenceTildeOpen' || this.lineTypes[lineNum - 1] == 'TMFencedCodeTilde')) {
229
+ else if (codeBlockType == "TMCodeFenceTildeOpen") {
230
+ // We're in a tilde-fenced code block
231
+ let capture = lineGrammar.TMCodeFenceTildeClose.regexp.exec(
232
+ this.lines[lineNum]
233
+ );
234
+ if (capture && capture.groups["seq"].length >= codeBlockSeqLength) {
235
+ lineType = "TMCodeFenceTildeClose";
236
+ lineReplacement = lineGrammar.TMCodeFenceTildeClose.replacement;
237
+ lineCapture = capture;
238
+ codeBlockType = false;
239
+ } else {
240
+ lineType = "TMFencedCodeTilde";
241
+ lineReplacement = '<span class="TMFencedCode">$0<br /></span>';
242
+ lineCapture = [this.lines[lineNum]];
243
+ }
244
+ }
245
+
246
+ // Check HTML block types
247
+ if (lineType == "TMPara" && htmlBlock === false) {
248
+ for (let htmlBlockType of htmlBlockGrammar) {
249
+ if (this.lines[lineNum].match(htmlBlockType.start)) {
250
+ // Matching start condition. Check if this tag can start here (not all start conditions allow breaking a paragraph).
251
+ if (
252
+ htmlBlockType.paraInterrupt ||
253
+ lineNum == 0 ||
254
+ !(
255
+ this.lineTypes[lineNum - 1] == "TMPara" ||
256
+ this.lineTypes[lineNum - 1] == "TMUL" ||
257
+ this.lineTypes[lineNum - 1] == "TMOL" ||
258
+ this.lineTypes[lineNum - 1] == "TMBlockquote"
259
+ )
260
+ ) {
261
+ htmlBlock = htmlBlockType;
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ if (htmlBlock !== false) {
269
+ lineType = "TMHTMLBlock";
270
+ lineReplacement = '<span class="TMHTMLContent">$0<br /></span>'; // No formatting in TMHTMLBlock
271
+ lineCapture = [this.lines[lineNum]]; // This should already be set but better safe than sorry
272
+
273
+ // Check if HTML block should be closed
274
+ if (htmlBlock.end) {
275
+ // Specific end condition
276
+ if (this.lines[lineNum].match(htmlBlock.end)) {
277
+ htmlBlock = false;
278
+ }
279
+ } else {
280
+ // No specific end condition, ends with blank line
281
+ if (
282
+ lineNum == this.lines.length - 1 ||
283
+ this.lines[lineNum + 1].match(lineGrammar.TMBlankLine.regexp)
284
+ ) {
285
+ htmlBlock = false;
286
+ }
287
+ }
288
+ }
289
+
290
+ // Check all regexps if we haven't applied one of the code block types
291
+ if (lineType == "TMPara") {
292
+ for (let type in lineGrammar) {
293
+ if (lineGrammar[type].regexp) {
294
+ let capture = lineGrammar[type].regexp.exec(this.lines[lineNum]);
295
+ if (capture) {
296
+ lineType = type;
297
+ lineReplacement = lineGrammar[type].replacement;
298
+ lineCapture = capture;
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // If we've opened a code block, remember that
306
+ if (
307
+ lineType == "TMCodeFenceBacktickOpen" ||
308
+ lineType == "TMCodeFenceTildeOpen"
309
+ ) {
310
+ codeBlockType = lineType;
311
+ codeBlockSeqLength = lineCapture.groups["seq"].length;
312
+ }
313
+
314
+ // Link reference definition and indented code can't interrupt a paragraph
315
+ if (
316
+ (lineType == "TMIndentedCode" ||
317
+ lineType == "TMLinkReferenceDefinition") &&
318
+ lineNum > 0 &&
319
+ (this.lineTypes[lineNum - 1] == "TMPara" ||
320
+ this.lineTypes[lineNum - 1] == "TMUL" ||
321
+ this.lineTypes[lineNum - 1] == "TMOL" ||
322
+ this.lineTypes[lineNum - 1] == "TMBlockquote")
323
+ ) {
324
+ // Fall back to TMPara
325
+ lineType = "TMPara";
326
+ lineCapture = [this.lines[lineNum]];
327
+ lineReplacement = "$$0";
328
+ }
329
+
330
+ // Setext H2 markers that can also be interpreted as an empty list item should be regarded as such (as per CommonMark spec)
331
+ if (lineType == "TMSetextH2Marker") {
332
+ let capture = lineGrammar.TMUL.regexp.exec(this.lines[lineNum]);
333
+ if (capture) {
334
+ lineType = "TMUL";
335
+ lineReplacement = lineGrammar.TMUL.replacement;
336
+ lineCapture = capture;
337
+ }
338
+ }
339
+
340
+ // Setext headings are only valid if preceded by a paragraph (and if so, they change the type of the previous paragraph)
341
+ if (lineType == "TMSetextH1Marker" || lineType == "TMSetextH2Marker") {
342
+ if (lineNum == 0 || this.lineTypes[lineNum - 1] != "TMPara") {
343
+ // Setext marker is invalid. However, a H2 marker might still be a valid HR, so let's check that
344
+ let capture = lineGrammar.TMHR.regexp.exec(this.lines[lineNum]);
345
+ if (capture) {
346
+ // Valid HR
347
+ lineType = "TMHR";
348
+ lineCapture = capture;
349
+ lineReplacement = lineGrammar.TMHR.replacement;
350
+ } else {
351
+ // Not valid HR, format as TMPara
352
+ lineType = "TMPara";
353
+ lineCapture = [this.lines[lineNum]];
354
+ lineReplacement = "$$0";
355
+ }
356
+ } else {
357
+ // Valid setext marker. Change types of preceding para lines
358
+ let headingLine = lineNum - 1;
359
+ const headingLineType =
360
+ lineType == "TMSetextH1Marker" ? "TMSetextH1" : "TMSetextH2";
361
+ do {
362
+ if (this.lineTypes[headingLineType] != headingLineType) {
363
+ this.lineTypes[headingLine] = headingLineType;
364
+ this.lineDirty[headingLineType] = true;
365
+ }
366
+ this.lineReplacements[headingLine] = "$$0";
367
+ this.lineCaptures[headingLine] = [this.lines[headingLine]];
368
+
369
+ headingLine--;
370
+ } while (headingLine >= 0 && this.lineTypes[headingLine] == "TMPara");
371
+ }
372
+ }
373
+ // Lastly, save the line style to be applied later
374
+ if (this.lineTypes[lineNum] != lineType) {
375
+ this.lineTypes[lineNum] = lineType;
376
+ this.lineDirty[lineNum] = true;
377
+ }
378
+ this.lineReplacements[lineNum] = lineReplacement;
379
+ this.lineCaptures[lineNum] = lineCapture;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Updates all line contents from the HTML, then re-applies formatting.
385
+ */
386
+ updateLineContentsAndFormatting() {
387
+ this.clearDirtyFlag();
388
+ this.updateLineContents();
389
+ this.updateFormatting();
390
+ }
391
+
392
+ /**
393
+ * Attempts to parse a link or image at the current position. This assumes that the opening [ or ![ has already been matched.
394
+ * Returns false if this is not a valid link, image. See below for more information
395
+ * @param {string} originalString The original string, starting at the opening marker ([ or ![)
396
+ * @param {boolean} isImage Whether or not this is an image (opener == ![)
397
+ * @returns false if not a valid link / image.
398
+ * Otherwise returns an object with two properties: output is the string to be included in the processed output,
399
+ * charCount is the number of input characters (from originalString) consumed.
400
+ */
401
+ parseLinkOrImage(originalString, isImage) {
402
+ // Skip the opening bracket
403
+ let textOffset = isImage ? 2 : 1;
404
+ let opener = originalString.substr(0, textOffset);
405
+ let type = isImage ? "TMImage" : "TMLink";
406
+ let currentOffset = textOffset;
407
+
408
+ let bracketLevel = 1;
409
+ let linkText = false;
410
+ let linkRef = false;
411
+ let linkLabel = [];
412
+ let linkDetails = []; // If matched, this will be an array: [whitespace + link destination delimiter, link destination, link destination delimiter, whitespace, link title delimiter, link title, link title delimiter + whitespace]. All can be empty strings.
413
+
414
+ textOuter: while (
415
+ currentOffset < originalString.length &&
416
+ linkText === false /* empty string is okay */
417
+ ) {
418
+ let string = originalString.substr(currentOffset);
419
+
420
+ // Capture any escapes and code blocks at current position, they bind more strongly than links
421
+ // We don't have to actually process them here, that'll be done later in case the link / image is valid, but we need to skip over them.
422
+ for (let rule of ["escape", "code", "autolink", "html"]) {
423
+ let cap = inlineGrammar[rule].regexp.exec(string);
424
+ if (cap) {
425
+ currentOffset += cap[0].length;
426
+ continue textOuter;
427
+ }
428
+ }
429
+
430
+ // Check for image. It's okay for an image to be included in a link or image
431
+ if (string.match(inlineGrammar.imageOpen.regexp)) {
432
+ // Opening image. It's okay if this is a matching pair of brackets
433
+ bracketLevel++;
434
+ currentOffset += 2;
435
+ continue textOuter;
436
+ }
437
+
438
+ // Check for link (not an image because that would have been captured and skipped over above)
439
+ if (string.match(inlineGrammar.linkOpen.regexp)) {
440
+ // Opening bracket. Two things to do:
441
+ // 1) it's okay if this part of a pair of brackets.
442
+ // 2) If we are currently trying to parse a link, this nested bracket musn't start a valid link (no nested links allowed)
443
+ bracketLevel++;
444
+ // if (bracketLevel >= 2) return false; // Nested unescaped brackets, this doesn't qualify as a link / image
445
+ if (!isImage) {
446
+ if (this.parseLinkOrImage(string, false)) {
447
+ // Valid link inside this possible link, which makes this link invalid (inner links beat outer ones)
448
+ return false;
449
+ }
450
+ }
451
+ currentOffset += 1;
452
+ continue textOuter;
453
+ }
454
+
455
+ // Check for closing bracket
456
+ if (string.match(/^\]/)) {
457
+ bracketLevel--;
458
+ if (bracketLevel == 0) {
459
+ // Found matching bracket and haven't found anything disqualifying this as link / image.
460
+ linkText = originalString.substr(
461
+ textOffset,
462
+ currentOffset - textOffset
463
+ );
464
+ currentOffset++;
465
+ continue textOuter;
466
+ }
467
+ }
468
+
469
+ // Nothing matches, proceed to next char
470
+ currentOffset++;
471
+ }
472
+
473
+ // Did we find a link text (i.e., find a matching closing bracket?)
474
+ if (linkText === false) return false; // Nope
475
+
476
+ // So far, so good. We've got a valid link text. Let's see what type of link this is
477
+ let nextChar =
478
+ currentOffset < originalString.length
479
+ ? originalString.substr(currentOffset, 1)
480
+ : "";
481
+
482
+ // REFERENCE LINKS
483
+ if (nextChar == "[") {
484
+ let string = originalString.substr(currentOffset);
485
+ let cap = inlineGrammar.linkLabel.regexp.exec(string);
486
+ if (cap) {
487
+ // Valid link label
488
+ currentOffset += cap[0].length;
489
+ linkLabel.push(cap[1], cap[2], cap[3]);
490
+ if (cap[inlineGrammar.linkLabel.labelPlaceholder]) {
491
+ // Full reference link
492
+ linkRef = cap[inlineGrammar.linkLabel.labelPlaceholder];
493
+ } else {
494
+ // Collapsed reference link
495
+ linkRef = linkText.trim();
496
+ }
497
+ } else {
498
+ // Not a valid link label
499
+ return false;
500
+ }
501
+ } else if (nextChar != "(") {
502
+ // Shortcut ref link
503
+ linkRef = linkText.trim();
504
+
505
+ // INLINE LINKS
506
+ } else {
507
+ // nextChar == '('
508
+
509
+ // Potential inline link
510
+ currentOffset++;
511
+
512
+ let parenthesisLevel = 1;
513
+ inlineOuter: while (
514
+ currentOffset < originalString.length &&
515
+ parenthesisLevel > 0
516
+ ) {
517
+ let string = originalString.substr(currentOffset);
518
+
519
+ // Process whitespace
520
+ let cap = /^\s+/.exec(string);
521
+ if (cap) {
522
+ switch (linkDetails.length) {
523
+ case 0:
524
+ linkDetails.push(cap[0]);
525
+ break; // Opening whitespace
526
+ case 1:
527
+ linkDetails.push(cap[0]);
528
+ break; // Open destination, but not a destination yet; desination opened with <
529
+ case 2: // Open destination with content in it. Whitespace only allowed if opened by angle bracket, otherwise this closes the destination
530
+ if (linkDetails[0].match(/</)) {
531
+ linkDetails[1] = linkDetails[1].concat(cap[0]);
532
+ } else {
533
+ if (parenthesisLevel != 1) return false; // Unbalanced parenthesis
534
+ linkDetails.push(""); // Empty end delimiter for destination
535
+ linkDetails.push(cap[0]); // Whitespace in between destination and title
536
+ }
537
+ break;
538
+ case 3:
539
+ linkDetails.push(cap[0]);
540
+ break; // Whitespace between destination and title
541
+ case 4:
542
+ return false; // This should never happen (no opener for title yet, but more whitespace to capture)
543
+ case 5:
544
+ linkDetails.push(""); // Whitespace at beginning of title, push empty title and continue
545
+ case 6:
546
+ linkDetails[5] = linkDetails[5].concat(cap[0]);
547
+ break; // Whitespace in title
548
+ case 7:
549
+ linkDetails[6] = linkDetails[6].concat(cap[0]);
550
+ break; // Whitespace after closing delimiter
551
+ default:
552
+ return false; // We should never get here
553
+ }
554
+ currentOffset += cap[0].length;
555
+ continue inlineOuter;
556
+ }
557
+
558
+ // Process backslash escapes
559
+ cap = inlineGrammar.escape.regexp.exec(string);
560
+ if (cap) {
561
+ switch (linkDetails.length) {
562
+ case 0:
563
+ linkDetails.push(""); // this opens the link destination, add empty opening delimiter and proceed to next case
564
+ case 1:
565
+ linkDetails.push(cap[0]);
566
+ break; // This opens the link destination, append it
567
+ case 2:
568
+ linkDetails[1] = linkDetails[1].concat(cap[0]);
569
+ break; // Part of the link destination
570
+ case 3:
571
+ return false; // Lacking opening delimiter for link title
572
+ case 4:
573
+ return false; // Lcaking opening delimiter for link title
574
+ case 5:
575
+ linkDetails.push(""); // This opens the link title
576
+ case 6:
577
+ linkDetails[5] = linkDetails[5].concat(cap[0]);
578
+ break; // Part of the link title
579
+ default:
580
+ return false; // After link title was closed, without closing parenthesis
581
+ }
582
+ currentOffset += cap[0].length;
583
+ continue inlineOuter;
584
+ }
585
+
586
+ // Process opening angle bracket as deilimiter of destination
587
+ if (linkDetails.length < 2 && string.match(/^</)) {
588
+ if (linkDetails.length == 0) linkDetails.push("");
589
+ linkDetails[0] = linkDetails[0].concat("<");
590
+ currentOffset++;
591
+ continue inlineOuter;
592
+ }
593
+
594
+ // Process closing angle bracket as delimiter of destination
595
+ if (
596
+ (linkDetails.length == 1 || linkDetails.length == 2) &&
597
+ string.match(/^>/)
598
+ ) {
599
+ if (linkDetails.length == 1) linkDetails.push(""); // Empty link destination
600
+ linkDetails.push(">");
601
+ currentOffset++;
602
+ continue inlineOuter;
603
+ }
604
+
605
+ // Process non-parenthesis delimiter for title.
606
+ cap = /^["']/.exec(string);
607
+ // For this to be a valid opener, we have to either have no destination, only whitespace so far,
608
+ // or a destination with trailing whitespace.
609
+ if (
610
+ cap &&
611
+ (linkDetails.length == 0 ||
612
+ linkDetails.length == 1 ||
613
+ linkDetails.length == 4)
614
+ ) {
615
+ while (linkDetails.length < 4) linkDetails.push("");
616
+ linkDetails.push(cap[0]);
617
+ currentOffset++;
618
+ continue inlineOuter;
619
+ }
620
+
621
+ // For this to be a valid closer, we have to have an opener and some or no title, and this has to match the opener
622
+ if (
623
+ cap &&
624
+ (linkDetails.length == 5 || linkDetails.length == 6) &&
625
+ linkDetails[4] == cap[0]
626
+ ) {
627
+ if (linkDetails.length == 5) linkDetails.push(""); // Empty link title
628
+ linkDetails.push(cap[0]);
629
+ currentOffset++;
630
+ continue inlineOuter;
631
+ }
632
+ // Other cases (linkDetails.length == 2, 3, 7) will be handled with the "default" case below.
633
+
634
+ // Process opening parenthesis
635
+ if (string.match(/^\(/)) {
636
+ switch (linkDetails.length) {
637
+ case 0:
638
+ linkDetails.push(""); // this opens the link destination, add empty opening delimiter and proceed to next case
639
+ case 1:
640
+ linkDetails.push(""); // This opens the link destination
641
+ case 2: // Part of the link destination
642
+ linkDetails[1] = linkDetails[1].concat("(");
643
+ if (!linkDetails[0].match(/<$/)) parenthesisLevel++;
644
+ break;
645
+ case 3:
646
+ linkDetails.push(""); // opening delimiter for link title
647
+ case 4:
648
+ linkDetails.push("(");
649
+ break; // opening delimiter for link title
650
+ case 5:
651
+ linkDetails.push(""); // opens the link title, add empty title content and proceed to next case
652
+ case 6: // Part of the link title. Un-escaped parenthesis only allowed in " or ' delimited title
653
+ if (linkDetails[4] == "(") return false;
654
+ linkDetails[5] = linkDetails[5].concat("(");
655
+ break;
656
+ default:
657
+ return false; // After link title was closed, without closing parenthesis
658
+ }
659
+ currentOffset++;
660
+ continue inlineOuter;
661
+ }
662
+
663
+ // Process closing parenthesis
664
+ if (string.match(/^\)/)) {
665
+ if (linkDetails.length <= 2) {
666
+ // We are inside the link destination. Parentheses have to be matched if not in angle brackets
667
+ while (linkDetails.length < 2) linkDetails.push("");
668
+
669
+ if (!linkDetails[0].match(/<$/)) parenthesisLevel--;
670
+
671
+ if (parenthesisLevel > 0) {
672
+ linkDetails[1] = linkDetails[1].concat(")");
673
+ }
674
+ } else if (linkDetails.length == 5 || linkDetails.length == 6) {
675
+ // We are inside the link title.
676
+ if (linkDetails[4] == "(") {
677
+ // This closes the link title
678
+ if (linkDetails.length == 5) linkDetails.push("");
679
+ linkDetails.push(")");
680
+ } else {
681
+ // Just regular ol' content
682
+ if (linkDetails.length == 5) linkDetails.push(")");
683
+ else linkDetails[5] = linkDetails[5].concat(")");
684
+ }
685
+ } else {
686
+ parenthesisLevel--; // This should decrease it from 1 to 0...
687
+ }
688
+
689
+ if (parenthesisLevel == 0) {
690
+ // No invalid condition, let's make sure the linkDetails array is complete
691
+ while (linkDetails.length < 7) linkDetails.push("");
692
+ }
693
+
694
+ currentOffset++;
695
+ continue inlineOuter;
696
+ }
697
+
698
+ // Any old character
699
+ cap = /^./.exec(string);
700
+ if (cap) {
701
+ switch (linkDetails.length) {
702
+ case 0:
703
+ linkDetails.push(""); // this opens the link destination, add empty opening delimiter and proceed to next case
704
+ case 1:
705
+ linkDetails.push(cap[0]);
706
+ break; // This opens the link destination, append it
707
+ case 2:
708
+ linkDetails[1] = linkDetails[1].concat(cap[0]);
709
+ break; // Part of the link destination
710
+ case 3:
711
+ return false; // Lacking opening delimiter for link title
712
+ case 4:
713
+ return false; // Lcaking opening delimiter for link title
714
+ case 5:
715
+ linkDetails.push(""); // This opens the link title
716
+ case 6:
717
+ linkDetails[5] = linkDetails[5].concat(cap[0]);
718
+ break; // Part of the link title
719
+ default:
720
+ return false; // After link title was closed, without closing parenthesis
721
+ }
722
+ currentOffset += cap[0].length;
723
+ continue inlineOuter;
724
+ }
725
+ throw "Infinite loop"; // we should never get here since the last test matches any character
726
+ }
727
+ if (parenthesisLevel > 0) return false; // Parenthes(es) not closed
728
+ }
729
+
730
+ if (linkRef !== false) {
731
+ // Ref link; check that linkRef is valid
732
+ let valid = false;
733
+ for (let label of this.linkLabels) {
734
+ if (label == linkRef) {
735
+ valid = true;
736
+ break;
737
+ }
738
+ }
739
+ let label = valid
740
+ ? "TMLinkLabel TMLinkLabel_Valid"
741
+ : "TMLinkLabel TMLinkLabel_Invalid";
742
+ let output = `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type} ${
743
+ linkLabel.length < 3 || !linkLabel[1] ? label : ""
744
+ }">${this.processInlineStyles(
745
+ linkText
746
+ )}</span><span class="TMMark TMMark_${type}">]</span>`;
747
+
748
+ if (linkLabel.length >= 3) {
749
+ output = output.concat(
750
+ `<span class="TMMark TMMark_${type}">${linkLabel[0]}</span>`,
751
+ `<span class="${label}">${linkLabel[1]}</span>`,
752
+ `<span class="TMMark TMMark_${type}">${linkLabel[2]}</span>`
753
+ );
754
+ }
755
+ return {
756
+ output: output,
757
+ charCount: currentOffset,
758
+ };
759
+ } else if (linkDetails) {
760
+ // Inline link
761
+
762
+ // This should never happen, but better safe than sorry.
763
+ while (linkDetails.length < 7) {
764
+ linkDetails.push("");
765
+ }
766
+
767
+ return {
768
+ output: `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type}">${this.processInlineStyles(
769
+ linkText
770
+ )}</span><span class="TMMark TMMark_${type}">](${
771
+ linkDetails[0]
772
+ }</span><span class="${type}Destination">${
773
+ linkDetails[1]
774
+ }</span><span class="TMMark TMMark_${type}">${linkDetails[2]}${
775
+ linkDetails[3]
776
+ }${linkDetails[4]}</span><span class="${type}Title">${
777
+ linkDetails[5]
778
+ }</span><span class="TMMark TMMark_${type}">${linkDetails[6]})</span>`,
779
+ charCount: currentOffset,
780
+ };
781
+ }
782
+
783
+ return false;
784
+ }
785
+
786
+ /**
787
+ * Formats a markdown string as HTML, using Markdown inline formatting.
788
+ * @param {string} originalString The input (markdown inline formatted) string
789
+ * @returns {string} The HTML formatted output
790
+ */
791
+ processInlineStyles(originalString) {
792
+ let processed = "";
793
+ let stack = []; // Stack is an array of objects of the format: {delimiter, delimString, count, output}
794
+ let offset = 0;
795
+ let string = originalString;
796
+
797
+ outer: while (string) {
798
+ // Process simple rules (non-delimiter)
799
+ for (let rule of ["escape", "code", "autolink", "html"]) {
800
+ let cap = inlineGrammar[rule].regexp.exec(string);
801
+ if (cap) {
802
+ string = string.substr(cap[0].length);
803
+ offset += cap[0].length;
804
+ processed += inlineGrammar[rule].replacement
805
+ // .replace(/\$\$([1-9])/g, (str, p1) => processInlineStyles(cap[p1])) // todo recursive calling
806
+ .replace(/\$([1-9])/g, (str, p1) => htmlescape(cap[p1]));
807
+ continue outer;
808
+ }
809
+ }
810
+
811
+ // Check for links / images
812
+ let potentialLink = string.match(inlineGrammar.linkOpen.regexp);
813
+ let potentialImage = string.match(inlineGrammar.imageOpen.regexp);
814
+ if (potentialImage || potentialLink) {
815
+ let result = this.parseLinkOrImage(string, potentialImage);
816
+ if (result) {
817
+ processed = `${processed}${result.output}`;
818
+ string = string.substr(result.charCount);
819
+ offset += result.charCount;
820
+ continue outer;
821
+ }
822
+ }
823
+
824
+ // Check for em / strong delimiters
825
+ let cap = /(^\*+)|(^_+)/.exec(string);
826
+ if (cap) {
827
+ let delimCount = cap[0].length;
828
+ const delimString = cap[0];
829
+ const currentDelimiter = cap[0][0]; // This should be * or _
830
+
831
+ string = string.substr(cap[0].length);
832
+
833
+ // We have a delimiter run. Let's check if it can open or close an emphasis.
834
+
835
+ const preceding = offset > 0 ? originalString.substr(0, offset) : " "; // beginning and end of line count as whitespace
836
+ const following =
837
+ offset + cap[0].length < originalString.length ? string : " ";
838
+
839
+ const punctuationFollows = following.match(punctuationLeading);
840
+ const punctuationPrecedes = preceding.match(punctuationTrailing);
841
+ const whitespaceFollows = following.match(/^\s/);
842
+ const whitespacePrecedes = preceding.match(/\s$/);
843
+
844
+ // These are the rules for right-flanking and left-flanking delimiter runs as per CommonMark spec
845
+ let canOpen =
846
+ !whitespaceFollows &&
847
+ (!punctuationFollows ||
848
+ !!whitespacePrecedes ||
849
+ !!punctuationPrecedes);
850
+ let canClose =
851
+ !whitespacePrecedes &&
852
+ (!punctuationPrecedes || !!whitespaceFollows || !!punctuationFollows);
853
+
854
+ // Underscores have more detailed rules than just being part of left- or right-flanking run:
855
+ if (currentDelimiter == "_" && canOpen && canClose) {
856
+ canOpen = punctuationPrecedes;
857
+ canClose = punctuationFollows;
858
+ }
859
+
860
+ // If the delimiter can close, check the stack if there's something it can close
861
+ if (canClose) {
862
+ let stackPointer = stack.length - 1;
863
+ // See if we can find a matching opening delimiter, move down through the stack
864
+ while (delimCount && stackPointer >= 0) {
865
+ if (stack[stackPointer].delimiter == currentDelimiter) {
866
+ // We found a matching delimiter, let's construct the formatted string
867
+
868
+ // Firstly, if we skipped any stack levels, pop them immediately (non-matching delimiters)
869
+ while (stackPointer < stack.length - 1) {
870
+ const entry = stack.pop();
871
+ processed = `${entry.output}${entry.delimString.substr(
872
+ 0,
873
+ entry.count
874
+ )}${processed}`;
875
+ }
876
+
877
+ // Then, format the string
878
+ if (delimCount >= 2 && stack[stackPointer].count >= 2) {
879
+ // Strong
880
+ processed = `<span class="TMMark">${currentDelimiter}${currentDelimiter}</span><strong class="TMStrong">${processed}</strong><span class="TMMark">${currentDelimiter}${currentDelimiter}</span>`;
881
+ delimCount -= 2;
882
+ stack[stackPointer].count -= 2;
883
+ } else {
884
+ // Em
885
+ processed = `<span class="TMMark">${currentDelimiter}</span><em class="TMEm">${processed}</em><span class="TMMark">${currentDelimiter}</span>`;
886
+ delimCount -= 1;
887
+ stack[stackPointer].count -= 1;
888
+ }
889
+
890
+ // If that stack level is empty now, pop it
891
+ if (stack[stackPointer].count == 0) {
892
+ let entry = stack.pop();
893
+ processed = `${entry.output}${processed}`;
894
+ stackPointer--;
895
+ }
896
+ } else {
897
+ // This stack level's delimiter type doesn't match the current delimiter type
898
+ // Go down one level in the stack
899
+ stackPointer--;
900
+ }
901
+ }
902
+ }
903
+ // If there are still delimiters left, and the delimiter run can open, push it on the stack
904
+ if (delimCount && canOpen) {
905
+ stack.push({
906
+ delimiter: currentDelimiter,
907
+ delimString: delimString,
908
+ count: delimCount,
909
+ output: processed,
910
+ });
911
+ processed = ""; // Current formatted output has been pushed on the stack and will be prepended when the stack gets popped
912
+ delimCount = 0;
913
+ }
914
+
915
+ // Any delimiters that are left (closing unmatched) are appended to the output.
916
+ if (delimCount) {
917
+ processed = `${processed}${delimString.substr(0, delimCount)}`;
918
+ }
919
+
920
+ offset += cap[0].length;
921
+ continue outer;
922
+ }
923
+
924
+ // Check for strikethrough delimiter
925
+ cap = /^~~/.exec(string);
926
+ if (cap) {
927
+ let consumed = false;
928
+ let stackPointer = stack.length - 1;
929
+ // See if we can find a matching opening delimiter, move down through the stack
930
+ while (!consumed && stackPointer >= 0) {
931
+ if (stack[stackPointer].delimiter == "~") {
932
+ // We found a matching delimiter, let's construct the formatted string
933
+
934
+ // Firstly, if we skipped any stack levels, pop them immediately (non-matching delimiters)
935
+ while (stackPointer < stack.length - 1) {
936
+ const entry = stack.pop();
937
+ processed = `${entry.output}${entry.delimString.substr(
938
+ 0,
939
+ entry.count
940
+ )}${processed}`;
941
+ }
942
+
943
+ // Then, format the string
944
+ processed = `<span class="TMMark">~~</span><del class="TMStrikethrough">${processed}</del><span class="TMMark">~~</span>`;
945
+ let entry = stack.pop();
946
+ processed = `${entry.output}${processed}`;
947
+ consumed = true;
948
+ } else {
949
+ // This stack level's delimiter type doesn't match the current delimiter type
950
+ // Go down one level in the stack
951
+ stackPointer--;
952
+ }
953
+ }
954
+
955
+ // If there are still delimiters left, and the delimiter run can open, push it on the stack
956
+ if (!consumed) {
957
+ stack.push({
958
+ delimiter: "~",
959
+ delimString: "~~",
960
+ count: 2,
961
+ output: processed,
962
+ });
963
+ processed = ""; // Current formatted output has been pushed on the stack and will be prepended when the stack gets popped
964
+ }
965
+
966
+ offset += cap[0].length;
967
+ string = string.substr(cap[0].length);
968
+ continue outer;
969
+ }
970
+
971
+ // Process 'default' rule
972
+ cap = inlineGrammar.default.regexp.exec(string);
973
+ if (cap) {
974
+ string = string.substr(cap[0].length);
975
+ offset += cap[0].length;
976
+ processed += inlineGrammar.default.replacement.replace(
977
+ /\$([1-9])/g,
978
+ (str, p1) => htmlescape(cap[p1])
979
+ );
980
+ continue outer;
981
+ }
982
+ throw "Infinite loop!";
983
+ }
984
+
985
+ // Empty the stack, any opening delimiters are unused
986
+ while (stack.length) {
987
+ const entry = stack.pop();
988
+ processed = `${entry.output}${entry.delimString.substr(
989
+ 0,
990
+ entry.count
991
+ )}${processed}`;
992
+ }
993
+
994
+ return processed;
995
+ }
996
+
997
+ /**
998
+ * Clears the line dirty flag (resets it to an array of false)
999
+ */
1000
+ clearDirtyFlag() {
1001
+ this.lineDirty = new Array(this.lines.length);
1002
+ for (let i = 0; i < this.lineDirty.length; i++) {
1003
+ this.lineDirty[i] = false;
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Updates the class properties (lines, lineElements) from the DOM.
1009
+ * @returns true if contents changed
1010
+ */
1011
+ updateLineContents() {
1012
+ // this.lineDirty = [];
1013
+ // Check if we have changed anything about the number of lines (inserted or deleted a paragraph)
1014
+ // < 0 means line(s) removed; > 0 means line(s) added
1015
+ let lineDelta = this.e.childElementCount - this.lines.length;
1016
+ if (lineDelta) {
1017
+ // yup. Let's try how much we can salvage (find out which lines from beginning and end were unchanged)
1018
+ // Find lines from the beginning that haven't changed...
1019
+ let firstChangedLine = 0;
1020
+ while (
1021
+ firstChangedLine <= this.lines.length &&
1022
+ firstChangedLine <= this.lineElements.length &&
1023
+ this.lineElements[firstChangedLine] && // Check that the line element hasn't been deleted
1024
+ this.lines[firstChangedLine] ==
1025
+ this.lineElements[firstChangedLine].textContent
1026
+ ) {
1027
+ firstChangedLine++;
1028
+ }
1029
+
1030
+ // End also from the end
1031
+ let lastChangedLine = -1;
1032
+ while (
1033
+ -lastChangedLine < this.lines.length &&
1034
+ -lastChangedLine < this.lineElements.length &&
1035
+ this.lines[this.lines.length + lastChangedLine] ==
1036
+ this.lineElements[this.lineElements.length + lastChangedLine]
1037
+ .textContent
1038
+ ) {
1039
+ lastChangedLine--;
1040
+ }
1041
+
1042
+ let linesToDelete =
1043
+ this.lines.length + lastChangedLine + 1 - firstChangedLine;
1044
+ if (linesToDelete < -lineDelta) linesToDelete = -lineDelta;
1045
+ if (linesToDelete < 0) linesToDelete = 0;
1046
+
1047
+ let linesToAdd = [];
1048
+ for (let l = 0; l < linesToDelete + lineDelta; l++) {
1049
+ linesToAdd.push(this.lineElements[firstChangedLine + l].textContent);
1050
+ }
1051
+ this.spliceLines(firstChangedLine, linesToDelete, linesToAdd, false);
1052
+ } else {
1053
+ // No lines added or removed
1054
+ for (let line = 0; line < this.lineElements.length; line++) {
1055
+ let e = this.lineElements[line];
1056
+ let ct = e.textContent;
1057
+ if (this.lines[line] !== ct) {
1058
+ // Line changed, update it
1059
+ this.lines[line] = ct;
1060
+ this.lineDirty[line] = true;
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Processes a new paragraph.
1068
+ * @param sel The current selection
1069
+ */
1070
+ processNewParagraph(sel) {
1071
+ if (!sel) return;
1072
+
1073
+ // Update lines from content
1074
+ this.updateLineContents();
1075
+
1076
+ let continuableType = false;
1077
+ // Let's see if we need to continue a list
1078
+
1079
+ let checkLine = sel.col > 0 ? sel.row : sel.row - 1;
1080
+ switch (this.lineTypes[checkLine]) {
1081
+ case "TMUL":
1082
+ continuableType = "TMUL";
1083
+ break;
1084
+ case "TMOL":
1085
+ continuableType = "TMOL";
1086
+ break;
1087
+ case "TMIndentedCode":
1088
+ continuableType = "TMIndentedCode";
1089
+ break;
1090
+ }
1091
+
1092
+ let lines = this.lines[sel.row]
1093
+ .replace(/\n\n$/, "\n")
1094
+ .split(/(?:\r\n|\n|\r)/);
1095
+ if (lines.length == 1) {
1096
+ // No new line
1097
+ this.updateFormatting();
1098
+ return;
1099
+ }
1100
+ this.spliceLines(sel.row, 1, lines, true);
1101
+ sel.row++;
1102
+ sel.col = 0;
1103
+
1104
+ if (continuableType) {
1105
+ // Check if the previous line was non-empty
1106
+ let capture = lineGrammar[continuableType].regexp.exec(
1107
+ this.lines[sel.row - 1]
1108
+ );
1109
+ if (capture) {
1110
+ // Convention: capture[1] is the line type marker, capture[2] is the content
1111
+ if (capture[2]) {
1112
+ // Previous line has content, continue the continuable type
1113
+
1114
+ // Hack for OL: increment number
1115
+ if (continuableType == "TMOL") {
1116
+ capture[1] = capture[1].replace(/\d{1,9}/, (result) => {
1117
+ return parseInt(result[0]) + 1;
1118
+ });
1119
+ }
1120
+ this.lines[sel.row] = `${capture[1]}${this.lines[sel.row]}`;
1121
+ this.lineDirty[sel.row] = true;
1122
+ sel.col = capture[1].length;
1123
+ } else {
1124
+ // Previous line has no content, remove the continuable type from the previous row
1125
+ this.lines[sel.row - 1] = "";
1126
+ this.lineDirty[sel.row - 1] = true;
1127
+ }
1128
+ }
1129
+ }
1130
+ this.updateFormatting();
1131
+ }
1132
+
1133
+ // /**
1134
+ // * Processes a "delete" input action.
1135
+ // * @param {object} focus The selection
1136
+ // * @param {boolean} forward If true, performs a forward delete, otherwise performs a backward delete
1137
+ // */
1138
+ // processDelete(focus, forward) {
1139
+ // if (!focus) return;
1140
+ // let anchor = this.getSelection(true);
1141
+ // // Do we have a non-empty selection?
1142
+ // if (focus.col != anchor.col || focus.row != anchor.row) {
1143
+ // // non-empty. direction doesn't matter.
1144
+ // this.paste('', anchor, focus);
1145
+ // } else {
1146
+ // if (forward) {
1147
+ // if (focus.col < this.lines[focus.row].length) this.paste('', {row: focus.row, col: focus.col + 1}, focus);
1148
+ // else if (focus.col < this.lines.length) this.paste('', {row: focus.row + 1, col: 0}, focus);
1149
+ // // Otherwise, we're at the very end and can't delete forward
1150
+ // } else {
1151
+ // if (focus.col > 0) this.paste('', {row: focus.row, col: focus.col - 1}, focus);
1152
+ // else if (focus.row > 0) this.paste('', {row: focus.row - 1, col: this.lines[focus.row - 1].length - 1}, focus);
1153
+ // // Otherwise, we're at the very beginning and can't delete backwards
1154
+ // }
1155
+ // }
1156
+
1157
+ // }
1158
+
1159
+ /**
1160
+ * Gets the current position of the selection counted by row and column of the editor Markdown content (as opposed to the position in the DOM).
1161
+ *
1162
+ * @param {boolean} getAnchor if set to true, gets the selection anchor (start point of the selection), otherwise gets the focus (end point).
1163
+ * @return {object} An object representing the selection, with properties col and row.
1164
+ */
1165
+ getSelection(getAnchor = false) {
1166
+ const selection = window.getSelection();
1167
+ let startNode = getAnchor ? selection.anchorNode : selection.focusNode;
1168
+ if (!startNode) return null;
1169
+ let offset = getAnchor ? selection.anchorOffset : selection.focusOffset;
1170
+ if (startNode == this.e) {
1171
+ if (offset < this.lines.length)
1172
+ return {
1173
+ row: offset,
1174
+ col: 0,
1175
+ };
1176
+ return {
1177
+ row: offset - 1,
1178
+ col: this.lines[offset - 1].length,
1179
+ };
1180
+ }
1181
+
1182
+ let col = this.computeColumn(startNode, offset);
1183
+ if (col === null) return null; // We are outside of the editor
1184
+
1185
+ // Find the row node
1186
+ let node = startNode;
1187
+ while (node.parentElement != this.e) {
1188
+ node = node.parentElement;
1189
+ }
1190
+
1191
+ let row = 0;
1192
+ // Check if we can read a line number from the data-line-num attribute.
1193
+ // The last condition is a security measure since inserting a new paragraph copies the previous rows' line number
1194
+ if (
1195
+ node.dataset &&
1196
+ node.dataset.lineNum &&
1197
+ (!node.previousSibling ||
1198
+ node.previousSibling.dataset.lineNum != node.dataset.lineNum)
1199
+ ) {
1200
+ row = parseInt(node.dataset.lineNum);
1201
+ } else {
1202
+ while (node.previousSibling) {
1203
+ row++;
1204
+ node = node.previousSibling;
1205
+ }
1206
+ }
1207
+ return { row: row, col: col, node: startNode };
1208
+ }
1209
+
1210
+ /**
1211
+ * Computes a column within an editor line from a node and offset within that node.
1212
+ * @param {Node} startNode The node
1213
+ * @param {int} offset THe selection
1214
+ * @returns {int} the column, or null if the node is not inside the editor
1215
+ */
1216
+ computeColumn(startNode, offset) {
1217
+ let node = startNode;
1218
+ let col;
1219
+ // First, make sure we're actually in the editor.
1220
+ while (node && node.parentNode != this.e) {
1221
+ node = node.parentNode;
1222
+ }
1223
+ if (node == null) return null;
1224
+
1225
+ // There are two ways that offset can be defined:
1226
+ // - Either, the node is a text node, in which case it is the offset within the text
1227
+ // - Or, the node is an element with child notes, in which case the offset refers to the
1228
+ // child node after which the selection is located
1229
+ if (startNode.nodeType === Node.TEXT_NODE || offset === 0) {
1230
+ // In the case that the node is non-text node but the offset is 0,
1231
+ // The selection is at the beginning of that element so we
1232
+ // can simply use the same approach as if it were at the beginning
1233
+ // of a text node.
1234
+ col = offset;
1235
+ node = startNode;
1236
+ } else if (offset > 0) {
1237
+ node = startNode.childNodes[offset - 1];
1238
+ col = node.textContent.length;
1239
+ }
1240
+ while (node.parentNode != this.e) {
1241
+ if (node.previousSibling) {
1242
+ node = node.previousSibling;
1243
+ col += node.textContent.length;
1244
+ } else {
1245
+ node = node.parentNode;
1246
+ }
1247
+ }
1248
+ return col;
1249
+ }
1250
+
1251
+ /**
1252
+ * Computes DOM node and offset within that node from a position expressed as row and column.
1253
+ * @param {int} row Row (line number)
1254
+ * @param {int} col Column
1255
+ * @returns An object with two properties: node and offset. offset may be null;
1256
+ */
1257
+ computeNodeAndOffset(row, col, bindRight = false) {
1258
+ if (row >= this.lineElements.length) {
1259
+ // Selection past the end of text, set selection to end of text
1260
+ row = this.lineElements.length - 1;
1261
+ col = this.lines[row].length;
1262
+ }
1263
+ if (col > this.lines[row].length) {
1264
+ col = this.lines[row].length;
1265
+ }
1266
+ const parentNode = this.lineElements[row];
1267
+ let node = parentNode.firstChild;
1268
+
1269
+ let childrenComplete = false;
1270
+ // default return value
1271
+ let rv = {
1272
+ node: parentNode.firstChild ? parentNode.firstChild : parentNode,
1273
+ offset: 0,
1274
+ };
1275
+
1276
+ while (node != parentNode) {
1277
+ if (!childrenComplete && node.nodeType === Node.TEXT_NODE) {
1278
+ if (node.nodeValue.length >= col) {
1279
+ if (bindRight && node.nodeValue.length == col) {
1280
+ // Selection is at the end of this text node, but we are binding right (prefer an offset of 0 in the next text node)
1281
+ // Remember return value in case we don't find another text node
1282
+ rv = { node: node, offset: col };
1283
+ col = 0;
1284
+ } else {
1285
+ return { node: node, offset: col };
1286
+ }
1287
+ } else {
1288
+ col -= node.nodeValue.length;
1289
+ }
1290
+ }
1291
+ if (!childrenComplete && node.firstChild) {
1292
+ node = node.firstChild;
1293
+ } else if (node.nextSibling) {
1294
+ childrenComplete = false;
1295
+ node = node.nextSibling;
1296
+ } else {
1297
+ childrenComplete = true;
1298
+ node = node.parentNode;
1299
+ }
1300
+ }
1301
+
1302
+ // Either, the position was invalid and we just return the default return value
1303
+ // Or we are binding right and the selection is at the end of the line
1304
+ return rv;
1305
+ }
1306
+
1307
+ /**
1308
+ * Sets the selection based on rows and columns within the editor Markdown content.
1309
+ * @param {object} focus Object representing the selection, needs to have properties row and col.
1310
+ * @param anchor Anchor of the selection. If not given, assumes the current anchor.
1311
+ */
1312
+ setSelection(focus, anchor = null) {
1313
+ if (!focus) return;
1314
+
1315
+ let range = document.createRange();
1316
+
1317
+ let { node: focusNode, offset: focusOffset } = this.computeNodeAndOffset(
1318
+ focus.row,
1319
+ focus.col,
1320
+ anchor && anchor.row == focus.row && anchor.col > focus.col
1321
+ ); // Bind selection right if anchor is in the same row and behind the focus
1322
+ let anchorNode = null,
1323
+ anchorOffset = null;
1324
+ if (anchor && (anchor.row != focus.row || anchor.col != focus.col)) {
1325
+ let { node, offset } = this.computeNodeAndOffset(
1326
+ anchor.row,
1327
+ anchor.col,
1328
+ focus.row == anchor.row && focus.col > anchor.col
1329
+ );
1330
+ anchorNode = node;
1331
+ anchorOffset = offset;
1332
+ }
1333
+
1334
+ if (anchorNode) range.setStart(anchorNode, anchorOffset);
1335
+ else range.setStart(focusNode, focusOffset);
1336
+ range.setEnd(focusNode, focusOffset);
1337
+
1338
+ let windowSelection = window.getSelection();
1339
+ windowSelection.removeAllRanges();
1340
+ windowSelection.addRange(range);
1341
+ }
1342
+
1343
+ /**
1344
+ * Event handler for input events
1345
+ */
1346
+ handleInputEvent(event) {
1347
+ // For composition input, we are only updating the text after we have received
1348
+ // a compositionend event, so we return upon insertCompositionText.
1349
+ // Otherwise, the DOM changes break the text input.
1350
+ if (event.inputType == "insertCompositionText") return;
1351
+
1352
+ let focus = this.getSelection();
1353
+
1354
+ if (
1355
+ (event.inputType == "insertParagraph" ||
1356
+ event.inputType == "insertLineBreak") &&
1357
+ focus
1358
+ ) {
1359
+ this.clearDirtyFlag();
1360
+ this.processNewParagraph(focus);
1361
+ } else {
1362
+ if (!this.e.firstChild) {
1363
+ this.e.innerHTML = '<div class="TMBlankLine"><br></div>';
1364
+ } else {
1365
+ this.fixNodeHierarchy();
1366
+ }
1367
+ this.updateLineContentsAndFormatting();
1368
+ }
1369
+ if (focus) {
1370
+ this.setSelection(focus);
1371
+ }
1372
+
1373
+ this.fireChange();
1374
+ }
1375
+
1376
+ /**
1377
+ * Fixes the node hierarchy – makes sure that each line is in a div, and there are no nested divs
1378
+ */
1379
+ fixNodeHierarchy() {
1380
+ const originalChildren = Array.from(this.e.childNodes);
1381
+
1382
+ const replaceChild = (child, ...newChildren) => {
1383
+ const parent = child.parentElement;
1384
+ const nextSibling = child.nextSibling;
1385
+ parent.removeChild(child);
1386
+ newChildren.forEach((newChild) =>
1387
+ nextSibling
1388
+ ? parent.insertBefore(newChild, nextSibling)
1389
+ : parent.appendChild(newChild)
1390
+ );
1391
+ };
1392
+
1393
+ originalChildren.forEach((child) => {
1394
+ if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "DIV") {
1395
+ // Found a child node that's either not an element or not a div. Wrap it in a div.
1396
+ const divWrapper = document.createElement("div");
1397
+ replaceChild(child, divWrapper);
1398
+ divWrapper.appendChild(child);
1399
+ } else if (child.childNodes.length == 0) {
1400
+ // Empty div child node, include at least a <br />
1401
+ child.appendChild(document.createElement("br"));
1402
+ } else {
1403
+ const grandChildren = Array.from(child.childNodes);
1404
+ if (
1405
+ grandChildren.some(
1406
+ (grandChild) =>
1407
+ grandChild.nodeType === Node.ELEMENT_NODE &&
1408
+ grandChild.tagName === "DIV"
1409
+ )
1410
+ ) {
1411
+ return replaceChild(child, grandChildren);
1412
+ }
1413
+ }
1414
+ });
1415
+ }
1416
+
1417
+ /**
1418
+ * Event handler for "selectionchange" events.
1419
+ */
1420
+ handleSelectionChangeEvent() {
1421
+ this.fireSelection();
1422
+ }
1423
+
1424
+ /**
1425
+ * Convenience function to "splice" new lines into the arrays this.lines, this.lineDirty, this.lineTypes, and the DOM elements
1426
+ * underneath the editor element.
1427
+ * This method is essentially Array.splice, only that the third parameter takes an un-spread array (and the forth parameter)
1428
+ * determines whether the DOM should also be adjusted.
1429
+ *
1430
+ * @param {int} startLine Position at which to start changing the array of lines
1431
+ * @param {int} linesToDelete Number of lines to delete
1432
+ * @param {array} linesToInsert Array of strings representing the lines to be inserted
1433
+ * @param {boolean} adjustLineElements If true, then <div> elements are also inserted in the DOM at the respective position
1434
+ */
1435
+ spliceLines(
1436
+ startLine,
1437
+ linesToDelete = 0,
1438
+ linesToInsert = [],
1439
+ adjustLineElements = true
1440
+ ) {
1441
+ if (adjustLineElements) {
1442
+ for (let i = 0; i < linesToDelete; i++) {
1443
+ this.e.removeChild(this.e.childNodes[startLine]);
1444
+ }
1445
+ }
1446
+
1447
+ let insertedBlank = [];
1448
+ let insertedDirty = [];
1449
+
1450
+ for (let i = 0; i < linesToInsert.length; i++) {
1451
+ insertedBlank.push("");
1452
+ insertedDirty.push(true);
1453
+ if (adjustLineElements) {
1454
+ if (this.e.childNodes[startLine])
1455
+ this.e.insertBefore(
1456
+ document.createElement("div"),
1457
+ this.e.childNodes[startLine]
1458
+ );
1459
+ else this.e.appendChild(document.createElement("div"));
1460
+ }
1461
+ }
1462
+
1463
+ this.lines.splice(startLine, linesToDelete, ...linesToInsert);
1464
+ this.lineTypes.splice(startLine, linesToDelete, ...insertedBlank);
1465
+ this.lineDirty.splice(startLine, linesToDelete, ...insertedDirty);
1466
+ }
1467
+
1468
+ /**
1469
+ * Event handler for the "paste" event
1470
+ */
1471
+ handlePaste(event) {
1472
+ event.preventDefault();
1473
+
1474
+ // get text representation of clipboard
1475
+ let text = (event.originalEvent || event).clipboardData.getData(
1476
+ "text/plain"
1477
+ );
1478
+
1479
+ // insert text manually
1480
+ this.paste(text);
1481
+ }
1482
+
1483
+ /**
1484
+ * Pastes the text at the current selection (or at the end, if no current selection)
1485
+ * @param {string} text
1486
+ */
1487
+ paste(text, anchor = null, focus = null) {
1488
+ if (!anchor) anchor = this.getSelection(true);
1489
+ if (!focus) focus = this.getSelection(false);
1490
+ let beginning, end;
1491
+ if (!focus) {
1492
+ focus = {
1493
+ row: this.lines.length - 1,
1494
+ col: this.lines[this.lines.length - 1].length,
1495
+ }; // Insert at end
1496
+ }
1497
+ if (!anchor) {
1498
+ anchor = focus;
1499
+ }
1500
+ if (
1501
+ anchor.row < focus.row ||
1502
+ (anchor.row == focus.row && anchor.col <= focus.col)
1503
+ ) {
1504
+ beginning = anchor;
1505
+ end = focus;
1506
+ } else {
1507
+ beginning = focus;
1508
+ end = anchor;
1509
+ }
1510
+ let insertedLines = text.split(/(?:\r\n|\r|\n)/);
1511
+ let lineBefore = this.lines[beginning.row].substr(0, beginning.col);
1512
+ let lineEnd = this.lines[end.row].substr(end.col);
1513
+ insertedLines[0] = lineBefore.concat(insertedLines[0]);
1514
+ let endColPos = insertedLines[insertedLines.length - 1].length;
1515
+ insertedLines[insertedLines.length - 1] =
1516
+ insertedLines[insertedLines.length - 1].concat(lineEnd);
1517
+ this.spliceLines(beginning.row, 1 + end.row - beginning.row, insertedLines);
1518
+ focus.row = beginning.row + insertedLines.length - 1;
1519
+ focus.col = endColPos;
1520
+ this.updateFormatting();
1521
+ this.setSelection(focus);
1522
+ this.fireChange();
1523
+ }
1524
+
1525
+ /**
1526
+ * Computes the (lowest in the DOM tree) common ancestor of two DOM nodes.
1527
+ * @param {Node} node1 the first node
1528
+ * @param {Node} node2 the second node
1529
+ * @returns {Node} The commen ancestor node, or null if there is no common ancestor
1530
+ */
1531
+ computeCommonAncestor(node1, node2) {
1532
+ if (!node1 || !node2) return null;
1533
+ if (node1 == node2) return node1;
1534
+ const ancestry = (node) => {
1535
+ let ancestry = [];
1536
+ while (node) {
1537
+ ancestry.unshift(node);
1538
+ node = node.parentNode;
1539
+ }
1540
+ return ancestry;
1541
+ };
1542
+
1543
+ const ancestry1 = ancestry(node1);
1544
+ const ancestry2 = ancestry(node2);
1545
+
1546
+ if (ancestry1[0] != ancestry2[0]) return null;
1547
+ let i;
1548
+ for (i = 0; ancestry1[i] == ancestry2[i]; i++);
1549
+ return ancestry1[i - 1];
1550
+ }
1551
+
1552
+ /**
1553
+ * Finds the (lowest in the DOM tree) enclosing DOM node with a given class.
1554
+ * @param {object} focus The focus selection object
1555
+ * @param {object} anchor The anchor selection object
1556
+ * @param {string} className The class name to find
1557
+ * @returns {Node} The enclosing DOM node with the respective class (inside the editor), if there is one; null otherwise.
1558
+ */
1559
+ computeEnclosingMarkupNode(focus, anchor, className) {
1560
+ let node = null;
1561
+ if (!focus) return null;
1562
+ if (!anchor) {
1563
+ node = focus.node;
1564
+ } else {
1565
+ if (focus.row != anchor.row) return null;
1566
+ node = this.computeCommonAncestor(focus.node, anchor.node);
1567
+ }
1568
+ if (!node) return null;
1569
+ while (node != this.e) {
1570
+ if (node.className && node.className.includes(className)) return node;
1571
+ node = node.parentNode;
1572
+ }
1573
+ // Ascended all the way to the editor element
1574
+ return null;
1575
+ }
1576
+
1577
+ /**
1578
+ * Returns the state (true / false) of all commands.
1579
+ * @param focus Focus of the selection. If not given, assumes the current focus.
1580
+ * @param anchor Anchor of the selection. If not given, assumes the current anchor.
1581
+ */
1582
+ getCommandState(focus = null, anchor = null) {
1583
+ let commandState = {};
1584
+ if (!focus) focus = this.getSelection(false);
1585
+ if (!anchor) anchor = this.getSelection(true);
1586
+ if (!focus) {
1587
+ for (let cmd in commands) {
1588
+ commandState[cmd] = null;
1589
+ }
1590
+ return commandState;
1591
+ }
1592
+ if (!anchor) anchor = focus;
1593
+
1594
+ let start, end;
1595
+ if (
1596
+ anchor.row < focus.row ||
1597
+ (anchor.row == focus.row && anchor.col < focus.col)
1598
+ ) {
1599
+ start = anchor;
1600
+ end = focus;
1601
+ } else {
1602
+ start = focus;
1603
+ end = anchor;
1604
+ }
1605
+ if (end.row > start.row && end.col == 0) {
1606
+ end.row--;
1607
+ end.col = this.lines[end.row].length; // Selection to beginning of next line is said to end at the beginning of the last line
1608
+ }
1609
+
1610
+ for (let cmd in commands) {
1611
+ if (commands[cmd].type == "inline") {
1612
+ if (
1613
+ !focus ||
1614
+ focus.row != anchor.row ||
1615
+ !this.isInlineFormattingAllowed(focus, anchor)
1616
+ ) {
1617
+ commandState[cmd] = null;
1618
+ } else {
1619
+ // The command state is true if there is a respective enclosing markup node (e.g., the selection is enclosed in a <b>..</b>) ...
1620
+ commandState[cmd] =
1621
+ !!this.computeEnclosingMarkupNode(
1622
+ focus,
1623
+ anchor,
1624
+ commands[cmd].className
1625
+ ) ||
1626
+ // ... or if it's an empty string preceded by and followed by formatting markers, e.g. **|** where | is the cursor
1627
+ (focus.col == anchor.col &&
1628
+ !!this.lines[focus.row]
1629
+ .substr(0, focus.col)
1630
+ .match(commands[cmd].unset.prePattern) &&
1631
+ !!this.lines[focus.row]
1632
+ .substr(focus.col)
1633
+ .match(commands[cmd].unset.postPattern));
1634
+ }
1635
+ }
1636
+ if (commands[cmd].type == "line") {
1637
+ if (!focus) {
1638
+ commandState[cmd] = null;
1639
+ } else {
1640
+ let state = this.lineTypes[start.row] == commands[cmd].className;
1641
+
1642
+ for (let line = start.row; line <= end.row; line++) {
1643
+ if ((this.lineTypes[line] == commands[cmd].className) != state) {
1644
+ state = null;
1645
+ break;
1646
+ }
1647
+ }
1648
+ commandState[cmd] = state;
1649
+ }
1650
+ }
1651
+ }
1652
+ return commandState;
1653
+ }
1654
+
1655
+ /**
1656
+ * Sets a command state
1657
+ * @param {string} command
1658
+ * @param {boolean} state
1659
+ */
1660
+ setCommandState(command, state) {
1661
+ if (commands[command].type == "inline") {
1662
+ let anchor = this.getSelection(true);
1663
+ let focus = this.getSelection(false);
1664
+ if (!anchor) anchor = focus;
1665
+ if (!anchor) return;
1666
+ if (anchor.row != focus.row) return;
1667
+ if (!this.isInlineFormattingAllowed(focus, anchor)) return;
1668
+ let markupNode = this.computeEnclosingMarkupNode(
1669
+ focus,
1670
+ anchor,
1671
+ commands[command].className
1672
+ );
1673
+ this.clearDirtyFlag();
1674
+
1675
+ // First case: There's an enclosing markup node, remove the markers around that markup node
1676
+ if (markupNode) {
1677
+ this.lineDirty[focus.row] = true;
1678
+ const startCol = this.computeColumn(markupNode, 0);
1679
+ const len = markupNode.textContent.length;
1680
+ const left = this.lines[focus.row]
1681
+ .substr(0, startCol)
1682
+ .replace(commands[command].unset.prePattern, "");
1683
+ const mid = this.lines[focus.row].substr(startCol, len);
1684
+ const right = this.lines[focus.row]
1685
+ .substr(startCol + len)
1686
+ .replace(commands[command].unset.postPattern, "");
1687
+ this.lines[focus.row] = left.concat(mid, right);
1688
+ anchor.col = left.length;
1689
+ focus.col = anchor.col + len;
1690
+ this.updateFormatting();
1691
+ this.setSelection(focus, anchor);
1692
+ this.fireChange();
1693
+
1694
+ // Second case: Empty selection with surrounding formatting markers, remove those
1695
+ } else if (
1696
+ focus.col == anchor.col &&
1697
+ !!this.lines[focus.row]
1698
+ .substr(0, focus.col)
1699
+ .match(commands[command].unset.prePattern) &&
1700
+ !!this.lines[focus.row]
1701
+ .substr(focus.col)
1702
+ .match(commands[command].unset.postPattern)
1703
+ ) {
1704
+ this.lineDirty[focus.row] = true;
1705
+ const left = this.lines[focus.row]
1706
+ .substr(0, focus.col)
1707
+ .replace(commands[command].unset.prePattern, "");
1708
+ const right = this.lines[focus.row]
1709
+ .substr(focus.col)
1710
+ .replace(commands[command].unset.postPattern, "");
1711
+ this.lines[focus.row] = left.concat(right);
1712
+ focus.col = anchor.col = left.length;
1713
+ this.updateFormatting();
1714
+ this.setSelection(focus, anchor);
1715
+ this.fireChange();
1716
+
1717
+ // Not currently formatted, insert formatting markers
1718
+ } else {
1719
+ // Trim any spaces from the selection
1720
+ let { startCol, endCol } =
1721
+ focus.col < anchor.col
1722
+ ? { startCol: focus.col, endCol: anchor.col }
1723
+ : { startCol: anchor.col, endCol: focus.col };
1724
+
1725
+ let match = this.lines[focus.row]
1726
+ .substr(startCol, endCol - startCol)
1727
+ .match(/^(?<leading>\s*).*\S(?<trailing>\s*)$/);
1728
+ if (match) {
1729
+ startCol += match.groups.leading.length;
1730
+ endCol -= match.groups.trailing.length;
1731
+ }
1732
+
1733
+ focus.col = startCol;
1734
+ anchor.col = endCol;
1735
+
1736
+ // Just insert markup before and after and hope for the best.
1737
+ this.wrapSelection(
1738
+ commands[command].set.pre,
1739
+ commands[command].set.post,
1740
+ focus,
1741
+ anchor
1742
+ );
1743
+ this.fireChange();
1744
+ // TODO clean this up so that markup remains properly nested
1745
+ }
1746
+ } else if (commands[command].type == "line") {
1747
+ let anchor = this.getSelection(true);
1748
+ let focus = this.getSelection(false);
1749
+ if (!anchor) anchor = focus;
1750
+ if (!focus) return;
1751
+ this.clearDirtyFlag();
1752
+ let start = anchor.row > focus.row ? focus : anchor;
1753
+ let end = anchor.row > focus.row ? anchor : focus;
1754
+ if (end.row > start.row && end.col == 0) {
1755
+ end.row--;
1756
+ }
1757
+
1758
+ for (let line = start.row; line <= end.row; line++) {
1759
+ if (state && this.lineTypes[line] != commands[command].className) {
1760
+ this.lines[line] = this.lines[line].replace(
1761
+ commands[command].set.pattern,
1762
+ commands[command].set.replacement.replace(
1763
+ "$#",
1764
+ line - start.row + 1
1765
+ )
1766
+ );
1767
+ this.lineDirty[line] = true;
1768
+ }
1769
+ if (!state && this.lineTypes[line] == commands[command].className) {
1770
+ this.lines[line] = this.lines[line].replace(
1771
+ commands[command].unset.pattern,
1772
+ commands[command].unset.replacement
1773
+ );
1774
+ this.lineDirty[line] = true;
1775
+ }
1776
+ }
1777
+ this.updateFormatting();
1778
+ this.setSelection(
1779
+ { row: end.row, col: this.lines[end.row].length },
1780
+ { row: start.row, col: 0 }
1781
+ );
1782
+ this.fireChange();
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * Returns whether or not inline formatting is allowed at the current focus
1788
+ * @param {object} focus The current focus
1789
+ */
1790
+ isInlineFormattingAllowed() {
1791
+ // TODO Remove parameters from all calls
1792
+ const sel = window.getSelection();
1793
+ if (!sel || !sel.focusNode || !sel.anchorNode) return false;
1794
+
1795
+ // Check if we can find a common ancestor with the class `TMInlineFormatted`
1796
+
1797
+ // Special case: Empty selection right before `TMInlineFormatted`
1798
+ if (
1799
+ sel.isCollapsed &&
1800
+ sel.focusNode.nodeType == 3 &&
1801
+ sel.focusOffset == sel.focusNode.nodeValue.length
1802
+ ) {
1803
+ let node;
1804
+ for (
1805
+ node = sel.focusNode;
1806
+ node && node.nextSibling == null;
1807
+ node = node.parentNode
1808
+ );
1809
+ if (
1810
+ node &&
1811
+ node.nextSibling.className &&
1812
+ node.nextSibling.className.includes("TMInlineFormatted")
1813
+ )
1814
+ return true;
1815
+ }
1816
+
1817
+ // Look for a common ancestor
1818
+ let ancestor = this.computeCommonAncestor(sel.focusNode, sel.anchorNode);
1819
+ if (!ancestor) return false;
1820
+
1821
+ // Check if there's an ancestor of class 'TMInlineFormatted' or 'TMBlankLine'
1822
+ while (ancestor && ancestor != this.e) {
1823
+ if (
1824
+ ancestor.className &&
1825
+ (ancestor.className.includes("TMInlineFormatted") ||
1826
+ ancestor.className.includes("TMBlankLine"))
1827
+ )
1828
+ return true;
1829
+ ancestor = ancestor.parentNode;
1830
+ }
1831
+
1832
+ return false;
1833
+ }
1834
+
1835
+ /**
1836
+ * Wraps the current selection in the strings pre and post. If the selection is not on one line, returns.
1837
+ * @param {string} pre The string to insert before the selection.
1838
+ * @param {string} post The string to insert after the selection.
1839
+ * @param {object} focus The current selection focus. If null, selection will be computed.
1840
+ * @param {object} anchor The current selection focus. If null, selection will be computed.
1841
+ */
1842
+ wrapSelection(pre, post, focus = null, anchor = null) {
1843
+ if (!focus) focus = this.getSelection(false);
1844
+ if (!anchor) anchor = this.getSelection(true);
1845
+ if (!focus || !anchor || focus.row != anchor.row) return;
1846
+ this.lineDirty[focus.row] = true;
1847
+
1848
+ const startCol = focus.col < anchor.col ? focus.col : anchor.col;
1849
+ const endCol = focus.col < anchor.col ? anchor.col : focus.col;
1850
+ const left = this.lines[focus.row].substr(0, startCol).concat(pre);
1851
+ const mid =
1852
+ endCol == startCol
1853
+ ? ""
1854
+ : this.lines[focus.row].substr(startCol, endCol - startCol);
1855
+ const right = post.concat(this.lines[focus.row].substr(endCol));
1856
+ this.lines[focus.row] = left.concat(mid, right);
1857
+ anchor.col = left.length;
1858
+ focus.col = anchor.col + mid.length;
1859
+
1860
+ this.updateFormatting();
1861
+ this.setSelection(focus, anchor);
1862
+ }
1863
+
1864
+ /**
1865
+ * Toggles the command state for a command (true <-> false)
1866
+ * @param {string} command The editor command
1867
+ */
1868
+ toggleCommandState(command) {
1869
+ if (!this.lastCommandState) this.lastCommandState = this.getCommandState();
1870
+ this.setCommandState(command, !this.lastCommandState[command]);
1871
+ }
1872
+
1873
+ /**
1874
+ * Fires a change event. Updates the linked textarea and notifies any event listeners.
1875
+ */
1876
+ fireChange() {
1877
+ if (!this.textarea && !this.listeners.change.length) return;
1878
+ const content = this.getContent();
1879
+ if (this.textarea) this.textarea.value = content;
1880
+ for (let listener of this.listeners.change) {
1881
+ listener({
1882
+ content: content,
1883
+ linesDirty: this.linesDirty,
1884
+ });
1885
+ }
1886
+ }
1887
+
1888
+ /**
1889
+ * Fires a "selection changed" event.
1890
+ */
1891
+ fireSelection() {
1892
+ if (this.listeners.selection && this.listeners.selection.length) {
1893
+ let focus = this.getSelection(false);
1894
+ let anchor = this.getSelection(true);
1895
+ let commandState = this.getCommandState(focus, anchor);
1896
+ if (this.lastCommandState) {
1897
+ Object.assign(this.lastCommandState, commandState);
1898
+ } else {
1899
+ this.lastCommandState = Object.assign({}, commandState);
1900
+ }
1901
+ for (let listener of this.listeners.selection) {
1902
+ listener({
1903
+ focus: focus,
1904
+ anchor: anchor,
1905
+ commandState: this.lastCommandState,
1906
+ });
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ /**
1912
+ * Adds an event listener.
1913
+ * @param {string} type The type of event to listen to. Can be 'change' or 'selection'
1914
+ * @param {*} listener Function of the type (event) => {} to be called when the event occurs.
1915
+ */
1916
+ addEventListener(type, listener) {
1917
+ if (type.match(/^(?:change|input)$/i)) {
1918
+ this.listeners.change.push(listener);
1919
+ }
1920
+ if (type.match(/^(?:selection|selectionchange)$/i)) {
1921
+ this.listeners.selection.push(listener);
1922
+ }
1923
+ }
1924
+ }
1925
+
1926
+ export default Editor;