Skip to content

Commit eb522b3

Browse files
author
Rajat
committed
Image uploader prop for image block
1 parent 01b9177 commit eb522b3

12 files changed

Lines changed: 401 additions & 197 deletions

File tree

apps/docs/components/email-block-demo.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo, useState } from "react";
3+
import { useMemo, useRef, useState, type ChangeEvent } from "react";
44
import {
55
ContactFilterBuilder,
66
EmailEditor,
@@ -22,6 +22,13 @@ import {
2222
type SystemTemplateSummary,
2323
type TriggerOption,
2424
} from "@sendlit/email-blocks";
25+
import {
26+
ImageBlock,
27+
Link,
28+
Separator,
29+
Text,
30+
type UploaderProps,
31+
} from "@sendlit/email-editor/blocks";
2532

2633
type DemoName =
2734
| "contact-filter-builder"
@@ -369,6 +376,53 @@ const savedTemplates: EmailTemplate[] = [
369376
},
370377
];
371378

379+
function LocalImageUploader({ children, onChange }: UploaderProps) {
380+
const inputRef = useRef<HTMLInputElement>(null);
381+
382+
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
383+
const file = event.currentTarget.files?.[0];
384+
if (!file) {
385+
return;
386+
}
387+
388+
const reader = new FileReader();
389+
reader.onload = () => {
390+
if (typeof reader.result !== "string") {
391+
return;
392+
}
393+
394+
onChange({
395+
src: reader.result,
396+
alt: file.name,
397+
});
398+
};
399+
reader.readAsDataURL(file);
400+
event.currentTarget.value = "";
401+
};
402+
403+
return (
404+
<span className="inline-flex" onClick={() => inputRef.current?.click()}>
405+
{children}
406+
<input
407+
ref={inputRef}
408+
type="file"
409+
accept="image/*"
410+
className="sr-only"
411+
tabIndex={-1}
412+
onClick={(event) => event.stopPropagation()}
413+
onChange={handleFileChange}
414+
/>
415+
</span>
416+
);
417+
}
418+
419+
const emailEditorBlocks = [
420+
Text,
421+
Separator,
422+
Link,
423+
ImageBlock.configure({ uploader: LocalImageUploader }),
424+
];
425+
372426
export function EmailBlockDemo({ demo }: { demo: DemoName }) {
373427
const [filter, setFilter] =
374428
useState<ContactFilterWithAggregator>(initialFilter);
@@ -616,6 +670,7 @@ export function EmailBlockDemo({ demo }: { demo: DemoName }) {
616670
<EmailEditor
617671
email={editorEmail}
618672
onChange={setEditorEmail}
673+
blocks={emailEditorBlocks}
619674
/>
620675
</DemoFrame>
621676
);

apps/docs/content/docs/email-blocks/email-editor.mdx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ its types without you needing a second dependency.
1919

2020
## Usage
2121

22-
This is the exact code behind the demo above.
22+
### Simple editor
2323

2424
```tsx
2525
import { useState } from "react";
@@ -36,6 +36,80 @@ function Example() {
3636
}
3737
```
3838

39+
### Custom image uploader
40+
41+
Use `blocks` to replace the default image block settings with a wrapper that
42+
opens your own uploader from the existing image upload button.
43+
44+
```tsx
45+
import { useRef, useState, type ChangeEvent } from "react";
46+
import {
47+
EmailEditor,
48+
defaultTemplateEmail,
49+
type Email,
50+
} from "@sendlit/email-blocks";
51+
import {
52+
ImageBlock,
53+
Link,
54+
Separator,
55+
Text,
56+
type UploaderProps,
57+
} from "@sendlit/email-editor/blocks";
58+
59+
function LocalImageUploader({ children, onChange }: UploaderProps) {
60+
const inputRef = useRef<HTMLInputElement>(null);
61+
62+
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
63+
const file = event.currentTarget.files?.[0];
64+
if (!file) {
65+
return;
66+
}
67+
68+
const reader = new FileReader();
69+
reader.onload = () => {
70+
if (typeof reader.result !== "string") {
71+
return;
72+
}
73+
74+
onChange({
75+
src: reader.result,
76+
alt: file.name,
77+
});
78+
};
79+
reader.readAsDataURL(file);
80+
event.currentTarget.value = "";
81+
};
82+
83+
return (
84+
<span className="inline-flex" onClick={() => inputRef.current?.click()}>
85+
{children}
86+
<input
87+
ref={inputRef}
88+
type="file"
89+
accept="image/*"
90+
className="sr-only"
91+
tabIndex={-1}
92+
onClick={(event) => event.stopPropagation()}
93+
onChange={handleFileChange}
94+
/>
95+
</span>
96+
);
97+
}
98+
99+
const blocks = [
100+
Text,
101+
Separator,
102+
Link,
103+
ImageBlock.configure({ uploader: LocalImageUploader }),
104+
];
105+
106+
function Example() {
107+
const [email, setEmail] = useState<Email>(defaultTemplateEmail);
108+
109+
return <EmailEditor email={email} onChange={setEmail} blocks={blocks} />;
110+
}
111+
```
112+
39113
Pass the same `Email` value to [`EmailPreview`](/email-blocks/email-preview)
40114
or `renderEmailToHtml` (also re-exported) to see the rendered result or
41115
generate the HTML you actually send.

packages/email-editor/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
"default": "./dist/index.mjs"
1919
},
2020
"./styles.css": "./dist/index.css",
21-
"./blocks": "./dist/blocks"
21+
"./blocks": {
22+
"types": "./dist/blocks/index.d.ts",
23+
"import": "./dist/blocks/index.mjs",
24+
"default": "./dist/blocks/index.mjs"
25+
}
2226
},
2327
"publishConfig": {
2428
"access": "public"

packages/email-editor/src/blocks/image/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@ import { ImageBlock as block } from "./block";
22
import { ImageSettings as settings } from "./settings";
33
import { metadata } from "./metadata";
44
import type { BlockComponent } from "@/types/block-registry";
5+
import type { ImageBlockConfig, ImageBlockSettings } from "./types";
56

6-
export const ImageBlock: BlockComponent = {
7+
type ConfigurableImageBlock = BlockComponent<ImageBlockSettings> & {
8+
configure: (config: ImageBlockConfig) => BlockComponent<ImageBlockSettings>;
9+
};
10+
11+
const baseImageBlock: BlockComponent<ImageBlockSettings> = {
712
block,
813
settings,
914
metadata,
1015
};
16+
17+
export const ImageBlock: ConfigurableImageBlock = {
18+
...baseImageBlock,
19+
configure: ({ uploader }) => {
20+
const Settings = settings;
21+
22+
return {
23+
...baseImageBlock,
24+
settings: (props) => Settings({ ...props, uploader }),
25+
};
26+
},
27+
};

packages/email-editor/src/blocks/image/settings.tsx

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { EmailBlock, EmailStyle } from "@/types/email-editor";
2-
import type { ImageBlockSettings } from "./types";
2+
import type { ImageBlockSettings, Uploader, UploadedImage } from "./types";
33
import { SettingsInput } from "@/components/settings/settings-input";
44
import { SettingsSelect } from "@/components/settings/settings-select";
55
import { SettingsColorPicker } from "@/components/settings/settings-color-picker";
@@ -8,16 +8,18 @@ import { SettingsSection } from "@/components/settings/settings-section";
88
import { Button } from "@/components/ui/button";
99
import { Upload } from "lucide-react";
1010

11-
interface ImageSettingsProps {
11+
export interface ImageSettingsProps {
1212
block: Required<EmailBlock> & { settings: ImageBlockSettings };
1313
style?: EmailStyle;
1414
updateBlock: (id: string, content: Partial<EmailBlock>) => void;
15+
uploader?: Uploader;
1516
}
1617

1718
export function ImageSettings({
1819
block,
1920
style,
2021
updateBlock,
22+
uploader: UploaderComponent,
2123
}: ImageSettingsProps) {
2224
const handleSettingChange = (key: string, value: any) => {
2325
updateBlock(block.id, {
@@ -35,6 +37,15 @@ export function ImageSettings({
3537
}
3638
};
3739

40+
const handleUploadedImageChange = (image: UploadedImage) => {
41+
updateBlock(block.id, {
42+
settings: {
43+
...block.settings,
44+
...image,
45+
},
46+
});
47+
};
48+
3849
// Helper function to convert px string to number
3950
const pxToNumber = (
4051
value: string | undefined,
@@ -49,6 +60,40 @@ export function ImageSettings({
4960
const borderRadius = pxToNumber(block.settings.borderRadius, 0);
5061
const borderWidth = pxToNumber(block.settings.borderWidth, 0);
5162

63+
const uploadButton = (
64+
<Button
65+
type="button"
66+
variant="outline"
67+
size="icon"
68+
onClick={
69+
UploaderComponent
70+
? undefined
71+
: (event) => {
72+
event.preventDefault();
73+
handleImageUpload();
74+
}
75+
}
76+
title="Browse for image"
77+
>
78+
<Upload className="h-4 w-4" />
79+
</Button>
80+
);
81+
82+
let uploadControl = uploadButton;
83+
if (UploaderComponent) {
84+
uploadControl = (
85+
<UploaderComponent
86+
value={{
87+
...block.settings,
88+
src: block.settings.src || "",
89+
}}
90+
onChange={handleUploadedImageChange}
91+
>
92+
{uploadButton}
93+
</UploaderComponent>
94+
);
95+
}
96+
5297
// Sample images
5398
const sampleImages = [
5499
"https://images.unsplash.com/photo-1557804506-669a67965ba0?w=400&h=300&fit=crop",
@@ -112,18 +157,7 @@ export function ImageSettings({
112157
placeholder="https://example.com/image.jpg"
113158
className="flex-1"
114159
/>
115-
<Button
116-
type="button"
117-
variant="outline"
118-
size="icon"
119-
onClick={(e) => {
120-
e.preventDefault();
121-
handleImageUpload();
122-
}}
123-
title="Browse for image"
124-
>
125-
<Upload className="h-4 w-4" />
126-
</Button>
160+
{uploadControl}
127161
</div>
128162
</div>
129163

packages/email-editor/src/blocks/image/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ComponentType, ReactNode } from "react";
12
import type { CommonBlockSettings } from "@/types/email-editor";
23

34
export interface ImageBlockSettings extends CommonBlockSettings {
@@ -13,3 +14,18 @@ export interface ImageBlockSettings extends CommonBlockSettings {
1314
borderColor?: string;
1415
padding?: string;
1516
}
17+
18+
export type UploadedImage = Partial<ImageBlockSettings> &
19+
Pick<ImageBlockSettings, "src">;
20+
21+
export interface UploaderProps {
22+
value: UploadedImage;
23+
onChange: (image: UploadedImage) => void;
24+
children: ReactNode;
25+
}
26+
27+
export type Uploader = ComponentType<UploaderProps>;
28+
29+
export interface ImageBlockConfig {
30+
uploader?: Uploader;
31+
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export { TextBlock as Text } from "./text";
22
export { SeparatorBlock as Separator } from "./separator";
3-
export { ImageBlock as Image } from "./image";
3+
export { ImageBlock, ImageBlock as Image } from "./image";
4+
export type {
5+
ImageBlockConfig,
6+
ImageBlockSettings,
7+
Uploader,
8+
UploaderProps,
9+
UploadedImage,
10+
} from "./image/types";
11+
export type { ImageSettingsProps } from "./image/settings";
412
export { LinkBlock as Link } from "./link";

packages/email-editor/src/components/block-settings-panel.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { X, ChevronRight } from "lucide-react";
22
import { Button } from "@/components/ui/button";
33
import { EmailSettings } from "./email-settings";
4-
import type { Email, EmailStyle } from "../types/email-editor";
4+
import type {
5+
Email,
6+
EmailStyle,
7+
RuntimeEmailBlock,
8+
} from "../types/email-editor";
59
import type { BlockRegistry } from "../types/block-registry";
610
import type { EmailBlock } from "../types/email-editor";
711

@@ -48,7 +52,8 @@ export function BlockSettingsPanel({
4852
);
4953
}
5054

51-
const block = email.content.find((b) => b.id === blockId);
55+
const block = email.content.find((b) => b.id === blockId) as
56+
RuntimeEmailBlock | undefined;
5257

5358
if (!block) return null;
5459

0 commit comments

Comments
 (0)