@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react-hooks', 'simple-import-sort'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', 'node_modules'],
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Waypoint (Metaccountant, Inc.)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
# @usewaypoint/editor-sample
|
||||
|
||||
Use this as a sample to self-host EmailBuilder.js.
|
||||
|
||||
To run this locally, fork the repository and then in this directory run:
|
||||
|
||||
- `npm install`
|
||||
- `npx vite`
|
||||
|
||||
Once the server is running, open http://localhost:5173/email-builder-js/ in your browser.
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/src/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/src/favicon/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=900" />
|
||||
<meta name="description" content="EmailBuilder.js interactive playground. Brought to you by Waypoint." />
|
||||
<title>EmailBuilder.js — Free and Open Source Template Builder</title>
|
||||
<style>
|
||||
html {
|
||||
margin: 0px;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
#root {
|
||||
/* height: 100vh; */
|
||||
width: 800px;
|
||||
position: relative;
|
||||
}
|
||||
.root-wrapper {
|
||||
padding: 100px;
|
||||
background-color: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="root-wrapper">
|
||||
<div id="root" class="email-builder-container"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const testData = {
|
||||
"root": {
|
||||
"type": "EmailLayout",
|
||||
"data": {
|
||||
"backdropColor": "#F5F5F5",
|
||||
"canvasColor": "#FFFFFF",
|
||||
"textColor": "#262626",
|
||||
"fontFamily": "MODERN_SANS",
|
||||
"childrenIds": [
|
||||
"block-1727858083795"
|
||||
]
|
||||
}
|
||||
},
|
||||
"block-1727858083795": {
|
||||
"type": "Text",
|
||||
"data": {
|
||||
"style": {
|
||||
"fontWeight": "normal",
|
||||
"padding": {
|
||||
"top": 16,
|
||||
"bottom": 16,
|
||||
"right": 24,
|
||||
"left": 24
|
||||
}
|
||||
},
|
||||
"props": {
|
||||
"markdown": false,
|
||||
"text": "Test template"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import('/src/main.tsx')
|
||||
.then(module => {
|
||||
module.render('root', { data: testData, onChange: (json, html) => {
|
||||
console.log("onChange", json, html)
|
||||
}});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading the module:', error);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Prod build -->
|
||||
<!-- <script src="dist/eaglecast-email-builder.umd.js"></script>
|
||||
<script>
|
||||
EmailBuilder.render("root");
|
||||
</script> -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@eaglecast/email-builder",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
"@mui/material": "^5.15.10",
|
||||
"@usewaypoint/block-avatar": "^0.0.3",
|
||||
"@usewaypoint/block-button": "^0.0.3",
|
||||
"@usewaypoint/block-columns-container": "^0.0.3",
|
||||
"@usewaypoint/block-container": "^0.0.2",
|
||||
"@usewaypoint/block-divider": "^0.0.4",
|
||||
"@usewaypoint/block-heading": "^0.0.3",
|
||||
"@usewaypoint/block-html": "^0.0.3",
|
||||
"@usewaypoint/block-image": "^0.0.5",
|
||||
"@usewaypoint/block-spacer": "^0.0.3",
|
||||
"@usewaypoint/block-text": "^0.0.6",
|
||||
"@usewaypoint/document-core": "^0.0.6",
|
||||
"@usewaypoint/email-builder": "^0.0.8",
|
||||
"highlight.js": "^11.9.0",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"terser": "^5.34.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^6.4.2"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../documents/editor/core';
|
||||
import { setDocument, useDocument, useSelectedBlockId } from '../../../documents/editor/EditorContext';
|
||||
|
||||
import AvatarSidebarPanel from './input-panels/AvatarSidebarPanel';
|
||||
import ButtonSidebarPanel from './input-panels/ButtonSidebarPanel';
|
||||
import ColumnsContainerSidebarPanel from './input-panels/ColumnsContainerSidebarPanel';
|
||||
import ContainerSidebarPanel from './input-panels/ContainerSidebarPanel';
|
||||
import DividerSidebarPanel from './input-panels/DividerSidebarPanel';
|
||||
import EmailLayoutSidebarPanel from './input-panels/EmailLayoutSidebarPanel';
|
||||
import HeadingSidebarPanel from './input-panels/HeadingSidebarPanel';
|
||||
import HtmlSidebarPanel from './input-panels/HtmlSidebarPanel';
|
||||
import ImageSidebarPanel from './input-panels/ImageSidebarPanel';
|
||||
import SpacerSidebarPanel from './input-panels/SpacerSidebarPanel';
|
||||
import TextSidebarPanel from './input-panels/TextSidebarPanel';
|
||||
|
||||
function renderMessage(val: string) {
|
||||
return (
|
||||
<Box sx={{ m: 3, p: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography color="text.secondary">{val}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigurationPanel() {
|
||||
const document = useDocument();
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
|
||||
if (!selectedBlockId) {
|
||||
return renderMessage('Click on a block to inspect.');
|
||||
}
|
||||
const block = document[selectedBlockId];
|
||||
if (!block) {
|
||||
return renderMessage(`Block with id ${selectedBlockId} was not found. Click on a block to reset.`);
|
||||
}
|
||||
|
||||
const setBlock = (conf: TEditorBlock) => setDocument({ [selectedBlockId]: conf });
|
||||
const { data, type } = block;
|
||||
switch (type) {
|
||||
case 'Avatar':
|
||||
return <AvatarSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Button':
|
||||
return <ButtonSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'ColumnsContainer':
|
||||
return (
|
||||
<ColumnsContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />
|
||||
);
|
||||
case 'Container':
|
||||
return <ContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Divider':
|
||||
return <DividerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Heading':
|
||||
return <HeadingSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Html':
|
||||
return <HtmlSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Image':
|
||||
return <ImageSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'EmailLayout':
|
||||
return <EmailLayoutSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Spacer':
|
||||
return <SpacerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Text':
|
||||
return <TextSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
default:
|
||||
return <pre>{JSON.stringify(block, null, ' ')}</pre>;
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AspectRatioOutlined } from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { AvatarProps, AvatarPropsDefaults, AvatarPropsSchema } from '@usewaypoint/block-avatar';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type AvatarSidebarPanelProps = {
|
||||
data: AvatarProps;
|
||||
setData: (v: AvatarProps) => void;
|
||||
};
|
||||
export default function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = AvatarPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const size = data.props?.size ?? AvatarPropsDefaults.size;
|
||||
const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl;
|
||||
const alt = data.props?.alt ?? AvatarPropsDefaults.alt;
|
||||
const shape = data.props?.shape ?? AvatarPropsDefaults.shape;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Avatar block">
|
||||
<SliderInput
|
||||
label="Size"
|
||||
iconLabel={<AspectRatioOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={3}
|
||||
min={32}
|
||||
max={256}
|
||||
defaultValue={size}
|
||||
onChange={(size) => {
|
||||
updateData({ ...data, props: { ...data.props, size } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Shape"
|
||||
defaultValue={shape}
|
||||
onChange={(shape) => {
|
||||
updateData({ ...data, props: { ...data.props, shape } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="circle">Circle</ToggleButton>
|
||||
<ToggleButton value="square">Square</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<TextInput
|
||||
label="Image URL"
|
||||
className="image-url"
|
||||
defaultValue={imageUrl}
|
||||
onChange={(imageUrl) => {
|
||||
updateData({ ...data, props: { ...data.props, imageUrl } });
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={alt}
|
||||
onChange={(alt) => {
|
||||
updateData({ ...data, props: { ...data.props, alt } });
|
||||
}}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { ButtonProps, ButtonPropsDefaults, ButtonPropsSchema } from '@usewaypoint/block-button';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColorInput from './helpers/inputs/ColorInput';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ButtonSidebarPanelProps = {
|
||||
data: ButtonProps;
|
||||
setData: (v: ButtonProps) => void;
|
||||
};
|
||||
export default function ButtonSidebarPanel({ data, setData }: ButtonSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ButtonPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const text = data.props?.text ?? ButtonPropsDefaults.text;
|
||||
const url = data.props?.url ?? ButtonPropsDefaults.url;
|
||||
const fullWidth = data.props?.fullWidth ?? ButtonPropsDefaults.fullWidth;
|
||||
const size = data.props?.size ?? ButtonPropsDefaults.size;
|
||||
const buttonStyle = data.props?.buttonStyle ?? ButtonPropsDefaults.buttonStyle;
|
||||
const buttonTextColor = data.props?.buttonTextColor ?? ButtonPropsDefaults.buttonTextColor;
|
||||
const buttonBackgroundColor = data.props?.buttonBackgroundColor ?? ButtonPropsDefaults.buttonBackgroundColor;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Button block">
|
||||
<TextInput
|
||||
label="Text"
|
||||
defaultValue={text}
|
||||
onChange={(text) => updateData({ ...data, props: { ...data.props, text } })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Url"
|
||||
defaultValue={url}
|
||||
onChange={(url) => updateData({ ...data, props: { ...data.props, url } })}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Width"
|
||||
defaultValue={fullWidth ? 'FULL_WIDTH' : 'AUTO'}
|
||||
onChange={(v) => updateData({ ...data, props: { ...data.props, fullWidth: v === 'FULL_WIDTH' } })}
|
||||
>
|
||||
<ToggleButton value="FULL_WIDTH">Full</ToggleButton>
|
||||
<ToggleButton value="AUTO">Auto</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Size"
|
||||
defaultValue={size}
|
||||
onChange={(size) => updateData({ ...data, props: { ...data.props, size } })}
|
||||
>
|
||||
<ToggleButton value="x-small">Xs</ToggleButton>
|
||||
<ToggleButton value="small">Sm</ToggleButton>
|
||||
<ToggleButton value="medium">Md</ToggleButton>
|
||||
<ToggleButton value="large">Lg</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Style"
|
||||
defaultValue={buttonStyle}
|
||||
onChange={(buttonStyle) => updateData({ ...data, props: { ...data.props, buttonStyle } })}
|
||||
>
|
||||
<ToggleButton value="rectangle">Rectangle</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
<ToggleButton value="pill">Pill</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={buttonTextColor}
|
||||
onChange={(buttonTextColor) => updateData({ ...data, props: { ...data.props, buttonTextColor } })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Button color"
|
||||
defaultValue={buttonBackgroundColor}
|
||||
onChange={(buttonBackgroundColor) => updateData({ ...data, props: { ...data.props, buttonBackgroundColor } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'fontFamily', 'fontSize', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
SpaceBarOutlined,
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import ColumnsContainerPropsSchema, {
|
||||
ColumnsContainerProps,
|
||||
} from '../../../../documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColumnWidthsInput from './helpers/inputs/ColumnWidthsInput';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ColumnsContainerPanelProps = {
|
||||
data: ColumnsContainerProps;
|
||||
setData: (v: ColumnsContainerProps) => void;
|
||||
};
|
||||
export default function ColumnsContainerPanel({ data, setData }: ColumnsContainerPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ColumnsContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Columns block">
|
||||
<RadioGroupInput
|
||||
label="Number of columns"
|
||||
defaultValue={data.props?.columnsCount === 2 ? '2' : '3'}
|
||||
onChange={(v) => {
|
||||
updateData({ ...data, props: { ...data.props, columnsCount: v === '2' ? 2 : 3 } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="2">2</ToggleButton>
|
||||
<ToggleButton value="3">3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColumnWidthsInput
|
||||
defaultValue={data.props?.fixedWidths}
|
||||
onChange={(fixedWidths) => {
|
||||
updateData({ ...data, props: { ...data.props, fixedWidths } });
|
||||
}}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Columns gap"
|
||||
iconLabel={<SpaceBarOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={80}
|
||||
defaultValue={data.props?.columnsGap ?? 0}
|
||||
onChange={(columnsGap) => updateData({ ...data, props: { ...data.props, columnsGap } })}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? 'middle'}
|
||||
onChange={(contentAlignment) => {
|
||||
updateData({ ...data, props: { ...data.props, contentAlignment } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import ContainerPropsSchema, { ContainerProps } from '../../../../documents/blocks/Container/ContainerPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ContainerSidebarPanelProps = {
|
||||
data: ContainerProps;
|
||||
setData: (v: ContainerProps) => void;
|
||||
};
|
||||
|
||||
export default function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<BaseSidebarPanel title="Container block">
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'borderColor', 'borderRadius', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HeightOutlined } from '@mui/icons-material';
|
||||
import { DividerProps, DividerPropsDefaults, DividerPropsSchema } from '@usewaypoint/block-divider';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColorInput from './helpers/inputs/ColorInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type DividerSidebarPanelProps = {
|
||||
data: DividerProps;
|
||||
setData: (v: DividerProps) => void;
|
||||
};
|
||||
export default function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = DividerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor;
|
||||
const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Divider block">
|
||||
<ColorInput
|
||||
label="Color"
|
||||
defaultValue={lineColor}
|
||||
onChange={(lineColor) => updateData({ ...data, props: { ...data.props, lineColor } })}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={1}
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={lineHeight}
|
||||
onChange={(lineHeight) => updateData({ ...data, props: { ...data.props, lineHeight } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { RoundedCornerOutlined } from '@mui/icons-material';
|
||||
|
||||
import EmailLayoutPropsSchema, {
|
||||
EmailLayoutProps,
|
||||
} from '../../../../documents/blocks/EmailLayout/EmailLayoutPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import BooleanInput from './helpers/inputs/BooleanInput';
|
||||
import ColorInput, { NullableColorInput } from './helpers/inputs/ColorInput';
|
||||
import { NullableFontFamily } from './helpers/inputs/FontFamily';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
|
||||
type EmailLayoutSidebarFieldsProps = {
|
||||
data: EmailLayoutProps;
|
||||
setData: (v: EmailLayoutProps) => void;
|
||||
};
|
||||
export default function EmailLayoutSidebarFields({ data, setData }: EmailLayoutSidebarFieldsProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = EmailLayoutPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Global">
|
||||
<ColorInput
|
||||
label="Backdrop color"
|
||||
defaultValue={data.backdropColor ?? '#F5F5F5'}
|
||||
onChange={(backdropColor) => updateData({ ...data, backdropColor })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Canvas color"
|
||||
defaultValue={data.canvasColor ?? '#FFFFFF'}
|
||||
onChange={(canvasColor) => updateData({ ...data, canvasColor })}
|
||||
/>
|
||||
<NullableColorInput
|
||||
label="Canvas border color"
|
||||
defaultValue={data.borderColor ?? null}
|
||||
onChange={(borderColor) => updateData({ ...data, borderColor })}
|
||||
/>
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Canvas border radius"
|
||||
defaultValue={data.borderRadius ?? 0}
|
||||
onChange={(borderRadius) => updateData({ ...data, borderRadius })}
|
||||
/>
|
||||
<NullableFontFamily
|
||||
label="Font family"
|
||||
defaultValue="MODERN_SANS"
|
||||
onChange={(fontFamily) => updateData({ ...data, fontFamily })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={data.textColor ?? '#262626'}
|
||||
onChange={(textColor) => updateData({ ...data, textColor })}
|
||||
/>
|
||||
<BooleanInput
|
||||
label="Outlook compatibility"
|
||||
defaultValue={data.outlook ?? false}
|
||||
onChange={(outlook) => updateData({ ...data, outlook })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { HeadingProps, HeadingPropsDefaults, HeadingPropsSchema } from '@usewaypoint/block-heading';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type HeadingSidebarPanelProps = {
|
||||
data: HeadingProps;
|
||||
setData: (v: HeadingProps) => void;
|
||||
};
|
||||
export default function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HeadingPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Heading block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={3}
|
||||
defaultValue={data.props?.text ?? HeadingPropsDefaults.text}
|
||||
onChange={(text) => {
|
||||
updateData({ ...data, props: { ...data.props, text } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Level"
|
||||
defaultValue={data.props?.level ?? HeadingPropsDefaults.level}
|
||||
onChange={(level) => {
|
||||
updateData({ ...data, props: { ...data.props, level } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="h1">H1</ToggleButton>
|
||||
<ToggleButton value="h2">H2</ToggleButton>
|
||||
<ToggleButton value="h3">H3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type HtmlSidebarPanelProps = {
|
||||
data: HtmlProps;
|
||||
setData: (v: HtmlProps) => void;
|
||||
};
|
||||
export default function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HtmlPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Html block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.contents ?? ''}
|
||||
onChange={(contents) => updateData({ ...data, props: { ...data.props, contents } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontSize', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import { Checkbox, FormControlLabel, Stack, ToggleButton } from '@mui/material';
|
||||
import { ImageProps } from '@usewaypoint/block-image';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ImgPropsSchema, EagleCastImageProps } from '../../../../documents/editor/core';
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextDimensionInput from './helpers/inputs/TextDimensionInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ImageSidebarPanelProps = {
|
||||
data: ImageProps;
|
||||
setData: (v: ImageProps) => void;
|
||||
};
|
||||
export default function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ImgPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data as ImageProps);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const props = (data && (data as EagleCastImageProps).props) || {};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Image block">
|
||||
<TextInput
|
||||
label="Source URL"
|
||||
className="image-url"
|
||||
defaultValue={data.props?.url ?? ''}
|
||||
onChange={(v) => {
|
||||
const url = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, url } });
|
||||
}}
|
||||
/>
|
||||
<a href="#" class="select-media"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', marginTop: '5px' }}
|
||||
onClick={(e) => {
|
||||
// @ts-ignore
|
||||
window.parent.postMessage('visualeditor.select-media', '*');
|
||||
e.preventDefault();
|
||||
}}><CloudUploadIcon style={{fontSize: '1rem'}} /> Select media</a>
|
||||
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={data.props?.alt ?? ''}
|
||||
onChange={(alt) => updateData({ ...data, props: { ...data.props, alt } })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Click through URL"
|
||||
defaultValue={data.props?.linkHref ?? ''}
|
||||
onChange={(v) => {
|
||||
const linkHref = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, linkHref } });
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextDimensionInput
|
||||
label="Width"
|
||||
defaultValue={data.props?.width}
|
||||
onChange={(width) => updateData({ ...data, props: { ...data.props, width } })}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Height"
|
||||
defaultValue={data.props?.height}
|
||||
onChange={(height) => updateData({ ...data, props: { ...data.props, height } })}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? 'middle'}
|
||||
onChange={(contentAlignment) => updateData({ ...data, props: { ...data.props, contentAlignment } })}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={Boolean(props.embed)}
|
||||
onChange={(e) => updateData({ ...data, props: { ...data.props, embed: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label="Embed inline (CID)"
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HeightOutlined } from '@mui/icons-material';
|
||||
import { SpacerProps, SpacerPropsDefaults, SpacerPropsSchema } from '@usewaypoint/block-spacer';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
|
||||
type SpacerSidebarPanelProps = {
|
||||
data: SpacerProps;
|
||||
setData: (v: SpacerProps) => void;
|
||||
};
|
||||
export default function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = SpacerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Spacer block">
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
min={4}
|
||||
max={128}
|
||||
defaultValue={data.props?.height ?? SpacerPropsDefaults.height}
|
||||
onChange={(height) => updateData({ ...data, props: { ...data.props, height } })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TextProps, TextPropsSchema } from '@usewaypoint/block-text';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import BooleanInput from './helpers/inputs/BooleanInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type TextSidebarPanelProps = {
|
||||
data: TextProps;
|
||||
setData: (v: TextProps) => void;
|
||||
};
|
||||
export default function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = TextPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Text block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.text ?? ''}
|
||||
onChange={(text) => updateData({ ...data, props: { ...data.props, text } })}
|
||||
/>
|
||||
<BooleanInput
|
||||
label="Markdown"
|
||||
defaultValue={data.props?.markdown ?? false}
|
||||
onChange={(markdown) => updateData({ ...data, props: { ...data.props, markdown } })}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontSize', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
|
||||
type SidebarPanelProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export default function BaseSidebarPanel({ title, children }: SidebarPanelProps) {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={5} mb={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormControlLabel, Switch } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function BooleanInput({ label, defaultValue, onChange }: Props) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
label={label}
|
||||
control={
|
||||
<Switch
|
||||
checked={defaultValue}
|
||||
onChange={(_, checked: boolean) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AddOutlined, CloseOutlined } from '@mui/icons-material';
|
||||
import { ButtonBase, InputLabel, Menu, Stack } from '@mui/material';
|
||||
|
||||
import Picker from './Picker';
|
||||
|
||||
const BUTTON_SX = {
|
||||
border: '1px solid',
|
||||
borderColor: 'cadet.400',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '4px',
|
||||
bgcolor: '#FFFFFF',
|
||||
};
|
||||
|
||||
type Props =
|
||||
| {
|
||||
nullable: true;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
defaultValue: string | null;
|
||||
}
|
||||
| {
|
||||
nullable: false;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput({ label, defaultValue, onChange, nullable }: Props) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleClickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const renderResetButton = () => {
|
||||
if (!nullable) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => {
|
||||
setValue(null);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined fontSize="small" sx={{ color: 'grey.600' }} />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOpenButton = () => {
|
||||
if (value) {
|
||||
return <ButtonBase onClick={handleClickOpen} sx={{ ...BUTTON_SX, bgcolor: value }} />;
|
||||
}
|
||||
return (
|
||||
<ButtonBase onClick={handleClickOpen} sx={{ ...BUTTON_SX }}>
|
||||
<AddOutlined fontSize="small" />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel sx={{ mb: 0.5 }}>{label}</InputLabel>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{renderOpenButton()}
|
||||
{renderResetButton()}
|
||||
</Stack>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
MenuListProps={{
|
||||
sx: { height: 'auto', padding: 0 },
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
value={value || ''}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { Box, Stack, SxProps } from '@mui/material';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
const DEFAULT_PRESET_COLORS = [
|
||||
'#E11D48',
|
||||
'#DB2777',
|
||||
'#C026D3',
|
||||
'#9333EA',
|
||||
'#7C3AED',
|
||||
'#4F46E5',
|
||||
'#2563EB',
|
||||
'#0284C7',
|
||||
'#0891B2',
|
||||
'#0D9488',
|
||||
'#059669',
|
||||
'#16A34A',
|
||||
'#65A30D',
|
||||
'#CA8A04',
|
||||
'#D97706',
|
||||
'#EA580C',
|
||||
'#DC2626',
|
||||
'#FFFFFF',
|
||||
'#FAFAFA',
|
||||
'#F5F5F5',
|
||||
'#E5E5E5',
|
||||
'#D4D4D4',
|
||||
'#A3A3A3',
|
||||
'#737373',
|
||||
'#525252',
|
||||
'#404040',
|
||||
'#262626',
|
||||
'#171717',
|
||||
'#0A0A0A',
|
||||
'#000000',
|
||||
];
|
||||
|
||||
const SX: SxProps = {
|
||||
p: 1,
|
||||
'.react-colorful__pointer ': {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'.react-colorful__saturation': {
|
||||
mb: 1,
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'.react-colorful__last-control': {
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'.react-colorful__hue-pointer': {
|
||||
width: '4px',
|
||||
borderRadius: '4px',
|
||||
height: 24,
|
||||
cursor: 'col-resize',
|
||||
},
|
||||
'.react-colorful__saturation-pointer': {
|
||||
cursor: 'all-scroll',
|
||||
},
|
||||
input: {
|
||||
padding: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function Picker({ value, onChange }: Props) {
|
||||
return (
|
||||
<Stack spacing={1} sx={SX}>
|
||||
<HexColorPicker color={value} onChange={onChange} />
|
||||
<Swatch paletteColors={DEFAULT_PRESET_COLORS} value={value} onChange={onChange} />
|
||||
<Box pt={1}>
|
||||
<HexColorInput prefixed color={value} onChange={onChange} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Button, SxProps } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
paletteColors: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const TILE_BUTTON: SxProps = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
};
|
||||
export default function Swatch({ paletteColors, value, onChange }: Props) {
|
||||
const renderButton = (colorValue: string) => {
|
||||
return (
|
||||
<Button
|
||||
key={colorValue}
|
||||
onClick={() => onChange(colorValue)}
|
||||
sx={{
|
||||
...TILE_BUTTON,
|
||||
backgroundColor: colorValue,
|
||||
border: '1px solid',
|
||||
borderColor: value === colorValue ? 'black' : 'grey.200',
|
||||
minWidth: 24,
|
||||
display: 'inline-flex',
|
||||
'&:hover': {
|
||||
backgroundColor: colorValue,
|
||||
borderColor: 'grey.500',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Box width="100%" sx={{ display: 'grid', gap: 1, gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
||||
{paletteColors.map((c) => renderButton(c))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import BaseColorInput from './BaseColorInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput(props: Props) {
|
||||
return <BaseColorInput {...props} nullable={false} />;
|
||||
}
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableColorInput(props: NullableProps) {
|
||||
return <BaseColorInput {...props} nullable />;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Stack } from '@mui/material';
|
||||
|
||||
import TextDimensionInput from './TextDimensionInput';
|
||||
|
||||
export const DEFAULT_2_COLUMNS = [6] as [number];
|
||||
export const DEFAULT_3_COLUMNS = [4, 8] as [number, number];
|
||||
|
||||
type TWidthValue = number | null | undefined;
|
||||
type FixedWidths = [
|
||||
//
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
];
|
||||
type ColumnsLayoutInputProps = {
|
||||
defaultValue: FixedWidths | null | undefined;
|
||||
onChange: (v: FixedWidths | null | undefined) => void;
|
||||
};
|
||||
export default function ColumnWidthsInput({ defaultValue, onChange }: ColumnsLayoutInputProps) {
|
||||
const [currentValue, setCurrentValue] = useState<[TWidthValue, TWidthValue, TWidthValue]>(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return [null, null, null];
|
||||
});
|
||||
|
||||
const setIndexValue = (index: 0 | 1 | 2, value: number | null | undefined) => {
|
||||
const nValue: FixedWidths = [...currentValue];
|
||||
nValue[index] = value;
|
||||
setCurrentValue(nValue);
|
||||
onChange(nValue);
|
||||
};
|
||||
|
||||
const columnsCountValue = 3;
|
||||
let column3 = null;
|
||||
if (columnsCountValue === 3) {
|
||||
column3 = (
|
||||
<TextDimensionInput
|
||||
label="Column 3"
|
||||
defaultValue={currentValue?.[2]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(2, v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextDimensionInput
|
||||
label="Column 1"
|
||||
defaultValue={currentValue?.[0]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(0, v);
|
||||
}}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Column 2"
|
||||
defaultValue={currentValue?.[1]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(1, v);
|
||||
}}
|
||||
/>
|
||||
{column3}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { MenuItem, TextField } from '@mui/material';
|
||||
|
||||
import { FONT_FAMILIES } from '../../../../../../documents/blocks/helpers/fontFamily';
|
||||
|
||||
const OPTIONS = FONT_FAMILIES.map((option) => (
|
||||
<MenuItem key={option.key} value={option.key} sx={{ fontFamily: option.value }}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableFontFamily({ label, onChange, defaultValue }: NullableProps) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'inherit');
|
||||
return (
|
||||
<TextField
|
||||
select
|
||||
variant="standard"
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v === null ? null : v);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="inherit">Match email settings</MenuItem>
|
||||
{OPTIONS}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TextFieldsOutlined } from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
export default function FontSizeInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleChange = (value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
};
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
iconLabel={<TextFieldsOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value}
|
||||
setValue={handleChange}
|
||||
units="px"
|
||||
step={1}
|
||||
min={10}
|
||||
max={48}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
export default function FontWeightInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(fontWeight) => {
|
||||
setValue(fontWeight);
|
||||
onChange(fontWeight);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="normal">Regular</ToggleButton>
|
||||
<ToggleButton value="bold">Bold</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
AlignHorizontalLeftOutlined,
|
||||
AlignHorizontalRightOutlined,
|
||||
AlignVerticalBottomOutlined,
|
||||
AlignVerticalTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type TPaddingValue = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
left: number;
|
||||
};
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: TPaddingValue | null;
|
||||
onChange: (value: TPaddingValue) => void;
|
||||
};
|
||||
export default function PaddingInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
};
|
||||
});
|
||||
|
||||
function handleChange(internalName: keyof TPaddingValue, nValue: number) {
|
||||
const v = {
|
||||
...value,
|
||||
[internalName]: nValue,
|
||||
};
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} alignItems="flex-start" pb={1}>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalTopOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.top}
|
||||
setValue={(num) => handleChange('top', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalBottomOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.bottom}
|
||||
setValue={(num) => handleChange('bottom', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalLeftOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.left}
|
||||
setValue={(num) => handleChange('left', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalRightOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.right}
|
||||
setValue={(num) => handleChange('right', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string | JSX.Element;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
defaultValue: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function RadioGroupInput({ label, children, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
value={value}
|
||||
size="small"
|
||||
onChange={(_, v: unknown) => {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error('RadioGroupInput can only receive string values');
|
||||
}
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type SliderInputProps = {
|
||||
label: string;
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function SliderInput({ label, defaultValue, onChange, ...props }: SliderInputProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
value={value}
|
||||
setValue={(value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FormatAlignCenterOutlined, FormatAlignLeftOutlined, FormatAlignRightOutlined } from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
export default function TextAlignInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'left');
|
||||
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="left">
|
||||
<FormatAlignLeftOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="center">
|
||||
<FormatAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="right">
|
||||
<FormatAlignRightOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TextField, Typography } from '@mui/material';
|
||||
|
||||
type TextDimensionInputProps = {
|
||||
label: string;
|
||||
defaultValue: number | null | undefined;
|
||||
onChange: (v: number | null) => void;
|
||||
};
|
||||
export default function TextDimensionInput({ label, defaultValue, onChange }: TextDimensionInputProps) {
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const value = parseInt(ev.target.value);
|
||||
onChange(isNaN(value) ? null : value);
|
||||
};
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
onChange={handleChange}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
variant="standard"
|
||||
placeholder="auto"
|
||||
size="small"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
px
|
||||
</Typography>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputProps, TextField } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
helperText?: string | JSX.Element;
|
||||
InputProps?: InputProps;
|
||||
defaultValue: string;
|
||||
className?: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, className, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const isMultiline = typeof rows === 'number' && rows > 1;
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={isMultiline}
|
||||
minRows={rows}
|
||||
variant={isMultiline ? 'outlined' : 'standard'}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
InputProps={InputProps}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Slider, Stack, Typography } from '@mui/material';
|
||||
|
||||
type SliderInputProps = {
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
value: number;
|
||||
setValue: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function RawSliderInput({ iconLabel, value, setValue, units, ...props }: SliderInputProps) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={2} justifyContent="space-between" width="100%">
|
||||
<Box sx={{ minWidth: 24, lineHeight: 1, flexShrink: 0 }}>{iconLabel}</Box>
|
||||
<Slider
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(_, value: unknown) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error('RawSliderInput values can only receive numeric values');
|
||||
}
|
||||
setValue(value);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ minWidth: 32, textAlign: 'right', flexShrink: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1 }}>
|
||||
{value}
|
||||
{units}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';
|
||||
|
||||
import SingleStylePropertyPanel from './SingleStylePropertyPanel';
|
||||
|
||||
type MultiStylePropertyPanelProps = {
|
||||
names: (keyof TStyle)[];
|
||||
value: TStyle | undefined | null;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function MultiStylePropertyPanel({ names, value, onChange }: MultiStylePropertyPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{names.map((name) => (
|
||||
<SingleStylePropertyPanel key={name} name={name} value={value || {}} onChange={onChange} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RoundedCornerOutlined } from '@mui/icons-material';
|
||||
|
||||
import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';
|
||||
import { NullableColorInput } from '../inputs/ColorInput';
|
||||
import { NullableFontFamily } from '../inputs/FontFamily';
|
||||
import FontSizeInput from '../inputs/FontSizeInput';
|
||||
import FontWeightInput from '../inputs/FontWeightInput';
|
||||
import PaddingInput from '../inputs/PaddingInput';
|
||||
import SliderInput from '../inputs/SliderInput';
|
||||
import TextAlignInput from '../inputs/TextAlignInput';
|
||||
|
||||
type StylePropertyPanelProps = {
|
||||
name: keyof TStyle;
|
||||
value: TStyle;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function SingleStylePropertyPanel({ name, value, onChange }: StylePropertyPanelProps) {
|
||||
const defaultValue = value[name] ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = (v: any) => {
|
||||
onChange({ ...value, [name]: v });
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'backgroundColor':
|
||||
return <NullableColorInput label="Background color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'borderColor':
|
||||
return <NullableColorInput label="Border color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'borderRadius':
|
||||
return (
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Border radius"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case 'color':
|
||||
return <NullableColorInput label="Text color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontFamily':
|
||||
return <NullableFontFamily label="Font family" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontSize':
|
||||
return <FontSizeInput label="Font size" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontWeight':
|
||||
return <FontWeightInput label="Font weight" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'textAlign':
|
||||
return <TextAlignInput label="Alignment" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'padding':
|
||||
return <PaddingInput label="Padding" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setDocument, useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
import EmailLayoutSidebarPanel from './ConfigurationPanel/input-panels/EmailLayoutSidebarPanel';
|
||||
|
||||
export default function StylesPanel() {
|
||||
const block = useDocument().root;
|
||||
if (!block) {
|
||||
return <p>Block not found</p>;
|
||||
}
|
||||
|
||||
const { data, type } = block;
|
||||
if (type !== 'EmailLayout') {
|
||||
throw new Error('Expected "root" element to be of type EmailLayout');
|
||||
}
|
||||
|
||||
return <EmailLayoutSidebarPanel key="root" data={data} setData={(data) => setDocument({ root: { type, data } })} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppRegistrationOutlined, LastPageOutlined } from '@mui/icons-material';
|
||||
import { IconButton } from '@mui/material';
|
||||
|
||||
import { toggleInspectorDrawerOpen, useInspectorDrawerOpen } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function ToggleInspectorPanelButton() {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const handleClick = () => {
|
||||
toggleInspectorDrawerOpen();
|
||||
};
|
||||
if (inspectorDrawerOpen) {
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<LastPageOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<AppRegistrationOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
Box, Drawer, Tab, Tabs,
|
||||
} from '@mui/material';
|
||||
|
||||
import { setSidebarTab, useInspectorDrawerOpen, useSelectedSidebarTab } from '../../documents/editor/EditorContext';
|
||||
|
||||
import ConfigurationPanel from './ConfigurationPanel';
|
||||
import StylesPanel from './StylesPanel';
|
||||
|
||||
export const INSPECTOR_DRAWER_WIDTH = 320;
|
||||
|
||||
export default function InspectorDrawer() {
|
||||
const selectedSidebarTab = useSelectedSidebarTab();
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const renderCurrentSidebarPanel = () => {
|
||||
switch (selectedSidebarTab) {
|
||||
case 'block-configuration':
|
||||
return <ConfigurationPanel />;
|
||||
case 'styles':
|
||||
return <StylesPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
className="sidebar"
|
||||
open={inspectorDrawerOpen}
|
||||
sx={{
|
||||
width: inspectorDrawerOpen ? INSPECTOR_DRAWER_WIDTH : 0,
|
||||
}}
|
||||
// Make the drawer relative to the wrapper instead of body.
|
||||
PaperProps={{ style: { position: 'absolute', zIndex: 0 } }}
|
||||
ModalProps={{
|
||||
container: document.querySelector('.email-builder-container'),
|
||||
style: { position: 'absolute', zIndex: 0 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
width: INSPECTOR_DRAWER_WIDTH, height: 49, borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box px={2}>
|
||||
<Tabs value={selectedSidebarTab} onChange={(_, v) => setSidebarTab(v)}>
|
||||
<Tab value="styles" label="Styles" />
|
||||
<Tab value="block-configuration" label="Inspect" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ width: INSPECTOR_DRAWER_WIDTH, height: 'calc(100% - 49px)', overflow: 'auto' }}>
|
||||
{renderCurrentSidebarPanel()}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { FileDownloadOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
|
||||
import { useDocument } from '../../../documents/editor/EditorContext';
|
||||
|
||||
export default function DownloadJson() {
|
||||
const doc = useDocument();
|
||||
const href = useMemo(() => {
|
||||
return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`;
|
||||
}, [doc]);
|
||||
return (
|
||||
<Tooltip title="Download JSON file">
|
||||
<IconButton href={href} download="emailTemplate.json">
|
||||
<FileDownloadOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
import { renderHtmlWithMeta } from '../../utils';
|
||||
|
||||
import HighlightedCodePanel from './helper/HighlightedCodePanel';
|
||||
|
||||
export default function HtmlPanel() {
|
||||
const document = useDocument();
|
||||
const code = useMemo(
|
||||
() => renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) }),
|
||||
[document]
|
||||
);
|
||||
return <HighlightedCodePanel type="html" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Link,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
import { resetDocument } from '../../../documents/editor/EditorContext';
|
||||
|
||||
import validateJsonStringValue from './validateJsonStringValue';
|
||||
|
||||
type ImportJsonDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
export default function ImportJsonDialog({ onClose }: ImportJsonDialogProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (ev) => {
|
||||
const v = ev.currentTarget.value;
|
||||
setValue(v);
|
||||
const { error } = validateJsonStringValue(v);
|
||||
setError(error ?? null);
|
||||
};
|
||||
|
||||
let errorAlert = null;
|
||||
if (error) {
|
||||
errorAlert = <Alert color="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose}>
|
||||
<DialogTitle>Import JSON</DialogTitle>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
const { error, data } = validateJsonStringValue(value);
|
||||
setError(error ?? null);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
resetDocument(data);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography color="text.secondary" paragraph>
|
||||
Copy and paste an EmailBuilder.js JSON (
|
||||
<Link
|
||||
href="https://gist.githubusercontent.com/jordanisip/efb61f56ba71bd36d3a9440122cb7f50/raw/30ea74a6ac7e52ebdc309bce07b71a9286ce2526/emailBuilderTemplate.json"
|
||||
target="_blank"
|
||||
underline="none"
|
||||
>
|
||||
example
|
||||
</Link>
|
||||
).
|
||||
</Typography>
|
||||
{errorAlert}
|
||||
<TextField
|
||||
error={error !== null}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
type="text"
|
||||
helperText="This will override your current template."
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
rows={10}
|
||||
multiline
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" type="submit" disabled={error !== null}>
|
||||
Import
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FileUploadOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
|
||||
import ImportJsonDialog from './ImportJsonDialog';
|
||||
|
||||
export default function ImportJson() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let dialog = null;
|
||||
if (open) {
|
||||
dialog = <ImportJsonDialog onClose={() => setOpen(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Import JSON">
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core';
|
||||
|
||||
type TResult = { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined };
|
||||
|
||||
export default function validateTextAreaValue(value: string): TResult {
|
||||
let jsonObject = undefined;
|
||||
try {
|
||||
jsonObject = JSON.parse(value);
|
||||
} catch {
|
||||
return { error: 'Invalid json' };
|
||||
}
|
||||
|
||||
const parseResult = EditorConfigurationSchema.safeParse(jsonObject);
|
||||
if (!parseResult.success) {
|
||||
return { error: 'Invalid JSON schema' };
|
||||
}
|
||||
|
||||
if (!parseResult.data.root) {
|
||||
return { error: 'Missing "root" node' };
|
||||
}
|
||||
|
||||
return { data: parseResult.data };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
import HighlightedCodePanel from './helper/HighlightedCodePanel';
|
||||
|
||||
export default function JsonPanel() {
|
||||
const document = useDocument();
|
||||
const code = useMemo(() => JSON.stringify(document, null, ' '), [document]);
|
||||
return <HighlightedCodePanel type="json" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-material';
|
||||
import { Tab, Tabs, Tooltip } from '@mui/material';
|
||||
|
||||
import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function MainTabsGroup() {
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const handleChange = (_: unknown, v: unknown) => {
|
||||
switch (v) {
|
||||
case 'json':
|
||||
case 'preview':
|
||||
case 'editor':
|
||||
case 'html':
|
||||
setSelectedMainTab(v);
|
||||
return;
|
||||
default:
|
||||
setSelectedMainTab('editor');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={selectedMainTab} onChange={handleChange}>
|
||||
<Tab
|
||||
value="editor"
|
||||
label={
|
||||
<Tooltip title="Edit">
|
||||
<EditOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="preview"
|
||||
label={
|
||||
<Tooltip title="Preview">
|
||||
<PreviewOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="html"
|
||||
label={
|
||||
<Tooltip title="HTML output">
|
||||
<CodeOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="json"
|
||||
label={
|
||||
<Tooltip title="JSON output">
|
||||
<DataObjectOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { IosShareOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Snackbar, Tooltip } from '@mui/material';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function ShareButton() {
|
||||
const document = useDocument();
|
||||
const [message, setMessage] = useState < string | null > (null);
|
||||
|
||||
const onClick = async () => {
|
||||
const c = encodeURIComponent(JSON.stringify(document));
|
||||
location.hash = `#code/${btoa(c)}`;
|
||||
setMessage('The URL was updated. Copy it to share your current template.');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={onClick}>
|
||||
<Tooltip title="Share current template">
|
||||
<IosShareOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
open={message !== null}
|
||||
onClose={onClose}
|
||||
message={message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { html, json } from './highlighters';
|
||||
|
||||
type TextEditorPanelProps = {
|
||||
type: 'json' | 'html' | 'javascript';
|
||||
value: string;
|
||||
};
|
||||
export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
switch (type) {
|
||||
case 'html':
|
||||
html(value).then(setCode);
|
||||
return;
|
||||
case 'json':
|
||||
json(value).then(setCode);
|
||||
return;
|
||||
}
|
||||
}, [setCode, value, type]);
|
||||
|
||||
if (code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{ margin: 0, padding: 16, height: '100%', overflow: 'auto' }}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
onClick={(ev) => {
|
||||
const s = window.getSelection();
|
||||
if (s === null) {
|
||||
return;
|
||||
}
|
||||
s.selectAllChildren(ev.currentTarget);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import hljs from 'highlight.js';
|
||||
import jsonHighlighter from 'highlight.js/lib/languages/json';
|
||||
import xmlHighlighter from 'highlight.js/lib/languages/xml';
|
||||
import prettierPluginBabel from 'prettier/plugins/babel';
|
||||
import prettierPluginEstree from 'prettier/plugins/estree';
|
||||
import prettierPluginHtml from 'prettier/plugins/html';
|
||||
import { format } from 'prettier/standalone';
|
||||
|
||||
hljs.registerLanguage('json', jsonHighlighter);
|
||||
hljs.registerLanguage('html', xmlHighlighter);
|
||||
|
||||
export async function html(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: 'html',
|
||||
plugins: [prettierPluginHtml],
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: 'html' }).value;
|
||||
}
|
||||
|
||||
export async function json(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: 'json',
|
||||
printWidth: 0,
|
||||
trailingComma: 'all',
|
||||
plugins: [prettierPluginBabel, prettierPluginEstree],
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: 'javascript' }).value;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MonitorOutlined, PhoneIphoneOutlined } from '@mui/icons-material';
|
||||
import { Box, Stack, SxProps, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
|
||||
import { Reader } from '@usewaypoint/email-builder';
|
||||
|
||||
import EditorBlock from '../../documents/editor/EditorBlock';
|
||||
import {
|
||||
setSelectedScreenSize,
|
||||
useDocument,
|
||||
useSelectedMainTab,
|
||||
useSelectedScreenSize,
|
||||
} from '../../documents/editor/EditorContext';
|
||||
import ToggleInspectorPanelButton from '../InspectorDrawer/ToggleInspectorPanelButton';
|
||||
|
||||
import DownloadJson from './DownloadJson';
|
||||
import HtmlPanel from './HtmlPanel';
|
||||
import ImportJson from './ImportJson';
|
||||
import JsonPanel from './JsonPanel';
|
||||
import MainTabsGroup from './MainTabsGroup';
|
||||
|
||||
export default function TemplatePanel() {
|
||||
const document = useDocument();
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const selectedScreenSize = useSelectedScreenSize();
|
||||
|
||||
let mainBoxSx: SxProps = {
|
||||
height: '100%',
|
||||
};
|
||||
if (selectedScreenSize === 'mobile') {
|
||||
mainBoxSx = {
|
||||
...mainBoxSx,
|
||||
margin: '32px auto',
|
||||
width: 370,
|
||||
height: 800,
|
||||
boxShadow:
|
||||
'rgba(33, 36, 67, 0.04) 0px 10px 20px, rgba(33, 36, 67, 0.04) 0px 2px 6px, rgba(33, 36, 67, 0.04) 0px 0px 1px',
|
||||
};
|
||||
}
|
||||
|
||||
const handleScreenSizeChange = (_: unknown, value: unknown) => {
|
||||
switch (value) {
|
||||
case 'mobile':
|
||||
case 'desktop':
|
||||
setSelectedScreenSize(value);
|
||||
return;
|
||||
default:
|
||||
setSelectedScreenSize('desktop');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainPanel = () => {
|
||||
switch (selectedMainTab) {
|
||||
case 'editor':
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<EditorBlock id="root" />
|
||||
</Box>
|
||||
);
|
||||
case 'preview':
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<Reader document={document} rootBlockId="root" />
|
||||
</Box>
|
||||
);
|
||||
case 'html':
|
||||
return <HtmlPanel />;
|
||||
case 'json':
|
||||
return <JsonPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
height: 49,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'white',
|
||||
top: 0,
|
||||
px: 1,
|
||||
}}
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack px={2} direction="row" gap={2} width="100%" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" spacing={2}>
|
||||
<MainTabsGroup />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<DownloadJson />
|
||||
<ImportJson />
|
||||
<ToggleButtonGroup value={selectedScreenSize} exclusive size="small" onChange={handleScreenSizeChange}>
|
||||
<ToggleButton value="desktop">
|
||||
<Tooltip title="Desktop view">
|
||||
<MonitorOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="mobile">
|
||||
<Tooltip title="Mobile view">
|
||||
<PhoneIphoneOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ToggleInspectorPanelButton />
|
||||
</Stack>
|
||||
<Box sx={{ height: 'calc(100vh - 49px)', overflow: 'auto', minWidth: 370 }}>{renderMainPanel()}</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Stack, useTheme } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
import { TEditorConfiguration } from '../documents/editor/core';
|
||||
import { setDocument, subscribeDocument, useInspectorDrawerOpen, useSamplesDrawerOpen } from '../documents/editor/EditorContext';
|
||||
import { renderHtmlWithMeta } from '../utils';
|
||||
import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from './InspectorDrawer';
|
||||
import TemplatePanel from './TemplatePanel';
|
||||
|
||||
export const DEFAULT_SOURCE: TEditorConfiguration = {
|
||||
"root": {
|
||||
"type": "EmailLayout",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
|
||||
function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right', open: boolean) {
|
||||
const { transitions } = useTheme();
|
||||
return transitions.create(cssProperty, {
|
||||
easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
|
||||
duration: !open ? transitions.duration.leavingScreen : transitions.duration.enteringScreen,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AppProps {
|
||||
// Initial configuration to load. Optional.
|
||||
data?: TEditorConfiguration,
|
||||
// Callback for any change in document. Optional.
|
||||
onChange?: (json: TEditorConfiguration, html: String) => void,
|
||||
// Optional height for the Stack component.
|
||||
height?: string,
|
||||
}
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
const samplesDrawerOpen = useSamplesDrawerOpen();
|
||||
|
||||
const marginLeftTransition = useDrawerTransition('margin-left', samplesDrawerOpen);
|
||||
const marginRightTransition = useDrawerTransition('margin-right', inspectorDrawerOpen);
|
||||
|
||||
if (props.data) {
|
||||
setDocument(props.data)
|
||||
} else {
|
||||
setDocument(DEFAULT_SOURCE)
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
subscribeDocument ((document) => {
|
||||
props.onChange?.(
|
||||
document,
|
||||
renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspectorDrawer />
|
||||
|
||||
<Stack
|
||||
sx={{
|
||||
marginRight: inspectorDrawerOpen ? `${INSPECTOR_DRAWER_WIDTH}px` : 0,
|
||||
transition: [marginLeftTransition, marginRightTransition].join(', '),
|
||||
height: props.height ? props.height : 'auto',
|
||||
}}
|
||||
>
|
||||
<TemplatePanel />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds, { EditorChildrenChange } from '../helpers/EditorChildrenIds';
|
||||
|
||||
import ColumnsContainerPropsSchema, { ColumnsContainerProps } from './ColumnsContainerPropsSchema';
|
||||
|
||||
const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }];
|
||||
|
||||
export default function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) {
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
const { columns, ...restProps } = props ?? {};
|
||||
const columnsValue = columns ?? EMPTY_COLUMNS;
|
||||
|
||||
const updateColumn = (columnIndex: 0 | 1 | 2, { block, blockId, childrenIds }: EditorChildrenChange) => {
|
||||
const nColumns = [...columnsValue];
|
||||
nColumns[columnIndex] = { childrenIds };
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'ColumnsContainer',
|
||||
data: ColumnsContainerPropsSchema.parse({
|
||||
style,
|
||||
props: {
|
||||
...restProps,
|
||||
columns: nColumns,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseColumnsContainer
|
||||
props={restProps}
|
||||
style={style}
|
||||
columns={[
|
||||
<EditorChildrenIds childrenIds={columns?.[0]?.childrenIds} onChange={(change) => updateColumn(0, change)} />,
|
||||
<EditorChildrenIds childrenIds={columns?.[1]?.childrenIds} onChange={(change) => updateColumn(1, change)} />,
|
||||
<EditorChildrenIds childrenIds={columns?.[2]?.childrenIds} onChange={(change) => updateColumn(2, change)} />,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container';
|
||||
|
||||
const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape;
|
||||
|
||||
const ColumnsContainerPropsSchema = z.object({
|
||||
style: BaseColumnsContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
...BasePropsShape,
|
||||
columns: z.tuple([
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
]),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>;
|
||||
export default ColumnsContainerPropsSchema;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Container as BaseContainer } from '@usewaypoint/block-container';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds from '../helpers/EditorChildrenIds';
|
||||
|
||||
import { ContainerProps } from './ContainerPropsSchema';
|
||||
|
||||
export default function ContainerEditor({ style, props }: ContainerProps) {
|
||||
const childrenIds = props?.childrenIds ?? [];
|
||||
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<BaseContainer style={style}>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'Container',
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
props: { childrenIds: childrenIds },
|
||||
},
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container';
|
||||
|
||||
const ContainerPropsSchema = z.object({
|
||||
style: BaseContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
childrenIds: z.array(z.string()).optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export default ContainerPropsSchema;
|
||||
|
||||
export type ContainerProps = z.infer<typeof ContainerPropsSchema>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds from '../helpers/EditorChildrenIds';
|
||||
|
||||
import { EmailLayoutProps } from './EmailLayoutPropsSchema';
|
||||
|
||||
function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) {
|
||||
const f = fontFamily ?? 'MODERN_SANS';
|
||||
switch (f) {
|
||||
case 'MODERN_SANS':
|
||||
return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif';
|
||||
case 'BOOK_SANS':
|
||||
return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif';
|
||||
case 'ORGANIC_SANS':
|
||||
return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif';
|
||||
case 'GEOMETRIC_SANS':
|
||||
return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif';
|
||||
case 'HEAVY_SANS':
|
||||
return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif';
|
||||
case 'ROUNDED_SANS':
|
||||
return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif';
|
||||
case 'MODERN_SERIF':
|
||||
return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif';
|
||||
case 'BOOK_SERIF':
|
||||
return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif';
|
||||
case 'MONOSPACE':
|
||||
return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace';
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailLayoutEditor(props: EmailLayoutProps) {
|
||||
const childrenIds = props.childrenIds ?? [];
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedBlockId(null);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: props.backdropColor ?? '#F5F5F5',
|
||||
color: props.textColor ?? '#262626',
|
||||
fontFamily: getFontFamily(props.fontFamily),
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.15008px',
|
||||
lineHeight: '1.5',
|
||||
margin: '0',
|
||||
padding: '32px 0',
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: props.canvasColor ?? '#FFFFFF',
|
||||
borderRadius: props.borderRadius ?? undefined,
|
||||
border: (() => {
|
||||
const v = props.borderColor;
|
||||
if (!v) {
|
||||
return undefined;
|
||||
}
|
||||
return `1px solid ${v}`;
|
||||
})(),
|
||||
}}
|
||||
role="presentation"
|
||||
cellSpacing="0"
|
||||
cellPadding="0"
|
||||
border={0}
|
||||
>
|
||||
<tbody>
|
||||
<tr style={{ width: '100%' }}>
|
||||
<td>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'EmailLayout',
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
childrenIds: childrenIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const COLOR_SCHEMA = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const FONT_FAMILY_SCHEMA = z
|
||||
.enum([
|
||||
'MODERN_SANS',
|
||||
'BOOK_SANS',
|
||||
'ORGANIC_SANS',
|
||||
'GEOMETRIC_SANS',
|
||||
'HEAVY_SANS',
|
||||
'ROUNDED_SANS',
|
||||
'MODERN_SERIF',
|
||||
'BOOK_SERIF',
|
||||
'MONOSPACE',
|
||||
])
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const EmailLayoutPropsSchema = z.object({
|
||||
backdropColor: COLOR_SCHEMA,
|
||||
borderColor: COLOR_SCHEMA,
|
||||
borderRadius: z.number().optional().nullable(),
|
||||
canvasColor: COLOR_SCHEMA,
|
||||
textColor: COLOR_SCHEMA,
|
||||
fontFamily: FONT_FAMILY_SCHEMA,
|
||||
childrenIds: z.array(z.string()).optional().nullable(),
|
||||
outlook: z.boolean().optional().nullable(),
|
||||
});
|
||||
|
||||
export default EmailLayoutPropsSchema;
|
||||
|
||||
export type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Button, SxProps, Typography } from '@mui/material';
|
||||
|
||||
type BlockMenuButtonProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'column' };
|
||||
const ICON_SX: SxProps = {
|
||||
mb: 0.75,
|
||||
width: '100%',
|
||||
bgcolor: 'cadet.200',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'cadet.300',
|
||||
};
|
||||
|
||||
export default function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
sx={BUTTON_SX}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Box sx={ICON_SX}>{icon}</Box>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Menu } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
import BlockButton from './BlockButton';
|
||||
import { BUTTONS } from './buttons';
|
||||
|
||||
type BlocksMenuProps = {
|
||||
anchorEl: HTMLElement | null;
|
||||
setAnchorEl: (v: HTMLElement | null) => void;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function BlocksMenu({ anchorEl, setAnchorEl, onSelect }: BlocksMenuProps) {
|
||||
const onClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const onClick = (block: TEditorBlock) => {
|
||||
onSelect(block);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
if (anchorEl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Box sx={{ p: 1, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr' }}>
|
||||
{BUTTONS.map((k, i) => (
|
||||
<BlockButton key={i} label={k.label} icon={k.icon} onClick={() => onClick(k.block())} />
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AddOutlined } from '@mui/icons-material';
|
||||
import { Fade, IconButton } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
buttonElement: HTMLElement | null;
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function DividerButton({ buttonElement, onClick }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function listener({ clientX, clientY }: MouseEvent) {
|
||||
if (!buttonElement) {
|
||||
return;
|
||||
}
|
||||
const rect = buttonElement.getBoundingClientRect();
|
||||
const rectY = rect.y;
|
||||
const bottomX = rect.x;
|
||||
const topX = bottomX + rect.width;
|
||||
|
||||
if (Math.abs(clientY - rectY) < 20) {
|
||||
if (bottomX < clientX && clientX < topX) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
}
|
||||
window.addEventListener('mousemove', listener);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', listener);
|
||||
};
|
||||
}, [buttonElement, setVisible]);
|
||||
|
||||
return (
|
||||
<Fade in={visible}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
p: 0.12,
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-10px)',
|
||||
bgcolor: 'brand.blue',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover, &:active, &:focus': {
|
||||
bgcolor: 'brand.blue',
|
||||
color: 'primary.contrastText',
|
||||
},
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<AddOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AddOutlined } from '@mui/icons-material';
|
||||
import { ButtonBase } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function PlaceholderButton({ onClick }: Props) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 48,
|
||||
width: '100%',
|
||||
bgcolor: 'rgba(0,0,0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<AddOutlined
|
||||
sx={{
|
||||
p: 0.12,
|
||||
bgcolor: 'brand.blue',
|
||||
borderRadius: 24,
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
fontSize="small"
|
||||
/>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
AccountCircleOutlined,
|
||||
Crop32Outlined,
|
||||
HMobiledataOutlined,
|
||||
HorizontalRuleOutlined,
|
||||
HtmlOutlined,
|
||||
ImageOutlined,
|
||||
LibraryAddOutlined,
|
||||
NotesOutlined,
|
||||
SmartButtonOutlined,
|
||||
ViewColumnOutlined,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
type TButtonProps = {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
block: () => TEditorBlock;
|
||||
};
|
||||
export const BUTTONS: TButtonProps[] = [
|
||||
{
|
||||
label: 'Heading',
|
||||
icon: <HMobiledataOutlined />,
|
||||
block: () => ({
|
||||
type: 'Heading',
|
||||
data: {
|
||||
props: { text: 'Heading' },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Text',
|
||||
icon: <NotesOutlined />,
|
||||
block: () => ({
|
||||
type: 'Text',
|
||||
data: {
|
||||
props: { text: 'My new text block' },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Button',
|
||||
icon: <SmartButtonOutlined />,
|
||||
block: () => ({
|
||||
type: 'Button',
|
||||
data: {
|
||||
props: {
|
||||
text: 'Button',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Image',
|
||||
icon: <ImageOutlined />,
|
||||
block: () => ({
|
||||
type: 'Image',
|
||||
data: {
|
||||
props: {
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Placeholder_view_vector.svg',
|
||||
alt: 'Sample product',
|
||||
contentAlignment: 'middle',
|
||||
linkHref: null,
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Avatar',
|
||||
icon: <AccountCircleOutlined />,
|
||||
block: () => ({
|
||||
type: 'Avatar',
|
||||
data: {
|
||||
props: {
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png',
|
||||
shape: 'circle',
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Divider',
|
||||
icon: <HorizontalRuleOutlined />,
|
||||
block: () => ({
|
||||
type: 'Divider',
|
||||
data: {
|
||||
style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } },
|
||||
props: {
|
||||
lineColor: '#CCCCCC',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Spacer',
|
||||
icon: <Crop32Outlined />,
|
||||
block: () => ({
|
||||
type: 'Spacer',
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Html',
|
||||
icon: <HtmlOutlined />,
|
||||
block: () => ({
|
||||
type: 'Html',
|
||||
data: {
|
||||
props: { contents: '<strong>Hello world</strong>' },
|
||||
style: {
|
||||
fontSize: 16,
|
||||
textAlign: null,
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Columns',
|
||||
icon: <ViewColumnOutlined />,
|
||||
block: () => ({
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
props: {
|
||||
columnsGap: 16,
|
||||
columnsCount: 3,
|
||||
columns: [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }],
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Container',
|
||||
icon: <LibraryAddOutlined />,
|
||||
block: () => ({
|
||||
type: 'Container',
|
||||
data: {
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
// { label: 'ProgressBar', icon: <ProgressBarOutlined />, block: () => ({}) },
|
||||
// { label: 'LoopContainer', icon: <ViewListOutlined />, block: () => ({}) },
|
||||
];
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
import BlocksMenu from './BlocksMenu';
|
||||
import DividerButton from './DividerButton';
|
||||
import PlaceholderButton from './PlaceholderButton';
|
||||
|
||||
type Props = {
|
||||
placeholder?: boolean;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function AddBlockButton({ onSelect, placeholder }: Props) {
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [buttonElement, setButtonElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setMenuAnchorEl(buttonElement);
|
||||
};
|
||||
|
||||
const renderButton = () => {
|
||||
if (placeholder) {
|
||||
return <PlaceholderButton onClick={handleButtonClick} />;
|
||||
} else {
|
||||
return <DividerButton buttonElement={buttonElement} onClick={handleButtonClick} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setButtonElement} style={{ position: 'relative' }}>
|
||||
{renderButton()}
|
||||
</div>
|
||||
<BlocksMenu anchorEl={menuAnchorEl} setAnchorEl={setMenuAnchorEl} onSelect={onSelect} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { TEditorBlock } from '../../../editor/core';
|
||||
import EditorBlock from '../../../editor/EditorBlock';
|
||||
|
||||
import AddBlockButton from './AddBlockMenu';
|
||||
|
||||
export type EditorChildrenChange = {
|
||||
blockId: string;
|
||||
block: TEditorBlock;
|
||||
childrenIds: string[];
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `block-${Date.now()}`;
|
||||
}
|
||||
|
||||
export type EditorChildrenIdsProps = {
|
||||
childrenIds: string[] | null | undefined;
|
||||
onChange: (val: EditorChildrenChange) => void;
|
||||
};
|
||||
export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) {
|
||||
const appendBlock = (block: TEditorBlock) => {
|
||||
const blockId = generateId();
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: [...(childrenIds || []), blockId],
|
||||
});
|
||||
};
|
||||
|
||||
const insertBlock = (block: TEditorBlock, index: number) => {
|
||||
const blockId = generateId();
|
||||
const newChildrenIds = [...(childrenIds || [])];
|
||||
newChildrenIds.splice(index, 0, blockId);
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: newChildrenIds,
|
||||
});
|
||||
};
|
||||
|
||||
if (!childrenIds || childrenIds.length === 0) {
|
||||
return <AddBlockButton placeholder onSelect={appendBlock} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{childrenIds.map((childId, i) => (
|
||||
<Fragment key={childId}>
|
||||
<AddBlockButton onSelect={(block) => insertBlock(block, i)} />
|
||||
<EditorBlock id={childId} />
|
||||
</Fragment>
|
||||
))}
|
||||
<AddBlockButton onSelect={appendBlock} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type TStyle = {
|
||||
backgroundColor?: any;
|
||||
borderColor?: any;
|
||||
borderRadius?: any;
|
||||
color?: any;
|
||||
fontFamily?: any;
|
||||
fontSize?: any;
|
||||
fontWeight?: any;
|
||||
padding?: any;
|
||||
textAlign?: any;
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
import { useCurrentBlockId } from '../../../editor/EditorBlock';
|
||||
import { setSelectedBlockId, useSelectedBlockId } from '../../../editor/EditorContext';
|
||||
|
||||
import TuneMenu from './TuneMenu';
|
||||
|
||||
type TEditorBlockWrapperProps = {
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) {
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
const [mouseInside, setMouseInside] = useState(false);
|
||||
const blockId = useCurrentBlockId();
|
||||
|
||||
let outline: CSSProperties['outline'];
|
||||
if (selectedBlockId === blockId) {
|
||||
outline = '2px solid rgba(0,121,204, 1)';
|
||||
} else if (mouseInside) {
|
||||
outline = '2px solid rgba(0,121,204, 0.3)';
|
||||
}
|
||||
|
||||
const renderMenu = () => {
|
||||
if (selectedBlockId !== blockId) {
|
||||
return null;
|
||||
}
|
||||
return <TuneMenu blockId={blockId} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
outlineOffset: '-1px',
|
||||
outline,
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
setMouseInside(true);
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseInside(false);
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
setSelectedBlockId(blockId);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
{renderMenu()}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
import { TStyle } from '../TStyle';
|
||||
|
||||
type TReaderBlockWrapperProps = {
|
||||
style: TStyle;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) {
|
||||
const { padding, borderColor, ...restStyle } = style;
|
||||
const cssStyle: CSSProperties = {
|
||||
...restStyle,
|
||||
};
|
||||
|
||||
if (padding) {
|
||||
const { top, bottom, left, right } = padding;
|
||||
cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
}
|
||||
|
||||
if (borderColor) {
|
||||
cssStyle.border = `1px solid ${borderColor}`;
|
||||
}
|
||||
|
||||
return <div style={{ maxWidth: '100%', ...cssStyle }}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ArrowDownwardOutlined, ArrowUpwardOutlined, ContentCopyOutlined, DeleteOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Paper, Stack, SxProps, Tooltip } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../editor/core';
|
||||
import { resetDocument, setSelectedBlockId, useDocument } from '../../../editor/EditorContext';
|
||||
import { ColumnsContainerProps } from '../../ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
|
||||
const sx: SxProps = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -56,
|
||||
borderRadius: 64,
|
||||
paddingX: 0.5,
|
||||
paddingY: 1
|
||||
};
|
||||
|
||||
type Props = {
|
||||
blockId: string;
|
||||
};
|
||||
export default function TuneMenu({ blockId }: Props) {
|
||||
const document = useDocument();
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
const filterChildrenIds = (childrenIds: string[] | null | undefined) => {
|
||||
if (!childrenIds) {
|
||||
return childrenIds;
|
||||
}
|
||||
return childrenIds.filter((f) => f !== blockId);
|
||||
};
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
switch (block.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: filterChildrenIds(block.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: filterChildrenIds(block.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: filterChildrenIds(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
delete nDocument[blockId];
|
||||
resetDocument(nDocument);
|
||||
};
|
||||
|
||||
const handleDuplicateClick = () => {
|
||||
const block = document[blockId] as TEditorBlock;
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively clone a block and all its descendants, assigning new IDs.
|
||||
const clones: Record<string, TEditorBlock> = {};
|
||||
const cloneBlock = (srcId: string): string => {
|
||||
const newId = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const src = document[srcId] as TEditorBlock;
|
||||
if (!src) {
|
||||
return newId;
|
||||
}
|
||||
const cloned: TEditorBlock = JSON.parse(JSON.stringify(src));
|
||||
|
||||
// Remap childrenIds for container types so each child is also cloned.
|
||||
if (cloned.type === 'Container' && cloned.data.props?.childrenIds) {
|
||||
cloned.data.props.childrenIds = cloned.data.props.childrenIds.map(cloneBlock);
|
||||
} else if (cloned.type === 'ColumnsContainer' && cloned.data.props?.columns) {
|
||||
cloned.data.props.columns = cloned.data.props.columns.map((c) => ({
|
||||
childrenIds: c.childrenIds?.map(cloneBlock) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
clones[newId] = cloned;
|
||||
return newId;
|
||||
};
|
||||
const newBlockId = cloneBlock(blockId);
|
||||
|
||||
const insertAfter = (ids: string[] | null | undefined) => {
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
const index = ids.indexOf(blockId);
|
||||
if (index < 0) {
|
||||
return ids;
|
||||
}
|
||||
const newIds = [...ids];
|
||||
newIds.splice(index + 1, 0, newBlockId);
|
||||
return newIds;
|
||||
};
|
||||
|
||||
const nDocument: typeof document = {
|
||||
...document,
|
||||
...clones,
|
||||
};
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const entry = b as TEditorBlock;
|
||||
if (id === blockId || id in clones) {
|
||||
continue;
|
||||
}
|
||||
switch (entry.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...entry,
|
||||
data: {
|
||||
...entry.data,
|
||||
childrenIds: insertAfter(entry.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...entry,
|
||||
data: {
|
||||
...entry.data,
|
||||
props: {
|
||||
...entry.data.props,
|
||||
childrenIds: insertAfter(entry.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: entry.data.style,
|
||||
props: {
|
||||
...entry.data.props,
|
||||
columns: entry.data.props?.columns?.map((c) => ({
|
||||
childrenIds: insertAfter(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = entry;
|
||||
}
|
||||
}
|
||||
resetDocument(nDocument);
|
||||
setSelectedBlockId(newBlockId);
|
||||
};
|
||||
|
||||
const handleMoveClick = (direction: 'up' | 'down') => {
|
||||
const moveChildrenIds = (ids: string[] | null | undefined) => {
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
const index = ids.indexOf(blockId);
|
||||
if (index < 0) {
|
||||
return ids;
|
||||
}
|
||||
const childrenIds = [...ids];
|
||||
if (direction === 'up' && index > 0) {
|
||||
[childrenIds[index], childrenIds[index - 1]] = [childrenIds[index - 1], childrenIds[index]];
|
||||
} else if (direction === 'down' && index < childrenIds.length - 1) {
|
||||
[childrenIds[index], childrenIds[index + 1]] = [childrenIds[index + 1], childrenIds[index]];
|
||||
}
|
||||
return childrenIds;
|
||||
};
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
switch (block.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: moveChildrenIds(block.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: moveChildrenIds(block.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: moveChildrenIds(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
|
||||
resetDocument(nDocument);
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={sx} onClick={(ev) => ev.stopPropagation()}>
|
||||
<Stack>
|
||||
<Tooltip title="Move up" placement="left-start">
|
||||
<IconButton onClick={() => handleMoveClick('up')} sx={{ color: 'text.primary' }}>
|
||||
<ArrowUpwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down" placement="left-start">
|
||||
<IconButton onClick={() => handleMoveClick('down')} sx={{ color: 'text.primary' }}>
|
||||
<ArrowDownwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate" placement="left-start">
|
||||
<IconButton onClick={handleDuplicateClick} sx={{ color: 'text.primary' }}>
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete" placement="left-start">
|
||||
<IconButton onClick={handleDeleteClick} sx={{ color: 'text.primary' }}>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const FONT_FAMILIES = [
|
||||
{
|
||||
key: 'MODERN_SANS',
|
||||
label: 'Modern sans',
|
||||
value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'BOOK_SANS',
|
||||
label: 'Book sans',
|
||||
value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'ORGANIC_SANS',
|
||||
label: 'Organic sans',
|
||||
value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'GEOMETRIC_SANS',
|
||||
label: 'Geometric sans',
|
||||
value: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'HEAVY_SANS',
|
||||
label: 'Heavy sans',
|
||||
value:
|
||||
'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'ROUNDED_SANS',
|
||||
label: 'Rounded sans',
|
||||
value:
|
||||
'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'MODERN_SERIF',
|
||||
label: 'Modern serif',
|
||||
value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif',
|
||||
},
|
||||
{
|
||||
key: 'BOOK_SERIF',
|
||||
label: 'Book serif',
|
||||
value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif',
|
||||
},
|
||||
{
|
||||
key: 'MONOSPACE',
|
||||
label: 'Monospace',
|
||||
value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
export const FONT_FAMILY_NAMES = [
|
||||
'MODERN_SANS',
|
||||
'BOOK_SANS',
|
||||
'ORGANIC_SANS',
|
||||
'GEOMETRIC_SANS',
|
||||
'HEAVY_SANS',
|
||||
'ROUNDED_SANS',
|
||||
'MODERN_SERIF',
|
||||
'BOOK_SERIF',
|
||||
'MONOSPACE',
|
||||
] as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FONT_FAMILY_NAMES } from './fontFamily';
|
||||
|
||||
export function zColor() {
|
||||
return z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
|
||||
export function zFontFamily() {
|
||||
return z.enum(FONT_FAMILY_NAMES);
|
||||
}
|
||||
|
||||
export function zFontWeight() {
|
||||
return z.enum(['bold', 'normal']);
|
||||
}
|
||||
|
||||
export function zTextAlign() {
|
||||
return z.enum(['left', 'center', 'right']);
|
||||
}
|
||||
|
||||
export function zPadding() {
|
||||
return z.object({
|
||||
top: z.number(),
|
||||
bottom: z.number(),
|
||||
right: z.number(),
|
||||
left: z.number(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
import { EditorBlock as CoreEditorBlock } from './core';
|
||||
import { useDocument } from './EditorContext';
|
||||
|
||||
const EditorBlockContext = createContext<string | null>(null);
|
||||
export const useCurrentBlockId = () => useContext(EditorBlockContext)!;
|
||||
|
||||
type EditorBlockProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id - Block id
|
||||
* @returns EditorBlock component that loads data from the EditorDocumentContext
|
||||
*/
|
||||
export default function EditorBlock({ id }: EditorBlockProps) {
|
||||
const document = useDocument();
|
||||
const block = document[id];
|
||||
if (!block) {
|
||||
throw new Error('Could not find block');
|
||||
}
|
||||
return (
|
||||
<EditorBlockContext.Provider value={id}>
|
||||
<CoreEditorBlock {...block} />
|
||||
</EditorBlockContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
|
||||
import getConfiguration from '../../getConfiguration';
|
||||
|
||||
import { TEditorConfiguration } from './core';
|
||||
|
||||
type TValue = {
|
||||
document: TEditorConfiguration;
|
||||
|
||||
selectedBlockId: string | null;
|
||||
selectedSidebarTab: 'block-configuration' | 'styles';
|
||||
selectedMainTab: 'editor' | 'preview' | 'json' | 'html';
|
||||
selectedScreenSize: 'desktop' | 'mobile';
|
||||
|
||||
inspectorDrawerOpen: boolean;
|
||||
samplesDrawerOpen: boolean;
|
||||
};
|
||||
|
||||
const editorStateStore = create(subscribeWithSelector<TValue>(() => ({
|
||||
document: getConfiguration(window.location.hash),
|
||||
selectedBlockId: null,
|
||||
selectedSidebarTab: 'styles',
|
||||
selectedMainTab: 'editor',
|
||||
selectedScreenSize: 'desktop',
|
||||
|
||||
inspectorDrawerOpen: true,
|
||||
samplesDrawerOpen: true,
|
||||
})));
|
||||
|
||||
export function useDocument() {
|
||||
return editorStateStore((s) => s.document);
|
||||
}
|
||||
|
||||
export function subscribeDocument (listener: (selectedState: TEditorConfiguration, previousSelectedState: TEditorConfiguration) => void) {
|
||||
editorStateStore.subscribe((state) => state.document, listener)
|
||||
}
|
||||
|
||||
export function useSelectedBlockId() {
|
||||
return editorStateStore((s) => s.selectedBlockId);
|
||||
}
|
||||
|
||||
export function useSelectedScreenSize() {
|
||||
return editorStateStore((s) => s.selectedScreenSize);
|
||||
}
|
||||
|
||||
export function useSelectedMainTab() {
|
||||
return editorStateStore((s) => s.selectedMainTab);
|
||||
}
|
||||
|
||||
export function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) {
|
||||
return editorStateStore.setState({ selectedMainTab });
|
||||
}
|
||||
|
||||
export function useSelectedSidebarTab() {
|
||||
return editorStateStore((s) => s.selectedSidebarTab);
|
||||
}
|
||||
|
||||
export function useInspectorDrawerOpen() {
|
||||
return editorStateStore((s) => s.inspectorDrawerOpen);
|
||||
}
|
||||
|
||||
export function useSamplesDrawerOpen() {
|
||||
return editorStateStore((s) => s.samplesDrawerOpen);
|
||||
}
|
||||
|
||||
export function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) {
|
||||
const selectedSidebarTab = selectedBlockId === null ? 'styles' : 'block-configuration';
|
||||
const options: Partial<TValue> = {};
|
||||
if (selectedBlockId !== null) {
|
||||
options.inspectorDrawerOpen = true;
|
||||
}
|
||||
return editorStateStore.setState({
|
||||
selectedBlockId,
|
||||
selectedSidebarTab,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) {
|
||||
return editorStateStore.setState({ selectedSidebarTab });
|
||||
}
|
||||
|
||||
export function resetDocument(document: TValue['document']) {
|
||||
return editorStateStore.setState({
|
||||
document,
|
||||
selectedSidebarTab: 'styles',
|
||||
selectedBlockId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function setDocument(document: TValue['document']) {
|
||||
const originalDocument = editorStateStore.getState().document;
|
||||
return editorStateStore.setState({
|
||||
document: {
|
||||
...originalDocument,
|
||||
...document,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleInspectorDrawerOpen() {
|
||||
const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen;
|
||||
return editorStateStore.setState({ inspectorDrawerOpen });
|
||||
}
|
||||
|
||||
export function toggleSamplesDrawerOpen() {
|
||||
const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen;
|
||||
return editorStateStore.setState({ samplesDrawerOpen });
|
||||
}
|
||||
|
||||
export function setSelectedScreenSize(selectedScreenSize: TValue['selectedScreenSize']) {
|
||||
return editorStateStore.setState({ selectedScreenSize });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Avatar, AvatarPropsSchema } from '@usewaypoint/block-avatar';
|
||||
import { Button, ButtonPropsSchema } from '@usewaypoint/block-button';
|
||||
import { Divider, DividerPropsSchema } from '@usewaypoint/block-divider';
|
||||
import { Heading, HeadingPropsSchema } from '@usewaypoint/block-heading';
|
||||
import { Html, HtmlPropsSchema } from '@usewaypoint/block-html';
|
||||
import { Image, ImagePropsSchema } from '@usewaypoint/block-image';
|
||||
import { Spacer, SpacerPropsSchema } from '@usewaypoint/block-spacer';
|
||||
import { Text, TextPropsSchema } from '@usewaypoint/block-text';
|
||||
import {
|
||||
buildBlockComponent,
|
||||
buildBlockConfigurationDictionary,
|
||||
buildBlockConfigurationSchema,
|
||||
} from '@usewaypoint/document-core';
|
||||
|
||||
import ColumnsContainerEditor from '../blocks/ColumnsContainer/ColumnsContainerEditor';
|
||||
import ColumnsContainerPropsSchema from '../blocks/ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
import ContainerEditor from '../blocks/Container/ContainerEditor';
|
||||
import ContainerPropsSchema from '../blocks/Container/ContainerPropsSchema';
|
||||
import EmailLayoutEditor from '../blocks/EmailLayout/EmailLayoutEditor';
|
||||
import EmailLayoutPropsSchema from '../blocks/EmailLayout/EmailLayoutPropsSchema';
|
||||
import EditorBlockWrapper from '../blocks/helpers/block-wrappers/EditorBlockWrapper';
|
||||
|
||||
// Adds an opt-in `embed` flag to the upstream Image props. The renderer
|
||||
// (frontend/email-builder/src/utils.tsx) re-tags marked <img>s with
|
||||
// `data-embed` and the backend resolves the src filename to a media item.
|
||||
export const ImgPropsSchema = ImagePropsSchema.extend({
|
||||
props: z.object({
|
||||
width: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
alt: z.string().nullable().optional(),
|
||||
linkHref: z.string().nullable().optional(),
|
||||
contentAlignment: z.enum(['top', 'middle', 'bottom']).nullable().optional(),
|
||||
embed: z.boolean().nullable().optional(),
|
||||
}).nullable().optional(),
|
||||
});
|
||||
export type EagleCastImageProps = z.infer<typeof ImgPropsSchema>;
|
||||
|
||||
const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({
|
||||
Avatar: {
|
||||
schema: AvatarPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Avatar {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Button: {
|
||||
schema: ButtonPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Button {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Container: {
|
||||
schema: ContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
ColumnsContainer: {
|
||||
schema: ColumnsContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ColumnsContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Heading: {
|
||||
schema: HeadingPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Heading {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Html: {
|
||||
schema: HtmlPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Html {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Image: {
|
||||
schema: ImgPropsSchema,
|
||||
Component: (data) => {
|
||||
const props = {
|
||||
...data,
|
||||
props: {
|
||||
...data.props,
|
||||
url: data.props?.url ?? 'https://placehold.co/600x400@2x/F8F8F8/CCC?text=Your%20image',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<EditorBlockWrapper>
|
||||
<Image {...props} />
|
||||
</EditorBlockWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
Text: {
|
||||
schema: TextPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Text {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
EmailLayout: {
|
||||
schema: EmailLayoutPropsSchema,
|
||||
Component: (p) => <EmailLayoutEditor {...p} />,
|
||||
},
|
||||
Spacer: {
|
||||
schema: SpacerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Spacer {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Divider: {
|
||||
schema: DividerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Divider {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export const EditorBlock = buildBlockComponent(EDITOR_DICTIONARY);
|
||||
export const EditorBlockSchema = buildBlockConfigurationSchema(EDITOR_DICTIONARY);
|
||||
export const EditorConfigurationSchema = z.record(z.string(), EditorBlockSchema);
|
||||
|
||||
export type TEditorBlock = z.infer<typeof EditorBlockSchema>;
|
||||
export type TEditorConfiguration = Record<string, TEditorBlock>;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 415 B |
Binary file not shown.
|
After Width: | Height: | Size: 722 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,15 @@
|
||||
import EMPTY_EMAIL_MESSAGE from './sample/empty-email-message';
|
||||
|
||||
export default function getConfiguration(template: string) {
|
||||
if (template.startsWith('#code/')) {
|
||||
const encodedString = template.replace('#code/', '');
|
||||
const configurationString = decodeURIComponent(atob(encodedString));
|
||||
try {
|
||||
return JSON.parse(configurationString);
|
||||
} catch {
|
||||
console.error(`Couldn't load configuration from hash.`);
|
||||
}
|
||||
}
|
||||
|
||||
return EMPTY_EMAIL_MESSAGE;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TEditorConfiguration } from '../../documents/editor/core';
|
||||
|
||||
const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = {
|
||||
root: {
|
||||
type: 'EmailLayout',
|
||||
data: {
|
||||
backdropColor: '#F5F5F5',
|
||||
canvasColor: '#FFFFFF',
|
||||
textColor: '#262626',
|
||||
fontFamily: 'MODERN_SANS',
|
||||
childrenIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default EMPTY_EMAIL_MESSAGE;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App, { AppProps, DEFAULT_SOURCE } from './App';
|
||||
import { setDocument, resetDocument } from './documents/editor/EditorContext';
|
||||
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import theme from './theme';
|
||||
|
||||
function isRendered(containerId: string): boolean {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error(`Container with id ${containerId} not found`);
|
||||
return false;
|
||||
}
|
||||
return container.hasChildNodes();
|
||||
}
|
||||
|
||||
function render(containerId: string, props: AppProps, force: boolean = false) {
|
||||
if (!isRendered(containerId) || force) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App {...props} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { App, setDocument, resetDocument, render, isRendered, DEFAULT_SOURCE };
|
||||
@@ -0,0 +1,402 @@
|
||||
type TStyleMap = Record<string, string>;
|
||||
type TPaddingValues = {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const PRESENTATION_TABLE_STYLE = 'border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;';
|
||||
|
||||
function appendMissingStyles(style: string | null, declarations: Array<[string, string]>) {
|
||||
const current = (style || '').trim();
|
||||
const lower = current.toLowerCase();
|
||||
const missing = declarations
|
||||
.filter(([property]) => !lower.includes(`${property.toLowerCase()}:`))
|
||||
.map(([property, value]) => `${property}:${value}`);
|
||||
|
||||
if (missing.length === 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [current.replace(/;+\s*$/, ''), ...missing]
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function setStyleValues(style: string | null, declarations: Array<[string, string | null]>) {
|
||||
const styleMap = parseStyleMap(style);
|
||||
|
||||
declarations.forEach(([property, value]) => {
|
||||
const key = property.toLowerCase();
|
||||
if (value === null || value === '') {
|
||||
delete styleMap[key];
|
||||
return;
|
||||
}
|
||||
|
||||
styleMap[key] = value;
|
||||
});
|
||||
|
||||
return Object.entries(styleMap)
|
||||
.map(([property, value]) => `${property}:${value}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function parseStyleMap(style: string | null) {
|
||||
return (style || '')
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<TStyleMap>((acc, entry) => {
|
||||
const separator = entry.indexOf(':');
|
||||
if (separator === -1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const property = entry.slice(0, separator).trim().toLowerCase();
|
||||
const value = entry.slice(separator + 1).trim();
|
||||
if (property) {
|
||||
acc[property] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getPixelValue(value?: string) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(Number(match[1]));
|
||||
}
|
||||
|
||||
function getPixelWidthFromImage(img: HTMLImageElement) {
|
||||
const attrWidth = img.getAttribute('width');
|
||||
if (attrWidth && /^\d+$/.test(attrWidth)) {
|
||||
return attrWidth;
|
||||
}
|
||||
|
||||
const style = img.getAttribute('style') || '';
|
||||
const widthMatch = style.match(/(?:^|;)\s*width\s*:\s*(\d+)px(?:;|$)/i);
|
||||
if (widthMatch) {
|
||||
return widthMatch[1];
|
||||
}
|
||||
|
||||
const maxWidthMatch = style.match(/(?:^|;)\s*max-width\s*:\s*(\d+)px(?:;|$)/i);
|
||||
if (maxWidthMatch) {
|
||||
return maxWidthMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPaddingValues(styleMap: TStyleMap): TPaddingValues {
|
||||
const shorthand = styleMap.padding?.trim().split(/\s+/) || [];
|
||||
|
||||
const [
|
||||
topFromShorthand,
|
||||
rightFromShorthand = topFromShorthand,
|
||||
bottomFromShorthand = topFromShorthand,
|
||||
leftFromShorthand = rightFromShorthand,
|
||||
] = shorthand;
|
||||
|
||||
return {
|
||||
top: getPixelValue(styleMap['padding-top'] || topFromShorthand) || 0,
|
||||
right: getPixelValue(styleMap['padding-right'] || rightFromShorthand) || 0,
|
||||
bottom: getPixelValue(styleMap['padding-bottom'] || bottomFromShorthand) || 0,
|
||||
left: getPixelValue(styleMap['padding-left'] || leftFromShorthand) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return escapeHtml(value).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createFragmentFromHtml(node: Element, html: string) {
|
||||
const range = node.ownerDocument.createRange();
|
||||
range.selectNode(node);
|
||||
return range.createContextualFragment(html);
|
||||
}
|
||||
|
||||
function replaceNodeWithHtml(node: Element, html: string) {
|
||||
node.replaceWith(createFragmentFromHtml(node, html));
|
||||
}
|
||||
|
||||
function escapeTemplateString(value: string) {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function makeSafeTemplate(raw: string) {
|
||||
// Encode angle brackets so DOMParser does not consume Outlook conditional comments
|
||||
// before the Go template expression is evaluated.
|
||||
const escaped = escapeTemplateString(raw)
|
||||
.replace(/</g, '\\x3c')
|
||||
.replace(/>/g, '\\x3e');
|
||||
|
||||
return `{{ Safe "${escaped}" }}`;
|
||||
}
|
||||
|
||||
function getWrapperOptions(style: string | null) {
|
||||
const styleValue = style || '';
|
||||
const styleMap = parseStyleMap(styleValue);
|
||||
const align = styleMap['text-align'] || 'left';
|
||||
const backgroundColor = styleMap['background-color'];
|
||||
const bgcolorAttr = backgroundColor ? ` bgcolor="${escapeAttribute(backgroundColor)}"` : '';
|
||||
|
||||
return { styleValue, styleMap, align, bgcolorAttr };
|
||||
}
|
||||
|
||||
function buildPresentationTable(contents: string, width: string = '100%') {
|
||||
const widthAttr = width && width !== 'auto' ? ` width="${escapeAttribute(width)}"` : '';
|
||||
|
||||
return `<table role="presentation"${widthAttr} cellpadding="0" cellspacing="0" border="0" style="${PRESENTATION_TABLE_STYLE}">${contents}</table>`;
|
||||
}
|
||||
|
||||
function hasSingleChildMatching(div: HTMLDivElement, predicate: (child: Element) => boolean) {
|
||||
const children = Array.from(div.children);
|
||||
return children.length === 1 && predicate(children[0]);
|
||||
}
|
||||
|
||||
function addTableDefaults(doc: Document) {
|
||||
doc.querySelectorAll('table[role="presentation"]').forEach((table) => {
|
||||
if (!table.getAttribute('cellpadding')) {
|
||||
table.setAttribute('cellpadding', '0');
|
||||
}
|
||||
if (!table.getAttribute('cellspacing')) {
|
||||
table.setAttribute('cellspacing', '0');
|
||||
}
|
||||
if (!table.getAttribute('border')) {
|
||||
table.setAttribute('border', '0');
|
||||
}
|
||||
|
||||
table.setAttribute(
|
||||
'style',
|
||||
appendMissingStyles(table.getAttribute('style'), [
|
||||
['border-collapse', 'collapse'],
|
||||
['mso-table-lspace', '0pt'],
|
||||
['mso-table-rspace', '0pt'],
|
||||
])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isStandaloneImage(img: HTMLImageElement) {
|
||||
const parent = img.parentElement;
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parent.tagName === 'DIV') {
|
||||
return hasSingleChildMatching(parent as HTMLDivElement, (child) => child.tagName === 'IMG');
|
||||
}
|
||||
|
||||
if (parent.tagName === 'A' && parent.children.length === 1) {
|
||||
const grandparent = parent.parentElement;
|
||||
return grandparent?.tagName === 'DIV'
|
||||
&& hasSingleChildMatching(grandparent as HTMLDivElement, (child) => child.tagName === 'A');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hardenImages(doc: Document) {
|
||||
doc.querySelectorAll('img').forEach((img) => {
|
||||
img.setAttribute('border', '0');
|
||||
|
||||
const width = getPixelWidthFromImage(img);
|
||||
if (width && !img.getAttribute('width')) {
|
||||
img.setAttribute('width', width);
|
||||
}
|
||||
|
||||
const standaloneImage = isStandaloneImage(img);
|
||||
const declarations: Array<[string, string | null]> = [
|
||||
['border', '0'],
|
||||
['outline', 'none'],
|
||||
['text-decoration', 'none'],
|
||||
['height', 'auto'],
|
||||
['-ms-interpolation-mode', 'bicubic'],
|
||||
];
|
||||
|
||||
if (standaloneImage) {
|
||||
declarations.unshift(['display', 'block']);
|
||||
declarations.push(['vertical-align', null]);
|
||||
}
|
||||
|
||||
img.setAttribute('style', setStyleValues(img.getAttribute('style'), declarations));
|
||||
|
||||
const parent = img.parentElement;
|
||||
if (standaloneImage && parent?.tagName === 'A') {
|
||||
parent.setAttribute('style', setStyleValues(parent.getAttribute('style'), [
|
||||
['display', 'inline-block'],
|
||||
['border', '0'],
|
||||
['text-decoration', 'none'],
|
||||
]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function transformImageBlocks(doc: Document) {
|
||||
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => {
|
||||
if (child.tagName === 'IMG') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return child.tagName === 'A' && child.children.length === 1 && child.querySelector('img') !== null;
|
||||
})) as HTMLDivElement[];
|
||||
|
||||
wrappers.forEach((div) => {
|
||||
const { styleValue, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style'));
|
||||
const content = div.innerHTML;
|
||||
|
||||
const innerTable = buildPresentationTable(`<tbody><tr><td align="${escapeAttribute(align)}">${content}</td></tr></tbody>`, 'auto');
|
||||
const html = buildPresentationTable(
|
||||
`<tbody><tr><td align="${escapeAttribute(align)}"${bgcolorAttr} style="${escapeAttribute(styleValue)}">${innerTable}</td></tr></tbody>`
|
||||
);
|
||||
|
||||
replaceNodeWithHtml(div, html);
|
||||
});
|
||||
}
|
||||
|
||||
function transformSimpleDivBlocks(doc: Document) {
|
||||
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => {
|
||||
const { styleMap } = getWrapperOptions(div.getAttribute('style'));
|
||||
|
||||
if (!styleMap.padding && !styleMap.height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (div.children.length > 0) {
|
||||
const firstChild = div.children[0];
|
||||
if (firstChild.tagName === 'A' || firstChild.tagName === 'IMG' || firstChild.tagName === 'TABLE') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (styleMap['min-height'] && styleMap.width === '100%') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as HTMLDivElement[];
|
||||
|
||||
wrappers.forEach((div) => {
|
||||
const { styleValue, styleMap, align, bgcolorAttr } = getWrapperOptions(div.getAttribute('style'));
|
||||
const height = getPixelValue(styleMap.height);
|
||||
const isSpacer = div.children.length === 0 && (div.textContent || '').trim() === '' && height !== null;
|
||||
|
||||
if (isSpacer) {
|
||||
const spacerHtml = buildPresentationTable(
|
||||
`<tbody><tr><td${bgcolorAttr} height="${height}" style="${escapeAttribute(styleValue)};line-height:${height}px;font-size:${height}px;"> </td></tr></tbody>`
|
||||
);
|
||||
|
||||
replaceNodeWithHtml(div, spacerHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockHtml = buildPresentationTable(
|
||||
`<tbody><tr><td align="${escapeAttribute(align)}"${bgcolorAttr} style="${escapeAttribute(styleValue)}">${div.innerHTML}</td></tr></tbody>`
|
||||
);
|
||||
|
||||
replaceNodeWithHtml(div, blockHtml);
|
||||
});
|
||||
}
|
||||
|
||||
function buildBulletproofButton(anchor: HTMLAnchorElement, wrapperStyle: string) {
|
||||
const anchorStyleMap = parseStyleMap(anchor.getAttribute('style'));
|
||||
const wrapperStyleMap = parseStyleMap(wrapperStyle);
|
||||
const text = anchor.textContent?.replace(/\s+/g, ' ').trim() || '';
|
||||
const href = anchor.getAttribute('href') || '#';
|
||||
const target = anchor.getAttribute('target');
|
||||
const align = wrapperStyleMap['text-align'] || 'left';
|
||||
const buttonColor = anchorStyleMap['background-color'] || '#1b3a5b';
|
||||
const textColor = anchorStyleMap.color || '#ffffff';
|
||||
const fontSize = getPixelValue(anchorStyleMap['font-size']) || 16;
|
||||
const fontWeight = anchorStyleMap['font-weight'] || 'bold';
|
||||
const fontFamily = anchorStyleMap['font-family'] || 'Arial, sans-serif';
|
||||
const borderRadius = getPixelValue(anchorStyleMap['border-radius']) || 0;
|
||||
const paddingValues = getPaddingValues(anchorStyleMap);
|
||||
const lineHeight = getPixelValue(anchorStyleMap['line-height']) || Math.round(fontSize * 1.2);
|
||||
const display = (anchorStyleMap.display || '').toLowerCase();
|
||||
const fullWidth = display === 'block' || anchorStyleMap.width === '100%';
|
||||
|
||||
const targetAttr = target ? ` target="${escapeAttribute(target)}"` : '';
|
||||
|
||||
if (fullWidth) {
|
||||
const anchorStyle = setStyleValues(anchor.getAttribute('style'), [
|
||||
['display', 'block'],
|
||||
['text-align', 'center'],
|
||||
['border', '1px solid ' + buttonColor],
|
||||
]);
|
||||
|
||||
return [
|
||||
buildPresentationTable(
|
||||
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${buildPresentationTable(
|
||||
`<tbody><tr><td bgcolor="${escapeAttribute(buttonColor)}" style="background-color:${escapeAttribute(buttonColor)};border-radius:${borderRadius}px;"><a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(anchorStyle)}">${escapeHtml(text)}</a></td></tr></tbody>`
|
||||
)}</td></tr></tbody>`
|
||||
),
|
||||
].join('');
|
||||
}
|
||||
|
||||
const estimatedTextWidth = Math.max(1, Math.round(text.length * fontSize * (fontWeight.toLowerCase() === 'bold' ? 0.68 : 0.62)));
|
||||
const estimatedWidth = Math.max(40, estimatedTextWidth + paddingValues.left + paddingValues.right);
|
||||
const estimatedHeight = Math.max(lineHeight + paddingValues.top + paddingValues.bottom, 32);
|
||||
const arcsize = Math.max(0, Math.min(50, Math.round((borderRadius / estimatedHeight) * 100)));
|
||||
const cleanAnchorStyle = anchor.getAttribute('style') || '';
|
||||
const msoStart = makeSafeTemplate('<!--[if mso]>');
|
||||
const msoEnd = makeSafeTemplate('<![endif]-->');
|
||||
const vml = `<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${estimatedHeight}px;v-text-anchor:middle;width:${estimatedWidth}px;" arcsize="${arcsize}%" strokecolor="${escapeAttribute(buttonColor)}" fillcolor="${escapeAttribute(buttonColor)}"><w:anchorlock/><center style="color:${escapeAttribute(textColor)};font-family:${escapeAttribute(fontFamily)};font-size:${fontSize}px;font-weight:${escapeAttribute(fontWeight)};">${escapeHtml(text)}</center></v:roundrect>`;
|
||||
const nonMsoStart = makeSafeTemplate('<!--[if !mso]><!-->');
|
||||
const nonMsoEnd = makeSafeTemplate('<!--<![endif]-->');
|
||||
|
||||
return buildPresentationTable(
|
||||
`<tbody><tr><td align="${escapeAttribute(align)}" style="${escapeAttribute(wrapperStyle)}">${msoStart}${vml}${msoEnd}${nonMsoStart}<a href="${escapeAttribute(href)}"${targetAttr} style="${escapeAttribute(cleanAnchorStyle)}">${escapeHtml(text)}</a>${nonMsoEnd}</td></tr></tbody>`
|
||||
);
|
||||
}
|
||||
|
||||
function transformButtonBlocks(doc: Document) {
|
||||
const wrappers = Array.from(doc.querySelectorAll('div')).filter((div) => hasSingleChildMatching(div as HTMLDivElement, (child) => {
|
||||
if (child.tagName !== 'A' || child.querySelector('img')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const styleMap = parseStyleMap((child as HTMLAnchorElement).getAttribute('style'));
|
||||
return Boolean(styleMap['background-color'] && styleMap.padding);
|
||||
})) as HTMLDivElement[];
|
||||
|
||||
wrappers.forEach((div) => {
|
||||
const anchor = div.children[0] as HTMLAnchorElement;
|
||||
replaceNodeWithHtml(div, buildBulletproofButton(anchor, div.getAttribute('style') || ''));
|
||||
});
|
||||
}
|
||||
|
||||
export function postProcessForOutlook(html: string) {
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
return html;
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
addTableDefaults(doc);
|
||||
hardenImages(doc);
|
||||
transformButtonBlocks(doc);
|
||||
transformImageBlocks(doc);
|
||||
transformSimpleDivBlocks(doc);
|
||||
addTableDefaults(doc);
|
||||
hardenImages(doc);
|
||||
|
||||
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import {
|
||||
alpha, createTheme, darken, lighten,
|
||||
} from '@mui/material/styles';
|
||||
|
||||
const BRAND_NAVY = '#212443';
|
||||
const BRAND_BLUE = '#0079CC';
|
||||
const BRAND_GREEN = '#1F8466';
|
||||
const BRAND_RED = '#E81212';
|
||||
const BRAND_YELLOW = '#F6DC9F';
|
||||
const BRAND_PURPLE = '#6C0E7C';
|
||||
const BRAND_BROWN = '#CC996C';
|
||||
const STANDARD_FONT_FAMILY = 'sans-serif, "Segoe UI", Roboto, Helvetica, Arial';
|
||||
const MONOSPACE_FONT_FAMILY = 'monospace, Menlo, Monaco, "Segoe UI Mono", "Roboto Mono"';
|
||||
|
||||
const BASE_THEME = createTheme({
|
||||
palette: {
|
||||
background: {
|
||||
default: '#f2f5f7',
|
||||
},
|
||||
text: {
|
||||
primary: '#1F1F21',
|
||||
secondary: '#4F4F4F',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: STANDARD_FONT_FAMILY,
|
||||
},
|
||||
});
|
||||
|
||||
const THEME = createTheme(BASE_THEME, {
|
||||
palette: {
|
||||
brand: {
|
||||
navy: BRAND_NAVY,
|
||||
blue: BRAND_BLUE,
|
||||
red: BRAND_RED,
|
||||
green: BRAND_GREEN,
|
||||
yellow: BRAND_YELLOW,
|
||||
purple: BRAND_PURPLE,
|
||||
brown: BRAND_BROWN,
|
||||
},
|
||||
success: {
|
||||
main: BRAND_GREEN,
|
||||
light: lighten(BRAND_GREEN, 0.15),
|
||||
dark: darken(BRAND_GREEN, 0.15),
|
||||
},
|
||||
error: {
|
||||
main: BRAND_RED,
|
||||
light: lighten(BRAND_RED, 0.15),
|
||||
dark: darken(BRAND_RED, 0.15),
|
||||
},
|
||||
cadet: {
|
||||
100: '#F9FAFB',
|
||||
200: '#F2F5F7',
|
||||
300: '#DCE4EA',
|
||||
400: '#A8BBCA',
|
||||
500: '#6A8BA4',
|
||||
},
|
||||
highlight: {
|
||||
100: lighten(BRAND_YELLOW, 0.8),
|
||||
200: lighten(BRAND_YELLOW, 0.6),
|
||||
300: lighten(BRAND_YELLOW, 0.4),
|
||||
400: lighten(BRAND_YELLOW, 0.2),
|
||||
500: BRAND_YELLOW,
|
||||
},
|
||||
info: {
|
||||
main: BRAND_BLUE,
|
||||
},
|
||||
primary: {
|
||||
main: BRAND_BLUE,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: `
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
pre {
|
||||
font-family: ${MONOSPACE_FONT_FAMILY}
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
},
|
||||
action: {
|
||||
paddingTop: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
filledSuccess: {
|
||||
backgroundColor: BRAND_GREEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiStepLabel: {
|
||||
styleOverrides: {
|
||||
label: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
fullWidth: true,
|
||||
},
|
||||
},
|
||||
MuiDialogContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: BASE_THEME.spacing(1),
|
||||
paddingBottom: BASE_THEME.spacing(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
defaultProps: {
|
||||
variant: 'h4',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: BASE_THEME.spacing(3),
|
||||
paddingBottom: BASE_THEME.spacing(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialogActions: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: BASE_THEME.palette.divider,
|
||||
marginTop: BASE_THEME.spacing(2.5),
|
||||
padding: `${BASE_THEME.spacing(1.5)} ${BASE_THEME.spacing(3)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
...BASE_THEME.typography.body2,
|
||||
borderColor: BASE_THEME.palette.grey[200],
|
||||
},
|
||||
head: {
|
||||
...BASE_THEME.typography.overline,
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
letterSpacing: '0.075em',
|
||||
color: BASE_THEME.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableRow: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:last-child td': {
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'uppercase',
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&.MuiChip-filledError, &.MuiChip-filledSuccess': {
|
||||
fill: BASE_THEME.palette.primary.contrastText,
|
||||
},
|
||||
},
|
||||
sizeSmall: {
|
||||
borderRadius: BASE_THEME.spacing(0.5),
|
||||
fontSize: 12,
|
||||
},
|
||||
iconSmall: {
|
||||
fontSize: 14,
|
||||
marginLeft: BASE_THEME.spacing(1),
|
||||
},
|
||||
colorSecondary: {
|
||||
borderColor: BASE_THEME.palette.grey[400],
|
||||
color: BASE_THEME.palette.text.secondary,
|
||||
},
|
||||
label: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
defaultProps: {
|
||||
PaperProps: {
|
||||
elevation: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(12),
|
||||
backgroundColor: alpha(BASE_THEME.palette.text.primary, 0.9),
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSlider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 1,
|
||||
},
|
||||
track: {
|
||||
height: 1,
|
||||
border: 'none',
|
||||
},
|
||||
rail: {
|
||||
height: 1,
|
||||
backgroundColor: BASE_THEME.palette.grey[500],
|
||||
},
|
||||
mark: {
|
||||
backgroundColor: BASE_THEME.palette.grey[500],
|
||||
},
|
||||
markActive: {
|
||||
height: 0,
|
||||
},
|
||||
thumb: {
|
||||
height: 16,
|
||||
width: 16,
|
||||
cursor: 'col-resize',
|
||||
'&:hover, &.Mui-active, &.Mui-focusVisible': {
|
||||
boxShadow: `0 0 0 4px ${alpha(BRAND_BLUE, 0.2)}`,
|
||||
},
|
||||
'&:before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
defaultProps: {
|
||||
elevation: 2,
|
||||
square: true,
|
||||
},
|
||||
},
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableTouchRipple: true,
|
||||
focusRipple: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&.MuiButton-containedSecondary.Mui-disabled': {
|
||||
backgroundColor: BASE_THEME.palette.grey[100],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButtonGroup: {
|
||||
defaultProps: {
|
||||
disableElevation: true,
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
edgeStart: {
|
||||
marginLeft: BASE_THEME.spacing(-1),
|
||||
},
|
||||
colorSecondary: {
|
||||
color: BASE_THEME.palette.grey[500],
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableElevation: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
textPrimary: {
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
textSecondary: {
|
||||
color: BASE_THEME.palette.text.secondary,
|
||||
},
|
||||
outlinedPrimary: {
|
||||
borderColor: BASE_THEME.palette.grey[300],
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
'&:hover, &:active, &:focus': {
|
||||
borderColor: BASE_THEME.palette.grey[500],
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
},
|
||||
containedSecondary: {
|
||||
backgroundColor: BASE_THEME.palette.common.white,
|
||||
border: `1px solid ${BASE_THEME.palette.grey[300]}`,
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
'&:hover, &:active, &:focus': {
|
||||
backgroundColor: BASE_THEME.palette.common.white,
|
||||
borderColor: BASE_THEME.palette.grey[500],
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingLeft: BASE_THEME.spacing(1.5),
|
||||
paddingRight: BASE_THEME.spacing(1.5),
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:not(.Mui-disabled, .Mui-error):before': {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.grey[400]}`,
|
||||
},
|
||||
'&:hover:not(.Mui-disabled, .Mui-error):before': {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.grey[500]} !important`,
|
||||
},
|
||||
'&:after': {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.text.primary} !important`,
|
||||
},
|
||||
'&.MuiOutlinedInput-root:not(.Mui-error)': {
|
||||
'& fieldset': {
|
||||
borderColor: BASE_THEME.palette.grey[300],
|
||||
transition: 'border-color 0.2s',
|
||||
},
|
||||
},
|
||||
'&.MuiOutlinedInput-root:not(.Mui-disabled, .Mui-error)': {
|
||||
'&:hover fieldset': {
|
||||
borderColor: BASE_THEME.palette.grey[400],
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: BASE_THEME.palette.text.secondary,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
'&.Mui-disabled': {
|
||||
WebkitTextFillColor: 'inherit',
|
||||
color: BASE_THEME.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
inputSizeSmall: {},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
notchedOutline: {
|
||||
'& legend': {
|
||||
fontSize: '0.85em',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputAdornment: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiTypography-root': {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
color: BASE_THEME.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputLabel: {
|
||||
defaultProps: {
|
||||
shrink: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
shrink: {
|
||||
transform: 'scale(0.85)',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
'&.Mui-focused': {
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
'&.MuiInputLabel-standard': {
|
||||
transform: 'translate(0, -4px) scale(0.85)',
|
||||
color: '#4F4F4F',
|
||||
},
|
||||
'&.MuiInputLabel-outlined': {
|
||||
transform: 'translate(15px, -8px) scale(0.85)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
defaultProps: {
|
||||
variant: 'scrollable',
|
||||
},
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
height: 1,
|
||||
backgroundColor: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
minWidth: BASE_THEME.spacing(2),
|
||||
paddingLeft: BASE_THEME.spacing(1.5),
|
||||
paddingRight: BASE_THEME.spacing(1.5),
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
lineHeight: 1.5,
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
transition: 'color 0.2s',
|
||||
'&.Mui-selected': {
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
'&:hover': {
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardHeader: {
|
||||
styleOverrides: {
|
||||
title: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(18),
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
h1: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(40),
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.02em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
h2: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(32),
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.02em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
h3: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(24),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: '-0.01em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
h4: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(20),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: '-0.01em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
h5: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(18),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: '-0.01em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
h6: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(16),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: '-0.005em',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
},
|
||||
body1: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
},
|
||||
body2: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(12),
|
||||
},
|
||||
overline: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
caption: {
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
},
|
||||
shadows: [
|
||||
'none',
|
||||
'0px 4px 15px rgba(33, 36, 67, 0.04), 0px 0px 2px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)',
|
||||
'0px 10px 20px rgba(33, 36, 67, 0.04), 0px 2px 6px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)',
|
||||
'0px 16px 24px rgba(33, 36, 67, 0.05), 0px 2px 6px rgba(33, 36, 67, 0.05), 0px 0px 1px rgba(33, 36, 67, 0.05)',
|
||||
'0px 24px 32px rgba(33, 36, 67, 0.06), 0px 16px 24px rgba(33, 36, 67, 0.06), 0px 4px 8px rgba(33, 36, 67, 0.06)',
|
||||
...Array(20).fill('none'),
|
||||
],
|
||||
});
|
||||
|
||||
export default THEME;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderToStaticMarkup } from '@usewaypoint/email-builder';
|
||||
import { TEditorConfiguration } from './documents/editor/core';
|
||||
import { postProcessForOutlook } from './outlook';
|
||||
|
||||
const VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
const MSO_DOCUMENT_SETTINGS = '<!--[if mso]><noscript><xml xmlns:o="urn:schemas-microsoft-com:office:office"><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->';
|
||||
const HTML_ATTRIBUTE_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
};
|
||||
|
||||
function injectHeadContents(html: string, contents: string) {
|
||||
const headMatch = html.match(/<head\b([^>]*)>/i);
|
||||
if (headMatch) {
|
||||
return html.replace(/<head\b([^>]*)>/i, `<head$1>${contents}`);
|
||||
}
|
||||
|
||||
const htmlMatch = html.match(/<html\b([^>]*)>/i);
|
||||
if (htmlMatch) {
|
||||
return html.replace(/<html\b([^>]*)>/i, `<html$1><head>${contents}</head>`);
|
||||
}
|
||||
|
||||
return `<head>${contents}</head>${html}`;
|
||||
}
|
||||
|
||||
function collectImageEmbedURLs(document: TEditorConfiguration): string[] {
|
||||
// The upstream renderer strips the custom `embed` prop before rendering, so
|
||||
// collect URLs from blocks marked for embedding and re-tag the matching <img>
|
||||
// with a `data-embed` flag after rendering. The backend resolves the src
|
||||
// filename to a media item at compile time.
|
||||
const embedURLs: string[] = [];
|
||||
|
||||
for (const block of Object.values(document)) {
|
||||
if (!block || (block as { type?: string }).type !== 'Image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const props = ((block as { data?: { props?: { url?: string; embed?: boolean } } }).data || {}).props || {};
|
||||
if (props.embed && props.url) {
|
||||
embedURLs.push(props.url);
|
||||
}
|
||||
}
|
||||
|
||||
return embedURLs;
|
||||
}
|
||||
|
||||
function applyImageEmbeds(html: string, embedURLs: string[]): string {
|
||||
let output = html;
|
||||
|
||||
for (const url of embedURLs) {
|
||||
const re = new RegExp(`<img\\b[^>]*?\\ssrc="${escapeRegExp(escapeHtmlAttribute(url))}"[^>]*>`, 'g');
|
||||
output = output.replace(re, (tag) => (
|
||||
/\bdata-embed\b/.test(tag) ? tag : tag.replace(/(\ssrc="[^"]*")/, '$1 data-embed="true"')
|
||||
));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function renderHtmlWithMeta(
|
||||
document: TEditorConfiguration,
|
||||
options: { rootBlockId: string; outlook?: boolean }
|
||||
): string {
|
||||
const embedURLs = collectImageEmbedURLs(document);
|
||||
const html = renderToStaticMarkup(document, options);
|
||||
const rendered = options.outlook ? postProcessForOutlook(html) : html;
|
||||
const output = applyImageEmbeds(rendered, embedURLs);
|
||||
const head = options.outlook ? `${VIEWPORT_META}${MSO_DOCUMENT_SETTINGS}` : VIEWPORT_META;
|
||||
|
||||
return injectHeadContents(output, head);
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(s: string): string {
|
||||
return s.replace(/[&"'<>]/g, (ch) => HTML_ATTRIBUTE_ESCAPES[ch]);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"module": "esnext",
|
||||
"outDir": "dist",
|
||||
"lib": [],
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declarationMap": true,
|
||||
"declaration": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/main.tsx'),
|
||||
name: 'EmailBuilder',
|
||||
formats: ['umd'],
|
||||
fileName: (format) => `email-builder.${format}.js`,
|
||||
},
|
||||
minify: 'terser',
|
||||
cssCodeSplit: true,
|
||||
cssMinify: true,
|
||||
|
||||
// Option to externalize deps.
|
||||
// rollupOptions: {
|
||||
// external: ['react', 'react-dom'],
|
||||
// output: {
|
||||
// globals: {
|
||||
// react: 'React',
|
||||
// 'react-dom': 'ReactDOM',
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user