@bendyline/squisq-editor-react 1.1.1 → 1.2.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.
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // markdownToTiptap
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('markdownToTiptap', () => {
9
+ it('returns empty paragraph for empty input', () => {
10
+ expect(markdownToTiptap('')).toBe('<p></p>');
11
+ expect(markdownToTiptap(' ')).toBe('<p></p>');
12
+ });
13
+
14
+ it('converts a plain paragraph', () => {
15
+ const html = markdownToTiptap('Hello world');
16
+ expect(html).toContain('<p>');
17
+ expect(html).toContain('Hello world');
18
+ });
19
+
20
+ it('converts headings h1-h3', () => {
21
+ expect(markdownToTiptap('# Title')).toContain('<h1');
22
+ expect(markdownToTiptap('## Subtitle')).toContain('<h2');
23
+ expect(markdownToTiptap('### Section')).toContain('<h3');
24
+ });
25
+
26
+ it('converts bold text', () => {
27
+ const html = markdownToTiptap('This is **bold** text');
28
+ expect(html).toContain('<strong>bold</strong>');
29
+ });
30
+
31
+ it('converts italic text', () => {
32
+ const html = markdownToTiptap('This is *italic* text');
33
+ expect(html).toContain('<em>italic</em>');
34
+ });
35
+
36
+ it('converts strikethrough text', () => {
37
+ const html = markdownToTiptap('This is ~~deleted~~ text');
38
+ expect(html).toContain('<s>deleted</s>');
39
+ });
40
+
41
+ it('converts inline code', () => {
42
+ const html = markdownToTiptap('Use `console.log` here');
43
+ expect(html).toContain('<code>console.log</code>');
44
+ });
45
+
46
+ it('converts links', () => {
47
+ const html = markdownToTiptap('Visit [Example](https://example.com)');
48
+ expect(html).toContain('href="https://example.com"');
49
+ expect(html).toContain('Example');
50
+ });
51
+
52
+ it('converts images', () => {
53
+ const html = markdownToTiptap('![Logo](logo.png)');
54
+ expect(html).toContain('alt="Logo"');
55
+ expect(html).toContain('src="logo.png"');
56
+ });
57
+
58
+ it('converts fenced code blocks', () => {
59
+ const md = '```javascript\nconst x = 1;\n```';
60
+ const html = markdownToTiptap(md);
61
+ expect(html).toContain('<pre>');
62
+ expect(html).toContain('<code');
63
+ expect(html).toContain('language-javascript');
64
+ expect(html).toContain('const x = 1;');
65
+ });
66
+
67
+ it('converts unordered lists', () => {
68
+ const md = '- Item one\n- Item two\n- Item three';
69
+ const html = markdownToTiptap(md);
70
+ expect(html).toContain('<ul>');
71
+ expect(html).toContain('<li>');
72
+ expect(html).toContain('Item one');
73
+ expect(html).toContain('Item two');
74
+ });
75
+
76
+ it('converts ordered lists', () => {
77
+ const md = '1. First\n2. Second\n3. Third';
78
+ const html = markdownToTiptap(md);
79
+ expect(html).toContain('<ol>');
80
+ expect(html).toContain('<li>');
81
+ expect(html).toContain('First');
82
+ });
83
+
84
+ it('converts blockquotes', () => {
85
+ const md = '> This is a quote';
86
+ const html = markdownToTiptap(md);
87
+ expect(html).toContain('<blockquote>');
88
+ expect(html).toContain('This is a quote');
89
+ });
90
+
91
+ it('converts horizontal rules', () => {
92
+ const md = 'Before\n\n---\n\nAfter';
93
+ const html = markdownToTiptap(md);
94
+ expect(html).toContain('<hr');
95
+ });
96
+
97
+ it('converts markdown tables', () => {
98
+ const md = '| A | B |\n| --- | --- |\n| 1 | 2 |';
99
+ const html = markdownToTiptap(md);
100
+ expect(html).toContain('<table>');
101
+ expect(html).toContain('<thead>');
102
+ expect(html).toContain('<th>');
103
+ expect(html).toContain('<td>');
104
+ });
105
+
106
+ it('handles table column alignment', () => {
107
+ const md = '| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |';
108
+ const html = markdownToTiptap(md);
109
+ expect(html).toContain('text-align: left');
110
+ expect(html).toContain('text-align: center');
111
+ expect(html).toContain('text-align: right');
112
+ });
113
+
114
+ it('escapes HTML special characters in text', () => {
115
+ const html = markdownToTiptap('Use <div> & "quotes"');
116
+ expect(html).toContain('&lt;div&gt;');
117
+ expect(html).toContain('&amp;');
118
+ });
119
+
120
+ it('normalizes \\r\\n line endings', () => {
121
+ const html = markdownToTiptap('Line one\r\nLine two');
122
+ // Should not contain raw \r
123
+ expect(html).not.toContain('\r');
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // tiptapToMarkdown
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe('tiptapToMarkdown', () => {
132
+ it('returns empty string for empty paragraph', () => {
133
+ expect(tiptapToMarkdown('')).toBe('');
134
+ expect(tiptapToMarkdown('<p></p>')).toBe('');
135
+ });
136
+
137
+ it('converts a paragraph to plain text', () => {
138
+ const md = tiptapToMarkdown('<p>Hello world</p>');
139
+ expect(md).toContain('Hello world');
140
+ });
141
+
142
+ it('converts headings', () => {
143
+ expect(tiptapToMarkdown('<h1>Title</h1>')).toContain('# Title');
144
+ expect(tiptapToMarkdown('<h2>Sub</h2>')).toContain('## Sub');
145
+ expect(tiptapToMarkdown('<h3>Section</h3>')).toContain('### Section');
146
+ });
147
+
148
+ it('converts strong tags to bold markdown', () => {
149
+ const md = tiptapToMarkdown('<p>This is <strong>bold</strong> text</p>');
150
+ expect(md).toContain('**bold**');
151
+ });
152
+
153
+ it('converts em tags to italic markdown', () => {
154
+ const md = tiptapToMarkdown('<p>This is <em>italic</em> text</p>');
155
+ expect(md).toContain('*italic*');
156
+ });
157
+
158
+ it('converts s/del tags to strikethrough', () => {
159
+ const md = tiptapToMarkdown('<p>This is <s>deleted</s> text</p>');
160
+ expect(md).toContain('~~deleted~~');
161
+ });
162
+
163
+ it('converts code tags to inline code', () => {
164
+ const md = tiptapToMarkdown('<p>Use <code>foo</code> here</p>');
165
+ expect(md).toContain('`foo`');
166
+ });
167
+
168
+ it('converts links', () => {
169
+ const md = tiptapToMarkdown('<p><a href="https://example.com">Example</a></p>');
170
+ expect(md).toContain('[Example](https://example.com)');
171
+ });
172
+
173
+ it('converts code blocks with language', () => {
174
+ const md = tiptapToMarkdown('<pre><code class="language-js">const x = 1;</code></pre>');
175
+ expect(md).toContain('```js');
176
+ expect(md).toContain('const x = 1;');
177
+ expect(md).toContain('```');
178
+ });
179
+
180
+ it('converts code blocks without language', () => {
181
+ const md = tiptapToMarkdown('<pre><code>plain code</code></pre>');
182
+ expect(md).toContain('```');
183
+ expect(md).toContain('plain code');
184
+ });
185
+
186
+ it('converts blockquotes', () => {
187
+ const md = tiptapToMarkdown('<blockquote><p>A wise quote</p></blockquote>');
188
+ expect(md).toContain('> ');
189
+ expect(md).toContain('A wise quote');
190
+ });
191
+
192
+ it('converts horizontal rules', () => {
193
+ const md = tiptapToMarkdown('<p>Before</p><hr><p>After</p>');
194
+ expect(md).toContain('---');
195
+ });
196
+
197
+ it('converts unordered lists', () => {
198
+ const md = tiptapToMarkdown('<ul><li><p>Alpha</p></li><li><p>Beta</p></li></ul>');
199
+ expect(md).toContain('- Alpha');
200
+ expect(md).toContain('- Beta');
201
+ });
202
+
203
+ it('converts ordered lists', () => {
204
+ const md = tiptapToMarkdown('<ol><li><p>First</p></li><li><p>Second</p></li></ol>');
205
+ expect(md).toContain('1. First');
206
+ expect(md).toContain('2. Second');
207
+ });
208
+
209
+ it('converts tables', () => {
210
+ const html =
211
+ '<table><thead><tr><th>Name</th><th>Age</th></tr></thead>' +
212
+ '<tbody><tr><td>Alice</td><td>30</td></tr></tbody></table>';
213
+ const md = tiptapToMarkdown(html);
214
+ expect(md).toContain('| Name | Age |');
215
+ expect(md).toContain('| --- | --- |');
216
+ expect(md).toContain('| Alice | 30 |');
217
+ });
218
+
219
+ it('preserves template annotations in headings', () => {
220
+ const html = '<h2 data-template="statHighlight" data-template-params="stat=42%">Stats</h2>';
221
+ const md = tiptapToMarkdown(html);
222
+ expect(md).toContain('## Stats');
223
+ expect(md).toContain('{[statHighlight');
224
+ expect(md).toContain('stat=42%');
225
+ });
226
+
227
+ it('unescapes HTML entities', () => {
228
+ const md = tiptapToMarkdown('<pre><code>&lt;div&gt; &amp; &quot;test&quot;</code></pre>');
229
+ expect(md).toContain('<div>');
230
+ expect(md).toContain('&');
231
+ expect(md).toContain('"test"');
232
+ });
233
+ });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Round-trip tests
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
240
+ const roundTrip = (md: string) => tiptapToMarkdown(markdownToTiptap(md));
241
+
242
+ it('preserves plain text', () => {
243
+ expect(roundTrip('Hello world')).toContain('Hello world');
244
+ });
245
+
246
+ it('preserves headings', () => {
247
+ const result = roundTrip('## My Heading');
248
+ expect(result).toContain('## My Heading');
249
+ });
250
+
251
+ it('preserves bold text', () => {
252
+ expect(roundTrip('Some **bold** here')).toContain('**bold**');
253
+ });
254
+
255
+ it('preserves italic text', () => {
256
+ expect(roundTrip('Some *italic* here')).toContain('*italic*');
257
+ });
258
+
259
+ it('preserves inline code', () => {
260
+ expect(roundTrip('Use `code` here')).toContain('`code`');
261
+ });
262
+
263
+ it('preserves code blocks', () => {
264
+ const md = '```js\nconst x = 1;\n```';
265
+ const result = roundTrip(md);
266
+ expect(result).toContain('```js');
267
+ expect(result).toContain('const x = 1;');
268
+ });
269
+
270
+ it('preserves links', () => {
271
+ const result = roundTrip('Click [here](https://example.com)');
272
+ expect(result).toContain('[here](https://example.com)');
273
+ });
274
+
275
+ it('preserves blockquotes', () => {
276
+ expect(roundTrip('> Important note')).toContain('> Important note');
277
+ });
278
+
279
+ it('preserves unordered lists', () => {
280
+ const result = roundTrip('- Alpha\n- Beta');
281
+ expect(result).toContain('- Alpha');
282
+ expect(result).toContain('- Beta');
283
+ });
284
+
285
+ it('preserves ordered lists', () => {
286
+ const result = roundTrip('1. First\n2. Second');
287
+ expect(result).toContain('1. First');
288
+ expect(result).toContain('2. Second');
289
+ });
290
+ });
@@ -31,6 +31,8 @@
31
31
 
32
32
  .squisq-view-tab {
33
33
  padding: 6px 16px;
34
+ min-width: 72px;
35
+ text-align: center;
34
36
  border: none;
35
37
  background: transparent;
36
38
  color: #6b7280;
@@ -57,9 +59,10 @@
57
59
  .squisq-toolbar {
58
60
  display: flex;
59
61
  align-items: center;
60
- flex-wrap: wrap;
62
+ flex-wrap: nowrap;
61
63
  padding: 0 12px 0 0;
62
64
  gap: 2px;
65
+ background: rgba(0, 0, 0, 0.07);
63
66
  }
64
67
 
65
68
  /* ─── View Tabs (inside toolbar) ─────────────────────── */
@@ -67,9 +70,9 @@
67
70
  .squisq-toolbar-view-tabs {
68
71
  display: flex;
69
72
  gap: 0;
70
- margin-right: 12px;
73
+ margin-right: 0;
71
74
  padding: 0 16px 0 12px;
72
- background: rgba(0, 0, 0, 0.07);
75
+ background: #ffffff;
73
76
  border-right: 1px solid rgba(0, 0, 0, 0.12);
74
77
  align-self: stretch;
75
78
  align-items: center;
@@ -79,6 +82,9 @@
79
82
  display: flex;
80
83
  align-items: center;
81
84
  gap: 2px;
85
+ overflow: hidden;
86
+ flex: 1;
87
+ min-width: 0;
82
88
  }
83
89
 
84
90
  .squisq-toolbar-view-tab {
@@ -95,6 +101,15 @@
95
101
  border-color 0.15s;
96
102
  }
97
103
 
104
+ .squisq-toolbar-view-tab::after {
105
+ content: attr(data-label);
106
+ display: block;
107
+ font-weight: 600;
108
+ height: 0;
109
+ overflow: hidden;
110
+ visibility: hidden;
111
+ }
112
+
98
113
  .squisq-toolbar-view-tab:hover {
99
114
  color: #111827;
100
115
  }
@@ -155,6 +170,90 @@
155
170
  color: #1d4ed8;
156
171
  }
157
172
 
173
+ /* ─── Toolbar Overflow Menu ──────────────────────────── */
174
+
175
+ .squisq-toolbar-overflow {
176
+ position: relative;
177
+ flex-shrink: 0;
178
+ margin-left: 2px;
179
+ }
180
+
181
+ .squisq-toolbar-overflow-trigger {
182
+ font-size: 16px;
183
+ letter-spacing: 1px;
184
+ }
185
+
186
+ .squisq-toolbar-overflow-menu {
187
+ position: absolute;
188
+ top: 100%;
189
+ right: 0;
190
+ z-index: 100;
191
+ min-width: 180px;
192
+ padding: 4px 0;
193
+ margin-top: 4px;
194
+ background: #fff;
195
+ border: 1px solid #e5e7eb;
196
+ border-radius: 6px;
197
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
198
+ }
199
+
200
+ .squisq-toolbar-overflow-item {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 8px;
204
+ width: 100%;
205
+ padding: 6px 12px;
206
+ border: none;
207
+ background: transparent;
208
+ color: #374151;
209
+ font-size: 13px;
210
+ cursor: pointer;
211
+ text-align: left;
212
+ white-space: nowrap;
213
+ }
214
+
215
+ .squisq-toolbar-overflow-item:hover {
216
+ background: #f3f4f6;
217
+ }
218
+
219
+ .squisq-toolbar-overflow-item:disabled {
220
+ opacity: 0.4;
221
+ cursor: default;
222
+ }
223
+
224
+ .squisq-toolbar-overflow-item--active {
225
+ color: #2563eb;
226
+ background: #eff6ff;
227
+ }
228
+
229
+ .squisq-toolbar-overflow-icon {
230
+ display: inline-flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ width: 18px;
234
+ font-size: 14px;
235
+ font-weight: 600;
236
+ }
237
+
238
+ .squisq-toolbar-overflow-item--danger {
239
+ color: #dc2626;
240
+ }
241
+
242
+ .squisq-toolbar-overflow-item--danger:hover {
243
+ background: #fef2f2;
244
+ color: #b91c1c;
245
+ }
246
+
247
+ .squisq-toolbar-overflow-template {
248
+ gap: 6px;
249
+ padding: 6px 12px;
250
+ }
251
+
252
+ .squisq-toolbar-overflow-template select {
253
+ flex: 1;
254
+ min-width: 0;
255
+ }
256
+
158
257
  /* ─── Template Picker (toolbar) ──────────────────────── */
159
258
 
160
259
  .squisq-template-picker {
@@ -351,6 +450,81 @@
351
450
  font-weight: 600;
352
451
  }
353
452
 
453
+ /* Tiptap table interaction — resize handles, selection, wrapper */
454
+
455
+ .squisq-wysiwyg-editor th,
456
+ .squisq-wysiwyg-editor td {
457
+ position: relative;
458
+ }
459
+
460
+ .squisq-wysiwyg-editor .tableWrapper {
461
+ overflow-x: auto;
462
+ margin: 1em 0;
463
+ }
464
+
465
+ .squisq-wysiwyg-editor .column-resize-handle {
466
+ position: absolute;
467
+ right: -2px;
468
+ top: 0;
469
+ bottom: 0;
470
+ width: 4px;
471
+ background: #3b82f6;
472
+ pointer-events: none;
473
+ }
474
+
475
+ .squisq-wysiwyg-editor.resize-cursor {
476
+ cursor: col-resize;
477
+ }
478
+
479
+ .squisq-wysiwyg-editor .selectedCell::after {
480
+ content: '';
481
+ position: absolute;
482
+ left: 0;
483
+ right: 0;
484
+ top: 0;
485
+ bottom: 0;
486
+ background: rgba(59, 130, 246, 0.12);
487
+ pointer-events: none;
488
+ z-index: 2;
489
+ }
490
+
491
+ /* ─── Table Controls (toolbar) ───────────────────────── */
492
+
493
+ .squisq-table-controls {
494
+ display: flex;
495
+ align-items: center;
496
+ gap: 2px;
497
+ }
498
+
499
+ .squisq-table-controls-label {
500
+ font-size: 11px;
501
+ color: #6b7280;
502
+ font-weight: 500;
503
+ margin-right: 4px;
504
+ white-space: nowrap;
505
+ }
506
+
507
+ .squisq-table-controls .squisq-toolbar-button {
508
+ display: inline-flex;
509
+ align-items: center;
510
+ justify-content: center;
511
+ padding: 3px 5px;
512
+ }
513
+
514
+ .squisq-table-controls .squisq-toolbar-button svg {
515
+ display: block;
516
+ flex-shrink: 0;
517
+ }
518
+
519
+ .squisq-toolbar-button--danger {
520
+ color: #dc2626;
521
+ }
522
+
523
+ .squisq-toolbar-button--danger:hover {
524
+ background: #fef2f2;
525
+ color: #b91c1c;
526
+ }
527
+
354
528
  .squisq-wysiwyg-editor a {
355
529
  color: #2563eb;
356
530
  text-decoration: underline;
@@ -446,11 +620,11 @@
446
620
 
447
621
  /* Toolbar */
448
622
  .squisq-editor-shell[data-theme='dark'] .squisq-toolbar {
449
- /* no border-top sits directly in header */
623
+ background: rgba(255, 255, 255, 0.08);
450
624
  }
451
625
 
452
626
  .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-view-tabs {
453
- background: rgba(255, 255, 255, 0.08);
627
+ background: #111827;
454
628
  border-right-color: rgba(255, 255, 255, 0.15);
455
629
  }
456
630
 
@@ -495,6 +669,35 @@
495
669
  background: #4b5563;
496
670
  }
497
671
 
672
+ /* Overflow menu (dark) */
673
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-menu {
674
+ background: #1f2937;
675
+ border-color: #374151;
676
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
677
+ }
678
+
679
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item {
680
+ color: #d1d5db;
681
+ }
682
+
683
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item:hover {
684
+ background: #374151;
685
+ }
686
+
687
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--active {
688
+ color: #60a5fa;
689
+ background: #1e3a5f;
690
+ }
691
+
692
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--danger {
693
+ color: #f87171;
694
+ }
695
+
696
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--danger:hover {
697
+ background: #450a0a;
698
+ color: #fca5a5;
699
+ }
700
+
498
701
  /* Template picker (dark) */
499
702
  .squisq-editor-shell[data-theme='dark'] .squisq-template-picker-label {
500
703
  color: #9ca3af;
@@ -575,6 +778,27 @@
575
778
  background: #1f2937;
576
779
  }
577
780
 
781
+ .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor .column-resize-handle {
782
+ background: #60a5fa;
783
+ }
784
+
785
+ .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor .selectedCell::after {
786
+ background: rgba(96, 165, 250, 0.15);
787
+ }
788
+
789
+ .squisq-editor-shell[data-theme='dark'] .squisq-table-controls-label {
790
+ color: #9ca3af;
791
+ }
792
+
793
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-button--danger {
794
+ color: #f87171;
795
+ }
796
+
797
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-button--danger:hover {
798
+ background: #450a0a;
799
+ color: #fca5a5;
800
+ }
801
+
578
802
  .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor a {
579
803
  color: #60a5fa;
580
804
  }
@@ -629,12 +853,6 @@
629
853
  .squisq-toolbar-view-tabs {
630
854
  margin-right: 0;
631
855
  }
632
-
633
- .squisq-toolbar-actions {
634
- flex-basis: 100%;
635
- flex-wrap: wrap;
636
- padding-top: 2px;
637
- }
638
856
  }
639
857
 
640
858
  /* ─── Media Bin ──────────────────────────────────────── */