@kubex/zinc 1.1.167 → 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 +80 -74
- package/dist/vscode.html-custom-data.json +11 -11
- package/dist/web-types.json +23 -23
- package/dist/zn.d.ts +13 -0
- package/dist/zn.min.js +586 -564
- package/package.json +1 -1
- 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 +19 -1
- package/src/components/remarkd-editor/remarkd-editor.test.ts +47 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ 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";
|
|
7
|
+
import {pastedRemarkd} from './paste';
|
|
7
8
|
import {property, query, state} from 'lit/decorators.js';
|
|
8
9
|
import {parse as remarkdParse} from "remarkd-js";
|
|
9
10
|
import {ToolbarOverflowController} from '../../internal/toolbar-overflow';
|
|
@@ -1076,12 +1077,28 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
1076
1077
|
|
|
1077
1078
|
private handleEditPaste = (e: ClipboardEvent) => {
|
|
1078
1079
|
const file = Array.from(e.clipboardData?.files ?? []).find(f => f.type.startsWith('image/'));
|
|
1079
|
-
if (!file)
|
|
1080
|
+
if (!file) {
|
|
1081
|
+
this.pasteFormatted(e);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1080
1084
|
e.preventDefault();
|
|
1081
1085
|
const index = this.editingIndex ?? this.blocks.length;
|
|
1082
1086
|
void this.insertImage(file, index + 1);
|
|
1083
1087
|
};
|
|
1084
1088
|
|
|
1089
|
+
/** Pastes rich clipboard content as remarkd source, keeping its lists and formatting. */
|
|
1090
|
+
private pasteFormatted = (e: ClipboardEvent) => {
|
|
1091
|
+
const source = pastedRemarkd(e.clipboardData);
|
|
1092
|
+
if (source === null) return;
|
|
1093
|
+
e.preventDefault();
|
|
1094
|
+
const input = e.target as HTMLTextAreaElement;
|
|
1095
|
+
// insertText keeps the paste on the native undo stack; it fires `input` like a typed paste.
|
|
1096
|
+
if (!document.execCommand('insertText', false, source)) {
|
|
1097
|
+
input.setRangeText(source, input.selectionStart, input.selectionEnd, 'end');
|
|
1098
|
+
input.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1085
1102
|
private handleDragOver = (e: DragEvent) => {
|
|
1086
1103
|
if (this.rawMode) return;
|
|
1087
1104
|
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
|
@@ -2021,6 +2038,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
2021
2038
|
.value=${this.value}
|
|
2022
2039
|
@input=${this.handleRawInput}
|
|
2023
2040
|
@keydown=${this.handleRawKeydown}
|
|
2041
|
+
@paste=${this.pasteFormatted}
|
|
2024
2042
|
@blur=${this.commitRaw}></textarea>`;
|
|
2025
2043
|
}
|
|
2026
2044
|
|
|
@@ -688,6 +688,53 @@ Second"></zn-remarkd-editor>`);
|
|
|
688
688
|
});
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
+
describe('paste', () => {
|
|
692
|
+
const paste = (input: HTMLTextAreaElement, data: Record<string, string>) => {
|
|
693
|
+
const clipboardData = new DataTransfer();
|
|
694
|
+
for (const [type, value] of Object.entries(data)) clipboardData.setData(type, value);
|
|
695
|
+
const event = new ClipboardEvent('paste', {clipboardData, bubbles: true, composed: true, cancelable: true});
|
|
696
|
+
input.dispatchEvent(event);
|
|
697
|
+
return event;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
it('should paste rich content as remarkd source at the caret', async () => {
|
|
701
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
702
|
+
<zn-remarkd-editor value="Intro"></zn-remarkd-editor>`);
|
|
703
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
704
|
+
await el.updateComplete;
|
|
705
|
+
const input = typeInBlock(el, 'Intro\n');
|
|
706
|
+
input.focus();
|
|
707
|
+
|
|
708
|
+
const event = paste(input, {'text/html': '<ol><li>a<ol><li>b</li></ol></li></ol>', 'text/plain': 'a\nb'});
|
|
709
|
+
expect(event.defaultPrevented).to.be.true;
|
|
710
|
+
await el.updateComplete;
|
|
711
|
+
expect(input.value).to.equal('Intro\n1. a\n1.1. b');
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('should leave a plain-text paste to the browser', async () => {
|
|
715
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
716
|
+
<zn-remarkd-editor value="Intro"></zn-remarkd-editor>`);
|
|
717
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
718
|
+
await el.updateComplete;
|
|
719
|
+
const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
720
|
+
expect(paste(input, {'text/plain': 'just text'}).defaultPrevented).to.be.false;
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('should paste rich content in the raw source view', async () => {
|
|
724
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
725
|
+
<zn-remarkd-editor allow-raw value=""></zn-remarkd-editor>`);
|
|
726
|
+
el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
|
|
727
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
728
|
+
await el.updateComplete;
|
|
729
|
+
const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
|
|
730
|
+
raw.focus();
|
|
731
|
+
|
|
732
|
+
expect(paste(raw, {'text/html': '<ul><li>a</li></ul>'}).defaultPrevented).to.be.true;
|
|
733
|
+
await el.updateComplete;
|
|
734
|
+
expect(el.value).to.equal('- a');
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
691
738
|
it('should insert a placeholder when nothing is selected', async () => {
|
|
692
739
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
693
740
|
<zn-remarkd-editor value="hello"></zn-remarkd-editor>`);
|