punkweb-bb 0.2.3__py3-none-any.whl → 0.4.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.
- punkweb_bb/__pycache__/admin.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/admin_forms.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/bbcode.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/forms.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/middleware.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/mixins.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/models.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/pagination.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/parsers.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/response.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/settings.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/signals.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/tags.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/tests.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/urls.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/utils.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/views.cpython-311.pyc +0 -0
- punkweb_bb/__pycache__/widgets.cpython-311.pyc +0 -0
- punkweb_bb/admin.py +0 -5
- punkweb_bb/admin_forms.py +6 -5
- punkweb_bb/bbcode.py +155 -0
- punkweb_bb/forms.py +13 -5
- punkweb_bb/migrations/0005_alter_thread_options.py +24 -0
- punkweb_bb/migrations/0006_remove_boardprofile__signature_rendered_and_more.py +60 -0
- punkweb_bb/migrations/__pycache__/0005_alter_thread_options.cpython-311.pyc +0 -0
- punkweb_bb/migrations/__pycache__/0006_remove_boardprofile__signature_rendered_and_more.cpython-311.pyc +0 -0
- punkweb_bb/models.py +6 -6
- punkweb_bb/settings.py +1 -0
- punkweb_bb/static/punkweb_bb/css/defaults.css +2 -2
- punkweb_bb/static/punkweb_bb/css/punkweb-modal.css +2 -0
- punkweb_bb/static/punkweb_bb/css/punkweb.css +2 -2
- punkweb_bb/static/punkweb_bb/css/subcategory.css +4 -0
- punkweb_bb/static/punkweb_bb/css/thread.css +24 -0
- punkweb_bb/static/punkweb_bb/editor/bbcode-editor-content.css +4 -5
- punkweb_bb/static/punkweb_bb/editor/bbcode-editor.js +0 -5
- punkweb_bb/static/punkweb_bb/editor/markdown-editor.js +49 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.eslintrc.json +15 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.gitignore +108 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/.prettierrc.json +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/LICENSE +21 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/README.md +240 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/babel.config.json +14 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/blank.html +18 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/demo.html +126 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.css +231 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.js +3086 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.min.css +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.min.js +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/dist/tiny-mde.tiny.js +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/_config.yml +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/_layouts/default.html +50 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/docs/index.md +174 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/globals.d.ts +172 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/gulpfile.mjs +226 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/block.test.js +696 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/commandbar.test.js +84 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/inline.test.js +486 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/interaction.test.js +31 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/setup.test.js +164 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/config.js +2 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/server.js +9 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/setup.js +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest/util/test-helpers.js +98 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest-puppeteer.config.js +8 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/jest.config.js +13 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/package-lock.json +16295 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/package.json +72 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/TinyMDE.js +1926 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/TinyMDECommandBar.js +256 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/commandbar.css +72 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/editor.css +157 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/css/index.css +3 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/grammar.js +300 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/html/blank.html +18 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/html/demo.html +126 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/index.js +4 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/blockquote.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/bold.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/clear_formatting.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/code.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/h1.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/h2.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/hr.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/image.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/italic.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/link.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/ol.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/strikethrough.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/svg.js +17 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/svg/ul.svg +1 -0
- punkweb_bb/static/punkweb_bb/vendor/tiny-markdown-editor/src/tiny.js +3 -0
- punkweb_bb/templates/punkweb_bb/base_delete_modal.html +13 -0
- punkweb_bb/templates/punkweb_bb/bbcode.html +2 -3
- punkweb_bb/templates/punkweb_bb/index.html +2 -2
- punkweb_bb/templates/punkweb_bb/partials/category_delete.html +4 -8
- punkweb_bb/templates/punkweb_bb/partials/post_delete.html +4 -8
- punkweb_bb/templates/punkweb_bb/partials/shout_delete.html +4 -8
- punkweb_bb/templates/punkweb_bb/partials/subcategory_delete.html +4 -8
- punkweb_bb/templates/punkweb_bb/partials/thread_delete.html +4 -8
- punkweb_bb/templates/punkweb_bb/partials/thread_move.html +24 -0
- punkweb_bb/templates/punkweb_bb/profile.html +2 -2
- punkweb_bb/templates/punkweb_bb/shoutbox/shout_list.html +2 -2
- punkweb_bb/templates/punkweb_bb/subcategory.html +1 -1
- punkweb_bb/templates/punkweb_bb/thread.html +24 -14
- punkweb_bb/templates/punkweb_bb/widgets/markdown-editor.html +4 -0
- punkweb_bb/templatetags/__pycache__/markdown.cpython-311.pyc +0 -0
- punkweb_bb/templatetags/__pycache__/render.cpython-311.pyc +0 -0
- punkweb_bb/templatetags/__pycache__/shoutbox_bbcode.cpython-311.pyc +0 -0
- punkweb_bb/templatetags/__pycache__/shoutbox_render.cpython-311.pyc +0 -0
- punkweb_bb/templatetags/render.py +35 -0
- punkweb_bb/tests.py +3 -3
- punkweb_bb/urls.py +1 -0
- punkweb_bb/utils.py +24 -10
- punkweb_bb/views.py +45 -23
- punkweb_bb/widgets.py +20 -0
- {punkweb_bb-0.2.3.dist-info → punkweb_bb-0.4.0.dist-info}/METADATA +58 -51
- {punkweb_bb-0.2.3.dist-info → punkweb_bb-0.4.0.dist-info}/RECORD +120 -53
- punkweb_bb/bbcode_tags.py +0 -167
- punkweb_bb/parsers.py +0 -70
- punkweb_bb/templatetags/shoutbox_bbcode.py +0 -14
- {punkweb_bb-0.2.3.dist-info → punkweb_bb-0.4.0.dist-info}/LICENSE +0 -0
- {punkweb_bb-0.2.3.dist-info → punkweb_bb-0.4.0.dist-info}/WHEEL +0 -0
- {punkweb_bb-0.2.3.dist-info → punkweb_bb-0.4.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;
|