@akinon/ui-editor 1.0.6 → 1.0.7-rc.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/dist/cjs/i18n/translations/en.d.ts +1 -0
- package/dist/cjs/i18n/translations/en.d.ts.map +1 -1
- package/dist/cjs/i18n/translations/en.js +1 -0
- package/dist/cjs/i18n/translations/tr.d.ts +1 -0
- package/dist/cjs/i18n/translations/tr.d.ts.map +1 -1
- package/dist/cjs/i18n/translations/tr.js +1 -0
- package/dist/cjs/tiptap/components/__tests__/html-code-viewer.test.js +0 -8
- package/dist/cjs/tiptap/components/__tests__/html-code-viewer.test.tsx +0 -11
- package/dist/cjs/tiptap/components/__tests__/tiptap.test.js +53 -12
- package/dist/cjs/tiptap/components/__tests__/tiptap.test.tsx +79 -13
- package/dist/cjs/tiptap/components/html-code-viewer.d.ts.map +1 -1
- package/dist/cjs/tiptap/components/html-code-viewer.js +1 -24
- package/dist/cjs/tiptap/components/menu-bar/__tests__/action-toolbar.test.js +7 -7
- package/dist/cjs/tiptap/components/menu-bar/__tests__/action-toolbar.test.tsx +7 -0
- package/dist/cjs/tiptap/components/menu-bar/__tests__/index.test.js +1 -0
- package/dist/cjs/tiptap/components/menu-bar/__tests__/index.test.tsx +1 -0
- package/dist/cjs/tiptap/components/menu-bar/action-toolbar.d.ts +2 -1
- package/dist/cjs/tiptap/components/menu-bar/action-toolbar.d.ts.map +1 -1
- package/dist/cjs/tiptap/components/menu-bar/action-toolbar.js +10 -2
- package/dist/cjs/tiptap/components/menu-bar/index.d.ts +2 -1
- package/dist/cjs/tiptap/components/menu-bar/index.d.ts.map +1 -1
- package/dist/cjs/tiptap/components/menu-bar/index.js +2 -2
- package/dist/cjs/tiptap/components/tiptap.d.ts.map +1 -1
- package/dist/cjs/tiptap/components/tiptap.js +23 -9
- package/dist/cjs/tiptap/constants/editor-config.d.ts +1 -0
- package/dist/cjs/tiptap/constants/editor-config.d.ts.map +1 -1
- package/dist/cjs/tiptap/constants/editor-config.js +1 -0
- package/dist/cjs/tiptap/utils/__tests__/clean-html.test.js +26 -7
- package/dist/cjs/tiptap/utils/__tests__/clean-html.test.ts +29 -7
- package/dist/cjs/tiptap/utils/prettier-format.js +1 -1
- package/dist/esm/i18n/translations/en.d.ts +1 -0
- package/dist/esm/i18n/translations/en.d.ts.map +1 -1
- package/dist/esm/i18n/translations/en.js +1 -0
- package/dist/esm/i18n/translations/tr.d.ts +1 -0
- package/dist/esm/i18n/translations/tr.d.ts.map +1 -1
- package/dist/esm/i18n/translations/tr.js +1 -0
- package/dist/esm/tiptap/components/__tests__/html-code-viewer.test.js +0 -8
- package/dist/esm/tiptap/components/__tests__/html-code-viewer.test.tsx +0 -11
- package/dist/esm/tiptap/components/__tests__/tiptap.test.js +53 -12
- package/dist/esm/tiptap/components/__tests__/tiptap.test.tsx +79 -13
- package/dist/esm/tiptap/components/html-code-viewer.d.ts.map +1 -1
- package/dist/esm/tiptap/components/html-code-viewer.js +1 -24
- package/dist/esm/tiptap/components/menu-bar/__tests__/action-toolbar.test.js +7 -7
- package/dist/esm/tiptap/components/menu-bar/__tests__/action-toolbar.test.tsx +7 -0
- package/dist/esm/tiptap/components/menu-bar/__tests__/index.test.js +1 -0
- package/dist/esm/tiptap/components/menu-bar/__tests__/index.test.tsx +1 -0
- package/dist/esm/tiptap/components/menu-bar/action-toolbar.d.ts +2 -1
- package/dist/esm/tiptap/components/menu-bar/action-toolbar.d.ts.map +1 -1
- package/dist/esm/tiptap/components/menu-bar/action-toolbar.js +10 -2
- package/dist/esm/tiptap/components/menu-bar/index.d.ts +2 -1
- package/dist/esm/tiptap/components/menu-bar/index.d.ts.map +1 -1
- package/dist/esm/tiptap/components/menu-bar/index.js +2 -2
- package/dist/esm/tiptap/components/tiptap.d.ts.map +1 -1
- package/dist/esm/tiptap/components/tiptap.js +23 -9
- package/dist/esm/tiptap/constants/editor-config.d.ts +1 -0
- package/dist/esm/tiptap/constants/editor-config.d.ts.map +1 -1
- package/dist/esm/tiptap/constants/editor-config.js +1 -0
- package/dist/esm/tiptap/utils/__tests__/clean-html.test.js +26 -7
- package/dist/esm/tiptap/utils/__tests__/clean-html.test.ts +29 -7
- package/dist/esm/tiptap/utils/prettier-format.js +1 -1
- package/package.json +7 -7
|
@@ -8,7 +8,7 @@ import clsx from 'clsx';
|
|
|
8
8
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
9
9
|
import { DragHandleIcon } from '../assets/icons';
|
|
10
10
|
import { EDITOR_CONTENT_HEIGHT, getTableCellDefaults, getTableHeaderDefaults } from '../constants';
|
|
11
|
-
import { cleanDuplicateSpans, getExtensions } from '../utils';
|
|
11
|
+
import { cleanDuplicateSpans, formatHtmlWithPrettier, getExtensions } from '../utils';
|
|
12
12
|
import { FormatBubbleMenu } from './format-bubble-menu';
|
|
13
13
|
import { HtmlCodeViewer } from './html-code-viewer';
|
|
14
14
|
import { MenuBar } from './menu-bar';
|
|
@@ -47,19 +47,27 @@ export const Tiptap = ({ value, defaultValue, onChange, readOnly = false, classN
|
|
|
47
47
|
const editor = useEditor({
|
|
48
48
|
editable: !readOnly,
|
|
49
49
|
content: defaultValue !== undefined ? defaultValue : value,
|
|
50
|
-
|
|
50
|
+
extensions,
|
|
51
|
+
editorProps
|
|
52
|
+
});
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!editor)
|
|
55
|
+
return;
|
|
56
|
+
const handleUpdate = () => {
|
|
51
57
|
var _a;
|
|
52
58
|
(_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, {
|
|
53
59
|
html: editor.getHTML(),
|
|
54
60
|
json: editor.getJSON()
|
|
55
61
|
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
};
|
|
63
|
+
editor.on('update', handleUpdate);
|
|
64
|
+
return () => {
|
|
65
|
+
editor.off('update', handleUpdate);
|
|
66
|
+
};
|
|
67
|
+
}, [editor]);
|
|
60
68
|
useEffect(() => {
|
|
61
69
|
if (editor) {
|
|
62
|
-
editor.setEditable(!readOnly);
|
|
70
|
+
editor.setEditable(!readOnly, false);
|
|
63
71
|
}
|
|
64
72
|
}, [editor, readOnly]);
|
|
65
73
|
useEffect(() => {
|
|
@@ -67,7 +75,7 @@ export const Tiptap = ({ value, defaultValue, onChange, readOnly = false, classN
|
|
|
67
75
|
value !== undefined &&
|
|
68
76
|
value !== prevValueRef.current &&
|
|
69
77
|
value !== editor.getHTML()) {
|
|
70
|
-
editor.commands.setContent(value);
|
|
78
|
+
editor.commands.setContent(value, { emitUpdate: false });
|
|
71
79
|
if (isSourceMode) {
|
|
72
80
|
setRawHTML(value);
|
|
73
81
|
}
|
|
@@ -127,6 +135,12 @@ export const Tiptap = ({ value, defaultValue, onChange, readOnly = false, classN
|
|
|
127
135
|
}
|
|
128
136
|
};
|
|
129
137
|
}, []);
|
|
138
|
+
const handleFormat = useCallback(async () => {
|
|
139
|
+
const formatted = await formatHtmlWithPrettier(rawHTML);
|
|
140
|
+
if (formatted !== rawHTML) {
|
|
141
|
+
setRawHTML(formatted);
|
|
142
|
+
}
|
|
143
|
+
}, [rawHTML]);
|
|
130
144
|
const useStyle = useStyleRegister({
|
|
131
145
|
token: token,
|
|
132
146
|
path: ['TiptapEditor', String(contentHeight)],
|
|
@@ -330,7 +344,7 @@ export const Tiptap = ({ value, defaultValue, onChange, readOnly = false, classN
|
|
|
330
344
|
});
|
|
331
345
|
return useStyle(React.createElement("div", { className: `${baseCls} ${hashId}` },
|
|
332
346
|
editor && (React.createElement(React.Fragment, null,
|
|
333
|
-
React.createElement(MenuBar, { isSourceMode: isSourceMode, toggleSource: toggleSource, editor: editor, onSave: getCleanHtml, className: classNames === null || classNames === void 0 ? void 0 : classNames.editorToolbar, toolbar: toolbar, readOnly: readOnly }),
|
|
347
|
+
React.createElement(MenuBar, { isSourceMode: isSourceMode, toggleSource: toggleSource, onFormat: handleFormat, editor: editor, onSave: getCleanHtml, className: classNames === null || classNames === void 0 ? void 0 : classNames.editorToolbar, toolbar: toolbar, readOnly: readOnly }),
|
|
334
348
|
React.createElement(FormatBubbleMenu, { editor: editor }),
|
|
335
349
|
React.createElement(TableBubbleMenu, { editor: editor }),
|
|
336
350
|
React.createElement(DragHandle, { editor: editor },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editor-config.d.ts","sourceRoot":"","sources":["../../../../src/tiptap/constants/editor-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,eAAO,MAAM,aAAa,UAqBzB,CAAC;AAEF,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;EAMlB,CAAC;AAEX,eAAO,MAAM,UAAU;IAErB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;;CACd,CAAC;AAEX,eAAO,MAAM,oBAAoB;;;;;CAKvB,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,eAAO,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"editor-config.d.ts","sourceRoot":"","sources":["../../../../src/tiptap/constants/editor-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,eAAO,MAAM,aAAa,UAqBzB,CAAC;AAEF,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;EAMlB,CAAC;AAEX,eAAO,MAAM,UAAU;IAErB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;IACtB,SAAA,CAAC,aAAa,CAAC,EAAE,CAAC,KAAI;;CACd,CAAC;AAEX,eAAO,MAAM,oBAAoB;;;;;CAKvB,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BrB,CAAC;AAEX,eAAO,MAAM,iBAAiB;;;;;;;;CAQpB,CAAC;AAEX,eAAO,MAAM,iBAAiB;;;;;CAK7B,CAAC;AAEF,eAAO,MAAM,mBAAmB,UAK/B,CAAC;AAEF,eAAO,MAAM,cAAc,uBAAwB,CAAC;AAEpD,eAAO,MAAM,gBAAgB,6BAG5B,CAAC;AAEF,eAAO,MAAM,WAAW;;;;sBAIE,MAAM,EAAE;yBACL,MAAM,EAAE;CACpC,CAAC;AAEF,eAAO,MAAM,qBAAqB,MAAM,CAAC"}
|
|
@@ -77,17 +77,36 @@ describe('clean-html', () => {
|
|
|
77
77
|
const result = cleanDuplicateSpans(html);
|
|
78
78
|
expect(result).toBe('<div><script>console.log("test")</script>Content</div>');
|
|
79
79
|
});
|
|
80
|
-
it('should handle mixed content with text nodes', () => {
|
|
81
|
-
// This test covers line 87: return node.textContent || '';
|
|
82
|
-
// Testing text nodes between elements
|
|
80
|
+
it('should handle mixed content with text nodes inside elements', () => {
|
|
83
81
|
const html = '<div>Start<p>Middle</p>End</div>';
|
|
84
82
|
const result = cleanDuplicateSpans(html);
|
|
85
83
|
expect(result).toBe('<div>Start<p>Middle</p>End</div>');
|
|
86
84
|
});
|
|
87
|
-
it('should handle
|
|
88
|
-
//
|
|
89
|
-
const html = '
|
|
85
|
+
it('should handle top-level text nodes (covers line 87)', () => {
|
|
86
|
+
// text nodes at the root of template.content hit line 87
|
|
87
|
+
const html = 'plain text only';
|
|
90
88
|
const result = cleanDuplicateSpans(html);
|
|
91
|
-
expect(result).toBe('
|
|
89
|
+
expect(result).toBe('plain text only');
|
|
90
|
+
});
|
|
91
|
+
it('should handle mixed top-level text nodes and elements', () => {
|
|
92
|
+
// "before " and " after" are top-level text nodes, hitting line 87
|
|
93
|
+
const html = 'before <div>element</div> after';
|
|
94
|
+
const result = cleanDuplicateSpans(html);
|
|
95
|
+
expect(result).toBe('before <div>element</div> after');
|
|
96
|
+
});
|
|
97
|
+
it("should handle empty text content fallback (|| '' branch)", () => {
|
|
98
|
+
// A whitespace-only top-level text node: textContent is ' ' (truthy)
|
|
99
|
+
// but tests the same code path; empty-string case uses the || '' fallback
|
|
100
|
+
const html = ' ';
|
|
101
|
+
const result = cleanDuplicateSpans(html);
|
|
102
|
+
expect(result).toBe(' ');
|
|
103
|
+
});
|
|
104
|
+
it("should return '' for top-level empty comment nodes (covers || '' branch on line 87)", () => {
|
|
105
|
+
// An empty HTML comment (<!---->) creates a top-level Comment node.
|
|
106
|
+
// Comment nodes are not ELEMENT_NODEs, so they hit line 87.
|
|
107
|
+
// Their textContent is '' (empty string / falsy), triggering the || '' fallback.
|
|
108
|
+
const html = '<!---->';
|
|
109
|
+
const result = cleanDuplicateSpans(html);
|
|
110
|
+
expect(result).toBe('');
|
|
92
111
|
});
|
|
93
112
|
});
|
|
@@ -112,18 +112,40 @@ describe('clean-html', () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
it('should handle mixed content with text nodes', () => {
|
|
116
|
-
// This test covers line 87: return node.textContent || '';
|
|
117
|
-
// Testing text nodes between elements
|
|
115
|
+
it('should handle mixed content with text nodes inside elements', () => {
|
|
118
116
|
const html = '<div>Start<p>Middle</p>End</div>';
|
|
119
117
|
const result = cleanDuplicateSpans(html);
|
|
120
118
|
expect(result).toBe('<div>Start<p>Middle</p>End</div>');
|
|
121
119
|
});
|
|
122
120
|
|
|
123
|
-
it('should handle
|
|
124
|
-
//
|
|
125
|
-
const html = '
|
|
121
|
+
it('should handle top-level text nodes (covers line 87)', () => {
|
|
122
|
+
// text nodes at the root of template.content hit line 87
|
|
123
|
+
const html = 'plain text only';
|
|
126
124
|
const result = cleanDuplicateSpans(html);
|
|
127
|
-
expect(result).toBe('
|
|
125
|
+
expect(result).toBe('plain text only');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle mixed top-level text nodes and elements', () => {
|
|
129
|
+
// "before " and " after" are top-level text nodes, hitting line 87
|
|
130
|
+
const html = 'before <div>element</div> after';
|
|
131
|
+
const result = cleanDuplicateSpans(html);
|
|
132
|
+
expect(result).toBe('before <div>element</div> after');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should handle empty text content fallback (|| '' branch)", () => {
|
|
136
|
+
// A whitespace-only top-level text node: textContent is ' ' (truthy)
|
|
137
|
+
// but tests the same code path; empty-string case uses the || '' fallback
|
|
138
|
+
const html = ' ';
|
|
139
|
+
const result = cleanDuplicateSpans(html);
|
|
140
|
+
expect(result).toBe(' ');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should return '' for top-level empty comment nodes (covers || '' branch on line 87)", () => {
|
|
144
|
+
// An empty HTML comment (<!---->) creates a top-level Comment node.
|
|
145
|
+
// Comment nodes are not ELEMENT_NODEs, so they hit line 87.
|
|
146
|
+
// Their textContent is '' (empty string / falsy), triggering the || '' fallback.
|
|
147
|
+
const html = '<!---->';
|
|
148
|
+
const result = cleanDuplicateSpans(html);
|
|
149
|
+
expect(result).toBe('');
|
|
128
150
|
});
|
|
129
151
|
});
|
|
@@ -33,6 +33,7 @@ declare const translations: {
|
|
|
33
33
|
readonly redo: "Redo";
|
|
34
34
|
readonly source_mode: "Switch to Source Mode";
|
|
35
35
|
readonly normal_mode: "Switch to Preview Mode";
|
|
36
|
+
readonly format: "Format HTML";
|
|
36
37
|
readonly link: "Link";
|
|
37
38
|
readonly align_left: "Align Left";
|
|
38
39
|
readonly align_center: "Align Center";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../../../src/i18n/translations/en.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../../../src/i18n/translations/en.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoFR,CAAC;AAEX,eAAe,YAAY,CAAC"}
|
|
@@ -33,6 +33,7 @@ declare const translations: {
|
|
|
33
33
|
readonly redo: "Yinele";
|
|
34
34
|
readonly source_mode: "Kaynak Koduna Geç";
|
|
35
35
|
readonly normal_mode: "Önizleme Moduna Geç";
|
|
36
|
+
readonly format: "HTML Formatla";
|
|
36
37
|
readonly link: "Bağlantı";
|
|
37
38
|
readonly align_left: "Sola Hizala";
|
|
38
39
|
readonly align_center: "Ortala";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tr.d.ts","sourceRoot":"","sources":["../../../../src/i18n/translations/tr.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"tr.d.ts","sourceRoot":"","sources":["../../../../src/i18n/translations/tr.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoFR,CAAC;AAEX,eAAe,YAAY,CAAC"}
|
|
@@ -98,14 +98,6 @@ describe('HtmlCodeViewer', () => {
|
|
|
98
98
|
const textarea = screen.getByRole('textbox');
|
|
99
99
|
expect(textarea).toBeInTheDocument();
|
|
100
100
|
});
|
|
101
|
-
it('should format code on mount', async () => {
|
|
102
|
-
const formatted = '<p> Formatted </p>';
|
|
103
|
-
vi.mocked(formatHtmlWithPrettier).mockResolvedValue(formatted);
|
|
104
|
-
render(React.createElement(HtmlCodeViewer, Object.assign({}, defaultProps, { value: "<p>Raw</p>" })));
|
|
105
|
-
await waitFor(() => {
|
|
106
|
-
expect(defaultProps.onChange).toHaveBeenCalledWith(formatted);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
101
|
it('should handle paste event', async () => {
|
|
110
102
|
const formatted = '<p>Formatted Paste</p>';
|
|
111
103
|
vi.mocked(formatHtmlWithPrettier).mockResolvedValue(formatted);
|
|
@@ -143,17 +143,6 @@ describe('HtmlCodeViewer', () => {
|
|
|
143
143
|
expect(textarea).toBeInTheDocument();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it('should format code on mount', async () => {
|
|
147
|
-
const formatted = '<p> Formatted </p>';
|
|
148
|
-
vi.mocked(formatHtmlWithPrettier).mockResolvedValue(formatted);
|
|
149
|
-
|
|
150
|
-
render(<HtmlCodeViewer {...defaultProps} value="<p>Raw</p>" />);
|
|
151
|
-
|
|
152
|
-
await waitFor(() => {
|
|
153
|
-
expect(defaultProps.onChange).toHaveBeenCalledWith(formatted);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
146
|
it('should handle paste event', async () => {
|
|
158
147
|
const formatted = '<p>Formatted Paste</p>';
|
|
159
148
|
vi.mocked(formatHtmlWithPrettier).mockResolvedValue(formatted);
|
|
@@ -5,9 +5,10 @@ import React from 'react';
|
|
|
5
5
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { MenuBar } from '../menu-bar';
|
|
7
7
|
import { Tiptap } from '../tiptap';
|
|
8
|
-
const { mockUseEditor, mockChangeLanguage } = vi.hoisted(() => ({
|
|
8
|
+
const { mockUseEditor, mockChangeLanguage, mockFormatHtmlWithPrettier } = vi.hoisted(() => ({
|
|
9
9
|
mockUseEditor: vi.fn(),
|
|
10
|
-
mockChangeLanguage: vi.fn()
|
|
10
|
+
mockChangeLanguage: vi.fn(),
|
|
11
|
+
mockFormatHtmlWithPrettier: vi.fn(async (html) => html)
|
|
11
12
|
}));
|
|
12
13
|
vi.useFakeTimers();
|
|
13
14
|
vi.mock('@tiptap/react', () => ({
|
|
@@ -25,8 +26,9 @@ vi.mock('@tiptap/html', () => ({
|
|
|
25
26
|
}));
|
|
26
27
|
vi.mock('../menu-bar', () => ({
|
|
27
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
-
MenuBar: vi.fn(({ toggleSource, isSourceMode }) => (React.createElement("div", { "data-testid": "menu-bar" },
|
|
29
|
-
React.createElement("button", { onClick: toggleSource, "data-testid": "toggle-source" }, isSourceMode ? 'Switch to Visual' : 'Switch to Source')
|
|
29
|
+
MenuBar: vi.fn(({ toggleSource, isSourceMode, onFormat }) => (React.createElement("div", { "data-testid": "menu-bar" },
|
|
30
|
+
React.createElement("button", { onClick: toggleSource, "data-testid": "toggle-source" }, isSourceMode ? 'Switch to Visual' : 'Switch to Source'),
|
|
31
|
+
onFormat && (React.createElement("button", { onClick: onFormat, "data-testid": "format-button" }, "Format")))))
|
|
30
32
|
}));
|
|
31
33
|
vi.mock('../format-bubble-menu', () => ({
|
|
32
34
|
FormatBubbleMenu: () => React.createElement("div", { "data-testid": "format-bubble-menu" })
|
|
@@ -43,7 +45,8 @@ vi.mock('../../assets/icons', () => ({
|
|
|
43
45
|
}));
|
|
44
46
|
vi.mock('../../utils', () => ({
|
|
45
47
|
getExtensions: vi.fn(() => []),
|
|
46
|
-
cleanDuplicateSpans: vi.fn(html => html)
|
|
48
|
+
cleanDuplicateSpans: vi.fn(html => html),
|
|
49
|
+
formatHtmlWithPrettier: mockFormatHtmlWithPrettier
|
|
47
50
|
}));
|
|
48
51
|
vi.mock('@akinon/akilocale/react', () => ({
|
|
49
52
|
useTranslation: () => ({
|
|
@@ -78,6 +81,8 @@ describe('Tiptap', () => {
|
|
|
78
81
|
getHTML: vi.fn(() => '<p>Initial Content</p>'),
|
|
79
82
|
getJSON: vi.fn(() => ({ type: 'doc' })),
|
|
80
83
|
setEditable: vi.fn(),
|
|
84
|
+
on: vi.fn(),
|
|
85
|
+
off: vi.fn(),
|
|
81
86
|
commands: {
|
|
82
87
|
setContent: vi.fn(),
|
|
83
88
|
blur: vi.fn()
|
|
@@ -141,27 +146,31 @@ describe('Tiptap', () => {
|
|
|
141
146
|
const { rerender } = renderComponent({ value: '<p>Old</p>' });
|
|
142
147
|
mockEditor.getHTML.mockReturnValue('<p>Old</p>');
|
|
143
148
|
rerender(React.createElement(Tiptap, { value: "<p>New</p>", onChange: onChange }));
|
|
144
|
-
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('<p>New</p>'
|
|
149
|
+
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('<p>New</p>', {
|
|
150
|
+
emitUpdate: false
|
|
151
|
+
});
|
|
145
152
|
});
|
|
146
153
|
it('should update editor editable state when readOnly prop changes', () => {
|
|
147
154
|
const { rerender } = renderComponent({ readOnly: false });
|
|
148
|
-
expect(mockEditor.setEditable).toHaveBeenCalledWith(true);
|
|
155
|
+
expect(mockEditor.setEditable).toHaveBeenCalledWith(true, false);
|
|
149
156
|
rerender(React.createElement(Tiptap, { value: "<p>Test</p>", readOnly: true, onChange: onChange }));
|
|
150
|
-
expect(mockEditor.setEditable).toHaveBeenCalledWith(false);
|
|
157
|
+
expect(mockEditor.setEditable).toHaveBeenCalledWith(false, false);
|
|
151
158
|
});
|
|
152
159
|
it('should call onChange on editor update', () => {
|
|
160
|
+
var _a;
|
|
153
161
|
renderComponent();
|
|
154
|
-
const
|
|
155
|
-
|
|
162
|
+
const handler = (_a = mockEditor.on.mock.calls.find(([event]) => event === 'update')) === null || _a === void 0 ? void 0 : _a[1];
|
|
163
|
+
handler();
|
|
156
164
|
expect(onChange).toHaveBeenCalledWith({
|
|
157
165
|
html: '<p>Initial Content</p>',
|
|
158
166
|
json: { type: 'doc' }
|
|
159
167
|
});
|
|
160
168
|
});
|
|
161
169
|
it('should handle undefined onChange', () => {
|
|
170
|
+
var _a;
|
|
162
171
|
const { rerender } = render(React.createElement(Tiptap, { value: "<p>Test</p>", onChange: undefined }));
|
|
163
|
-
const
|
|
164
|
-
expect(() =>
|
|
172
|
+
const handler = (_a = mockEditor.on.mock.calls.find(([event]) => event === 'update')) === null || _a === void 0 ? void 0 : _a[1];
|
|
173
|
+
expect(() => handler()).not.toThrow();
|
|
165
174
|
const viewer = screen.getByTestId('html-code-viewer');
|
|
166
175
|
fireEvent.change(viewer, { target: { value: 'new' } });
|
|
167
176
|
rerender(React.createElement(Tiptap, { value: "<p>Test</p>", onChange: undefined }));
|
|
@@ -310,4 +319,36 @@ describe('Tiptap', () => {
|
|
|
310
319
|
const viewer = screen.getByTestId('html-code-viewer');
|
|
311
320
|
expect(viewer).toHaveValue('<script>console.log("test")</script>');
|
|
312
321
|
});
|
|
322
|
+
it('should format HTML when handleFormat is called and HTML changes', async () => {
|
|
323
|
+
const formattedHtml = '<p>\n Formatted Content\n</p>';
|
|
324
|
+
mockFormatHtmlWithPrettier.mockResolvedValueOnce(formattedHtml);
|
|
325
|
+
renderComponent({ value: '<p>Unformatted</p>' });
|
|
326
|
+
const formatButton = screen.getByTestId('format-button');
|
|
327
|
+
fireEvent.click(formatButton);
|
|
328
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
329
|
+
expect(mockFormatHtmlWithPrettier).toHaveBeenCalledWith('<p>Unformatted</p>');
|
|
330
|
+
// Formatting should only update local rawHTML state, not trigger onChange
|
|
331
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
332
|
+
// Verify the formatted HTML is displayed in the source viewer
|
|
333
|
+
const viewer = screen.getByTestId('html-code-viewer');
|
|
334
|
+
expect(viewer).toHaveValue(formattedHtml);
|
|
335
|
+
});
|
|
336
|
+
it('should not call onChange when formatted HTML is same as original', async () => {
|
|
337
|
+
const html = '<p>Already Formatted</p>';
|
|
338
|
+
mockFormatHtmlWithPrettier.mockResolvedValueOnce(html);
|
|
339
|
+
renderComponent({ value: html });
|
|
340
|
+
onChange.mockClear();
|
|
341
|
+
const formatButton = screen.getByTestId('format-button');
|
|
342
|
+
fireEvent.click(formatButton);
|
|
343
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
344
|
+
expect(mockFormatHtmlWithPrettier).toHaveBeenCalledWith(html);
|
|
345
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
it('should handle toggleSource gracefully when editor is null', () => {
|
|
348
|
+
mockUseEditor.mockReturnValueOnce(null);
|
|
349
|
+
renderComponent();
|
|
350
|
+
const toggleButton = screen.queryByTestId('toggle-source');
|
|
351
|
+
// MenuBar should not be rendered when editor is null
|
|
352
|
+
expect(toggleButton).not.toBeInTheDocument();
|
|
353
|
+
});
|
|
313
354
|
});
|
|
@@ -8,10 +8,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
8
8
|
import { MenuBar } from '../menu-bar';
|
|
9
9
|
import { Tiptap } from '../tiptap';
|
|
10
10
|
|
|
11
|
-
const { mockUseEditor, mockChangeLanguage } =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const { mockUseEditor, mockChangeLanguage, mockFormatHtmlWithPrettier } =
|
|
12
|
+
vi.hoisted(() => ({
|
|
13
|
+
mockUseEditor: vi.fn(),
|
|
14
|
+
mockChangeLanguage: vi.fn(),
|
|
15
|
+
mockFormatHtmlWithPrettier: vi.fn(async (html: string) => html)
|
|
16
|
+
}));
|
|
15
17
|
|
|
16
18
|
vi.useFakeTimers();
|
|
17
19
|
|
|
@@ -39,11 +41,16 @@ vi.mock('@tiptap/html', () => ({
|
|
|
39
41
|
|
|
40
42
|
vi.mock('../menu-bar', () => ({
|
|
41
43
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
-
MenuBar: vi.fn(({ toggleSource, isSourceMode }: any) => (
|
|
44
|
+
MenuBar: vi.fn(({ toggleSource, isSourceMode, onFormat }: any) => (
|
|
43
45
|
<div data-testid="menu-bar">
|
|
44
46
|
<button onClick={toggleSource} data-testid="toggle-source">
|
|
45
47
|
{isSourceMode ? 'Switch to Visual' : 'Switch to Source'}
|
|
46
48
|
</button>
|
|
49
|
+
{onFormat && (
|
|
50
|
+
<button onClick={onFormat} data-testid="format-button">
|
|
51
|
+
Format
|
|
52
|
+
</button>
|
|
53
|
+
)}
|
|
47
54
|
</div>
|
|
48
55
|
))
|
|
49
56
|
}));
|
|
@@ -73,7 +80,8 @@ vi.mock('../../assets/icons', () => ({
|
|
|
73
80
|
|
|
74
81
|
vi.mock('../../utils', () => ({
|
|
75
82
|
getExtensions: vi.fn(() => []),
|
|
76
|
-
cleanDuplicateSpans: vi.fn(html => html)
|
|
83
|
+
cleanDuplicateSpans: vi.fn(html => html),
|
|
84
|
+
formatHtmlWithPrettier: mockFormatHtmlWithPrettier
|
|
77
85
|
}));
|
|
78
86
|
|
|
79
87
|
vi.mock('@akinon/akilocale/react', () => ({
|
|
@@ -113,6 +121,8 @@ describe('Tiptap', () => {
|
|
|
113
121
|
getHTML: vi.fn(() => '<p>Initial Content</p>'),
|
|
114
122
|
getJSON: vi.fn(() => ({ type: 'doc' })),
|
|
115
123
|
setEditable: vi.fn(),
|
|
124
|
+
on: vi.fn(),
|
|
125
|
+
off: vi.fn(),
|
|
116
126
|
commands: {
|
|
117
127
|
setContent: vi.fn(),
|
|
118
128
|
blur: vi.fn()
|
|
@@ -197,24 +207,28 @@ describe('Tiptap', () => {
|
|
|
197
207
|
|
|
198
208
|
rerender(<Tiptap value="<p>New</p>" onChange={onChange} />);
|
|
199
209
|
|
|
200
|
-
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('<p>New</p>'
|
|
210
|
+
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('<p>New</p>', {
|
|
211
|
+
emitUpdate: false
|
|
212
|
+
});
|
|
201
213
|
});
|
|
202
214
|
|
|
203
215
|
it('should update editor editable state when readOnly prop changes', () => {
|
|
204
216
|
const { rerender } = renderComponent({ readOnly: false });
|
|
205
|
-
expect(mockEditor.setEditable).toHaveBeenCalledWith(true);
|
|
217
|
+
expect(mockEditor.setEditable).toHaveBeenCalledWith(true, false);
|
|
206
218
|
|
|
207
219
|
rerender(
|
|
208
220
|
<Tiptap value="<p>Test</p>" readOnly={true} onChange={onChange} />
|
|
209
221
|
);
|
|
210
|
-
expect(mockEditor.setEditable).toHaveBeenCalledWith(false);
|
|
222
|
+
expect(mockEditor.setEditable).toHaveBeenCalledWith(false, false);
|
|
211
223
|
});
|
|
212
224
|
|
|
213
225
|
it('should call onChange on editor update', () => {
|
|
214
226
|
renderComponent();
|
|
215
227
|
|
|
216
|
-
const
|
|
217
|
-
|
|
228
|
+
const handler = mockEditor.on.mock.calls.find(
|
|
229
|
+
([event]: [string]) => event === 'update'
|
|
230
|
+
)?.[1];
|
|
231
|
+
handler();
|
|
218
232
|
|
|
219
233
|
expect(onChange).toHaveBeenCalledWith({
|
|
220
234
|
html: '<p>Initial Content</p>',
|
|
@@ -227,8 +241,10 @@ describe('Tiptap', () => {
|
|
|
227
241
|
<Tiptap value="<p>Test</p>" onChange={undefined} />
|
|
228
242
|
);
|
|
229
243
|
|
|
230
|
-
const
|
|
231
|
-
|
|
244
|
+
const handler = mockEditor.on.mock.calls.find(
|
|
245
|
+
([event]: [string]) => event === 'update'
|
|
246
|
+
)?.[1];
|
|
247
|
+
expect(() => handler()).not.toThrow();
|
|
232
248
|
|
|
233
249
|
const viewer = screen.getByTestId('html-code-viewer');
|
|
234
250
|
fireEvent.change(viewer, { target: { value: 'new' } });
|
|
@@ -425,4 +441,54 @@ describe('Tiptap', () => {
|
|
|
425
441
|
const viewer = screen.getByTestId('html-code-viewer');
|
|
426
442
|
expect(viewer).toHaveValue('<script>console.log("test")</script>');
|
|
427
443
|
});
|
|
444
|
+
|
|
445
|
+
it('should format HTML when handleFormat is called and HTML changes', async () => {
|
|
446
|
+
const formattedHtml = '<p>\n Formatted Content\n</p>';
|
|
447
|
+
mockFormatHtmlWithPrettier.mockResolvedValueOnce(formattedHtml);
|
|
448
|
+
|
|
449
|
+
renderComponent({ value: '<p>Unformatted</p>' });
|
|
450
|
+
|
|
451
|
+
const formatButton = screen.getByTestId('format-button');
|
|
452
|
+
fireEvent.click(formatButton);
|
|
453
|
+
|
|
454
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
455
|
+
|
|
456
|
+
expect(mockFormatHtmlWithPrettier).toHaveBeenCalledWith(
|
|
457
|
+
'<p>Unformatted</p>'
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Formatting should only update local rawHTML state, not trigger onChange
|
|
461
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
462
|
+
|
|
463
|
+
// Verify the formatted HTML is displayed in the source viewer
|
|
464
|
+
const viewer = screen.getByTestId('html-code-viewer');
|
|
465
|
+
expect(viewer).toHaveValue(formattedHtml);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('should not call onChange when formatted HTML is same as original', async () => {
|
|
469
|
+
const html = '<p>Already Formatted</p>';
|
|
470
|
+
mockFormatHtmlWithPrettier.mockResolvedValueOnce(html);
|
|
471
|
+
|
|
472
|
+
renderComponent({ value: html });
|
|
473
|
+
onChange.mockClear();
|
|
474
|
+
|
|
475
|
+
const formatButton = screen.getByTestId('format-button');
|
|
476
|
+
fireEvent.click(formatButton);
|
|
477
|
+
|
|
478
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
479
|
+
|
|
480
|
+
expect(mockFormatHtmlWithPrettier).toHaveBeenCalledWith(html);
|
|
481
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should handle toggleSource gracefully when editor is null', () => {
|
|
485
|
+
mockUseEditor.mockReturnValueOnce(null);
|
|
486
|
+
|
|
487
|
+
renderComponent();
|
|
488
|
+
|
|
489
|
+
const toggleButton = screen.queryByTestId('toggle-source');
|
|
490
|
+
|
|
491
|
+
// MenuBar should not be rendered when editor is null
|
|
492
|
+
expect(toggleButton).not.toBeInTheDocument();
|
|
493
|
+
});
|
|
428
494
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html-code-viewer.d.ts","sourceRoot":"","sources":["../../../../src/tiptap/components/html-code-viewer.tsx"],"names":[],"mappings":"AAYA,OAAO,
|
|
1
|
+
{"version":3,"file":"html-code-viewer.d.ts","sourceRoot":"","sources":["../../../../src/tiptap/components/html-code-viewer.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAK5D,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,cAAc,GAAI,yDAM5B,mBAAmB,sBAkJrB,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getSafeCustomTokens, useToken } from '@akinon/ui-theme';
|
|
2
2
|
import { useStyleRegister } from '@ant-design/cssinjs';
|
|
3
3
|
import { ConfigProvider } from 'antd';
|
|
4
|
-
import React, { useCallback,
|
|
4
|
+
import React, { useCallback, useMemo, useRef } from 'react';
|
|
5
5
|
import { EDITOR_CONTENT_HEIGHT } from '../constants';
|
|
6
6
|
import { formatHtmlWithPrettier, highlightHtml } from '../utils';
|
|
7
7
|
export const HtmlCodeViewer = ({ value, onChange, className, contentHeight = EDITOR_CONTENT_HEIGHT, readOnly = false }) => {
|
|
@@ -19,29 +19,6 @@ export const HtmlCodeViewer = ({ value, onChange, className, contentHeight = EDI
|
|
|
19
19
|
const handleChange = useCallback((e) => {
|
|
20
20
|
onChange(e.target.value);
|
|
21
21
|
}, [onChange]);
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
{
|
|
24
|
-
/*
|
|
25
|
-
Auto-format on mount (switching to source mode).
|
|
26
|
-
The isMounted check is necessary because formatHtmlWithPrettier is async.
|
|
27
|
-
If the component unmounts before format completes, we must not call onChange
|
|
28
|
-
to avoid "state update on unmounted component" errors.
|
|
29
|
-
*/
|
|
30
|
-
}
|
|
31
|
-
let isMounted = true;
|
|
32
|
-
const formatOnMount = async () => {
|
|
33
|
-
if (value && !readOnly) {
|
|
34
|
-
const formatted = await formatHtmlWithPrettier(value);
|
|
35
|
-
if (isMounted && formatted !== value) {
|
|
36
|
-
onChange(formatted);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
formatOnMount();
|
|
41
|
-
return () => {
|
|
42
|
-
isMounted = false;
|
|
43
|
-
};
|
|
44
|
-
}, []);
|
|
45
22
|
const handlePaste = useCallback(async (e) => {
|
|
46
23
|
if (readOnly)
|
|
47
24
|
return;
|