@akinon/ui-editor 1.0.5 → 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.
Files changed (61) hide show
  1. package/dist/cjs/i18n/translations/en.d.ts +1 -0
  2. package/dist/cjs/i18n/translations/en.d.ts.map +1 -1
  3. package/dist/cjs/i18n/translations/en.js +1 -0
  4. package/dist/cjs/i18n/translations/tr.d.ts +1 -0
  5. package/dist/cjs/i18n/translations/tr.d.ts.map +1 -1
  6. package/dist/cjs/i18n/translations/tr.js +1 -0
  7. package/dist/cjs/tiptap/components/__tests__/html-code-viewer.test.js +0 -8
  8. package/dist/cjs/tiptap/components/__tests__/html-code-viewer.test.tsx +0 -11
  9. package/dist/cjs/tiptap/components/__tests__/tiptap.test.js +53 -12
  10. package/dist/cjs/tiptap/components/__tests__/tiptap.test.tsx +79 -13
  11. package/dist/cjs/tiptap/components/html-code-viewer.d.ts.map +1 -1
  12. package/dist/cjs/tiptap/components/html-code-viewer.js +1 -24
  13. package/dist/cjs/tiptap/components/menu-bar/__tests__/action-toolbar.test.js +7 -7
  14. package/dist/cjs/tiptap/components/menu-bar/__tests__/action-toolbar.test.tsx +7 -0
  15. package/dist/cjs/tiptap/components/menu-bar/__tests__/index.test.js +1 -0
  16. package/dist/cjs/tiptap/components/menu-bar/__tests__/index.test.tsx +1 -0
  17. package/dist/cjs/tiptap/components/menu-bar/action-toolbar.d.ts +2 -1
  18. package/dist/cjs/tiptap/components/menu-bar/action-toolbar.d.ts.map +1 -1
  19. package/dist/cjs/tiptap/components/menu-bar/action-toolbar.js +10 -2
  20. package/dist/cjs/tiptap/components/menu-bar/index.d.ts +2 -1
  21. package/dist/cjs/tiptap/components/menu-bar/index.d.ts.map +1 -1
  22. package/dist/cjs/tiptap/components/menu-bar/index.js +2 -2
  23. package/dist/cjs/tiptap/components/tiptap.d.ts.map +1 -1
  24. package/dist/cjs/tiptap/components/tiptap.js +23 -9
  25. package/dist/cjs/tiptap/constants/editor-config.d.ts +1 -0
  26. package/dist/cjs/tiptap/constants/editor-config.d.ts.map +1 -1
  27. package/dist/cjs/tiptap/constants/editor-config.js +1 -0
  28. package/dist/cjs/tiptap/utils/__tests__/clean-html.test.js +26 -7
  29. package/dist/cjs/tiptap/utils/__tests__/clean-html.test.ts +29 -7
  30. package/dist/cjs/tiptap/utils/prettier-format.js +1 -1
  31. package/dist/esm/i18n/translations/en.d.ts +1 -0
  32. package/dist/esm/i18n/translations/en.d.ts.map +1 -1
  33. package/dist/esm/i18n/translations/en.js +1 -0
  34. package/dist/esm/i18n/translations/tr.d.ts +1 -0
  35. package/dist/esm/i18n/translations/tr.d.ts.map +1 -1
  36. package/dist/esm/i18n/translations/tr.js +1 -0
  37. package/dist/esm/tiptap/components/__tests__/html-code-viewer.test.js +0 -8
  38. package/dist/esm/tiptap/components/__tests__/html-code-viewer.test.tsx +0 -11
  39. package/dist/esm/tiptap/components/__tests__/tiptap.test.js +53 -12
  40. package/dist/esm/tiptap/components/__tests__/tiptap.test.tsx +79 -13
  41. package/dist/esm/tiptap/components/html-code-viewer.d.ts.map +1 -1
  42. package/dist/esm/tiptap/components/html-code-viewer.js +1 -24
  43. package/dist/esm/tiptap/components/menu-bar/__tests__/action-toolbar.test.js +7 -7
  44. package/dist/esm/tiptap/components/menu-bar/__tests__/action-toolbar.test.tsx +7 -0
  45. package/dist/esm/tiptap/components/menu-bar/__tests__/index.test.js +1 -0
  46. package/dist/esm/tiptap/components/menu-bar/__tests__/index.test.tsx +1 -0
  47. package/dist/esm/tiptap/components/menu-bar/action-toolbar.d.ts +2 -1
  48. package/dist/esm/tiptap/components/menu-bar/action-toolbar.d.ts.map +1 -1
  49. package/dist/esm/tiptap/components/menu-bar/action-toolbar.js +10 -2
  50. package/dist/esm/tiptap/components/menu-bar/index.d.ts +2 -1
  51. package/dist/esm/tiptap/components/menu-bar/index.d.ts.map +1 -1
  52. package/dist/esm/tiptap/components/menu-bar/index.js +2 -2
  53. package/dist/esm/tiptap/components/tiptap.d.ts.map +1 -1
  54. package/dist/esm/tiptap/components/tiptap.js +23 -9
  55. package/dist/esm/tiptap/constants/editor-config.d.ts +1 -0
  56. package/dist/esm/tiptap/constants/editor-config.d.ts.map +1 -1
  57. package/dist/esm/tiptap/constants/editor-config.js +1 -0
  58. package/dist/esm/tiptap/utils/__tests__/clean-html.test.js +26 -7
  59. package/dist/esm/tiptap/utils/__tests__/clean-html.test.ts +29 -7
  60. package/dist/esm/tiptap/utils/prettier-format.js +1 -1
  61. package/package.json +12 -12
@@ -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
- onUpdate: ({ editor }) => {
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
- extensions,
58
- editorProps
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 },
@@ -66,6 +66,7 @@ export declare const TOOLBAR_BUTTON_IDS: {
66
66
  readonly HEADING: "heading";
67
67
  readonly UNDO: "undo";
68
68
  readonly REDO: "redo";
69
+ readonly FORMAT: "format";
69
70
  readonly SOURCE_MODE: "sourceMode";
70
71
  readonly YOUTUBE: "youtube";
71
72
  };
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BrB,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"}
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"}
@@ -73,6 +73,7 @@ export const TOOLBAR_BUTTON_IDS = {
73
73
  HEADING: 'heading',
74
74
  UNDO: 'undo',
75
75
  REDO: 'redo',
76
+ FORMAT: 'format',
76
77
  SOURCE_MODE: 'sourceMode',
77
78
  YOUTUBE: 'youtube'
78
79
  };
@@ -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 empty text content fallback', () => {
88
- // Edge case: elements with only whitespace text nodes
89
- const html = '<div> </div>';
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('<div> </div>');
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 empty text content fallback', () => {
124
- // Edge case: elements with only whitespace text nodes
125
- const html = '<div> </div>';
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('<div> </div>');
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
  });
@@ -14,7 +14,7 @@ export const formatHtmlWithPrettier = async (html) => {
14
14
  printWidth: 60,
15
15
  tabWidth: 2,
16
16
  useTabs: false,
17
- htmlWhitespaceSensitivity: 'css',
17
+ htmlWhitespaceSensitivity: 'ignore',
18
18
  singleAttributePerLine: false,
19
19
  proseWrap: 'always'
20
20
  });
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmFR,CAAC;AAEX,eAAe,YAAY,CAAC"}
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 @@ const translations = {
33
33
  redo: 'Redo',
34
34
  source_mode: 'Switch to Source Mode',
35
35
  normal_mode: 'Switch to Preview Mode',
36
+ format: 'Format HTML',
36
37
  link: 'Link',
37
38
  align_left: 'Align Left',
38
39
  align_center: 'Align Center',
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmFR,CAAC;AAEX,eAAe,YAAY,CAAC"}
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"}
@@ -33,6 +33,7 @@ const translations = {
33
33
  redo: 'Yinele',
34
34
  source_mode: 'Kaynak Koduna Geç',
35
35
  normal_mode: 'Önizleme Moduna Geç',
36
+ format: 'HTML Formatla',
36
37
  link: 'Bağlantı',
37
38
  align_left: 'Sola Hizala',
38
39
  align_center: 'Ortala',
@@ -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 onUpdateCallback = mockUseEditor.mock.calls[0][0].onUpdate;
155
- onUpdateCallback({ editor: mockEditor });
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 onUpdateCallback = mockUseEditor.mock.calls[0][0].onUpdate;
164
- expect(() => onUpdateCallback({ editor: mockEditor })).not.toThrow();
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 } = vi.hoisted(() => ({
12
- mockUseEditor: vi.fn(),
13
- mockChangeLanguage: vi.fn()
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 onUpdateCallback = mockUseEditor.mock.calls[0][0].onUpdate;
217
- onUpdateCallback({ editor: mockEditor });
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 onUpdateCallback = mockUseEditor.mock.calls[0][0].onUpdate;
231
- expect(() => onUpdateCallback({ editor: mockEditor })).not.toThrow();
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,KAAkD,MAAM,OAAO,CAAC;AAKvE,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,sBA2KrB,CAAC"}
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, useEffect, useMemo, useRef } from 'react';
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;