@zipify/wysiwyg 2.0.0-0 → 2.0.0-1

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.
Files changed (44) hide show
  1. package/.eslintrc.js +1 -1
  2. package/config/build/lib.config.js +4 -2
  3. package/dist/cli.js +10 -2
  4. package/dist/wysiwyg.css +12 -6
  5. package/dist/wysiwyg.mjs +1464 -378
  6. package/example/ExampleApp.vue +3 -1
  7. package/lib/components/toolbar/controls/StylePresetControl.vue +1 -1
  8. package/lib/components/toolbar/controls/__tests__/StylePresetControl.test.js +2 -2
  9. package/lib/enums/TextSettings.js +5 -5
  10. package/lib/extensions/Link.js +1 -1
  11. package/lib/extensions/StylePreset.js +1 -1
  12. package/lib/extensions/TextDecoration.js +12 -29
  13. package/lib/extensions/__tests__/FontWeight.test.js +2 -2
  14. package/lib/extensions/__tests__/TextDecoration.test.js +20 -24
  15. package/lib/extensions/__tests__/__snapshots__/BackgroundColor.test.js.snap +1 -1
  16. package/lib/extensions/__tests__/__snapshots__/FontColor.test.js.snap +1 -1
  17. package/lib/extensions/__tests__/__snapshots__/FontFamily.test.js.snap +19 -23
  18. package/lib/extensions/__tests__/__snapshots__/FontSize.test.js.snap +2 -2
  19. package/lib/extensions/__tests__/__snapshots__/FontStyle.test.js.snap +1 -1
  20. package/lib/extensions/__tests__/__snapshots__/FontWeight.test.js.snap +13 -17
  21. package/lib/extensions/__tests__/__snapshots__/TextDecoration.test.js.snap +102 -102
  22. package/lib/extensions/core/NodeProcessor.js +37 -15
  23. package/lib/extensions/core/TextProcessor.js +0 -5
  24. package/lib/extensions/core/__tests__/NodeProcessor.test.js +55 -0
  25. package/lib/extensions/core/__tests__/TextProcessor.test.js +0 -21
  26. package/lib/extensions/core/__tests__/__snapshots__/NodeProcessor.test.js.snap +60 -0
  27. package/lib/extensions/core/__tests__/__snapshots__/TextProcessor.test.js.snap +7 -27
  28. package/lib/extensions/core/steps/AttrStep.js +54 -0
  29. package/lib/extensions/core/steps/index.js +1 -0
  30. package/lib/services/NodeFactory.js +12 -18
  31. package/lib/services/index.js +1 -1
  32. package/lib/services/normalizer/BaseNormalizer.js +11 -0
  33. package/lib/services/{BrowserDomParser.js → normalizer/BrowserDomParser.js} +0 -0
  34. package/lib/services/normalizer/ContentNormalizer.js +24 -0
  35. package/lib/services/normalizer/HtmlNormalizer.js +245 -0
  36. package/lib/services/normalizer/JsonNormalizer.js +81 -0
  37. package/lib/services/{__tests__/ContentNormalizer.test.js → normalizer/__tests__/HtmlNormalizer.test.js} +31 -7
  38. package/lib/services/normalizer/__tests__/JsonNormalizer.test.js +70 -0
  39. package/lib/services/normalizer/__tests__/__snapshots__/JsonNormalizer.test.js.snap +159 -0
  40. package/lib/services/normalizer/index.js +1 -0
  41. package/lib/styles/content.css +8 -0
  42. package/lib/utils/isMarkAppliedToParent.js +2 -7
  43. package/package.json +3 -1
  44. package/lib/services/ContentNormalizer.js +0 -194
@@ -2,49 +2,43 @@ import { NodeTypes } from '../enums';
2
2
 
3
3
  export class NodeFactory {
4
4
  static doc(content) {
5
- return {
6
- type: NodeTypes.DOCUMENT,
7
- content
8
- };
5
+ return { type: NodeTypes.DOCUMENT, content };
9
6
  }
10
7
 
11
8
  static list(type, items) {
12
9
  return {
13
10
  type: NodeTypes.LIST,
14
11
  attrs: { bullet: { type } },
15
- content: items.map((item) => this.listItem([].concat(item)))
12
+
13
+ content: items.map((item) => {
14
+ return item.type === NodeTypes.LIST_ITEM ? item : this.listItem([].concat(item));
15
+ })
16
16
  };
17
17
  }
18
18
 
19
- static listItem(content) {
20
- return {
21
- type: NodeTypes.LIST_ITEM,
22
- content
23
- };
19
+ static listItem(...args) {
20
+ return { type: NodeTypes.LIST_ITEM, ...this.#textBlock(args, this.paragraph) };
24
21
  }
25
22
 
26
23
  static heading(level, ...args) {
27
- const config = this.#textBlock(args);
24
+ const config = this.#textBlock(args, this.text);
28
25
 
29
26
  config.attrs ??= {};
30
27
  config.attrs.level = level;
31
28
 
32
- return {
33
- type: NodeTypes.HEADING,
34
- ...config
35
- };
29
+ return { type: NodeTypes.HEADING, ...config };
36
30
  }
37
31
 
38
32
  static paragraph(...args) {
39
33
  return {
40
34
  type: NodeTypes.PARAGRAPH,
41
- ...this.#textBlock(args)
35
+ ...this.#textBlock(args, this.text)
42
36
  };
43
37
  }
44
38
 
45
- static #textBlock(args) {
39
+ static #textBlock(args, createChildNode) {
46
40
  const { attrs, content, marks } = this.#normalizeTextBlockArgs(args);
47
- const children = typeof content === 'string' ? [this.text(content)] : content;
41
+ const children = typeof content === 'string' ? [createChildNode.call(this, content)] : content;
48
42
 
49
43
  return {
50
44
  content: children,
@@ -1,6 +1,6 @@
1
1
  export { JsonSerializer } from './JsonSerializer';
2
2
  export { Storage } from './Storage';
3
3
  export { FavoriteColors } from './FavoriteColors';
4
- export { ContentNormalizer } from './ContentNormalizer';
4
+ export { ContentNormalizer } from './normalizer';
5
5
  export { ContextWindow } from './ContextWidnow';
6
6
  export { NodeFactory } from './NodeFactory';
@@ -0,0 +1,11 @@
1
+ export class BaseNormalizer {
2
+ content;
3
+
4
+ constructor({ content }) {
5
+ this.content = content;
6
+ }
7
+
8
+ normalize() {
9
+ throw new Error('Implement abstract method');
10
+ }
11
+ }
@@ -0,0 +1,24 @@
1
+ import { BrowserDomParser } from './BrowserDomParser';
2
+ import { HtmlNormalizer } from './HtmlNormalizer';
3
+ import { JsonNormalizer } from './JsonNormalizer';
4
+
5
+ export class ContentNormalizer {
6
+ static build(content, options = {}) {
7
+ return typeof content === 'string' ? this.#buildHtml(content, options) : this.#buildJson(content);
8
+ }
9
+
10
+ static #buildHtml(content, options) {
11
+ return new HtmlNormalizer({
12
+ content,
13
+ parser: options.parser || new BrowserDomParser()
14
+ });
15
+ }
16
+
17
+ static #buildJson(content) {
18
+ return new JsonNormalizer({ content });
19
+ }
20
+
21
+ static normalize(content, options = {}) {
22
+ return ContentNormalizer.build(content, options).normalize();
23
+ }
24
+ }
@@ -0,0 +1,245 @@
1
+ import { BaseNormalizer } from './BaseNormalizer';
2
+
3
+ export class HtmlNormalizer extends BaseNormalizer {
4
+ static BLOCK_NODE_NAMES = ['P', 'H1', 'H2', 'H3', 'H4'];
5
+
6
+ static BLOCK_STYLES = [
7
+ 'text-align',
8
+ 'line-height',
9
+ 'margin',
10
+ 'margin-top',
11
+ 'margin-bottom',
12
+ 'margin-left',
13
+ 'margin-right'
14
+ ];
15
+
16
+ #parser;
17
+
18
+ constructor({ content, parser }) {
19
+ super({ content });
20
+ this.#parser = parser;
21
+ this.dom = null;
22
+ }
23
+
24
+ normalize() {
25
+ this.normalizeHTML();
26
+ return this.normalizedHTML;
27
+ }
28
+
29
+ normalizeHTML() {
30
+ this.dom = this.#parser.parse(this.content.replace(/(\r)?\n/g, ''));
31
+
32
+ this.#removeComments();
33
+ this.#iterateNodes(this.#normalizeBreakLines, (node) => node.tagName === 'BR');
34
+ this.#iterateNodes(this.#removeEmptyNodes, this.#isBlockNode);
35
+ this.#iterateNodes(this.#normalizeListItems, (node) => node.tagName === 'LI');
36
+ this.#normalizeBlockTextDecoration();
37
+ }
38
+
39
+ get normalizedHTML() {
40
+ return this.dom.body.innerHTML;
41
+ }
42
+
43
+ get #NodeFilter() {
44
+ return this.#parser.types.NodeFilter;
45
+ }
46
+
47
+ get #Node() {
48
+ return this.#parser.types.Node;
49
+ }
50
+
51
+ #removeComments() {
52
+ const iterator = this.#createNodeIterator(this.#NodeFilter.SHOW_COMMENT);
53
+
54
+ this.#runIterator(iterator, (node) => node.remove());
55
+ }
56
+
57
+ #createNodeIterator(whatToShow, filter) {
58
+ return this.dom.createNodeIterator(this.dom.body, whatToShow, filter);
59
+ }
60
+
61
+ #iterateNodes(handler, condition = () => true) {
62
+ const checkCondition = (node) => node.tagName !== 'BODY' && condition.call(this, node);
63
+
64
+ const iterator = this.#createNodeIterator(this.#NodeFilter.SHOW_ELEMENT, {
65
+ acceptNode: (node) => checkCondition(node) ? this.#NodeFilter.FILTER_ACCEPT : this.#NodeFilter.FILTER_REJECT
66
+ });
67
+
68
+ this.#runIterator(iterator, handler);
69
+ }
70
+
71
+ #runIterator(iterator, handler) {
72
+ let currentNode = iterator.nextNode();
73
+
74
+ while (currentNode) {
75
+ handler.call(this, currentNode);
76
+ currentNode = iterator.nextNode();
77
+ }
78
+ }
79
+
80
+ #removeEmptyNodes(node) {
81
+ if (!node.innerHTML.trim()) node.remove();
82
+ }
83
+
84
+ #normalizeListItems(itemEl) {
85
+ const fragment = this.dom.createDocumentFragment();
86
+ const children = Array.from(itemEl.childNodes);
87
+ let capturingParagraph;
88
+ let previousNode;
89
+
90
+ const append = (node) => {
91
+ this.#assignElementProperties(node, itemEl, HtmlNormalizer.BLOCK_STYLES);
92
+ fragment.append(node);
93
+ };
94
+
95
+ this.#assignElementProperties(itemEl, itemEl.parentElement, HtmlNormalizer.BLOCK_STYLES);
96
+
97
+ for (const node of children) {
98
+ if (this.#isBlockNode(node)) {
99
+ append(node);
100
+ capturingParagraph = null;
101
+ previousNode = node;
102
+ continue;
103
+ }
104
+
105
+ if (node.tagName === 'BR' && previousNode && previousNode?.tagName !== 'BR') {
106
+ node.remove();
107
+ previousNode = node;
108
+ continue;
109
+ }
110
+
111
+ if (node.tagName === 'BR') {
112
+ const emptyLineEl = this.dom.createElement('p');
113
+
114
+ emptyLineEl.append(node);
115
+ append(emptyLineEl);
116
+ capturingParagraph = null;
117
+ previousNode = node;
118
+ continue;
119
+ }
120
+
121
+ if (!capturingParagraph) {
122
+ capturingParagraph = this.dom.createElement('p');
123
+ append(capturingParagraph);
124
+ }
125
+
126
+ capturingParagraph.append(node);
127
+ previousNode = node;
128
+ }
129
+
130
+ itemEl.append(fragment);
131
+ this.#removeStyleProperties(itemEl, HtmlNormalizer.BLOCK_STYLES);
132
+ }
133
+
134
+ #isBlockNode(node) {
135
+ return HtmlNormalizer.BLOCK_NODE_NAMES.includes(node.tagName);
136
+ }
137
+
138
+ #assignElementProperties(target, source, properties) {
139
+ for (const property of properties) {
140
+ const value = source.style.getPropertyValue(property);
141
+
142
+ if (value && !target.style.getPropertyValue(property)) {
143
+ target.style.setProperty(property, value);
144
+ }
145
+ }
146
+ }
147
+
148
+ #removeStyleProperties(element, properties) {
149
+ for (const property of properties) {
150
+ element.style.removeProperty(property);
151
+ }
152
+
153
+ if (element.style.length === 0) {
154
+ element.removeAttribute('style');
155
+ }
156
+ }
157
+
158
+ #normalizeBreakLines({ parentElement }) {
159
+ if (!this.#isBlockNode(parentElement)) return;
160
+ if (!parentElement.textContent) return;
161
+
162
+ const fragment = this.dom.createDocumentFragment();
163
+ const children = Array.from(parentElement.childNodes);
164
+ const parentTemplate = parentElement.cloneNode(true);
165
+
166
+ parentTemplate.innerHTML = '';
167
+ let capturingParagraph = parentTemplate.cloneNode();
168
+
169
+ const append = (node) => {
170
+ this.#assignElementProperties(node, parentElement, HtmlNormalizer.BLOCK_STYLES);
171
+ fragment.append(node);
172
+ };
173
+
174
+ for (const child of children) {
175
+ if (child.tagName === 'BR') {
176
+ append(capturingParagraph);
177
+ capturingParagraph = parentTemplate.cloneNode();
178
+ continue;
179
+ }
180
+
181
+ capturingParagraph.append(child);
182
+ }
183
+
184
+ fragment.append(capturingParagraph);
185
+ parentElement.replaceWith(fragment);
186
+ }
187
+
188
+ #normalizeBlockTextDecoration() {
189
+ const blockEls = this.dom.querySelectorAll('[style*="text-decoration"]:where(p, h1, h2, h3, h4, li)');
190
+
191
+ for (const blockEl of blockEls) {
192
+ this.#moveTextDecorationToChildren(blockEl);
193
+ }
194
+ }
195
+
196
+ #moveTextDecorationToChildren(blockEl) {
197
+ const blockDecoration = this.#parseTextDecoration(blockEl);
198
+
199
+ blockEl.style.removeProperty('text-decoration-line');
200
+ blockEl.style.removeProperty('text-decoration');
201
+ if (!blockEl.style.cssText) blockEl.removeAttribute('style');
202
+
203
+ if (blockDecoration.none) return;
204
+
205
+ for (const childNode of blockEl.childNodes) {
206
+ const textEl = this.#wrapTextNode(blockEl, childNode);
207
+ const textDecoration = this.#parseTextDecoration(textEl);
208
+
209
+ const mergedDecoration = {
210
+ underline: textDecoration.underline || blockDecoration.underline,
211
+ line_through: textDecoration.line_through || blockDecoration.line_through
212
+ };
213
+
214
+ textEl.style.removeProperty('text-decoration-line');
215
+ textEl.style.removeProperty('text-decoration');
216
+
217
+ textEl.style.textDecoration = Object.entries(mergedDecoration)
218
+ .filter(([, value]) => value)
219
+ .map(([name]) => name.replace('_', '-'))
220
+ .join(' ');
221
+ }
222
+ }
223
+
224
+ #parseTextDecoration(element) {
225
+ const { textDecoration, textDecorationLine } = element.style;
226
+ const decoration = textDecoration || textDecorationLine || '';
227
+
228
+ return {
229
+ none: decoration.includes('none'),
230
+ underline: decoration.includes('underline'),
231
+ line_through: decoration.includes('line-through')
232
+ };
233
+ }
234
+
235
+ #wrapTextNode(parent, node) {
236
+ if (node.nodeType !== this.#Node.TEXT_NODE) return node;
237
+
238
+ const span = this.dom.createElement('span');
239
+
240
+ span.append(node.cloneNode());
241
+ parent.replaceChild(span, node);
242
+
243
+ return span;
244
+ }
245
+ }
@@ -0,0 +1,81 @@
1
+ import { isEqual } from 'lodash';
2
+ import { NodeTypes, TextSettings } from '../../enums';
3
+ import { BaseNormalizer } from './BaseNormalizer';
4
+
5
+ export class JsonNormalizer extends BaseNormalizer {
6
+ normalize() {
7
+ this.#iterateNodes(this.#bubbleMarks);
8
+ return this.content;
9
+ }
10
+
11
+ #iterateNodes(handler) {
12
+ this.#iterateChildNodes(this.content, handler);
13
+ }
14
+
15
+ #iterateChildNodes(node, handler) {
16
+ for (const child of node.content) {
17
+ handler.call(this, child);
18
+ child.content && this.#iterateChildNodes(child, handler);
19
+ }
20
+ }
21
+
22
+ #bubbleMarks(node) {
23
+ if (!node.content) return;
24
+ if (node.type === NodeTypes.LIST) return;
25
+
26
+ for (const child of node.content) {
27
+ if (!child.marks) continue;
28
+
29
+ for (const childMark of child.marks.slice()) {
30
+ if (this.#canBubbleMark(node, childMark)) {
31
+ this.#removeMark(child, childMark);
32
+ this.#addMark(node, childMark);
33
+ continue;
34
+ }
35
+
36
+ if (this.#includesMark(node, childMark)) {
37
+ this.#removeMark(child, childMark);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ #canBubbleMark(node, childMark) {
44
+ if (TextSettings.inlineMarks.includes(childMark.type)) return false;
45
+ if (this.#includesMarkType(node, childMark.type)) return false;
46
+
47
+ for (const child of node.content) {
48
+ if (!child.marks) return false;
49
+ if (!this.#includesMarkType(child, childMark.type)) return false;
50
+ }
51
+
52
+ return true;
53
+ }
54
+
55
+ #includesMark(node, checkingMark) {
56
+ return node.marks?.some((mark) => isEqual(mark, checkingMark)) ?? false;
57
+ }
58
+
59
+ #includesMarkType(node, type) {
60
+ return node.marks?.some((mark) => mark.type === type) ?? false;
61
+ }
62
+
63
+ #removeMark(node, mark) {
64
+ if (!node.marks) return;
65
+
66
+ const index = this.#findMarkIndexByType(node, mark.type);
67
+
68
+ if (index >= 0) node.marks.splice(index, 1);
69
+ if (!node.marks.length) delete node.marks;
70
+ }
71
+
72
+ #addMark(node, mark) {
73
+ this.#removeMark(node, mark);
74
+ node.marks ??= [];
75
+ node.marks.push(mark);
76
+ }
77
+
78
+ #findMarkIndexByType(node, type) {
79
+ return node.marks?.findIndex((mark) => mark.type === type) ?? null;
80
+ }
81
+ }
@@ -1,13 +1,6 @@
1
1
  import { ContentNormalizer } from '../ContentNormalizer';
2
- import { NodeFactory } from '../NodeFactory';
3
2
 
4
3
  describe('normalize text content', () => {
5
- test('should ignore json content', () => {
6
- const content = NodeFactory.doc([NodeFactory.paragraph('Test')]);
7
-
8
- expect(ContentNormalizer.normalize(content)).toBe(content);
9
- });
10
-
11
4
  test('should wrap list content in paragraph', () => {
12
5
  const input = '<ul><li style="line-height: 2;">lorem impsum</li></ul>';
13
6
  const output = '<ul><li><p style="line-height: 2;">lorem impsum</p></li></ul>';
@@ -107,4 +100,35 @@ describe('normalize text content', () => {
107
100
 
108
101
  expect(ContentNormalizer.normalize(input)).toBe(output);
109
102
  });
103
+
104
+ test('should ignore text decoration on text nodes', () => {
105
+ const input = '<p><span style="text-decoration: underline;">lorem ipsum</span></p>';
106
+ const output = '<p><span style="text-decoration: underline;">lorem ipsum</span></p>';
107
+
108
+ expect(ContentNormalizer.normalize(input)).toBe(output);
109
+ });
110
+
111
+ test('should ignore none text decoration', () => {
112
+ const input = '<p style="text-decoration: none;">lorem ipsum</p>';
113
+ const output = '<p>lorem ipsum</p>';
114
+
115
+ expect(ContentNormalizer.normalize(input)).toBe(output);
116
+ });
117
+
118
+ test('should move text decoration from block to text only nodes', () => {
119
+ const input = '<p style="text-decoration: underline;">lorem ipsum</p>';
120
+ const output = '<p><span style="text-decoration: underline;">lorem ipsum</span></p>';
121
+
122
+ expect(ContentNormalizer.normalize(input)).toBe(output);
123
+ });
124
+
125
+ test('should move text decoration from block to mixed content', () => {
126
+ const input = '<p style="text-decoration: underline;"><span style="text-decoration: line-through;">lorem</span> ipsum</p>';
127
+ const output = '<p>' +
128
+ '<span style="text-decoration: underline line-through;">lorem</span>' +
129
+ '<span style="text-decoration: underline;"> ipsum</span>' +
130
+ '</p>';
131
+
132
+ expect(ContentNormalizer.normalize(input)).toBe(output);
133
+ });
110
134
  });
@@ -0,0 +1,70 @@
1
+ import { NodeFactory } from '../../NodeFactory';
2
+ import { ListTypes, TextSettings } from '../../../enums';
3
+ import { ContentNormalizer } from '../ContentNormalizer';
4
+
5
+ describe('normalize json content', () => {
6
+ test('should bubble mark from single text node to paragraph', () => {
7
+ const input = NodeFactory.doc([
8
+ NodeFactory.paragraph([
9
+ NodeFactory.text('lorem ipsum', [
10
+ NodeFactory.mark(TextSettings.FONT_WEIGHT, { value: '700' })
11
+ ])
12
+ ])
13
+ ]);
14
+
15
+ expect(ContentNormalizer.normalize(input)).toMatchSnapshot();
16
+ });
17
+
18
+ test('should bubble mark from two text nodes to paragraph', () => {
19
+ const input = NodeFactory.doc([
20
+ NodeFactory.paragraph([
21
+ NodeFactory.text('lorem', [
22
+ NodeFactory.mark(TextSettings.FONT_WEIGHT, { value: '700' })
23
+ ]),
24
+ NodeFactory.text(' ipsum', [
25
+ NodeFactory.mark(TextSettings.FONT_WEIGHT, { value: '700' }),
26
+ NodeFactory.mark(TextSettings.FONT_COLOR, { value: '#FF0000' })
27
+ ])
28
+ ])
29
+ ]);
30
+
31
+ expect(ContentNormalizer.normalize(input)).toMatchSnapshot();
32
+ });
33
+
34
+ test('should bubble mark from text to list item', () => {
35
+ const input = NodeFactory.doc([
36
+ NodeFactory.list(ListTypes.DISC, [
37
+ NodeFactory.paragraph(null, [
38
+ NodeFactory.mark(TextSettings.FONT_WEIGHT, { value: '700' })
39
+ ], 'lorem ipsum')
40
+ ])
41
+ ]);
42
+
43
+ expect(ContentNormalizer.normalize(input)).toMatchSnapshot();
44
+ });
45
+
46
+ test('should bubble two marks', () => {
47
+ const input = NodeFactory.doc([
48
+ NodeFactory.paragraph([
49
+ NodeFactory.text('hello world', [
50
+ NodeFactory.mark(TextSettings.FONT_FAMILY, { value: 'Bungee' }),
51
+ NodeFactory.mark(TextSettings.FONT_WEIGHT, { value: '800' })
52
+ ])
53
+ ])
54
+ ]);
55
+
56
+ expect(ContentNormalizer.normalize(input)).toMatchSnapshot();
57
+ });
58
+
59
+ test('should not bubble inline marks', () => {
60
+ const input = NodeFactory.doc([
61
+ NodeFactory.paragraph([
62
+ NodeFactory.text('hello world', [
63
+ NodeFactory.mark(TextSettings.TEXT_DECORATION, { underline: true })
64
+ ])
65
+ ])
66
+ ]);
67
+
68
+ expect(ContentNormalizer.normalize(input)).toMatchSnapshot();
69
+ });
70
+ });