@fragments-sdk/ui 0.9.7 → 0.11.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/README.md +32 -24
- package/dist/assets/ui.css +304 -0
- package/dist/blocks/BlogEditor.block.d.ts +3 -0
- package/dist/blocks/BlogEditor.block.d.ts.map +1 -0
- package/dist/components/Editor/Editor.module.scss.cjs +57 -0
- package/dist/components/Editor/Editor.module.scss.cjs.map +1 -0
- package/dist/components/Editor/Editor.module.scss.js +57 -0
- package/dist/components/Editor/Editor.module.scss.js.map +1 -0
- package/dist/components/Editor/index.cjs +548 -0
- package/dist/components/Editor/index.cjs.map +1 -0
- package/dist/components/Editor/index.d.ts +107 -0
- package/dist/components/Editor/index.d.ts.map +1 -0
- package/dist/components/Editor/index.js +531 -0
- package/dist/components/Editor/index.js.map +1 -0
- package/dist/components/Sidebar/index.cjs +6 -11
- package/dist/components/Sidebar/index.cjs.map +1 -1
- package/dist/components/Sidebar/index.d.ts.map +1 -1
- package/dist/components/Sidebar/index.js +6 -11
- package/dist/components/Sidebar/index.js.map +1 -1
- package/dist/components/Theme/index.cjs +86 -1
- package/dist/components/Theme/index.cjs.map +1 -1
- package/dist/components/Theme/index.d.ts +44 -1
- package/dist/components/Theme/index.d.ts.map +1 -1
- package/dist/components/Theme/index.js +86 -1
- package/dist/components/Theme/index.js.map +1 -1
- package/dist/index.cjs +24 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/keyboard-shortcuts.cjs +295 -0
- package/dist/utils/keyboard-shortcuts.cjs.map +1 -0
- package/dist/utils/keyboard-shortcuts.d.ts +293 -0
- package/dist/utils/keyboard-shortcuts.d.ts.map +1 -0
- package/dist/utils/keyboard-shortcuts.js +295 -0
- package/dist/utils/keyboard-shortcuts.js.map +1 -0
- package/fragments.json +1 -1
- package/package.json +32 -3
- package/src/blocks/BlogEditor.block.ts +34 -0
- package/src/components/Editor/Editor.fragment.tsx +322 -0
- package/src/components/Editor/Editor.module.scss +333 -0
- package/src/components/Editor/Editor.test.tsx +174 -0
- package/src/components/Editor/index.tsx +815 -0
- package/src/components/Sidebar/index.tsx +7 -14
- package/src/components/Theme/index.tsx +168 -1
- package/src/index.ts +49 -0
- package/src/tokens/_seeds.scss +20 -0
- package/src/utils/keyboard-shortcuts.test.ts +357 -0
- package/src/utils/keyboard-shortcuts.ts +502 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import styles from './Editor.module.scss';
|
|
5
|
+
import {
|
|
6
|
+
TextB,
|
|
7
|
+
TextItalic,
|
|
8
|
+
TextStrikethrough,
|
|
9
|
+
LinkSimple,
|
|
10
|
+
Code,
|
|
11
|
+
ListBullets,
|
|
12
|
+
ListNumbers,
|
|
13
|
+
TextHOne,
|
|
14
|
+
TextHTwo,
|
|
15
|
+
TextHThree,
|
|
16
|
+
Quotes,
|
|
17
|
+
ArrowCounterClockwise,
|
|
18
|
+
ArrowClockwise,
|
|
19
|
+
} from '@phosphor-icons/react';
|
|
20
|
+
import { KEYBOARD_SHORTCUTS } from '../../utils/keyboard-shortcuts';
|
|
21
|
+
|
|
22
|
+
// ============================================
|
|
23
|
+
// Lazy-loaded dependency (TipTap)
|
|
24
|
+
// ============================================
|
|
25
|
+
|
|
26
|
+
let _useEditor: ((config: Record<string, unknown>) => unknown) | null = null;
|
|
27
|
+
let _EditorContent: React.ComponentType<Record<string, unknown>> | null = null;
|
|
28
|
+
let _StarterKit: unknown = null;
|
|
29
|
+
let _LinkExtension: unknown = null;
|
|
30
|
+
let _tiptapLoaded = false;
|
|
31
|
+
let _tiptapFailed = false;
|
|
32
|
+
|
|
33
|
+
function loadTipTapDeps() {
|
|
34
|
+
if (_tiptapLoaded) return;
|
|
35
|
+
_tiptapLoaded = true;
|
|
36
|
+
try {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const tiptapReact = require('@tiptap/react');
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
const starterKit = require('@tiptap/starter-kit');
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
const linkExt = require('@tiptap/extension-link');
|
|
43
|
+
|
|
44
|
+
_useEditor = tiptapReact.useEditor;
|
|
45
|
+
_EditorContent = tiptapReact.EditorContent;
|
|
46
|
+
_StarterKit = starterKit.default ?? starterKit.StarterKit ?? starterKit;
|
|
47
|
+
_LinkExtension = linkExt.default ?? linkExt.Link ?? linkExt;
|
|
48
|
+
} catch {
|
|
49
|
+
_tiptapFailed = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================
|
|
54
|
+
// Types
|
|
55
|
+
// ============================================
|
|
56
|
+
|
|
57
|
+
export type EditorFormat =
|
|
58
|
+
| 'bold' | 'italic' | 'strikethrough' | 'link' | 'code'
|
|
59
|
+
| 'bulletList' | 'orderedList'
|
|
60
|
+
| 'heading1' | 'heading2' | 'heading3'
|
|
61
|
+
| 'blockquote'
|
|
62
|
+
| 'undo' | 'redo';
|
|
63
|
+
|
|
64
|
+
export type EditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
65
|
+
|
|
66
|
+
export type EditorMode = 'rich' | 'markdown';
|
|
67
|
+
|
|
68
|
+
export type EditorSize = 'sm' | 'md' | 'lg';
|
|
69
|
+
|
|
70
|
+
export interface EditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {
|
|
71
|
+
children?: React.ReactNode;
|
|
72
|
+
/** Controlled value */
|
|
73
|
+
value?: string;
|
|
74
|
+
/** Default value for uncontrolled usage */
|
|
75
|
+
defaultValue?: string;
|
|
76
|
+
/** Called when content changes */
|
|
77
|
+
onValueChange?: (value: string) => void;
|
|
78
|
+
/** Placeholder text */
|
|
79
|
+
placeholder?: string;
|
|
80
|
+
/** Disable the editor */
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
/** Read-only mode */
|
|
83
|
+
readOnly?: boolean;
|
|
84
|
+
/** Which format buttons to show */
|
|
85
|
+
formats?: EditorFormat[];
|
|
86
|
+
/** Show default toolbar */
|
|
87
|
+
toolbar?: boolean;
|
|
88
|
+
/** Show default status bar */
|
|
89
|
+
statusBar?: boolean;
|
|
90
|
+
/** Auto-save callback */
|
|
91
|
+
onAutoSave?: (value: string) => void;
|
|
92
|
+
/** Auto-save interval in ms */
|
|
93
|
+
autoSaveInterval?: number;
|
|
94
|
+
/** Editor size preset */
|
|
95
|
+
size?: EditorSize;
|
|
96
|
+
/** Maximum character count (shows indicator in status bar) */
|
|
97
|
+
maxLength?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface EditorToolbarProps {
|
|
101
|
+
children: React.ReactNode;
|
|
102
|
+
className?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EditorToolbarGroupProps {
|
|
106
|
+
children: React.ReactNode;
|
|
107
|
+
'aria-label'?: string;
|
|
108
|
+
className?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface EditorToolbarButtonProps {
|
|
112
|
+
/** Which format this button toggles */
|
|
113
|
+
format: EditorFormat;
|
|
114
|
+
className?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface EditorSeparatorProps {
|
|
118
|
+
className?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface EditorStatusIndicatorProps {
|
|
122
|
+
/** Override the save status from context */
|
|
123
|
+
status?: EditorSaveStatus;
|
|
124
|
+
/** Custom labels per status */
|
|
125
|
+
labels?: Partial<Record<EditorSaveStatus, string>>;
|
|
126
|
+
className?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface EditorContentProps {
|
|
130
|
+
className?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface EditorStatusBarProps {
|
|
134
|
+
/** Show word count */
|
|
135
|
+
showWordCount?: boolean;
|
|
136
|
+
/** Show character count */
|
|
137
|
+
showCharCount?: boolean;
|
|
138
|
+
className?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================
|
|
142
|
+
// Format metadata
|
|
143
|
+
// ============================================
|
|
144
|
+
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
const FORMAT_META: Record<EditorFormat, { icon: React.ComponentType<any>; label: string; shortcut: string }> = {
|
|
147
|
+
bold: { icon: TextB, label: 'Bold', shortcut: KEYBOARD_SHORTCUTS.EDITOR_BOLD.label },
|
|
148
|
+
italic: { icon: TextItalic, label: 'Italic', shortcut: KEYBOARD_SHORTCUTS.EDITOR_ITALIC.label },
|
|
149
|
+
strikethrough: { icon: TextStrikethrough, label: 'Strikethrough', shortcut: KEYBOARD_SHORTCUTS.EDITOR_STRIKETHROUGH.label },
|
|
150
|
+
link: { icon: LinkSimple, label: 'Link', shortcut: KEYBOARD_SHORTCUTS.EDITOR_LINK.label },
|
|
151
|
+
code: { icon: Code, label: 'Code', shortcut: KEYBOARD_SHORTCUTS.EDITOR_CODE.label },
|
|
152
|
+
bulletList: { icon: ListBullets, label: 'Bullet list', shortcut: KEYBOARD_SHORTCUTS.EDITOR_BULLET_LIST.label },
|
|
153
|
+
orderedList: { icon: ListNumbers, label: 'Ordered list', shortcut: KEYBOARD_SHORTCUTS.EDITOR_ORDERED_LIST.label },
|
|
154
|
+
heading1: { icon: TextHOne, label: 'Heading 1', shortcut: KEYBOARD_SHORTCUTS.EDITOR_HEADING1.label },
|
|
155
|
+
heading2: { icon: TextHTwo, label: 'Heading 2', shortcut: KEYBOARD_SHORTCUTS.EDITOR_HEADING2.label },
|
|
156
|
+
heading3: { icon: TextHThree, label: 'Heading 3', shortcut: KEYBOARD_SHORTCUTS.EDITOR_HEADING3.label },
|
|
157
|
+
blockquote: { icon: Quotes, label: 'Blockquote', shortcut: KEYBOARD_SHORTCUTS.EDITOR_BLOCKQUOTE.label },
|
|
158
|
+
undo: { icon: ArrowCounterClockwise, label: 'Undo', shortcut: KEYBOARD_SHORTCUTS.EDITOR_UNDO.label },
|
|
159
|
+
redo: { icon: ArrowClockwise, label: 'Redo', shortcut: KEYBOARD_SHORTCUTS.EDITOR_REDO.label },
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const DEFAULT_FORMATS: EditorFormat[] = ['bold', 'italic', 'strikethrough', 'link', 'code', 'bulletList'];
|
|
163
|
+
|
|
164
|
+
/** Formats that are actions (not toggles) — no aria-pressed, different disable logic */
|
|
165
|
+
const ACTION_FORMATS = new Set<EditorFormat>(['undo', 'redo']);
|
|
166
|
+
|
|
167
|
+
const DEFAULT_STATUS_LABELS: Record<EditorSaveStatus, string> = {
|
|
168
|
+
idle: '',
|
|
169
|
+
saving: 'SAVING...',
|
|
170
|
+
saved: 'AUTO-SAVED',
|
|
171
|
+
error: 'SAVE FAILED',
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ============================================
|
|
175
|
+
// Markdown formatting helpers (textarea fallback)
|
|
176
|
+
// ============================================
|
|
177
|
+
|
|
178
|
+
interface TextareaSelection {
|
|
179
|
+
start: number;
|
|
180
|
+
end: number;
|
|
181
|
+
text: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getSelection(textarea: HTMLTextAreaElement): TextareaSelection {
|
|
185
|
+
return {
|
|
186
|
+
start: textarea.selectionStart,
|
|
187
|
+
end: textarea.selectionEnd,
|
|
188
|
+
text: textarea.value.substring(textarea.selectionStart, textarea.selectionEnd),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function wrapSelection(
|
|
193
|
+
textarea: HTMLTextAreaElement,
|
|
194
|
+
prefix: string,
|
|
195
|
+
suffix: string,
|
|
196
|
+
setValue: (v: string) => void,
|
|
197
|
+
) {
|
|
198
|
+
const sel = getSelection(textarea);
|
|
199
|
+
const before = textarea.value.substring(0, sel.start);
|
|
200
|
+
const after = textarea.value.substring(sel.end);
|
|
201
|
+
const wrapped = `${prefix}${sel.text || 'text'}${suffix}`;
|
|
202
|
+
const newValue = `${before}${wrapped}${after}`;
|
|
203
|
+
setValue(newValue);
|
|
204
|
+
|
|
205
|
+
requestAnimationFrame(() => {
|
|
206
|
+
textarea.focus();
|
|
207
|
+
const newStart = sel.start + prefix.length;
|
|
208
|
+
const newEnd = newStart + (sel.text || 'text').length;
|
|
209
|
+
textarea.setSelectionRange(newStart, newEnd);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyMarkdownFormat(
|
|
214
|
+
format: EditorFormat,
|
|
215
|
+
textarea: HTMLTextAreaElement,
|
|
216
|
+
setValue: (v: string) => void,
|
|
217
|
+
) {
|
|
218
|
+
switch (format) {
|
|
219
|
+
case 'bold':
|
|
220
|
+
wrapSelection(textarea, '**', '**', setValue);
|
|
221
|
+
break;
|
|
222
|
+
case 'italic':
|
|
223
|
+
wrapSelection(textarea, '*', '*', setValue);
|
|
224
|
+
break;
|
|
225
|
+
case 'strikethrough':
|
|
226
|
+
wrapSelection(textarea, '~~', '~~', setValue);
|
|
227
|
+
break;
|
|
228
|
+
case 'code':
|
|
229
|
+
wrapSelection(textarea, '`', '`', setValue);
|
|
230
|
+
break;
|
|
231
|
+
case 'link': {
|
|
232
|
+
const sel = getSelection(textarea);
|
|
233
|
+
const linkText = sel.text || 'link text';
|
|
234
|
+
const before = textarea.value.substring(0, sel.start);
|
|
235
|
+
const after = textarea.value.substring(sel.end);
|
|
236
|
+
const newValue = `${before}[${linkText}](url)${after}`;
|
|
237
|
+
setValue(newValue);
|
|
238
|
+
requestAnimationFrame(() => {
|
|
239
|
+
textarea.focus();
|
|
240
|
+
const urlStart = sel.start + linkText.length + 3;
|
|
241
|
+
textarea.setSelectionRange(urlStart, urlStart + 3);
|
|
242
|
+
});
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'bulletList': {
|
|
246
|
+
const sel = getSelection(textarea);
|
|
247
|
+
const before = textarea.value.substring(0, sel.start);
|
|
248
|
+
const after = textarea.value.substring(sel.end);
|
|
249
|
+
const lines = (sel.text || 'item').split('\n');
|
|
250
|
+
const bulleted = lines.map((l) => `- ${l}`).join('\n');
|
|
251
|
+
const newValue = `${before}${bulleted}${after}`;
|
|
252
|
+
setValue(newValue);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 'orderedList': {
|
|
256
|
+
const sel = getSelection(textarea);
|
|
257
|
+
const before = textarea.value.substring(0, sel.start);
|
|
258
|
+
const after = textarea.value.substring(sel.end);
|
|
259
|
+
const lines = (sel.text || 'item').split('\n');
|
|
260
|
+
const numbered = lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
261
|
+
const newValue = `${before}${numbered}${after}`;
|
|
262
|
+
setValue(newValue);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case 'heading1':
|
|
266
|
+
wrapSelection(textarea, '# ', '', setValue);
|
|
267
|
+
break;
|
|
268
|
+
case 'heading2':
|
|
269
|
+
wrapSelection(textarea, '## ', '', setValue);
|
|
270
|
+
break;
|
|
271
|
+
case 'heading3':
|
|
272
|
+
wrapSelection(textarea, '### ', '', setValue);
|
|
273
|
+
break;
|
|
274
|
+
case 'blockquote': {
|
|
275
|
+
const sel = getSelection(textarea);
|
|
276
|
+
const before = textarea.value.substring(0, sel.start);
|
|
277
|
+
const after = textarea.value.substring(sel.end);
|
|
278
|
+
const lines = (sel.text || 'quote').split('\n');
|
|
279
|
+
const quoted = lines.map((l) => `> ${l}`).join('\n');
|
|
280
|
+
const newValue = `${before}${quoted}${after}`;
|
|
281
|
+
setValue(newValue);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'undo':
|
|
285
|
+
case 'redo':
|
|
286
|
+
// Undo/redo in textarea mode is handled natively by the browser
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ============================================
|
|
292
|
+
// Context
|
|
293
|
+
// ============================================
|
|
294
|
+
|
|
295
|
+
interface EditorContextValue {
|
|
296
|
+
value: string;
|
|
297
|
+
setValue: (v: string) => void;
|
|
298
|
+
placeholder: string;
|
|
299
|
+
disabled: boolean;
|
|
300
|
+
readOnly: boolean;
|
|
301
|
+
formats: EditorFormat[];
|
|
302
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
303
|
+
editor: any | null;
|
|
304
|
+
mode: EditorMode;
|
|
305
|
+
size: EditorSize;
|
|
306
|
+
maxLength?: number;
|
|
307
|
+
wordCount: number;
|
|
308
|
+
charCount: number;
|
|
309
|
+
toggleFormat: (f: EditorFormat) => void;
|
|
310
|
+
isFormatActive: (f: EditorFormat) => boolean;
|
|
311
|
+
saveStatus: EditorSaveStatus;
|
|
312
|
+
contentRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const EditorContext = React.createContext<EditorContextValue | null>(null);
|
|
316
|
+
|
|
317
|
+
function useEditorContext() {
|
|
318
|
+
const context = React.useContext(EditorContext);
|
|
319
|
+
if (!context) {
|
|
320
|
+
throw new Error('Editor compound components must be used within an Editor');
|
|
321
|
+
}
|
|
322
|
+
return context;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ============================================
|
|
326
|
+
// Hooks
|
|
327
|
+
// ============================================
|
|
328
|
+
|
|
329
|
+
function useControllableState<T>(
|
|
330
|
+
controlledValue: T | undefined,
|
|
331
|
+
defaultValue: T,
|
|
332
|
+
onChange?: (value: T) => void,
|
|
333
|
+
): [T, (value: T) => void] {
|
|
334
|
+
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
|
|
335
|
+
const isControlled = controlledValue !== undefined;
|
|
336
|
+
const value = isControlled ? controlledValue : uncontrolledValue;
|
|
337
|
+
|
|
338
|
+
const setValue = React.useCallback(
|
|
339
|
+
(newValue: T) => {
|
|
340
|
+
if (!isControlled) {
|
|
341
|
+
setUncontrolledValue(newValue);
|
|
342
|
+
}
|
|
343
|
+
onChange?.(newValue);
|
|
344
|
+
},
|
|
345
|
+
[isControlled, onChange],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
return [value, setValue];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function countWords(text: string): number {
|
|
352
|
+
const trimmed = text.trim();
|
|
353
|
+
if (!trimmed) return 0;
|
|
354
|
+
return trimmed.split(/\s+/).length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============================================
|
|
358
|
+
// Components
|
|
359
|
+
// ============================================
|
|
360
|
+
|
|
361
|
+
function EditorRoot({
|
|
362
|
+
children,
|
|
363
|
+
value: controlledValue,
|
|
364
|
+
defaultValue = '',
|
|
365
|
+
onValueChange,
|
|
366
|
+
placeholder = 'Start typing...',
|
|
367
|
+
disabled = false,
|
|
368
|
+
readOnly = false,
|
|
369
|
+
formats = DEFAULT_FORMATS,
|
|
370
|
+
toolbar = true,
|
|
371
|
+
statusBar = true,
|
|
372
|
+
onAutoSave,
|
|
373
|
+
autoSaveInterval = 30000,
|
|
374
|
+
size = 'md',
|
|
375
|
+
maxLength,
|
|
376
|
+
className,
|
|
377
|
+
...htmlProps
|
|
378
|
+
}: EditorProps) {
|
|
379
|
+
const contentRef = React.useRef<HTMLTextAreaElement>(null);
|
|
380
|
+
|
|
381
|
+
const [value, setValue] = useControllableState(
|
|
382
|
+
controlledValue,
|
|
383
|
+
defaultValue,
|
|
384
|
+
onValueChange,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const [saveStatus, setSaveStatus] = React.useState<EditorSaveStatus>('idle');
|
|
388
|
+
|
|
389
|
+
// Try loading TipTap
|
|
390
|
+
loadTipTapDeps();
|
|
391
|
+
const hasTipTap = !_tiptapFailed && _useEditor && _EditorContent && _StarterKit;
|
|
392
|
+
const mode: EditorMode = hasTipTap ? 'rich' : 'markdown';
|
|
393
|
+
|
|
394
|
+
// TipTap editor instance (only when available)
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
396
|
+
const tiptapEditor: any = hasTipTap
|
|
397
|
+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
398
|
+
(_useEditor as any)({
|
|
399
|
+
extensions: [
|
|
400
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
401
|
+
(_StarterKit as any).configure({
|
|
402
|
+
heading: { levels: [1, 2, 3] },
|
|
403
|
+
blockquote: {},
|
|
404
|
+
codeBlock: false,
|
|
405
|
+
horizontalRule: false,
|
|
406
|
+
hardBreak: false,
|
|
407
|
+
}),
|
|
408
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
409
|
+
(_LinkExtension as any).configure({
|
|
410
|
+
openOnClick: false,
|
|
411
|
+
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
412
|
+
}),
|
|
413
|
+
],
|
|
414
|
+
editorProps: {
|
|
415
|
+
attributes: {
|
|
416
|
+
role: 'textbox',
|
|
417
|
+
'aria-label': placeholder,
|
|
418
|
+
'aria-multiline': 'true',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
content: defaultValue || controlledValue || '',
|
|
422
|
+
editable: !disabled && !readOnly,
|
|
423
|
+
onUpdate: ({ editor: e }: { editor: { getHTML: () => string } }) => {
|
|
424
|
+
const html = e.getHTML();
|
|
425
|
+
setValue(html);
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
: null;
|
|
429
|
+
|
|
430
|
+
// Sync controlled value to TipTap
|
|
431
|
+
React.useEffect(() => {
|
|
432
|
+
if (tiptapEditor && controlledValue !== undefined) {
|
|
433
|
+
const currentContent = tiptapEditor.getHTML();
|
|
434
|
+
if (currentContent !== controlledValue) {
|
|
435
|
+
tiptapEditor.commands.setContent(controlledValue, false);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}, [controlledValue, tiptapEditor]);
|
|
439
|
+
|
|
440
|
+
// Update editable state
|
|
441
|
+
React.useEffect(() => {
|
|
442
|
+
if (tiptapEditor) {
|
|
443
|
+
tiptapEditor.setEditable(!disabled && !readOnly);
|
|
444
|
+
}
|
|
445
|
+
}, [tiptapEditor, disabled, readOnly]);
|
|
446
|
+
|
|
447
|
+
// Auto-save
|
|
448
|
+
React.useEffect(() => {
|
|
449
|
+
if (!onAutoSave || !value) return;
|
|
450
|
+
|
|
451
|
+
const timer = setTimeout(() => {
|
|
452
|
+
setSaveStatus('saving');
|
|
453
|
+
try {
|
|
454
|
+
onAutoSave(value);
|
|
455
|
+
setSaveStatus('saved');
|
|
456
|
+
} catch {
|
|
457
|
+
setSaveStatus('error');
|
|
458
|
+
}
|
|
459
|
+
}, autoSaveInterval);
|
|
460
|
+
|
|
461
|
+
return () => clearTimeout(timer);
|
|
462
|
+
}, [value, onAutoSave, autoSaveInterval]);
|
|
463
|
+
|
|
464
|
+
const toggleFormat = React.useCallback(
|
|
465
|
+
(format: EditorFormat) => {
|
|
466
|
+
if (disabled || readOnly) return;
|
|
467
|
+
|
|
468
|
+
if (tiptapEditor) {
|
|
469
|
+
switch (format) {
|
|
470
|
+
case 'bold':
|
|
471
|
+
tiptapEditor.chain().focus().toggleBold().run();
|
|
472
|
+
break;
|
|
473
|
+
case 'italic':
|
|
474
|
+
tiptapEditor.chain().focus().toggleItalic().run();
|
|
475
|
+
break;
|
|
476
|
+
case 'strikethrough':
|
|
477
|
+
tiptapEditor.chain().focus().toggleStrike().run();
|
|
478
|
+
break;
|
|
479
|
+
case 'code':
|
|
480
|
+
tiptapEditor.chain().focus().toggleCode().run();
|
|
481
|
+
break;
|
|
482
|
+
case 'bulletList':
|
|
483
|
+
tiptapEditor.chain().focus().toggleBulletList().run();
|
|
484
|
+
break;
|
|
485
|
+
case 'orderedList':
|
|
486
|
+
tiptapEditor.chain().focus().toggleOrderedList().run();
|
|
487
|
+
break;
|
|
488
|
+
case 'heading1':
|
|
489
|
+
tiptapEditor.chain().focus().toggleHeading({ level: 1 }).run();
|
|
490
|
+
break;
|
|
491
|
+
case 'heading2':
|
|
492
|
+
tiptapEditor.chain().focus().toggleHeading({ level: 2 }).run();
|
|
493
|
+
break;
|
|
494
|
+
case 'heading3':
|
|
495
|
+
tiptapEditor.chain().focus().toggleHeading({ level: 3 }).run();
|
|
496
|
+
break;
|
|
497
|
+
case 'blockquote':
|
|
498
|
+
tiptapEditor.chain().focus().toggleBlockquote().run();
|
|
499
|
+
break;
|
|
500
|
+
case 'undo':
|
|
501
|
+
tiptapEditor.chain().focus().undo().run();
|
|
502
|
+
break;
|
|
503
|
+
case 'redo':
|
|
504
|
+
tiptapEditor.chain().focus().redo().run();
|
|
505
|
+
break;
|
|
506
|
+
case 'link': {
|
|
507
|
+
const previousUrl = tiptapEditor.getAttributes('link').href;
|
|
508
|
+
if (previousUrl) {
|
|
509
|
+
tiptapEditor.chain().focus().unsetLink().run();
|
|
510
|
+
} else {
|
|
511
|
+
const url = window.prompt('Enter URL');
|
|
512
|
+
if (url) {
|
|
513
|
+
tiptapEditor.chain().focus().setLink({ href: url }).run();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} else if (contentRef.current) {
|
|
520
|
+
applyMarkdownFormat(format, contentRef.current, setValue);
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
[disabled, readOnly, tiptapEditor, setValue],
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const isFormatActive = React.useCallback(
|
|
527
|
+
(format: EditorFormat): boolean => {
|
|
528
|
+
if (!tiptapEditor) return false;
|
|
529
|
+
switch (format) {
|
|
530
|
+
case 'bold':
|
|
531
|
+
return tiptapEditor.isActive('bold');
|
|
532
|
+
case 'italic':
|
|
533
|
+
return tiptapEditor.isActive('italic');
|
|
534
|
+
case 'strikethrough':
|
|
535
|
+
return tiptapEditor.isActive('strike');
|
|
536
|
+
case 'code':
|
|
537
|
+
return tiptapEditor.isActive('code');
|
|
538
|
+
case 'bulletList':
|
|
539
|
+
return tiptapEditor.isActive('bulletList');
|
|
540
|
+
case 'orderedList':
|
|
541
|
+
return tiptapEditor.isActive('orderedList');
|
|
542
|
+
case 'heading1':
|
|
543
|
+
return tiptapEditor.isActive('heading', { level: 1 });
|
|
544
|
+
case 'heading2':
|
|
545
|
+
return tiptapEditor.isActive('heading', { level: 2 });
|
|
546
|
+
case 'heading3':
|
|
547
|
+
return tiptapEditor.isActive('heading', { level: 3 });
|
|
548
|
+
case 'blockquote':
|
|
549
|
+
return tiptapEditor.isActive('blockquote');
|
|
550
|
+
case 'link':
|
|
551
|
+
return tiptapEditor.isActive('link');
|
|
552
|
+
case 'undo':
|
|
553
|
+
case 'redo':
|
|
554
|
+
return false; // Actions don't have active state
|
|
555
|
+
default:
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
[tiptapEditor],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const wordCount = React.useMemo(() => {
|
|
563
|
+
if (tiptapEditor) {
|
|
564
|
+
const text = tiptapEditor.getText?.() ?? '';
|
|
565
|
+
return countWords(text);
|
|
566
|
+
}
|
|
567
|
+
return countWords(value);
|
|
568
|
+
}, [value, tiptapEditor]);
|
|
569
|
+
|
|
570
|
+
const charCount = React.useMemo(() => {
|
|
571
|
+
if (tiptapEditor) {
|
|
572
|
+
return (tiptapEditor.getText?.() ?? '').length;
|
|
573
|
+
}
|
|
574
|
+
return value.length;
|
|
575
|
+
}, [value, tiptapEditor]);
|
|
576
|
+
|
|
577
|
+
const contextValue: EditorContextValue = {
|
|
578
|
+
value,
|
|
579
|
+
setValue,
|
|
580
|
+
placeholder,
|
|
581
|
+
disabled,
|
|
582
|
+
readOnly,
|
|
583
|
+
formats,
|
|
584
|
+
editor: tiptapEditor,
|
|
585
|
+
mode,
|
|
586
|
+
size,
|
|
587
|
+
maxLength,
|
|
588
|
+
wordCount,
|
|
589
|
+
charCount,
|
|
590
|
+
toggleFormat,
|
|
591
|
+
isFormatActive,
|
|
592
|
+
saveStatus,
|
|
593
|
+
contentRef,
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const classes = [
|
|
597
|
+
styles.editor,
|
|
598
|
+
disabled && styles.disabled,
|
|
599
|
+
readOnly && styles.readOnly,
|
|
600
|
+
className,
|
|
601
|
+
].filter(Boolean).join(' ');
|
|
602
|
+
|
|
603
|
+
const hasCustomChildren = children !== undefined;
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
<EditorContext.Provider value={contextValue}>
|
|
607
|
+
<div
|
|
608
|
+
{...htmlProps}
|
|
609
|
+
className={classes}
|
|
610
|
+
data-disabled={disabled || undefined}
|
|
611
|
+
data-readonly={readOnly || undefined}
|
|
612
|
+
data-size={size}
|
|
613
|
+
>
|
|
614
|
+
{hasCustomChildren ? (
|
|
615
|
+
children
|
|
616
|
+
) : (
|
|
617
|
+
<>
|
|
618
|
+
{toolbar && (
|
|
619
|
+
<EditorToolbar>
|
|
620
|
+
<EditorToolbarGroup aria-label="Text formatting">
|
|
621
|
+
{formats.map((f) => (
|
|
622
|
+
<EditorToolbarButton key={f} format={f} />
|
|
623
|
+
))}
|
|
624
|
+
</EditorToolbarGroup>
|
|
625
|
+
</EditorToolbar>
|
|
626
|
+
)}
|
|
627
|
+
<EditorContentArea />
|
|
628
|
+
{statusBar && <EditorStatusBar showWordCount showCharCount />}
|
|
629
|
+
</>
|
|
630
|
+
)}
|
|
631
|
+
</div>
|
|
632
|
+
</EditorContext.Provider>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function EditorToolbar({ children, className }: EditorToolbarProps) {
|
|
637
|
+
const classes = [styles.toolbar, className].filter(Boolean).join(' ');
|
|
638
|
+
return (
|
|
639
|
+
<div className={classes} role="toolbar" aria-label="Editor formatting">
|
|
640
|
+
{children}
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function EditorToolbarGroup({ children, 'aria-label': ariaLabel, className }: EditorToolbarGroupProps) {
|
|
646
|
+
const classes = [styles.toolbarGroup, className].filter(Boolean).join(' ');
|
|
647
|
+
return (
|
|
648
|
+
<div className={classes} role="group" aria-label={ariaLabel}>
|
|
649
|
+
{children}
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function EditorToolbarButton({ format, className }: EditorToolbarButtonProps) {
|
|
655
|
+
const { toggleFormat, isFormatActive, disabled, readOnly, editor, mode } = useEditorContext();
|
|
656
|
+
const meta = FORMAT_META[format];
|
|
657
|
+
const isAction = ACTION_FORMATS.has(format);
|
|
658
|
+
const active = isAction ? false : isFormatActive(format);
|
|
659
|
+
const IconComponent = meta.icon;
|
|
660
|
+
|
|
661
|
+
// Action buttons (undo/redo) have special disable logic
|
|
662
|
+
let isDisabled = disabled || readOnly;
|
|
663
|
+
if (isAction && !isDisabled) {
|
|
664
|
+
if (mode === 'markdown') {
|
|
665
|
+
// Undo/redo in textarea mode is handled natively by the browser
|
|
666
|
+
isDisabled = true;
|
|
667
|
+
} else if (editor) {
|
|
668
|
+
isDisabled = format === 'undo' ? !editor.can().undo() : !editor.can().redo();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const classes = [
|
|
673
|
+
styles.toolbarButton,
|
|
674
|
+
active && styles.toolbarButtonActive,
|
|
675
|
+
className,
|
|
676
|
+
].filter(Boolean).join(' ');
|
|
677
|
+
|
|
678
|
+
return (
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
className={classes}
|
|
682
|
+
onClick={() => toggleFormat(format)}
|
|
683
|
+
disabled={isDisabled}
|
|
684
|
+
aria-label={meta.label}
|
|
685
|
+
title={`${meta.label} (${meta.shortcut})`}
|
|
686
|
+
{...(isAction ? {} : { 'aria-pressed': active })}
|
|
687
|
+
>
|
|
688
|
+
<IconComponent size={16} weight={active ? 'bold' : 'regular'} />
|
|
689
|
+
</button>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function EditorSeparator({ className }: EditorSeparatorProps) {
|
|
694
|
+
const classes = [styles.separator, className].filter(Boolean).join(' ');
|
|
695
|
+
return <div className={classes} role="separator" aria-orientation="vertical" />;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function EditorStatusIndicator({ status: statusOverride, labels, className }: EditorStatusIndicatorProps) {
|
|
699
|
+
const { saveStatus } = useEditorContext();
|
|
700
|
+
const status = statusOverride ?? saveStatus;
|
|
701
|
+
const mergedLabels = { ...DEFAULT_STATUS_LABELS, ...labels };
|
|
702
|
+
const label = mergedLabels[status];
|
|
703
|
+
|
|
704
|
+
if (!label) return null;
|
|
705
|
+
|
|
706
|
+
const classes = [
|
|
707
|
+
styles.statusIndicator,
|
|
708
|
+
status === 'error' && styles.statusError,
|
|
709
|
+
className,
|
|
710
|
+
].filter(Boolean).join(' ');
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<span className={classes} aria-live="polite" role="status">
|
|
714
|
+
{label}
|
|
715
|
+
</span>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function EditorContentArea({ className }: EditorContentProps) {
|
|
720
|
+
const { value, setValue, placeholder, disabled, readOnly, editor, mode, contentRef } =
|
|
721
|
+
useEditorContext();
|
|
722
|
+
|
|
723
|
+
if (mode === 'rich' && editor && _EditorContent) {
|
|
724
|
+
const TipTapContent = _EditorContent;
|
|
725
|
+
const classes = [styles.content, styles.contentRich, className].filter(Boolean).join(' ');
|
|
726
|
+
return (
|
|
727
|
+
<div className={classes} data-placeholder={placeholder}>
|
|
728
|
+
<TipTapContent editor={editor} />
|
|
729
|
+
</div>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Textarea fallback for markdown mode
|
|
734
|
+
const classes = [styles.content, className].filter(Boolean).join(' ');
|
|
735
|
+
return (
|
|
736
|
+
<div className={classes}>
|
|
737
|
+
<textarea
|
|
738
|
+
ref={contentRef}
|
|
739
|
+
className={styles.contentTextarea}
|
|
740
|
+
value={value}
|
|
741
|
+
onChange={(e) => setValue(e.target.value)}
|
|
742
|
+
placeholder={placeholder}
|
|
743
|
+
disabled={disabled}
|
|
744
|
+
readOnly={readOnly}
|
|
745
|
+
aria-label={placeholder}
|
|
746
|
+
/>
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function EditorStatusBar({ showWordCount = true, showCharCount = true, className }: EditorStatusBarProps) {
|
|
752
|
+
const { wordCount, charCount, maxLength } = useEditorContext();
|
|
753
|
+
|
|
754
|
+
const classes = [styles.statusBar, className].filter(Boolean).join(' ');
|
|
755
|
+
|
|
756
|
+
const isOverLimit = maxLength !== undefined && charCount > maxLength;
|
|
757
|
+
const isNearLimit = maxLength !== undefined && !isOverLimit && charCount >= maxLength * 0.9;
|
|
758
|
+
|
|
759
|
+
const charLimitClasses = [
|
|
760
|
+
styles.statusBarItem,
|
|
761
|
+
isNearLimit && styles.statusBarItemWarning,
|
|
762
|
+
isOverLimit && styles.statusBarItemError,
|
|
763
|
+
].filter(Boolean).join(' ');
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<div className={classes} aria-label="Editor statistics">
|
|
767
|
+
<div className={styles.statusBarLeft} />
|
|
768
|
+
<div className={styles.statusBarRight}>
|
|
769
|
+
{showWordCount && (
|
|
770
|
+
<span className={styles.statusBarItem}>
|
|
771
|
+
{wordCount} {wordCount === 1 ? 'Word' : 'Words'}
|
|
772
|
+
</span>
|
|
773
|
+
)}
|
|
774
|
+
{showWordCount && showCharCount && (
|
|
775
|
+
<EditorSeparator />
|
|
776
|
+
)}
|
|
777
|
+
{showCharCount && (
|
|
778
|
+
<span className={charLimitClasses}>
|
|
779
|
+
{maxLength !== undefined
|
|
780
|
+
? `${charCount} / ${maxLength}`
|
|
781
|
+
: `${charCount} ${charCount === 1 ? 'Character' : 'Characters'}`
|
|
782
|
+
}
|
|
783
|
+
</span>
|
|
784
|
+
)}
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ============================================
|
|
791
|
+
// Export compound component
|
|
792
|
+
// ============================================
|
|
793
|
+
|
|
794
|
+
export const Editor = Object.assign(EditorRoot, {
|
|
795
|
+
Toolbar: EditorToolbar,
|
|
796
|
+
ToolbarGroup: EditorToolbarGroup,
|
|
797
|
+
ToolbarButton: EditorToolbarButton,
|
|
798
|
+
Separator: EditorSeparator,
|
|
799
|
+
StatusIndicator: EditorStatusIndicator,
|
|
800
|
+
Content: EditorContentArea,
|
|
801
|
+
StatusBar: EditorStatusBar,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
export {
|
|
805
|
+
EditorRoot,
|
|
806
|
+
EditorToolbar,
|
|
807
|
+
EditorToolbarGroup,
|
|
808
|
+
EditorToolbarButton,
|
|
809
|
+
EditorSeparator,
|
|
810
|
+
EditorStatusIndicator,
|
|
811
|
+
EditorContentArea,
|
|
812
|
+
EditorStatusBar,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
export { useEditorContext };
|