@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.
Files changed (50) hide show
  1. package/README.md +32 -24
  2. package/dist/assets/ui.css +304 -0
  3. package/dist/blocks/BlogEditor.block.d.ts +3 -0
  4. package/dist/blocks/BlogEditor.block.d.ts.map +1 -0
  5. package/dist/components/Editor/Editor.module.scss.cjs +57 -0
  6. package/dist/components/Editor/Editor.module.scss.cjs.map +1 -0
  7. package/dist/components/Editor/Editor.module.scss.js +57 -0
  8. package/dist/components/Editor/Editor.module.scss.js.map +1 -0
  9. package/dist/components/Editor/index.cjs +548 -0
  10. package/dist/components/Editor/index.cjs.map +1 -0
  11. package/dist/components/Editor/index.d.ts +107 -0
  12. package/dist/components/Editor/index.d.ts.map +1 -0
  13. package/dist/components/Editor/index.js +531 -0
  14. package/dist/components/Editor/index.js.map +1 -0
  15. package/dist/components/Sidebar/index.cjs +6 -11
  16. package/dist/components/Sidebar/index.cjs.map +1 -1
  17. package/dist/components/Sidebar/index.d.ts.map +1 -1
  18. package/dist/components/Sidebar/index.js +6 -11
  19. package/dist/components/Sidebar/index.js.map +1 -1
  20. package/dist/components/Theme/index.cjs +86 -1
  21. package/dist/components/Theme/index.cjs.map +1 -1
  22. package/dist/components/Theme/index.d.ts +44 -1
  23. package/dist/components/Theme/index.d.ts.map +1 -1
  24. package/dist/components/Theme/index.js +86 -1
  25. package/dist/components/Theme/index.js.map +1 -1
  26. package/dist/index.cjs +24 -0
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +25 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/utils/keyboard-shortcuts.cjs +295 -0
  33. package/dist/utils/keyboard-shortcuts.cjs.map +1 -0
  34. package/dist/utils/keyboard-shortcuts.d.ts +293 -0
  35. package/dist/utils/keyboard-shortcuts.d.ts.map +1 -0
  36. package/dist/utils/keyboard-shortcuts.js +295 -0
  37. package/dist/utils/keyboard-shortcuts.js.map +1 -0
  38. package/fragments.json +1 -1
  39. package/package.json +32 -3
  40. package/src/blocks/BlogEditor.block.ts +34 -0
  41. package/src/components/Editor/Editor.fragment.tsx +322 -0
  42. package/src/components/Editor/Editor.module.scss +333 -0
  43. package/src/components/Editor/Editor.test.tsx +174 -0
  44. package/src/components/Editor/index.tsx +815 -0
  45. package/src/components/Sidebar/index.tsx +7 -14
  46. package/src/components/Theme/index.tsx +168 -1
  47. package/src/index.ts +49 -0
  48. package/src/tokens/_seeds.scss +20 -0
  49. package/src/utils/keyboard-shortcuts.test.ts +357 -0
  50. 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 };