@bendyline/squisq-editor-react 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorContext.d.ts +65 -1
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +31 -4
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +112 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +95 -11
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts.map +1 -1
- package/dist/ImageNodeView.js +12 -2
- package/dist/ImageNodeView.js.map +1 -1
- package/dist/MediaBin.d.ts +12 -1
- package/dist/MediaBin.d.ts.map +1 -1
- package/dist/MediaBin.js +29 -4
- package/dist/MediaBin.js.map +1 -1
- package/dist/MentionExtension.d.ts +22 -0
- package/dist/MentionExtension.d.ts.map +1 -0
- package/dist/MentionExtension.js +242 -0
- package/dist/MentionExtension.js.map +1 -0
- package/dist/RawEditor.d.ts +8 -1
- package/dist/RawEditor.d.ts.map +1 -1
- package/dist/RawEditor.js +167 -30
- package/dist/RawEditor.js.map +1 -1
- package/dist/TemplateAnnotation.d.ts.map +1 -1
- package/dist/TemplateAnnotation.js +4 -2
- package/dist/TemplateAnnotation.js.map +1 -1
- package/dist/Toolbar.d.ts +7 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +57 -18
- package/dist/Toolbar.js.map +1 -1
- package/dist/Tooltip.d.ts +10 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip.js +104 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/ViewSwitcher.d.ts +1 -1
- package/dist/ViewSwitcher.d.ts.map +1 -1
- package/dist/ViewSwitcher.js +10 -4
- package/dist/ViewSwitcher.js.map +1 -1
- package/dist/WysiwygEditor.d.ts +13 -2
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +239 -4
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
- package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
- package/dist/__tests__/detectMarkdown.test.js +69 -0
- package/dist/__tests__/detectMarkdown.test.js.map +1 -0
- package/dist/__tests__/fileKind.test.d.ts +2 -0
- package/dist/__tests__/fileKind.test.d.ts.map +1 -0
- package/dist/__tests__/fileKind.test.js +81 -0
- package/dist/__tests__/fileKind.test.js.map +1 -0
- package/dist/__tests__/mediaAttachmentFlow.test.d.ts +2 -0
- package/dist/__tests__/mediaAttachmentFlow.test.d.ts.map +1 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js +99 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +49 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts +2 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts.map +1 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js +68 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js.map +1 -0
- package/dist/detectMarkdown.d.ts +20 -0
- package/dist/detectMarkdown.d.ts.map +1 -0
- package/dist/detectMarkdown.js +61 -0
- package/dist/detectMarkdown.js.map +1 -0
- package/dist/fileKind.d.ts +30 -0
- package/dist/fileKind.d.ts.map +1 -0
- package/dist/fileKind.js +123 -0
- package/dist/fileKind.js.map +1 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -1
- package/dist/hooks/useFileDrop.js +9 -7
- package/dist/hooks/useFileDrop.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mediaDragMime.d.ts +17 -0
- package/dist/mediaDragMime.d.ts.map +1 -0
- package/dist/mediaDragMime.js +22 -0
- package/dist/mediaDragMime.js.map +1 -0
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +99 -6
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +313 -21
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +45 -4
- package/src/MentionExtension.tsx +258 -0
- package/src/RawEditor.tsx +193 -37
- package/src/TemplateAnnotation.ts +4 -2
- package/src/Toolbar.tsx +111 -48
- package/src/Tooltip.tsx +124 -0
- package/src/ViewSwitcher.tsx +15 -5
- package/src/WysiwygEditor.tsx +270 -5
- package/src/__tests__/detectMarkdown.test.ts +88 -0
- package/src/__tests__/fileKind.test.ts +96 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +110 -0
- package/src/__tests__/tiptapBridge.test.ts +58 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +73 -0
- package/src/detectMarkdown.ts +62 -0
- package/src/fileKind.ts +134 -0
- package/src/hooks/useFileDrop.ts +10 -6
- package/src/index.ts +11 -0
- package/src/mediaDragMime.ts +32 -0
- package/src/styles/editor.css +214 -8
- package/src/tiptapBridge.ts +107 -6
package/src/styles/editor.css
CHANGED
|
@@ -5,8 +5,20 @@
|
|
|
5
5
|
/* ─── Shell ──────────────────────────────────────────── */
|
|
6
6
|
|
|
7
7
|
.squisq-editor-shell {
|
|
8
|
-
font
|
|
9
|
-
|
|
8
|
+
/* UX font applies to editor *chrome* — toolbar, tabs, buttons, status
|
|
9
|
+
bar. The actual editing surfaces (Tiptap, Monaco) keep their own
|
|
10
|
+
fonts. `--squisq-ux-font` is set when a consumer passes the `uxFont`
|
|
11
|
+
prop on EditorShell; unset, we fall back to the system stack. */
|
|
12
|
+
font-family: var(
|
|
13
|
+
--squisq-ux-font,
|
|
14
|
+
-apple-system,
|
|
15
|
+
BlinkMacSystemFont,
|
|
16
|
+
'Segoe UI',
|
|
17
|
+
'Noto Sans',
|
|
18
|
+
Helvetica,
|
|
19
|
+
Arial,
|
|
20
|
+
sans-serif
|
|
21
|
+
);
|
|
10
22
|
color: #1f2937;
|
|
11
23
|
background: #fff;
|
|
12
24
|
}
|
|
@@ -27,6 +39,25 @@
|
|
|
27
39
|
.squisq-view-switcher {
|
|
28
40
|
display: flex;
|
|
29
41
|
gap: 0;
|
|
42
|
+
container-type: inline-size;
|
|
43
|
+
container-name: squisq-view-switcher;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.squisq-view-tab-label {
|
|
47
|
+
display: inline-block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.squisq-view-tab-label--short {
|
|
51
|
+
display: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@container squisq-view-switcher (max-width: 280px) {
|
|
55
|
+
.squisq-view-tab:has(.squisq-view-tab-label--short) .squisq-view-tab-label--long {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
.squisq-view-tab-label--short {
|
|
59
|
+
display: inline-block;
|
|
60
|
+
}
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
.squisq-view-tab {
|
|
@@ -63,6 +94,8 @@
|
|
|
63
94
|
padding: 0 12px 0 0;
|
|
64
95
|
gap: 2px;
|
|
65
96
|
background: rgba(0, 0, 0, 0.07);
|
|
97
|
+
container-type: inline-size;
|
|
98
|
+
container-name: squisq-toolbar;
|
|
66
99
|
}
|
|
67
100
|
|
|
68
101
|
/* ─── View Tabs (inside toolbar) ─────────────────────── */
|
|
@@ -101,7 +134,15 @@
|
|
|
101
134
|
border-color 0.15s;
|
|
102
135
|
}
|
|
103
136
|
|
|
104
|
-
.squisq-toolbar-view-tab
|
|
137
|
+
.squisq-toolbar-view-tab-label {
|
|
138
|
+
display: inline-block;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.squisq-toolbar-view-tab-label--short {
|
|
142
|
+
display: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.squisq-toolbar-view-tab-label::after {
|
|
105
146
|
content: attr(data-label);
|
|
106
147
|
display: block;
|
|
107
148
|
font-weight: 600;
|
|
@@ -110,6 +151,16 @@
|
|
|
110
151
|
visibility: hidden;
|
|
111
152
|
}
|
|
112
153
|
|
|
154
|
+
@container squisq-toolbar (max-width: 900px) {
|
|
155
|
+
.squisq-toolbar-view-tab:has(.squisq-toolbar-view-tab-label--short)
|
|
156
|
+
.squisq-toolbar-view-tab-label--long {
|
|
157
|
+
display: none;
|
|
158
|
+
}
|
|
159
|
+
.squisq-toolbar-view-tab-label--short {
|
|
160
|
+
display: inline-block;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
113
164
|
.squisq-toolbar-view-tab:hover {
|
|
114
165
|
color: #111827;
|
|
115
166
|
}
|
|
@@ -146,6 +197,7 @@
|
|
|
146
197
|
cursor: pointer;
|
|
147
198
|
font-size: 13px;
|
|
148
199
|
font-weight: 600;
|
|
200
|
+
white-space: nowrap;
|
|
149
201
|
transition:
|
|
150
202
|
background 0.12s,
|
|
151
203
|
color 0.12s;
|
|
@@ -193,18 +245,32 @@
|
|
|
193
245
|
|
|
194
246
|
.squisq-toolbar-overflow-menu {
|
|
195
247
|
position: absolute;
|
|
196
|
-
top: 100%;
|
|
197
248
|
right: 0;
|
|
198
249
|
z-index: 100;
|
|
199
|
-
min-width:
|
|
250
|
+
min-width: 220px;
|
|
251
|
+
max-width: 280px;
|
|
200
252
|
padding: 4px 0;
|
|
201
|
-
margin-top: 4px;
|
|
202
253
|
background: #fff;
|
|
203
254
|
border: 1px solid #e5e7eb;
|
|
204
255
|
border-radius: 6px;
|
|
205
256
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
206
257
|
}
|
|
207
258
|
|
|
259
|
+
/* Downward-opening (default): menu sits below the trigger. */
|
|
260
|
+
.squisq-toolbar-overflow-menu--down {
|
|
261
|
+
top: 100%;
|
|
262
|
+
margin-top: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* Upward-opening: when the host clips the bottom of the toolbar (e.g. a
|
|
266
|
+
chat composer near the bottom of the viewport), the menu flips above
|
|
267
|
+
the trigger so the items stay visible. */
|
|
268
|
+
.squisq-toolbar-overflow-menu--up {
|
|
269
|
+
bottom: 100%;
|
|
270
|
+
margin-bottom: 4px;
|
|
271
|
+
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.12);
|
|
272
|
+
}
|
|
273
|
+
|
|
208
274
|
.squisq-toolbar-overflow-item {
|
|
209
275
|
display: flex;
|
|
210
276
|
align-items: center;
|
|
@@ -255,11 +321,14 @@
|
|
|
255
321
|
.squisq-toolbar-overflow-template {
|
|
256
322
|
gap: 6px;
|
|
257
323
|
padding: 6px 12px;
|
|
324
|
+
box-sizing: border-box;
|
|
325
|
+
max-width: 100%;
|
|
258
326
|
}
|
|
259
327
|
|
|
260
328
|
.squisq-toolbar-overflow-template select {
|
|
261
329
|
flex: 1;
|
|
262
330
|
min-width: 0;
|
|
331
|
+
max-width: 100%;
|
|
263
332
|
}
|
|
264
333
|
|
|
265
334
|
/* ─── Template Picker (toolbar) ──────────────────────── */
|
|
@@ -295,6 +364,32 @@
|
|
|
295
364
|
outline-offset: -1px;
|
|
296
365
|
}
|
|
297
366
|
|
|
367
|
+
/* ─── Tooltip (portal) ────────────────────────────────── */
|
|
368
|
+
|
|
369
|
+
.squisq-tooltip {
|
|
370
|
+
transform: translateX(-50%);
|
|
371
|
+
padding: 4px 8px;
|
|
372
|
+
font-size: 12px;
|
|
373
|
+
font-weight: 500;
|
|
374
|
+
color: #f9fafb;
|
|
375
|
+
background: #1f2937;
|
|
376
|
+
border-radius: 4px;
|
|
377
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
|
378
|
+
white-space: nowrap;
|
|
379
|
+
pointer-events: none;
|
|
380
|
+
z-index: 2000;
|
|
381
|
+
animation: squisq-tooltip-fade 0.1s ease-out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@keyframes squisq-tooltip-fade {
|
|
385
|
+
from {
|
|
386
|
+
opacity: 0;
|
|
387
|
+
}
|
|
388
|
+
to {
|
|
389
|
+
opacity: 1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
298
393
|
/* ─── Template Badge (WYSIWYG heading) ───────────────── */
|
|
299
394
|
|
|
300
395
|
.squisq-template-badge {
|
|
@@ -314,6 +409,10 @@
|
|
|
314
409
|
line-height: 1.6;
|
|
315
410
|
}
|
|
316
411
|
|
|
412
|
+
.squisq-template-badge::after {
|
|
413
|
+
content: attr(data-template);
|
|
414
|
+
}
|
|
415
|
+
|
|
317
416
|
/* ─── Status Bar ─────────────────────────────────────── */
|
|
318
417
|
|
|
319
418
|
.squisq-status-bar {
|
|
@@ -351,7 +450,7 @@
|
|
|
351
450
|
/* ─── WYSIWYG Editor ─────────────────────────────────── */
|
|
352
451
|
|
|
353
452
|
.squisq-wysiwyg-container {
|
|
354
|
-
background: #
|
|
453
|
+
background: #dcd8d0;
|
|
355
454
|
}
|
|
356
455
|
|
|
357
456
|
.squisq-wysiwyg-editor {
|
|
@@ -361,7 +460,7 @@
|
|
|
361
460
|
outline: none;
|
|
362
461
|
min-height: 100%;
|
|
363
462
|
background: #fff;
|
|
364
|
-
box-shadow: 0
|
|
463
|
+
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.12);
|
|
365
464
|
}
|
|
366
465
|
|
|
367
466
|
.squisq-wysiwyg-editor h1 {
|
|
@@ -1229,3 +1328,110 @@
|
|
|
1229
1328
|
min-height: 120px;
|
|
1230
1329
|
}
|
|
1231
1330
|
}
|
|
1331
|
+
|
|
1332
|
+
/* ─── Full-width mode (opt-in via <EditorShell fullWidth />) ─────────
|
|
1333
|
+
*
|
|
1334
|
+
* Drops the centered 800px "page" column so the WYSIWYG surface fills
|
|
1335
|
+
* the host container. Used by hosts where the page metaphor doesn't fit
|
|
1336
|
+
* — chat composers, narrow side panels, dialog embeds. */
|
|
1337
|
+
.squisq-editor-shell[data-full-width='true'] .squisq-wysiwyg-editor {
|
|
1338
|
+
max-width: none;
|
|
1339
|
+
margin: 0;
|
|
1340
|
+
box-shadow: none;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
.squisq-editor-shell[data-full-width='true'] .squisq-wysiwyg-container {
|
|
1344
|
+
background: transparent;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.squisq-editor-shell[data-theme='dark'][data-full-width='true'] .squisq-wysiwyg-container {
|
|
1348
|
+
background: transparent;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/* Thin-margins mode — drops the 16×24px page padding on the editing
|
|
1352
|
+
surface so the composer hugs its container (chat composers etc.). */
|
|
1353
|
+
.squisq-editor-shell[data-thin-margins='true'] .squisq-wysiwyg-editor {
|
|
1354
|
+
padding: 6px 10px;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/* ── @-mention chip + suggestion popover ─────────────────────────── */
|
|
1358
|
+
|
|
1359
|
+
/* Chip rendered in both WYSIWYG (Tiptap Node) and bridge-HTML surfaces. */
|
|
1360
|
+
.squisq-wysiwyg-editor .mention,
|
|
1361
|
+
.squisq-wysiwyg-editor span[data-mention] {
|
|
1362
|
+
display: inline-block;
|
|
1363
|
+
padding: 0 6px;
|
|
1364
|
+
border-radius: 10px;
|
|
1365
|
+
font-weight: 500;
|
|
1366
|
+
background: rgba(88, 101, 242, 0.18);
|
|
1367
|
+
color: #1a2a8a;
|
|
1368
|
+
line-height: 1.4;
|
|
1369
|
+
white-space: nowrap;
|
|
1370
|
+
cursor: default;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor .mention,
|
|
1374
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-wysiwyg-editor span[data-mention] {
|
|
1375
|
+
background: rgba(128, 140, 255, 0.22);
|
|
1376
|
+
color: #c8d0ff;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/* Suggestion popover appended to <body> so it escapes overflow clipping. */
|
|
1380
|
+
.squisq-mention-popover {
|
|
1381
|
+
min-width: 220px;
|
|
1382
|
+
max-width: 320px;
|
|
1383
|
+
max-height: 240px;
|
|
1384
|
+
overflow-y: auto;
|
|
1385
|
+
background: #fff;
|
|
1386
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
1387
|
+
border-radius: 6px;
|
|
1388
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
1389
|
+
padding: 4px;
|
|
1390
|
+
font-family: var(--squisq-ux-font, system-ui, sans-serif);
|
|
1391
|
+
font-size: 13px;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
.squisq-mention-popover .squisq-mention-item {
|
|
1395
|
+
display: flex;
|
|
1396
|
+
flex-direction: column;
|
|
1397
|
+
gap: 2px;
|
|
1398
|
+
align-items: flex-start;
|
|
1399
|
+
width: 100%;
|
|
1400
|
+
padding: 6px 8px;
|
|
1401
|
+
border: none;
|
|
1402
|
+
background: transparent;
|
|
1403
|
+
border-radius: 4px;
|
|
1404
|
+
cursor: pointer;
|
|
1405
|
+
text-align: left;
|
|
1406
|
+
color: inherit;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
.squisq-mention-popover .squisq-mention-item.is-selected,
|
|
1410
|
+
.squisq-mention-popover .squisq-mention-item:hover {
|
|
1411
|
+
background: rgba(88, 101, 242, 0.12);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
.squisq-mention-popover .squisq-mention-label {
|
|
1415
|
+
font-weight: 500;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
.squisq-mention-popover .squisq-mention-desc {
|
|
1419
|
+
font-size: 11px;
|
|
1420
|
+
color: rgba(0, 0, 0, 0.55);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
@media (prefers-color-scheme: dark) {
|
|
1424
|
+
.squisq-mention-popover {
|
|
1425
|
+
background: #1f2230;
|
|
1426
|
+
border-color: rgba(255, 255, 255, 0.14);
|
|
1427
|
+
color: #e5e7eb;
|
|
1428
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
|
1429
|
+
}
|
|
1430
|
+
.squisq-mention-popover .squisq-mention-item.is-selected,
|
|
1431
|
+
.squisq-mention-popover .squisq-mention-item:hover {
|
|
1432
|
+
background: rgba(128, 140, 255, 0.18);
|
|
1433
|
+
}
|
|
1434
|
+
.squisq-mention-popover .squisq-mention-desc {
|
|
1435
|
+
color: rgba(255, 255, 255, 0.6);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
package/src/tiptapBridge.ts
CHANGED
|
@@ -19,7 +19,16 @@ const RE_ITALIC_UNDER = /_(.+?)_/g;
|
|
|
19
19
|
const RE_STRIKETHROUGH = /~~(.+?)~~/g;
|
|
20
20
|
const RE_INLINE_CODE = /`(.+?)`/g;
|
|
21
21
|
const RE_LINK = /\[(.+?)\]\((.+?)\)/g;
|
|
22
|
-
|
|
22
|
+
// `*?` on the alt — an empty alt (``) is valid markdown and
|
|
23
|
+
// the most common shape for pasted/uploaded images that don't yet have
|
|
24
|
+
// a human-picked caption. Previously required at least one alt char,
|
|
25
|
+
// which dropped those images on the floor during markdown→HTML.
|
|
26
|
+
const RE_IMAGE = /!\[(.*?)\]\((.+?)\)/g;
|
|
27
|
+
// Mentions: `@[Display](scheme:id)` — scheme-part must start with a letter
|
|
28
|
+
// so plain `$100` or price-style parentheticals don't accidentally match.
|
|
29
|
+
// remark-stringify may round-trip the colon as `\:` — tolerate either.
|
|
30
|
+
const RE_MENTION = /@\[([^\]]+?)\]\(([a-z][a-z0-9+.-]*)\\?:([^)\s]+)\)/gi;
|
|
31
|
+
const RE_MENTION_TAG = /<span\b[^>]*?\bdata-mention\b[^>]*?>(?:<[^>]+>)*([^<]*)<\/span>/gi;
|
|
23
32
|
const RE_STRONG_TAG = /<strong>(.*?)<\/strong>/g;
|
|
24
33
|
const RE_B_TAG = /<b>(.*?)<\/b>/g;
|
|
25
34
|
const RE_EM_TAG = /<em>(.*?)<\/em>/g;
|
|
@@ -28,7 +37,14 @@ const RE_S_TAG = /<s>(.*?)<\/s>/g;
|
|
|
28
37
|
const RE_DEL_TAG = /<del>(.*?)<\/del>/g;
|
|
29
38
|
const RE_CODE_TAG = /<code>(.*?)<\/code>/g;
|
|
30
39
|
const RE_A_TAG = /<a[^>]+href="([^"]*)"[^>]*>(.*?)<\/a>/g;
|
|
31
|
-
|
|
40
|
+
// Matches any `<img>` tag and captures its `src` + `alt` regardless of
|
|
41
|
+
// attribute order. TipTap's Image extension renders `<img src="..."
|
|
42
|
+
// alt="...">` (src first), while some other producers — including our
|
|
43
|
+
// own `markdownToTiptap` conversion — emit alt-first. The previous
|
|
44
|
+
// regex required alt-before-src and silently dropped every src-first
|
|
45
|
+
// image; `RE_STRIP_TAGS` below would then delete the unmatched tag,
|
|
46
|
+
// so the outgoing markdown had no image reference at all.
|
|
47
|
+
const RE_IMG_TAG = /<img\b([^>]*)>/g;
|
|
32
48
|
const RE_STRIP_TAGS = /<[^>]+>/g;
|
|
33
49
|
|
|
34
50
|
/**
|
|
@@ -426,7 +442,7 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
426
442
|
if (ulMatch) {
|
|
427
443
|
const items = ulMatch[1].matchAll(/<li>(.*?)<\/li>/gs);
|
|
428
444
|
for (const item of items) {
|
|
429
|
-
lines.push('- '
|
|
445
|
+
lines.push(...renderListItem('- ', item[1]));
|
|
430
446
|
}
|
|
431
447
|
lines.push('');
|
|
432
448
|
remaining = remaining.slice(ulMatch[0].length);
|
|
@@ -438,7 +454,7 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
438
454
|
if (olMatch) {
|
|
439
455
|
const items = [...olMatch[1].matchAll(/<li>(.*?)<\/li>/gs)];
|
|
440
456
|
items.forEach((item, idx) => {
|
|
441
|
-
lines.push(`${idx + 1}.
|
|
457
|
+
lines.push(...renderListItem(`${idx + 1}. `, item[1]));
|
|
442
458
|
});
|
|
443
459
|
lines.push('');
|
|
444
460
|
remaining = remaining.slice(olMatch[0].length);
|
|
@@ -457,6 +473,26 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
457
473
|
continue;
|
|
458
474
|
}
|
|
459
475
|
|
|
476
|
+
// Block-level image. TipTap's Image extension with `inline: false`
|
|
477
|
+
// emits `<img src alt>` as a bare top-level element (no wrapping
|
|
478
|
+
// `<p>`). Without this handler the skip-unknown-tags catch-all
|
|
479
|
+
// below silently drops the image from the outgoing markdown —
|
|
480
|
+
// the bug that made the chat composer ship image-less messages
|
|
481
|
+
// even though the editor showed the picture. Handled here,
|
|
482
|
+
// before the inline walker ever sees it.
|
|
483
|
+
const imgMatch = remaining.match(/^<img\b([^>]*)>/);
|
|
484
|
+
if (imgMatch) {
|
|
485
|
+
const attrs = imgMatch[1] ?? '';
|
|
486
|
+
const src = /\bsrc="([^"]*)"/i.exec(attrs)?.[1];
|
|
487
|
+
if (src) {
|
|
488
|
+
const alt = /\balt="([^"]*)"/i.exec(attrs)?.[1] ?? '';
|
|
489
|
+
lines.push(``);
|
|
490
|
+
lines.push('');
|
|
491
|
+
}
|
|
492
|
+
remaining = remaining.slice(imgMatch[0].length);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
460
496
|
// Skip unknown tags or whitespace
|
|
461
497
|
const skipMatch = remaining.match(/^(<[^>]+>|\s+)/);
|
|
462
498
|
if (skipMatch) {
|
|
@@ -485,6 +521,41 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
485
521
|
);
|
|
486
522
|
}
|
|
487
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Render a list item's HTML content as one or more markdown lines.
|
|
526
|
+
* Handles `<p>` paragraph breaks (blank line) and `<br>` hard breaks
|
|
527
|
+
* (two trailing spaces). Continuation lines are indented to keep them
|
|
528
|
+
* inside the list item.
|
|
529
|
+
*/
|
|
530
|
+
function renderListItem(prefix: string, html: string): string[] {
|
|
531
|
+
const indent = ' '.repeat(prefix.length);
|
|
532
|
+
|
|
533
|
+
// Split on </p><p> to detect paragraph breaks within the item
|
|
534
|
+
const paragraphs = html
|
|
535
|
+
.split(/<\/p>\s*<p[^>]*>/i)
|
|
536
|
+
.map((p) => p.replace(/^<p[^>]*>/i, '').replace(/<\/p>\s*$/i, ''));
|
|
537
|
+
|
|
538
|
+
const result: string[] = [];
|
|
539
|
+
paragraphs.forEach((paragraph, pIdx) => {
|
|
540
|
+
const inline = htmlToInline(paragraph).trim();
|
|
541
|
+
if (!inline) return;
|
|
542
|
+
|
|
543
|
+
// Each <br> already became " \n" in htmlToInline; split on it now.
|
|
544
|
+
const subLines = inline.split('\n');
|
|
545
|
+
subLines.forEach((sub, sIdx) => {
|
|
546
|
+
if (pIdx === 0 && sIdx === 0) {
|
|
547
|
+
result.push(prefix + sub);
|
|
548
|
+
} else {
|
|
549
|
+
// Blank line separator between paragraphs (sIdx === 0 means new paragraph)
|
|
550
|
+
if (sIdx === 0) result.push('');
|
|
551
|
+
result.push(indent + sub);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return result.length > 0 ? result : [prefix];
|
|
557
|
+
}
|
|
558
|
+
|
|
488
559
|
// ─── Table helpers ───────────────────────────────────────
|
|
489
560
|
|
|
490
561
|
/** Split a GFM table row into trimmed cell strings (strips outer pipes). */
|
|
@@ -545,6 +616,16 @@ function inlineToHtml(text: string): string {
|
|
|
545
616
|
// Images first:  — must be before links so the `!` prefix is consumed
|
|
546
617
|
result = result.replace(RE_IMAGE, '<img alt="$1" src="$2">');
|
|
547
618
|
|
|
619
|
+
// Mentions: @[Display](scheme:id) — must run before links so the
|
|
620
|
+
// bracket+paren isn't consumed as a regular link. The input here has
|
|
621
|
+
// already been run through escapeHtml at the top of this function, so
|
|
622
|
+
// the captured groups are safe to interpolate directly.
|
|
623
|
+
result = result.replace(
|
|
624
|
+
RE_MENTION,
|
|
625
|
+
(_match, label, kind, id) =>
|
|
626
|
+
`<span data-mention="true" data-kind="${kind}" data-id="${id}" data-label="${label}" class="mention">@${label}</span>`,
|
|
627
|
+
);
|
|
628
|
+
|
|
548
629
|
// Links: [text](url)
|
|
549
630
|
result = result.replace(RE_LINK, '<a href="$2">$1</a>');
|
|
550
631
|
|
|
@@ -555,6 +636,10 @@ function inlineToHtml(text: string): string {
|
|
|
555
636
|
function htmlToInline(html: string): string {
|
|
556
637
|
let result = html;
|
|
557
638
|
|
|
639
|
+
// Soft line breaks — convert <br> to GFM hard-break syntax (two trailing
|
|
640
|
+
// spaces + newline) before stripping tags so the newline survives.
|
|
641
|
+
result = result.replace(/<br\s*\/?>/gi, ' \n');
|
|
642
|
+
|
|
558
643
|
// Strong
|
|
559
644
|
result = result.replace(RE_STRONG_TAG, '**$1**');
|
|
560
645
|
result = result.replace(RE_B_TAG, '**$1**');
|
|
@@ -570,11 +655,27 @@ function htmlToInline(html: string): string {
|
|
|
570
655
|
// Code
|
|
571
656
|
result = result.replace(RE_CODE_TAG, '`$1`');
|
|
572
657
|
|
|
658
|
+
// Mentions — match before the link handler so the span isn't stripped
|
|
659
|
+
// out as an unknown tag. Pull kind + id out of the data attributes.
|
|
660
|
+
result = result.replace(RE_MENTION_TAG, (match, _inner) => {
|
|
661
|
+
const kind = /data-kind="([^"]*)"/i.exec(match)?.[1] ?? '';
|
|
662
|
+
const id = /data-id="([^"]*)"/i.exec(match)?.[1] ?? '';
|
|
663
|
+
const label = /data-label="([^"]*)"/i.exec(match)?.[1] ?? '';
|
|
664
|
+
if (!kind || !id || !label) return match;
|
|
665
|
+
return `@[${label}](${kind}:${id})`;
|
|
666
|
+
});
|
|
667
|
+
|
|
573
668
|
// Links
|
|
574
669
|
result = result.replace(RE_A_TAG, '[$2]($1)');
|
|
575
670
|
|
|
576
|
-
// Images
|
|
577
|
-
|
|
671
|
+
// Images — order-agnostic attribute parsing (tiptap emits src-first,
|
|
672
|
+
// our markdown-to-html emits alt-first; either must serialize back).
|
|
673
|
+
result = result.replace(RE_IMG_TAG, (match, attrs: string) => {
|
|
674
|
+
const src = /\bsrc="([^"]*)"/i.exec(attrs)?.[1];
|
|
675
|
+
if (!src) return match;
|
|
676
|
+
const alt = /\balt="([^"]*)"/i.exec(attrs)?.[1] ?? '';
|
|
677
|
+
return ``;
|
|
678
|
+
});
|
|
578
679
|
|
|
579
680
|
// Strip remaining tags
|
|
580
681
|
result = result.replace(RE_STRIP_TAGS, '');
|