feat: Inject email-builder instead of loading it as ES module

- Add email-builder source
- Update yarn lock
This commit is contained in:
Vivek R 2024-11-01 00:04:11 +05:30 committed by Kailash Nadh
parent ae98280858
commit e6f08a052c
98 changed files with 10514 additions and 172 deletions

9
.gitignore vendored
View file

@ -2,9 +2,16 @@ frontend/node_modules/
frontend/.cache/
frontend/yarn.lock
frontend/build/
frontend/public/static/email-builder/
frontend/dist/
email-builder/node_modules/
email-builder/.cache/
email-builder/yarn.lock
email-builder/dist/
.vscode/
config.toml
node_modules
listmonk
dist/*
dist/*
uploads/

View file

@ -11,13 +11,23 @@ GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
FRONTEND_YARN_MODULES = frontend/node_modules
FRONTEND_DIST = frontend/dist
FRONTEND_EMAIL_BUILDER_DIST = frontend/public/static/email-builder
FRONTEND_DEPS = \
$(FRONTEND_YARN_MODULES) \
$(FRONTEND_EMAIL_BUILDER_DIST) \
frontend/index.html \
frontend/package.json \
frontend/vite.config.js \
frontend/.eslintrc.js \
$(shell find frontend/fontello frontend/public frontend/src -type f)
EMAIL_BUILDER_YARN_MODULES = email-builder/node_modules
EMAIL_BUILDER_DIST = email-builder/dist
EMAIL_BUILDER_DEPS = \
$(EMAIL_BUILDER_YARN_MODULES) \
email-builder/package.json \
email-builder/tsconfig.json \
email-builder/vite.config.ts \
$(shell find email-builder/src -type f)
BIN := listmonk
STATIC := config.toml.sample \
@ -37,6 +47,10 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
cd frontend && $(YARN) install
touch -c $(FRONTEND_YARN_MODULES)
$(EMAIL_BUILDER_YARN_MODULES): frontend/package.json frontend/yarn.lock
cd email-builder && $(YARN) install
touch -c $(EMAIL_BUILDER_YARN_MODULES)
# Build the backend to ./listmonk.
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
@ -51,9 +65,22 @@ $(FRONTEND_DIST): $(FRONTEND_DEPS)
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build
touch -c $(FRONTEND_DIST)
# Build the JS email-builder dist.
$(EMAIL_BUILDER_DIST): $(EMAIL_BUILDER_DEPS)
export VUE_APP_VERSION="${VERSION}" && cd email-builder && $(YARN) build
touch -c $(EMAIL_BUILDER_DIST)
# Copy the build assets to frontend.
$(FRONTEND_EMAIL_BUILDER_DIST): $(EMAIL_BUILDER_DIST)
mkdir -p $(FRONTEND_EMAIL_BUILDER_DIST)
cp -r $(EMAIL_BUILDER_DIST)/* $(FRONTEND_EMAIL_BUILDER_DIST)
touch -c $(FRONTEND_EMAIL_BUILDER_DIST)
.PHONY: build-frontend
build-frontend: $(FRONTEND_DIST)
build-frontend: $(FRONTEND_EMAIL_BUILDER_DIST) $(FRONTEND_DIST)
.PHONY: build-email-builder
build-email-builder: $(EMAIL_BUILDER_DIST) $(FRONTEND_EMAIL_BUILDER_DIST)
# Run the JS frontend server in dev mode.
.PHONY: run-frontend

21
email-builder/LICENSE Normal file
View file

@ -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.

10
email-builder/README.md Normal file
View file

@ -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.

87
email-builder/index.html Normal file
View file

@ -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 &mdash; 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/listmonk-email-builder.umd.js"></script>
<script>
EmailBuilder.render("root");
</script> -->
</body>
</html>

View file

@ -0,0 +1,50 @@
{
"name": "@usewaypoint/editor-sample",
"version": "0.0.1",
"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": "^5.1.0"
}
}

View file

@ -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>;
}
}

View file

@ -0,0 +1,81 @@
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"
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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,71 @@
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 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 })}
/>
</BaseSidebarPanel>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,94 @@
import React, { useState } from 'react';
import {
VerticalAlignBottomOutlined,
VerticalAlignCenterOutlined,
VerticalAlignTopOutlined,
} from '@mui/icons-material';
import { Stack, ToggleButton } from '@mui/material';
import { ImageProps, ImagePropsSchema } from '@usewaypoint/block-image';
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 = ImagePropsSchema.safeParse(d);
if (res.success) {
setData(res.data);
setErrors(null);
} else {
setErrors(res.error);
}
};
return (
<BaseSidebarPanel title="Image block">
<TextInput
label="Source URL"
defaultValue={data.props?.url ?? ''}
onChange={(v) => {
const url = v.trim().length === 0 ? null : v.trim();
updateData({ ...data, props: { ...data.props, url } });
}}
/>
<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>
<MultiStylePropertyPanel
names={['backgroundColor', 'textAlign', 'padding']}
value={data.style}
onChange={(style) => updateData({ ...data, style })}
/>
</BaseSidebarPanel>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,27 @@
import React, { useState } 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) {
const [value, setValue] = useState(defaultValue);
return (
<FormControlLabel
label={label}
control={
<Switch
checked={value}
onChange={(_, checked: boolean) => {
setValue(checked);
onChange(checked);
}}
/>
}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 />;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
),
}}
/>
);
}

View file

@ -0,0 +1,35 @@
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;
onChange: (v: string) => void;
};
export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, 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}
value={value}
onChange={(ev) => {
const v = ev.target.value;
setValue(v);
onChange(v);
}}
/>
);
}

View file

@ -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>
);
}

View file

@ -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} />
))}
</>
);
}

View file

@ -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} />;
}
}

View file

@ -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 } })} />;
}

View file

@ -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>
);
}

View file

@ -0,0 +1,53 @@
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"
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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,13 @@
import React, { useMemo } from 'react';
import { renderToStaticMarkup } from '@usewaypoint/email-builder';
import { useDocument } from '../../documents/editor/EditorContext';
import HighlightedCodePanel from './helper/HighlightedCodePanel';
export default function HtmlPanel() {
const document = useDocument();
const code = useMemo(() => renderToStaticMarkup(document, { rootBlockId: 'root' }), [document]);
return <HighlightedCodePanel type="html" value={code} />;
}

View file

@ -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>
);
}

View file

@ -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}
</>
);
}

View file

@ -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 };
}

View file

@ -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} />;
}

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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);
}}
/>
);
}

View file

@ -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;
}

View file

@ -0,0 +1,115 @@
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',
position: 'sticky',
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>
</>
);
}

View file

@ -0,0 +1,68 @@
import React from 'react';
import { Stack, useTheme } from '@mui/material';
import { renderToStaticMarkup } from '@usewaypoint/email-builder';
import { TEditorConfiguration } from '../documents/editor/core';
import { useInspectorDrawerOpen, useSamplesDrawerOpen, subscribeDocument, setDocument } from '../documents/editor/EditorContext';
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, renderToStaticMarkup(document, { rootBlockId: 'root' }))
})
}
return (
<>
<InspectorDrawer />
<Stack
sx={{
marginRight: inspectorDrawerOpen ? `${INSPECTOR_DRAWER_WIDTH}px` : 0,
transition: [marginLeftTransition, marginRightTransition].join(', '),
height: props.height ? props.height : 'auto',
}}
>
<TemplatePanel />
</Stack>
</>
);
}

View file

@ -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)} />,
]}
/>
);
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>;

View file

@ -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>
);
}

View file

@ -0,0 +1,36 @@
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(),
});
export default EmailLayoutPropsSchema;
export type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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: 'Hello friend' },
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://www.usewaypoint.com',
},
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
},
}),
},
{
label: 'Image',
icon: <ImageOutlined />,
block: () => ({
type: 'Image',
data: {
props: {
url: 'https://assets.usewaypoint.com/sample-image.jpg',
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://ui-avatars.com/api/?size=128',
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: () => ({}) },
];

View file

@ -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} />
</>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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;
};

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -0,0 +1,171 @@
import React from 'react';
import { ArrowDownwardOutlined, ArrowUpwardOutlined, 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 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="Delete" placement="left-start">
<IconButton onClick={handleDeleteClick} sx={{ color: 'text.primary' }}>
<DeleteOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Paper>
);
}

View file

@ -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;

View file

@ -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(),
});
}

View file

@ -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>
);
}

View file

@ -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 });
}

View file

@ -0,0 +1,127 @@
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';
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: ImagePropsSchema,
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

View file

@ -0,0 +1,45 @@
import EMPTY_EMAIL_MESSAGE from './sample/empty-email-message';
import ONE_TIME_PASSCODE from './sample/one-time-passcode';
import ORDER_ECOMMERCE from './sample/order-ecommerce';
import POST_METRICS_REPORT from './sample/post-metrics-report';
import RESERVATION_REMINDER from './sample/reservation-reminder';
import RESET_PASSWORD from './sample/reset-password';
import RESPOND_TO_MESSAGE from './sample/respond-to-message';
import SUBSCRIPTION_RECEIPT from './sample/subscription-receipt';
import WELCOME from './sample/welcome';
export default function getConfiguration(template: string) {
if (template.startsWith('#sample/')) {
const sampleName = template.replace('#sample/', '');
switch (sampleName) {
case 'welcome':
return WELCOME;
case 'one-time-password':
return ONE_TIME_PASSCODE;
case 'order-ecomerce':
return ORDER_ECOMMERCE;
case 'post-metrics-report':
return POST_METRICS_REPORT;
case 'reservation-reminder':
return RESERVATION_REMINDER;
case 'reset-password':
return RESET_PASSWORD;
case 'respond-to-message':
return RESPOND_TO_MESSAGE;
case 'subscription-receipt':
return SUBSCRIPTION_RECEIPT;
}
}
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;
}

View file

@ -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;

View file

@ -0,0 +1,130 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const ONE_TIME_PASSCODE: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#000000',
canvasColor: '#000000',
textColor: '#FFFFFF',
fontFamily: 'BOOK_SERIF',
childrenIds: [
'block_ChPX66qUhF46uynDE8AY11',
'block_CkNrtQgkqPt2YWLv1hr5eJ',
'block_BFLBa3q5y8kax9KngyXP65',
'block_4T7sDFb4rqbSyWjLGJKmov',
'block_Rvc8ZfTjfhXjpphHquJKvP',
],
},
},
block_ChPX66qUhF46uynDE8AY11: {
type: 'Image',
data: {
style: {
backgroundColor: null,
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
textAlign: 'center',
},
props: {
height: 24,
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_jc7ZfPvdHJ6rtH1W/&.png',
contentAlignment: 'middle',
},
},
},
block_CkNrtQgkqPt2YWLv1hr5eJ: {
type: 'Text',
data: {
style: {
color: '#ffffff',
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'center',
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'Here is your one-time passcode:',
},
},
},
block_BFLBa3q5y8kax9KngyXP65: {
type: 'Heading',
data: {
style: {
color: null,
backgroundColor: null,
fontFamily: 'MONOSPACE',
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
level: 'h1',
text: '0123456',
},
},
},
block_4T7sDFb4rqbSyWjLGJKmov: {
type: 'Text',
data: {
style: {
color: '#868686',
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'center',
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'This code will expire in 30 minutes.',
},
},
},
block_Rvc8ZfTjfhXjpphHquJKvP: {
type: 'Text',
data: {
style: {
color: '#868686',
backgroundColor: null,
fontSize: 14,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'center',
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'Problems? Just reply to this email.',
},
},
},
};
export default ONE_TIME_PASSCODE;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,394 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const POST_METRICS_REPORT: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#EEEEEE',
canvasColor: '#FFFFFF',
textColor: '#242424',
fontFamily: 'MODERN_SANS',
childrenIds: [
'block_6B5Ke1N2KdM4STQjw7eEHT',
'block_VE1bKDbSqiYD9VtmmaYX7w',
'block_QQqjnauXAixe2LnJXVmHwQ',
'block_9yEYNZmmmFauyuSKi9iJA9',
'block_AC6eRbFVSbXHVCg2zutkLu',
'block_CYXkzjxrj6e4Sb74Kt8quM',
'block_AUAxG2BgwA6XC8rF5xAaaP',
'block_C1YvcFvMvzB1Fhxn3uV8zV',
'block_FsiiokCgr9bZitHn7sx7TB',
'block_DomD4MLJ58VcMo49vmeTH8',
],
},
},
block_6sP1Pi9AimAoti2ZPdNXUf: {
type: 'Image',
data: {
style: {
backgroundColor: null,
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
textAlign: 'left',
},
props: {
height: 16,
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_n3eLjsf37dcjFaj5/Narrative.png',
contentAlignment: 'middle',
},
},
},
block_9G37m6eNPw2MpUj6SYGoq1: {
type: 'Container',
data: {
style: {
backgroundColor: null,
borderColor: null,
borderRadius: null,
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
childrenIds: ['block_6sP1Pi9AimAoti2ZPdNXUf'],
},
},
},
block_A8GU16mV1RdP85jaszN7oj: {
type: 'Avatar',
data: {
style: {
textAlign: 'right',
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
size: 32,
shape: 'circle',
imageUrl: 'https://ui-avatars.com/api/?name=John+Doe',
alt: 'Jordan',
},
},
},
block_4WmdbYU15yfdpYcYjsVDBA: {
type: 'Container',
data: {
style: {
backgroundColor: null,
borderColor: null,
borderRadius: null,
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
childrenIds: ['block_A8GU16mV1RdP85jaszN7oj'],
},
},
},
block_JQAdLSAtvmfsRih13srJ8m: {
type: 'Container',
data: {
style: {
backgroundColor: null,
borderColor: null,
borderRadius: null,
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
childrenIds: [],
},
},
},
block_6B5Ke1N2KdM4STQjw7eEHT: {
type: 'ColumnsContainer',
data: {
style: {
backgroundColor: null,
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
columnsCount: 2,
columns: [
{
childrenIds: ['block_9G37m6eNPw2MpUj6SYGoq1'],
},
{
childrenIds: ['block_4WmdbYU15yfdpYcYjsVDBA'],
},
{
childrenIds: ['block_JQAdLSAtvmfsRih13srJ8m'],
},
],
},
},
},
block_VE1bKDbSqiYD9VtmmaYX7w: {
type: 'Heading',
data: {
style: {
color: null,
backgroundColor: null,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 24,
bottom: 0,
left: 24,
right: 24,
},
},
props: {
level: 'h3',
text: 'Last week, your posts received',
},
},
},
block_QQqjnauXAixe2LnJXVmHwQ: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: null,
fontSize: 48,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 16,
bottom: 0,
left: 24,
right: 24,
},
},
props: {
text: '1,511',
},
},
},
block_9yEYNZmmmFauyuSKi9iJA9: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: null,
fontSize: 14,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 0,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'Post impressions',
},
},
},
block_AC6eRbFVSbXHVCg2zutkLu: {
type: 'Button',
data: {
style: {
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 16,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
buttonBackgroundColor: '#24AF7F',
buttonStyle: 'rounded',
buttonTextColor: '#FFFFFF',
fullWidth: false,
size: 'medium',
text: 'View your analytics →',
url: 'https://example.usewaypoint.com/post/1234/analytics',
},
},
},
block_CYXkzjxrj6e4Sb74Kt8quM: {
type: 'Heading',
data: {
style: {
color: null,
backgroundColor: null,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 24,
bottom: 8,
left: 24,
right: 24,
},
},
props: {
level: 'h3',
text: 'Top performing post last week',
},
},
},
block_FpDmSnPwiVzBXUvTc4yWFh: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'left',
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
text: 'So excited to now have drag and drop on Waypoint. This builds on top of our new Navigator feature that we shipped earlier this week 🚢.',
},
},
},
block_LjuDF6uu4qWL3Ju3ng63ky: {
type: 'Container',
data: {
style: {
backgroundColor: '#FAFAFA',
borderColor: null,
borderRadius: 8,
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
childrenIds: ['block_FpDmSnPwiVzBXUvTc4yWFh'],
},
},
},
block_AUAxG2BgwA6XC8rF5xAaaP: {
type: 'Container',
data: {
style: {
backgroundColor: null,
borderColor: null,
borderRadius: null,
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
childrenIds: ['block_LjuDF6uu4qWL3Ju3ng63ky'],
},
},
},
block_C1YvcFvMvzB1Fhxn3uV8zV: {
type: 'Button',
data: {
style: {
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'center',
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
buttonBackgroundColor: '#EEEEEE',
buttonStyle: 'rounded',
buttonTextColor: '#474849',
fullWidth: false,
size: 'medium',
text: 'Show more',
url: 'https://example.usewaypoint.com/jordanisip/posts',
},
},
},
block_FsiiokCgr9bZitHn7sx7TB: {
type: 'Divider',
data: {
style: {
backgroundColor: null,
padding: {
top: 40,
bottom: 0,
left: 0,
right: 0,
},
},
props: {
lineHeight: 1,
lineColor: '#EEEEEE',
},
},
},
block_DomD4MLJ58VcMo49vmeTH8: {
type: 'Text',
data: {
style: {
color: '#474849',
backgroundColor: null,
fontSize: 12,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'center',
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
text: 'Questions? Just reply to this email.',
},
},
},
};
export default POST_METRICS_REPORT;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,156 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const RESET_PASSWORD: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#F2F5F7',
canvasColor: '#FFFFFF',
textColor: '#242424',
fontFamily: 'MODERN_SANS',
childrenIds: [
'block_3gpSGmkgL4nWSBQjWCjK2z',
'block_BjpQ7DGTtvaEuYRMd7VE7w',
'block_xyg4GWmgGbJJEDRQc76bC',
'block_76VptLCZ47t3EkAarUufEJ',
'block_Gtk3kDYwsJqEmQf2XGWPRc',
'block_LACDCzUS2bsvEbmnq1KHuW',
],
},
},
block_3gpSGmkgL4nWSBQjWCjK2z: {
type: 'Image',
data: {
style: {
padding: {
top: 24,
bottom: 8,
right: 24,
left: 24,
},
backgroundColor: null,
textAlign: 'left',
},
props: {
height: 24,
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_Xh1R23U9ziyct9nd/codoc.png',
alt: '',
linkHref: null,
contentAlignment: 'middle',
},
},
},
block_BjpQ7DGTtvaEuYRMd7VE7w: {
type: 'Heading',
data: {
style: {
color: null,
backgroundColor: null,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'left',
padding: {
top: 32,
bottom: 0,
left: 24,
right: 24,
},
},
props: {
level: 'h3',
text: 'Reset your password?',
},
},
},
block_xyg4GWmgGbJJEDRQc76bC: {
type: 'Text',
data: {
style: {
color: '#474849',
backgroundColor: null,
fontSize: 14,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'left',
padding: {
top: 8,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: `If you didn't request a reset, don't worry. You can safely ignore this email.`,
},
},
},
block_76VptLCZ47t3EkAarUufEJ: {
type: 'Button',
data: {
style: {
backgroundColor: null,
fontSize: 14,
fontFamily: null,
fontWeight: 'bold',
textAlign: 'left',
padding: {
top: 12,
bottom: 32,
right: 24,
left: 24,
},
},
props: {
buttonBackgroundColor: '#0068FF',
buttonStyle: 'rectangle',
buttonTextColor: '#FFFFFF',
fullWidth: false,
size: 'medium',
text: 'Change my password',
url: 'https://example.usewaypoint.com/reset_password?token=02938409809w8r09a83wr098aw0',
},
},
},
block_Gtk3kDYwsJqEmQf2XGWPRc: {
type: 'Divider',
data: {
style: {
backgroundColor: null,
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
lineHeight: 1,
lineColor: '#EEEEEE',
},
},
},
block_LACDCzUS2bsvEbmnq1KHuW: {
type: 'Text',
data: {
style: {
color: '#474849',
backgroundColor: null,
fontSize: 12,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'left',
padding: {
top: 4,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
text: 'Need help? Just reply to this email to contact support.',
},
},
},
};
export default RESET_PASSWORD;

View file

@ -0,0 +1,175 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const RESPOND_TO_MESSAGE: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#F0ECE5',
canvasColor: '#F0ECE5',
textColor: '#030303',
fontFamily: 'MODERN_SERIF',
childrenIds: [
'block_HjX7RN8eDEz7BLBHSQCNgU',
'block_Jf65r5cUAnEzBfxnHKGa5S',
'block_WmPDNHDpyHkygqjHuqF7St',
'block_4VCKUvRMo7EbuMdN1VsdRw',
'block_4siwziT4BkewmN55PpXvEu',
'block_S9Rg9F3bGcviRyfMpoU5e4',
],
},
},
block_HjX7RN8eDEz7BLBHSQCNgU: {
type: 'Image',
data: {
style: {
padding: {
top: 8,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
height: 32,
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_hW6RusynHUNTKoLm/boop.png',
contentAlignment: 'middle',
},
},
},
block_Jf65r5cUAnEzBfxnHKGa5S: {
type: 'Heading',
data: {
style: {
color: null,
backgroundColor: null,
fontFamily: null,
fontWeight: null,
textAlign: null,
padding: {
top: 16,
bottom: 0,
left: 24,
right: 24,
},
},
props: {
level: 'h2',
text: `Respond to Anna's Inquiry`,
},
},
},
block_WmPDNHDpyHkygqjHuqF7St: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: null,
textAlign: null,
padding: {
top: 8,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'Dog boarding for Aug 1 - Aug 29.',
},
},
},
block_95nkowWyi4p2VBiA46Eizs: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: '#faf9f9',
fontSize: 21,
fontFamily: null,
fontWeight: null,
textAlign: null,
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
text: 'Any chance you can watch Emma again next month?',
},
},
},
block_4VCKUvRMo7EbuMdN1VsdRw: {
type: 'Container',
data: {
style: {
backgroundColor: null,
borderColor: null,
borderRadius: null,
padding: {
top: 16,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
childrenIds: ['block_95nkowWyi4p2VBiA46Eizs'],
},
},
},
block_4siwziT4BkewmN55PpXvEu: {
type: 'Button',
data: {
style: {
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: null,
textAlign: 'left',
padding: {
top: 24,
bottom: 24,
left: 24,
right: 24,
},
},
props: {
buttonBackgroundColor: '#BE4F46',
buttonTextColor: '#FFFFFF',
size: 'large',
buttonStyle: 'pill',
text: 'Respond',
url: 'http://example.usewaypoint.com/request/2334234',
fullWidth: false,
},
},
},
block_S9Rg9F3bGcviRyfMpoU5e4: {
type: 'Text',
data: {
style: {
color: null,
backgroundColor: null,
fontSize: 16,
fontFamily: null,
fontWeight: 'normal',
textAlign: 'left',
padding: {
top: 0,
bottom: 16,
left: 24,
right: 24,
},
},
props: {
text: 'You need 2 more walks to become a Super Walker on Boop!',
},
},
},
};
export default RESPOND_TO_MESSAGE;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
import { TEditorConfiguration } from '../../documents/editor/core';
const WELCOME: TEditorConfiguration = {
root: {
type: 'EmailLayout',
data: {
backdropColor: '#F2F5F7',
canvasColor: '#FFFFFF',
textColor: '#242424',
fontFamily: 'MODERN_SANS',
childrenIds: [
'block-1709571212684',
'block-1709571228545',
'block-1709571234315',
'block-1709571247550',
'block-1709571258507',
'block-1709571281151',
'block-1709571302968',
'block-1709571282795',
],
},
},
'block-1709571212684': {
type: 'Image',
data: {
style: {
padding: {
top: 24,
bottom: 24,
right: 24,
left: 24,
},
},
props: {
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_JTNBBPGrNs2Ph4JL/marketbase.png',
alt: 'Marketbase',
linkHref: 'https://marketbase.app',
contentAlignment: 'middle',
},
},
},
'block-1709571228545': {
type: 'Text',
data: {
style: {
fontWeight: 'normal',
padding: {
top: 0,
bottom: 16,
right: 24,
left: 24,
},
},
props: {
text: 'Hi Anna 👋,',
},
},
},
'block-1709571234315': {
type: 'Text',
data: {
style: {
fontWeight: 'normal',
padding: {
top: 0,
bottom: 16,
right: 24,
left: 24,
},
},
props: {
text: 'Welcome to Marketbase! Marketbase is how teams within fast growing marketplaces effortlessly monitor conversations to prevent disintermediation, identify problematic users, and increase trust & safety within their community.',
},
},
},
'block-1709571247550': {
type: 'Text',
data: {
style: {
fontWeight: 'normal',
padding: {
top: 0,
bottom: 16,
right: 24,
left: 24,
},
},
props: {
text: 'Best of all, you can connect your existing messaging services in minutes:',
},
},
},
'block-1709571258507': {
type: 'Image',
data: {
style: {
padding: {
top: 16,
bottom: 16,
right: 24,
left: 24,
},
},
props: {
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_oWB821TUkDXvr2f4/Screenshot%202023-11-22%20at%2011.51.30%20AM.png',
alt: 'Video thumbnail',
linkHref: 'https://capture.dropbox.com/NBQEmoCKKP9RGBWr',
contentAlignment: 'middle',
},
},
},
'block-1709571281151': {
type: 'Text',
data: {
style: {
fontWeight: 'normal',
padding: {
top: 16,
bottom: 16,
right: 24,
left: 24,
},
},
props: {
text: 'If you ever need help, just reply to this email and one of us will get back to you shortly. Were here to help.',
},
},
},
'block-1709571282795': {
type: 'Image',
data: {
style: {
padding: {
top: 16,
bottom: 40,
right: 24,
left: 24,
},
},
props: {
url: 'https://d1iiu589g39o6c.cloudfront.net/live/platforms/platform_A9wwKSL6EV6orh6f/images/wptemplateimage_cAK8FpmBEGoSRNi3/Screenshot%202023-11-22%20at%2011.48.53%20AM.png',
alt: 'Illustration',
linkHref: null,
contentAlignment: 'middle',
},
},
},
'block-1709571302968': {
type: 'Button',
data: {
style: {
fontSize: 14,
padding: {
top: 16,
bottom: 24,
right: 24,
left: 24,
},
},
props: {
buttonBackgroundColor: '#0079cc',
buttonStyle: 'rectangle',
text: 'Open dashboard',
url: 'https://www.usewaypoint.com',
},
},
},
};
export default WELCOME;

View file

@ -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 };

520
email-builder/src/theme.ts Normal file
View file

@ -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 =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
const MONOSPACE_FONT_FAMILY =
'ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace';
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;

1
email-builder/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -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"]
}

View file

@ -0,0 +1,31 @@
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',
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',
// },
// },
// }
}
});

View file

@ -5,8 +5,6 @@
</template>
<script>
import { render, isRendered, setDocument, DEFAULT_SOURCE } from '../email-builder';
export default {
props: {
source: { type: String, default: '' },
@ -20,7 +18,7 @@ export default {
source = JSON.parse(this.$props.source);
}
render('visual-editor', {
window.EmailBuilder.render('visual-editor', {
data: source,
onChange: (data, body) => {
this.$emit('change', { source: JSON.stringify(data), body });
@ -28,19 +26,42 @@ export default {
height: this.height,
});
},
loadScript() {
return new Promise((resolve, reject) => {
if (window.EmailBuilder) {
resolve();
return;
}
const script = document.createElement('script');
script.id = 'email-builder-script';
script.src = '/admin/static/email-builder/email-builder.umd.js';
script.onload = () => {
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
},
},
mounted() {
this.initEditor();
this.loadScript().then(() => {
this.initEditor();
}).catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to load email-builder script:', error);
});
},
watch: {
source(val) {
if (isRendered('visual-editor')) {
if (window.EmailBuilder?.isRendered('visual-editor')) {
if (val) {
setDocument(JSON.parse(val));
window.EmailBuilder.setDocument(JSON.parse(val));
} else {
setDocument(DEFAULT_SOURCE);
window.EmailBuilder.setDocument(window.EmailBuilder.DEFAULT_SOURCE);
}
} else {
this.initEditor();

View file

@ -327,7 +327,12 @@ export default Vue.extend({
lists: [],
tags: [],
sendAt: null,
content: { contentType: 'richtext', body: '', bodySource: null, templateId: null },
content: {
contentType: 'richtext',
body: '',
bodySource: null,
templateId: null,
},
altbody: null,
media: [],
@ -447,7 +452,12 @@ export default Vue.extend({
archiveMetaStr: data.archiveMeta ? JSON.stringify(data.archiveMeta, null, 4) : '{}',
// The structure that is populated by editor input event.
content: { contentType: data.contentType, body: data.body, bodySource: data.bodySource, templateId: data.templateId },
content: {
contentType: data.contentType,
body: data.body,
bodySource: data.bodySource,
templateId: data.templateId,
},
};
this.isAttachFieldVisible = this.form.media.length > 0;

184
frontend/yarn.lock vendored
View file

@ -230,18 +230,6 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
dependencies:
string-width "^5.1.2"
string-width-cjs "npm:string-width@^4.2.0"
strip-ansi "^7.0.1"
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
wrap-ansi "^8.1.0"
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
"@kurkle/color@^0.3.0":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
@ -278,16 +266,6 @@
resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323"
integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==
"@pkgjs/parseargs@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@rollup/rollup-android-arm-eabi@4.22.4":
version "4.22.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz#8b613b9725e8f9479d142970b106b6ae878610d5"
integrity sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==
"@parcel/watcher-android-arm64@2.5.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz#e32d3dda6647791ee930556aee206fcd5ea0fb7a"
@ -616,11 +594,6 @@ ansi-regex@^5.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-regex@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654"
integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
@ -628,19 +601,6 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
ansi-styles@^6.1.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arch@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
@ -864,7 +824,7 @@ brace-expansion@^2.0.1:
dependencies:
balanced-match "^1.0.0"
braces@~3.0.2:
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
@ -1273,11 +1233,6 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
ecc-jsbn@~0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@ -1983,18 +1938,6 @@ glob@^10.3.3:
package-json-from-dist "^1.0.0"
path-scurry "^1.11.1"
glob@^10.3.3:
version "10.4.5"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
dependencies:
foreground-child "^3.1.0"
jackspeak "^3.1.2"
minimatch "^9.0.4"
minipass "^7.1.2"
package-json-from-dist "^1.0.0"
path-scurry "^1.11.1"
glob@^7.1.3:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
@ -2139,6 +2082,11 @@ indent-string@^4.0.0:
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
indent.js@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/indent.js/-/indent.js-0.3.5.tgz#e2fefa04043b7be69c33635dc9e0cfb9cc7c4d7f"
integrity sha512-wiTA5fEz0kc8tHzY6CSujl/k62WVNvTxAZzmPe5V7MYxRCeGGibPCIYWYBzPp/bcJh3CXUO/8qrTrO/x9s1i2Q==
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -2162,15 +2110,6 @@ ini@^1.3.4:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
internal-slot@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986"
integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==
dependencies:
get-intrinsic "^1.2.0"
has "^1.0.3"
side-channel "^1.0.4"
internal-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
@ -2448,31 +2387,6 @@ js-cookie@^3.0.5:
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
jackspeak@^3.1.2:
version "3.4.3"
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a"
integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==
dependencies:
"@isaacs/cliui" "^8.0.2"
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
js-beautify@^1.15.1:
version "1.15.1"
resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64"
integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==
dependencies:
config-chain "^1.1.13"
editorconfig "^1.0.4"
glob "^10.3.3"
js-cookie "^3.0.5"
nopt "^7.2.0"
js-cookie@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -2649,12 +2563,10 @@ lru-cache@^10.2.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
dependencies:
yallist "^4.0.0"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
merge-stream@^2.0.0:
version "2.0.0"
@ -2717,11 +2629,6 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.1, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@ -2737,6 +2644,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
node-addon-api@^7.0.0:
version "7.1.1"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
nopt@^7.2.0:
version "7.2.1"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
@ -2744,11 +2656,6 @@ nopt@^7.2.0:
dependencies:
abbrev "^2.0.0"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
npm-run-path@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@ -3378,15 +3285,6 @@ sshpk@^1.18.0:
safer-buffer "^2.0.2"
tweetnacl "~0.14.0"
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@ -3396,19 +3294,10 @@ string-width@^4.1.0, string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
dependencies:
eastasianwidth "^0.2.0"
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
string.prototype.matchall@^4.0.8:
version "4.0.10"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100"
integrity sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==
string.prototype.includes@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92"
integrity sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==
dependencies:
call-bind "^1.0.7"
define-properties "^1.2.1"
@ -3473,13 +3362,6 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@ -3487,13 +3369,6 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
dependencies:
ansi-regex "^6.0.1"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@ -3850,14 +3725,10 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wrap-ansi@^6.2.0:
version "6.2.0"
@ -3877,15 +3748,6 @@ wrap-ansi@^7.0.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
dependencies:
ansi-styles "^6.1.0"
string-width "^5.0.1"
strip-ansi "^7.0.1"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"