react-email 4.0.0-alpha.0 → 4.0.0-alpha.2

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 (92) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli/index.js +10 -10
  3. package/dist/cli/index.mjs +10 -13
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +19 -19
  6. package/dist/preview/.next/app-path-routes-manifest.json +1 -1
  7. package/dist/preview/.next/build-manifest.json +6 -6
  8. package/dist/preview/.next/cache/.rscinfo +1 -1
  9. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  13. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  14. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  15. package/dist/preview/.next/next-server.js.nft.json +1 -1
  16. package/dist/preview/.next/prerender-manifest.json +1 -1
  17. package/dist/preview/.next/required-server-files.json +1 -1
  18. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  19. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  21. package/dist/preview/.next/server/app/page.js +1 -1
  22. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  23. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js +6 -6
  25. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  26. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  27. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  28. package/dist/preview/.next/server/chunks/196.js +2 -2
  29. package/dist/preview/.next/server/chunks/590.js +1 -0
  30. package/dist/preview/.next/server/chunks/631.js +2 -2
  31. package/dist/preview/.next/server/chunks/734.js +15 -0
  32. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  33. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  34. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  35. package/dist/preview/.next/server/pages/500.html +1 -1
  36. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  37. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  38. package/dist/preview/.next/static/chunks/285-dbf6306a0d45c33d.js +1 -0
  39. package/dist/preview/.next/static/chunks/490-d26ba2019ccd4d2f.js +1 -0
  40. package/dist/preview/.next/static/chunks/603-36207c8905355e23.js +1 -0
  41. package/dist/preview/.next/static/chunks/afa401a5-9ebf2515b1397993.js +6 -0
  42. package/dist/preview/.next/static/chunks/app/layout-b13c19549e2d3e57.js +1 -0
  43. package/dist/preview/.next/static/chunks/app/page-8f366f3c14282f33.js +1 -0
  44. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9906dc842681db05.js +1 -0
  45. package/dist/preview/.next/static/chunks/main-app-d1b0aa870bcfb13e.js +1 -0
  46. package/dist/preview/.next/static/chunks/webpack-9255716c9496e606.js +1 -0
  47. package/dist/preview/.next/static/css/b60917edfd15a496.css +3 -0
  48. package/dist/preview/.next/trace +22 -21
  49. package/dist/preview/.next/types/app/layout.ts +1 -1
  50. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  51. package/module-punycode.d.ts +3 -0
  52. package/package.json +9 -9
  53. package/src/actions/email-validation/check-images.spec.tsx +89 -0
  54. package/src/actions/email-validation/check-images.ts +141 -0
  55. package/src/actions/email-validation/check-links.spec.tsx +91 -0
  56. package/src/actions/email-validation/check-links.ts +18 -15
  57. package/src/app/preview/[...slug]/preview.tsx +105 -19
  58. package/src/components/button.tsx +47 -36
  59. package/src/components/code-snippet.tsx +0 -2
  60. package/src/components/icons/icon-image.tsx +19 -0
  61. package/src/components/logo.tsx +0 -2
  62. package/src/components/resizable-wrapper.tsx +176 -0
  63. package/src/components/shell.tsx +17 -3
  64. package/src/components/sidebar/checking-results.tsx +150 -0
  65. package/src/components/sidebar/file-tree-directory-children.tsx +3 -6
  66. package/src/components/sidebar/image-checker.tsx +161 -0
  67. package/src/components/sidebar/link-checker.tsx +83 -223
  68. package/src/components/sidebar/sidebar.tsx +75 -27
  69. package/src/components/topbar/active-view-toggle-group.tsx +86 -0
  70. package/src/components/topbar/view-size-controls.tsx +247 -0
  71. package/src/components/topbar.tsx +50 -125
  72. package/src/hooks/use-clamped-state.ts +24 -0
  73. package/src/hooks/use-icon-animation.ts +4 -7
  74. package/src/utils/static-node-modules-for-vm.ts +2 -1
  75. package/tailwind.config.ts +12 -17
  76. package/tsconfig.json +6 -2
  77. package/tsconfig.test.json +8 -0
  78. package/vitest.config.ts +13 -0
  79. package/dist/preview/.next/server/chunks/273.js +0 -1
  80. package/dist/preview/.next/server/chunks/594.js +0 -10
  81. package/dist/preview/.next/static/chunks/18b16e15-6ad9b58e10ff8891.js +0 -1
  82. package/dist/preview/.next/static/chunks/490-48951f2e19ae3aef.js +0 -1
  83. package/dist/preview/.next/static/chunks/600-2e2ca4c8bbd97b61.js +0 -1
  84. package/dist/preview/.next/static/chunks/860-38d96c8819ba6f19.js +0 -1
  85. package/dist/preview/.next/static/chunks/app/layout-490964e2c3604d33.js +0 -1
  86. package/dist/preview/.next/static/chunks/app/page-d2432acd08db8fc0.js +0 -1
  87. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-f4e211e00c026401.js +0 -1
  88. package/dist/preview/.next/static/chunks/main-app-cd104297c6bcc87e.js +0 -1
  89. package/dist/preview/.next/static/chunks/webpack-7bf1ffb05f5540be.js +0 -1
  90. package/dist/preview/.next/static/css/5e0736cafbb392a9.css +0 -3
  91. /package/dist/preview/.next/static/{fZaiKz58wDr55pxLu9uHa → ll_lhpCErxdDFU8uF5Ujy}/_buildManifest.js +0 -0
  92. /package/dist/preview/.next/static/{fZaiKz58wDr55pxLu9uHa → ll_lhpCErxdDFU8uF5Ujy}/_ssgManifest.js +0 -0
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email.git/tertiary/packages/react-email/src/app/layout.tsx
1
+ // File: /home/gabriel/Projects/Resend/react-email.git/chore-1/packages/react-email/src/app/layout.tsx
2
2
  import * as entry from '../../../src/app/layout.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email.git/tertiary/packages/react-email/src/app/preview/[...slug]/page.tsx
1
+ // File: /home/gabriel/Projects/Resend/react-email.git/chore-1/packages/react-email/src/app/preview/[...slug]/page.tsx
2
2
  import * as entry from '../../../../../src/app/preview/[...slug]/page.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -0,0 +1,3 @@
1
+ declare module 'module-punycode' {
2
+ export * from 'node:punycode';
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "4.0.0-alpha.0",
3
+ "version": "4.0.0-alpha.2",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.js"
@@ -25,22 +25,19 @@
25
25
  "chokidar": "4.0.3",
26
26
  "commander": "11.1.0",
27
27
  "debounce": "2.0.0",
28
- "esbuild": "0.19.11",
28
+ "esbuild": "0.23.0",
29
29
  "glob": "10.3.4",
30
30
  "log-symbols": "4.1.0",
31
31
  "mime-types": "2.1.35",
32
32
  "next": "15.1.2",
33
33
  "normalize-path": "3.0.0",
34
34
  "ora": "5.4.1",
35
- "socket.io": "4.8.0"
36
- },
37
- "overrides": {
38
- "react": "^19",
39
- "react-dom": "^19"
35
+ "socket.io": "4.8.1"
40
36
  },
41
37
  "devDependencies": {
42
38
  "@radix-ui/colors": "1.0.1",
43
39
  "@radix-ui/react-collapsible": "1.1.0",
40
+ "@radix-ui/react-dropdown-menu": "2.1.4",
44
41
  "@radix-ui/react-popover": "1.1.1",
45
42
  "@radix-ui/react-slot": "1.1.0",
46
43
  "@radix-ui/react-tabs": "1.1.1",
@@ -60,11 +57,13 @@
60
57
  "autoprefixer": "10.4.20",
61
58
  "clsx": "2.1.0",
62
59
  "framer-motion": "12.0.0-alpha.2",
63
- "lottie-react": "^2.4.0",
60
+ "@lottiefiles/dotlottie-react": "0.12.3",
64
61
  "node-html-parser": "6.1.13",
65
62
  "postcss": "8.4.40",
66
63
  "prettier-plugin-tailwindcss": "0.6.6",
64
+ "pretty-bytes": "6.1.1",
67
65
  "prism-react-renderer": "2.1.0",
66
+ "module-punycode": "npm:punycode@2.3.1",
68
67
  "react": "^19",
69
68
  "react-dom": "^19",
70
69
  "sharp": "0.33.3",
@@ -77,8 +76,9 @@
77
76
  "tsup": "7.2.0",
78
77
  "tsx": "4.9.0",
79
78
  "typescript": "5.1.6",
79
+ "use-debounce": "10.0.4",
80
80
  "vitest": "1.1.3",
81
- "@react-email/render": "1.0.4"
81
+ "@react-email/render": "1.0.5"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsup-node && node build-preview-server.mjs",
@@ -0,0 +1,89 @@
1
+ import { render } from '@react-email/render';
2
+ import { type ImageCheckingResult, checkImages } from './check-images';
3
+
4
+ test('checkImages()', async () => {
5
+ expect(
6
+ await checkImages(
7
+ await render(
8
+ <div>
9
+ {/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
10
+ <img src="https://resend.com/static/brand/resend-icon-white.png" />,
11
+ <img src="/static/codepen-challengers.png" alt="codepen challenges" />
12
+ ,
13
+ </div>,
14
+ ),
15
+ 'https://demo.react.email',
16
+ ),
17
+ ).toEqual([
18
+ {
19
+ source: 'https://resend.com/static/brand/resend-icon-white.png',
20
+ checks: [
21
+ {
22
+ passed: false,
23
+ type: 'accessibility',
24
+ metadata: {
25
+ alt: undefined,
26
+ },
27
+ },
28
+ {
29
+ passed: true,
30
+ type: 'syntax',
31
+ },
32
+ {
33
+ passed: true,
34
+ type: 'security',
35
+ },
36
+ {
37
+ passed: true,
38
+ type: 'fetch_attempt',
39
+ metadata: {
40
+ fetchStatusCode: 200,
41
+ },
42
+ },
43
+ {
44
+ passed: true,
45
+ type: 'image_size',
46
+ metadata: {
47
+ byteCount: 23_138,
48
+ },
49
+ },
50
+ ],
51
+ status: 'warning',
52
+ },
53
+ {
54
+ checks: [
55
+ {
56
+ metadata: {
57
+ alt: 'codepen challenges',
58
+ },
59
+ passed: true,
60
+ type: 'accessibility',
61
+ },
62
+ {
63
+ passed: true,
64
+ type: 'syntax',
65
+ },
66
+ {
67
+ passed: true,
68
+ type: 'security',
69
+ },
70
+ {
71
+ metadata: {
72
+ fetchStatusCode: 200,
73
+ },
74
+ passed: true,
75
+ type: 'fetch_attempt',
76
+ },
77
+ {
78
+ metadata: {
79
+ byteCount: 111_922,
80
+ },
81
+ passed: true,
82
+ type: 'image_size',
83
+ },
84
+ ],
85
+ source: '/static/codepen-challengers.png',
86
+ status: 'success',
87
+ },
88
+ ] satisfies ImageCheckingResult[]);
89
+ });
@@ -0,0 +1,141 @@
1
+ 'use server';
2
+
3
+ import type { IncomingMessage } from 'node:http';
4
+ import { parse } from 'node-html-parser';
5
+ import { quickFetch } from './quick-fetch';
6
+
7
+ type Check = { passed: boolean } & (
8
+ | {
9
+ type: 'accessibility';
10
+ metadata: {
11
+ alt: string | undefined;
12
+ };
13
+ }
14
+ | {
15
+ type: 'fetch_attempt';
16
+ metadata: {
17
+ fetchStatusCode: number | undefined;
18
+ };
19
+ }
20
+ | {
21
+ type: 'image_size';
22
+ metadata: {
23
+ byteCount: number | undefined;
24
+ };
25
+ }
26
+ | {
27
+ type: 'syntax';
28
+ }
29
+ | {
30
+ type: 'security';
31
+ }
32
+ );
33
+
34
+ export interface ImageCheckingResult {
35
+ status: 'success' | 'warning' | 'error';
36
+ source: string;
37
+ checks: Check[];
38
+ }
39
+
40
+ const getResponseSizeInBytes = async (res: IncomingMessage) => {
41
+ let totalBytes = 0;
42
+ for await (const chunk of res) {
43
+ totalBytes += chunk.byteLength;
44
+ }
45
+ return totalBytes;
46
+ };
47
+
48
+ export const checkImages = async (code: string, base: string) => {
49
+ const ast = parse(code);
50
+
51
+ const imageCheckingResults: ImageCheckingResult[] = [];
52
+
53
+ const images = ast.querySelectorAll('img');
54
+ for await (const image of images) {
55
+ const rawSource = image.attributes.src;
56
+ if (!rawSource) continue;
57
+ if (imageCheckingResults.some((result) => result.source === rawSource))
58
+ continue;
59
+
60
+ const source = rawSource?.startsWith('/')
61
+ ? `${base}${rawSource}`
62
+ : rawSource;
63
+
64
+ const result: ImageCheckingResult = {
65
+ source: rawSource,
66
+ status: 'success',
67
+ checks: [],
68
+ };
69
+
70
+ const alt = image.attributes.alt;
71
+ result.checks.push({
72
+ passed: alt !== undefined,
73
+ type: 'accessibility',
74
+ metadata: {
75
+ alt,
76
+ },
77
+ });
78
+ if (alt === undefined) {
79
+ result.status = 'warning';
80
+ }
81
+
82
+ try {
83
+ const url = new URL(source);
84
+ result.checks.push({
85
+ passed: true,
86
+ type: 'syntax',
87
+ });
88
+
89
+ if (source.startsWith('https://')) {
90
+ result.checks.push({
91
+ passed: true,
92
+ type: 'security',
93
+ });
94
+ } else {
95
+ result.checks.push({
96
+ passed: false,
97
+ type: 'security',
98
+ });
99
+ result.status = 'warning';
100
+ }
101
+
102
+ const res = await quickFetch(url);
103
+ const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
104
+
105
+ result.checks.push({
106
+ type: 'fetch_attempt',
107
+ passed: hasSucceeded,
108
+ metadata: {
109
+ fetchStatusCode: res.statusCode,
110
+ },
111
+ });
112
+ if (!hasSucceeded) {
113
+ result.status = res.statusCode?.toString().startsWith('3')
114
+ ? 'warning'
115
+ : 'error';
116
+ }
117
+
118
+ const responseSizeBytes = await getResponseSizeInBytes(res);
119
+ result.checks.push({
120
+ type: 'image_size',
121
+ passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
122
+ metadata: {
123
+ byteCount: responseSizeBytes,
124
+ },
125
+ });
126
+ if (responseSizeBytes > 1_048_576) {
127
+ result.status = 'warning';
128
+ }
129
+ } catch (exception) {
130
+ result.checks.push({
131
+ passed: false,
132
+ type: 'syntax',
133
+ });
134
+ result.status = 'error';
135
+ }
136
+
137
+ imageCheckingResults.push(result);
138
+ }
139
+
140
+ return imageCheckingResults;
141
+ };
@@ -0,0 +1,91 @@
1
+ import { render } from '@react-email/render';
2
+ import { type LinkCheckingResult, checkLinks } from './check-links';
3
+
4
+ test('checkLinks()', async () => {
5
+ expect(
6
+ await checkLinks(
7
+ await render(
8
+ <div>
9
+ <a href="/">Root</a>
10
+ <a href="https://resend.com">Resend</a>
11
+ <a href="https://notion.so">Notion</a>
12
+ <a href="http://example.com">Example unsafe</a>
13
+ </div>,
14
+ ),
15
+ ),
16
+ ).toEqual([
17
+ {
18
+ status: 'error',
19
+ checks: [
20
+ {
21
+ type: 'syntax',
22
+ passed: false,
23
+ },
24
+ ],
25
+ link: '/',
26
+ },
27
+ {
28
+ status: 'success',
29
+ checks: [
30
+ {
31
+ type: 'syntax',
32
+ passed: true,
33
+ },
34
+ {
35
+ type: 'security',
36
+ passed: true,
37
+ },
38
+ {
39
+ type: 'fetch_attempt',
40
+ passed: true,
41
+ metadata: {
42
+ fetchStatusCode: 200,
43
+ },
44
+ },
45
+ ],
46
+ link: 'https://resend.com',
47
+ },
48
+ {
49
+ status: 'warning',
50
+ checks: [
51
+ {
52
+ type: 'syntax',
53
+ passed: true,
54
+ },
55
+ {
56
+ type: 'security',
57
+ passed: true,
58
+ },
59
+ {
60
+ type: 'fetch_attempt',
61
+ metadata: {
62
+ fetchStatusCode: 301,
63
+ },
64
+ passed: false,
65
+ },
66
+ ],
67
+ link: 'https://notion.so',
68
+ },
69
+ {
70
+ status: 'warning',
71
+ checks: [
72
+ {
73
+ type: 'syntax',
74
+ passed: true,
75
+ },
76
+ {
77
+ type: 'security',
78
+ passed: false,
79
+ },
80
+ {
81
+ type: 'fetch_attempt',
82
+ metadata: {
83
+ fetchStatusCode: 200,
84
+ },
85
+ passed: true,
86
+ },
87
+ ],
88
+ link: 'http://example.com',
89
+ },
90
+ ] satisfies LinkCheckingResult[]);
91
+ });
@@ -33,6 +33,7 @@ export const checkLinks = async (code: string) => {
33
33
  for await (const anchor of anchors) {
34
34
  const link = anchor.attributes.href;
35
35
  if (!link) continue;
36
+ if (linkCheckingResults.some((result) => result.link === link)) continue;
36
37
  if (link.startsWith('mailto:')) continue;
37
38
 
38
39
  const result: LinkCheckingResult = {
@@ -43,23 +44,10 @@ export const checkLinks = async (code: string) => {
43
44
 
44
45
  try {
45
46
  const url = new URL(link);
46
-
47
- const res = await quickFetch(url);
48
- const hasntSucceeded =
49
- res.statusCode === undefined ||
50
- !res.statusCode.toString().startsWith('2');
51
47
  result.checks.push({
52
- type: 'fetch_attempt',
53
- passed: hasntSucceeded,
54
- metadata: {
55
- fetchStatusCode: res.statusCode,
56
- },
48
+ passed: true,
49
+ type: 'syntax',
57
50
  });
58
- if (hasntSucceeded) {
59
- result.status = res.statusCode?.toString().startsWith('3')
60
- ? 'warning'
61
- : 'error';
62
- }
63
51
 
64
52
  if (link.startsWith('https://')) {
65
53
  result.checks.push({
@@ -73,6 +61,21 @@ export const checkLinks = async (code: string) => {
73
61
  });
74
62
  result.status = 'warning';
75
63
  }
64
+
65
+ const res = await quickFetch(url);
66
+ const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
67
+ result.checks.push({
68
+ type: 'fetch_attempt',
69
+ passed: hasSucceeded,
70
+ metadata: {
71
+ fetchStatusCode: res.statusCode,
72
+ },
73
+ });
74
+ if (!hasSucceeded) {
75
+ result.status = res.statusCode?.toString().startsWith('3')
76
+ ? 'warning'
77
+ : 'error';
78
+ }
76
79
  } catch (exception) {
77
80
  result.checks.push({
78
81
  passed: false,
@@ -1,12 +1,19 @@
1
1
  'use client';
2
2
 
3
3
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
- import React from 'react';
4
+ import { useState } from 'react';
5
+ import { flushSync } from 'react-dom';
5
6
  import { Toaster } from 'sonner';
7
+ import { useDebouncedCallback } from 'use-debounce';
6
8
  import type { EmailRenderingResult } from '../../../actions/render-email-by-path';
7
9
  import { CodeContainer } from '../../../components/code-container';
10
+ import {
11
+ ResizableWarpper,
12
+ makeIframeDocumentBubbleEvents,
13
+ } from '../../../components/resizable-wrapper';
8
14
  import { Shell } from '../../../components/shell';
9
15
  import { Tooltip } from '../../../components/tooltip';
16
+ import { useClampedState } from '../../../hooks/use-clamped-state';
10
17
  import { useEmailRenderingResult } from '../../../hooks/use-email-rendering-result';
11
18
  import { useHotreload } from '../../../hooks/use-hot-reload';
12
19
  import { useRenderingMetadata } from '../../../hooks/use-rendering-metadata';
@@ -29,7 +36,7 @@ const Preview = ({
29
36
  const pathname = usePathname();
30
37
  const searchParams = useSearchParams();
31
38
 
32
- const activeView = searchParams.get('view') ?? 'desktop';
39
+ const activeView = searchParams.get('view') ?? 'preview';
33
40
  const activeLang = searchParams.get('lang') ?? 'jsx';
34
41
 
35
42
  const renderingResult = useEmailRenderingResult(
@@ -75,36 +82,115 @@ const Preview = ({
75
82
 
76
83
  const hasNoErrors = typeof renderedEmailMetadata !== 'undefined';
77
84
 
85
+ const [maxWidth, setMaxWidth] = useState(Number.POSITIVE_INFINITY);
86
+ const [maxHeight, setMaxHeight] = useState(Number.POSITIVE_INFINITY);
87
+ const minWidth = 350;
88
+ const minHeight = 600;
89
+ const storedWidth = searchParams.get('width');
90
+ const storedHeight = searchParams.get('height');
91
+ const [width, setWidth] = useClampedState(
92
+ storedWidth ? Number.parseInt(storedWidth) : 600,
93
+ 350,
94
+ maxWidth,
95
+ );
96
+ const [height, setHeight] = useClampedState(
97
+ storedHeight ? Number.parseInt(storedHeight) : 1024,
98
+ 600,
99
+ maxHeight,
100
+ );
101
+
102
+ const handleSaveViewSize = useDebouncedCallback(() => {
103
+ const params = new URLSearchParams(searchParams);
104
+ params.set('width', width.toString());
105
+ params.set('height', height.toString());
106
+ router.push(`${pathname}?${params.toString()}`);
107
+ }, 300);
108
+
78
109
  return (
79
110
  <Shell
80
- activeView={hasNoErrors ? activeView : undefined}
111
+ activeView={activeView}
81
112
  currentEmailOpenSlug={slug}
82
113
  markup={renderedEmailMetadata?.markup}
83
114
  pathSeparator={pathSeparator}
84
- setActiveView={hasNoErrors ? handleViewChange : undefined}
115
+ setActiveView={handleViewChange}
116
+ setViewHeight={(height) => {
117
+ setHeight(height);
118
+ flushSync(() => {
119
+ handleSaveViewSize();
120
+ });
121
+ }}
122
+ setViewWidth={(width) => {
123
+ setWidth(width);
124
+ flushSync(() => {
125
+ handleSaveViewSize();
126
+ });
127
+ }}
128
+ viewHeight={height}
129
+ viewWidth={width}
85
130
  >
86
131
  {/* This relative is so that when there is any error the user can still switch between emails */}
87
- <div className="relative h-full">
132
+ <div
133
+ className="relative flex h-full pb-8 bg-gray-200"
134
+ ref={(element) => {
135
+ const observer = new ResizeObserver((entry) => {
136
+ const [elementEntry] = entry;
137
+ if (elementEntry) {
138
+ setMaxWidth(elementEntry.contentRect.width - 80);
139
+ setMaxHeight(elementEntry.contentRect.height - 80);
140
+ }
141
+ });
142
+
143
+ if (element) {
144
+ observer.observe(element);
145
+ }
146
+
147
+ return () => {
148
+ observer.disconnect();
149
+ };
150
+ }}
151
+ >
88
152
  {'error' in renderingResult ? (
89
153
  <RenderingError error={renderingResult.error} />
90
154
  ) : null}
91
155
 
92
156
  {hasNoErrors ? (
93
157
  <>
94
- {activeView === 'desktop' && (
95
- <iframe
96
- className="h-full w-full bg-white"
97
- srcDoc={renderedEmailMetadata.markup}
98
- title={slug}
99
- />
100
- )}
101
-
102
- {activeView === 'mobile' && (
103
- <iframe
104
- className="mx-auto h-full w-[360px] bg-white"
105
- srcDoc={renderedEmailMetadata.markup}
106
- title={slug}
107
- />
158
+ {activeView === 'preview' && (
159
+ <ResizableWarpper
160
+ minHeight={minHeight}
161
+ minWidth={minWidth}
162
+ maxHeight={maxHeight}
163
+ maxWidth={maxWidth}
164
+ height={height}
165
+ onResizeEnd={() => {
166
+ handleSaveViewSize();
167
+ }}
168
+ onResize={(value, direction) => {
169
+ const isHorizontal =
170
+ direction === 'east' || direction === 'west';
171
+ if (isHorizontal) {
172
+ setWidth(value);
173
+ } else {
174
+ setHeight(value);
175
+ }
176
+ }}
177
+ width={width}
178
+ >
179
+ <iframe
180
+ className="solid max-h-full rounded-lg bg-white"
181
+ ref={(iframe) => {
182
+ if (iframe) {
183
+ return makeIframeDocumentBubbleEvents(iframe);
184
+ }
185
+ }}
186
+ srcDoc={renderedEmailMetadata.markup}
187
+ style={{
188
+ width: `${width}px`,
189
+ height: `${height}px`,
190
+ }}
191
+ title={slug}
192
+ />
193
+ </ResizableWarpper>
108
194
  )}
109
195
 
110
196
  {activeView === 'source' && (