@kubex/zinc 1.1.165 → 1.1.168
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/dist/custom-elements.json +98 -74
- package/dist/vscode.html-custom-data.json +11 -11
- package/dist/web-types.json +23 -23
- package/dist/zn.d.ts +27 -0
- package/dist/zn.min.js +599 -570
- package/docs/pages/components/remarkd-editor.md +1 -1
- package/package.json +1 -1
- package/src/components/remarkd-editor/actions.ts +2 -2
- package/src/components/remarkd-editor/list-continuation.test.ts +77 -1
- package/src/components/remarkd-editor/list-continuation.ts +117 -16
- package/src/components/remarkd-editor/paste.test.ts +119 -0
- package/src/components/remarkd-editor/paste.ts +241 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +38 -5
- package/src/components/remarkd-editor/remarkd-editor.test.ts +85 -1
|
@@ -160,7 +160,7 @@ outgrows the 20rem cap set here."
|
|
|
160
160
|
### Actions
|
|
161
161
|
|
|
162
162
|
The toolbar and the `/` slash menu are driven by one shared registry of actions, grouped
|
|
163
|
-
under ten headings: **Text**, **Inline**, **Lists**, **
|
|
163
|
+
under ten headings: **Text**, **Inline**, **Lists**, **Alerts**, **Blocks**,
|
|
164
164
|
**Structured**, **Media**, **Objects**, **Breaks**, and **Logic**. The toolbar shows as
|
|
165
165
|
many groups as fit its width and folds the rest into an overflow menu; the slash menu
|
|
166
166
|
carries every action regardless of width, and typing narrows it by label or keyword — so
|
package/package.json
CHANGED
|
@@ -42,7 +42,7 @@ export const ACTION_GROUPS: { id: ActionGroup; label: string }[] = [
|
|
|
42
42
|
// they are among the last groups to collapse, not the first.
|
|
43
43
|
{id: 'inline', label: 'Inline'},
|
|
44
44
|
{id: 'lists', label: 'Lists'},
|
|
45
|
-
{id: 'admonitions', label: '
|
|
45
|
+
{id: 'admonitions', label: 'Alerts'},
|
|
46
46
|
{id: 'blocks', label: 'Blocks'},
|
|
47
47
|
{id: 'structured', label: 'Structured'},
|
|
48
48
|
{id: 'media', label: 'Media'},
|
|
@@ -67,7 +67,7 @@ export const EDITOR_ACTIONS: EditorAction[] = [
|
|
|
67
67
|
{key: 'checkboxes', label: 'Checklist', icon: 'list-todo@lu', group: 'lists', prefix: '- [ ] ', keywords: ['task', 'todo']},
|
|
68
68
|
{key: 'definition-list', label: 'Definition list', icon: 'book-a@lu', group: 'lists', prefix: 'Term:: Definition'},
|
|
69
69
|
|
|
70
|
-
//
|
|
70
|
+
// Alerts
|
|
71
71
|
{key: 'admonition', label: 'Note', icon: 'info@lu', group: 'admonitions', prefix: 'NOTE: '},
|
|
72
72
|
{key: 'tip', label: 'Tip', icon: 'lightbulb@lu', group: 'admonitions', prefix: 'TIP: '},
|
|
73
73
|
{key: 'warning', label: 'Warning', icon: 'triangle-alert@lu', group: 'admonitions', prefix: 'WARNING: '},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {continueList} from './list-continuation';
|
|
1
|
+
import {continueList, indentList} from './list-continuation';
|
|
2
2
|
import {expect} from '@open-wc/testing';
|
|
3
3
|
|
|
4
4
|
/** Enter at `caret` (the end by default), rendered as the new value with `|` at the caret, or null. */
|
|
@@ -12,15 +12,23 @@ describe('continueList', () => {
|
|
|
12
12
|
expect(enter('- a')).to.equal('- a\n- |');
|
|
13
13
|
expect(enter('* a')).to.equal('* a\n* |');
|
|
14
14
|
expect(enter('-- nested')).to.equal('-- nested\n-- |');
|
|
15
|
+
expect(enter('- a\n---- deep\n---- deeper')).to.equal('- a\n---- deep\n---- deeper\n---- |');
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
it('should continue numbered lists with the next number', () => {
|
|
18
19
|
expect(enter('1. hello')).to.equal('1. hello\n2. |');
|
|
19
20
|
expect(enter('9. nine')).to.equal('9. nine\n10. |');
|
|
21
|
+
expect(enter('1. a\n1.1. b')).to.equal('1. a\n1.1. b\n1.2. |');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should number skipped levels from one', () => {
|
|
25
|
+
expect(enter('1. a\n1.1.1. b\n2. c', 4)).to.equal('1. a\n2. |\n2.1.1. b\n3. c');
|
|
20
26
|
});
|
|
21
27
|
|
|
22
28
|
it('should renumber the items after one inserted mid-list', () => {
|
|
23
29
|
expect(enter('1. a\n2. b\n3. c', 4)).to.equal('1. a\n2. |\n3. b\n4. c');
|
|
30
|
+
expect(enter('1. a\n1.1. b\n2. c', 4)).to.equal('1. a\n2. |\n2.1. b\n3. c');
|
|
31
|
+
expect(enter('1. a\n-- x\n2. b', 4)).to.equal('1. a\n2. |\n-- x\n3. b');
|
|
24
32
|
});
|
|
25
33
|
|
|
26
34
|
it('should continue checklists unticked, keeping the editable kind', () => {
|
|
@@ -35,6 +43,7 @@ describe('continueList', () => {
|
|
|
35
43
|
});
|
|
36
44
|
|
|
37
45
|
it('should end the list when Enter is pressed on an empty item', () => {
|
|
46
|
+
expect(enter('1. a\n1.1. ')).to.equal('1. a\n|');
|
|
38
47
|
expect(enter('- a\n- ')).to.equal('- a\n|');
|
|
39
48
|
expect(enter('1. a\n2. ')).to.equal('1. a\n|');
|
|
40
49
|
expect(enter('- [ ] a\n- [ ] ')).to.equal('- [ ] a\n|');
|
|
@@ -65,3 +74,70 @@ describe('continueList', () => {
|
|
|
65
74
|
expect(continueList('- abc', 2, 4)).to.be.null;
|
|
66
75
|
});
|
|
67
76
|
});
|
|
77
|
+
|
|
78
|
+
/** Tab over `[start, end]` (a caret at the end by default), rendered with `[`/`]` around the selection, or null. */
|
|
79
|
+
function tab(value: string, outdent = false, start = value.length, end = start): string | null {
|
|
80
|
+
const edit = indentList(value, start, end, outdent);
|
|
81
|
+
if (!edit) return null;
|
|
82
|
+
const v = edit.value;
|
|
83
|
+
return edit.start === edit.end
|
|
84
|
+
? `${v.slice(0, edit.start)}|${v.slice(edit.start)}`
|
|
85
|
+
: `${v.slice(0, edit.start)}[${v.slice(edit.start, edit.end)}]${v.slice(edit.end)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('indentList', () => {
|
|
89
|
+
it('should nest a bullet item one level deeper on Tab', () => {
|
|
90
|
+
expect(tab('- a')).to.equal('-- a|');
|
|
91
|
+
expect(tab('* a')).to.equal('** a|');
|
|
92
|
+
expect(tab('- [ ] task')).to.equal('-- [ ] task|');
|
|
93
|
+
expect(tab('- a\n- b')).to.equal('- a\n-- b|');
|
|
94
|
+
expect(tab('---- a\n---- b')).to.equal('---- a\n----- b|');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should un-nest a bullet item on Shift+Tab, stopping at the top level', () => {
|
|
98
|
+
expect(tab('--- a', true)).to.equal('-- a|');
|
|
99
|
+
expect(tab('- a', true)).to.equal('- a|');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should stop at the deepest level the parser supports', () => {
|
|
103
|
+
expect(tab('---------- a')).to.equal('---------- a|');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should keep the caret on the same text', () => {
|
|
107
|
+
expect(tab('- hello', false, 4)).to.equal('-- he|llo');
|
|
108
|
+
expect(tab('-- hello', true, 5)).to.equal('- he|llo');
|
|
109
|
+
expect(tab('- a', false, 0)).to.equal('|-- a');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should nest every bullet item a selection touches', () => {
|
|
113
|
+
const value = '- a\n- b\n- c';
|
|
114
|
+
expect(tab(value, false, 4, 11)).to.equal('- a\n[-- b\n-- c]');
|
|
115
|
+
expect(tab(value, false, 0, 4)).to.equal('[-- a\n]- b\n- c');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should nest a numbered item under the one above, renumbering the items after it', () => {
|
|
119
|
+
expect(tab('1. a\n2. b')).to.equal('1. a\n1.1. b|');
|
|
120
|
+
expect(tab('1. a\n2. b\n3. c', false, 9)).to.equal('1. a\n1.1. b|\n2. c');
|
|
121
|
+
expect(tab('1. a\n1.1. b\n2. c')).to.equal('1. a\n1.1. b\n1.2. c|');
|
|
122
|
+
expect(tab('1. a\n-- x\n2. b')).to.equal('1. a\n-- x\n1.1. b|');
|
|
123
|
+
expect(tab('1. first')).to.equal('1. first|');
|
|
124
|
+
expect(tab('1. a\n1.1. b')).to.equal('1. a\n1.1. b|');
|
|
125
|
+
expect(tab('1. a\n1.1. b\n1.2. c')).to.equal('1. a\n1.1. b\n1.1.1. c|');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should un-nest a numbered item on Shift+Tab, adopting the items after it', () => {
|
|
129
|
+
expect(tab('1. a\n1.1. b\n1.2. c\n2. d', true, 10)).to.equal('1. a\n2. |b\n2.1. c\n3. d');
|
|
130
|
+
expect(tab('1. a', true)).to.equal('1. a|');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should keep the caret on the same text when the marker changes length', () => {
|
|
134
|
+
expect(tab('1. a\n2. hello', false, 10)).to.equal('1. a\n1.1. he|llo');
|
|
135
|
+
expect(tab('1. a\n1.1. hello', true, 12)).to.equal('1. a\n2. he|llo');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should leave Tab alone outside list items', () => {
|
|
139
|
+
expect(tab('plain text')).to.be.null;
|
|
140
|
+
expect(tab('CPU:: x')).to.be.null;
|
|
141
|
+
expect(tab('```\n- a')).to.be.null;
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -4,14 +4,15 @@ export interface TextEdit {
|
|
|
4
4
|
caret: number;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
// Nesting depth is the marker's repeat count (`--`, `**`), never indentation.
|
|
8
|
-
const LIST_ITEM = /^(-+|\*+|(
|
|
9
|
-
const
|
|
7
|
+
// Nesting depth is the marker's repeat count (`--`, `**`) or dot count (`1.2.`), never indentation.
|
|
8
|
+
const LIST_ITEM = /^(-+|\*+|((?:\d+\.)+)) (\[[ xX_*]\] )?/;
|
|
9
|
+
const ORDERED_PATH = /^(?:\d+\.)+(?= )/;
|
|
10
10
|
// `::` must end the line or be followed by a space, so macros (`include::x[]`) never match.
|
|
11
11
|
const DEFINITION = /^[^:\s][^\n]*?::(?: |$)/;
|
|
12
12
|
|
|
13
13
|
// Code, listing and literal blocks — their content is verbatim, so a `- ` there is not a list.
|
|
14
|
-
|
|
14
|
+
// A `----`/`....` delimiter is the whole line; `---- x` is a fourth-level bullet.
|
|
15
|
+
const VERBATIM_DELIMITER = /^(?:`{3,}|-{4,} *$|\.{4,} *$)/;
|
|
15
16
|
|
|
16
17
|
function insideVerbatim(before: string): boolean {
|
|
17
18
|
return before.split('\n').filter(line => VERBATIM_DELIMITER.test(line)).length % 2 === 1;
|
|
@@ -58,22 +59,122 @@ export function continueList(value: string, start: number, end: number): TextEdi
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const [, bullet, ordinal, box] = item!;
|
|
61
|
-
const
|
|
62
|
+
const own = ordinal ? parsePath(ordinal) : null;
|
|
63
|
+
const path = own && nextPath(own, own.length);
|
|
64
|
+
const next = (path ? formatPath(path) : bullet) + ' ' + (box ? nextCheckbox(box) : '');
|
|
62
65
|
const head = `${value.slice(0, start)}\n${next}`;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
const edited = head + value.slice(start).replace(/^ +/, '');
|
|
67
|
+
if (!path) return {value: edited, caret: head.length};
|
|
68
|
+
|
|
69
|
+
const lines = edited.split('\n');
|
|
70
|
+
renumber(lines, head.split('\n').length);
|
|
71
|
+
return {value: lines.join('\n'), caret: head.length};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The numbers of an ordered marker: `1.2.` is `[1, 2]`. */
|
|
75
|
+
function parsePath(marker: string): number[] {
|
|
76
|
+
return marker.split('.').filter(Boolean).map(Number);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatPath(path: number[]): string {
|
|
80
|
+
return path.map(n => `${n}.`).join('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The marker of the ordered item at `depth` that follows the one numbered `previous`. */
|
|
84
|
+
function nextPath(previous: number[], depth: number): number[] {
|
|
85
|
+
if (depth <= previous.length) return [...previous.slice(0, depth - 1), previous[depth - 1] + 1];
|
|
86
|
+
return [...previous, ...Array<number>(depth - previous.length).fill(1)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The number of the nearest ordered item above line `i` in the same list, passing over nested bullets. */
|
|
90
|
+
function orderedAbove(lines: string[], i: number): number[] | null {
|
|
91
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
92
|
+
const item = LIST_ITEM.exec(lines[j]);
|
|
93
|
+
if (!item) return null;
|
|
94
|
+
if (item[2]) return parsePath(item[2]);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Renumbers the ordered items from line `from` on as an outline counting on from the item above,
|
|
101
|
+
* so the source stays in step with the rendered numbering. Bullet items nested among them are
|
|
102
|
+
* passed over; any other line ends the list. With no ordered item above, the first keeps its
|
|
103
|
+
* number.
|
|
104
|
+
*/
|
|
105
|
+
function renumber(lines: string[], from: number) {
|
|
106
|
+
let path = orderedAbove(lines, from);
|
|
107
|
+
for (let i = from; i < lines.length; i++) {
|
|
108
|
+
const item = LIST_ITEM.exec(lines[i]);
|
|
109
|
+
if (!item) break;
|
|
110
|
+
if (!item[2]) continue;
|
|
111
|
+
const own = parsePath(item[2]);
|
|
112
|
+
path = path ? nextPath(path, own.length) : own;
|
|
113
|
+
lines[i] = lines[i].replace(ORDERED_PATH, formatPath(path));
|
|
114
|
+
}
|
|
66
115
|
}
|
|
67
116
|
|
|
117
|
+
/** A textarea edit that keeps a selection: the new value and the selected range in it. */
|
|
118
|
+
export interface RangeEdit {
|
|
119
|
+
value: string;
|
|
120
|
+
start: number;
|
|
121
|
+
end: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The parser caps bullet depth at ten marker characters.
|
|
125
|
+
const MAX_BULLET_DEPTH = 10;
|
|
126
|
+
|
|
68
127
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
128
|
+
* What Tab (or Shift+Tab when `outdent`) does on the list items the selection touches: nests
|
|
129
|
+
* each one level deeper or shallower, renumbering the ordered items that follow. Null when no
|
|
130
|
+
* list item is touched, so Tab keeps its default. Items already at the limit are left as they
|
|
131
|
+
* are, but the key is still claimed.
|
|
71
132
|
*/
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
export function indentList(value: string, start: number, end: number, outdent: boolean): RangeEdit | null {
|
|
134
|
+
const firstStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
135
|
+
if (insideVerbatim(value.slice(0, firstStart))) return null;
|
|
136
|
+
// A selection ending at the start of a line does not touch that line.
|
|
137
|
+
const last = end > start && value[end - 1] === '\n' ? end - 1 : end;
|
|
138
|
+
|
|
139
|
+
const before = value.split('\n');
|
|
140
|
+
const lines = [...before];
|
|
141
|
+
const first = value.slice(0, firstStart).split('\n').length - 1;
|
|
142
|
+
const lastIndex = value.slice(0, last).split('\n').length - 1;
|
|
143
|
+
|
|
144
|
+
let touched = false;
|
|
145
|
+
let renumberFrom = -1;
|
|
146
|
+
for (let i = first; i <= lastIndex; i++) {
|
|
147
|
+
const item = LIST_ITEM.exec(lines[i]);
|
|
148
|
+
if (!item) continue;
|
|
149
|
+
touched = true;
|
|
150
|
+
const [, bullet, ordinal] = item;
|
|
151
|
+
if (ordinal) {
|
|
152
|
+
const own = parsePath(ordinal);
|
|
153
|
+
// An item nests at most one level below the item above it.
|
|
154
|
+
const deepest = (orderedAbove(lines, i)?.length ?? 0) + 1;
|
|
155
|
+
if (outdent ? own.length === 1 : own.length >= deepest) continue;
|
|
156
|
+
lines[i] = lines[i].replace(ORDERED_PATH, formatPath(outdent ? own.slice(0, -1) : [...own, 1]));
|
|
157
|
+
if (renumberFrom === -1) renumberFrom = i;
|
|
158
|
+
} else if (outdent ? bullet.length > 1 : bullet.length < MAX_BULLET_DEPTH) {
|
|
159
|
+
lines[i] = outdent ? lines[i].slice(1) : bullet[0] + lines[i];
|
|
160
|
+
}
|
|
77
161
|
}
|
|
78
|
-
|
|
162
|
+
if (!touched) return null;
|
|
163
|
+
if (renumberFrom !== -1) renumber(lines, renumberFrom);
|
|
164
|
+
|
|
165
|
+
const move = (pos: number) => {
|
|
166
|
+
let oldAt = 0;
|
|
167
|
+
let newAt = 0;
|
|
168
|
+
for (let i = 0; i < before.length; i++) {
|
|
169
|
+
const oldEnd = oldAt + before[i].length;
|
|
170
|
+
if (pos <= oldEnd) {
|
|
171
|
+
const delta = lines[i].length - before[i].length;
|
|
172
|
+
return pos > oldAt ? Math.max(newAt, pos + (newAt - oldAt) + delta) : newAt;
|
|
173
|
+
}
|
|
174
|
+
oldAt = oldEnd + 1;
|
|
175
|
+
newAt += lines[i].length + 1;
|
|
176
|
+
}
|
|
177
|
+
return pos;
|
|
178
|
+
};
|
|
179
|
+
return {value: lines.join('\n'), start: move(start), end: move(end)};
|
|
79
180
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {expect} from '@open-wc/testing';
|
|
2
|
+
import {htmlToRemarkd, pastedRemarkd, plainTextToRemarkd} from './paste';
|
|
3
|
+
import {parse} from 'remarkd-js';
|
|
4
|
+
|
|
5
|
+
function clipboard(data: Record<string, string>): DataTransfer {
|
|
6
|
+
const transfer = new DataTransfer();
|
|
7
|
+
for (const [type, value] of Object.entries(data)) transfer.setData(type, value);
|
|
8
|
+
return transfer;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('htmlToRemarkd', () => {
|
|
12
|
+
it('should keep nested bullet and numbered lists', () => {
|
|
13
|
+
expect(htmlToRemarkd('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'))
|
|
14
|
+
.to.equal('- a\n-- b\n--- c\n- d');
|
|
15
|
+
expect(htmlToRemarkd('<ol><li>a</li><li>b<ol><li>c</li><li>d</li></ol></li><li>e</li></ol>'))
|
|
16
|
+
.to.equal('1. a\n2. b\n2.1. c\n2.2. d\n3. e');
|
|
17
|
+
expect(htmlToRemarkd('<ol start="4"><li>four</li></ol>')).to.equal('4. four');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should nest numbered and bullet lists inside each other', () => {
|
|
21
|
+
expect(htmlToRemarkd('<ol><li>a<ul><li>b</li></ul></li></ol>')).to.equal('1. a\n- b');
|
|
22
|
+
expect(htmlToRemarkd('<ul><li>a<ol><li>b</li></ol></li></ul>')).to.equal('- a\n1. b');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should keep a list item on one line', () => {
|
|
26
|
+
expect(htmlToRemarkd('<ul><li><p>first</p><p>second</p></li><li>line<br>break</li></ul>'))
|
|
27
|
+
.to.equal('- first second\n- line break');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should keep checkboxes in list items', () => {
|
|
31
|
+
expect(htmlToRemarkd('<ul><li><input type="checkbox" checked> done</li><li><input type="checkbox"> todo</li></ul>'))
|
|
32
|
+
.to.equal('- [x] done\n- [ ] todo');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should keep headings, paragraphs and inline formatting', () => {
|
|
36
|
+
expect(htmlToRemarkd('<h2>Title</h2><p>Some <b>bold</b>, <em>italic</em>, <code>code</code>, <s>gone</s> and '
|
|
37
|
+
+ '<a href="https://example.com">a link</a>.</p><p>Next</p>'))
|
|
38
|
+
.to.equal('## Title\n\nSome **bold**, __italic__, `code`, ~~gone~~ and [a link](https://example.com).\n\nNext');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should keep the spaces around a mark outside it', () => {
|
|
42
|
+
expect(htmlToRemarkd('<p>a<b> bold </b>b</p>')).to.equal('a **bold** b');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should not nest a mark inside the same mark', () => {
|
|
46
|
+
expect(htmlToRemarkd('<p><b>a <strong>b</strong></b></p>')).to.equal('**a b**');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should drop links that are not web, mail or phone links', () => {
|
|
50
|
+
expect(htmlToRemarkd('<p><a href="javascript:alert(1)">x</a> <a href="#top">y</a> <b>z</b></p>')).to.equal('x y **z**');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should keep code blocks verbatim', () => {
|
|
54
|
+
expect(htmlToRemarkd('<pre><code> indented\n- not a list\n</code></pre>'))
|
|
55
|
+
.to.equal('```\n indented\n- not a list\n```');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should keep quotes, rules and tables', () => {
|
|
59
|
+
expect(htmlToRemarkd('<blockquote><p>quoted</p></blockquote><hr>'))
|
|
60
|
+
.to.equal('____\nquoted\n____\n\n---');
|
|
61
|
+
expect(htmlToRemarkd('<table><tr><th>Name</th><th>Value</th></tr><tr><td>Alpha</td><td><b>1</b></td></tr></table>'))
|
|
62
|
+
.to.equal('|===\n|Name |Value\n\n|Alpha |**1**\n|===');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should read Google Docs styling', () => {
|
|
66
|
+
const html = '<meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-1">'
|
|
67
|
+
+ '<p dir="ltr"><span style="font-weight:700;">Bold</span><span style="font-weight:400;"> and </span>'
|
|
68
|
+
+ '<span style="font-style:italic;">italic</span></p>'
|
|
69
|
+
+ '<ul><li dir="ltr" aria-level="1"><p dir="ltr"><span>one</span></p></li>'
|
|
70
|
+
+ '<ul><li dir="ltr" aria-level="2"><p dir="ltr"><span>nested</span></p></li></ul>'
|
|
71
|
+
+ '<li dir="ltr" aria-level="1"><p dir="ltr"><span>two</span></p></li></ul></b>';
|
|
72
|
+
expect(htmlToRemarkd(html)).to.equal('**Bold** and __italic__\n\n- one\n-- nested\n- two');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should read Word list paragraphs', () => {
|
|
76
|
+
const html = '<p class="MsoListParagraphCxSpFirst" style="mso-list:l0 level1 lfo1">'
|
|
77
|
+
+ '<span style="mso-list:Ignore">1.<span> </span></span>First</p>'
|
|
78
|
+
+ '<p class="MsoListParagraphCxSpMiddle" style="mso-list:l0 level2 lfo1">'
|
|
79
|
+
+ '<span style="mso-list:Ignore">a.<span> </span></span>Sub</p>'
|
|
80
|
+
+ '<p class="MsoListParagraphCxSpLast" style="mso-list:l0 level1 lfo1">'
|
|
81
|
+
+ '<span style="mso-list:Ignore">2.<span> </span></span>Second</p>'
|
|
82
|
+
+ '<p class="MsoNormal">After</p>'
|
|
83
|
+
+ '<p class="MsoListParagraph" style="mso-list:l1 level1 lfo2">'
|
|
84
|
+
+ '<span style="font-family:Symbol"><span style="mso-list:Ignore">·<span> </span></span></span>Bullet</p>';
|
|
85
|
+
expect(htmlToRemarkd(html)).to.equal('1. First\n1.1. Sub\n2. Second\n\nAfter\n\n- Bullet');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should produce source remarkd renders as the same structure', () => {
|
|
89
|
+
const source = htmlToRemarkd('<ol><li>a<ol><li>b</li></ol></li><li>c</li></ol>')!;
|
|
90
|
+
expect(parse(source)).to.contain('<ol><li><p>a</p>\n<ol><li><p>b</p></li></ol></li>\n<li><p>c</p></li></ol>');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should leave HTML with no formatting to the plain-text paste', () => {
|
|
94
|
+
expect(htmlToRemarkd('<div><span style="color:red">const</span> x = 1;</div>')).to.be.null;
|
|
95
|
+
expect(htmlToRemarkd('<p>just text</p>')).to.be.null;
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('plainTextToRemarkd', () => {
|
|
100
|
+
it('should turn bullet glyphs into list markers, nesting by indent', () => {
|
|
101
|
+
expect(plainTextToRemarkd('Intro\n• one\n ◦ nested\n• two')).to.equal('Intro\n- one\n-- nested\n- two');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should leave text without bullet glyphs alone', () => {
|
|
105
|
+
expect(plainTextToRemarkd('- already remarkd')).to.be.null;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('pastedRemarkd', () => {
|
|
110
|
+
it('should prefer the HTML flavour', () => {
|
|
111
|
+
expect(pastedRemarkd(clipboard({'text/html': '<ul><li>a</li></ul>', 'text/plain': 'a'}))).to.equal('- a');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should leave code editor copies to the plain-text paste', () => {
|
|
115
|
+
expect(pastedRemarkd(clipboard({
|
|
116
|
+
'text/html': '<div><b>**bold**</b></div>', 'text/plain': '**bold**', 'vscode-editor-data': '{}',
|
|
117
|
+
}))).to.be.null;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const BLOCK_TAGS = new Set([
|
|
2
|
+
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', 'FIGURE', 'FOOTER',
|
|
3
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION',
|
|
4
|
+
'TABLE', 'UL',
|
|
5
|
+
]);
|
|
6
|
+
const SKIPPED_TAGS = new Set(['HEAD', 'META', 'LINK', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE', 'NOSCRIPT']);
|
|
7
|
+
|
|
8
|
+
// Markup that carries formatting worth keeping; HTML without any of it (e.g. a code editor's
|
|
9
|
+
// coloured spans) pastes as its plain text.
|
|
10
|
+
const FORMATTED = 'ul, ol, h1, h2, h3, h4, h5, h6, table, blockquote, pre, hr, b, strong, i, em, u, s, strike, del, '
|
|
11
|
+
+ 'code, a[href], [style*="font-weight"], [style*="italic"], [style*="line-through"], [style*="mso-list"]';
|
|
12
|
+
|
|
13
|
+
// Word marks list paragraphs with `mso-list:l0 level2 lfo1`, and its bullet/number with `mso-list:Ignore`.
|
|
14
|
+
const WORD_LIST_LEVEL = /mso-list:\s*l\d+\s+level(\d+)/i;
|
|
15
|
+
const WORD_LIST_MARKER = 'span[style*="mso-list:Ignore" i], span[style*="mso-list: Ignore" i]';
|
|
16
|
+
const ORDERED_MARKER = /^\s*(\d+|[a-z]{1,4})[.)]/i;
|
|
17
|
+
|
|
18
|
+
const PLAIN_BULLET = /^([ \t]*)[•◦▪▫●○■□·‣⁃]\s+/;
|
|
19
|
+
|
|
20
|
+
type Style = 'strong' | 'emphasis' | 'underline' | 'strike' | 'code';
|
|
21
|
+
const MARKS: Record<Style, string> = {strong: '**', emphasis: '__', underline: '___', strike: '~~', code: '`'};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The clipboard as remarkd source, keeping the lists, headings, tables and inline formatting of
|
|
25
|
+
* rich content. Null when a plain-text paste already says the same thing.
|
|
26
|
+
*/
|
|
27
|
+
export function pastedRemarkd(data: DataTransfer | null): string | null {
|
|
28
|
+
if (!data) return null;
|
|
29
|
+
// VS Code's HTML is just coloured spans around the source that text/plain already holds.
|
|
30
|
+
if (data.types.includes('vscode-editor-data')) return null;
|
|
31
|
+
const html = data.getData('text/html');
|
|
32
|
+
const converted = html ? htmlToRemarkd(html) : null;
|
|
33
|
+
return converted ?? plainTextToRemarkd(data.getData('text/plain'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Remarkd source for pasted HTML, or null when it has no formatting to keep. */
|
|
37
|
+
export function htmlToRemarkd(html: string): string | null {
|
|
38
|
+
const body = new DOMParser().parseFromString(html, 'text/html').body;
|
|
39
|
+
if (!body.querySelector(FORMATTED)) return null;
|
|
40
|
+
const source = tidy(blocks(body).join('\n\n'));
|
|
41
|
+
return source || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Turns bullet glyphs (as copied from PDFs and slides) into list markers, or null when there are none. */
|
|
45
|
+
export function plainTextToRemarkd(text: string): string | null {
|
|
46
|
+
if (!text || !text.split('\n').some(line => PLAIN_BULLET.test(line))) return null;
|
|
47
|
+
return text.replace(/\r\n?/g, '\n').split('\n').map(line => line.replace(PLAIN_BULLET, (_, indent: string) =>
|
|
48
|
+
'-'.repeat(1 + Math.min(9, Math.floor(indent.replace(/\t/g, ' ').length / 2))) + ' ')).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tidy(source: string): string {
|
|
52
|
+
return source.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isBlock(node: Node): node is HTMLElement {
|
|
56
|
+
return node instanceof HTMLElement && (BLOCK_TAGS.has(node.tagName) || !!node.querySelector(
|
|
57
|
+
[...BLOCK_TAGS].join(',')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wordListLevel(el: Element): number | null {
|
|
61
|
+
const level = WORD_LIST_LEVEL.exec(el.getAttribute('style') ?? '');
|
|
62
|
+
return level ? Number(level[1]) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The blocks of `root`, each a remarkd block with no blank lines inside it unless it is a container. */
|
|
66
|
+
function blocks(root: Node): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
let run: Node[] = [];
|
|
69
|
+
const flush = () => {
|
|
70
|
+
const text = paragraph(run);
|
|
71
|
+
if (text) out.push(text);
|
|
72
|
+
run = [];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const children = Array.from(root.childNodes);
|
|
76
|
+
for (let i = 0; i < children.length; i++) {
|
|
77
|
+
const node = children[i];
|
|
78
|
+
if (node instanceof Element && SKIPPED_TAGS.has(node.tagName)) continue;
|
|
79
|
+
if (node instanceof Element && wordListLevel(node) !== null) {
|
|
80
|
+
flush();
|
|
81
|
+
const items: Element[] = [];
|
|
82
|
+
while (i < children.length) {
|
|
83
|
+
const next = children[i];
|
|
84
|
+
if (next instanceof Element && wordListLevel(next) !== null) items.push(next);
|
|
85
|
+
else if (!(next.nodeType === Node.TEXT_NODE && !next.textContent!.trim())) break;
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
i--;
|
|
89
|
+
out.push(wordList(items));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!isBlock(node)) {
|
|
93
|
+
run.push(node);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
out.push(...block(node));
|
|
98
|
+
}
|
|
99
|
+
flush();
|
|
100
|
+
return out.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function block(el: HTMLElement): string[] {
|
|
104
|
+
const tag = el.tagName;
|
|
105
|
+
if (/^H[1-6]$/.test(tag)) {
|
|
106
|
+
// Headings render bold already; a bold span inside one is just the source app's styling.
|
|
107
|
+
const text = oneLine(inline(el, new Set(['strong'])));
|
|
108
|
+
return text ? [`${'#'.repeat(Number(tag[1]))} ${text}`] : [];
|
|
109
|
+
}
|
|
110
|
+
if (tag === 'UL' || tag === 'OL') return [listLines(el, 0, []).join('\n')];
|
|
111
|
+
if (tag === 'HR') return ['---'];
|
|
112
|
+
if (tag === 'PRE') {
|
|
113
|
+
const code = (el.textContent ?? '').replace(/\n$/, '');
|
|
114
|
+
return code.trim() ? ['```\n' + code + '\n```'] : [];
|
|
115
|
+
}
|
|
116
|
+
if (tag === 'BLOCKQUOTE') {
|
|
117
|
+
const inner = blocks(el);
|
|
118
|
+
return inner.length ? [`____\n${inner.join('\n\n')}\n____`] : [];
|
|
119
|
+
}
|
|
120
|
+
if (tag === 'TABLE') return [table(el)];
|
|
121
|
+
return blocks(el);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function paragraph(nodes: Node[]): string {
|
|
125
|
+
return nodes.map(node => inline(node)).join('')
|
|
126
|
+
.split('\n').map(line => line.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function oneLine(text: string): string {
|
|
130
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function styleOf(el: HTMLElement): Style[] {
|
|
134
|
+
const styles: Style[] = [];
|
|
135
|
+
const tag = el.tagName;
|
|
136
|
+
const weight = el.style.fontWeight;
|
|
137
|
+
const decoration = el.style.textDecorationLine || el.style.textDecoration;
|
|
138
|
+
// Google Docs wraps the whole selection in `<b style="font-weight:normal">`.
|
|
139
|
+
const unbolded = weight === 'normal' || (Number(weight) > 0 && Number(weight) < 600);
|
|
140
|
+
if (((tag === 'B' || tag === 'STRONG') && !unbolded) || weight === 'bold' || Number(weight) >= 600) {
|
|
141
|
+
styles.push('strong');
|
|
142
|
+
}
|
|
143
|
+
if (((tag === 'I' || tag === 'EM') && el.style.fontStyle !== 'normal') || el.style.fontStyle === 'italic') {
|
|
144
|
+
styles.push('emphasis');
|
|
145
|
+
}
|
|
146
|
+
if (tag === 'U' || decoration.includes('underline')) styles.push('underline');
|
|
147
|
+
if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL' || decoration.includes('line-through')) styles.push('strike');
|
|
148
|
+
if (tag === 'CODE' || tag === 'KBD' || tag === 'TT' || tag === 'SAMP') styles.push('code');
|
|
149
|
+
return styles;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Puts `open`/`close` around the text of `inner`, leaving its outer whitespace outside the marks. */
|
|
153
|
+
function wrap(inner: string, open: string, close = open): string {
|
|
154
|
+
const [, lead, text, trail] = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner)!;
|
|
155
|
+
return text ? `${lead}${open}${text}${close}${trail}` : inner;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function linkTarget(href: string | null): string | null {
|
|
159
|
+
return href && /^(https?:|mailto:|tel:)/i.test(href) ? href : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The inline source of `node`. `active` holds the marks already open around it, so they never nest twice. */
|
|
163
|
+
function inline(node: Node, active: Set<Style> = new Set()): string {
|
|
164
|
+
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '').replace(/[ \t\r\n\f]+/g, ' ');
|
|
165
|
+
if (!(node instanceof HTMLElement)) return '';
|
|
166
|
+
if (SKIPPED_TAGS.has(node.tagName) || node.matches(WORD_LIST_MARKER)) return '';
|
|
167
|
+
if (node.tagName === 'BR') return '\n';
|
|
168
|
+
if (node.tagName === 'IMG') {
|
|
169
|
+
const src = linkTarget(node.getAttribute('src'));
|
|
170
|
+
return src ? `` : '';
|
|
171
|
+
}
|
|
172
|
+
if (node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox') {
|
|
173
|
+
return (node as HTMLInputElement).checked ? '[x] ' : '[ ] ';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const styles = styleOf(node).filter(style => !active.has(style));
|
|
177
|
+
const inner = styles.includes('code')
|
|
178
|
+
? (node.textContent ?? '').replace(/\s+/g, ' ')
|
|
179
|
+
: Array.from(node.childNodes).map(child => inline(child, new Set([...active, ...styles]))).join('');
|
|
180
|
+
|
|
181
|
+
let text = BLOCK_TAGS.has(node.tagName) ? ` ${inner} ` : inner;
|
|
182
|
+
for (const style of styles) text = wrap(text, MARKS[style]);
|
|
183
|
+
if (node.tagName === 'SUP') text = wrap(text, '^');
|
|
184
|
+
if (node.tagName === 'SUB') text = wrap(text, '~');
|
|
185
|
+
const href = node.tagName === 'A' ? linkTarget(node.getAttribute('href')) : null;
|
|
186
|
+
return href && text.trim() ? wrap(text.replace(/\n/g, ' '), '[', `](${href})`) : text;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Remarkd list lines for `list`: bullets nest by marker count, numbers as an outline under
|
|
191
|
+
* `path`, and each item stays on one line — a remarkd item cannot span lines.
|
|
192
|
+
*/
|
|
193
|
+
function listLines(list: HTMLElement, bulletDepth: number, path: number[]): string[] {
|
|
194
|
+
const ordered = list.tagName === 'OL';
|
|
195
|
+
const depth = ordered ? bulletDepth : bulletDepth + 1;
|
|
196
|
+
const lines: string[] = [];
|
|
197
|
+
let n = ordered ? Number(list.getAttribute('start') ?? 1) - 1 : 0;
|
|
198
|
+
for (const child of Array.from(list.children)) {
|
|
199
|
+
if (child.tagName === 'UL' || child.tagName === 'OL') {
|
|
200
|
+
// Google Docs nests a level as a list directly inside the list.
|
|
201
|
+
lines.push(...listLines(child as HTMLElement, depth, ordered ? [...path, Math.max(n, 1)] : path));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (child.tagName !== 'LI') continue;
|
|
205
|
+
n++;
|
|
206
|
+
const itemPath = ordered ? [...path, n] : path;
|
|
207
|
+
const nested = Array.from(child.children).filter(el => el.tagName === 'UL' || el.tagName === 'OL');
|
|
208
|
+
const text = oneLine(Array.from(child.childNodes).filter(node => !nested.includes(node as Element))
|
|
209
|
+
.map(node => inline(node)).join(''));
|
|
210
|
+
if (text) {
|
|
211
|
+
const marker = ordered ? itemPath.map(i => `${i}.`).join('') : '-'.repeat(Math.min(depth, 10));
|
|
212
|
+
lines.push(`${marker} ${text}`);
|
|
213
|
+
}
|
|
214
|
+
for (const sub of nested) lines.push(...listLines(sub as HTMLElement, depth, itemPath));
|
|
215
|
+
}
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Word's list paragraphs, numbered by their `levelN`; bullet or number is read off Word's own marker. */
|
|
220
|
+
function wordList(items: Element[]): string {
|
|
221
|
+
const counters: number[] = [];
|
|
222
|
+
return items.map(item => {
|
|
223
|
+
const level = wordListLevel(item)!;
|
|
224
|
+
const ordered = ORDERED_MARKER.test(item.querySelector(WORD_LIST_MARKER)?.textContent ?? '');
|
|
225
|
+
counters.length = level;
|
|
226
|
+
counters[level - 1] = (counters[level - 1] ?? 0) + 1;
|
|
227
|
+
for (let i = 0; i < level - 1; i++) counters[i] ??= 1;
|
|
228
|
+
const marker = ordered ? counters.map(i => `${i}.`).join('') : '-'.repeat(Math.min(level, 10));
|
|
229
|
+
return `${marker} ${oneLine(inline(item))}`;
|
|
230
|
+
}).filter(line => !/^\S+ $/.test(line)).join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function table(el: HTMLElement): string {
|
|
234
|
+
const rows = Array.from(el.querySelectorAll('tr'))
|
|
235
|
+
.filter(row => row.closest('table') === el)
|
|
236
|
+
.map(row => Array.from(row.children)
|
|
237
|
+
.filter(cell => cell.tagName === 'TD' || cell.tagName === 'TH')
|
|
238
|
+
.map(cell => oneLine(inline(cell)) || ' '))
|
|
239
|
+
.filter(cells => cells.length);
|
|
240
|
+
return `|===\n${rows.map(cells => cells.map(cell => `|${cell}`).join(' ')).join('\n\n')}\n|===`;
|
|
241
|
+
}
|