tiny-markdown-editor 0.2.27 → 0.2.29
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.
- package/README.md +1 -1
- package/dist/demo.html +11 -3
- package/dist/tiny-mde.css +47 -0
- package/dist/tiny-mde.js +551 -149
- package/dist/tiny-mde.min.css +1 -1
- package/dist/tiny-mde.min.js +1 -1
- package/dist/tiny-mde.tiny.js +1 -1
- package/lib/TinyMDE.d.ts +63 -0
- package/lib/TinyMDE.js +423 -36
- package/lib/grammar.d.ts +7 -0
- package/lib/grammar.js +60 -2
- package/package.json +3 -3
package/lib/TinyMDE.js
CHANGED
|
@@ -136,7 +136,7 @@ class Editor {
|
|
|
136
136
|
this.isRestoringHistory = false;
|
|
137
137
|
}
|
|
138
138
|
handleUndoRedoKey(e) {
|
|
139
|
-
const isMac =
|
|
139
|
+
const isMac = grammar_1.editorRegExp.macPlatform.test(navigator.platform);
|
|
140
140
|
const ctrl = isMac ? e.metaKey : e.ctrlKey;
|
|
141
141
|
if (ctrl && !e.altKey) {
|
|
142
142
|
if (e.key === "z" || e.key === "Z") {
|
|
@@ -195,13 +195,14 @@ class Editor {
|
|
|
195
195
|
this.e.addEventListener("focus", () => this.hasFocus = true);
|
|
196
196
|
this.e.addEventListener("paste", (e) => this.handlePaste(e));
|
|
197
197
|
this.e.addEventListener("drop", (e) => this.handleDrop(e));
|
|
198
|
+
this.e.addEventListener("click", (e) => this.handleClick(e));
|
|
198
199
|
this.lineElements = this.e.childNodes;
|
|
199
200
|
}
|
|
200
201
|
setContent(content) {
|
|
201
202
|
while (this.e.firstChild) {
|
|
202
203
|
this.e.removeChild(this.e.firstChild);
|
|
203
204
|
}
|
|
204
|
-
this.lines = content.split(
|
|
205
|
+
this.lines = content.split(grammar_1.editorRegExp.lineSplit);
|
|
205
206
|
this.lineDirty = [];
|
|
206
207
|
for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
|
|
207
208
|
let le = document.createElement("div");
|
|
@@ -242,7 +243,7 @@ class Editor {
|
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
245
|
replace(replacement, capture) {
|
|
245
|
-
return replacement.replace(
|
|
246
|
+
return replacement.replace(grammar_1.editorRegExp.replacementPlaceholder, (str, p1, p2) => {
|
|
246
247
|
if (p1 === "$")
|
|
247
248
|
return (0, grammar_1.htmlescape)(capture[parseInt(p2)]);
|
|
248
249
|
else
|
|
@@ -250,17 +251,194 @@ class Editor {
|
|
|
250
251
|
});
|
|
251
252
|
}
|
|
252
253
|
applyLineTypes() {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
let lineNum = 0;
|
|
255
|
+
while (lineNum < this.lines.length) {
|
|
256
|
+
// A "block" whose text flows across several source lines (a paragraph, a multi-line
|
|
257
|
+
// blockquote, or a list item with wrapped/lazy-continuation lines) is rendered as one
|
|
258
|
+
// inline-parsing unit so that inline styles (bold, italic, ...) can span the soft line
|
|
259
|
+
// breaks between its lines.
|
|
260
|
+
if (this.isInlineGroupLeader(lineNum)) {
|
|
261
|
+
const leaderType = this.lineTypes[lineNum];
|
|
262
|
+
let end = lineNum;
|
|
263
|
+
while (end + 1 < this.lines.length &&
|
|
264
|
+
this.isInlineContinuation(end + 1, leaderType)) {
|
|
265
|
+
end++;
|
|
266
|
+
}
|
|
267
|
+
this.applyInlineGroup(lineNum, end);
|
|
268
|
+
lineNum = end + 1;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.applyLine(lineNum);
|
|
272
|
+
lineNum++;
|
|
260
273
|
}
|
|
261
|
-
this.lineElements[lineNum].dataset.lineNum = lineNum.toString();
|
|
262
274
|
}
|
|
263
275
|
}
|
|
276
|
+
/** A line that can begin a multi-line inline group (a paragraph-bearing block). */
|
|
277
|
+
isInlineGroupLeader(lineNum) {
|
|
278
|
+
const t = this.lineTypes[lineNum];
|
|
279
|
+
if (t === "TMPara" || t === "TMUL" || t === "TMOL")
|
|
280
|
+
return true;
|
|
281
|
+
// A blockquote line whose content is blank is a paragraph break inside the quote, so it
|
|
282
|
+
// doesn't start (or join) a group.
|
|
283
|
+
if (t === "TMBlockquote")
|
|
284
|
+
return !this.isBlankBlockquoteContent(lineNum);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
/** Whether `lineNum` continues the inline group whose leader has type `leaderType`. */
|
|
288
|
+
isInlineContinuation(lineNum, leaderType) {
|
|
289
|
+
const t = this.lineTypes[lineNum];
|
|
290
|
+
// A plain paragraph line is a lazy continuation of the preceding block (paragraph, list item,
|
|
291
|
+
// or blockquote). Consecutive list markers (TMUL/TMOL), by contrast, each start a new item
|
|
292
|
+
// and therefore do NOT continue the group.
|
|
293
|
+
if (t === "TMPara")
|
|
294
|
+
return true;
|
|
295
|
+
if (t === "TMBlockquote" && leaderType === "TMBlockquote")
|
|
296
|
+
return !this.isBlankBlockquoteContent(lineNum);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
/** True when a blockquote line carries no (non-whitespace) content, e.g. `>` or `> `. */
|
|
300
|
+
isBlankBlockquoteContent(lineNum) {
|
|
301
|
+
const cap = this.lineCaptures[lineNum];
|
|
302
|
+
const content = cap && cap[2] !== undefined ? cap[2] : "";
|
|
303
|
+
return grammar_1.editorRegExp.blankContent.test(content);
|
|
304
|
+
}
|
|
305
|
+
applyLine(lineNum) {
|
|
306
|
+
const el = this.lineElements[lineNum];
|
|
307
|
+
if (this.lineDirty[lineNum]) {
|
|
308
|
+
let contentHTML = this.replace(this.lineReplacements[lineNum], this.lineCaptures[lineNum]);
|
|
309
|
+
el.className = this.lineTypes[lineNum];
|
|
310
|
+
el.removeAttribute("style");
|
|
311
|
+
el.innerHTML = contentHTML === "" ? "<br />" : contentHTML;
|
|
312
|
+
// This line is no longer rendered as part of a multi-line inline group.
|
|
313
|
+
delete el.dataset.mlSig;
|
|
314
|
+
}
|
|
315
|
+
el.dataset.lineNum = lineNum.toString();
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Renders a group of lines [start..end] that together form one inline-parsing unit. The inline
|
|
319
|
+
* content of each line (the `$$N` portion of its replacement — the whole line for a paragraph,
|
|
320
|
+
* the text after the marker for a list item or blockquote) is joined with newlines and parsed as
|
|
321
|
+
* a single unit, so inline styles can span the soft line breaks. The resulting HTML is split back
|
|
322
|
+
* into one fragment per line; any element still open at a line break is closed and re-opened so
|
|
323
|
+
* each line keeps its own well-formed block element. Per-line markers (list bullets, blockquote
|
|
324
|
+
* `>`) are preserved exactly. A single-line group (start === end) renders identically to before.
|
|
325
|
+
*/
|
|
326
|
+
applyInlineGroup(start, end) {
|
|
327
|
+
// Signature of every input that affects this group's rendering (line types, replacements and
|
|
328
|
+
// captures — hence the marker prefixes and the inline content). Stored per line element so a
|
|
329
|
+
// group is re-rendered whenever its content, a sibling line, or its boundaries change (e.g. on
|
|
330
|
+
// a split/merge) even if the individual line wasn't separately marked dirty.
|
|
331
|
+
const contents = [];
|
|
332
|
+
let sigSource = `${end - start}`;
|
|
333
|
+
for (let i = start; i <= end; i++) {
|
|
334
|
+
const idx = this.inlineContentIndex(this.lineReplacements[i]);
|
|
335
|
+
contents.push(this.lineCaptures[i][idx] !== undefined ? this.lineCaptures[i][idx] : "");
|
|
336
|
+
sigSource += `\x1f${this.lineTypes[i]}\x1f${this.lineReplacements[i]}\x1f${JSON.stringify(this.lineCaptures[i])}`;
|
|
337
|
+
}
|
|
338
|
+
const sig = `${this.hashString(sigSource)}`;
|
|
339
|
+
let needsRender = false;
|
|
340
|
+
for (let i = start; i <= end; i++) {
|
|
341
|
+
if (this.lineDirty[i] ||
|
|
342
|
+
this.lineElements[i].dataset.mlSig !== sig) {
|
|
343
|
+
needsRender = true;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (needsRender) {
|
|
348
|
+
const fragments = this.splitInlineHTMLByLine(this.processInlineStyles(contents.join("\n")));
|
|
349
|
+
for (let i = start; i <= end; i++) {
|
|
350
|
+
const el = this.lineElements[i];
|
|
351
|
+
const frag = fragments[i - start] || "";
|
|
352
|
+
const contentHTML = this.replaceWithInline(this.lineReplacements[i], this.lineCaptures[i], frag);
|
|
353
|
+
el.className = this.lineTypes[i];
|
|
354
|
+
el.removeAttribute("style");
|
|
355
|
+
el.innerHTML = contentHTML === "" ? "<br />" : contentHTML;
|
|
356
|
+
el.dataset.mlSig = sig;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (let i = start; i <= end; i++) {
|
|
360
|
+
this.lineElements[i].dataset.lineNum = i.toString();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Index of the inline-content placeholder (`$$N`) in a line replacement, or 0 if none. */
|
|
364
|
+
inlineContentIndex(replacement) {
|
|
365
|
+
const m = grammar_1.editorRegExp.inlineContentPlaceholder.exec(replacement);
|
|
366
|
+
return m ? parseInt(m[1]) : 0;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Like replace(), but uses a pre-computed inline fragment for the `$$N` (inline content)
|
|
370
|
+
* placeholder instead of inline-parsing the capture itself. Used for multi-line groups where the
|
|
371
|
+
* inline content was parsed jointly across all lines.
|
|
372
|
+
*/
|
|
373
|
+
replaceWithInline(replacement, capture, inlineFragment) {
|
|
374
|
+
return replacement.replace(grammar_1.editorRegExp.replacementPlaceholder, (str, p1, p2) => {
|
|
375
|
+
if (p1 === "$")
|
|
376
|
+
return (0, grammar_1.htmlescape)(capture[parseInt(p2)]);
|
|
377
|
+
else
|
|
378
|
+
return `<span class="TMInlineFormatted">${inlineFragment}</span>`;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Splits inline HTML produced for a joined multi-line paragraph back into one fragment per
|
|
383
|
+
* source line (fragments are separated by the `\n` markers emitted by processInlineStyles).
|
|
384
|
+
* Inline elements that are still open at a line break are closed at the end of the fragment and
|
|
385
|
+
* re-opened at the start of the next one, so that every fragment is independently well-formed
|
|
386
|
+
* while the formatting visually continues across the lines.
|
|
387
|
+
*/
|
|
388
|
+
splitInlineHTMLByLine(html) {
|
|
389
|
+
const fragments = [];
|
|
390
|
+
const openTags = []; // full opening-tag strings of currently open elements
|
|
391
|
+
let current = "";
|
|
392
|
+
let i = 0;
|
|
393
|
+
while (i < html.length) {
|
|
394
|
+
const ch = html[i];
|
|
395
|
+
if (ch === "\n") {
|
|
396
|
+
// Close currently open tags (in reverse) to finish this line's fragment ...
|
|
397
|
+
for (let t = openTags.length - 1; t >= 0; t--) {
|
|
398
|
+
current += `</${this.inlineTagName(openTags[t])}>`;
|
|
399
|
+
}
|
|
400
|
+
fragments.push(current);
|
|
401
|
+
// ... and re-open them (in order) at the start of the next line's fragment.
|
|
402
|
+
current = openTags.join("");
|
|
403
|
+
i++;
|
|
404
|
+
}
|
|
405
|
+
else if (ch === "<") {
|
|
406
|
+
const close = html.indexOf(">", i);
|
|
407
|
+
if (close < 0) {
|
|
408
|
+
// Defensive: malformed output, emit the remainder verbatim.
|
|
409
|
+
current += html.substr(i);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
const tag = html.substring(i, close + 1);
|
|
413
|
+
current += tag;
|
|
414
|
+
if (tag[1] === "/") {
|
|
415
|
+
openTags.pop();
|
|
416
|
+
}
|
|
417
|
+
else if (tag[tag.length - 2] !== "/") {
|
|
418
|
+
// Opening tag that isn't self-closing.
|
|
419
|
+
openTags.push(tag);
|
|
420
|
+
}
|
|
421
|
+
i = close + 1;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
current += ch;
|
|
425
|
+
i++;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
fragments.push(current);
|
|
429
|
+
return fragments;
|
|
430
|
+
}
|
|
431
|
+
inlineTagName(openTag) {
|
|
432
|
+
const m = grammar_1.editorRegExp.inlineTagName.exec(openTag);
|
|
433
|
+
return m ? m[1] : "span";
|
|
434
|
+
}
|
|
435
|
+
hashString(s) {
|
|
436
|
+
let h = 5381;
|
|
437
|
+
for (let i = 0; i < s.length; i++) {
|
|
438
|
+
h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
|
|
439
|
+
}
|
|
440
|
+
return h;
|
|
441
|
+
}
|
|
264
442
|
updateLineTypes() {
|
|
265
443
|
let codeBlockType = false;
|
|
266
444
|
let codeBlockSeqLength = 0;
|
|
@@ -398,6 +576,40 @@ class Editor {
|
|
|
398
576
|
} while (headingLine >= 0 && this.lineTypes[headingLine] === "TMPara");
|
|
399
577
|
}
|
|
400
578
|
}
|
|
579
|
+
// GFM task list item: a list item whose content begins with a checkbox marker `[ ]`, `[x]`
|
|
580
|
+
// or `[X]` followed by whitespace (or the end of the line). The brackets and separator stay
|
|
581
|
+
// as ordinary TMMark text; only the inner character is rendered as a checkbox (via CSS), so
|
|
582
|
+
// the line's text content still equals its source and the cursor navigates it normally.
|
|
583
|
+
if (lineType === "TMUL" || lineType === "TMOL") {
|
|
584
|
+
const content = lineCapture[2] !== undefined ? lineCapture[2] : "";
|
|
585
|
+
const task = grammar_1.editorRegExp.taskListItem.exec(content);
|
|
586
|
+
if (task) {
|
|
587
|
+
const checked = task[2] !== " "; // [x] / [X] are checked, [ ] is not
|
|
588
|
+
const bulletClass = lineType === "TMUL" ? "TMMark_TMUL" : "TMMark_TMOL";
|
|
589
|
+
const boxClass = checked
|
|
590
|
+
? "TMCheckbox TMCheckbox_checked"
|
|
591
|
+
: "TMCheckbox TMCheckbox_unchecked";
|
|
592
|
+
// Synthetic capture: [full, bullet, "[", inner, "]", separator, inline content]. The
|
|
593
|
+
// inline content stays the last `$$N` group so inlineContentIndex()/applyInlineGroup()
|
|
594
|
+
// (and thus multi-line continuation) behave exactly as for a plain list item.
|
|
595
|
+
lineCapture = [
|
|
596
|
+
lineCapture[0],
|
|
597
|
+
lineCapture[1],
|
|
598
|
+
task[1],
|
|
599
|
+
task[2],
|
|
600
|
+
task[3],
|
|
601
|
+
task[4],
|
|
602
|
+
task[5],
|
|
603
|
+
];
|
|
604
|
+
lineReplacement =
|
|
605
|
+
`<span class="TMMark ${bulletClass}">$1</span>` +
|
|
606
|
+
`<span class="TMMark TMMark_TMTask">$2</span>` +
|
|
607
|
+
`<span class="${boxClass}">$3</span>` +
|
|
608
|
+
`<span class="TMMark TMMark_TMTask">$4</span>` +
|
|
609
|
+
`<span class="TMMark TMMark_TMTask">$5</span>` +
|
|
610
|
+
`$$6`;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
401
613
|
if (this.lineTypes[lineNum] !== lineType) {
|
|
402
614
|
this.lineTypes[lineNum] = lineType;
|
|
403
615
|
this.lineDirty[lineNum] = true;
|
|
@@ -499,7 +711,7 @@ class Editor {
|
|
|
499
711
|
beginning = focus;
|
|
500
712
|
end = anchor;
|
|
501
713
|
}
|
|
502
|
-
let insertedLines = text.split(
|
|
714
|
+
let insertedLines = text.split(grammar_1.editorRegExp.lineSplit);
|
|
503
715
|
let lineBefore = this.lines[beginning.row].substr(0, beginning.col);
|
|
504
716
|
let lineEnd = this.lines[end.row].substr(end.col);
|
|
505
717
|
insertedLines[0] = lineBefore.concat(insertedLines[0]);
|
|
@@ -534,13 +746,13 @@ class Editor {
|
|
|
534
746
|
this.setSelection(focus, anchor);
|
|
535
747
|
}
|
|
536
748
|
addEventListener(type, listener) {
|
|
537
|
-
if (type.match(
|
|
749
|
+
if (type.match(grammar_1.editorRegExp.changeEvent)) {
|
|
538
750
|
this.listeners.change.push(listener);
|
|
539
751
|
}
|
|
540
|
-
if (type.match(
|
|
752
|
+
if (type.match(grammar_1.editorRegExp.selectionEvent)) {
|
|
541
753
|
this.listeners.selection.push(listener);
|
|
542
754
|
}
|
|
543
|
-
if (type.match(
|
|
755
|
+
if (type.match(grammar_1.editorRegExp.dropEvent)) {
|
|
544
756
|
this.listeners.drop.push(listener);
|
|
545
757
|
}
|
|
546
758
|
}
|
|
@@ -663,7 +875,7 @@ class Editor {
|
|
|
663
875
|
if (capture) {
|
|
664
876
|
if (capture[2]) {
|
|
665
877
|
if (continuableType === "TMOL") {
|
|
666
|
-
capture[1] = capture[1].replace(
|
|
878
|
+
capture[1] = capture[1].replace(grammar_1.editorRegExp.orderedListNumber, (result) => {
|
|
667
879
|
return (parseInt(result) + 1).toString();
|
|
668
880
|
});
|
|
669
881
|
}
|
|
@@ -728,12 +940,59 @@ class Editor {
|
|
|
728
940
|
event.preventDefault();
|
|
729
941
|
this.fireDrop(event.dataTransfer);
|
|
730
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Toggles a task list checkbox when its rendered box is clicked. Only the single inner character
|
|
945
|
+
* of the marker (`[ ]` -> `[x]` and back) is changed; the edit is length-preserving, recorded in
|
|
946
|
+
* the undo history, and fires a `change` event, mirroring setCommandState's line-edit flow.
|
|
947
|
+
*/
|
|
948
|
+
handleClick(event) {
|
|
949
|
+
const target = event.target;
|
|
950
|
+
const box = target && target.closest ? target.closest(".TMCheckbox") : null;
|
|
951
|
+
if (!box)
|
|
952
|
+
return; // Not a checkbox click — let the default caret placement happen.
|
|
953
|
+
// Walk up to the line element (a direct child of the editor) to find its source row.
|
|
954
|
+
let lineEl = box;
|
|
955
|
+
while (lineEl && lineEl.parentNode !== this.e) {
|
|
956
|
+
lineEl = lineEl.parentNode;
|
|
957
|
+
}
|
|
958
|
+
if (!lineEl || lineEl.dataset.lineNum === undefined)
|
|
959
|
+
return;
|
|
960
|
+
const row = parseInt(lineEl.dataset.lineNum, 10);
|
|
961
|
+
if (isNaN(row) || row < 0 || row >= this.lines.length)
|
|
962
|
+
return;
|
|
963
|
+
const col = this.computeColumn(box, 0);
|
|
964
|
+
if (col === null)
|
|
965
|
+
return;
|
|
966
|
+
const ch = this.lines[row].charAt(col);
|
|
967
|
+
if (ch !== " " && ch !== "x" && ch !== "X")
|
|
968
|
+
return; // Defensive: not actually a checkbox char.
|
|
969
|
+
event.preventDefault();
|
|
970
|
+
if (!this.isRestoringHistory)
|
|
971
|
+
this.pushHistory();
|
|
972
|
+
this.clearDirtyFlag();
|
|
973
|
+
const replacement = ch === " " ? "x" : " ";
|
|
974
|
+
this.lines[row] = this.lines[row].substr(0, col) + replacement + this.lines[row].substr(col + 1);
|
|
975
|
+
this.lineDirty[row] = true;
|
|
976
|
+
this.updateFormatting();
|
|
977
|
+
this.fireChange();
|
|
978
|
+
}
|
|
731
979
|
processInlineStyles(originalString) {
|
|
732
980
|
let processed = "";
|
|
733
981
|
let stack = [];
|
|
734
982
|
let offset = 0;
|
|
735
983
|
let string = originalString;
|
|
736
984
|
outer: while (string) {
|
|
985
|
+
// Soft line break: when a paragraph that spans several source lines is parsed as one
|
|
986
|
+
// joined string, the lines are separated by \n. Preserve it as a newline marker in the
|
|
987
|
+
// output; splitInlineHTMLByLine() later splits the result back into per-line fragments.
|
|
988
|
+
// No inline rule starts on a newline (NotTriggerChar and `.` both exclude it), so handling
|
|
989
|
+
// it here keeps the tokenizer from stalling.
|
|
990
|
+
if (string.charCodeAt(0) === 10) {
|
|
991
|
+
processed += "\n";
|
|
992
|
+
string = string.substr(1);
|
|
993
|
+
offset += 1;
|
|
994
|
+
continue outer;
|
|
995
|
+
}
|
|
737
996
|
// Process simple rules (non-delimiter)
|
|
738
997
|
for (let rule of ["escape", "code", "autolink", "html"]) {
|
|
739
998
|
if (this.mergedInlineGrammar[rule]) {
|
|
@@ -741,7 +1000,7 @@ class Editor {
|
|
|
741
1000
|
if (cap) {
|
|
742
1001
|
string = string.substr(cap[0].length);
|
|
743
1002
|
offset += cap[0].length;
|
|
744
|
-
processed += this.mergedInlineGrammar[rule].replacement.replace(
|
|
1003
|
+
processed += this.mergedInlineGrammar[rule].replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
|
|
745
1004
|
continue outer;
|
|
746
1005
|
}
|
|
747
1006
|
}
|
|
@@ -753,11 +1012,24 @@ class Editor {
|
|
|
753
1012
|
if (cap) {
|
|
754
1013
|
string = string.substr(cap[0].length);
|
|
755
1014
|
offset += cap[0].length;
|
|
756
|
-
processed += this.mergedInlineGrammar[rule].replacement.replace(
|
|
1015
|
+
processed += this.mergedInlineGrammar[rule].replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
|
|
757
1016
|
continue outer;
|
|
758
1017
|
}
|
|
759
1018
|
}
|
|
760
1019
|
}
|
|
1020
|
+
// Check for GFM extended (non-delimited) autolinks: bare URLs (www.…, http(s)://…, ftp://…),
|
|
1021
|
+
// email addresses, and mailto:/xmpp: links. These are only recognized at the start of the
|
|
1022
|
+
// string or immediately after whitespace or one of the delimiters `*`, `_`, `~`, `(`.
|
|
1023
|
+
const precedingChar = offset > 0 ? originalString[offset - 1] : "";
|
|
1024
|
+
if (precedingChar === "" || grammar_1.editorRegExp.autolinkPrecedingChar.test(precedingChar)) {
|
|
1025
|
+
let autolink = this.parseAutolinkExtension(string);
|
|
1026
|
+
if (autolink) {
|
|
1027
|
+
processed = `${processed}${autolink.output}`;
|
|
1028
|
+
string = string.substr(autolink.charCount);
|
|
1029
|
+
offset += autolink.charCount;
|
|
1030
|
+
continue outer;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
761
1033
|
// Check for links / images
|
|
762
1034
|
let potentialLink = string.match(this.mergedInlineGrammar.linkOpen.regexp);
|
|
763
1035
|
let potentialImage = string.match(this.mergedInlineGrammar.imageOpen.regexp);
|
|
@@ -771,7 +1043,7 @@ class Editor {
|
|
|
771
1043
|
}
|
|
772
1044
|
}
|
|
773
1045
|
// Check for em / strong delimiters
|
|
774
|
-
let cap =
|
|
1046
|
+
let cap = grammar_1.editorRegExp.emphasisDelimiter.exec(string);
|
|
775
1047
|
if (cap) {
|
|
776
1048
|
let delimCount = cap[0].length;
|
|
777
1049
|
const delimString = cap[0];
|
|
@@ -781,8 +1053,8 @@ class Editor {
|
|
|
781
1053
|
const following = offset + cap[0].length < originalString.length ? string : " ";
|
|
782
1054
|
const punctuationFollows = following.match(grammar_1.punctuationLeading);
|
|
783
1055
|
const punctuationPrecedes = preceding.match(grammar_1.punctuationTrailing);
|
|
784
|
-
const whitespaceFollows = following.match(
|
|
785
|
-
const whitespacePrecedes = preceding.match(
|
|
1056
|
+
const whitespaceFollows = following.match(grammar_1.editorRegExp.leadingWhitespace);
|
|
1057
|
+
const whitespacePrecedes = preceding.match(grammar_1.editorRegExp.trailingWhitespace);
|
|
786
1058
|
let canOpen = !whitespaceFollows && (!punctuationFollows || !!whitespacePrecedes || !!punctuationPrecedes);
|
|
787
1059
|
let canClose = !whitespacePrecedes && (!punctuationPrecedes || !!whitespaceFollows || !!punctuationFollows);
|
|
788
1060
|
if (currentDelimiter === "_" && canOpen && canClose) {
|
|
@@ -835,7 +1107,7 @@ class Editor {
|
|
|
835
1107
|
continue outer;
|
|
836
1108
|
}
|
|
837
1109
|
// Check for strikethrough delimiter
|
|
838
|
-
cap =
|
|
1110
|
+
cap = grammar_1.editorRegExp.strikethroughDelimiter.exec(string);
|
|
839
1111
|
if (cap) {
|
|
840
1112
|
let consumed = false;
|
|
841
1113
|
let stackPointer = stack.length - 1;
|
|
@@ -872,7 +1144,7 @@ class Editor {
|
|
|
872
1144
|
if (cap) {
|
|
873
1145
|
string = string.substr(cap[0].length);
|
|
874
1146
|
offset += cap[0].length;
|
|
875
|
-
processed += this.mergedInlineGrammar.default.replacement.replace(
|
|
1147
|
+
processed += this.mergedInlineGrammar.default.replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
|
|
876
1148
|
continue outer;
|
|
877
1149
|
}
|
|
878
1150
|
throw "Infinite loop!";
|
|
@@ -1059,6 +1331,118 @@ class Editor {
|
|
|
1059
1331
|
}
|
|
1060
1332
|
});
|
|
1061
1333
|
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Attempts to parse a GFM extended (non-delimited) autolink at the start of `string`: a bare URL
|
|
1336
|
+
* (`www.…`, `http://…`, `https://…`, `ftp://…`), an email address, or a `mailto:`/`xmpp:` link.
|
|
1337
|
+
* The caller is responsible for the preceding-character boundary check. On success, returns the
|
|
1338
|
+
* rendered HTML (the matched text wrapped in a `TMAutolink` span, exactly like the content of an
|
|
1339
|
+
* angle-bracketed autolink) and the number of source characters consumed; otherwise `false`.
|
|
1340
|
+
*/
|
|
1341
|
+
parseAutolinkExtension(string) {
|
|
1342
|
+
const match = this.matchExtendedAutolink(string);
|
|
1343
|
+
if (!match)
|
|
1344
|
+
return false;
|
|
1345
|
+
return {
|
|
1346
|
+
output: `<span class="TMAutolink">${(0, grammar_1.htmlescape)(match)}</span>`,
|
|
1347
|
+
charCount: match.length,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
/** Returns the longest extended-autolink substring at the start of `string`, or `false`. */
|
|
1351
|
+
matchExtendedAutolink(string) {
|
|
1352
|
+
// --- Bare URL / www autolink -----------------------------------------------------------------
|
|
1353
|
+
// www. is treated as part of the domain; an explicit scheme is stripped before the domain.
|
|
1354
|
+
const schemeCap = grammar_1.editorRegExp.autolinkScheme.exec(string);
|
|
1355
|
+
const isWww = grammar_1.editorRegExp.autolinkWww.test(string);
|
|
1356
|
+
if (schemeCap || isWww) {
|
|
1357
|
+
const prefixLen = schemeCap ? schemeCap[0].length : 0;
|
|
1358
|
+
const afterPrefix = string.substr(prefixLen);
|
|
1359
|
+
// A valid domain is dot-separated segments of alphanumerics, `_` and `-`, with at least one
|
|
1360
|
+
// dot and no underscore in the last two segments.
|
|
1361
|
+
const domainCap = grammar_1.editorRegExp.autolinkDomain.exec(afterPrefix);
|
|
1362
|
+
if (domainCap && this.isValidAutolinkDomain(domainCap[0])) {
|
|
1363
|
+
// After the domain, any run of non-space, non-`<` characters forms the path/query/fragment.
|
|
1364
|
+
const pathCap = grammar_1.editorRegExp.autolinkPath.exec(afterPrefix.substr(domainCap[0].length));
|
|
1365
|
+
const link = string.substr(0, prefixLen + domainCap[0].length + (pathCap ? pathCap[0].length : 0));
|
|
1366
|
+
const trimmed = this.trimAutolinkTrailing(link);
|
|
1367
|
+
// Guard against trailing punctuation trimming having eaten into the domain.
|
|
1368
|
+
if (trimmed.length >= prefixLen + domainCap[0].length)
|
|
1369
|
+
return trimmed;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
// --- mailto: / xmpp: autolink ----------------------------------------------------------------
|
|
1373
|
+
const protoCap = grammar_1.editorRegExp.autolinkProtocol.exec(string);
|
|
1374
|
+
if (protoCap) {
|
|
1375
|
+
const emailCap = this.matchAutolinkEmail(string.substr(protoCap[0].length));
|
|
1376
|
+
if (emailCap) {
|
|
1377
|
+
// xmpp addresses may carry a `/resource` suffix.
|
|
1378
|
+
let extra = "";
|
|
1379
|
+
if (grammar_1.editorRegExp.autolinkXmpp.test(string)) {
|
|
1380
|
+
const resCap = grammar_1.editorRegExp.autolinkXmppResource.exec(string.substr(protoCap[0].length + emailCap.length));
|
|
1381
|
+
if (resCap)
|
|
1382
|
+
extra = resCap[0];
|
|
1383
|
+
}
|
|
1384
|
+
return protoCap[0] + emailCap + extra;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// --- Bare email autolink ---------------------------------------------------------------------
|
|
1388
|
+
const email = this.matchAutolinkEmail(string);
|
|
1389
|
+
if (email)
|
|
1390
|
+
return email;
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
/** GFM domain validity: at least one dot and no underscore in the last two segments. */
|
|
1394
|
+
isValidAutolinkDomain(domain) {
|
|
1395
|
+
const segments = domain.split(".");
|
|
1396
|
+
if (segments.length < 2)
|
|
1397
|
+
return false;
|
|
1398
|
+
return !segments.slice(-2).some((seg) => seg.indexOf("_") >= 0);
|
|
1399
|
+
}
|
|
1400
|
+
/** Matches a bare email address at the start of `string` per the GFM extension, or `false`. */
|
|
1401
|
+
matchAutolinkEmail(string) {
|
|
1402
|
+
// Local part: alphanumerics plus `.`, `-`, `_`, `+`. Domain: dot-separated labels of
|
|
1403
|
+
// alphanumerics, `-` and `_`, with at least one dot and no `-`/`_` as the final character.
|
|
1404
|
+
const cap = grammar_1.editorRegExp.autolinkEmail.exec(string);
|
|
1405
|
+
if (!cap)
|
|
1406
|
+
return false;
|
|
1407
|
+
let email = cap[0];
|
|
1408
|
+
while (email.length && grammar_1.editorRegExp.emailTrailingChar.test(email))
|
|
1409
|
+
email = email.substr(0, email.length - 1);
|
|
1410
|
+
// Must still end in a complete TLD (i.e. retain a dot after the trimming).
|
|
1411
|
+
if (!grammar_1.editorRegExp.emailTld.test(email))
|
|
1412
|
+
return false;
|
|
1413
|
+
return email;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Strips trailing characters that GFM excludes from an autolink: the punctuation `?!.,:*_~`, and
|
|
1417
|
+
* any unbalanced trailing `)` (more closing than opening parentheses in the link).
|
|
1418
|
+
*/
|
|
1419
|
+
trimAutolinkTrailing(link) {
|
|
1420
|
+
let end = link.length;
|
|
1421
|
+
let changed = true;
|
|
1422
|
+
while (changed && end > 0) {
|
|
1423
|
+
changed = false;
|
|
1424
|
+
const c = link[end - 1];
|
|
1425
|
+
if ("?!.,:*_~".indexOf(c) >= 0) {
|
|
1426
|
+
end--;
|
|
1427
|
+
changed = true;
|
|
1428
|
+
}
|
|
1429
|
+
else if (c === ")") {
|
|
1430
|
+
let open = 0;
|
|
1431
|
+
let close = 0;
|
|
1432
|
+
for (let i = 0; i < end; i++) {
|
|
1433
|
+
if (link[i] === "(")
|
|
1434
|
+
open++;
|
|
1435
|
+
else if (link[i] === ")")
|
|
1436
|
+
close++;
|
|
1437
|
+
}
|
|
1438
|
+
if (close > open) {
|
|
1439
|
+
end--;
|
|
1440
|
+
changed = true;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return link.substr(0, end);
|
|
1445
|
+
}
|
|
1062
1446
|
parseLinkOrImage(originalString, isImage) {
|
|
1063
1447
|
// Skip the opening bracket
|
|
1064
1448
|
let textOffset = isImage ? 2 : 1;
|
|
@@ -1099,7 +1483,7 @@ class Editor {
|
|
|
1099
1483
|
continue textOuter;
|
|
1100
1484
|
}
|
|
1101
1485
|
// Check for closing bracket
|
|
1102
|
-
if (string.match(
|
|
1486
|
+
if (string.match(grammar_1.editorRegExp.linkClosingBracket)) {
|
|
1103
1487
|
bracketLevel--;
|
|
1104
1488
|
if (bracketLevel === 0) {
|
|
1105
1489
|
linkText = originalString.substr(textOffset, currentOffset - textOffset);
|
|
@@ -1144,7 +1528,7 @@ class Editor {
|
|
|
1144
1528
|
inlineOuter: while (currentOffset < originalString.length && parenthesisLevel > 0) {
|
|
1145
1529
|
let string = originalString.substr(currentOffset);
|
|
1146
1530
|
// Process whitespace
|
|
1147
|
-
let cap =
|
|
1531
|
+
let cap = grammar_1.editorRegExp.leadingWhitespaceRun.exec(string);
|
|
1148
1532
|
if (cap) {
|
|
1149
1533
|
switch (linkDetails.length) {
|
|
1150
1534
|
case 0:
|
|
@@ -1154,7 +1538,7 @@ class Editor {
|
|
|
1154
1538
|
linkDetails.push(cap[0]);
|
|
1155
1539
|
break;
|
|
1156
1540
|
case 2:
|
|
1157
|
-
if (linkDetails[0].match(
|
|
1541
|
+
if (linkDetails[0].match(grammar_1.editorRegExp.containsOpenAngle)) {
|
|
1158
1542
|
linkDetails[1] = linkDetails[1].concat(cap[0]);
|
|
1159
1543
|
}
|
|
1160
1544
|
else {
|
|
@@ -1211,7 +1595,7 @@ class Editor {
|
|
|
1211
1595
|
continue inlineOuter;
|
|
1212
1596
|
}
|
|
1213
1597
|
// Process opening angle bracket
|
|
1214
|
-
if (linkDetails.length < 2 && string.match(
|
|
1598
|
+
if (linkDetails.length < 2 && string.match(grammar_1.editorRegExp.openingAngle)) {
|
|
1215
1599
|
if (linkDetails.length === 0)
|
|
1216
1600
|
linkDetails.push("");
|
|
1217
1601
|
linkDetails[0] = linkDetails[0].concat("<");
|
|
@@ -1219,7 +1603,7 @@ class Editor {
|
|
|
1219
1603
|
continue inlineOuter;
|
|
1220
1604
|
}
|
|
1221
1605
|
// Process closing angle bracket
|
|
1222
|
-
if ((linkDetails.length === 1 || linkDetails.length === 2) && string.match(
|
|
1606
|
+
if ((linkDetails.length === 1 || linkDetails.length === 2) && string.match(grammar_1.editorRegExp.closingAngle)) {
|
|
1223
1607
|
if (linkDetails.length === 1)
|
|
1224
1608
|
linkDetails.push("");
|
|
1225
1609
|
linkDetails.push(">");
|
|
@@ -1227,7 +1611,7 @@ class Editor {
|
|
|
1227
1611
|
continue inlineOuter;
|
|
1228
1612
|
}
|
|
1229
1613
|
// Process non-parenthesis delimiter for title
|
|
1230
|
-
cap =
|
|
1614
|
+
cap = grammar_1.editorRegExp.titleQuote.exec(string);
|
|
1231
1615
|
if (cap && (linkDetails.length === 0 || linkDetails.length === 1 || linkDetails.length === 4)) {
|
|
1232
1616
|
while (linkDetails.length < 4)
|
|
1233
1617
|
linkDetails.push("");
|
|
@@ -1243,7 +1627,7 @@ class Editor {
|
|
|
1243
1627
|
continue inlineOuter;
|
|
1244
1628
|
}
|
|
1245
1629
|
// Process opening parenthesis
|
|
1246
|
-
if (string.match(
|
|
1630
|
+
if (string.match(grammar_1.editorRegExp.openingParen)) {
|
|
1247
1631
|
switch (linkDetails.length) {
|
|
1248
1632
|
case 0:
|
|
1249
1633
|
linkDetails.push("");
|
|
@@ -1251,7 +1635,7 @@ class Editor {
|
|
|
1251
1635
|
linkDetails.push("");
|
|
1252
1636
|
case 2:
|
|
1253
1637
|
linkDetails[1] = linkDetails[1].concat("(");
|
|
1254
|
-
if (!linkDetails[0].match(
|
|
1638
|
+
if (!linkDetails[0].match(grammar_1.editorRegExp.endsWithOpenAngle))
|
|
1255
1639
|
parenthesisLevel++;
|
|
1256
1640
|
break;
|
|
1257
1641
|
case 3:
|
|
@@ -1273,11 +1657,11 @@ class Editor {
|
|
|
1273
1657
|
continue inlineOuter;
|
|
1274
1658
|
}
|
|
1275
1659
|
// Process closing parenthesis
|
|
1276
|
-
if (string.match(
|
|
1660
|
+
if (string.match(grammar_1.editorRegExp.closingParen)) {
|
|
1277
1661
|
if (linkDetails.length <= 2) {
|
|
1278
1662
|
while (linkDetails.length < 2)
|
|
1279
1663
|
linkDetails.push("");
|
|
1280
|
-
if (!linkDetails[0].match(
|
|
1664
|
+
if (!linkDetails[0].match(grammar_1.editorRegExp.endsWithOpenAngle))
|
|
1281
1665
|
parenthesisLevel--;
|
|
1282
1666
|
if (parenthesisLevel > 0) {
|
|
1283
1667
|
linkDetails[1] = linkDetails[1].concat(")");
|
|
@@ -1307,7 +1691,7 @@ class Editor {
|
|
|
1307
1691
|
continue inlineOuter;
|
|
1308
1692
|
}
|
|
1309
1693
|
// Any old character
|
|
1310
|
-
cap =
|
|
1694
|
+
cap = grammar_1.editorRegExp.anyChar.exec(string);
|
|
1311
1695
|
if (cap) {
|
|
1312
1696
|
switch (linkDetails.length) {
|
|
1313
1697
|
case 0:
|
|
@@ -1362,8 +1746,11 @@ class Editor {
|
|
|
1362
1746
|
while (linkDetails.length < 7) {
|
|
1363
1747
|
linkDetails.push("");
|
|
1364
1748
|
}
|
|
1749
|
+
// Every literal mark fragment is HTML-escaped: linkDetails[0]/[2] hold the angle brackets of
|
|
1750
|
+
// an `<...>` destination, so leaving them raw would inject a stray `<`/`>` into the output —
|
|
1751
|
+
// which splitInlineHTMLByLine then mistakes for an HTML tag.
|
|
1365
1752
|
return {
|
|
1366
|
-
output: `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type}">${this.processInlineStyles(linkText)}</span><span class="TMMark TMMark_${type}">](${linkDetails[0]}</span><span class="${type}Destination">${(0, grammar_1.htmlescape)(linkDetails[1])}</span><span class="TMMark TMMark_${type}">${linkDetails[2]}${linkDetails[3]}${linkDetails[4]}</span><span class="${type}Title">${(0, grammar_1.htmlescape)(linkDetails[5])}</span><span class="TMMark TMMark_${type}">${linkDetails[6]})</span>`,
|
|
1753
|
+
output: `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type}">${this.processInlineStyles(linkText)}</span><span class="TMMark TMMark_${type}">](${(0, grammar_1.htmlescape)(linkDetails[0])}</span><span class="${type}Destination">${(0, grammar_1.htmlescape)(linkDetails[1])}</span><span class="TMMark TMMark_${type}">${(0, grammar_1.htmlescape)(linkDetails[2])}${(0, grammar_1.htmlescape)(linkDetails[3])}${(0, grammar_1.htmlescape)(linkDetails[4])}</span><span class="${type}Title">${(0, grammar_1.htmlescape)(linkDetails[5])}</span><span class="TMMark TMMark_${type}">${(0, grammar_1.htmlescape)(linkDetails[6])})</span>`,
|
|
1367
1754
|
charCount: currentOffset,
|
|
1368
1755
|
};
|
|
1369
1756
|
}
|
|
@@ -1532,7 +1919,7 @@ class Editor {
|
|
|
1532
1919
|
: { startCol: anchor.col, endCol: focus.col };
|
|
1533
1920
|
let match = this.lines[focus.row]
|
|
1534
1921
|
.substr(startCol, endCol - startCol)
|
|
1535
|
-
.match(
|
|
1922
|
+
.match(grammar_1.editorRegExp.surroundingWhitespace);
|
|
1536
1923
|
if (match) {
|
|
1537
1924
|
startCol += match.groups.leading.length;
|
|
1538
1925
|
endCol -= match.groups.trailing.length;
|