@bendyline/squisq-editor-react 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,98 @@
155
170
  color: #1d4ed8;
156
171
  }
157
172
 
173
+ /* ─── Toolbar: narrow screen adjustments ───────────────── */
174
+
175
+ @media (max-width: 768px) {
176
+ .squisq-toolbar-view-tabs {
177
+ padding: 0 8px;
178
+ }
179
+ }
180
+
181
+ /* ─── Toolbar Overflow Menu ──────────────────────────── */
182
+
183
+ .squisq-toolbar-overflow {
184
+ position: relative;
185
+ flex-shrink: 0;
186
+ margin-left: 2px;
187
+ }
188
+
189
+ .squisq-toolbar-overflow-trigger {
190
+ font-size: 16px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .squisq-toolbar-overflow-menu {
195
+ position: absolute;
196
+ top: 100%;
197
+ right: 0;
198
+ z-index: 100;
199
+ min-width: 180px;
200
+ padding: 4px 0;
201
+ margin-top: 4px;
202
+ background: #fff;
203
+ border: 1px solid #e5e7eb;
204
+ border-radius: 6px;
205
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
206
+ }
207
+
208
+ .squisq-toolbar-overflow-item {
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 8px;
212
+ width: 100%;
213
+ padding: 6px 12px;
214
+ border: none;
215
+ background: transparent;
216
+ color: #374151;
217
+ font-size: 13px;
218
+ cursor: pointer;
219
+ text-align: left;
220
+ white-space: nowrap;
221
+ }
222
+
223
+ .squisq-toolbar-overflow-item:hover {
224
+ background: #f3f4f6;
225
+ }
226
+
227
+ .squisq-toolbar-overflow-item:disabled {
228
+ opacity: 0.4;
229
+ cursor: default;
230
+ }
231
+
232
+ .squisq-toolbar-overflow-item--active {
233
+ color: #2563eb;
234
+ background: #eff6ff;
235
+ }
236
+
237
+ .squisq-toolbar-overflow-icon {
238
+ display: inline-flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ width: 18px;
242
+ font-size: 14px;
243
+ font-weight: 600;
244
+ }
245
+
246
+ .squisq-toolbar-overflow-item--danger {
247
+ color: #dc2626;
248
+ }
249
+
250
+ .squisq-toolbar-overflow-item--danger:hover {
251
+ background: #fef2f2;
252
+ color: #b91c1c;
253
+ }
254
+
255
+ .squisq-toolbar-overflow-template {
256
+ gap: 6px;
257
+ padding: 6px 12px;
258
+ }
259
+
260
+ .squisq-toolbar-overflow-template select {
261
+ flex: 1;
262
+ min-width: 0;
263
+ }
264
+
158
265
  /* ─── Template Picker (toolbar) ──────────────────────── */
159
266
 
160
267
  .squisq-template-picker {
@@ -351,6 +458,130 @@
351
458
  font-weight: 600;
352
459
  }
353
460
 
461
+ /* Tiptap table interaction — resize handles, selection, wrapper */
462
+
463
+ .squisq-wysiwyg-editor th,
464
+ .squisq-wysiwyg-editor td {
465
+ position: relative;
466
+ }
467
+
468
+ .squisq-wysiwyg-editor .tableWrapper {
469
+ overflow-x: auto;
470
+ margin: 1em 0;
471
+ }
472
+
473
+ .squisq-wysiwyg-editor .column-resize-handle {
474
+ position: absolute;
475
+ right: -2px;
476
+ top: 0;
477
+ bottom: 0;
478
+ width: 4px;
479
+ background: #3b82f6;
480
+ pointer-events: none;
481
+ }
482
+
483
+ .squisq-wysiwyg-editor.resize-cursor {
484
+ cursor: col-resize;
485
+ }
486
+
487
+ .squisq-wysiwyg-editor .selectedCell::after {
488
+ content: '';
489
+ position: absolute;
490
+ left: 0;
491
+ right: 0;
492
+ top: 0;
493
+ bottom: 0;
494
+ background: rgba(59, 130, 246, 0.12);
495
+ pointer-events: none;
496
+ z-index: 2;
497
+ }
498
+
499
+ /* ─── Table Controls (toolbar) ───────────────────────── */
500
+
501
+ .squisq-table-controls {
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 2px;
505
+ }
506
+
507
+ .squisq-table-controls-label {
508
+ font-size: 11px;
509
+ color: #6b7280;
510
+ font-weight: 500;
511
+ margin-right: 4px;
512
+ white-space: nowrap;
513
+ }
514
+
515
+ .squisq-table-controls .squisq-toolbar-button {
516
+ display: inline-flex;
517
+ align-items: center;
518
+ justify-content: center;
519
+ padding: 3px 5px;
520
+ }
521
+
522
+ .squisq-table-controls .squisq-toolbar-button svg {
523
+ display: block;
524
+ flex-shrink: 0;
525
+ }
526
+
527
+ .squisq-toolbar-button--danger {
528
+ color: #dc2626;
529
+ }
530
+
531
+ .squisq-toolbar-button--danger:hover {
532
+ background: #fef2f2;
533
+ color: #b91c1c;
534
+ }
535
+
536
+ /* ─── Preview Controls ──────────────────────────────── */
537
+
538
+ .squisq-preview-controls-inline {
539
+ display: flex;
540
+ align-items: center;
541
+ gap: 6px;
542
+ flex-wrap: wrap;
543
+ padding: 2px 0 2px 9px;
544
+ }
545
+
546
+ .squisq-preview-controls-inline .squisq-preview-control {
547
+ display: flex;
548
+ align-items: center;
549
+ gap: 4px;
550
+ }
551
+
552
+ .squisq-preview-controls-compact {
553
+ position: relative;
554
+ }
555
+
556
+ .squisq-preview-controls-popover {
557
+ position: absolute;
558
+ top: 100%;
559
+ right: 0;
560
+ z-index: 80;
561
+ background: var(--squisq-bg, #fff);
562
+ border: 1px solid var(--squisq-border, #d1d5db);
563
+ border-radius: 6px;
564
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
565
+ padding: 8px 12px;
566
+ margin-top: 4px;
567
+ display: flex;
568
+ flex-direction: column;
569
+ gap: 8px;
570
+ min-width: 180px;
571
+ }
572
+
573
+ .squisq-preview-control--compact {
574
+ display: flex;
575
+ align-items: center;
576
+ justify-content: space-between;
577
+ gap: 8px;
578
+ }
579
+
580
+ .squisq-preview-control--compact select {
581
+ flex: 1;
582
+ min-width: 0;
583
+ }
584
+
354
585
  .squisq-wysiwyg-editor a {
355
586
  color: #2563eb;
356
587
  text-decoration: underline;
@@ -446,11 +677,11 @@
446
677
 
447
678
  /* Toolbar */
448
679
  .squisq-editor-shell[data-theme='dark'] .squisq-toolbar {
449
- /* no border-top sits directly in header */
680
+ background: rgba(255, 255, 255, 0.08);
450
681
  }
451
682
 
452
683
  .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-view-tabs {
453
- background: rgba(255, 255, 255, 0.08);
684
+ background: #111827;
454
685
  border-right-color: rgba(255, 255, 255, 0.15);
455
686
  }
456
687
 
@@ -495,6 +726,35 @@
495
726
  background: #4b5563;
496
727
  }
497
728
 
729
+ /* Overflow menu (dark) */
730
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-menu {
731
+ background: #1f2937;
732
+ border-color: #374151;
733
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
734
+ }
735
+
736
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item {
737
+ color: #d1d5db;
738
+ }
739
+
740
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item:hover {
741
+ background: #374151;
742
+ }
743
+
744
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--active {
745
+ color: #60a5fa;
746
+ background: #1e3a5f;
747
+ }
748
+
749
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--danger {
750
+ color: #f87171;
751
+ }
752
+
753
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-overflow-item--danger:hover {
754
+ background: #450a0a;
755
+ color: #fca5a5;
756
+ }
757
+
498
758
  /* Template picker (dark) */
499
759
  .squisq-editor-shell[data-theme='dark'] .squisq-template-picker-label {
500
760
  color: #9ca3af;
@@ -575,6 +835,27 @@
575
835
  background: #1f2937;
576
836
  }
577
837
 
838
+ .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor .column-resize-handle {
839
+ background: #60a5fa;
840
+ }
841
+
842
+ .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor .selectedCell::after {
843
+ background: rgba(96, 165, 250, 0.15);
844
+ }
845
+
846
+ .squisq-editor-shell[data-theme='dark'] .squisq-table-controls-label {
847
+ color: #9ca3af;
848
+ }
849
+
850
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-button--danger {
851
+ color: #f87171;
852
+ }
853
+
854
+ .squisq-editor-shell[data-theme='dark'] .squisq-toolbar-button--danger:hover {
855
+ background: #450a0a;
856
+ color: #fca5a5;
857
+ }
858
+
578
859
  .squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor a {
579
860
  color: #60a5fa;
580
861
  }
@@ -629,12 +910,6 @@
629
910
  .squisq-toolbar-view-tabs {
630
911
  margin-right: 0;
631
912
  }
632
-
633
- .squisq-toolbar-actions {
634
- flex-basis: 100%;
635
- flex-wrap: wrap;
636
- padding-top: 2px;
637
- }
638
913
  }
639
914
 
640
915
  /* ─── Media Bin ──────────────────────────────────────── */