@capillarytech/creatives-library 8.0.207 → 8.0.209

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 (77) hide show
  1. package/assets/Android.png +0 -0
  2. package/assets/iOS.png +0 -0
  3. package/package.json +16 -2
  4. package/v2Components/HtmlEditor/HTMLEditor.js +508 -0
  5. package/v2Components/HtmlEditor/__tests__/HTMLEditor.test.js +1809 -0
  6. package/v2Components/HtmlEditor/__tests__/index.lazy.test.js +532 -0
  7. package/v2Components/HtmlEditor/_htmlEditor.scss +304 -0
  8. package/v2Components/HtmlEditor/_index.lazy.scss +26 -0
  9. package/v2Components/HtmlEditor/components/CodeEditorPane/_codeEditorPane.scss +376 -0
  10. package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +331 -0
  11. package/v2Components/HtmlEditor/components/DeviceToggle/__tests__/index.test.js +314 -0
  12. package/v2Components/HtmlEditor/components/DeviceToggle/_deviceToggle.scss +244 -0
  13. package/v2Components/HtmlEditor/components/DeviceToggle/index.js +111 -0
  14. package/v2Components/HtmlEditor/components/EditorToolbar/PreviewModeGroup.js +72 -0
  15. package/v2Components/HtmlEditor/components/EditorToolbar/__tests__/PreviewModeGroup.test.js +1594 -0
  16. package/v2Components/HtmlEditor/components/EditorToolbar/_editorToolbar.scss +113 -0
  17. package/v2Components/HtmlEditor/components/EditorToolbar/_previewModeGroup.scss +82 -0
  18. package/v2Components/HtmlEditor/components/EditorToolbar/index.js +115 -0
  19. package/v2Components/HtmlEditor/components/FullscreenModal/_fullscreenModal.scss +57 -0
  20. package/v2Components/HtmlEditor/components/InAppPreviewPane/ContentOverlay.js +90 -0
  21. package/v2Components/HtmlEditor/components/InAppPreviewPane/DeviceFrame.js +60 -0
  22. package/v2Components/HtmlEditor/components/InAppPreviewPane/LayoutSelector.js +58 -0
  23. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/ContentOverlay.test.js +389 -0
  24. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/DeviceFrame.test.js +424 -0
  25. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/LayoutSelector.test.js +248 -0
  26. package/v2Components/HtmlEditor/components/InAppPreviewPane/_inAppPreviewPane.scss +253 -0
  27. package/v2Components/HtmlEditor/components/InAppPreviewPane/constants.js +104 -0
  28. package/v2Components/HtmlEditor/components/InAppPreviewPane/index.js +179 -0
  29. package/v2Components/HtmlEditor/components/PreviewPane/_previewPane.scss +220 -0
  30. package/v2Components/HtmlEditor/components/PreviewPane/index.js +229 -0
  31. package/v2Components/HtmlEditor/components/SplitContainer/SplitContainer.js +276 -0
  32. package/v2Components/HtmlEditor/components/SplitContainer/__tests__/SplitContainer.test.js +295 -0
  33. package/v2Components/HtmlEditor/components/SplitContainer/_splitContainer.scss +257 -0
  34. package/v2Components/HtmlEditor/components/SplitContainer/index.js +7 -0
  35. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/__tests__/index.test.js +152 -0
  36. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/_validationErrorDisplay.scss +31 -0
  37. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/index.js +70 -0
  38. package/v2Components/HtmlEditor/components/ValidationPanel/__tests__/index.test.js +98 -0
  39. package/v2Components/HtmlEditor/components/ValidationPanel/_validationPanel.scss +311 -0
  40. package/v2Components/HtmlEditor/components/ValidationPanel/index.js +297 -0
  41. package/v2Components/HtmlEditor/components/ValidationPanel/messages.js +57 -0
  42. package/v2Components/HtmlEditor/components/common/EditorContext.js +84 -0
  43. package/v2Components/HtmlEditor/components/common/__tests__/EditorContext.test.js +660 -0
  44. package/v2Components/HtmlEditor/constants.js +241 -0
  45. package/v2Components/HtmlEditor/hooks/__tests__/useEditorContent.test.js +450 -0
  46. package/v2Components/HtmlEditor/hooks/__tests__/useInAppContent.test.js +785 -0
  47. package/v2Components/HtmlEditor/hooks/__tests__/useLayoutState.test.js +580 -0
  48. package/v2Components/HtmlEditor/hooks/__tests__/useValidation.enhanced.test.js +768 -0
  49. package/v2Components/HtmlEditor/hooks/__tests__/useValidation.test.js +590 -0
  50. package/v2Components/HtmlEditor/hooks/useEditorContent.js +274 -0
  51. package/v2Components/HtmlEditor/hooks/useInAppContent.js +407 -0
  52. package/v2Components/HtmlEditor/hooks/useLayoutState.js +247 -0
  53. package/v2Components/HtmlEditor/hooks/useValidation.js +325 -0
  54. package/v2Components/HtmlEditor/index.js +29 -0
  55. package/v2Components/HtmlEditor/index.lazy.js +114 -0
  56. package/v2Components/HtmlEditor/messages.js +389 -0
  57. package/v2Components/HtmlEditor/utils/__tests__/contentSanitizer.test.js +741 -0
  58. package/v2Components/HtmlEditor/utils/__tests__/htmlValidator.enhanced.test.js +1042 -0
  59. package/v2Components/HtmlEditor/utils/__tests__/liquidTemplateSupport.test.js +515 -0
  60. package/v2Components/HtmlEditor/utils/__tests__/properSyntaxHighlighting.test.js +473 -0
  61. package/v2Components/HtmlEditor/utils/__tests__/simplePerformance.test.js +1109 -0
  62. package/v2Components/HtmlEditor/utils/__tests__/validationAdapter.test.js +240 -0
  63. package/v2Components/HtmlEditor/utils/contentSanitizer.js +433 -0
  64. package/v2Components/HtmlEditor/utils/htmlValidator.js +508 -0
  65. package/v2Components/HtmlEditor/utils/liquidTemplateSupport.js +524 -0
  66. package/v2Components/HtmlEditor/utils/properSyntaxHighlighting.js +163 -0
  67. package/v2Components/HtmlEditor/utils/simplePerformance.js +145 -0
  68. package/v2Components/HtmlEditor/utils/validationAdapter.js +130 -0
  69. package/v2Containers/EmailWrapper/components/HTMLEditorTesting.js +200 -0
  70. package/v2Containers/EmailWrapper/components/__tests__/HTMLEditorTesting.test.js +545 -0
  71. package/v2Containers/EmailWrapper/index.js +8 -1
  72. package/v2Containers/Templates/constants.js +8 -0
  73. package/v2Containers/Templates/index.js +56 -28
  74. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +5 -14
  75. package/v2Containers/Whatsapp/constants.js +26 -2
  76. package/v2Containers/Whatsapp/index.js +4 -1
  77. package/v2Containers/Whatsapp/tests/index.test.js +460 -18
@@ -0,0 +1,241 @@
1
+ /**
2
+ * HTML Editor Constants
3
+ *
4
+ * Centralized constants for the HTML Editor component
5
+ */
6
+
7
+ // HTML Editor Variants
8
+ export const HTML_EDITOR_VARIANTS = {
9
+ EMAIL: 'email',
10
+ INAPP: 'inapp'
11
+ };
12
+
13
+ // Device Types (for InApp variant)
14
+ export const DEVICE_TYPES = {
15
+ ANDROID: 'android',
16
+ IOS: 'ios'
17
+ };
18
+
19
+ // Editor languages
20
+ export const EDITOR_LANGUAGES = {
21
+ HTML: 'html',
22
+ CSS: 'css',
23
+ JAVASCRIPT: 'javascript'
24
+ };
25
+
26
+ // Preview modes
27
+ export const PREVIEW_MODES = {
28
+ DESKTOP: 'desktop',
29
+ MOBILE: 'mobile'
30
+ };
31
+
32
+ // Device specifications for mobile preview
33
+ export const DEVICE_PRESETS = {
34
+ ANDROID_PHONE: {
35
+ key: 'android-phone',
36
+ width: 393,
37
+ height: 851,
38
+ devicePixelRatio: 2.75,
39
+ userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
40
+ platform: 'android',
41
+ model: 'Pixel 7'
42
+ },
43
+ IPHONE: {
44
+ key: 'iphone',
45
+ width: 390,
46
+ height: 844,
47
+ devicePixelRatio: 3,
48
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
49
+ platform: 'ios',
50
+ model: 'iPhone 15'
51
+ }
52
+ };
53
+
54
+ // Validation severity levels
55
+ export const VALIDATION_SEVERITY = {
56
+ ERROR: 'error',
57
+ WARNING: 'warning',
58
+ INFO: 'info'
59
+ };
60
+
61
+ // Validation types
62
+ export const VALIDATION_TYPES = {
63
+ HTML: 'html',
64
+ CSS: 'css',
65
+ JAVASCRIPT: 'javascript',
66
+ SECURITY: 'security'
67
+ };
68
+
69
+ // Error types for preview
70
+ export const PREVIEW_ERROR_TYPES = {
71
+ RUNTIME: 'runtime',
72
+ RENDER: 'render',
73
+ SECURITY: 'security'
74
+ };
75
+
76
+ // Performance thresholds
77
+ export const PERFORMANCE = {
78
+ PREVIEW_UPDATE_DEBOUNCE: 300, // ms
79
+ VALIDATION_DEBOUNCE: 500, // ms
80
+ AUTO_SAVE_INTERVAL: 30000, // ms
81
+ MAX_CONTENT_LENGTH: 100000, // characters
82
+ PERFORMANCE_WARNING_THRESHOLD: 50000 // characters
83
+ };
84
+
85
+ // Layout constraints
86
+ export const LAYOUT = {
87
+ MIN_PANE_SIZE: 20, // percentage
88
+ MAX_PANE_SIZE: 80, // percentage
89
+ DEFAULT_SPLIT_SIZES: [50, 50], // [left, right] percentages
90
+ GUTTER_SIZE: 8, // pixels
91
+ MOBILE_BREAKPOINT: 768, // pixels
92
+ MOBILE_WIDTH_DEFAULT: 375 // pixels
93
+ };
94
+
95
+
96
+ // CodeMirror configuration
97
+ export const CODEMIRROR_CONFIG = {
98
+ THEME: 'default',
99
+ FONT_SIZE: 14,
100
+ FONT_FAMILY: '"Fira Code", "Monaco", "Menlo", "Consolas", monospace',
101
+ LINE_NUMBERS: true,
102
+ HIGHLIGHT_ACTIVE_LINE: true,
103
+ BRACKET_MATCHING: true,
104
+ AUTO_CLOSE_BRACKETS: true,
105
+ FOLD_GUTTER: true,
106
+ SEARCH_ENABLED: true,
107
+ AUTOCOMPLETE_ENABLED: true,
108
+ LINT_ENABLED: true
109
+ };
110
+
111
+ // HTML validation rules (HTMLHint)
112
+ export const HTML_VALIDATION_RULES = {
113
+ 'tag-pair': true,
114
+ 'tag-self-close': true,
115
+ 'tagname-lowercase': true,
116
+ 'attr-lowercase': true,
117
+ 'attr-value-double-quotes': true,
118
+ 'id-unique': true,
119
+ 'src-not-empty': true,
120
+ 'title-require': false,
121
+ 'alt-require': true,
122
+ 'doctype-first': false, // Optional: support both fragments and full documents
123
+ 'doctype-html5': false, // Optional: support both fragments and full documents
124
+ 'head-script-disabled': false,
125
+ 'style-disabled': false,
126
+ 'inline-style-disabled': false,
127
+ 'inline-script-disabled': false,
128
+ 'space-tab-mixed-disabled': 'space',
129
+ 'spec-char-escape': true
130
+ };
131
+
132
+ // CSS validation rules
133
+ export const CSS_VALIDATION_RULES = {
134
+ // Will be implemented with Stylelint or similar
135
+ 'property-case': 'lower',
136
+ 'value-case': 'lower',
137
+ 'declaration-colon-space-after': 'always',
138
+ 'declaration-colon-space-before': 'never',
139
+ 'block-closing-brace-newline-after': 'always',
140
+ 'block-opening-brace-space-before': 'always'
141
+ };
142
+
143
+ // JavaScript validation rules (ESLint-style)
144
+ export const JS_VALIDATION_RULES = {
145
+ // Basic rules for HTML editor context
146
+ 'no-undef': 'error',
147
+ 'no-unused-vars': 'warn',
148
+ 'no-console': 'warn',
149
+ 'semi': ['error', 'always'],
150
+ 'quotes': ['error', 'single'],
151
+ 'indent': ['error', 2]
152
+ };
153
+
154
+ // Keyboard shortcuts
155
+ export const KEYBOARD_SHORTCUTS = {
156
+ SAVE: 'Ctrl+S',
157
+ SAVE_MAC: 'Cmd+S',
158
+ FULLSCREEN: 'F11',
159
+ TOGGLE_PREVIEW: 'Ctrl+P',
160
+ TOGGLE_PREVIEW_MAC: 'Cmd+P',
161
+ FORMAT_CODE: 'Shift+Alt+F',
162
+ FORMAT_CODE_MAC: 'Shift+Option+F',
163
+ FIND: 'Ctrl+F',
164
+ FIND_MAC: 'Cmd+F',
165
+ REPLACE: 'Ctrl+H',
166
+ REPLACE_MAC: 'Cmd+Option+F'
167
+ };
168
+
169
+ // Feature flags
170
+ export const FEATURES = {
171
+ AUTO_SAVE: true,
172
+ VALIDATION: true,
173
+ MOBILE_PREVIEW: true,
174
+ DEVICE_PREVIEW: true,
175
+ FULLSCREEN_MODE: true,
176
+ KEYBOARD_SHORTCUTS: true,
177
+ ERROR_RECOVERY: true,
178
+ PERFORMANCE_MONITORING: true,
179
+ ACCESSIBILITY_FEATURES: true
180
+ };
181
+
182
+ // Animation durations (ms)
183
+ export const ANIMATIONS = {
184
+ FAST: 150,
185
+ NORMAL: 300,
186
+ SLOW: 500,
187
+ PANEL_RESIZE: 200,
188
+ FADE_IN: 300,
189
+ SLIDE_IN: 250
190
+ };
191
+
192
+ // Z-index layers
193
+ export const Z_INDEX = {
194
+ BASE: 1,
195
+ OVERLAY: 1000,
196
+ MODAL: 1050,
197
+ FULLSCREEN: 9999,
198
+ TOOLTIP: 10000
199
+ };
200
+
201
+ // Default HTML content template
202
+ export const DEFAULT_HTML_CONTENT = `<!DOCTYPE html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="UTF-8">
206
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
207
+ <title>HTML Editor Preview</title>
208
+ <style>
209
+ /* Your CSS styles here */
210
+ body {
211
+ font-family: Arial, sans-serif;
212
+ margin: 0;
213
+ padding: 20px;
214
+ background-color: #f5f5f5;
215
+ }
216
+
217
+ .container {
218
+ max-width: 800px;
219
+ margin: 0 auto;
220
+ background: white;
221
+ padding: 20px;
222
+ border-radius: 8px;
223
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div class="container">
229
+ <h1>Welcome to HTML Editor</h1>
230
+ <p>Start editing your HTML, CSS, and JavaScript code to see the live preview here.</p>
231
+ <button id="demoButton">Click me!</button>
232
+ </div>
233
+
234
+ <script>
235
+ // Your JavaScript code here
236
+ document.getElementById('demoButton').addEventListener('click', function() {
237
+ alert('Hello from the HTML Editor!');
238
+ });
239
+ </script>
240
+ </body>
241
+ </html>`;
@@ -0,0 +1,450 @@
1
+ /**
2
+ * useEditorContent Hook Tests
3
+ *
4
+ * Tests for the useEditorContent custom hook that manages unified editor content state
5
+ */
6
+
7
+ import React from 'react';
8
+ import { render, screen, act } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import useEditorContent from '../useEditorContent';
11
+
12
+ // Test wrapper component
13
+ const TestComponent = ({ initialContent, options, onStateChange }) => {
14
+ const editorState = useEditorContent(initialContent, options);
15
+
16
+ React.useEffect(() => {
17
+ if (onStateChange) {
18
+ onStateChange(editorState);
19
+ }
20
+ });
21
+
22
+ return (
23
+ <div>
24
+ <div data-testid="content">{editorState.content}</div>
25
+ <div data-testid="is-dirty">{String(editorState.isDirty)}</div>
26
+ <div data-testid="is-empty">{String(editorState.isEmpty)}</div>
27
+ <div data-testid="character-count">{editorState.characterCount}</div>
28
+ <div data-testid="can-save">{String(editorState.canSave)}</div>
29
+ <div data-testid="is-large">{String(editorState.isLargeContent)}</div>
30
+ <div data-testid="auto-save-enabled">{String(editorState.isAutoSaveEnabled)}</div>
31
+ <button onClick={() => editorState.updateContent('Updated content')} data-testid="update">
32
+ Update
33
+ </button>
34
+ <button onClick={() => editorState.resetContent()} data-testid="reset">
35
+ Reset
36
+ </button>
37
+ <button onClick={() => editorState.saveContent()} data-testid="save">
38
+ Save
39
+ </button>
40
+ </div>
41
+ );
42
+ };
43
+
44
+ describe('useEditorContent', () => {
45
+ beforeEach(() => {
46
+ jest.useFakeTimers();
47
+ });
48
+
49
+ afterEach(() => {
50
+ jest.runOnlyPendingTimers();
51
+ jest.useRealTimers();
52
+ });
53
+
54
+ describe('Initial State', () => {
55
+ it('initializes with default content when no initial content provided', () => {
56
+ render(<TestComponent />);
57
+
58
+ expect(screen.getByTestId('content')).toHaveTextContent('<!DOCTYPE html>');
59
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
60
+ expect(screen.getByTestId('is-empty')).toHaveTextContent('false');
61
+ });
62
+
63
+ it('initializes with provided initial content', () => {
64
+ render(<TestComponent initialContent="<h1>Test</h1>" />);
65
+
66
+ expect(screen.getByTestId('content')).toHaveTextContent('<h1>Test</h1>');
67
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
68
+ });
69
+
70
+ it('initializes with auto-save enabled by default', () => {
71
+ render(<TestComponent />);
72
+
73
+ expect(screen.getByTestId('auto-save-enabled')).toHaveTextContent('true');
74
+ });
75
+
76
+ it('respects auto-save option', () => {
77
+ render(<TestComponent options={{ autoSave: false }} />);
78
+
79
+ expect(screen.getByTestId('auto-save-enabled')).toHaveTextContent('false');
80
+ });
81
+ });
82
+
83
+ describe('Content Updates', () => {
84
+ it('updates content when updateContent is called', () => {
85
+ let editorState;
86
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
87
+
88
+ act(() => {
89
+ editorState.updateContent('<p>New content</p>');
90
+ });
91
+
92
+ expect(screen.getByTestId('content')).toHaveTextContent('<p>New content</p>');
93
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
94
+ });
95
+
96
+ it('marks content as dirty after update', () => {
97
+ let editorState;
98
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
99
+
100
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
101
+
102
+ act(() => {
103
+ editorState.updateContent('<p>Modified</p>');
104
+ });
105
+
106
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
107
+ });
108
+
109
+ it('does not update if content is the same', () => {
110
+ const onChange = jest.fn();
111
+ let editorState;
112
+ render(<TestComponent
113
+ initialContent="<p>Same</p>"
114
+ options={{ onChange }}
115
+ onStateChange={(state) => { editorState = state; }}
116
+ />);
117
+
118
+ act(() => {
119
+ editorState.updateContent('<p>Same</p>');
120
+ });
121
+
122
+ // onChange should not be called if content hasn't changed
123
+ expect(onChange).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('calls onChange callback when content updates', () => {
127
+ const onChange = jest.fn();
128
+ let editorState;
129
+ render(<TestComponent
130
+ options={{ onChange }}
131
+ onStateChange={(state) => { editorState = state; }}
132
+ />);
133
+
134
+ act(() => {
135
+ editorState.updateContent('<p>New</p>');
136
+ });
137
+
138
+ act(() => {
139
+ jest.advanceTimersByTime(300);
140
+ });
141
+
142
+ expect(onChange).toHaveBeenCalled();
143
+ });
144
+ });
145
+
146
+ describe('Content State', () => {
147
+ it('reports isEmpty correctly for empty content', () => {
148
+ let editorState;
149
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
150
+
151
+ // Update to empty content
152
+ act(() => {
153
+ editorState.updateContent('');
154
+ });
155
+
156
+ expect(screen.getByTestId('is-empty')).toHaveTextContent('true');
157
+ });
158
+
159
+ it('reports isEmpty correctly for non-empty content', () => {
160
+ render(<TestComponent initialContent="<p>Content</p>" />);
161
+
162
+ expect(screen.getByTestId('is-empty')).toHaveTextContent('false');
163
+ });
164
+
165
+ it('calculates character count correctly', () => {
166
+ const content = '<p>Test</p>';
167
+ render(<TestComponent initialContent={content} />);
168
+
169
+ expect(screen.getByTestId('character-count')).toHaveTextContent(String(content.length));
170
+ });
171
+
172
+ it('detects large content', async () => {
173
+ // Create content larger than threshold (make it 100KB to be safe)
174
+ const largeContent = 'a'.repeat(100000);
175
+ let editorState;
176
+ const { rerender } = render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
177
+
178
+ await act(async () => {
179
+ editorState.updateContent(largeContent);
180
+ // Wait for state update
181
+ await Promise.resolve();
182
+ });
183
+
184
+ // Force TestComponent to re-render and call onStateChange with updated state
185
+ rerender(<TestComponent onStateChange={(state) => { editorState = state; }} />);
186
+
187
+ // Now check the state
188
+ expect(editorState.isLargeContent).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('Reset Functionality', () => {
193
+ it('resets to initial content', () => {
194
+ const initialContent = '<h1>Initial</h1>';
195
+ let editorState;
196
+ render(<TestComponent
197
+ initialContent={initialContent}
198
+ onStateChange={(state) => { editorState = state; }}
199
+ />);
200
+
201
+ act(() => {
202
+ editorState.updateContent('<p>Modified</p>');
203
+ });
204
+
205
+ expect(screen.getByTestId('content')).toHaveTextContent('<p>Modified</p>');
206
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
207
+
208
+ act(() => {
209
+ editorState.resetContent();
210
+ });
211
+
212
+ expect(screen.getByTestId('content')).toHaveTextContent(initialContent);
213
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
214
+ });
215
+
216
+ it('resets to default content if no initial content', () => {
217
+ let editorState;
218
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
219
+
220
+ act(() => {
221
+ editorState.updateContent('<p>Modified</p>');
222
+ });
223
+
224
+ act(() => {
225
+ editorState.resetContent();
226
+ });
227
+
228
+ expect(screen.getByTestId('content')).toHaveTextContent('<!DOCTYPE html>');
229
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
230
+ });
231
+ });
232
+
233
+ describe('Save Functionality', () => {
234
+ it('reports canSave as false when no onSave provided', () => {
235
+ let editorState;
236
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
237
+
238
+ act(() => {
239
+ editorState.updateContent('<p>Modified</p>');
240
+ });
241
+
242
+ expect(screen.getByTestId('can-save')).toHaveTextContent('false');
243
+ });
244
+
245
+ it('reports canSave as true when onSave provided and content is dirty', () => {
246
+ const onSave = jest.fn();
247
+ let editorState;
248
+ render(<TestComponent
249
+ options={{ onSave }}
250
+ onStateChange={(state) => { editorState = state; }}
251
+ />);
252
+
253
+ act(() => {
254
+ editorState.updateContent('<p>Modified</p>');
255
+ });
256
+
257
+ expect(screen.getByTestId('can-save')).toHaveTextContent('true');
258
+ });
259
+
260
+ it('calls onSave callback when saveContent is called', async () => {
261
+ const onSave = jest.fn().mockResolvedValue(true);
262
+ let editorState;
263
+ render(<TestComponent
264
+ options={{ onSave }}
265
+ onStateChange={(state) => { editorState = state; }}
266
+ />);
267
+
268
+ act(() => {
269
+ editorState.updateContent('<p>Modified</p>');
270
+ });
271
+
272
+ await act(async () => {
273
+ await editorState.saveContent();
274
+ });
275
+
276
+ expect(onSave).toHaveBeenCalledWith('<p>Modified</p>');
277
+ });
278
+
279
+ it('marks content as saved after successful save', async () => {
280
+ const onSave = jest.fn().mockResolvedValue(true);
281
+ let editorState;
282
+ render(<TestComponent
283
+ options={{ onSave }}
284
+ onStateChange={(state) => { editorState = state; }}
285
+ />);
286
+
287
+ act(() => {
288
+ editorState.updateContent('<p>Modified</p>');
289
+ });
290
+
291
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
292
+
293
+ await act(async () => {
294
+ await editorState.saveContent();
295
+ });
296
+
297
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
298
+ });
299
+
300
+ it('does not save if content is not dirty', async () => {
301
+ const onSave = jest.fn().mockResolvedValue(true);
302
+ let editorState;
303
+ render(<TestComponent
304
+ options={{ onSave }}
305
+ onStateChange={(state) => { editorState = state; }}
306
+ />);
307
+
308
+ const result = await act(async () => {
309
+ return await editorState.saveContent();
310
+ });
311
+
312
+ expect(result).toBe(false);
313
+ expect(onSave).not.toHaveBeenCalled();
314
+ });
315
+ });
316
+
317
+ describe('Auto-save', () => {
318
+ it('toggles auto-save state', () => {
319
+ let editorState;
320
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
321
+
322
+ expect(screen.getByTestId('auto-save-enabled')).toHaveTextContent('true');
323
+
324
+ act(() => {
325
+ editorState.toggleAutoSave();
326
+ });
327
+
328
+ expect(screen.getByTestId('auto-save-enabled')).toHaveTextContent('false');
329
+
330
+ act(() => {
331
+ editorState.toggleAutoSave();
332
+ });
333
+
334
+ expect(screen.getByTestId('auto-save-enabled')).toHaveTextContent('true');
335
+ });
336
+
337
+ it('auto-saves after interval when enabled and dirty', async () => {
338
+ const onSave = jest.fn().mockResolvedValue(true);
339
+ let editorState;
340
+ render(<TestComponent
341
+ options={{ onSave, autoSaveInterval: 5000 }}
342
+ onStateChange={(state) => { editorState = state; }}
343
+ />);
344
+
345
+ act(() => {
346
+ editorState.updateContent('<p>Modified</p>');
347
+ });
348
+
349
+ expect(onSave).not.toHaveBeenCalled();
350
+
351
+ await act(async () => {
352
+ jest.advanceTimersByTime(5000);
353
+ await Promise.resolve();
354
+ });
355
+
356
+ expect(onSave).toHaveBeenCalled();
357
+ });
358
+
359
+ it('does not auto-save when auto-save is disabled', async () => {
360
+ const onSave = jest.fn().mockResolvedValue(true);
361
+ let editorState;
362
+ render(<TestComponent
363
+ options={{ onSave, autoSave: false, autoSaveInterval: 5000 }}
364
+ onStateChange={(state) => { editorState = state; }}
365
+ />);
366
+
367
+ act(() => {
368
+ editorState.updateContent('<p>Modified</p>');
369
+ });
370
+
371
+ await act(async () => {
372
+ jest.advanceTimersByTime(5000);
373
+ await Promise.resolve();
374
+ });
375
+
376
+ expect(onSave).not.toHaveBeenCalled();
377
+ });
378
+ });
379
+
380
+ describe('Validation Integration', () => {
381
+ it('registers and calls validation callback', () => {
382
+ const validationCallback = jest.fn();
383
+ let editorState;
384
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
385
+
386
+ act(() => {
387
+ editorState.registerValidationCallback(validationCallback);
388
+ });
389
+
390
+ act(() => {
391
+ editorState.validateContent();
392
+ });
393
+
394
+ expect(validationCallback).toHaveBeenCalled();
395
+ });
396
+
397
+ it('unregisters validation callback', () => {
398
+ const validationCallback = jest.fn();
399
+ let editorState;
400
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
401
+
402
+ act(() => {
403
+ editorState.registerValidationCallback(validationCallback);
404
+ editorState.unregisterValidationCallback();
405
+ editorState.validateContent();
406
+ });
407
+
408
+ expect(validationCallback).not.toHaveBeenCalled();
409
+ });
410
+
411
+ it('calls validation callback on content update with debounce', () => {
412
+ const validationCallback = jest.fn();
413
+ let editorState;
414
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
415
+
416
+ act(() => {
417
+ editorState.registerValidationCallback(validationCallback);
418
+ editorState.updateContent('<p>New</p>');
419
+ });
420
+
421
+ expect(validationCallback).not.toHaveBeenCalled();
422
+
423
+ act(() => {
424
+ jest.advanceTimersByTime(500);
425
+ });
426
+
427
+ expect(validationCallback).toHaveBeenCalled();
428
+ });
429
+ });
430
+
431
+ describe('Computed Properties', () => {
432
+ it('provides content analysis', () => {
433
+ let editorState;
434
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
435
+
436
+ expect(editorState.contentAnalysis).toBeDefined();
437
+ expect(editorState.contentAnalysis).toHaveProperty('size');
438
+ expect(editorState.contentAnalysis).toHaveProperty('isLarge');
439
+ });
440
+
441
+ it('provides performance warning flag', () => {
442
+ let editorState;
443
+ render(<TestComponent onStateChange={(state) => { editorState = state; }} />);
444
+
445
+ expect(editorState.shouldShowPerformanceWarning).toBeDefined();
446
+ expect(typeof editorState.shouldShowPerformanceWarning).toBe('boolean');
447
+ });
448
+ });
449
+ });
450
+