@bendyline/squisq-editor-react 1.2.2 → 1.3.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 (95) hide show
  1. package/dist/EditorContext.d.ts +65 -1
  2. package/dist/EditorContext.d.ts.map +1 -1
  3. package/dist/EditorContext.js +31 -4
  4. package/dist/EditorContext.js.map +1 -1
  5. package/dist/EditorShell.d.ts +101 -2
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +20 -8
  8. package/dist/EditorShell.js.map +1 -1
  9. package/dist/ImageNodeView.d.ts.map +1 -1
  10. package/dist/ImageNodeView.js +12 -2
  11. package/dist/ImageNodeView.js.map +1 -1
  12. package/dist/MediaBin.d.ts.map +1 -1
  13. package/dist/MediaBin.js +16 -1
  14. package/dist/MediaBin.js.map +1 -1
  15. package/dist/MentionExtension.d.ts +22 -0
  16. package/dist/MentionExtension.d.ts.map +1 -0
  17. package/dist/MentionExtension.js +242 -0
  18. package/dist/MentionExtension.js.map +1 -0
  19. package/dist/RawEditor.d.ts +8 -1
  20. package/dist/RawEditor.d.ts.map +1 -1
  21. package/dist/RawEditor.js +167 -30
  22. package/dist/RawEditor.js.map +1 -1
  23. package/dist/TemplateAnnotation.d.ts.map +1 -1
  24. package/dist/TemplateAnnotation.js +4 -2
  25. package/dist/TemplateAnnotation.js.map +1 -1
  26. package/dist/Toolbar.d.ts +7 -1
  27. package/dist/Toolbar.d.ts.map +1 -1
  28. package/dist/Toolbar.js +57 -18
  29. package/dist/Toolbar.js.map +1 -1
  30. package/dist/Tooltip.d.ts +10 -0
  31. package/dist/Tooltip.d.ts.map +1 -0
  32. package/dist/Tooltip.js +104 -0
  33. package/dist/Tooltip.js.map +1 -0
  34. package/dist/ViewSwitcher.d.ts +1 -1
  35. package/dist/ViewSwitcher.d.ts.map +1 -1
  36. package/dist/ViewSwitcher.js +10 -4
  37. package/dist/ViewSwitcher.js.map +1 -1
  38. package/dist/WysiwygEditor.d.ts +13 -2
  39. package/dist/WysiwygEditor.d.ts.map +1 -1
  40. package/dist/WysiwygEditor.js +239 -4
  41. package/dist/WysiwygEditor.js.map +1 -1
  42. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  43. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  44. package/dist/__tests__/detectMarkdown.test.js +69 -0
  45. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  46. package/dist/__tests__/fileKind.test.d.ts +2 -0
  47. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  48. package/dist/__tests__/fileKind.test.js +81 -0
  49. package/dist/__tests__/fileKind.test.js.map +1 -0
  50. package/dist/__tests__/tiptapBridge.test.js +36 -0
  51. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  52. package/dist/detectMarkdown.d.ts +20 -0
  53. package/dist/detectMarkdown.d.ts.map +1 -0
  54. package/dist/detectMarkdown.js +61 -0
  55. package/dist/detectMarkdown.js.map +1 -0
  56. package/dist/fileKind.d.ts +30 -0
  57. package/dist/fileKind.d.ts.map +1 -0
  58. package/dist/fileKind.js +123 -0
  59. package/dist/fileKind.js.map +1 -0
  60. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  61. package/dist/hooks/useFileDrop.js +9 -7
  62. package/dist/hooks/useFileDrop.js.map +1 -1
  63. package/dist/index.d.ts +4 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +4 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/mediaDragMime.d.ts +17 -0
  68. package/dist/mediaDragMime.d.ts.map +1 -0
  69. package/dist/mediaDragMime.js +22 -0
  70. package/dist/mediaDragMime.js.map +1 -0
  71. package/dist/tiptapBridge.d.ts.map +1 -1
  72. package/dist/tiptapBridge.js +58 -2
  73. package/dist/tiptapBridge.js.map +1 -1
  74. package/package.json +9 -7
  75. package/src/EditorContext.tsx +106 -3
  76. package/src/EditorShell.tsx +195 -15
  77. package/src/ImageNodeView.tsx +15 -2
  78. package/src/MediaBin.tsx +23 -1
  79. package/src/MentionExtension.tsx +258 -0
  80. package/src/RawEditor.tsx +193 -37
  81. package/src/TemplateAnnotation.ts +4 -2
  82. package/src/Toolbar.tsx +111 -48
  83. package/src/Tooltip.tsx +124 -0
  84. package/src/ViewSwitcher.tsx +15 -5
  85. package/src/WysiwygEditor.tsx +270 -5
  86. package/src/__tests__/detectMarkdown.test.ts +88 -0
  87. package/src/__tests__/fileKind.test.ts +96 -0
  88. package/src/__tests__/tiptapBridge.test.ts +44 -0
  89. package/src/detectMarkdown.ts +62 -0
  90. package/src/fileKind.ts +134 -0
  91. package/src/hooks/useFileDrop.ts +10 -6
  92. package/src/index.ts +11 -0
  93. package/src/mediaDragMime.ts +32 -0
  94. package/src/styles/editor.css +214 -8
  95. package/src/tiptapBridge.ts +66 -2
@@ -0,0 +1,134 @@
1
+ /**
2
+ * fileKind
3
+ *
4
+ * Maps a file name (or bare extension) to a Monaco language ID and decides
5
+ * whether the editor shell should operate in markdown mode (full WYSIWYG +
6
+ * Preview experience) or code mode (Monaco-only view with formatting
7
+ * buttons hidden).
8
+ *
9
+ * The mapping favors common web / systems languages that Monaco ships with
10
+ * out of the box. Unknown extensions fall back to markdown mode so the
11
+ * existing UX remains the default for anything we don't recognize.
12
+ */
13
+
14
+ export interface FileKind {
15
+ /** 'markdown' keeps the full editor (WYSIWYG + Preview tabs); 'code' is Monaco-only. */
16
+ mode: 'markdown' | 'code';
17
+ /** Monaco language ID — passed to `<Editor defaultLanguage={...} />`. */
18
+ language: string;
19
+ }
20
+
21
+ /**
22
+ * Extension → Monaco language ID. Keys are lowercase, no leading dot.
23
+ * Extend as needed; unknown extensions fall back to markdown mode.
24
+ */
25
+ const EXT_TO_LANGUAGE: Record<string, string> = {
26
+ md: 'markdown',
27
+ markdown: 'markdown',
28
+ mdown: 'markdown',
29
+ txt: 'plaintext',
30
+ ts: 'typescript',
31
+ tsx: 'typescript',
32
+ js: 'javascript',
33
+ jsx: 'javascript',
34
+ mjs: 'javascript',
35
+ cjs: 'javascript',
36
+ json: 'json',
37
+ jsonc: 'json',
38
+ html: 'html',
39
+ htm: 'html',
40
+ css: 'css',
41
+ scss: 'scss',
42
+ less: 'less',
43
+ py: 'python',
44
+ go: 'go',
45
+ rs: 'rust',
46
+ rb: 'ruby',
47
+ java: 'java',
48
+ c: 'c',
49
+ h: 'c',
50
+ cpp: 'cpp',
51
+ hpp: 'cpp',
52
+ cc: 'cpp',
53
+ cs: 'csharp',
54
+ php: 'php',
55
+ sh: 'shell',
56
+ bash: 'shell',
57
+ zsh: 'shell',
58
+ yml: 'yaml',
59
+ yaml: 'yaml',
60
+ toml: 'ini',
61
+ ini: 'ini',
62
+ xml: 'xml',
63
+ svg: 'xml',
64
+ sql: 'sql',
65
+ lua: 'lua',
66
+ swift: 'swift',
67
+ kt: 'kotlin',
68
+ kts: 'kotlin',
69
+ dockerfile: 'dockerfile',
70
+ };
71
+
72
+ /**
73
+ * Languages that keep the full markdown shell (WYSIWYG + Preview). Anything
74
+ * outside this set is treated as code.
75
+ */
76
+ const MARKDOWN_MODE_LANGUAGES = new Set(['markdown', 'plaintext']);
77
+
78
+ /**
79
+ * Pull the lowercase extension (no leading dot) from a file name or bare
80
+ * extension string. Returns null when none is discernible.
81
+ *
82
+ * Examples:
83
+ * "foo.ts" → "ts"
84
+ * "foo.tar.gz" → "gz"
85
+ * ".ts" → "ts"
86
+ * "ts" → "ts"
87
+ * "Dockerfile" → "dockerfile" (full name match for extensionless files)
88
+ * "" → null
89
+ */
90
+ function extractExtension(fileName: string): string | null {
91
+ const trimmed = fileName.trim();
92
+ if (!trimmed) return null;
93
+
94
+ // Strip any leading path — take only the basename.
95
+ const base = trimmed.replace(/^.*[/\\]/, '');
96
+ if (!base) return null;
97
+
98
+ const dotIdx = base.lastIndexOf('.');
99
+ if (dotIdx === -1) {
100
+ // No dot — could still be a recognized bare name (Dockerfile) or a bare
101
+ // extension passed by a caller like "ts". Lower-case and return.
102
+ return base.toLowerCase();
103
+ }
104
+ if (dotIdx === base.length - 1) return null; // Trailing dot, no extension.
105
+ return base.slice(dotIdx + 1).toLowerCase();
106
+ }
107
+
108
+ /**
109
+ * Detect a Monaco language ID from a file name. Returns null when the
110
+ * extension (or bare name) is not in the mapping.
111
+ */
112
+ export function detectLanguageFromFileName(fileName: string): string | null {
113
+ const ext = extractExtension(fileName);
114
+ if (!ext) return null;
115
+ return EXT_TO_LANGUAGE[ext] ?? null;
116
+ }
117
+
118
+ /**
119
+ * Resolve the editor mode + Monaco language for a given file. The explicit
120
+ * `language` argument, if provided, wins over any detection from
121
+ * `fileName`. When nothing matches, falls back to markdown mode.
122
+ */
123
+ export function resolveFileKind(fileName?: string, language?: string): FileKind {
124
+ const resolvedLanguage = language ?? (fileName ? detectLanguageFromFileName(fileName) : null);
125
+
126
+ if (!resolvedLanguage) {
127
+ return { mode: 'markdown', language: 'markdown' };
128
+ }
129
+
130
+ const mode: FileKind['mode'] = MARKDOWN_MODE_LANGUAGES.has(resolvedLanguage)
131
+ ? 'markdown'
132
+ : 'code';
133
+ return { mode, language: resolvedLanguage };
134
+ }
@@ -137,17 +137,21 @@ export function useFileDrop({ onDrop, enabled = true }: UseFileDropOptions): Use
137
137
  const handleDragEnter = useCallback(
138
138
  (e: React.DragEvent) => {
139
139
  if (!enabled) return;
140
+
141
+ // Only react to OS file drags. In-app drags (e.g. dragging a thumbnail
142
+ // out of the MediaBin) don't carry file-kind items and must pass
143
+ // through to the editors without showing the drop overlay.
144
+ const classification = e.dataTransfer.items
145
+ ? classifyDataTransferItems(e.dataTransfer.items)
146
+ : 'mixed';
147
+ if (!classification) return;
148
+
140
149
  e.preventDefault();
141
150
  dragCounterRef.current++;
142
151
 
143
152
  if (dragCounterRef.current === 1) {
144
153
  setIsDragging(true);
145
- if (e.dataTransfer.items) {
146
- setDragContentType(classifyDataTransferItems(e.dataTransfer.items));
147
- } else {
148
- // Fallback: can't classify, show all zones
149
- setDragContentType('mixed');
150
- }
154
+ setDragContentType(classification);
151
155
  }
152
156
  },
153
157
  [enabled],
package/src/index.ts CHANGED
@@ -25,12 +25,21 @@ export type { EditorShellProps, EditorTheme } from './EditorShell.js';
25
25
  export { EditorProvider, useEditorContext } from './EditorContext.js';
26
26
  export type {
27
27
  EditorView,
28
+ EditorMode,
28
29
  EditorState,
29
30
  EditorActions,
30
31
  EditorContextValue,
31
32
  EditorProviderProps,
33
+ ImageDisplayMode,
34
+ MentionCandidate,
35
+ MentionProvider,
32
36
  } from './EditorContext.js';
33
37
 
38
+ // File-kind detection — useful for hosts that want to pre-decide chrome
39
+ // around the editor based on whether a file is markdown or code.
40
+ export { resolveFileKind, detectLanguageFromFileName } from './fileKind.js';
41
+ export type { FileKind } from './fileKind.js';
42
+
34
43
  // Individual editors (for custom layouts)
35
44
  export { RawEditor } from './RawEditor.js';
36
45
  export type { RawEditorProps } from './RawEditor.js';
@@ -60,6 +69,8 @@ export type { MediaBinProps } from './MediaBin.js';
60
69
  export { StatusBar } from './StatusBar.js';
61
70
  export type { StatusBarProps } from './StatusBar.js';
62
71
 
72
+ export { TooltipLayer } from './Tooltip.js';
73
+
63
74
  // Drag-and-drop
64
75
  export { DropZoneOverlay } from './DropZoneOverlay.js';
65
76
  export type { DropZoneOverlayProps } from './DropZoneOverlay.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared MIME type used to signal an in-app drag from the MediaBin to either
3
+ * the Raw or WYSIWYG editor. Carries a JSON payload of the form
4
+ * `{ name, mimeType, alt }` so the receiving editor can insert a reference
5
+ * to an existing media entry without re-uploading it.
6
+ */
7
+ export const SQUISQ_MEDIA_MIME = 'application/x-squisq-media';
8
+
9
+ export interface SquisqMediaDragPayload {
10
+ /** Relative path / filename as stored in the MediaProvider. */
11
+ name: string;
12
+ /** MIME type of the entry. */
13
+ mimeType: string;
14
+ /** Default alt text derived from the filename. */
15
+ alt: string;
16
+ }
17
+
18
+ export function parseSquisqMediaPayload(raw: string): SquisqMediaDragPayload | null {
19
+ try {
20
+ const parsed = JSON.parse(raw) as Partial<SquisqMediaDragPayload>;
21
+ if (
22
+ typeof parsed.name === 'string' &&
23
+ typeof parsed.mimeType === 'string' &&
24
+ typeof parsed.alt === 'string'
25
+ ) {
26
+ return parsed as SquisqMediaDragPayload;
27
+ }
28
+ } catch {
29
+ // fall through
30
+ }
31
+ return null;
32
+ }
@@ -5,8 +5,20 @@
5
5
  /* ─── Shell ──────────────────────────────────────────── */
6
6
 
7
7
  .squisq-editor-shell {
8
- font-family:
9
- -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
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::after {
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: 180px;
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: #eeecea;
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 0 8px rgba(0, 0, 0, 0.06);
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
+ }
@@ -20,6 +20,11 @@ const RE_STRIKETHROUGH = /~~(.+?)~~/g;
20
20
  const RE_INLINE_CODE = /`(.+?)`/g;
21
21
  const RE_LINK = /\[(.+?)\]\((.+?)\)/g;
22
22
  const RE_IMAGE = /!\[(.+?)\]\((.+?)\)/g;
23
+ // Mentions: `@[Display](scheme:id)` — scheme-part must start with a letter
24
+ // so plain `$100` or price-style parentheticals don't accidentally match.
25
+ // remark-stringify may round-trip the colon as `\:` — tolerate either.
26
+ const RE_MENTION = /@\[([^\]]+?)\]\(([a-z][a-z0-9+.-]*)\\?:([^)\s]+)\)/gi;
27
+ const RE_MENTION_TAG = /<span\b[^>]*?\bdata-mention\b[^>]*?>(?:<[^>]+>)*([^<]*)<\/span>/gi;
23
28
  const RE_STRONG_TAG = /<strong>(.*?)<\/strong>/g;
24
29
  const RE_B_TAG = /<b>(.*?)<\/b>/g;
25
30
  const RE_EM_TAG = /<em>(.*?)<\/em>/g;
@@ -426,7 +431,7 @@ export function tiptapToMarkdown(html: string): string {
426
431
  if (ulMatch) {
427
432
  const items = ulMatch[1].matchAll(/<li>(.*?)<\/li>/gs);
428
433
  for (const item of items) {
429
- lines.push('- ' + htmlToInline(item[1].replace(/<\/?p>/g, '')));
434
+ lines.push(...renderListItem('- ', item[1]));
430
435
  }
431
436
  lines.push('');
432
437
  remaining = remaining.slice(ulMatch[0].length);
@@ -438,7 +443,7 @@ export function tiptapToMarkdown(html: string): string {
438
443
  if (olMatch) {
439
444
  const items = [...olMatch[1].matchAll(/<li>(.*?)<\/li>/gs)];
440
445
  items.forEach((item, idx) => {
441
- lines.push(`${idx + 1}. ` + htmlToInline(item[1].replace(/<\/?p>/g, '')));
446
+ lines.push(...renderListItem(`${idx + 1}. `, item[1]));
442
447
  });
443
448
  lines.push('');
444
449
  remaining = remaining.slice(olMatch[0].length);
@@ -485,6 +490,41 @@ export function tiptapToMarkdown(html: string): string {
485
490
  );
486
491
  }
487
492
 
493
+ /**
494
+ * Render a list item's HTML content as one or more markdown lines.
495
+ * Handles `<p>` paragraph breaks (blank line) and `<br>` hard breaks
496
+ * (two trailing spaces). Continuation lines are indented to keep them
497
+ * inside the list item.
498
+ */
499
+ function renderListItem(prefix: string, html: string): string[] {
500
+ const indent = ' '.repeat(prefix.length);
501
+
502
+ // Split on </p><p> to detect paragraph breaks within the item
503
+ const paragraphs = html
504
+ .split(/<\/p>\s*<p[^>]*>/i)
505
+ .map((p) => p.replace(/^<p[^>]*>/i, '').replace(/<\/p>\s*$/i, ''));
506
+
507
+ const result: string[] = [];
508
+ paragraphs.forEach((paragraph, pIdx) => {
509
+ const inline = htmlToInline(paragraph).trim();
510
+ if (!inline) return;
511
+
512
+ // Each <br> already became " \n" in htmlToInline; split on it now.
513
+ const subLines = inline.split('\n');
514
+ subLines.forEach((sub, sIdx) => {
515
+ if (pIdx === 0 && sIdx === 0) {
516
+ result.push(prefix + sub);
517
+ } else {
518
+ // Blank line separator between paragraphs (sIdx === 0 means new paragraph)
519
+ if (sIdx === 0) result.push('');
520
+ result.push(indent + sub);
521
+ }
522
+ });
523
+ });
524
+
525
+ return result.length > 0 ? result : [prefix];
526
+ }
527
+
488
528
  // ─── Table helpers ───────────────────────────────────────
489
529
 
490
530
  /** Split a GFM table row into trimmed cell strings (strips outer pipes). */
@@ -545,6 +585,16 @@ function inlineToHtml(text: string): string {
545
585
  // Images first: ![alt](src) — must be before links so the `!` prefix is consumed
546
586
  result = result.replace(RE_IMAGE, '<img alt="$1" src="$2">');
547
587
 
588
+ // Mentions: @[Display](scheme:id) — must run before links so the
589
+ // bracket+paren isn't consumed as a regular link. The input here has
590
+ // already been run through escapeHtml at the top of this function, so
591
+ // the captured groups are safe to interpolate directly.
592
+ result = result.replace(
593
+ RE_MENTION,
594
+ (_match, label, kind, id) =>
595
+ `<span data-mention="true" data-kind="${kind}" data-id="${id}" data-label="${label}" class="mention">@${label}</span>`,
596
+ );
597
+
548
598
  // Links: [text](url)
549
599
  result = result.replace(RE_LINK, '<a href="$2">$1</a>');
550
600
 
@@ -555,6 +605,10 @@ function inlineToHtml(text: string): string {
555
605
  function htmlToInline(html: string): string {
556
606
  let result = html;
557
607
 
608
+ // Soft line breaks — convert <br> to GFM hard-break syntax (two trailing
609
+ // spaces + newline) before stripping tags so the newline survives.
610
+ result = result.replace(/<br\s*\/?>/gi, ' \n');
611
+
558
612
  // Strong
559
613
  result = result.replace(RE_STRONG_TAG, '**$1**');
560
614
  result = result.replace(RE_B_TAG, '**$1**');
@@ -570,6 +624,16 @@ function htmlToInline(html: string): string {
570
624
  // Code
571
625
  result = result.replace(RE_CODE_TAG, '`$1`');
572
626
 
627
+ // Mentions — match before the link handler so the span isn't stripped
628
+ // out as an unknown tag. Pull kind + id out of the data attributes.
629
+ result = result.replace(RE_MENTION_TAG, (match, _inner) => {
630
+ const kind = /data-kind="([^"]*)"/i.exec(match)?.[1] ?? '';
631
+ const id = /data-id="([^"]*)"/i.exec(match)?.[1] ?? '';
632
+ const label = /data-label="([^"]*)"/i.exec(match)?.[1] ?? '';
633
+ if (!kind || !id || !label) return match;
634
+ return `@[${label}](${kind}:${id})`;
635
+ });
636
+
573
637
  // Links
574
638
  result = result.replace(RE_A_TAG, '[$2]($1)');
575
639