@kubex/zinc 1.1.164 → 1.1.167
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 +142 -74
- package/dist/vscode.html-custom-data.json +11 -11
- package/dist/web-types.json +23 -23
- package/dist/zn.d.ts +23 -0
- package/dist/zn.min.js +273 -265
- 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 +127 -20
- package/src/components/remarkd-editor/remarkd-editor.component.ts +47 -5
- package/src/components/remarkd-editor/remarkd-editor.test.ts +79 -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;
|
|
@@ -22,6 +23,13 @@ function nextCheckbox(box: string): string {
|
|
|
22
23
|
return box === '[_] ' || box === '[*] ' ? '[_] ' : '[ ] ';
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Whether `line` is a list marker with nothing after it — `- `, `2. `, `- [ ] `, or a bare `::`. */
|
|
27
|
+
export function isEmptyListItem(line: string): boolean {
|
|
28
|
+
if (line.trim() === '::') return true;
|
|
29
|
+
const item = LIST_ITEM.exec(line);
|
|
30
|
+
return !!item && line.slice(item[0].length).trim() === '';
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* What Enter does on a list line: continues the list with the next marker, or ends it by
|
|
27
35
|
* clearing the marker of an item that has no text. Null when Enter should just insert a
|
|
@@ -36,15 +44,14 @@ export function continueList(value: string, start: number, end: number): TextEdi
|
|
|
36
44
|
const line = value.slice(lineStart, lineEnd);
|
|
37
45
|
|
|
38
46
|
if (insideVerbatim(value.slice(0, lineStart))) return null;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
if (isEmptyListItem(line)) {
|
|
48
|
+
return {value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart};
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
const item = LIST_ITEM.exec(line);
|
|
44
52
|
const definition = item ? null : DEFINITION.exec(line);
|
|
45
53
|
const marker = item?.[0] ?? definition?.[0];
|
|
46
54
|
if (!marker || start - lineStart < marker.length) return null;
|
|
47
|
-
if (item && line.slice(marker.length).trim() === '') return clearLine;
|
|
48
55
|
|
|
49
56
|
if (definition) {
|
|
50
57
|
// The caret goes before `::` — the term is typed first.
|
|
@@ -52,22 +59,122 @@ export function continueList(value: string, start: number, end: number): TextEdi
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
const [, bullet, ordinal, box] = item!;
|
|
55
|
-
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) : '');
|
|
56
65
|
const head = `${value.slice(0, start)}\n${next}`;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
104
|
*/
|
|
66
|
-
function renumber(
|
|
67
|
-
|
|
68
|
-
for (let i =
|
|
69
|
-
|
|
70
|
-
|
|
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));
|
|
71
114
|
}
|
|
72
|
-
|
|
115
|
+
}
|
|
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
|
+
|
|
127
|
+
/**
|
|
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.
|
|
132
|
+
*/
|
|
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
|
+
}
|
|
161
|
+
}
|
|
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)};
|
|
73
180
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {ACTION_GROUPS, EDITOR_ACTIONS, matchesShortcut, shortcutLabel, slashItems} from './actions';
|
|
2
2
|
import {classMap} from "lit/directives/class-map.js";
|
|
3
|
-
import {continueList} from './list-continuation';
|
|
3
|
+
import {continueList, indentList, isEmptyListItem} from './list-continuation';
|
|
4
4
|
import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
|
|
5
5
|
import {defaultValue} from "../../internal/default-value";
|
|
6
6
|
import {FormControlController} from "../../internal/form";
|
|
@@ -15,6 +15,7 @@ import ZnMenu from "../menu";
|
|
|
15
15
|
import ZnMenuItem from "../menu-item";
|
|
16
16
|
import ZnSlashMenu, {SlashMenuController, type SlashMenuItem} from "../slash-menu";
|
|
17
17
|
import type {EditorAction, InlineMark} from './actions';
|
|
18
|
+
import type {RangeEdit} from './list-continuation';
|
|
18
19
|
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
19
20
|
import type ZnFile from "../file";
|
|
20
21
|
|
|
@@ -562,6 +563,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
562
563
|
* delimited containers (``` ==== !!!! .... ---- ____ **** ////) as single blocks.
|
|
563
564
|
*/
|
|
564
565
|
private splitBlocks(source: string): string[] {
|
|
566
|
+
return this.scanBlocks(source).blocks;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** `splitBlocks`, plus whether the source ends inside an unclosed container, where a blank line does not split. */
|
|
570
|
+
private scanBlocks(source: string): { blocks: string[]; open: boolean } {
|
|
565
571
|
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
|
566
572
|
const blocks: string[] = [];
|
|
567
573
|
let current: string[] = [];
|
|
@@ -612,7 +618,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
612
618
|
current.push(line);
|
|
613
619
|
}
|
|
614
620
|
push();
|
|
615
|
-
return blocks;
|
|
621
|
+
return {blocks, open: fence !== null || depth > 0};
|
|
616
622
|
}
|
|
617
623
|
|
|
618
624
|
private fenceMarker(line: string): string | null {
|
|
@@ -961,10 +967,23 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
961
967
|
e.preventDefault();
|
|
962
968
|
this.activateAction(shortcutAction);
|
|
963
969
|
} else if (this.isPlainEnter(e)) {
|
|
970
|
+
const finished = this.finishedDraft(input.value, input.selectionStart, input.selectionEnd);
|
|
971
|
+
if (finished !== null) {
|
|
972
|
+
e.preventDefault();
|
|
973
|
+
this.editingDraft = finished;
|
|
974
|
+
this.suppressBlurCommit = true;
|
|
975
|
+
this.insertDraftBlock(this.commitEdit());
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
964
978
|
const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
|
|
965
979
|
if (!edit) return;
|
|
966
980
|
e.preventDefault();
|
|
967
981
|
this.applyDraftEdit(input, edit.value, edit.caret, edit.caret);
|
|
982
|
+
} else if (this.isListTab(e)) {
|
|
983
|
+
const edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
|
|
984
|
+
if (!edit) return;
|
|
985
|
+
e.preventDefault();
|
|
986
|
+
this.applyDraftEdit(input, edit.value, edit.start, edit.end);
|
|
968
987
|
} else if (e.key === 'Enter' && e.shiftKey) {
|
|
969
988
|
e.preventDefault();
|
|
970
989
|
this.suppressBlurCommit = true;
|
|
@@ -979,18 +998,41 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
979
998
|
}
|
|
980
999
|
};
|
|
981
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* The draft to commit when Enter should finish the block — pressed on a trailing blank line or
|
|
1003
|
+
* empty list item, outside any container — with that line dropped. Null otherwise.
|
|
1004
|
+
*/
|
|
1005
|
+
private finishedDraft(value: string, start: number, end: number): string | null {
|
|
1006
|
+
if (start !== end || value.slice(start).trim() !== '') return null;
|
|
1007
|
+
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
1008
|
+
const line = value.slice(lineStart);
|
|
1009
|
+
if (line.trim() !== '' && !isEmptyListItem(line)) return null;
|
|
1010
|
+
const before = value.slice(0, lineStart);
|
|
1011
|
+
if (before.trim() === '' || this.scanBlocks(before).open) return null;
|
|
1012
|
+
return before.trimEnd();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
982
1015
|
private isPlainEnter(e: KeyboardEvent): boolean {
|
|
983
1016
|
return e.key === 'Enter' && !e.isComposing && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey;
|
|
984
1017
|
}
|
|
985
1018
|
|
|
1019
|
+
private isListTab(e: KeyboardEvent): boolean {
|
|
1020
|
+
return e.key === 'Tab' && !e.isComposing && !e.metaKey && !e.ctrlKey && !e.altKey;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
986
1023
|
private handleRawKeydown = (e: KeyboardEvent) => {
|
|
987
|
-
if (!this.isPlainEnter(e)) return;
|
|
988
1024
|
const input = e.target as HTMLTextAreaElement;
|
|
989
|
-
|
|
1025
|
+
let edit: RangeEdit | null = null;
|
|
1026
|
+
if (this.isPlainEnter(e)) {
|
|
1027
|
+
const next = continueList(input.value, input.selectionStart, input.selectionEnd);
|
|
1028
|
+
edit = next && {value: next.value, start: next.caret, end: next.caret};
|
|
1029
|
+
} else if (this.isListTab(e)) {
|
|
1030
|
+
edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
|
|
1031
|
+
}
|
|
990
1032
|
if (!edit) return;
|
|
991
1033
|
e.preventDefault();
|
|
992
1034
|
input.value = edit.value;
|
|
993
|
-
input.setSelectionRange(edit.
|
|
1035
|
+
input.setSelectionRange(edit.start, edit.end);
|
|
994
1036
|
this.handleRawInput(e);
|
|
995
1037
|
};
|
|
996
1038
|
|
|
@@ -595,6 +595,47 @@ Second"></zn-remarkd-editor>`);
|
|
|
595
595
|
expect(enter(input).defaultPrevented).to.be.false;
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
/** Opens the first block and replaces its draft with `draft`, caret at the end. */
|
|
599
|
+
async function editDraft(draft: string) {
|
|
600
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
601
|
+
<zn-remarkd-editor value="placeholder"></zn-remarkd-editor>`);
|
|
602
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
603
|
+
await el.updateComplete;
|
|
604
|
+
return {el, input: typeInBlock(el, draft)};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
it('should finish the block on Enter at an empty trailing list item', async () => {
|
|
608
|
+
const {el, input} = await editDraft('- a\n- ');
|
|
609
|
+
expect(enter(input).defaultPrevented).to.be.true;
|
|
610
|
+
await el.updateComplete;
|
|
611
|
+
|
|
612
|
+
expect(el.value).to.equal('- a');
|
|
613
|
+
const draft = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
614
|
+
expect(draft.value, 'a fresh block is opened below').to.equal('');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should finish the block on a double Enter after a paragraph', async () => {
|
|
618
|
+
const {el, input} = await editDraft('hello\n');
|
|
619
|
+
expect(enter(input).defaultPrevented).to.be.true;
|
|
620
|
+
await el.updateComplete;
|
|
621
|
+
|
|
622
|
+
expect(el.value).to.equal('hello');
|
|
623
|
+
expect(el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!.value).to.equal('');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should keep a blank line inside an open container', async () => {
|
|
627
|
+
const {input} = await editDraft('```\ncode\n');
|
|
628
|
+
expect(enter(input).defaultPrevented).to.be.false;
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should clear a mid-list empty item without finishing the block', async () => {
|
|
632
|
+
const {el, input} = await editDraft('- a\n- \n- b');
|
|
633
|
+
input.setSelectionRange(6, 6);
|
|
634
|
+
expect(enter(input).defaultPrevented).to.be.true;
|
|
635
|
+
await el.updateComplete;
|
|
636
|
+
expect(input.value).to.equal('- a\n\n- b');
|
|
637
|
+
});
|
|
638
|
+
|
|
598
639
|
it('should continue a list on Enter in the raw source view', async () => {
|
|
599
640
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
600
641
|
<zn-remarkd-editor allow-raw value="- [ ] first"></zn-remarkd-editor>`);
|
|
@@ -608,6 +649,43 @@ Second"></zn-remarkd-editor>`);
|
|
|
608
649
|
await el.updateComplete;
|
|
609
650
|
expect(el.value).to.equal('- [ ] first\n- [ ] ');
|
|
610
651
|
});
|
|
652
|
+
|
|
653
|
+
const tab = (input: HTMLTextAreaElement, shiftKey = false) => {
|
|
654
|
+
const event = new KeyboardEvent('keydown', {key: 'Tab', shiftKey, bubbles: true, composed: true, cancelable: true});
|
|
655
|
+
input.dispatchEvent(event);
|
|
656
|
+
return event;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
it('should nest and un-nest a list item on Tab and Shift+Tab in the open block', async () => {
|
|
660
|
+
const {el, input} = await editDraft('- a\n- b');
|
|
661
|
+
expect(tab(input).defaultPrevented).to.be.true;
|
|
662
|
+
await el.updateComplete;
|
|
663
|
+
expect(input.value).to.equal('- a\n-- b');
|
|
664
|
+
expect(input.selectionStart).to.equal(8);
|
|
665
|
+
|
|
666
|
+
expect(tab(input, true).defaultPrevented).to.be.true;
|
|
667
|
+
await el.updateComplete;
|
|
668
|
+
expect(input.value).to.equal('- a\n- b');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('should leave Tab alone outside a list', async () => {
|
|
672
|
+
const {input} = await editDraft('hello');
|
|
673
|
+
expect(tab(input).defaultPrevented).to.be.false;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should nest a list item on Tab in the raw source view', async () => {
|
|
677
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
678
|
+
<zn-remarkd-editor allow-raw value="- first"></zn-remarkd-editor>`);
|
|
679
|
+
el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
|
|
680
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
681
|
+
await el.updateComplete;
|
|
682
|
+
const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
|
|
683
|
+
raw.setSelectionRange(7, 7);
|
|
684
|
+
|
|
685
|
+
expect(tab(raw).defaultPrevented).to.be.true;
|
|
686
|
+
await el.updateComplete;
|
|
687
|
+
expect(el.value).to.equal('-- first');
|
|
688
|
+
});
|
|
611
689
|
});
|
|
612
690
|
|
|
613
691
|
it('should insert a placeholder when nothing is selected', async () => {
|
|
@@ -874,7 +952,7 @@ Second"></zn-remarkd-editor>`);
|
|
|
874
952
|
|
|
875
953
|
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
876
954
|
expect(menu.items.length).to.equal(EDITOR_ACTIONS.length);
|
|
877
|
-
expect(menu.items.some(item => item.group === '
|
|
955
|
+
expect(menu.items.some(item => item.group === 'Alerts')).to.be.true;
|
|
878
956
|
});
|
|
879
957
|
|
|
880
958
|
it('should omit the Include action from the slash menu without an include-url', async () => {
|