@codeauradev/ngx-wind-editor 0.0.0-stage → 1.0.0
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/.idea/workspace.xml +120 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/LICENSE +21 -0
- package/dist/README.md +90 -0
- package/dist/fesm2022/codeauradev-ngx-wind-editor.mjs +2596 -0
- package/dist/fesm2022/codeauradev-ngx-wind-editor.mjs.map +1 -0
- package/dist/ngx-wind-editor.css +184 -0
- package/dist/package.json +48 -0
- package/dist/types/codeauradev-ngx-wind-editor.d.ts +270 -0
- package/ng-package.json +15 -0
- package/package.json +59 -4
- package/scripts/patch-dist.mjs +11 -0
- package/src/catalog.ts +27 -0
- package/src/delta.ts +115 -0
- package/src/html.ts +327 -0
- package/src/public-api.ts +8 -0
- package/src/session.ts +843 -0
- package/src/styles/ngx-wind-editor.css +184 -0
- package/src/surface.ts +499 -0
- package/src/toolbar.ts +53 -0
- package/src/types.ts +69 -0
- package/src/wind-editor.ts +357 -0
- package/src/wind-icon.ts +32 -0
- package/test/editor.test.ts +58 -0
- package/tsconfig.json +27 -0
package/src/html.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { deltaToText, mergeDelta, packAttrs } from './delta';
|
|
2
|
+
import type { BlockData, BlockType, Delta, TextAttrs } from './types';
|
|
3
|
+
|
|
4
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
5
|
+
|
|
6
|
+
type Token =
|
|
7
|
+
| { kind: 'text'; value: string }
|
|
8
|
+
| { kind: 'open'; name: string; attrs: Record<string, string> }
|
|
9
|
+
| { kind: 'close'; name: string };
|
|
10
|
+
|
|
11
|
+
export function safeUrl(value: string): string | null {
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (!trimmed) return null;
|
|
14
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(trimmed, 'https://example.com');
|
|
19
|
+
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:' || url.protocol === 'tel:') {
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeText(value: string): string {
|
|
29
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeAttr(value: string): string {
|
|
33
|
+
return escapeText(value).replace(/"/g, '"');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodeEntity(body: string): string {
|
|
37
|
+
const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
|
|
38
|
+
if (named[body]) return named[body];
|
|
39
|
+
if (body.startsWith('#x') || body.startsWith('#X')) {
|
|
40
|
+
const code = Number.parseInt(body.slice(2), 16);
|
|
41
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : `&${body};`;
|
|
42
|
+
}
|
|
43
|
+
if (body.startsWith('#')) {
|
|
44
|
+
const code = Number.parseInt(body.slice(1), 10);
|
|
45
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : `&${body};`;
|
|
46
|
+
}
|
|
47
|
+
return `&${body};`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decode(value: string): string {
|
|
51
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (_, body: string) => decodeEntity(body));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tokenize(html: string): Token[] {
|
|
55
|
+
const tokens: Token[] = [];
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < html.length) {
|
|
58
|
+
if (html[i] !== '<') {
|
|
59
|
+
const next = html.indexOf('<', i);
|
|
60
|
+
const raw = html.slice(i, next === -1 ? html.length : next);
|
|
61
|
+
tokens.push({ kind: 'text', value: decode(raw) });
|
|
62
|
+
i = next === -1 ? html.length : next;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const next = html[i + 1];
|
|
66
|
+
if (!next || !/[a-zA-Z/!?]/.test(next)) {
|
|
67
|
+
tokens.push({ kind: 'text', value: '<' });
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (html.startsWith('<!--', i)) {
|
|
72
|
+
const end = html.indexOf('-->', i + 4);
|
|
73
|
+
i = end === -1 ? html.length : end + 3;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const end = html.indexOf('>', i + 1);
|
|
77
|
+
if (end === -1) {
|
|
78
|
+
tokens.push({ kind: 'text', value: html.slice(i) });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const raw = html.slice(i + 1, end).trim();
|
|
82
|
+
i = end + 1;
|
|
83
|
+
if (!raw) continue;
|
|
84
|
+
if (raw.startsWith('/')) {
|
|
85
|
+
tokens.push({ kind: 'close', name: raw.slice(1).trim().split(/\s+/)[0]?.toLowerCase() ?? '' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (raw.startsWith('!') || raw.startsWith('?')) continue;
|
|
89
|
+
const self = raw.endsWith('/');
|
|
90
|
+
const body = self ? raw.slice(0, -1).trim() : raw;
|
|
91
|
+
const name = body.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
92
|
+
const attrs: Record<string, string> = {};
|
|
93
|
+
const attrRe = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
94
|
+
let match: RegExpExecArray | null;
|
|
95
|
+
const rest = body.slice(name.length);
|
|
96
|
+
while ((match = attrRe.exec(rest))) {
|
|
97
|
+
const key = match[1]?.toLowerCase();
|
|
98
|
+
if (!key) continue;
|
|
99
|
+
attrs[key] = decode(match[2] ?? match[3] ?? match[4] ?? '');
|
|
100
|
+
}
|
|
101
|
+
tokens.push({ kind: 'open', name, attrs });
|
|
102
|
+
if (self || VOID.has(name)) tokens.push({ kind: 'close', name });
|
|
103
|
+
}
|
|
104
|
+
return tokens;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Draft {
|
|
108
|
+
type: BlockType;
|
|
109
|
+
level: number;
|
|
110
|
+
src: string;
|
|
111
|
+
alt: string;
|
|
112
|
+
delta: Delta;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pushText(draft: Draft, value: string, attrs: TextAttrs): void {
|
|
116
|
+
if (!value) return;
|
|
117
|
+
draft.delta.push({ insert: value, attributes: packAttrs(attrs) });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function finish(blocks: BlockData[], draft: Draft | null): Draft | null {
|
|
121
|
+
if (!draft) return null;
|
|
122
|
+
let delta = mergeDelta(draft.delta);
|
|
123
|
+
if (draft.type !== 'code') {
|
|
124
|
+
const text = deltaToText(delta).replace(/\u00a0/g, ' ');
|
|
125
|
+
if (text.trim() === '' && !text.includes('\n')) delta = [];
|
|
126
|
+
else if (delta.length === 1 && delta[0] && !delta[0].attributes) {
|
|
127
|
+
const trimmed = delta[0].insert.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
128
|
+
delta = trimmed ? [{ insert: trimmed }] : [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
blocks.push({
|
|
132
|
+
id: '',
|
|
133
|
+
type: draft.type,
|
|
134
|
+
level: draft.type === 'heading' ? draft.level : 0,
|
|
135
|
+
src: draft.src,
|
|
136
|
+
alt: draft.alt,
|
|
137
|
+
delta: draft.type === 'rule' || draft.type === 'image' ? [] : delta,
|
|
138
|
+
});
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseHtml(html: string): BlockData[] {
|
|
143
|
+
const tokens = tokenize(html);
|
|
144
|
+
const blocks: BlockData[] = [];
|
|
145
|
+
const marks: Array<{ tag: string; attrs: TextAttrs }> = [];
|
|
146
|
+
const lists: Array<'bullet' | 'ordered'> = [];
|
|
147
|
+
let quote = 0;
|
|
148
|
+
let pre = 0;
|
|
149
|
+
let ignore = 0;
|
|
150
|
+
let draft: Draft | null = null;
|
|
151
|
+
|
|
152
|
+
const attrs = (): TextAttrs => marks.reduce<TextAttrs>((acc, mark) => ({ ...acc, ...mark.attrs }), {});
|
|
153
|
+
const popTag = (tag: string) => {
|
|
154
|
+
for (let index = marks.length - 1; index >= 0; index -= 1) {
|
|
155
|
+
if (marks[index]?.tag === tag) {
|
|
156
|
+
marks.splice(index, 1);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const start = (type: BlockType, extra?: Partial<Draft>) => {
|
|
163
|
+
draft = finish(blocks, draft);
|
|
164
|
+
draft = { type, level: extra?.level ?? (type === 'heading' ? 1 : 0), src: extra?.src ?? '', alt: extra?.alt ?? '', delta: [] };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
for (const token of tokens) {
|
|
168
|
+
if (token.kind === 'text') {
|
|
169
|
+
if (ignore) continue;
|
|
170
|
+
if (pre) {
|
|
171
|
+
if (!draft) start('code');
|
|
172
|
+
pushText(draft!, token.value.replace(/^\n/, ''), {});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const collapsed = token.value.replace(/\u00a0/g, ' ').replace(/[ \t\r\n]+/g, ' ');
|
|
176
|
+
if (!draft) {
|
|
177
|
+
if (!collapsed.trim()) continue;
|
|
178
|
+
const type = quote > 0 ? 'blockquote' : (lists.at(-1) ?? 'paragraph');
|
|
179
|
+
start(type);
|
|
180
|
+
}
|
|
181
|
+
if (!draft) continue;
|
|
182
|
+
pushText(draft, collapsed, attrs());
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (token.kind === 'close') {
|
|
186
|
+
const name = token.name;
|
|
187
|
+
if (name === 'script' || name === 'style') ignore = Math.max(0, ignore - 1);
|
|
188
|
+
if (name === 'strong' || name === 'b') popTag(name);
|
|
189
|
+
if (name === 'em' || name === 'i') popTag(name);
|
|
190
|
+
if (name === 'u') popTag(name);
|
|
191
|
+
if (name === 's' || name === 'strike' || name === 'del') popTag(name);
|
|
192
|
+
if (name === 'code' && pre === 0) popTag('code');
|
|
193
|
+
if (name === 'a') popTag('a');
|
|
194
|
+
if (name === 'ul' || name === 'ol') lists.pop();
|
|
195
|
+
if (name === 'blockquote') quote = Math.max(0, quote - 1);
|
|
196
|
+
if (name === 'pre') {
|
|
197
|
+
pre = Math.max(0, pre - 1);
|
|
198
|
+
draft = finish(blocks, draft);
|
|
199
|
+
}
|
|
200
|
+
if (['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'].includes(name) && pre === 0) {
|
|
201
|
+
draft = finish(blocks, draft);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const name = token.name;
|
|
207
|
+
if (name === 'script' || name === 'style') {
|
|
208
|
+
ignore += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ignore) continue;
|
|
212
|
+
if (name === 'strong' || name === 'b') marks.push({ tag: name, attrs: { bold: true } });
|
|
213
|
+
else if (name === 'em' || name === 'i') marks.push({ tag: name, attrs: { italic: true } });
|
|
214
|
+
else if (name === 'u') marks.push({ tag: 'u', attrs: { underline: true } });
|
|
215
|
+
else if (name === 's' || name === 'strike' || name === 'del') marks.push({ tag: name, attrs: { strike: true } });
|
|
216
|
+
else if (name === 'code' && pre === 0) marks.push({ tag: 'code', attrs: { code: true } });
|
|
217
|
+
else if (name === 'a') {
|
|
218
|
+
const href = safeUrl(token.attrs['href'] ?? '');
|
|
219
|
+
if (href) marks.push({ tag: 'a', attrs: { href } });
|
|
220
|
+
} else if (name === 'br') {
|
|
221
|
+
if (!draft) start(quote > 0 ? 'blockquote' : (lists.at(-1) ?? 'paragraph'));
|
|
222
|
+
if (draft && deltaToText(draft.delta).length > 0) pushText(draft, '\n', attrs());
|
|
223
|
+
} else if (name === 'hr') {
|
|
224
|
+
draft = finish(blocks, draft);
|
|
225
|
+
blocks.push({ id: '', type: 'rule', level: 0, src: '', alt: '', delta: [] });
|
|
226
|
+
} else if (name === 'img') {
|
|
227
|
+
const src = safeUrl(token.attrs['src'] ?? '');
|
|
228
|
+
if (src) {
|
|
229
|
+
draft = finish(blocks, draft);
|
|
230
|
+
blocks.push({ id: '', type: 'image', level: 0, src, alt: token.attrs['alt'] ?? '', delta: [] });
|
|
231
|
+
}
|
|
232
|
+
} else if (name === 'ul') {
|
|
233
|
+
draft = finish(blocks, draft);
|
|
234
|
+
lists.push('bullet');
|
|
235
|
+
} else if (name === 'ol') {
|
|
236
|
+
draft = finish(blocks, draft);
|
|
237
|
+
lists.push('ordered');
|
|
238
|
+
} else if (name === 'blockquote') {
|
|
239
|
+
draft = finish(blocks, draft);
|
|
240
|
+
quote += 1;
|
|
241
|
+
} else if (name === 'pre') {
|
|
242
|
+
draft = finish(blocks, draft);
|
|
243
|
+
pre += 1;
|
|
244
|
+
start('code');
|
|
245
|
+
} else if (/^h[1-6]$/.test(name)) {
|
|
246
|
+
start('heading', { level: Math.min(3, Number(name[1])) });
|
|
247
|
+
} else if (name === 'li') {
|
|
248
|
+
start(lists.at(-1) ?? 'bullet');
|
|
249
|
+
} else if (name === 'p' || name === 'div') {
|
|
250
|
+
if (pre > 0) continue;
|
|
251
|
+
if (draft && deltaToText(draft.delta).length > 0) draft = finish(blocks, draft);
|
|
252
|
+
if (!draft) start(quote > 0 ? 'blockquote' : (lists.at(-1) ?? 'paragraph'));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finish(blocks, draft);
|
|
256
|
+
return blocks;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function inlineHtml(delta: Delta, pre: boolean): string {
|
|
260
|
+
let html = '';
|
|
261
|
+
for (const op of mergeDelta(delta)) {
|
|
262
|
+
let piece = escapeText(op.insert);
|
|
263
|
+
if (!pre) piece = piece.replace(/\n/g, '<br>');
|
|
264
|
+
const attrs = op.attributes ?? {};
|
|
265
|
+
if (attrs.code) piece = `<code>${piece}</code>`;
|
|
266
|
+
if (attrs.strike) piece = `<s>${piece}</s>`;
|
|
267
|
+
if (attrs.underline) piece = `<u>${piece}</u>`;
|
|
268
|
+
if (attrs.italic) piece = `<em>${piece}</em>`;
|
|
269
|
+
if (attrs.bold) piece = `<strong>${piece}</strong>`;
|
|
270
|
+
if (attrs.href) piece = `<a href="${escapeAttr(attrs.href)}">${piece}</a>`;
|
|
271
|
+
html += piece;
|
|
272
|
+
}
|
|
273
|
+
return html;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function blockHtml(block: BlockData): string {
|
|
277
|
+
if (block.type === 'rule') return '<hr>';
|
|
278
|
+
if (block.type === 'image') {
|
|
279
|
+
const alt = block.alt ? ` alt="${escapeAttr(block.alt)}"` : '';
|
|
280
|
+
return `<img src="${escapeAttr(block.src)}"${alt}>`;
|
|
281
|
+
}
|
|
282
|
+
if (block.type === 'code') return `<pre><code>${inlineHtml(block.delta, true)}</code></pre>`;
|
|
283
|
+
const inner = inlineHtml(block.delta, false);
|
|
284
|
+
if (block.type === 'heading') return `<h${Math.min(3, Math.max(1, block.level || 1))}>${inner}</h${Math.min(3, Math.max(1, block.level || 1))}>`;
|
|
285
|
+
if (block.type === 'blockquote') return `<blockquote>${inner}</blockquote>`;
|
|
286
|
+
if (block.type === 'bullet' || block.type === 'ordered') return `<li>${inner}</li>`;
|
|
287
|
+
return `<p>${inner}</p>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function serializeBlocks(blocks: BlockData[]): string {
|
|
291
|
+
let html = '';
|
|
292
|
+
let i = 0;
|
|
293
|
+
while (i < blocks.length) {
|
|
294
|
+
const block = blocks[i];
|
|
295
|
+
if (!block) break;
|
|
296
|
+
if (block.type === 'bullet' || block.type === 'ordered') {
|
|
297
|
+
const tag = block.type === 'bullet' ? 'ul' : 'ol';
|
|
298
|
+
html += `<${tag}>`;
|
|
299
|
+
while (i < blocks.length && blocks[i]?.type === block.type) {
|
|
300
|
+
html += blockHtml(blocks[i] as BlockData);
|
|
301
|
+
i += 1;
|
|
302
|
+
}
|
|
303
|
+
html += `</${tag}>`;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
html += blockHtml(block);
|
|
307
|
+
i += 1;
|
|
308
|
+
}
|
|
309
|
+
return html;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function plainText(blocks: BlockData[]): string {
|
|
313
|
+
return blocks
|
|
314
|
+
.map((block) => {
|
|
315
|
+
if (block.type === 'rule') return '';
|
|
316
|
+
if (block.type === 'image') return block.alt || block.src;
|
|
317
|
+
return deltaToText(block.delta);
|
|
318
|
+
})
|
|
319
|
+
.filter((line, index, all) => line.length > 0 || all.length === 1)
|
|
320
|
+
.join('\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function countWords(text: string): number {
|
|
324
|
+
const trimmed = text.trim();
|
|
325
|
+
if (!trimmed) return 0;
|
|
326
|
+
return trimmed.split(/\s+/).length;
|
|
327
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { SLASH_ITEMS, filterSlash } from './catalog';
|
|
2
|
+
export type { SlashItem } from './catalog';
|
|
3
|
+
export { loadHtml, readBlocks, WindSession } from './session';
|
|
4
|
+
export type { SlashChoice } from './session';
|
|
5
|
+
export { WIND_TOOLS, WIND_TOOLBAR_PRESETS, isToolbarPreset, isWindTool, resolveToolbar } from './toolbar';
|
|
6
|
+
export type { WindTool, WindToolbarPreset } from './toolbar';
|
|
7
|
+
export type { BlockData, BlockType, EditorSnapshot, MarkName, Selection, TextAttrs } from './types';
|
|
8
|
+
export { WindEditor } from './wind-editor';
|