@jhits/plugin-newsletter 0.0.10 → 0.0.11

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 (57) hide show
  1. package/package.json +3 -2
  2. package/src/api/email-utils.ts +165 -0
  3. package/src/api/handler.ts +28 -0
  4. package/src/api/handlers/index.ts +44 -0
  5. package/src/api/handlers/newsletters.ts +332 -0
  6. package/src/api/handlers/send-newsletter.ts +288 -0
  7. package/src/api/handlers/settings.ts +403 -0
  8. package/src/api/handlers/subscribers.ts +152 -0
  9. package/src/api/handlers/upload.ts +47 -0
  10. package/src/api/handlers/welcome-email.ts +210 -0
  11. package/src/api/router.ts +166 -0
  12. package/src/index.server.ts +12 -0
  13. package/src/index.tsx +353 -0
  14. package/src/index.tsx.patch +98 -0
  15. package/src/init.tsx +72 -0
  16. package/src/lib/blocks/BlockRenderer.tsx +125 -0
  17. package/src/lib/email/EmailRenderer.tsx +420 -0
  18. package/src/lib/email/index.ts +6 -0
  19. package/src/lib/i18n.ts +82 -0
  20. package/src/lib/mappers/apiMapper.ts +57 -0
  21. package/src/lib/utils/blockHelpers.ts +71 -0
  22. package/src/lib/utils/slugify.ts +43 -0
  23. package/src/registry/BlockRegistry.ts +53 -0
  24. package/src/registry/index.ts +5 -0
  25. package/src/state/EditorContext.tsx +278 -0
  26. package/src/state/index.ts +10 -0
  27. package/src/state/reducer.ts +561 -0
  28. package/src/state/types.ts +154 -0
  29. package/src/types/block.ts +275 -0
  30. package/src/types/newsletter.ts +152 -0
  31. package/src/types/registry.ts +14 -0
  32. package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
  33. package/src/views/CanvasEditor/CanvasEditorView.tsx +343 -0
  34. package/src/views/CanvasEditor/EditorBody.tsx +95 -0
  35. package/src/views/CanvasEditor/EditorHeader.tsx +255 -0
  36. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
  37. package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
  38. package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
  39. package/src/views/CanvasEditor/components/EditorSidebar.tsx +139 -0
  40. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  41. package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
  42. package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
  43. package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
  44. package/src/views/CanvasEditor/components/index.ts +16 -0
  45. package/src/views/CanvasEditor/hooks/index.ts +7 -0
  46. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
  47. package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +73 -0
  48. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
  49. package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
  50. package/src/views/CanvasEditor/index.ts +12 -0
  51. package/src/views/NewsletterEditor.tsx +42 -0
  52. package/src/views/NewsletterManager.tsx +483 -0
  53. package/src/views/SettingsView.tsx +216 -0
  54. package/src/views/SubscribersView.tsx +269 -0
  55. package/src/views/components/SendNewsletterModal.tsx +322 -0
  56. package/src/views/components/SmtpSettingsModal.tsx +433 -0
  57. package/src/views/components/TestEmailModal.tsx +268 -0
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Library, Image as ImageIcon, LayoutTemplate, Type, Box } from 'lucide-react';
5
+ import { LibraryItem, CustomBlockItem } from './index';
6
+ import type { BlockTypeDefinition } from '../../../types/block';
7
+
8
+ export interface EditorLibraryProps {
9
+ registeredBlocks: BlockTypeDefinition[];
10
+ onAddBlock: (type: string) => void;
11
+ }
12
+
13
+ export function EditorLibrary({ registeredBlocks, onAddBlock }: EditorLibraryProps) {
14
+ // Get all registered blocks (excluding Hero block for newsletters)
15
+ const allBlocks = registeredBlocks.filter(block => block.type !== 'hero');
16
+ const textBlocks = allBlocks.filter(block => block.category === 'text');
17
+ const customBlocks = allBlocks.filter(block => block.category === 'custom');
18
+ const mediaBlocks = allBlocks.filter(block => block.category === 'media');
19
+ const layoutBlocks = allBlocks.filter(block => block.category === 'layout');
20
+
21
+ return (
22
+ <div className="p-6 w-72 min-w-0 max-w-full">
23
+ {/* Text Blocks */}
24
+ {textBlocks.length > 0 && (
25
+ <div className="mb-10">
26
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Text</h3>
27
+ <div className="grid grid-cols-2 gap-3">
28
+ {textBlocks.map((block) => {
29
+ const IconComponent = block.icon || block.components.Icon || Type;
30
+ return (
31
+ <LibraryItem
32
+ key={block.type}
33
+ icon={<IconComponent size={16} />}
34
+ label={block.name}
35
+ blockType={block.type}
36
+ description={block.description}
37
+ onAddBlock={onAddBlock}
38
+ />
39
+ );
40
+ })}
41
+ </div>
42
+ </div>
43
+ )}
44
+
45
+ {/* Media Blocks */}
46
+ {mediaBlocks.length > 0 && (
47
+ <div className="mb-10">
48
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Media</h3>
49
+ <div className="grid grid-cols-2 gap-3">
50
+ {mediaBlocks.map((block) => {
51
+ const IconComponent = block.icon || block.components.Icon || ImageIcon;
52
+ return (
53
+ <LibraryItem
54
+ key={block.type}
55
+ icon={<IconComponent size={16} />}
56
+ label={block.name}
57
+ blockType={block.type}
58
+ description={block.description}
59
+ onAddBlock={onAddBlock}
60
+ />
61
+ );
62
+ })}
63
+ </div>
64
+ </div>
65
+ )}
66
+
67
+ {/* Layout Blocks */}
68
+ {layoutBlocks.length > 0 && (
69
+ <div className="mb-10">
70
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Layout</h3>
71
+ <div className="grid grid-cols-2 gap-3">
72
+ {layoutBlocks.map((block) => {
73
+ const IconComponent = block.icon || block.components.Icon || LayoutTemplate;
74
+ return (
75
+ <LibraryItem
76
+ key={block.type}
77
+ icon={<IconComponent size={16} />}
78
+ label={block.name}
79
+ blockType={block.type}
80
+ description={block.description}
81
+ onAddBlock={onAddBlock}
82
+ />
83
+ );
84
+ })}
85
+ </div>
86
+ </div>
87
+ )}
88
+
89
+ {/* Custom Blocks */}
90
+ {customBlocks.length > 0 && (
91
+ <div>
92
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Custom Blocks</h3>
93
+ <div className="space-y-3">
94
+ {customBlocks.map((block) => {
95
+ const IconComponent = block.icon || block.components.Icon || Box;
96
+ return (
97
+ <CustomBlockItem
98
+ key={block.type}
99
+ blockType={block.type}
100
+ name={block.name}
101
+ description={block.description}
102
+ icon={<IconComponent size={16} />}
103
+ onAddBlock={onAddBlock}
104
+ />
105
+ );
106
+ })}
107
+ </div>
108
+ </div>
109
+ )}
110
+
111
+ {allBlocks.length === 0 && (
112
+ <div className="text-center py-12 text-neutral-400 dark:text-neutral-500">
113
+ <Library size={32} className="mx-auto mb-4 opacity-50" />
114
+ <p className="text-sm">No blocks available</p>
115
+ <p className="text-xs mt-2">Register blocks in your app configuration</p>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,139 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Mail, Globe, Users } from 'lucide-react';
5
+ import type { NewsletterMetadata } from '../../../types/newsletter';
6
+
7
+ export interface EditorSidebarProps {
8
+ metadata: NewsletterMetadata;
9
+ status: string;
10
+ onMetadataUpdate: (metadata: Partial<NewsletterMetadata>) => void;
11
+ defaultLanguage?: string;
12
+ isWelcomeEmail?: boolean;
13
+ languages?: string[];
14
+ currentLanguage?: string;
15
+ onLanguageChange?: (language: string) => void;
16
+ }
17
+
18
+ export function EditorSidebar({
19
+ metadata,
20
+ status,
21
+ onMetadataUpdate,
22
+ defaultLanguage = 'en',
23
+ isWelcomeEmail = false,
24
+ languages = ['en'],
25
+ currentLanguage = 'en',
26
+ onLanguageChange,
27
+ }: EditorSidebarProps) {
28
+ return (
29
+ <div className="p-8 w-80 min-w-0 max-w-full space-y-12 overflow-y-auto max-h-full">
30
+ {/* Newsletter Settings */}
31
+ <section>
32
+ <div className="flex items-center gap-3 mb-6">
33
+ <Mail size={14} className="text-neutral-500 dark:text-neutral-400" />
34
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
35
+ Newsletter Settings
36
+ </label>
37
+ </div>
38
+ <div className="space-y-4">
39
+ <div>
40
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
41
+ Email Subject
42
+ </label>
43
+ <input
44
+ type="text"
45
+ value={metadata.subject || ''}
46
+ onChange={(e) => onMetadataUpdate({ subject: e.target.value })}
47
+ placeholder="Enter email subject"
48
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
49
+ />
50
+ <p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
51
+ {metadata.subject?.length || 0} / 100 characters
52
+ </p>
53
+ </div>
54
+ <div>
55
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
56
+ Preview Text
57
+ </label>
58
+ <textarea
59
+ value={metadata.previewText || ''}
60
+ onChange={(e) => onMetadataUpdate({ previewText: e.target.value })}
61
+ placeholder="Preview text shown in email clients"
62
+ rows={3}
63
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text resize-none"
64
+ />
65
+ <p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
66
+ {metadata.previewText?.length || 0} / 150 characters
67
+ </p>
68
+ </div>
69
+ </div>
70
+ </section>
71
+
72
+ {/* Recipient Filter */}
73
+ <section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
74
+ <div className="flex items-center gap-3 mb-6">
75
+ <Users size={14} className="text-neutral-500 dark:text-neutral-400" />
76
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
77
+ Recipients
78
+ </label>
79
+ </div>
80
+ <div className="space-y-4">
81
+ <div>
82
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
83
+ Filter Type
84
+ </label>
85
+ <select
86
+ value={metadata.recipientFilter?.type || 'all'}
87
+ onChange={(e) => onMetadataUpdate({
88
+ recipientFilter: {
89
+ type: e.target.value as 'all' | 'language' | 'custom',
90
+ value: metadata.recipientFilter?.value,
91
+ }
92
+ })}
93
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
94
+ >
95
+ <option value="all">All Subscribers</option>
96
+ <option value="language">By Language</option>
97
+ <option value="custom">Custom Filter</option>
98
+ </select>
99
+ </div>
100
+ {metadata.recipientFilter?.type === 'language' && (
101
+ <div>
102
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
103
+ Language
104
+ </label>
105
+ <select
106
+ value={metadata.recipientFilter?.value || 'en'}
107
+ onChange={(e) => onMetadataUpdate({
108
+ recipientFilter: {
109
+ type: 'language',
110
+ value: e.target.value,
111
+ }
112
+ })}
113
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
114
+ >
115
+ <option value="en">English</option>
116
+ <option value="nl">Dutch</option>
117
+ <option value="sv">Swedish</option>
118
+ </select>
119
+ </div>
120
+ )}
121
+ </div>
122
+ </section>
123
+
124
+ {/* Newsletter Info */}
125
+ <section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
126
+ <div className="space-y-3">
127
+ <div>
128
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
129
+ Status
130
+ </label>
131
+ <div className="px-3 py-2 text-xs bg-dashboard-bg border border-dashboard-border rounded-lg">
132
+ <span className="uppercase font-bold">{status}</span>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </section>
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { AlertTriangle, X } from 'lucide-react';
5
+
6
+ export interface ErrorBannerProps {
7
+ error: string | null;
8
+ onDismiss: () => void;
9
+ }
10
+
11
+ export function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
12
+ if (!error) return null;
13
+
14
+ return (
15
+ <div className="bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 px-6 py-3 flex items-center justify-between">
16
+ <div className="flex items-center gap-3 flex-1">
17
+ <AlertTriangle className="text-red-600 dark:text-red-400 flex-shrink-0" size={20} />
18
+ <p className="text-red-800 dark:text-red-300 text-sm font-medium">
19
+ {error}
20
+ </p>
21
+ </div>
22
+ <button
23
+ onClick={onDismiss}
24
+ className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 transition-colors"
25
+ aria-label="Dismiss error"
26
+ >
27
+ <X size={18} />
28
+ </button>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+
5
+ export interface LibraryItemProps {
6
+ icon: React.ReactNode;
7
+ label: string;
8
+ blockType: string;
9
+ description?: string;
10
+ onAddBlock?: (blockType: string) => void;
11
+ }
12
+
13
+ export function LibraryItem({
14
+ icon,
15
+ label,
16
+ blockType,
17
+ description,
18
+ onAddBlock
19
+ }: LibraryItemProps) {
20
+ const [hasDragged, setHasDragged] = useState(false);
21
+ const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
22
+
23
+ const handleDragStart = (e: React.DragEvent) => {
24
+ e.dataTransfer.setData('block-type', blockType);
25
+ e.dataTransfer.effectAllowed = 'move';
26
+ setHasDragged(true);
27
+ };
28
+
29
+ const handleMouseDown = (e: React.MouseEvent) => {
30
+ mouseDownRef.current = { x: e.clientX, y: e.clientY };
31
+ setHasDragged(false);
32
+ };
33
+
34
+ const handleMouseMove = (e: React.MouseEvent) => {
35
+ if (mouseDownRef.current) {
36
+ const dx = Math.abs(e.clientX - mouseDownRef.current.x);
37
+ const dy = Math.abs(e.clientY - mouseDownRef.current.y);
38
+ if (dx > 5 || dy > 5) {
39
+ setHasDragged(true);
40
+ }
41
+ }
42
+ };
43
+
44
+ const handleClick = (e: React.MouseEvent) => {
45
+ if (!hasDragged && onAddBlock) {
46
+ e.preventDefault();
47
+ e.stopPropagation();
48
+ onAddBlock(blockType);
49
+ }
50
+ mouseDownRef.current = null;
51
+ setTimeout(() => setHasDragged(false), 100);
52
+ };
53
+
54
+ return (
55
+ <div
56
+ draggable
57
+ onDragStart={handleDragStart}
58
+ onMouseDown={handleMouseDown}
59
+ onMouseMove={handleMouseMove}
60
+ onClick={handleClick}
61
+ className="flex flex-col items-center justify-center p-5 rounded-2xl border border-dashboard-border bg-dashboard-card hover:border-primary hover:shadow-xl hover:shadow-primary/5 transition-all cursor-pointer group"
62
+ >
63
+ <div className="text-neutral-400 dark:text-neutral-500 group-hover:text-primary dark:group-hover:text-primary mb-3 transition-colors duration-300">
64
+ {React.cloneElement(icon as React.ReactElement, { strokeWidth: 1.5 } as any)}
65
+ </div>
66
+ <span className="text-[9px] font-black uppercase tracking-[0.15em] text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
67
+ {label}
68
+ </span>
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useCallback } from 'react';
4
+ import type { useSlashCommand } from '../hooks/useSlashCommand';
5
+
6
+ export interface SlashCommandDetectorProps {
7
+ children: React.ReactElement;
8
+ blockId: string;
9
+ blockIndex: number;
10
+ blockType: string;
11
+ content: string;
12
+ onContentChange: (content: string) => void;
13
+ slashCommand?: ReturnType<typeof useSlashCommand>;
14
+ onAddBlockBelow?: (blockType: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Detects "/" input and shows slash command menu
19
+ * Wraps block edit components to add slash command functionality
20
+ */
21
+ export function SlashCommandDetector({
22
+ children,
23
+ blockId,
24
+ blockIndex,
25
+ blockType,
26
+ content,
27
+ onContentChange,
28
+ slashCommand,
29
+ onAddBlockBelow,
30
+ }: SlashCommandDetectorProps) {
31
+ const containerRef = useRef<HTMLDivElement>(null);
32
+ const lastContentRef = useRef<string>('');
33
+ const slashIndexRef = useRef<number>(-1);
34
+
35
+ // Listen for input events on contentEditable elements
36
+ useEffect(() => {
37
+ if (!slashCommand || blockType !== 'paragraph') return;
38
+
39
+ const container = containerRef.current;
40
+ if (!container) return;
41
+
42
+ const handleInput = (e: Event) => {
43
+ const target = e.target as HTMLElement;
44
+ if (!target.isContentEditable) return;
45
+
46
+ const textContent = target.textContent || '';
47
+ const plainText = textContent.replace(/\u00A0/g, ' '); // Replace non-breaking spaces
48
+ const previousText = (lastContentRef.current || '').replace(/\u00A0/g, ' ');
49
+
50
+ // Only allow "/" when paragraph is completely empty (no content before "/")
51
+ const wasEmpty = previousText.trim().length === 0;
52
+ const isNowEmpty = plainText.trim().length === 0;
53
+
54
+ if (plainText.length > previousText.length) {
55
+ // Character was added
56
+ const lastChar = plainText[plainText.length - 1];
57
+
58
+ // Check if "/" was typed and paragraph was empty before
59
+ if (lastChar === '/' && wasEmpty && plainText === '/') {
60
+ slashIndexRef.current = 0;
61
+
62
+ // Get cursor position
63
+ const selection = window.getSelection();
64
+ if (selection && selection.rangeCount > 0) {
65
+ const range = selection.getRangeAt(0);
66
+ const rect = range.getBoundingClientRect();
67
+
68
+ slashCommand.openMenu({
69
+ top: rect.bottom + 5,
70
+ left: rect.left,
71
+ }, blockId);
72
+ }
73
+ } else if (slashCommand.isOpen && slashIndexRef.current >= 0) {
74
+ // Update query if menu is open (only if still starts with "/")
75
+ if (plainText.startsWith('/')) {
76
+ const query = plainText.slice(1);
77
+ slashCommand.updateQuery(query);
78
+ } else {
79
+ // If user typed something other than "/", close the menu
80
+ slashCommand.closeMenu();
81
+ slashIndexRef.current = -1;
82
+ }
83
+ } else if (!wasEmpty || (plainText.trim().length > 0 && plainText !== '/')) {
84
+ // If paragraph had content before or now has content that's not just "/", ensure menu is closed
85
+ if (slashCommand.isOpen) {
86
+ slashCommand.closeMenu();
87
+ slashIndexRef.current = -1;
88
+ }
89
+ }
90
+ } else if (plainText.length < previousText.length) {
91
+ // Content was deleted
92
+ if (slashCommand.isOpen) {
93
+ if (plainText === '/') {
94
+ // Still just "/", keep menu open
95
+ slashIndexRef.current = 0;
96
+ } else {
97
+ // No longer just "/", close menu
98
+ slashCommand.closeMenu();
99
+ slashIndexRef.current = -1;
100
+ }
101
+ }
102
+ }
103
+
104
+ lastContentRef.current = textContent; // Store original with non-breaking spaces
105
+ };
106
+
107
+ // Listen for input events on contentEditable elements within container
108
+ container.addEventListener('input', handleInput);
109
+
110
+ return () => {
111
+ container.removeEventListener('input', handleInput);
112
+ };
113
+ }, [blockType, slashCommand, blockId]);
114
+
115
+ // Handle keyboard events for slash command and Enter key
116
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
117
+ // Handle Enter key to create new paragraph when not in slash command mode
118
+ if (!slashCommand?.isOpen && e.key === 'Enter' && !e.shiftKey) {
119
+ const container = containerRef.current;
120
+ if (container) {
121
+ const contentEditable = container.querySelector('[contenteditable="true"]') as HTMLElement;
122
+ if (contentEditable && document.activeElement === contentEditable) {
123
+ const selection = window.getSelection();
124
+ if (selection && selection.rangeCount > 0) {
125
+ const range = selection.getRangeAt(0);
126
+ const textContent = contentEditable.textContent || '';
127
+ const plainText = textContent.replace(/\u00A0/g, ' ');
128
+
129
+ // Check if cursor is at the end of the content
130
+ if (range.startOffset === plainText.length && range.endOffset === plainText.length) {
131
+ // Create new paragraph below when Enter is pressed at end
132
+ e.preventDefault();
133
+ e.stopPropagation();
134
+ if (onAddBlockBelow) {
135
+ onAddBlockBelow('paragraph');
136
+ }
137
+ return;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ if (!slashCommand?.isOpen) return;
145
+
146
+ if (e.key === 'ArrowDown') {
147
+ e.preventDefault();
148
+ slashCommand.moveSelection('down');
149
+ } else if (e.key === 'ArrowUp') {
150
+ e.preventDefault();
151
+ slashCommand.moveSelection('up');
152
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ const selectedBlock = slashCommand.filteredBlocks[slashCommand.selectedIndex];
156
+ if (selectedBlock) {
157
+ // Don't clear content here - the block replacement will handle it
158
+ // Just replace the block with selected type (this will replace the current paragraph)
159
+ slashCommand.selectCurrent(blockId);
160
+ }
161
+ } else if (e.key === 'Escape') {
162
+ e.preventDefault();
163
+ e.stopPropagation();
164
+ // Clear the "/" from content - only clear if menu was open
165
+ if (slashCommand.isOpen) {
166
+ const container = containerRef.current;
167
+ if (container) {
168
+ const contentEditable = container.querySelector('[contenteditable="true"]') as HTMLElement;
169
+ if (contentEditable) {
170
+ const textContent = contentEditable.textContent || '';
171
+ const plainText = textContent.replace(/\u00A0/g, ' ');
172
+ // Only clear if it's just "/"
173
+ if (plainText === '/') {
174
+ contentEditable.textContent = '';
175
+ onContentChange('');
176
+ }
177
+ }
178
+ }
179
+ }
180
+ slashCommand.closeMenu();
181
+ slashIndexRef.current = -1;
182
+ }
183
+ }, [slashCommand, content, onContentChange, blockId, onAddBlockBelow]);
184
+
185
+ useEffect(() => {
186
+ // Always listen for keydown events (for both slash command and Enter key handling)
187
+ window.addEventListener('keydown', handleKeyDown);
188
+ return () => window.removeEventListener('keydown', handleKeyDown);
189
+ }, [handleKeyDown]);
190
+
191
+ return (
192
+ <div ref={containerRef}>
193
+ {children}
194
+ </div>
195
+ );
196
+ }