@jhits/plugin-newsletter 0.0.6 → 0.0.8
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/package.json +8 -9
- package/src/api/handler.ts +0 -693
- package/src/api/router.ts +0 -111
- package/src/index.server.ts +0 -12
- package/src/index.tsx +0 -313
- package/src/index.tsx.patch +0 -98
- package/src/init.tsx +0 -72
- package/src/lib/blocks/BlockRenderer.tsx +0 -125
- package/src/lib/email/EmailRenderer.tsx +0 -425
- package/src/lib/email/index.ts +0 -6
- package/src/lib/mappers/apiMapper.ts +0 -57
- package/src/lib/utils/blockHelpers.ts +0 -71
- package/src/lib/utils/slugify.ts +0 -43
- package/src/registry/BlockRegistry.ts +0 -53
- package/src/registry/index.ts +0 -5
- package/src/state/EditorContext.tsx +0 -279
- package/src/state/index.ts +0 -10
- package/src/state/reducer.ts +0 -561
- package/src/state/types.ts +0 -154
- package/src/types/block.ts +0 -275
- package/src/types/newsletter.ts +0 -151
- package/src/types/registry.ts +0 -14
- package/src/views/CanvasEditor/BlockWrapper.tsx +0 -143
- package/src/views/CanvasEditor/CanvasEditorView.tsx +0 -249
- package/src/views/CanvasEditor/EditorBody.tsx +0 -95
- package/src/views/CanvasEditor/EditorHeader.tsx +0 -139
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +0 -83
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +0 -674
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +0 -120
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +0 -156
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +0 -31
- package/src/views/CanvasEditor/components/LibraryItem.tsx +0 -71
- package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +0 -196
- package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +0 -131
- package/src/views/CanvasEditor/components/index.ts +0 -16
- package/src/views/CanvasEditor/hooks/index.ts +0 -7
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +0 -136
- package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +0 -34
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +0 -54
- package/src/views/CanvasEditor/hooks/useSlashCommand.ts +0 -106
- package/src/views/CanvasEditor/index.ts +0 -12
- package/src/views/NewsletterEditor.tsx +0 -38
- package/src/views/NewsletterManager.tsx +0 -240
- package/src/views/SettingsView.tsx +0 -216
- package/src/views/SubscribersView.tsx +0 -269
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
slug: string;
|
|
9
|
-
metadata: NewsletterMetadata;
|
|
10
|
-
status: string;
|
|
11
|
-
onMetadataUpdate: (metadata: Partial<NewsletterMetadata>) => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function EditorSidebar({
|
|
15
|
-
slug,
|
|
16
|
-
metadata,
|
|
17
|
-
status,
|
|
18
|
-
onMetadataUpdate,
|
|
19
|
-
}: EditorSidebarProps) {
|
|
20
|
-
return (
|
|
21
|
-
<div className="p-8 w-80 min-w-0 max-w-full space-y-12 overflow-y-auto max-h-full">
|
|
22
|
-
{/* Newsletter Settings */}
|
|
23
|
-
<section>
|
|
24
|
-
<div className="flex items-center gap-3 mb-6">
|
|
25
|
-
<Mail size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
26
|
-
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
27
|
-
Newsletter Settings
|
|
28
|
-
</label>
|
|
29
|
-
</div>
|
|
30
|
-
<div className="space-y-4">
|
|
31
|
-
<div>
|
|
32
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
33
|
-
Email Subject
|
|
34
|
-
</label>
|
|
35
|
-
<input
|
|
36
|
-
type="text"
|
|
37
|
-
value={metadata.subject || ''}
|
|
38
|
-
onChange={(e) => onMetadataUpdate({ subject: e.target.value })}
|
|
39
|
-
placeholder="Enter email subject"
|
|
40
|
-
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"
|
|
41
|
-
/>
|
|
42
|
-
<p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
|
|
43
|
-
{metadata.subject?.length || 0} / 100 characters
|
|
44
|
-
</p>
|
|
45
|
-
</div>
|
|
46
|
-
<div>
|
|
47
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
48
|
-
Preview Text
|
|
49
|
-
</label>
|
|
50
|
-
<textarea
|
|
51
|
-
value={metadata.previewText || ''}
|
|
52
|
-
onChange={(e) => onMetadataUpdate({ previewText: e.target.value })}
|
|
53
|
-
placeholder="Preview text shown in email clients"
|
|
54
|
-
rows={3}
|
|
55
|
-
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"
|
|
56
|
-
/>
|
|
57
|
-
<p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
|
|
58
|
-
{metadata.previewText?.length || 0} / 150 characters
|
|
59
|
-
</p>
|
|
60
|
-
</div>
|
|
61
|
-
<div>
|
|
62
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
63
|
-
Language
|
|
64
|
-
</label>
|
|
65
|
-
<select
|
|
66
|
-
value={metadata.lang || 'en'}
|
|
67
|
-
onChange={(e) => onMetadataUpdate({ lang: e.target.value })}
|
|
68
|
-
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"
|
|
69
|
-
>
|
|
70
|
-
<option value="en">English</option>
|
|
71
|
-
<option value="nl">Dutch</option>
|
|
72
|
-
<option value="sv">Swedish</option>
|
|
73
|
-
</select>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
</section>
|
|
77
|
-
|
|
78
|
-
{/* Recipient Filter */}
|
|
79
|
-
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
80
|
-
<div className="flex items-center gap-3 mb-6">
|
|
81
|
-
<Users size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
82
|
-
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
83
|
-
Recipients
|
|
84
|
-
</label>
|
|
85
|
-
</div>
|
|
86
|
-
<div className="space-y-4">
|
|
87
|
-
<div>
|
|
88
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
89
|
-
Filter Type
|
|
90
|
-
</label>
|
|
91
|
-
<select
|
|
92
|
-
value={metadata.recipientFilter?.type || 'all'}
|
|
93
|
-
onChange={(e) => onMetadataUpdate({
|
|
94
|
-
recipientFilter: {
|
|
95
|
-
type: e.target.value as 'all' | 'language' | 'custom',
|
|
96
|
-
value: metadata.recipientFilter?.value,
|
|
97
|
-
}
|
|
98
|
-
})}
|
|
99
|
-
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"
|
|
100
|
-
>
|
|
101
|
-
<option value="all">All Subscribers</option>
|
|
102
|
-
<option value="language">By Language</option>
|
|
103
|
-
<option value="custom">Custom Filter</option>
|
|
104
|
-
</select>
|
|
105
|
-
</div>
|
|
106
|
-
{metadata.recipientFilter?.type === 'language' && (
|
|
107
|
-
<div>
|
|
108
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
109
|
-
Language
|
|
110
|
-
</label>
|
|
111
|
-
<select
|
|
112
|
-
value={metadata.recipientFilter?.value || 'en'}
|
|
113
|
-
onChange={(e) => onMetadataUpdate({
|
|
114
|
-
recipientFilter: {
|
|
115
|
-
type: 'language',
|
|
116
|
-
value: e.target.value,
|
|
117
|
-
}
|
|
118
|
-
})}
|
|
119
|
-
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"
|
|
120
|
-
>
|
|
121
|
-
<option value="en">English</option>
|
|
122
|
-
<option value="nl">Dutch</option>
|
|
123
|
-
<option value="sv">Swedish</option>
|
|
124
|
-
</select>
|
|
125
|
-
</div>
|
|
126
|
-
)}
|
|
127
|
-
</div>
|
|
128
|
-
</section>
|
|
129
|
-
|
|
130
|
-
{/* Newsletter Info */}
|
|
131
|
-
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
132
|
-
<div className="space-y-3">
|
|
133
|
-
<div>
|
|
134
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
135
|
-
Slug
|
|
136
|
-
</label>
|
|
137
|
-
<input
|
|
138
|
-
type="text"
|
|
139
|
-
value={slug}
|
|
140
|
-
readOnly
|
|
141
|
-
className="w-full px-3 py-2 text-xs bg-dashboard-bg border border-dashboard-border rounded-lg text-dashboard-text opacity-60 cursor-not-allowed"
|
|
142
|
-
/>
|
|
143
|
-
</div>
|
|
144
|
-
<div>
|
|
145
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
146
|
-
Status
|
|
147
|
-
</label>
|
|
148
|
-
<div className="px-3 py-2 text-xs bg-dashboard-bg border border-dashboard-border rounded-lg">
|
|
149
|
-
<span className="uppercase font-bold">{status}</span>
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
</div>
|
|
153
|
-
</section>
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
156
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
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
|
-
}
|