@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.
- 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 +101 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +20 -8
- 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.map +1 -1
- package/dist/MediaBin.js +16 -1
- 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__/tiptapBridge.test.js +36 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- 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 +58 -2
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +195 -15
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +23 -1
- 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__/tiptapBridge.test.ts +44 -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 +66 -2
package/src/fileKind.ts
ADDED
|
@@ -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
|
+
}
|
package/src/hooks/useFileDrop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
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
|
@@ -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('- '
|
|
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}.
|
|
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:  — 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
|
|